疗休养优化1.2

This commit is contained in:
2026-06-15 17:43:33 +08:00
parent 6b892bb026
commit 850f4742a2
33 changed files with 1303 additions and 180 deletions
@@ -12,9 +12,11 @@ import com.budwk.app.sys.models.Sys_home_template;
import com.budwk.app.sys.models.Sys_menu;
import com.budwk.app.sys.services.SysMenuService;
import com.budwk.app.sys.services.SysUserService;
import com.budwk.app.web.commons.base.Globals;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import io.swagger.annotations.ApiOperation;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
@@ -40,6 +42,7 @@ import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -278,6 +281,40 @@ public class SysHomeController {
return Result.success(list);
}
/**
* 查询PC首页疗休养报名浮动入口。
* 只返回本年度启用、开启首页入口且已上传图片的疗休养配置,避免首页出现空图片入口。
*
* @return 当前年度可展示的疗休养报名入口
*/
@At
@SaCheckLogin
@ApiOperation("PC首页疗休养报名浮动入口")
@Ok("json")
public Result listTourHomeSignupEntry() {
int currentYear = LocalDate.now().getYear();
Cnd cnd = Cnd.where(TourSetting::getDelFlag, "=", false)
.and(TourSetting::getEnabled, "=", true)
.and(TourSetting::getYear, "=", currentYear)
.and(TourSetting::getHomeSignupEntryEnabled, "=", true);
cnd.asc(TourSetting::getSortNo);
cnd.desc(TourSetting::getUpdatedAt);
cnd.desc(TourSetting::getCreatedAt);
List<TourSetting> settings = dao.query(TourSetting.class, cnd);
List<NutMap> rows = new ArrayList<>();
for (TourSetting setting : settings) {
// 优先使用配置上传图片,未上传时使用系统默认疗休养图片,避免首页浮动入口空图。
String imageUrl = StrUtil.blankToDefault(setting.getHomeSignupEntryImage(), Globals.AppDomain + "/assets/platform/images/tour/疗休养图片.jpeg");
rows.add(NutMap.NEW()
.addv("id", setting.getId())
.addv("year", setting.getYear())
.addv("title", setting.getYear() + "暑期教职工疗休养")
.addv("imageUrl", imageUrl)
.addv("href", "/platform/tour/signup"));
}
return Result.success(rows);
}
@At
@SaCheckLogin
@@ -48,12 +48,12 @@ public class TourBranchUserAssignmentController {
}
/**
* 分页查询候选人员,候选范围由疗休养配置可参加人员范围和当前登录人所在分工会共同决定。
* 分页查询候选人员,候选范围由疗休养配置可参加人员范围和当前登录人所在分工会共同决定;userIds 仅作为人员选择器筛选条件
*/
@At
@SaCheckPermission("tour.branchUserAssignment")
public Result candidatePageData(PageForm pageForm, String settingId, String keyword) {
return Result.success(tourUserAssignmentService.branchCandidatePage(pageForm, settingId, keyword));
public Result candidatePageData(PageForm pageForm, String settingId, String keyword, @Param("userIds") String userIds) {
return Result.success(tourUserAssignmentService.branchCandidatePage(pageForm, settingId, keyword, parseUserIds(userIds)));
}
/**
@@ -71,7 +71,7 @@ public class TourBranchUserAssignmentController {
@At
@SaCheckPermission("tour.branchUserAssignment")
public Result matterOptions(String settingId) {
return Result.success(tourUserAssignmentService.listMatterOptions(settingId));
return Result.success(tourUserAssignmentService.listSignupOpenMatterOptions(settingId));
}
/**
@@ -132,7 +132,7 @@ public class TourLedgerController {
dr.id AS directRelativeId,
COALESCE(NULLIF(l.lineName, ''), t.lineName) AS currentLineName,
COALESCE(NULLIF(vu.sex, ''), t.gender, '') AS currentGender,
vu.age AS age,
COALESCE(t.age, vu.age) AS age,
COALESCE(NULLIF(t.idCard, ''), NULLIF(vu.idCard, ''), '') AS currentIdCard,
COALESCE(NULLIF(t.mobile, ''), NULLIF(vu.mobile, ''), '') AS currentMobile,
IFNULL(f.familyCount, 0) AS familyCount,
@@ -393,7 +393,7 @@ public class TourLedgerController {
t.jobNo,
t.userName,
COALESCE(NULLIF(vu.sex, ''), t.gender, '') AS gender,
vu.age AS age,
COALESCE(t.age, vu.age) AS age,
COALESCE(NULLIF(t.idCard, ''), NULLIF(vu.idCard, ''), '') AS idCard,
COALESCE(NULLIF(t.mobile, ''), NULLIF(vu.mobile, ''), '') AS mobile,
t.unitName,
@@ -37,8 +37,6 @@ import java.util.stream.Collectors;
@At("/platform/tour/matter")
public class TourMatterController {
private static final String MOBILE_PATTERN = "^1[3-9]\\d{9}$";
@Inject
private TourMatterService tourMatterService;
@@ -353,8 +351,9 @@ public class TourMatterController {
if (StrUtil.isBlank(matter.getContactName())) {
return Result.error("联系人不能为空");
}
if (StrUtil.isBlank(matter.getContactPhone()) || !matter.getContactPhone().matches(MOBILE_PATTERN)) {
return Result.error("联系方式格式不正确");
// 线路联系人电话允许填写座机、分机或其他联系说明,后端只保留必填校验。
if (StrUtil.isBlank(matter.getContactPhone())) {
return Result.error("联系方式不能为空");
}
if (matter.getMinGroupPeople() == null || matter.getMinGroupPeople() <= 0) {
return Result.error("最少成团人数必须大于0");
@@ -154,6 +154,7 @@ public class TourMySignupController {
IFNULL(f.familyCount, 0) AS familyCount,
a.id AS assignmentId,
IFNULL(a.cancelled, 0) AS assignmentCancelled,
m.signupEndTime AS actionSignupEndTime,
m.travelStartTime AS actionTravelStartTime,
COALESCE(m.travelEndTime, tp.travelEndTime) AS travelEndTime,
CASE
@@ -309,6 +310,7 @@ public class TourMySignupController {
m.matterName,
m.unionId AS matterUnionId,
m.defaultBoardingPlace,
m.signupEndTime,
m.travelStartTime,
m.travelEndTime,
CASE
@@ -345,6 +347,8 @@ public class TourMySignupController {
if (isTravelEnded(matter.getString("travelEndTime"))) {
return Result.error("线路出行已结束,不能修改");
}
// 修改弹窗内的取消按钮也由后端返回的报名截止状态控制,避免报名结束后仍可操作。
matter.put("canCancelSignup", isWithinSignupCancelTime(matter.getString("signupEndTime", ""), LocalDateTime.now()));
Cnd familyCnd = Cnd.where(TourLedgerFamily::getDelFlag, "=", false)
.and(TourLedgerFamily::getLedgerId, "=", ledger.getId());
familyCnd.asc(TourLedgerFamily::getCreatedAt);
@@ -549,6 +553,10 @@ public class TourMySignupController {
if (ledger == null) {
return Result.error("报名记录不存在");
}
TourMatter matter = StrUtil.isBlank(ledger.getMatterId()) ? null : tourMatterService.fetch(ledger.getMatterId());
if (!isWithinSignupCancelTime(matter)) {
return Result.error("报名已结束,不能取消报名");
}
tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId()));
tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId()));
tourLedgerService.clear(Cnd.where(TourLedger::getId, "=", ledger.getId()));
@@ -676,22 +684,51 @@ public class TourMySignupController {
item.put("cancelDeadlineDays", cancelDeadlineDays);
item.put("canLeaveTour", StrUtil.isNotBlank(assignmentId)
&& !assignmentCancelled
&& isWithinLeaveTimeRange(item.getString("actionTravelStartTime", ""), cancelDeadlineDays, now));
&& isWithinLeaveTimeRange(item.getString("actionSignupEndTime", ""), item.getString("actionTravelStartTime", ""), cancelDeadlineDays, now));
item.put("canCancelSignup", isWithinSignupCancelTime(item.getString("actionSignupEndTime", ""), now));
}
}
/**
* 判断当前时间是否处于“出行开始前N天”到“出行开始前”的可退出范围内
* 判断当前报名是否处于允许取消的报名截止时间内,报名结束后不再显示或执行取消报名
*/
private boolean isWithinLeaveTimeRange(String travelStartTime, int cancelDeadlineDays, LocalDateTime now) {
private boolean isWithinSignupCancelTime(TourMatter matter) {
if (matter == null) {
return false;
}
return isWithinSignupCancelTime(matter.getSignupEndTime(), LocalDateTime.now());
}
/**
* 报名结束时间为空时不允许取消;当前时间小于等于报名结束时间才允许取消。
*/
private boolean isWithinSignupCancelTime(String signupEndTime, LocalDateTime now) {
try {
String normalized = normalizeDateTime(travelStartTime, false);
if (StrUtil.isBlank(normalized)) {
String normalizedSignupEnd = normalizeDateTime(signupEndTime, true);
if (StrUtil.isBlank(normalizedSignupEnd)) {
return false;
}
LocalDateTime startTime = LocalDateTime.parse(normalized, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
LocalDateTime endTime = LocalDateTime.parse(normalizedSignupEnd, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return !now.isAfter(endTime);
} catch (Exception e) {
return false;
}
}
/**
* 判断当前时间是否处于“报名截止时间 < 当前时间 < 出行开始前N天”的可退出范围内。
*/
private boolean isWithinLeaveTimeRange(String signupEndTime, String travelStartTime, int cancelDeadlineDays, LocalDateTime now) {
try {
String normalizedSignupEnd = normalizeDateTime(signupEndTime, true);
String normalizedTravelStart = normalizeDateTime(travelStartTime, false);
if (StrUtil.isBlank(normalizedSignupEnd) || StrUtil.isBlank(normalizedTravelStart)) {
return false;
}
LocalDateTime signupEnd = LocalDateTime.parse(normalizedSignupEnd, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
LocalDateTime startTime = LocalDateTime.parse(normalizedTravelStart, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
LocalDateTime deadline = startTime.minusDays(cancelDeadlineDays);
return !now.isBefore(deadline) && now.isBefore(startTime);
return now.isAfter(signupEnd) && now.isBefore(deadline);
} catch (Exception e) {
return false;
}
@@ -50,12 +50,12 @@ public class TourSchoolUserAssignmentController {
}
/**
* 分页查询候选人员,候选范围由疗休养配置的可参加人员范围决定。
* 分页查询候选人员,候选范围由疗休养配置的可参加人员范围决定;userIds 仅作为人员选择器筛选条件
*/
@At
@SaCheckPermission("tour.schoolUserAssignment")
public Result candidatePageData(PageForm pageForm, String settingId, String unionId, String keyword) {
return Result.success(tourUserAssignmentService.schoolCandidatePage(pageForm, settingId, unionId, keyword));
public Result candidatePageData(PageForm pageForm, String settingId, String unionId, String keyword, @Param("userIds") String userIds) {
return Result.success(tourUserAssignmentService.schoolCandidatePage(pageForm, settingId, unionId, keyword, parseUserIds(userIds)));
}
/**
@@ -155,6 +155,14 @@ public class TourSettingController {
if (tourSetting.getFillBedInfo() == null) {
tourSetting.setFillBedInfo(true);
}
if (tourSetting.getHomeSignupEntryEnabled() == null) {
tourSetting.setHomeSignupEntryEnabled(false);
}
if (!Boolean.TRUE.equals(tourSetting.getHomeSignupEntryEnabled())) {
tourSetting.setHomeSignupEntryImage("");
} else {
tourSetting.setHomeSignupEntryImage(normalizeSingleHomeSignupEntryImage(tourSetting.getHomeSignupEntryImage()));
}
// 报名资格校验方式默认按人员分配表校验后续报名校验切换会读取该配置
if (StrUtil.isBlank(tourSetting.getSignupEligibilityMode())) {
tourSetting.setSignupEligibilityMode(SIGNUP_ELIGIBILITY_MODE_ASSIGNED_USER);
@@ -226,6 +234,15 @@ public class TourSettingController {
.collect(Collectors.toList());
}
private String normalizeSingleHomeSignupEntryImage(String image) {
// 首页报名入口图片仅允许保存一张兼容逗号分隔的历史多图路径时只保留第一张
if (StrUtil.isBlank(image)) {
return "";
}
List<String> images = StrUtil.splitTrim(image, ",");
return Lang.isEmpty(images) ? "" : images.get(0);
}
private void saveLots(TourSetting tourSetting) {
List<TourSettingLot> lots = tourSetting.getLots();
if (Lang.isEmpty(lots)) {
@@ -93,11 +93,76 @@ public class TourSignupController {
}
@At("/h5")
@Ok("beetl:/platform/zhghh5/dayofficework/tour/signup/index.html")
@Ok("beetl:/platform/zhghh5/dayofficework/tour/signup/entry.html")
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public void h5() {
}
/**
* 移动端疗休养报名入口的信息确认页
* 用户先确认基础信息并保存到人员分配表再进入线路选择页面
*/
@At("/h5/confirm")
@Ok("beetl:/platform/zhghh5/dayofficework/tour/signup/confirm.html")
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public void h5Confirm() {
}
/**
* 移动端疗休养报名范围外提示页
* 当前登录人不存在人员分配记录时从报名入口跳转到此页并引导返回首页
*/
@At("/h5/noAssignment")
@Ok("beetl:/platform/zhghh5/dayofficework/tour/signup/noAssignment.html")
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public void h5NoAssignment() {
}
/**
* 移动端报名成功后展示服务须知页
* 用户点击我已阅读后返回线路选择页面
*/
@At("/h5/notice")
@Ok("beetl:/platform/zhghh5/dayofficework/tour/signup/index.html")
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public void h5Notice() {
}
/**
* 查询移动端信息确认页数据
* 数据优先来自人员分配表人员分配表不存在时返回 vw_user 基础信息供展示
*
* @return 信息确认表单数据和乘车地点选项
*/
@At("/h5/confirmInfo")
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public Result h5ConfirmInfo() {
return Result.success(tourUserAssignmentService.currentH5ConfirmInfo());
}
/**
* 保存移动端信息确认页数据
* 保存范围限制为当前年度既有人员分配记录避免新增分配资格影响报名校验
*
* @param userName 姓名
* @param gender 性别
* @param age 年龄
* @param idCard 身份证号
* @param mobile 手机号
* @param boardingPlace 乘车地点
* @return 保存结果
*/
@At("/h5/saveConfirmInfo")
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public Result h5SaveConfirmInfo(String userName, String gender, Integer age, String idCard, String mobile, String boardingPlace) {
try {
tourUserAssignmentService.saveCurrentH5ConfirmInfo(userName, gender, age, idCard, mobile, boardingPlace);
return Result.success();
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
@At("/h5/serviceNotice")
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public Result h5ServiceNotice() {
@@ -333,6 +398,7 @@ public class TourSignupController {
SELECT
m.id AS matterId,
m.`year`,
m.settingId,
m.matterName,
m.minGroupPeople,
m.maxGroupPeople,
@@ -502,12 +568,14 @@ public class TourSignupController {
SELECT
m.id AS matterId,
m.`year`,
m.settingId,
m.matterName,
m.unionId AS matterUnionId,
m.defaultBoardingPlace,
m.travelStartTime,
m.travelEndTime,
m.contactName,
m.contactPhone,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' ', m.travelEndTime)
@@ -569,6 +637,7 @@ public class TourSignupController {
.addv("unitName", user == null ? "" : defaultIfBlank(user.getUnitName(), ""))
.addv("unionId", user == null ? SecurityUtil.getUnionId() : defaultIfBlank(user.getUnionId(), SecurityUtil.getUnionId()))
.addv("unionName", user == null ? "" : defaultIfBlank(user.getUnionName(), ""));
NutMap assignment = tourUserAssignmentService.currentUserAssignmentInfo(matter.getString("settingId", ""));
Cnd ledgerCnd = Cnd.where(TourLedger::getDelFlag, "=", false)
.and(TourLedger::getMatterId, "=", matter.getString("matterId"))
.and(TourLedger::getJobNo, "=", staff.getString("jobNo"));
@@ -589,6 +658,7 @@ public class TourSignupController {
return Result.success(NutMap.NEW()
.addv("matter", matter)
.addv("staff", staff)
.addv("assignment", assignment)
.addv("ledger", ledger)
.addv("process", getSignupProcessInfo(ledger))
.addv("families", families)
@@ -803,6 +873,7 @@ public class TourSignupController {
TourLine line = tourLedgerService.dao().fetch(TourLine.class, matter.getLineId());
boolean directFamilyLine = line != null && Boolean.TRUE.equals(line.getDirectFamilyUnitLine());
TourSetting setting = tourLedgerService.dao().fetch(TourSetting.class, matter.getSettingId());
fillLedgerFromAssignmentConfirmInfo(ledger, matter.getSettingId());
Result boardingPlaceResult = normalizeBoardingPlace(ledger, matter, setting);
if (boardingPlaceResult != null) {
return boardingPlaceResult;
@@ -838,6 +909,7 @@ public class TourSignupController {
}
ledger.setOverCostReimbursed(Boolean.TRUE.equals(ledger.getOverCostReimbursed()));
fillStaffInfo(ledger);
fillLedgerFromAssignmentConfirmInfo(ledger, matter.getSettingId());
if (update) {
tourLedgerService.updateIgnoreNull(ledger);
@@ -1288,19 +1360,21 @@ public class TourSignupController {
// Button visibility is calculated on the server so H5 follows the same time rules as backend submit actions.
item.put("cancelDeadlineDays", cancelDeadlineDays);
item.put("canLeaveTour", signed && StrUtil.isNotBlank(assignmentId) && !assignmentCancelled
&& isWithinLeaveTimeRange(item.getString("travelStartTime", ""), cancelDeadlineDays, now));
&& isWithinLeaveTimeRange(item.getString("signupEndTime", ""), item.getString("travelStartTime", ""), cancelDeadlineDays, now));
item.put("canCancelLine", signed && StrUtil.isNotBlank(ledgerId)
&& isWithinSignupRange(item.getString("signupStartTime", ""), item.getString("signupEndTime", ""), now));
}
}
private boolean isWithinLeaveTimeRange(String travelStartTime, int cancelDeadlineDays, LocalDateTime now) {
private boolean isWithinLeaveTimeRange(String signupEndTime, String travelStartTime, int cancelDeadlineDays, LocalDateTime now) {
LocalDateTime signupEnd = parseDateTimeValue(signupEndTime, true);
LocalDateTime startTime = parseDateTimeValue(travelStartTime, false);
if (startTime == null) {
if (signupEnd == null || startTime == null) {
return false;
}
LocalDateTime deadline = startTime.minusDays(cancelDeadlineDays);
return !now.isBefore(deadline) && now.isBefore(startTime);
// 退出疗休养按钮严格限定在报名截止时间 < 当前时间 < 出行开始前N天范围内
return now.isAfter(signupEnd) && now.isBefore(deadline);
}
private boolean isWithinSignupRange(String signupStartTime, String signupEndTime, LocalDateTime now) {
@@ -1638,6 +1712,30 @@ public class TourSignupController {
ledger.setUnionName(user == null ? ledger.getUnionName() : defaultIfBlank(user.getUnionName(), ledger.getUnionName()));
}
private void fillLedgerFromAssignmentConfirmInfo(TourLedger ledger, String settingId) {
if (ledger == null || StrUtil.isBlank(settingId)) {
return;
}
NutMap assignment = tourUserAssignmentService.currentUserAssignmentInfo(settingId);
if (assignment == null || assignment.isEmpty()) {
return;
}
// 移动端信息确认页保存到人员分配表最终报名时以该确认信息回写台账
ledger.setUserName(defaultIfBlank(assignment.getString("userName", ""), ledger.getUserName()));
ledger.setGender(defaultIfBlank(assignment.getString("gender", ""), ledger.getGender()));
Integer assignmentAge = assignment.getInt("age");
if (assignmentAge != null) {
ledger.setAge(assignmentAge);
}
ledger.setIdCard(defaultIfBlank(assignment.getString("idCard", ""), ledger.getIdCard()));
ledger.setMobile(defaultIfBlank(assignment.getString("mobile", ""), ledger.getMobile()));
ledger.setBoardingPlace(defaultIfBlank(assignment.getString("boardingPlace", ""), ledger.getBoardingPlace()));
ledger.setUnitId(defaultIfBlank(assignment.getString("unitId", ""), ledger.getUnitId()));
ledger.setUnitName(defaultIfBlank(assignment.getString("unitName", ""), ledger.getUnitName()));
ledger.setUnionId(defaultIfBlank(assignment.getString("unionId", ""), ledger.getUnionId()));
ledger.setUnionName(defaultIfBlank(assignment.getString("unionName", ""), ledger.getUnionName()));
}
private void fillLedgerContactFallback(TourLedger ledger, NutMap staff) {
if (ledger == null || staff == null) {
return;
@@ -46,6 +46,11 @@ public class TourLedger extends BaseModel implements Serializable {
@ColDefine(type = ColType.VARCHAR, width = 20)
private String gender;
@Column
@Comment("年龄")
@ColDefine(type = ColType.INT, width = 3)
private Integer age;
@Column
@Comment("身份证号")
@ColDefine(type = ColType.VARCHAR, width = 30)
@@ -138,6 +138,17 @@ public class TourSetting extends BaseModel implements Serializable {
@Default("1")
private Boolean enabled;
@Column
@Comment("是否展示PC首页报名入口")
@ColDefine(type = ColType.BOOLEAN)
@Default("0")
private Boolean homeSignupEntryEnabled;
@Column
@Comment("PC首页报名入口图片")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String homeSignupEntryImage;
/**
* 标段管理沿用老疗休养配置的子表设计后续线路旅行社等模块可通过标段ID继续关联
*/
@@ -97,6 +97,16 @@ public class TourUserAssignment extends BaseModel implements Serializable {
@ColDefine(type = ColType.VARCHAR, width = 20)
private String gender;
@Column
@Comment("年龄")
@ColDefine(type = ColType.INT, width = 3)
private Integer age;
@Column
@Comment("身份证号")
@ColDefine(type = ColType.VARCHAR, width = 30)
private String idCard;
@Column
@Comment("手机号")
@ColDefine(type = ColType.VARCHAR, width = 30)
@@ -33,9 +33,10 @@ public interface TourUserAssignmentService extends BaseService<TourUserAssignmen
* @param settingId 疗休养配置ID
* @param unionId 所属分工会ID
* @param keyword 姓名或工号关键字
* @param userIds 指定候选人员ID列表人员选择器多选查询时使用
* @return 可分配候选人分页数据
*/
Pagination<NutMap> schoolCandidatePage(PageForm pageForm, String settingId, String unionId, String keyword);
Pagination<NutMap> schoolCandidatePage(PageForm pageForm, String settingId, String unionId, String keyword, List<String> userIds);
/**
* 分页查询当前登录人所在分工会的人员分配记录列表只读取人员分配表不读取报名台账
@@ -57,9 +58,10 @@ public interface TourUserAssignmentService extends BaseService<TourUserAssignmen
* @param pageForm 分页排序参数
* @param settingId 疗休养配置ID
* @param keyword 姓名或工号关键字
* @param userIds 指定候选人员ID列表人员选择器多选查询时使用
* @return 当前分工会可分配候选人分页数据
*/
Pagination<NutMap> branchCandidatePage(PageForm pageForm, String settingId, String keyword);
Pagination<NutMap> branchCandidatePage(PageForm pageForm, String settingId, String keyword, List<String> userIds);
/**
* 查询启用的疗休养配置选项供人员分配列表筛选和分配弹窗复用
@@ -77,6 +79,14 @@ public interface TourUserAssignmentService extends BaseService<TourUserAssignmen
*/
List<NutMap> listMatterOptions(String settingId);
/**
* 查询指定配置下当前处于报名时间内的分配线路选项供分工会人员选择线路时使用
*
* @param settingId 疗休养配置ID
* @return 当前报名时间内的事项选项列表
*/
List<NutMap> listSignupOpenMatterOptions(String settingId);
/**
* 查询分工会选项供校工会分配候选人和列表筛选使用
*
@@ -148,6 +158,36 @@ public interface TourUserAssignmentService extends BaseService<TourUserAssignmen
*/
NutMap switchCurrentBranchPersonType(String id, String personType);
/**
* 查询移动端疗休养信息确认页所需的当前用户信息
* 优先读取当前年度人员分配表无分配记录时读取 vw_user 基础信息用于页面展示
*
* @return 当前登录人的信息确认数据和乘车地点选项
*/
NutMap currentH5ConfirmInfo();
/**
* 保存移动端信息确认页填写的人员基础信息到当前年度人员分配表
* 仅更新既有人员分配记录不新增分配名单避免绕过疗休养报名资格控制
*
* @param userName 姓名
* @param gender 性别
* @param age 年龄
* @param idCard 身份证号
* @param mobile 手机号
* @param boardingPlace 乘车地点
*/
void saveCurrentH5ConfirmInfo(String userName, String gender, Integer age, String idCard, String mobile, String boardingPlace);
/**
* 查询指定配置下当前登录人的人员分配确认信息
* 报名详情和最终提交报名时使用该信息回填台账保证移动端确认信息能落到报名台账
*
* @param settingId 疗休养配置ID
* @return 人员分配确认信息不存在时返回空 Map
*/
NutMap currentUserAssignmentInfo(String settingId);
/**
* 用户报名写入台账后回填该用户在同一疗休养配置下已存在的人员分配记录
* 仅更新事项线路旅行社快照字段不新增记录不修改 assignSource 和人员类型
@@ -82,13 +82,18 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
}
@Override
public Pagination<NutMap> schoolCandidatePage(PageForm pageForm, String settingId, String unionId, String keyword) {
public Pagination<NutMap> schoolCandidatePage(PageForm pageForm, String settingId, String unionId, String keyword, List<String> userIds) {
TourSetting setting = fetchSettingForAssignment(settingId);
if (setting == null || StrUtil.isBlank(setting.getActivityGroupId())) {
return emptyPagination(pageForm);
}
Sql sql = buildCandidateSql(settingId);
Cnd cnd = buildCandidateCnd(settingId, setting.getActivityGroupId(), unionId, keyword);
List<String> normalizedUserIds = normalizeUserIds(userIds);
// 人员选择器多选查询时候选范围仍受活动组分工会已分配排除规则约束再按指定人员ID精确过滤
if (!Lang.isEmpty(normalizedUserIds)) {
cnd.and("u.id", "in", normalizedUserIds);
}
cnd.asc("u.unionname");
cnd.asc("u.unitname");
cnd.asc("u.loginname");
@@ -108,12 +113,12 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
}
@Override
public Pagination<NutMap> branchCandidatePage(PageForm pageForm, String settingId, String keyword) {
public Pagination<NutMap> branchCandidatePage(PageForm pageForm, String settingId, String keyword, List<String> userIds) {
String unionId = SecurityUtil.getUnionId();
if (StrUtil.isBlank(unionId)) {
return emptyPagination(pageForm);
}
return schoolCandidatePage(pageForm, settingId, unionId, keyword);
return schoolCandidatePage(pageForm, settingId, unionId, keyword, userIds);
}
@Override
@@ -134,6 +139,18 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
@Override
public List<NutMap> listMatterOptions(String settingId) {
return listMatterOptions(settingId, false);
}
@Override
public List<NutMap> listSignupOpenMatterOptions(String settingId) {
return listMatterOptions(settingId, true);
}
/**
* 统一查询人员分配可选线路分工会选择线路时只返回当前报名期内的事项
*/
private List<NutMap> listMatterOptions(String settingId, boolean onlySignupOpen) {
if (StrUtil.isBlank(settingId)) {
return Collections.emptyList();
}
@@ -143,6 +160,8 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
m.matterName,
m.`year` AS `year`,
m.settingId,
m.signupStartTime,
m.signupEndTime,
m.lineId,
l.lineName,
l.lineType,
@@ -161,6 +180,11 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
cnd.and("l.id", "is not", null);
cnd.and("l.delFlag", "=", false);
cnd.and("l.enabled", "=", true);
if (onlySignupOpen) {
String now = DateUtil.now();
cnd.and("m.signupStartTime", "<=", now);
cnd.and("m.signupEndTime", ">=", now);
}
sql.setCondition(cnd);
return listMap(sql);
}
@@ -336,6 +360,9 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
if (matterInfo == null) {
throw new IllegalArgumentException("分配路线不存在或未配置线路");
}
if (!isMatterSignupOpen(matterInfo)) {
throw new IllegalArgumentException("当前线路不在报名时间内,不能选择");
}
List<NutMap> users = Collections.singletonList(fetchAssignmentUserSnapshot(assignment));
checkProxySignupMaxGroupPeople(matterInfo, users);
updateAssignmentMatterSnapshot(id, matterInfo, TourUserAssignment.ASSIGN_SOURCE_BRANCH_UNION);
@@ -487,6 +514,103 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
return branchTypeSwitchResult(targetPersonType, branchQuotaInfo(assignment.getSettingId()));
}
@Override
public NutMap currentH5ConfirmInfo() {
TourSetting setting = fetchCurrentYearEnabledSetting();
NutMap userInfo = fetchCurrentUserViewInfo();
NutMap assignmentInfo = setting == null ? NutMap.NEW() : currentUserAssignmentInfo(setting.getId());
boolean existsAssignment = assignmentInfo != null && StrUtil.isNotBlank(assignmentInfo.getString("assignmentId", ""));
NutMap source = existsAssignment ? assignmentInfo : userInfo;
return NutMap.NEW()
.addv("settingId", setting == null ? "" : setting.getId())
.addv("year", setting == null ? LocalDate.now().getYear() : setting.getYear())
.addv("existsAssignment", existsAssignment)
.addv("boardingPlaceOptions", setting == null ? "[]" : StrUtil.blankToDefault(setting.getBoardingPlace(), "[]"))
.addv("assignment", assignmentInfo == null ? NutMap.NEW() : assignmentInfo)
.addv("user", userInfo)
.addv("form", NutMap.NEW()
.addv("userName", source.getString("userName", ""))
.addv("gender", source.getString("gender", ""))
.addv("age", source.get("age"))
.addv("idCard", source.getString("idCard", ""))
.addv("mobile", source.getString("mobile", ""))
.addv("boardingPlace", source.getString("boardingPlace", "")));
}
@Override
public void saveCurrentH5ConfirmInfo(String userName, String gender, Integer age, String idCard, String mobile, String boardingPlace) {
TourSetting setting = fetchCurrentYearEnabledSetting();
if (setting == null) {
throw new IllegalArgumentException("当前年度暂无启用的疗休养配置");
}
if (StrUtil.isBlank(userName) || StrUtil.isBlank(gender)
|| StrUtil.isBlank(idCard) || StrUtil.isBlank(mobile) || StrUtil.isBlank(boardingPlace)) {
throw new IllegalArgumentException("请完善姓名、性别、身份证号、手机号和乘车地点");
}
String normalizedIdCard = idCard.trim().toUpperCase();
if (!isValidIdCard(normalizedIdCard)) {
throw new IllegalArgumentException("请输入正确的身份证号");
}
Integer calculatedAge = calculateAge(normalizedIdCard, "");
if (calculatedAge == null) {
throw new IllegalArgumentException("请填写正确的身份证号以计算年龄");
}
Cnd cnd = Cnd.where(TourUserAssignment::getSettingId, "=", setting.getId())
.and(TourUserAssignment::getUserId, "=", SecurityUtil.getUserId())
.and(TourUserAssignment::getDelFlag, "=", false);
if (count(cnd) <= 0) {
throw new IllegalArgumentException("未找到当前年度人员分配记录,暂不能确认报名信息");
}
// 移动端信息确认只更新既有分配记录的人员基础信息不创建分配资格
update(Chain.make("userName", userName.trim())
.add("gender", gender.trim())
.add("age", calculatedAge)
.add("idCard", normalizedIdCard)
.add("mobile", mobile.trim())
.add("boardingPlace", boardingPlace.trim()),
cnd);
}
@Override
public NutMap currentUserAssignmentInfo(String settingId) {
if (StrUtil.isBlank(settingId)) {
return NutMap.NEW();
}
Sql sql = Sqls.create("""
SELECT
id AS assignmentId,
settingId,
matterId,
userId,
loginName AS jobNo,
userName,
gender,
age,
idCard,
mobile,
unitId,
unitName,
unionId,
unionName,
boardingPlace,
personType,
assignSource,
IFNULL(cancelled, 0) AS cancelled
FROM tour_user_assignment
WHERE delFlag = 0
AND settingId = @settingId
AND userId = @userId
ORDER BY CASE WHEN personType = 'FORMAL' THEN 0 ELSE 1 END ASC, createdAt DESC
LIMIT 1
""");
sql.setParam("settingId", settingId);
sql.setParam("userId", SecurityUtil.getUserId());
sql.setCallback(Sqls.callback.map());
dao().execute(sql);
NutMap map = sql.getObject(NutMap.class);
return map == null ? NutMap.NEW() : map;
}
@Override
public int backfillExistingAssignmentAfterSignup(String settingId, String matterId, String userId, String boardingPlace) {
if (StrUtil.isBlank(settingId) || StrUtil.isBlank(matterId) || StrUtil.isBlank(userId)) {
@@ -730,8 +854,10 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
u.loginname AS loginName,
u.username AS userName,
u.sex AS gender,
u.age AS age,
u.idcard AS idCard,
u.mobile AS mobile,
u.birthday AS birthday,
u.unitid AS unitId,
u.unitname AS unitName,
u.unionid AS unionId,
@@ -884,13 +1010,14 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
if (matter == null) {
throw new IllegalArgumentException("分配路线不存在,无法取消退出");
}
LocalDateTime travelStartTime = parseTravelStartTime(matter.getTravelStartTime());
LocalDateTime signupEndTime = parseMatterDateTime(matter.getSignupEndTime(), true, "分配路线未设置报名截止时间,无法取消退出", "分配路线报名截止时间格式不正确,无法取消退出");
LocalDateTime travelStartTime = parseMatterDateTime(matter.getTravelStartTime(), false, "分配路线未设置出行开始时间,无法取消退出", "分配路线出行开始时间格式不正确,无法取消退出");
int days = getCancelDeadlineDays();
LocalDateTime deadline = travelStartTime.minusDays(days);
LocalDateTime now = LocalDateTime.now();
// 退出报名仅允许在出行开始前N天出行开始前之间办理出行开始时刻不能再退出
if (now.isBefore(deadline) || !now.isBefore(travelStartTime)) {
throw new IllegalArgumentException("当前不在退出时间范围内,需在出行开始前" + days + "至出行开始前办理");
// 退出报名仅允许在报名截止时间 < 当前时间 < 出行开始前N天之间办理两个边界时刻均不可办理
if (!now.isAfter(signupEndTime) || !now.isBefore(deadline)) {
throw new IllegalArgumentException("当前不在退出时间范围内,需在报名截止后至出行开始前" + days + "前办理");
}
}
@@ -906,18 +1033,22 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
}
}
private LocalDateTime parseTravelStartTime(String travelStartTime) {
if (StrUtil.isBlank(travelStartTime)) {
throw new IllegalArgumentException("分配路线未设置出行开始时间,无法取消退出");
private LocalDateTime parseMatterDateTime(String value, boolean endOfDay, String blankMessage, String formatMessage) {
if (StrUtil.isBlank(value)) {
throw new IllegalArgumentException(blankMessage);
}
String normalizedTime = travelStartTime.trim();
String normalizedTime = value.trim();
try {
if (normalizedTime.length() <= 10) {
return LocalDate.parse(normalizedTime.substring(0, 10), DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay();
LocalDate date = LocalDate.parse(normalizedTime.substring(0, 10), DateTimeFormatter.ofPattern("yyyy-MM-dd"));
return endOfDay ? date.atTime(23, 59, 59) : date.atStartOfDay();
}
if (normalizedTime.length() == 16) {
normalizedTime = normalizedTime + ":00";
}
return LocalDateTime.parse(normalizedTime.substring(0, 19), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("分配路线出行开始时间格式不正确,无法取消退出");
throw new IllegalArgumentException(formatMessage);
}
}
@@ -929,6 +1060,55 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
.and(TourSetting::getDelFlag, "=", false));
}
private TourSetting fetchCurrentYearEnabledSetting() {
Cnd cnd = Cnd.where(TourSetting::getDelFlag, "=", false)
.and(TourSetting::getEnabled, "=", true)
.and(TourSetting::getYear, "=", LocalDate.now().getYear());
cnd.asc(TourSetting::getSortNo);
cnd.desc(TourSetting::getCreatedAt);
return dao().fetch(TourSetting.class, cnd);
}
private NutMap fetchCurrentUserViewInfo() {
Sql sql = Sqls.create("""
SELECT
id AS userId,
loginname AS jobNo,
username AS userName,
sex AS gender,
idcard AS idCard,
mobile,
unitId,
unitName,
unionId,
unionName,
birthday
FROM vw_user
WHERE id = @userId
LIMIT 1
""");
sql.setParam("userId", SecurityUtil.getUserId());
sql.setCallback(Sqls.callback.map());
dao().execute(sql);
NutMap user = sql.getObject(NutMap.class);
if (user == null) {
user = NutMap.NEW()
.addv("userId", SecurityUtil.getUserId())
.addv("jobNo", SecurityUtil.getUserLoginname())
.addv("userName", SecurityUtil.getUserUsername())
.addv("gender", "")
.addv("idCard", "")
.addv("mobile", "")
.addv("unitId", SecurityUtil.getUnitId())
.addv("unitName", "")
.addv("unionId", SecurityUtil.getUnionId())
.addv("unionName", "");
}
user.put("age", calculateAge(user.getString("idCard", ""), user.getString("birthday", "")));
user.put("boardingPlace", "");
return user;
}
private NutMap fetchMatterInfo(String settingId, String matterId) {
Sql sql = Sqls.create("""
SELECT
@@ -936,6 +1116,8 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
m.matterName,
m.`year` AS `year`,
m.settingId,
m.signupStartTime,
m.signupEndTime,
m.defaultBoardingPlace,
m.maxGroupPeople,
m.lineId,
@@ -961,6 +1143,22 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
return Lang.isEmpty(list) ? null : list.get(0);
}
/**
* 判断事项当前是否处于报名时间范围内用于分工会选择线路的最终提交保护
*/
private boolean isMatterSignupOpen(NutMap matterInfo) {
if (matterInfo == null) {
return false;
}
String signupStartTime = matterInfo.getString("signupStartTime", "");
String signupEndTime = matterInfo.getString("signupEndTime", "");
if (StrUtil.isBlank(signupStartTime) || StrUtil.isBlank(signupEndTime)) {
return false;
}
String now = DateUtil.now();
return signupStartTime.compareTo(now) <= 0 && signupEndTime.compareTo(now) >= 0;
}
private TourUserAssignment buildSchoolAssignment(String settingId, NutMap matterInfo, String personType, NutMap user) {
return buildAssignment(settingId, matterInfo, personType, user, TourUserAssignment.ASSIGN_SOURCE_SCHOOL_UNION);
}
@@ -973,6 +1171,9 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
assignment.setLoginName(user.getString("loginName"));
assignment.setUserName(user.getString("userName"));
assignment.setGender(user.getString("gender"));
Integer age = user.getInt("age");
assignment.setAge(age == null ? calculateAge(user.getString("idCard"), user.getString("birthday")) : age);
assignment.setIdCard(user.getString("idCard"));
assignment.setMobile(user.getString("mobile"));
assignment.setUnitId(user.getString("unitId"));
assignment.setUnitName(user.getString("unitName"));
@@ -1232,4 +1433,33 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
private int defaultInt(Integer value) {
return value == null || value < 0 ? 0 : value;
}
private Integer calculateAge(String idCard, String birthday) {
LocalDate birthDate = null;
try {
if (StrUtil.isNotBlank(idCard) && idCard.trim().length() >= 14) {
String normalizedIdCard = idCard.trim();
birthDate = LocalDate.parse(normalizedIdCard.substring(6, 14), DateTimeFormatter.ofPattern("yyyyMMdd"));
} else if (StrUtil.isNotBlank(birthday) && birthday.length() >= 10) {
birthDate = LocalDate.parse(birthday.substring(0, 10), DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
} catch (Exception e) {
birthDate = null;
}
if (birthDate == null) {
return null;
}
LocalDate today = LocalDate.now();
int age = today.getYear() - birthDate.getYear();
if (today.getMonthValue() < birthDate.getMonthValue()
|| (today.getMonthValue() == birthDate.getMonthValue() && today.getDayOfMonth() < birthDate.getDayOfMonth())) {
age--;
}
return Math.max(age, 0);
}
private boolean isValidIdCard(String idCard) {
return StrUtil.isNotBlank(idCard)
&& idCard.matches("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dX]$");
}
}
@@ -0,0 +1,3 @@
-- Add signup ledger age field for mobile confirmation snapshot.
ALTER TABLE `tour_ledger`
ADD COLUMN `age` int DEFAULT NULL COMMENT '年龄' AFTER `gender`;
@@ -0,0 +1,4 @@
-- 疗休养配置增加PC首页报名浮动入口控制字段。
ALTER TABLE `tour_setting`
ADD COLUMN `homeSignupEntryEnabled` tinyint(1) DEFAULT 0 COMMENT '是否展示PC首页报名入口' AFTER `enabled`,
ADD COLUMN `homeSignupEntryImage` varchar(500) DEFAULT NULL COMMENT 'PC首页报名入口图片' AFTER `homeSignupEntryEnabled`;
@@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS `tour_setting` (
`allowFamily` tinyint(1) DEFAULT 0 COMMENT '是否允许携带家属',
`fillBedInfo` tinyint(1) DEFAULT 1 COMMENT '是否填报床位信息',
`enabled` tinyint(1) DEFAULT 1 COMMENT '是否启用',
`homeSignupEntryEnabled` tinyint(1) DEFAULT 0 COMMENT '是否展示PC首页报名入口',
`homeSignupEntryImage` varchar(500) DEFAULT NULL COMMENT 'PC首页报名入口图片',
`serviceNotice` text COMMENT '服务须知',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
@@ -87,6 +89,8 @@ CREATE TABLE IF NOT EXISTS `tour_user_assignment` (
`loginName` varchar(50) DEFAULT NULL COMMENT '工号',
`userName` varchar(100) DEFAULT NULL COMMENT '姓名',
`gender` varchar(20) DEFAULT NULL COMMENT '性别',
`age` int DEFAULT NULL COMMENT '年龄',
`idCard` varchar(30) DEFAULT NULL COMMENT '身份证号',
`mobile` varchar(30) DEFAULT NULL COMMENT '手机号',
`unitId` varchar(32) DEFAULT NULL COMMENT '所在单位ID',
`unitName` varchar(100) DEFAULT NULL COMMENT '所在单位',
@@ -0,0 +1,4 @@
-- Add mobile H5 signup confirmation fields to tour user assignment.
ALTER TABLE `tour_user_assignment`
ADD COLUMN `age` int DEFAULT NULL COMMENT '年龄' AFTER `gender`,
ADD COLUMN `idCard` varchar(30) DEFAULT NULL COMMENT '身份证号' AFTER `age`;
@@ -13,6 +13,8 @@ CREATE TABLE IF NOT EXISTS `tour_user_assignment` (
`loginName` varchar(50) DEFAULT NULL COMMENT '工号',
`userName` varchar(100) DEFAULT NULL COMMENT '姓名',
`gender` varchar(20) DEFAULT NULL COMMENT '性别',
`age` int DEFAULT NULL COMMENT '年龄',
`idCard` varchar(30) DEFAULT NULL COMMENT '身份证号',
`mobile` varchar(30) DEFAULT NULL COMMENT '手机号',
`unitId` varchar(32) DEFAULT NULL COMMENT '所在单位ID',
`unitName` varchar(100) DEFAULT NULL COMMENT '所在单位',
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -93,6 +93,41 @@ layout("/layouts/v4/baseLayout.html"){
font-weight: 500;
}
.tour-home-float {
position: fixed;
left: 0;
top: 0;
z-index: 99999;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.22);
cursor: pointer;
background: #ffffff;
will-change: transform;
}
.tour-home-float img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.tour-home-float__title {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 10px;
color: #ffffff;
font-size: 15px;
font-weight: 700;
line-height: 1.2;
background: linear-gradient(180deg, rgba(15, 23, 42, 0), rgba(15, 23, 42, 0.75));
text-shadow: 0 1px 3px rgba(15, 23, 42, 0.45);
box-sizing: border-box;
}
</style>
<div class="v4-container" id="v4-home-app">
@@ -133,6 +168,16 @@ layout("/layouts/v4/baseLayout.html"){
<jcdt :list="websiteNews"></jcdt>
</div>
<div
v-for="item in tourHomeFloatEntries"
:key="item.id"
class="tour-home-float"
:style="tourHomeFloatStyle(item)"
@click="openTourSignup(item)">
<img :src="item.imageUrl" :alt="item.title || ''">
<div class="tour-home-float__title">{{ item.title }}</div>
</div>
</div>
<script nonce="${cspNonce!}">
@@ -149,6 +194,9 @@ layout("/layouts/v4/baseLayout.html"){
data(){
return{
websiteNews: [],
tourHomeFloatEntries: [],
tourHomeFloatFrame: null,
tourHomeFloatLastTime: 0,
homeBannerList: "${config.AppHomeImg!}".split(",").map(item => item.trim()).filter(item => item)
}
},
@@ -162,6 +210,10 @@ layout("/layouts/v4/baseLayout.html"){
},
mounted() {
this.getWebSiteNews()
this.loadTourHomeSignupEntry()
},
beforeDestroy() {
this.stopTourHomeFloat()
},
methods: {
getWebSiteNews(){
@@ -170,6 +222,109 @@ layout("/layouts/v4/baseLayout.html"){
this.websiteNews = res.data
}
})
},
loadTourHomeSignupEntry() {
this.$axios.post('/platform/home/listTourHomeSignupEntry').then(res => {
if (res.code === 0) {
this.initTourHomeFloatEntries(res.data || [])
}
})
},
initTourHomeFloatEntries(rows) {
this.stopTourHomeFloat()
// 疗休养首页浮动入口从左上角开始飘动,多条配置时使用轻微偏移避免完全重叠。
const width = 285
const height = 177
const bounds = this.getTourHomeFloatBounds(width, height)
this.tourHomeFloatEntries = (rows || []).map((item, index) => {
const offset = index * 28
return Object.assign({}, item, {
width: width,
height: height,
x: Math.min(bounds.minX + offset, bounds.maxX),
y: Math.min(bounds.minY + offset, bounds.maxY),
vx: (index % 2 === 0 ? 1 : -1) * (0.028 + index * 0.004),
vy: (index % 3 === 0 ? 1 : -1) * (0.022 + index * 0.003)
})
})
if (this.tourHomeFloatEntries.length > 0) {
this.startTourHomeFloat()
}
},
startTourHomeFloat() {
this.tourHomeFloatLastTime = 0
const step = (timestamp) => {
if (!this.tourHomeFloatEntries.length) {
this.tourHomeFloatFrame = null
return
}
if (!this.tourHomeFloatLastTime) {
this.tourHomeFloatLastTime = timestamp
}
const delta = Math.min(timestamp - this.tourHomeFloatLastTime, 40)
this.tourHomeFloatLastTime = timestamp
this.moveTourHomeFloat(delta)
this.tourHomeFloatFrame = window.requestAnimationFrame(step)
}
this.tourHomeFloatFrame = window.requestAnimationFrame(step)
},
stopTourHomeFloat() {
if (this.tourHomeFloatFrame) {
window.cancelAnimationFrame(this.tourHomeFloatFrame)
this.tourHomeFloatFrame = null
}
this.tourHomeFloatLastTime = 0
},
moveTourHomeFloat(delta) {
this.tourHomeFloatEntries.forEach((item) => {
const bounds = this.getTourHomeFloatBounds(item.width, item.height)
let nextX = item.x + item.vx * delta
let nextY = item.y + item.vy * delta
let nextVx = item.vx
let nextVy = item.vy
if (nextX <= bounds.minX || nextX >= bounds.maxX) {
nextVx = -nextVx
nextX = Math.min(Math.max(nextX, bounds.minX), bounds.maxX)
}
if (nextY <= bounds.minY || nextY >= bounds.maxY) {
nextVy = -nextVy
nextY = Math.min(Math.max(nextY, bounds.minY), bounds.maxY)
}
this.$set(item, "x", nextX)
this.$set(item, "y", nextY)
this.$set(item, "vx", nextVx)
this.$set(item, "vy", nextVy)
})
},
getTourHomeFloatBounds(width, height) {
// 根据固定导航和首页内容容器计算浮动范围,确保图片只在首页内容可视区域内飘动。
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
const header = document.querySelector(".v4-header")
const container = document.querySelector("#container")
const headerRect = header ? header.getBoundingClientRect() : { bottom: 0 }
const containerRect = container ? container.getBoundingClientRect() : { left: 0 }
const padding = 4
const minX = Math.max(containerRect.left + padding, padding)
const minY = Math.max(headerRect.bottom + padding, padding)
return {
minX: minX,
minY: minY,
maxX: Math.max(viewportWidth - width - padding, minX),
maxY: Math.max(viewportHeight - height - padding, minY)
}
},
tourHomeFloatStyle(item) {
return {
width: item.width + "px",
height: item.height + "px",
transform: "translate(" + item.x + "px, " + item.y + "px)"
}
},
openTourSignup(item) {
// 首页浮动入口点击后打开新页面,避免打断当前首页浏览位置。
const url = item && item.href ? item.href : "/platform/tour/signup"
window.open(url, "_blank")
}
}
})
@@ -153,13 +153,21 @@ layout("/layouts/platform.html"){
</div>
<div class="candidate-toolbar">
<el-input
v-model="candidateForm.keyword"
<el-select
v-model="selectedCandidateIds"
multiple
filterable
remote
clearable
placeholder="姓名/工号"
style="width: 240px"
@keyup.enter.native="candidateSearch">
</el-input>
collapse-tags
reserve-keyword
:remote-method="remoteCandidateSearch"
:loading="candidateSelectLoading"
placeholder="请选择姓名/工号"
style="width: 360px"
@change="candidateUserSelectChange">
<el-option v-for="item in candidateUserOptions" :key="item.userId" :label="candidateOptionLabel(item)" :value="item.userId"></el-option>
</el-select>
<el-button type="primary" icon="el-icon-search" @click="candidateSearch">查询</el-button>
<el-button @click="resetCandidateSearch">重置</el-button>
<span class="candidate-selected">已选 {{ selectedCandidates.length }} 人</span>
@@ -312,8 +320,11 @@ layout("/layouts/platform.html"){
},
assignDialogVisible: false,
candidateLoading: false,
candidateSelectLoading: false,
candidateData: [],
candidateUserOptions: [],
selectedCandidates: [],
selectedCandidateIds: [],
assignSubmitting: false,
selectMatterDialogVisible: false,
selectMatterSubmitting: false,
@@ -447,6 +458,8 @@ layout("/layouts/platform.html"){
this.quotaInfo = this.defaultQuotaInfo()
this.candidateData = []
this.selectedCandidates = []
this.selectedCandidateIds = []
this.candidateUserOptions = []
this.assignDialogVisible = true
this.loadAssignSettingOptions()
},
@@ -457,6 +470,8 @@ layout("/layouts/platform.html"){
this.quotaInfo = this.defaultQuotaInfo()
this.candidateData = []
this.selectedCandidates = []
this.selectedCandidateIds = []
this.candidateUserOptions = []
this.assignSubmitting = false
},
assignYearChange() {
@@ -464,12 +479,18 @@ layout("/layouts/platform.html"){
this.assignForm.matterId = ""
this.assignMatterOptions = []
this.quotaInfo = this.defaultQuotaInfo()
this.selectedCandidateIds = []
this.candidateUserOptions = []
this.candidateForm.keyword = ""
this.clearCandidateSelection()
this.loadAssignSettingOptions()
},
assignSettingChange() {
this.assignForm.matterId = ""
this.assignMatterOptions = []
this.selectedCandidateIds = []
this.candidateUserOptions = []
this.candidateForm.keyword = ""
this.clearCandidateSelection()
this.loadAssignMatterOptions()
this.loadQuotaInfo()
@@ -536,12 +557,14 @@ layout("/layouts/platform.html"){
pageNumber: this.candidateForm.pageNumber,
pageSize: this.candidateForm.pageSize,
settingId: this.assignForm.settingId,
keyword: this.candidateForm.keyword
keyword: (this.selectedCandidateIds || []).length > 0 ? "" : this.candidateForm.keyword,
userIds: JSON.stringify(this.selectedCandidateIds || [])
}).then((res) => {
if (res.code === 0) {
const data = res.data || {}
this.candidateData = data.list || []
this.candidateForm.totalCount = data.totalCount || 0
this.mergeCandidateOptions(this.candidateData)
} else {
this.$message.warning(res.msg || "候选人员查询失败")
}
@@ -551,11 +574,16 @@ layout("/layouts/platform.html"){
},
candidateSearch() {
this.candidateForm.pageNumber = 1
this.clearCandidateSelection()
this.loadCandidatePageData()
},
resetCandidateSearch() {
this.candidateForm.keyword = ""
this.candidateForm.pageNumber = 1
this.selectedCandidateIds = []
this.selectedCandidates = []
this.candidateUserOptions = []
this.clearCandidateSelection()
this.loadCandidatePageData()
},
candidateSizeChange(size) {
@@ -569,6 +597,51 @@ layout("/layouts/platform.html"){
},
candidateSelectionChange(rows) {
this.selectedCandidates = rows || []
this.mergeCandidateOptions(this.selectedCandidates)
},
remoteCandidateSearch(keyword) {
if (!this.assignForm.settingId) {
this.candidateUserOptions = []
return
}
// 人员选择器复用候选人员接口,后端会限定为当前登录人所在分工会会员。
this.candidateForm.keyword = keyword || ""
this.candidateSelectLoading = true
this.$axios.post(loc() + "/candidatePageData", {
pageNumber: 1,
pageSize: 20,
settingId: this.assignForm.settingId,
keyword: keyword || ""
}).then((res) => {
if (res.code === 0) {
const data = res.data || {}
this.candidateUserOptions = this.mergeOptionLists(this.selectedCandidates, data.list || [])
}
}).finally(() => {
this.candidateSelectLoading = false
})
},
candidateUserSelectChange(userIds) {
// 人员选择器仅作为查询条件;最终分配人员仍由下方列表勾选决定。
this.selectedCandidateIds = userIds || []
},
candidateOptionLabel(item) {
if (!item) {
return ""
}
return (item.userName || "") + (item.loginName ? "" + item.loginName + "" : "")
},
mergeCandidateOptions(list) {
this.candidateUserOptions = this.mergeOptionLists(this.candidateUserOptions, list || [])
},
mergeOptionLists(first, second) {
const map = {}
;(first || []).concat(second || []).forEach(item => {
if (item && item.userId) {
map[item.userId] = Object.assign({}, map[item.userId] || {}, item)
}
})
return Object.keys(map).map(key => map[key])
},
clearCandidateSelection() {
this.selectedCandidates = []
@@ -189,7 +189,7 @@ layout("/layouts/platform.html"){
</el-col>
<el-col :span="8">
<el-form-item label="联系方式" prop="contactPhone">
<el-input v-model="batchForm.contactPhone" maxlength="11" placeholder="请输入联系方式"></el-input>
<el-input v-model="batchForm.contactPhone" maxlength="30" placeholder="请输入联系方式"></el-input>
</el-form-item>
</el-col>
</el-row>
@@ -239,12 +239,10 @@ layout("/layouts/platform.html"){
callback(new Error("必填"))
}
}
const validateMobile = (rule, value, callback) => {
const mobileReg = /^1[3-9]\d{9}$/
const validateContactPhone = (rule, value, callback) => {
// 线路联系人电话允许填写座机、分机或其他联系说明,此处只校验必填。
if (!value) {
callback(new Error("必填"))
} else if (!mobileReg.test(value)) {
callback(new Error("手机号格式不正确"))
} else {
callback()
}
@@ -328,7 +326,7 @@ layout("/layouts/platform.html"){
travelStartTime: [{ validator: validateBatchTime, trigger: ["blur", "change"] }],
travelEndTime: [{ validator: validateBatchTime, trigger: ["blur", "change"] }],
contactName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
contactPhone: [{ validator: validateMobile, trigger: ["blur", "change"] }],
contactPhone: [{ validator: validateContactPhone, trigger: ["blur", "change"] }],
minGroupPeople: [{ validator: validatePeople, trigger: ["blur", "change"] }],
maxGroupPeople: [{ validator: validatePeople, trigger: ["blur", "change"] }],
estimatedCost: [{ validator: validateMoney, trigger: ["blur", "change"] }]
@@ -157,13 +157,21 @@ layout("/layouts/platform.html"){
<el-select v-model="candidateForm.unionId" clearable filterable placeholder="所属分工会" style="width: 220px" @change="candidateSearch">
<el-option v-for="item in unionOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
<el-input
v-model="candidateForm.keyword"
<el-select
v-model="selectedCandidateIds"
multiple
filterable
remote
clearable
placeholder="姓名/工号"
style="width: 220px"
@keyup.enter.native="candidateSearch">
</el-input>
collapse-tags
reserve-keyword
:remote-method="remoteCandidateSearch"
:loading="candidateSelectLoading"
placeholder="请选择姓名/工号"
style="width: 360px"
@change="candidateUserSelectChange">
<el-option v-for="item in candidateUserOptions" :key="item.userId" :label="candidateOptionLabel(item)" :value="item.userId"></el-option>
</el-select>
<el-button type="primary" icon="el-icon-search" @click="candidateSearch">查询</el-button>
<el-button @click="resetCandidateSearch">重置</el-button>
<span class="candidate-selected">已选 {{ selectedCandidates.length }} 人</span>
@@ -302,8 +310,11 @@ layout("/layouts/platform.html"){
unionOptions: [],
assignDialogVisible: false,
candidateLoading: false,
candidateSelectLoading: false,
candidateData: [],
candidateUserOptions: [],
selectedCandidates: [],
selectedCandidateIds: [],
assignSubmitting: false,
selectMatterDialogVisible: false,
selectMatterSubmitting: false,
@@ -437,6 +448,8 @@ layout("/layouts/platform.html"){
this.candidateForm = this.defaultCandidateForm()
this.candidateData = []
this.selectedCandidates = []
this.selectedCandidateIds = []
this.candidateUserOptions = []
this.assignDialogVisible = true
this.loadAssignSettingOptions()
},
@@ -446,12 +459,17 @@ layout("/layouts/platform.html"){
this.assignMatterOptions = []
this.candidateData = []
this.selectedCandidates = []
this.selectedCandidateIds = []
this.candidateUserOptions = []
this.assignSubmitting = false
},
assignYearChange() {
this.assignForm.settingId = ""
this.assignForm.matterId = ""
this.assignMatterOptions = []
this.selectedCandidateIds = []
this.candidateUserOptions = []
this.candidateForm.keyword = ""
this.clearCandidateSelection()
this.loadAssignSettingOptions()
this.loadCandidatePageData()
@@ -459,6 +477,9 @@ layout("/layouts/platform.html"){
assignSettingChange() {
this.assignForm.matterId = ""
this.assignMatterOptions = []
this.selectedCandidateIds = []
this.candidateUserOptions = []
this.candidateForm.keyword = ""
this.clearCandidateSelection()
this.loadAssignMatterOptions()
this.loadCandidatePageData()
@@ -506,7 +527,8 @@ layout("/layouts/platform.html"){
pageSize: this.candidateForm.pageSize,
settingId: this.assignForm.settingId,
unionId: this.candidateForm.unionId,
keyword: this.candidateForm.keyword
keyword: (this.selectedCandidateIds || []).length > 0 ? "" : this.candidateForm.keyword,
userIds: JSON.stringify(this.selectedCandidateIds || [])
}).then((res) => {
if (res.code === 0) {
const data = res.data || {}
@@ -514,6 +536,7 @@ layout("/layouts/platform.html"){
assignmentMatterId: ""
}))
this.candidateForm.totalCount = data.totalCount || 0
this.mergeCandidateOptions(this.candidateData)
} else {
this.$message.warning(res.msg || "候选人员查询失败")
}
@@ -528,12 +551,17 @@ layout("/layouts/platform.html"){
},
candidateSearch() {
this.candidateForm.pageNumber = 1
this.clearCandidateSelection()
this.loadCandidatePageData()
},
resetCandidateSearch() {
this.candidateForm.unionId = ""
this.candidateForm.keyword = ""
this.candidateForm.pageNumber = 1
this.selectedCandidateIds = []
this.selectedCandidates = []
this.candidateUserOptions = []
this.clearCandidateSelection()
this.loadCandidatePageData()
},
candidateSizeChange(size) {
@@ -555,10 +583,56 @@ layout("/layouts/platform.html"){
}
})
this.selectedCandidates = rows || []
this.mergeCandidateOptions(this.selectedCandidates)
if (this.selectedCandidates.length <= 0) {
this.assignForm.matterId = ""
}
},
remoteCandidateSearch(keyword) {
if (!this.assignForm.settingId) {
this.candidateUserOptions = []
return
}
// 人员选择器复用候选人员接口,校工会可按分工会过滤,也可不选分工会查询全部会员。
this.candidateForm.keyword = keyword || ""
this.candidateSelectLoading = true
this.$axios.post(loc() + "/candidatePageData", {
pageNumber: 1,
pageSize: 20,
settingId: this.assignForm.settingId,
unionId: this.candidateForm.unionId,
keyword: keyword || ""
}).then((res) => {
if (res.code === 0) {
const data = res.data || {}
this.candidateUserOptions = this.mergeOptionLists(this.selectedCandidates, data.list || [])
}
}).finally(() => {
this.candidateSelectLoading = false
})
},
candidateUserSelectChange(userIds) {
// 人员选择器仅作为查询条件;最终分配人员仍由下方列表勾选决定。
this.selectedCandidateIds = userIds || []
},
candidateOptionLabel(item) {
if (!item) {
return ""
}
return (item.userName || "") + (item.loginName ? "" + item.loginName + "" : "")
},
mergeCandidateOptions(list) {
this.candidateUserOptions = this.mergeOptionLists(this.candidateUserOptions, list || [])
},
mergeOptionLists(first, second) {
const map = {}
;(first || []).concat(second || []).forEach(item => {
if (item && item.userId) {
map[item.userId] = Object.assign({}, map[item.userId] || {}, item)
}
})
return Object.keys(map).map(key => map[key])
},
isCandidateSelected(row) {
return !!row && this.selectedCandidates.some(item => item.userId === row.userId)
},
@@ -241,6 +241,26 @@ layout("/layouts/platform.html"){
<el-switch v-model="formData.enabled" active-text="启用" inactive-text="停用"></el-switch>
</el-form-item>
</el-col>
<el-col :span="24">
<div class="tour-setting-basic-divider"></div>
</el-col>
<el-col :span="12">
<el-form-item label="首页报名入口" prop="homeSignupEntryEnabled">
<el-switch v-model="formData.homeSignupEntryEnabled" active-text="展示" inactive-text="不展示"></el-switch>
</el-form-item>
</el-col>
<el-col v-if="formData.homeSignupEntryEnabled" :span="12">
<el-form-item label="入口图片" prop="homeSignupEntryImage">
<file-upload
style="--upload-width: 220px;--upload-height:120px"
:value.sync="formData.homeSignupEntryImage"
:upload_number="1"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url">
</file-upload>
</el-form-item>
</el-col>
<el-col :span="24">
<div class="tour-setting-basic-divider"></div>
<div class="tour-boarding-panel">
@@ -621,6 +641,8 @@ layout("/layouts/platform.html"){
allowFamily: false,
fillBedInfo: true,
enabled: true,
homeSignupEntryEnabled: false,
homeSignupEntryImage: "",
lots: [],
unionQuotas: [],
serviceNotice: ""
@@ -693,6 +715,9 @@ layout("/layouts/platform.html"){
if (this.formData.fillBedInfo === null || this.formData.fillBedInfo === undefined) {
this.$set(this.formData, "fillBedInfo", true)
}
// 上一年配置只延用业务规则,首页浮动报名入口需要当年重新确认。
this.$set(this.formData, "homeSignupEntryEnabled", false)
this.$set(this.formData, "homeSignupEntryImage", "")
this.lotDeleteList = []
this.$message.success("已延用上一年配置信息")
this.$nextTick(() => this.$refs.form && this.$refs.form.clearValidate())
@@ -720,6 +745,8 @@ layout("/layouts/platform.html"){
allowFamily: false,
fillBedInfo: true,
enabled: true,
homeSignupEntryEnabled: false,
homeSignupEntryImage: "",
lots: [],
unionQuotas: [],
serviceNotice: ""
@@ -738,6 +765,14 @@ layout("/layouts/platform.html"){
if (this.formData.fillBedInfo === null || this.formData.fillBedInfo === undefined) {
this.$set(this.formData, "fillBedInfo", true)
}
if (this.formData.homeSignupEntryEnabled === null || this.formData.homeSignupEntryEnabled === undefined) {
this.$set(this.formData, "homeSignupEntryEnabled", false)
}
if (!this.formData.homeSignupEntryImage) {
this.$set(this.formData, "homeSignupEntryImage", "")
} else {
this.$set(this.formData, "homeSignupEntryImage", this.normalizeHomeSignupEntryImage(this.formData.homeSignupEntryImage))
}
this.formData.cycleStartYear = this.formData.cycleStartYear ? String(this.formData.cycleStartYear) : ""
this.formData.cycleEndYear = this.formData.cycleEndYear ? String(this.formData.cycleEndYear) : ""
this.formData.lots = (this.formData.lots || []).map(item => Object.assign({}, item, {
@@ -765,6 +800,12 @@ layout("/layouts/platform.html"){
if (submitData.outProvinceRatioType !== "固定人数") {
submitData.outProvinceFixedPeople = 0
}
if (!submitData.homeSignupEntryEnabled) {
submitData.homeSignupEntryImage = ""
} else {
// 入口图片只允许保存一张,兼容历史多路径数据时只取第一张。
submitData.homeSignupEntryImage = this.normalizeHomeSignupEntryImage(submitData.homeSignupEntryImage)
}
submitData.lots = JSON.stringify(this.formData.lots || [])
submitData.unionQuotas = JSON.stringify(this.formData.unionQuotas || [])
submitData.lotDeleteList = JSON.stringify(this.lotDeleteList)
@@ -782,6 +823,21 @@ layout("/layouts/platform.html"){
})
})
},
normalizeHomeSignupEntryImage(value) {
// file-upload 组件单图场景保存为逗号分隔字符串,这里统一裁剪为第一张图片。
if (!value) {
return ""
}
if (Array.isArray(value)) {
return value.length > 0 ? value[0] : ""
}
const imageList = String(value).split(",").map(function (item) {
return item.trim()
}).filter(function (item) {
return !!item
})
return imageList.length > 0 ? imageList[0] : ""
},
addLot() {
if (!this.formData.lots) {
this.$set(this.formData, "lots", [])
@@ -116,11 +116,10 @@ const select = {
</template>
<el-form-item :prop="'times.' + $index + '.contactPhone'"
:rules="[
{ required: true, message: '手机号码不能为空', trigger: 'blur' },
{ pattern: /^1[34578]\\d{9}$/, message: '手机号码格式不正确', trigger: 'blur' }
]"
{ required: true, message: '联系方式不能为空', trigger: 'blur' }
]"
label-width="0">
<el-input placeholder="请输入联系方式" clearable maxlength="11" v-model="row.contactPhone"></el-input>
<el-input placeholder="请输入联系方式" clearable maxlength="30" v-model="row.contactPhone"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="最少参与教工">
@@ -534,7 +534,7 @@ layout("/layouts/platform_h5.html"){
</div>
</div>
<div class="tour-bottom-bar">
<van-button v-if="signupForm.id" type="danger" plain block round :loading="cancelLoading" @click="cancelSignup">取消报名</van-button>
<van-button v-if="showCancel(signupForm)" type="danger" plain block round :loading="cancelLoading" @click="cancelSignup">取消报名</van-button>
<van-button type="info" block round :loading="signupLoading" @click="submitSignup">提交</van-button>
</div>
</van-popup>
@@ -733,7 +733,7 @@ layout("/layouts/platform_h5.html"){
return this.isApprovalRow(row) && this.toBoolean(row.canRevoke)
},
showCancel(row) {
return !this.isApprovalRow(row) || this.canModify(row)
return this.toBoolean(row && row.canCancelSignup) && (!this.isApprovalRow(row) || this.canModify(row))
},
// 退出疗休养报名依赖人员分配记录,已退出的数据不再展示入口。
showLeaveTour(row) {
@@ -832,8 +832,10 @@ layout("/layouts/platform_h5.html"){
travelAgencyName: matter.travelAgencyName || "",
boardingPlace: matter.defaultBoardingPlace || "",
travelPeriod: matter.travelPeriod || "",
signupEndTime: matter.signupEndTime || "",
travelStartTime: matter.travelStartTime || "",
travelEndTime: matter.travelEndTime || "",
canCancelSignup: this.toBoolean(matter.canCancelSignup),
allowOverReimbursement: matter.allowOverReimbursement,
overCostReimbursed: false,
hasFamily: false,
@@ -851,8 +853,10 @@ layout("/layouts/platform_h5.html"){
travelAgencyName: matter.travelAgencyName || ledger.travelAgencyName || "",
boardingPlace: ledger.boardingPlace || matter.defaultBoardingPlace || "",
travelPeriod: matter.travelPeriod || "",
signupEndTime: matter.signupEndTime || "",
travelStartTime: matter.travelStartTime || "",
travelEndTime: matter.travelEndTime || "",
canCancelSignup: this.toBoolean(matter.canCancelSignup),
allowOverReimbursement: matter.allowOverReimbursement,
overCostReimbursed: ledger.overCostReimbursed === true || ledger.overCostReimbursed === 1 || ledger.overCostReimbursed === "1",
hasFamily: ledger.hasFamily === true || ledger.hasFamily === 1 || ledger.hasFamily === "1"
@@ -383,6 +383,7 @@ layout("/layouts/platform_h5.html"){
const data = res.data || {}
const matter = data.matter || {}
const staff = data.staff || {}
const assignment = data.assignment || {}
const ledger = data.ledger || {}
const directRelative = data.directRelative || {}
this.boardingPlaceOptions = this.parseBoardingPlaceOptions(matter.boardingPlaceOptions || "")
@@ -409,7 +410,7 @@ layout("/layouts/platform_h5.html"){
hotelName: "",
travelAgencyId: matter.travelAgencyId || "",
travelAgencyName: matter.travelAgencyName || "",
boardingPlace: matter.defaultBoardingPlace || "",
boardingPlace: "",
travelPeriod: matter.travelPeriod || "",
travelStartTime: matter.travelStartTime || "",
travelEndTime: matter.travelEndTime || "",
@@ -419,7 +420,7 @@ layout("/layouts/platform_h5.html"){
intendedRoommate: "",
bedType: "",
bedInfo: ""
}, staff, ledger, {
}, staff, assignment, ledger, {
year: matter.year,
matterId: matter.matterId || ledger.matterId || "",
lineId: matter.lineId || ledger.lineId || "",
@@ -428,7 +429,7 @@ layout("/layouts/platform_h5.html"){
directFamilyUnitLine: matter.directFamilyUnitLine,
travelAgencyId: matter.travelAgencyId || ledger.travelAgencyId || "",
travelAgencyName: matter.travelAgencyName || ledger.travelAgencyName || "",
boardingPlace: ledger.boardingPlace || matter.defaultBoardingPlace || "",
boardingPlace: ledger.boardingPlace || assignment.boardingPlace || "",
travelPeriod: matter.travelPeriod || "",
travelStartTime: matter.travelStartTime || "",
travelEndTime: matter.travelEndTime || "",
@@ -450,7 +451,6 @@ layout("/layouts/platform_h5.html"){
this.familyData = []
this.signupForm.hasFamily = false
}
this.ensureBoardingPlace()
})
},
parseBoardingPlaceOptions(value) {
@@ -466,12 +466,6 @@ layout("/layouts/platform_h5.html"){
return String(value).split(",").map((item) => item.trim()).filter((item) => item)
}
},
ensureBoardingPlace() {
if (!this.signupForm) return
if (!this.signupForm.boardingPlace && this.boardingPlaceOptions.length === 1) {
this.$set(this.signupForm, "boardingPlace", this.boardingPlaceOptions[0])
}
},
emptyFamily() {
return {
familyName: "",
@@ -597,6 +591,26 @@ layout("/layouts/platform_h5.html"){
}
return true
},
validateStaffRequired() {
// 报名人身份证和手机号为移动端提交必填项,提交前统一清理空格并拦截空值。
const idCard = this.signupForm && this.signupForm.idCard ? String(this.signupForm.idCard).trim().toUpperCase() : ""
const mobile = this.signupForm && this.signupForm.mobile ? String(this.signupForm.mobile).trim() : ""
this.$set(this.signupForm, "idCard", idCard)
this.$set(this.signupForm, "mobile", mobile)
if (!idCard) {
vant.Toast("请填写身份证号码")
return false
}
if (!this.isValidIdCard(idCard)) {
vant.Toast("请输入正确的身份证号码")
return false
}
if (!mobile) {
vant.Toast("请填写手机号")
return false
}
return true
},
validateDirectRelative() {
if (!this.isDirectFamilyLine(this.signupForm)) return true
if (!this.directRelativeForm.relativeName) {
@@ -644,6 +658,7 @@ layout("/layouts/platform_h5.html"){
},
submitSignup() {
if (this.pageLoading || this.submitLoading) return
if (!this.validateStaffRequired()) return
if (this.boardingPlaceOptions.length > 0 && !this.signupForm.boardingPlace) {
vant.Toast("请选择乘车地点")
return
@@ -665,7 +680,7 @@ layout("/layouts/platform_h5.html"){
message: res.msg || "恭喜您已报名成功",
confirmButtonColor: "#1867b0"
}).then(() => {
window.location.replace("/platform/tour/signup/h5/signup")
window.location.replace("/platform/tour/signup/h5/notice")
})
} else {
this.showSubmitError(res)
@@ -0,0 +1,234 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style scoped>
.tour-confirm-page {
min-height: 100vh;
padding: 12px 12px 84px;
background: #f4f6f8;
box-sizing: border-box;
}
.tour-confirm-card {
overflow: hidden;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
}
.tour-confirm-tip {
margin: 10px 2px 0;
color: #ee8b00;
font-size: 12px;
line-height: 1.6;
}
.tour-confirm-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid #edf0f4;
background: #ffffff;
box-sizing: border-box;
}
</style>
<div id="app">
<van-nav-bar title="信息确认" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
<div class="tour-confirm-page">
<van-loading v-if="loading" size="24px" vertical>加载中...</van-loading>
<template v-else>
<div class="tour-confirm-card">
<van-field label="姓名" required v-model="form.userName" maxlength="100" placeholder="请填写姓名"></van-field>
<van-field label="性别" required readonly clickable is-link v-model="form.gender" placeholder="请选择性别" @click="genderPickerVisible=true"></van-field>
<van-field label="年龄" required readonly v-model="form.age" placeholder="根据身份证号自动计算"></van-field>
<van-field label="身份证号" required v-model="form.idCard" maxlength="18" placeholder="请填写身份证号" @blur="normalizeIdCard"></van-field>
<van-field label="手机号" required v-model="form.mobile" type="tel" maxlength="30" placeholder="请填写手机号"></van-field>
<van-field
label="乘车地点"
required
:readonly="boardingPlaceOptions.length > 0"
:clickable="boardingPlaceOptions.length > 0"
:is-link="boardingPlaceOptions.length > 0"
v-model="form.boardingPlace"
placeholder="请选择或填写乘车地点"
@click="openBoardingPlacePicker">
</van-field>
</div>
<div class="tour-confirm-tip">
请确认本人报名基础信息无误后,即可选择路线报名。
</div>
</template>
</div>
<div class="tour-confirm-footer">
<van-button type="info" block round :loading="submitLoading" @click="submitConfirm">我已确认</van-button>
</div>
<van-popup v-model="genderPickerVisible" position="bottom">
<van-picker show-toolbar :columns="genderColumns" @confirm="confirmGender" @cancel="genderPickerVisible=false"></van-picker>
</van-popup>
<van-popup v-model="boardingPlacePickerVisible" position="bottom">
<van-picker show-toolbar :columns="boardingPlaceOptions" @confirm="confirmBoardingPlace" @cancel="boardingPlacePickerVisible=false"></van-picker>
</van-popup>
</div>
<script nonce="${cspNonce!}">
const vue = new Vue({
el: "#app",
store,
data() {
return {
loading: false,
submitLoading: false,
existsAssignment: false,
form: {
userName: "",
gender: "",
age: "",
idCard: "",
mobile: "",
boardingPlace: ""
},
genderColumns: ["男", "女"],
boardingPlaceOptions: [],
genderPickerVisible: false,
boardingPlacePickerVisible: false
}
},
methods: {
parseBoardingPlaceOptions(value) {
if (!value) return []
try {
const list = JSON.parse(value)
if (!Array.isArray(list)) return []
return list.map(function (item) {
if (typeof item === "string") return item
return item && item.name ? item.name : ""
}).filter(function (item) {
return !!item
})
} catch (e) {
return String(value).split(",").map(function (item) {
return item.trim()
}).filter(function (item) {
return !!item
})
}
},
normalizeIdCard() {
this.form.idCard = this.form.idCard ? String(this.form.idCard).trim().toUpperCase() : ""
this.form.age = this.calcAgeByIdCard(this.form.idCard)
},
isValidIdCard(value) {
if (!value) return false
const idCard = String(value).trim().toUpperCase()
return /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]$/.test(idCard)
},
calcAgeByIdCard(value) {
if (!this.isValidIdCard(value)) {
return ""
}
const idCard = String(value).trim().toUpperCase()
const year = Number(idCard.substring(6, 10))
const month = Number(idCard.substring(10, 12))
const day = Number(idCard.substring(12, 14))
const today = new Date()
let age = today.getFullYear() - year
const currentMonth = today.getMonth() + 1
const currentDay = today.getDate()
if (currentMonth < month || (currentMonth === month && currentDay < day)) {
age--
}
return age >= 0 ? String(age) : ""
},
loadInfo() {
this.loading = true
this.$axios.post("/platform/tour/signup/h5/confirmInfo").then((res) => {
this.loading = false
if (res.code !== 0) {
vant.Toast(res.msg || "信息加载失败")
return
}
const data = res.data || {}
const form = data.form || {}
this.existsAssignment = data.existsAssignment === true || data.existsAssignment === 1 || data.existsAssignment === "1"
if (!this.existsAssignment) {
window.location.replace("/platform/tour/signup/h5/signup")
return
}
this.boardingPlaceOptions = this.parseBoardingPlaceOptions(data.boardingPlaceOptions || "")
this.form = Object.assign({}, this.form, form, {
age: form.idCard ? this.calcAgeByIdCard(form.idCard) : ""
})
}).catch(() => {
this.loading = false
})
},
openBoardingPlacePicker() {
if (this.boardingPlaceOptions.length === 0) return
this.boardingPlacePickerVisible = true
},
confirmGender(value) {
this.form.gender = value
this.genderPickerVisible = false
},
confirmBoardingPlace(value) {
this.form.boardingPlace = value
this.boardingPlacePickerVisible = false
},
validateForm() {
this.normalizeIdCard()
const requiredFields = [
{ key: "userName", message: "请填写姓名" },
{ key: "gender", message: "请选择性别" },
{ key: "idCard", message: "请填写身份证号" },
{ key: "age", message: "请填写正确的身份证号" },
{ key: "mobile", message: "请填写手机号" },
{ key: "boardingPlace", message: "请填写乘车地点" }
]
for (let i = 0; i < requiredFields.length; i++) {
const item = requiredFields[i]
if (this.form[item.key] === null || this.form[item.key] === undefined || String(this.form[item.key]).trim() === "") {
vant.Toast(item.message)
return false
}
}
if (!this.isValidIdCard(this.form.idCard)) {
vant.Toast("请输入正确的身份证号")
return false
}
this.form.age = this.calcAgeByIdCard(this.form.idCard)
return true
},
submitConfirm() {
if (this.loading || this.submitLoading) return
if (!this.validateForm()) return
this.submitLoading = true
this.$axios.post("/platform/tour/signup/h5/saveConfirmInfo", this.form).then((res) => {
this.submitLoading = false
if (res.code === 0) {
window.location.replace("/platform/tour/signup/h5/signup")
} else {
vant.Toast(res.msg || "信息确认失败")
}
}).catch(() => {
this.submitLoading = false
})
}
},
created() {
this.loadInfo()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,42 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style scoped>
.tour-entry-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f4f6f8;
}
</style>
<div id="app" class="tour-entry-page">
<van-loading size="24px" vertical>加载中...</van-loading>
</div>
<script nonce="${cspNonce!}">
const vue = new Vue({
el: "#app",
store,
methods: {
routeByAssignment() {
this.$axios.post("/platform/tour/signup/h5/confirmInfo").then((res) => {
const data = res.code === 0 ? (res.data || {}) : {}
const existsAssignment = data.existsAssignment === true || data.existsAssignment === 1 || data.existsAssignment === "1"
window.location.replace(existsAssignment ? "/platform/tour/signup/h5/confirm" : "/platform/tour/signup/h5/noAssignment")
}).catch(() => {
window.location.replace("/platform/tour/signup/h5/noAssignment")
})
}
},
created() {
this.routeByAssignment()
}
})
</script>
<!--#
}
#-->
@@ -6,83 +6,32 @@ layout("/layouts/platform_h5.html"){
.tour-signup-h5 {
min-height: 100vh;
background: #f5f7fb;
padding-bottom: 80px;
padding: 10px 10px 80px;
box-sizing: border-box;
}
.tour-signup-banner {
position: relative;
height: 193px;
overflow: hidden;
background: #0f74bc;
color: #ffffff;
}
.tour-signup-swipe {
width: 100%;
height: 100%;
}
.tour-signup-swipe img {
display: block;
width: 100%;
height: 193px;
object-fit: cover;
}
.tour-signup-banner::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(5, 83, 143, 0.72) 0%, rgba(5, 83, 143, 0.32) 48%, rgba(5, 83, 143, 0.08) 100%);
pointer-events: none;
}
.tour-signup-banner__text {
position: absolute;
left: 16px;
right: 16px;
bottom: 20px;
z-index: 1;
}
.tour-signup-title {
font-size: 22px;
font-weight: 700;
line-height: 1.35;
}
.tour-signup-subtitle {
margin-top: 8px;
color: rgba(255, 255, 255, 0.86);
font-size: 13px;
line-height: 1.5;
}
.tour-signup-card {
margin: 10px 12px 0;
display: flex;
flex-direction: column;
min-height: calc(100vh - 160px);
max-height: calc(100vh - 160px);
margin: 0;
padding: 14px 14px 16px;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
box-sizing: border-box;
}
.tour-signup-card__header {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid #eef2f7;
}
.tour-signup-card__title {
color: #111827;
font-size: 17px;
font-weight: 700;
line-height: 1.4;
}
.tour-signup-card__year {
flex-shrink: 0;
color: #0f74bc;
@@ -91,13 +40,19 @@ layout("/layouts/platform_h5.html"){
}
.tour-signup-notice {
margin-top: 14px;
color: #334155;
font-size: 15px;
line-height: 1.8;
word-break: break-word;
}
.tour-signup-card__body {
flex: 1;
min-height: 0;
margin-top: 14px;
overflow-y: auto;
}
.tour-signup-notice /deep/ img,
.tour-signup-notice /deep/ video {
max-width: 100%;
@@ -140,7 +95,10 @@ layout("/layouts/platform_h5.html"){
}
.tour-signup-empty {
padding: 34px 0 26px;
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.tour-signup-footer {
@@ -156,48 +114,38 @@ layout("/layouts/platform_h5.html"){
</style>
<div id="app" class="tour-signup-h5">
<van-nav-bar title="疗休养报名" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
<div class="tour-signup-banner">
<van-swipe class="tour-signup-swipe" :autoplay="3500" indicator-color="white">
<van-swipe-item v-for="item in bannerList" :key="item">
<img :src="item" alt="疗休养报名">
</van-swipe-item>
</van-swipe>
<div class="tour-signup-banner__text">
<div class="tour-signup-title">疗休养报名</div>
</div>
</div>
<van-nav-bar title="须知提醒" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
<div class="tour-signup-card">
<div class="tour-signup-card__header">
<div class="tour-signup-card__title">服务须知</div>
<div class="tour-signup-card__year">{{ setting.year || currentYear }}年度</div>
</div>
<van-skeleton title :row="8" :loading="loading">
<div v-if="setting.serviceNotice" class="tour-signup-notice">
<div v-if="serviceNoticePdfHref" class="tour-pdf-preview">
<div v-if="pdfPageCount" class="tour-pdf-toolbar">
已加载 {{ pdfRenderedPages }} / {{ pdfPageCount }} 页
<div class="tour-signup-card__body">
<van-skeleton title :row="8" :loading="loading">
<div v-if="setting.serviceNotice" class="tour-signup-notice">
<div v-if="serviceNoticePdfHref" class="tour-pdf-preview">
<div v-if="pdfPageCount" class="tour-pdf-toolbar">
已加载 {{ pdfRenderedPages }} / {{ pdfPageCount }} 页
</div>
<div ref="pdfPreviewContainer" class="tour-pdf-pages"></div>
<div v-if="pdfLoading && pdfRenderedPages === 0" class="tour-pdf-status">
<van-loading size="22px" vertical>正在加载第一页...</van-loading>
</div>
<div v-else-if="pdfLoading" class="tour-pdf-status">
正在继续加载后续页面...
</div>
<div v-if="pdfError" class="tour-pdf-status">{{ pdfError }}</div>
</div>
<div ref="pdfPreviewContainer" class="tour-pdf-pages"></div>
<div v-if="pdfLoading && pdfRenderedPages === 0" class="tour-pdf-status">
<van-loading size="22px" vertical>正在加载第一页...</van-loading>
</div>
<div v-else-if="pdfLoading" class="tour-pdf-status">
正在继续加载后续页面...
</div>
<div v-if="pdfError" class="tour-pdf-status">{{ pdfError }}</div>
<div v-else v-html="setting.serviceNotice"></div>
</div>
<div v-else v-html="setting.serviceNotice"></div>
</div>
<van-empty v-else class="tour-signup-empty" description="暂无服务须知"></van-empty>
</van-skeleton>
<van-empty v-else class="tour-signup-empty" description="暂无须知"></van-empty>
</van-skeleton>
</div>
</div>
<div class="tour-signup-footer">
<van-button block type="info" color="#0f74bc" round @click="confirmRead">我已阅读</van-button>
<van-button block type="info" color="#0f74bc" round @click="confirmRead">我已知晓</van-button>
</div>
</div>
@@ -217,10 +165,6 @@ layout("/layouts/platform_h5.html"){
pdfPageCount: 0,
pdfRenderedPages: 0,
pdfRenderToken: 0,
bannerList: [
"/assets/platform/images/tour/tour-h5-banner-1.jpg",
"/assets/platform/images/tour/tour-h5-banner-2.jpg"
],
setting: {
year: "",
configName: "",
@@ -379,7 +323,7 @@ layout("/layouts/platform_h5.html"){
this.renderServiceNoticePdf()
})
} else {
vant.Toast(res.msg || "服务须知加载失败")
vant.Toast(res.msg || "须知加载失败")
}
}).catch(() => {
this.loading = false
@@ -200,7 +200,7 @@ layout("/layouts/platform_h5.html"){
</div>
<div class="tour-detail-meta-row">
<span class="tour-detail-meta-label"><span>联系方式</span><span></span></span>
<span class="tour-detail-meta-value">{{ lineDetail.contactPhone || signupDetail.contactPhone || '暂无' }}</span>
<span class="tour-detail-meta-value">{{ signupDetail.contactPhone || lineDetail.contactPhone || '暂无' }}</span>
</div>
</div>
</div>
@@ -306,7 +306,7 @@ layout("/layouts/platform_h5.html"){
}
return this.canModifySignup() ? "修改报名" : "审核中"
}
return "我要核对信息"
return "我要报名"
},
detailActionType() {
return this.canRevokeSignup() ? "danger" : "info"
@@ -462,7 +462,7 @@ layout("/layouts/platform_h5.html"){
</style>
<div id="app" class="tour-line-page">
<van-nav-bar title="疗休养线路" left-text="返回" left-arrow @click-left="historyBack('/platform/tour/signup/h5')" fixed placeholder></van-nav-bar>
<van-nav-bar title="疗休养线路" left-text="返回" left-arrow @click-left="goHome" fixed placeholder></van-nav-bar>
<div class="tour-line-toolbar">
<div class="tour-line-search-row">
@@ -668,6 +668,9 @@ layout("/layouts/platform_h5.html"){
}
},
methods: {
goHome() {
window.location.replace("/platform/h5/home")
},
thumbUrl(row) {
const thumb = this.resolveThumbPath(row && (row.lineMobileThumb || row.agencyMobileThumb))
if (!thumb) {
@@ -817,7 +820,7 @@ layout("/layouts/platform_h5.html"){
}
vant.Dialog.confirm({
title: "提示",
message: "是否确认退出本次疗休,退出后将取消报名资格!",
message: "是否确认退出本次疗休,退出后将取消报名资格!",
confirmButtonColor: "#ee2f2f"
}).then(() => {
this.$axios.post("/platform/tour/signup/h5/doLeaveTour", { assignmentId: row.assignmentId }).then((res) => {
@@ -837,7 +840,7 @@ layout("/layouts/platform_h5.html"){
}
vant.Dialog.confirm({
title: "提示",
message: "是否确认取消本线路?",
message: "是否确认取消本线路报名",
confirmButtonColor: "#ee2f2f"
}).then(() => {
this.$axios.post("/platform/tour/signup/h5/doCancelLine", { ledgerId: row.ledgerId }).then((res) => {
@@ -73,7 +73,7 @@ const home = {
</div>
</div>
<div class="policy-panel">
<div class="policy-panel" @click="enterPolicy">
<div class="policy-panel__title">政策文件</div>
<div class="policy-panel__action">
<span>更多</span>
@@ -120,6 +120,7 @@ const home = {
return {
activityOptions: [],
quickEntries: [],
disabledHomeModules: ["劳模先进", "普惠信息", "我的课堂", "政策文件"],
classroomCourses: [],
classroomCourseIndex: 0,
classroomCourseTimer: null,
@@ -374,16 +375,40 @@ const home = {
}, 3000)
},
menuClick(item) {
if (this.isHomeModuleDisabled(item && item.name)) {
this.showDisabledHomeModuleNotice()
return
}
this.$pjaxReplace(item.href)
},
featureClick(item) {
if (this.isHomeModuleDisabled(item && item.name)) {
this.showDisabledHomeModuleNotice()
return
}
if (item.href) {
this.$pjaxReplace(item.href)
}
},
enterClassroom() {
if (this.isHomeModuleDisabled("我的课堂")) {
this.showDisabledHomeModuleNotice()
return
}
this.$pjaxReplace("/platform/learning/course/h5")
},
enterPolicy() {
if (this.isHomeModuleDisabled("政策文件")) {
this.showDisabledHomeModuleNotice()
}
},
isHomeModuleDisabled(moduleName) {
// 首页临时停用模块统一在点击入口拦截,保留原模块展示和后台数据配置。
return moduleName && this.disabledHomeModules.indexOf(moduleName) !== -1
},
showDisabledHomeModuleNotice() {
this.$toast("暂未启用")
},
bannerChange(index) {
this.activeBannerIndex = index
},