华住会更新人员整改

This commit is contained in:
2026-10-20 12:09:27 +08:00
parent 17fc9c7c83
commit aae28a6b4b
33 changed files with 2228 additions and 409 deletions
@@ -85,6 +85,9 @@ public class MainLauncher {
init_task();
init_auth();
ioc.get(Globals.class);
// Globals 会清空并恢复系统任务,必须等它完成后再恢复华住未结束批次;新批次由按钮提交时创建调度。
log.info("[华住同步][步骤=全局任务初始化完成] 开始检查待恢复批次");
ioc.get(com.budwk.app.zhgh.huazhu.service.impl.HuaZhuEmployeeServiceImpl.class).restorePendingSyncTask();
ioc.get(FlowEngine.class);
ioc.get(GlobalMessageSendService.class);
@@ -6,6 +6,8 @@ package com.budwk.app.base.constant;
public class RedisConstant {
//项目统一前缀
public final static String PLATFORM_REDIS_PREFIX = "budwk5mini:";
/** 与原华住认证服务一致,缓存 HuaZhuAuthTokenResultDTO 的 JSON。 */
public static final String REDIS_KEY_HUAZHU_AUTH_TOKEN = PLATFORM_REDIS_PREFIX + "hz:token:";
public final static String PLATFORM_REDIS_WKCACHE_PREFIX = PLATFORM_REDIS_PREFIX + "wkcache:";
//聊天室
@@ -84,7 +84,8 @@ public class SysDataUnitPullServiceImpl extends BaseServiceImpl<Sys_unit> implem
}
for (Sys_unit unit : units) {
unit.setParentId("0");
// 当前系统学校根单位 ID 为 1,同步的二级单位统一挂在该节点下。
unit.setParentId("1");
unit.setUnitLevel(2);
unit.setUnitTypeCode(1);
if (unitIds.contains(unit.getId())) {
@@ -92,7 +93,7 @@ public class SysDataUnitPullServiceImpl extends BaseServiceImpl<Sys_unit> implem
} else {
Map<String, Object> beanMap = BeanUtil.beanToMap(unit);
beanMap.remove("child");
sysUnitService.save(unit, "0");
sysUnitService.save(unit, "1");
}
}
@@ -21,11 +21,13 @@ import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatis
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.Static;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
@@ -370,11 +372,27 @@ public class TrainSignUpApplyController {
}
}
/**
* 接收本次报名信息并校验手机号必填及11位格式,PC 与 H5 共用此入口。
*
* @param trainSignUpUser 报名参数:activityId 为活动ID,courseId 为课程ID,mobile 为必填的11位大陆手机号;
* 限额课程通过 activityCourseId 指定时段,mobileColumnsValue 为动态字段及其填写值列表
* @return Result,code 为结果状态码,msg 为手机号校验或报名处理的结果说明
*/
@At
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("活动报名")
@SaCheckPermission(value = {"trainSignUp.apply", "h5.trainSignUp.apply"}, mode = SaMode.OR)
@SLog(tag = "品牌活动-活动报名", msg = "活动报名")
public Result doSignUp(TrainSignUpUser trainSignUpUser) {
// 在进入报名处理前拦截缺失、空串及纯空白手机号,避免绕过页面必填校验。
if (trainSignUpUser == null || StrUtil.isBlank(trainSignUpUser.getMobile())) {
return Result.error("请填写手机号");
}
// 与 PC、H5 使用同一规则,仅接受1开头、第二位为3至9的11位数字。
if (!trainSignUpUser.getMobile().matches("^1[3-9][0-9]{9}$")) {
return Result.error("请输入正确的11位手机号");
}
try {
lock.lock();
boolean courseByUser = trainSignUpActivityService.isSignCourseByUser(trainSignUpUser.getCourseId(), SecurityUtil.getUserId());
@@ -22,7 +22,6 @@ import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatis
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.nutz.dao.Chain;
@@ -188,6 +187,12 @@ public class TrainSignUpActivityStatisticsController {
return Result.success();
}
/**
* 导出指定活动的报名人员,按课程分别生成工作表,包含手机号等基础信息及该课程的动态报名字段。
*
* @param activityId 要导出报名人员的活动ID
* @param response 接收Excel下载内容的HTTP响应;方法无返回值,输出.xlsx格式的报名人员名单
*/
@At
@Ok("void")
@ApiOperation("导出签到名单")
@@ -237,9 +242,10 @@ public class TrainSignUpActivityStatisticsController {
for (TrainSignUpCourse c : courseList) {
String k = c.getCourseName();
List<NutMap> v = userList.stream().filter(x -> StrUtil.isNotBlank(x.getString("courseName")) && x.getString("courseName").equals(k)).collect(Collectors.toList());
ExportParams userExportParams = new ExportParams();
userExportParams.setSheetName(k);
userExportParams.setType(ExcelType.HSSF);
ExportParams exportParams = new ExportParams();
// 工作表参数与工作簿统一使用XSSF,确保导出的.xlsx文件格式一致。
exportParams.setType(ExcelType.XSSF);
exportParams.setSheetName(k);
List<ExcelExportEntity> currentEntities = new ArrayList<>();
currentEntities.addAll(excelCommonExportEntity);
@@ -285,20 +291,19 @@ public class TrainSignUpActivityStatisticsController {
Map<String, Object> userExportMap = new HashMap<>();
userExportMap.put("name", k);
userExportParams.setType(ExcelType.HSSF);
userExportMap.put("title", userExportParams);
userExportMap.put("title", exportParams);
userExportMap.put("entity", currentEntities);
userExportMap.put("data", v);
sheetsList.add(userExportMap);
}
Workbook workbook = new HSSFWorkbook();
Workbook workbook = new XSSFWorkbook();
for (Map<String, Object> map : sheetsList) {
ExcelExportService service = new ExcelExportService();
service.createSheetForMap(workbook, (ExportParams) map.get("title"), (List<ExcelExportEntity>) map.get("entity"), (Collection<?>) map.get("data"));
}
CommonDownloadUtil.download(activity.getActivityName() + "报名人员名单" + ".xls", workbook, response);
CommonDownloadUtil.download(activity.getActivityName() + "报名人员名单" + ".xlsx", workbook, response);
} catch (Exception e) {
e.printStackTrace();
}
@@ -557,6 +557,12 @@ public class ProposalExportServiceImpl extends BaseServiceImpl<ProposalInfo> imp
return ExcelExportUtil.exportExcel(exportParams, exportEntities, list);
}
/**
* 按综合导出查询条件将提案逐份生成为 DOCX,再完整打包为 ZIP。
*
* @param pageForm 综合导出页面的筛选参数,用于筛选提案名称、编号、单位等条件
* @param response HTTP 响应,成功时直接输出 ZIP 二进制,包内每个 DOCX 对应一条提案
*/
@Override
public void exportProposalStatisticsAsZip(ProposalQueryComprehensiveParam pageForm, HttpServletResponse response) {
Sql sql = Sqls.create("""
@@ -574,32 +580,38 @@ public class ProposalExportServiceImpl extends BaseServiceImpl<ProposalInfo> imp
""");
Cnd cnd = Cnd.NEW();
ProposalQueryComprehensiveParam.buildSearch(cnd, pageForm);
// 同一提案人可有多届代表记录,关联查询后按提案 ID 去重,避免重复生成同一提案。
cnd.groupBy("info.id");
sql.setCondition(cnd);
List<NutMap> list = listMap(sql);
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ZipOutputStream zipOutputStream = new ZipOutputStream(bos);
for (NutMap proposalRow : list) {
try (ByteArrayOutputStream docxByteArrayOutputStream = new ByteArrayOutputStream()) {
exportProposalAsDocx(proposalRow.getString("id"), docxByteArrayOutputStream);
ZipEntry zipEntry = new ZipEntry(proposalRow.getString("code") + "-" + sanitizeZipFileName(proposalRow.getString("name")) + ".docx");
zipOutputStream.putNextEntry(zipEntry);
docxByteArrayOutputStream.writeTo(zipOutputStream);
zipOutputStream.closeEntry();
} catch (IOException e) {
// 处理单个文件生成失败的情况
throw new RuntimeException("zip entry write failed, proposalId=" + proposalRow.getString("id"), e);
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
LinkedHashSet<String> zipEntryNames = new LinkedHashSet<>();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(bos)) {
for (NutMap proposalRow : list) {
try (ByteArrayOutputStream docxByteArrayOutputStream = new ByteArrayOutputStream()) {
exportProposalAsDocx(proposalRow.getString("id"), docxByteArrayOutputStream);
String baseEntryName = proposalRow.getString("code") + "-" + sanitizeZipFileName(proposalRow.getString("name"));
String entryName = baseEntryName + ".docx";
int sameNameIndex = 2;
// 不同提案可能同名,或清理特殊字符后重名;按最终文件名检查,保留全部提案。
while (!zipEntryNames.add(entryName)) {
entryName = baseEntryName + "-" + sameNameIndex++ + ".docx";
}
zipOutputStream.putNextEntry(new ZipEntry(entryName));
docxByteArrayOutputStream.writeTo(zipOutputStream);
zipOutputStream.closeEntry();
} catch (Exception e) {
throw new RuntimeException("zip entry write failed, proposalId=" + proposalRow.getString("id"), e);
}
}
}
zipOutputStream.finish();
zipOutputStream.close();
// 压缩流成功关闭、ZIP 目录完整写入后再下载,任一提案失败时不输出部分压缩包。
CommonDownloadUtil.download("提案汇总压缩包.zip", bos.toByteArray(), response);
} catch (Exception e) {
e.printStackTrace();
log.error("批量导出提案表 ZIP 失败", e);
throw new BaseException("批量导出提案表失败,请联系管理员");
}
}
@@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm;
import com.budwk.app.zhgh.huazhu.param.HuaZhuSyncPageForm;
import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
@@ -37,12 +38,16 @@ public class HuaZhuManageController {
@At
@SaCheckPermission("huazhu.manage")
public Result pageData(@Valid HuaZhuEmployeePageForm pageForm) {
return Result.success(huaZhuEmployeeService.pageUsers(pageForm));
try {
return Result.success(huaZhuEmployeeService.pageUsers(pageForm));
} catch (Exception e) {
return Result.error("读取待推送员工失败,请稍后重试");
}
}
/**
* @param userIds 页面勾选员工的本项目用户主键数组,至少传入一个主键
* @return Result,data 为逐人同步结果,含 userId、loginName、userName、success 和 message
* @return Result,data 为已创建批次,含 id、total、status、submittedAt;失败原因在 msg
*/
@At
@Aop(TransAop.READ_COMMITTED)
@@ -51,6 +56,7 @@ public class HuaZhuManageController {
if (userIds == null || userIds.length == 0) {
return Result.error("请先选择需要同步的员工");
}
// 写入异常交给统一异常处理器;不在事务内部吞掉异常,保证批次与人员一起回滚。
return Result.success(huaZhuEmployeeService.syncUsers(Arrays.asList(userIds)));
}
@@ -65,24 +71,45 @@ public class HuaZhuManageController {
return Result.success(NutMap.NEW().addv("description", huaZhuEmployeeService.getSyncRuleDescription()));
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
} catch (Exception e) {
return Result.error("读取同步规则失败,请稍后重试");
}
}
/**
* 无需请求参数;由服务端按固定业务规则筛选全部待同步人员。
* @return Result,data 为逐人同步结果,含 userId、loginName、userName、success 和 message;无人符合时为空列表
* @return Result,data 为已创建批次,含 id、total、status、submittedAt;失败原因在 msg;无人需要推送时返回中文错误
*/
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("huazhu.manage")
public Result syncRuleUsers() {
return Result.success(huaZhuEmployeeService.syncRuleUsers());
}
/** taskId 为可选批次主键;data 含 task、counts、tasks、running,不触发推送。 */
@At
@SaCheckPermission("huazhu.manage")
public Result syncSummary(@Param("taskId") String taskId) {
try {
return Result.success(huaZhuEmployeeService.syncRuleUsers());
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
return Result.success(huaZhuEmployeeService.syncSummary(taskId));
} catch (Exception e) {
return Result.error("读取推送进度失败,请稍后重试");
}
}
/** pageForm 含分页及可选批次、状态、工号/姓名;data.list 为逐人回执,totalCount 为总条数。 */
@At
@SaCheckPermission("huazhu.manage")
public Result recordPageData(@Valid HuaZhuSyncPageForm pageForm) {
try {
return Result.success(huaZhuEmployeeService.pageRecords(pageForm));
} catch (Exception e) {
return Result.error("读取推送记录失败,请稍后重试");
}
}
/** userId 为本项目员工主键;data 为华住实时状态,msg 为可直接显示的中文错误。 */
@At
@SaCheckPermission("huazhu.manage")
public Result queryStatus(@Param("userId") String userId) {
@@ -0,0 +1,18 @@
package com.budwk.app.zhgh.huazhu.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
/** 沿用原系统的认证响应对象及字段映射,同时用于 Redis 缓存序列化。 */
@Data
public class HuaZhuAuthTokenResultDTO {
/** 华住返回的裸 Token,调用接口时再拼接 Bearer 前缀。 */
@JSONField(name = "access_token")
private String accessToken;
@JSONField(name = "token_type")
private String tokenType;
/** 华住返回的有效时间,单位秒;保持原 DTO 的字符串类型。 */
@JSONField(name = "expires_in")
private String expiresIn;
private String scope;
}
@@ -0,0 +1,43 @@
package com.budwk.app.zhgh.huazhu.job;
import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.log.Log;
import org.nutz.log.Logs;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
/** 固定后台入口;同一 Job 串行运行整批人员,页面离开不会中断执行。 */
@IocBean
@DisallowConcurrentExecution
public class HuaZhuSyncJob implements Job {
private static final Log log = Logs.get();
@Inject
private HuaZhuEmployeeService huaZhuEmployeeService;
/** context 为 Quartz 触发上下文;仅调用业务服务,不在 Job 中编排人员或开启整批事务。 */
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
String fireId = context == null ? "-" : context.getFireInstanceId();
long started = System.nanoTime();
log.infof("[华住同步][步骤=调度触发] 开始,fireInstanceId=%s", fireId);
if (huaZhuEmployeeService == null) {
// 工厂回退为普通实例会丢失 IOC 注入,此日志明确区别于触发器没有触发。
log.error("[华住同步][步骤=检查服务注入] 失败,Job 未获得华住服务,请检查 Quartz 的 IOC JobFactory 配置");
throw new JobExecutionException("华住后台服务未注入", false);
}
try {
huaZhuEmployeeService.runPendingTask();
} catch (Exception e) {
log.errorf("[华住同步][步骤=后台调用] 异常,fireInstanceId=%s,类型=%s,栈=%s", fireId,
e.getClass().getName(), java.util.Arrays.toString(e.getStackTrace()));
// 不携带可能含认证信息的原异常消息,不立即重发推送。
throw new JobExecutionException("华住后台调用异常,请查看阶段日志", false);
} finally {
log.infof("[华住同步][步骤=调度结束] fireInstanceId=%s,耗时=%dms", fireId, (System.nanoTime() - started) / 1_000_000L);
}
}
}
@@ -0,0 +1,38 @@
package com.budwk.app.zhgh.huazhu.models;
import lombok.Data;
import org.nutz.dao.entity.annotation.*;
/** 每次推送的人员快照与回执;不保存认证 token、卡密码或请求正文。 */
@Data
@Table("huazhu_sync_record")
@TableMeta("{'mysql-charset':'utf8mb4','mysql-engine':'InnoDB'}")
// 同批次及同一员工均可存在多条相同状态的记录;这两个索引仅用于查询,不能限制唯一性。
@TableIndexes({@Index(name = "IDX_HUAZHU_TASK_STATUS", fields = {"taskId", "status"}, unique = false),
@Index(name = "IDX_HUAZHU_USER_STATUS", fields = {"userId", "status"}, unique = false)})
public class HuaZhuSyncRecord {
@Name
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String id;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String taskId;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
private String loginName;
@Column
private String userName;
/** PENDING 待推送、SENDING 发送中、SUCCESS 推送成功、EXISTS 华住已存在、FAILED 明确失败、UNKNOWN 待核实。 */
@Column
private String status;
@Column
@ColDefine(type = ColType.TEXT)
private String message;
@Column
private Long submittedAt;
@Column
private Long finishedAt;
}
@@ -0,0 +1,34 @@
package com.budwk.app.zhgh.huazhu.models;
import lombok.Data;
import org.nutz.dao.entity.annotation.*;
/** 华住推送批次。activeSlot 唯一约束防止管理员重复提交;后台按当前单实例 Quartz 配置串行执行。 */
@Data
@Table("huazhu_sync_task")
@TableMeta("{'mysql-charset':'utf8mb4','mysql-engine':'InnoDB'}")
@TableIndexes({@Index(name = "UK_HUAZHU_ACTIVE_SLOT", fields = {"activeSlot"}, unique = true)})
public class HuaZhuSyncTask {
@Name
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String id;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String activeSlot;
/** SELECTED 为勾选同步,RULE 为规则同步。 */
@Column
private String source;
/** RUNNING 为已提交待后台处理或处理中,COMPLETED 为遍历结束,FAILED 为批次异常中止。 */
@Column
private String status;
@Column
private Integer total;
@Column
private Long submittedAt;
@Column
private Long finishedAt;
/** 兼容旧表保留的租约字段;现由固定 Quartz Job 串行执行,不再按逐人租约领取。 */
@Column
private Long leaseUntil;
}
@@ -0,0 +1,13 @@
package com.budwk.app.zhgh.huazhu.param;
import com.budwk.app.base.param.PageForm;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 推送记录分页:taskId 为空查全部批次,status 为空查全部状态,searchKeyword 匹配工号或姓名。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class HuaZhuSyncPageForm extends PageForm {
private String taskId;
private String status;
}
@@ -0,0 +1,9 @@
package com.budwk.app.zhgh.huazhu.service;
import com.budwk.app.zhgh.huazhu.dto.HuaZhuAuthTokenResultDTO;
/** 华住认证,保留原系统 getToken 接口。 */
public interface HuaZhuAuthService {
/** 无参数;优先读取缓存,否则请求认证。返回 DTO,accessToken 为裸 Token,失败抛出异常。 */
HuaZhuAuthTokenResultDTO getToken();
}
@@ -3,7 +3,9 @@ package com.budwk.app.zhgh.huazhu.service;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm;
import com.budwk.app.zhgh.huazhu.vo.HuaZhuEmployeeStatus;
import com.budwk.app.zhgh.huazhu.vo.HuaZhuSyncResult;
import com.budwk.app.zhgh.huazhu.models.HuaZhuSyncTask;
import com.budwk.app.zhgh.huazhu.models.HuaZhuSyncRecord;
import com.budwk.app.zhgh.huazhu.param.HuaZhuSyncPageForm;
import org.nutz.lang.util.NutMap;
import java.util.List;
@@ -25,9 +27,9 @@ public interface HuaZhuEmployeeService {
* 将选中的本项目员工同步到华住。
*
* @param userIds 本项目用户主键列表
* @return 每位员工的同步结果
* @return 已创建批次,含 id、total、status 和 submittedAt;实际推送在后台进行
*/
List<HuaZhuSyncResult> syncUsers(List<String> userIds);
HuaZhuSyncTask syncUsers(List<String> userIds);
/**
* 获取规则同步的中文说明,无需传参;单位名称从当前单位表读取,缺失时拒绝继续同步。
@@ -39,9 +41,33 @@ public interface HuaZhuEmployeeService {
/**
* 同步全部符合规则且未删除的员工,无需传参,不受页面搜索、分页或勾选范围限制。
*
* @return 每位员工的工号、姓名、success 成功标记和 message 结果说明;无人符合时返回空列表
* @return 已创建批次;无人需要推送时抛出中文业务异常
*/
List<HuaZhuSyncResult> syncRuleUsers();
HuaZhuSyncTask syncRuleUsers();
/** 无参数、无返回值;后台顺序推送一个批次,明确认证失败时刷新 Token 并重试当前人员一次,仍失败则停止批次。 */
void runPendingTask();
/** 无参数;返回活动批次或 null,将上次中断的 SENDING 转待核实,禁止自动重发。 */
HuaZhuSyncTask prepareBatch();
/** recordId 为记录主键、taskId 为批次主键;返回是否将待推送记录成功标为发送中。 */
boolean markSending(String recordId, String taskId);
/** record 为发送结果;单独事务保存,状态不匹配或保存失败时抛出异常。 */
void finishRecord(HuaZhuSyncRecord record);
/** taskId 为完成批次主键;确认无待处理人员后结束批次,无返回值。 */
void completeBatch(String taskId);
/** taskId 为异常批次、reason 为中文原因;保留已完成结果,结束剩余记录及批次。 */
void failBatch(String taskId, String reason);
/** taskId 可为空,返回最近/指定批次、状态计数、最近批次及后台是否运行。 */
NutMap syncSummary(String taskId);
/** pageForm 提供分页及可选批次、状态、关键词;返回每人的持久化推送记录。 */
Pagination<HuaZhuSyncRecord> pageRecords(HuaZhuSyncPageForm pageForm);
/**
* 查询指定本项目员工在华住的实时状态。
@@ -0,0 +1,78 @@
package com.budwk.app.zhgh.huazhu.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.zhgh.huazhu.constant.HuaZhuConstant;
import com.budwk.app.zhgh.huazhu.dto.HuaZhuAuthTokenResultDTO;
import com.budwk.app.zhgh.huazhu.service.HuaZhuAuthService;
import org.apache.commons.lang3.StringUtils;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.integration.jedis.RedisService;
import org.nutz.log.Log;
import org.nutz.log.Logs;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/** 原认证方法仅增加阶段日志和响应关闭,认证参数、DTO 转换及缓存内容保持原写法。 */
@IocBean
public class HuaZhuAuthServiceImpl implements HuaZhuAuthService {
private static final Log log = Logs.get();
@Inject
private RedisService redisService;
/** 无参数;返回原 DTO。缓存 58 分钟,认证请求 20 秒超时,日志不输出认证头或 Token。 */
@Override
@Aop(TransAop.READ_COMMITTED)
public HuaZhuAuthTokenResultDTO getToken() {
String step = "读取Token缓存";
long started = System.nanoTime();
try {
log.info("[华住认证][步骤=读取Token缓存] 开始");
String token = redisService.get(RedisConstant.REDIS_KEY_HUAZHU_AUTH_TOKEN);
if (StringUtils.isNotBlank(token)) {
step = "缓存转Token对象";
log.info("[华住认证][步骤=缓存转Token对象] 开始,命中缓存");
return JSONUtil.toBean(token, HuaZhuAuthTokenResultDTO.class);
}
step = "构建Basic认证请求";
log.info("[华住认证][步骤=构建Basic认证请求] 开始");
String url = String.format("%s%s", HuaZhuConstant.OAUTH2_URL, "/oauth/token?scope=ALL&grant_type=client_credentials");
String sign = StringUtils.join("Basic ", Base64.getEncoder().encodeToString(String.format("%s:%s", HuaZhuConstant.CLIENT_ID, HuaZhuConstant.CLIENT_SECRET).getBytes(StandardCharsets.UTF_8)));
step = "请求华住Token";
log.info("[华住认证][步骤=请求华住Token] 开始,超时=20000ms");
try (HttpResponse response = HttpRequest.post(url).header("Authorization", sign).timeout(20000).execute()) {
log.infof("[华住认证][步骤=请求华住Token] 收到响应,HTTP=%d", response.getStatus());
if (response.isOk()) {
step = "读取并转换认证响应";
log.info("[华住认证][步骤=读取并转换认证响应] 开始");
String body = response.body();
HuaZhuAuthTokenResultDTO tokenResultDTO = JSONUtil.toBean(body, HuaZhuAuthTokenResultDTO.class);
String newToken = tokenResultDTO.getAccessToken();
if (StrUtil.isBlank(newToken)) {
throw new RuntimeException("请求华住认证接口失败,返回的Token为空");
}
step = "缓存Token对象";
log.info("[华住认证][步骤=缓存Token对象] 开始,有效期=3480秒");
redisService.setex(RedisConstant.REDIS_KEY_HUAZHU_AUTH_TOKEN, 60 * 58, JSONUtil.toJsonStr(tokenResultDTO));
return tokenResultDTO;
} else {
throw new RuntimeException("请求华住认证接口失败,状态码:" + response.getStatus());
}
}
} catch (RuntimeException e) {
// 认证异常原文可能包含响应正文,只记录类型和位置,业务层另行返回受控中文原因。
log.errorf("[华住认证][步骤=%s] 失败,异常类型=%s,位置=%s", step, e.getClass().getName(),
e.getStackTrace().length == 0 ? "无" : e.getStackTrace()[0]);
throw e;
} finally {
log.infof("[华住认证][步骤=%s] 调用结束,耗时=%dms", step, (System.nanoTime() - started) / 1_000_000L);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -81,10 +81,10 @@ public class CondolenceApplyController {
}
/**
* 保存慰问申报截止日期,服务层会再次校验当前用户必须为系统管理员。
* 保存慰问申报截止日期,服务层会再次校验当前用户必须为系统管理员或校工会管理员。
*
* @param endDate 截止日期,格式为 yyyy-MM-dd
* @return 保存成功结果
* @return Result,保存成功时返回成功状态和“设置成功”提示
*/
@At
@ApiOperation("保存慰问申报截止日期")
@@ -22,7 +22,7 @@ public interface CondolenceService extends BaseService<Condolence> {
String getApplyEndDate();
/**
* 保存慰问申报截止日期,仅允许系统管理员操作。
* 保存慰问申报截止日期,仅允许系统管理员或校工会管理员操作。
*
* @param endDate 截止日期,格式为 yyyy-MM-dd
*/
@@ -90,15 +90,15 @@ public class CondolenceServiceImpl extends BaseServiceImpl<Condolence> implement
}
/**
* 保存慰问申报截止日期。参数必须是 yyyy-MM-dd,且仅 SYSADMIN 可以修改。
* 保存慰问申报截止日期。参数必须是 yyyy-MM-dd,仅系统管理员或校工会管理员可以修改。
*
* @param endDate 截止日期,格式为 yyyy-MM-dd
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void saveApplyEndDate(String endDate) {
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name())) {
throw new BaseException("仅系统管理员可以设置慰问申报时间");
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
throw new BaseException("仅系统管理员或校工会管理员可以设置慰问申报时间");
}
parseApplyEndDate(endDate);
Sys_config config = sysConfigService.fetch(APPLY_END_DATE_CONFIG_KEY);
@@ -1,11 +1,14 @@
package com.budwk.app.zhgh.staffbenefit.difficultSubsidy.controller.process;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.models.DifficultSubsidyApply;
import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.service.DifficultSubsidyService;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
@@ -36,9 +39,32 @@ public class DifficultSubsidyApplyController {
public void form() {
}
/**
* 无请求参数;返回 Result,data 为 yyyy-MM-dd 截止日期,空字符串表示未设置。
* 审核修改复用本表单,因此允许申请或校工会审核权限读取。
*/
@At
@SaCheckPermission(value = {"difficultSubsidy.apply", "difficultSubsidy.schoolAudit"}, mode = SaMode.OR)
public Result getApplyEndDate() {
return Result.success().addData(difficultSubsidyService.getApplyEndDate());
}
/**
* @param endDate yyyy-MM-dd 格式的申报截止日期;允许设置的角色由 service 统一校验
* @return Result 成功状态,不附加业务数据
*/
@At
@SaCheckPermission("difficultSubsidy.apply")
@Aop(TransAop.READ_COMMITTED)
public Result saveApplyEndDate(@Param("endDate") String endDate) {
difficultSubsidyService.saveApplyEndDate(endDate);
return Result.success();
}
@At
@Ok("json")
@SaCheckPermission("difficultSubsidy.apply")
@Aop(TransAop.READ_COMMITTED)
public Result save(@Param("data") DifficultSubsidyApply apply) {
return Result.success(difficultSubsidyService.saveDraft(apply));
}
@@ -46,6 +72,7 @@ public class DifficultSubsidyApplyController {
@At
@Ok("json")
@SaCheckPermission("difficultSubsidy.apply")
@Aop(TransAop.READ_COMMITTED)
public Result submit(@Param("data") DifficultSubsidyApply apply) {
return Result.success(difficultSubsidyService.submit(apply));
}
@@ -15,6 +15,23 @@ import java.util.List;
*/
public interface DifficultSubsidyService extends BaseService<DifficultSubsidyApply> {
/**
* @return 系统参数 DifficultSubsidyApplyEndDate 的 yyyy-MM-dd 日期,未配置时返回空字符串
*/
String getApplyEndDate();
/**
* 仅系统管理员、校工会管理员可设置特困补助申报截止日期。
* @param endDate yyyy-MM-dd 格式的截止日期,当天起停止新增申报
*/
void saveApplyEndDate(String endDate);
/**
* 校验新申请是否在申报期内,数据库中已存在的申请可继续修改和重新提交。
* @param id 申请记录ID;空值或数据库中不存在的ID均按新申请校验
*/
void checkApplyDeadline(String id);
DifficultSubsidyApply saveDraft(DifficultSubsidyApply apply);
DifficultSubsidyApply submit(DifficultSubsidyApply apply);
@@ -13,6 +13,7 @@ import com.budwk.app.base.utils.SysOfficeTemplateUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.entity.ProcessDefine;
@@ -26,6 +27,8 @@ import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.FlowCommonService;
import com.budwk.app.flow.vo.ProcessTaskVO;
import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.sys.services.SysConfigService;
import com.budwk.app.sys.models.Sys_config;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.constants.DifficultSubsidyState;
@@ -56,6 +59,8 @@ import org.nutz.lang.util.NutMap;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
@@ -78,6 +83,12 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl<DifficultSubsid
private static final String PROCESS_KEY = "TKBZ";
private static final String SCHOOL_AUDIT_TASK_NAME = "校工会审核";
private static final String START_TASK_NAME = "startTask";
// 沿用现有角色表中的特困补助审批员编码,供全校代申请范围判断使用。
private static final String SCHOOL_AUDIT_ROLE_CODE = "sp04";
private static final String APPLY_END_DATE_CONFIG_KEY = "DifficultSubsidyApplyEndDate";
@Inject
private SysConfigService sysConfigService;
@Inject
private FlowEngine flowEngine;
@@ -95,9 +106,84 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl<DifficultSubsid
super(dao);
}
/** 直接读取系统参数,保证管理员保存后下一次申请校验立即生效。 */
@Override
public String getApplyEndDate() {
Sys_config config = sysConfigService.fetch(APPLY_END_DATE_CONFIG_KEY);
return config == null ? "" : StrUtil.blankToDefault(config.getConfigValue(), "");
}
/**
* 保存申报时间配置,页面隐藏入口之外还需在服务端校验管理角色。
* @param endDate yyyy-MM-dd 格式的截止日期,截止当天即停止新增申报
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void saveApplyEndDate(String endDate) {
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
throw new BaseException("仅系统管理员和校工会管理员可以设置特困补助申报时间");
}
parseApplyEndDate(endDate);
Sys_config config = sysConfigService.fetch(APPLY_END_DATE_CONFIG_KEY);
if (config == null) {
config = new Sys_config();
config.setConfigKey(APPLY_END_DATE_CONFIG_KEY);
config.setConfigValue(endDate);
config.setNote("特困补助申请截止日期(yyyy-MM-dd)");
sysConfigService.insert(config);
} else {
config.setConfigValue(endDate);
sysConfigService.update(config);
}
}
/**
* 截止时间仅限制新申请;已有草稿和退回记录可继续办理,不信任客户端随意传入的ID。
* @param id 申请ID;只有数据库中真实存在的记录才免除新申请截止校验
*/
@Override
public void checkApplyDeadline(String id) {
if (Strings.isNotBlank(id) && dao().fetch(DifficultSubsidyApply.class, id) != null) {
return;
}
String endDate = getApplyEndDate();
if (Strings.isBlank(endDate)) {
return;
}
// 与慰问申报保持一致:截止日期当天已关闭,不按当天 23:59:59 计算。
if (!LocalDate.now().isBefore(parseApplyEndDate(endDate))) {
throw new BaseException("本次申报已截止请关注下次申报");
}
}
/** 严格校验配置格式及真实日历日期,避免错误配置被当作开放申报。 */
private LocalDate parseApplyEndDate(String endDate) {
if (Strings.isBlank(endDate)) {
throw new BaseException("申报截止日期不能为空");
}
try {
if (!endDate.matches("\\d{4}-\\d{2}-\\d{2}")) {
throw new DateTimeParseException("Invalid date format", endDate, 0);
}
return LocalDate.parse(endDate);
} catch (DateTimeParseException e) {
throw new BaseException("申报截止日期格式不正确,应为yyyy-MM-dd");
}
}
/**
* 保存本人或当前账号有权代填的特困补助申请,受助人身份和组织信息以人员资料为准。
*
* @param apply 页面表单;userId 为受助人用户ID,未选择时按当前登录人处理,其余为业务填写内容
* @return 保存后的申请实体,applyUserId 为填报人,userId 为实际受助人
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public DifficultSubsidyApply saveDraft(DifficultSubsidyApply apply) {
if (apply == null) {
throw new IllegalArgumentException("申请表单不能为空");
}
checkApplyDeadline(apply.getId());
fillApplicantSnapshot(apply);
normalizeFormCollections(apply);
if (apply.getApplyDate() == null) {
@@ -110,21 +196,18 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl<DifficultSubsid
return apply;
}
/**
* 按代申请范围搜索受助人,审批员和校级管理员可搜索全校人员,分工会管理角色限本工会。
*
* @param keyword 姓名或工号关键字,空值时返回当前可选范围内的前十人
* @return 最多十条人员信息,包含id、loginname、username、birthday、mobile、sex及单位、工会ID和名称
*/
@Override
public List<NutMap> listRecipients(String keyword) {
Sql sql = Sqls.create("SELECT id, loginname, username, birthday, mobile, sex, "
+ "unitName, unionName, unionId, unitId FROM vw_user $condition");
Cnd cnd = Cnd.NEW();
// 校工会管理员可选择全校人员,分工会管理员仅可选择本工会人员,普通用户仅可选择本人。
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(),
RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name())) {
cnd.and("unionId", "=", SecurityUtil.getUnionId());
} else {
cnd.and("id", "=", SecurityUtil.getUserId());
}
}
appendRecipientScope(cnd);
if (Strings.isNotBlank(keyword)) {
SqlExpressionGroup keywordGroup = new SqlExpressionGroup();
keywordGroup.orLike("username", keyword);
@@ -725,23 +808,58 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl<DifficultSubsid
}
/**
* 补全申请人快照。受助人的姓名、工号、单位等数据来自页面选择结果,不更新 sys_user。
* 将同一套代申请范围用于人员搜索和保存校验,避免只限制下拉选项而允许越权提交受助人ID。
*
* @param cnd 人员查询条件;审批员和校级管理员不追加限制,分工会管理角色限本工会,其他账号限本人
*/
private void appendRecipientScope(Cnd cnd) {
if (AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name(),
SCHOOL_AUDIT_ROLE_CODE)) {
return;
}
if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(),
RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name())) {
// 未配置工会时仅允许本人,不能把空工会下的其他人员纳入代申请范围。
String unionId = SecurityUtil.getUnionId();
if (Strings.isNotBlank(unionId)) {
cnd.and("unionId", "=", unionId);
return;
}
}
cnd.and("id", "=", SecurityUtil.getUserId());
}
/**
* 记录当前填报人,并核实受助人确实在该账号可选择的范围内;跨部门代填时保留受助人真实组织。
*
* @param apply 页面表单,userId为空时默认本人;姓名、工号、性别、单位和工会字段由查询结果覆盖
*/
private void fillApplicantSnapshot(DifficultSubsidyApply apply) {
if (apply == null) {
throw new IllegalArgumentException("申请表单不能为空");
}
String userId = Strings.isBlank(apply.getUserId()) ? SecurityUtil.getUserId() : apply.getUserId();
Sql sql = Sqls.create("SELECT id, loginname, username, sex, unitId, unitName, unionId, unionName "
+ "FROM vw_user $condition");
Cnd cnd = Cnd.where("id", "=", userId);
appendRecipientScope(cnd);
sql.setCondition(cnd);
List<NutMap> recipients = listMap(sql);
if (recipients.isEmpty()) {
throw new IllegalArgumentException("受助人不存在或不在当前账号可代申请的范围内");
}
NutMap recipient = recipients.get(0);
apply.setApplyUserId(SecurityUtil.getUserId());
apply.setApplyUserName(SecurityUtil.getUserUsername());
apply.setApplyLoginName(SecurityUtil.getUserLoginname());
if (Strings.isBlank(apply.getUserId())) {
apply.setUserId(apply.getApplyUserId());
apply.setUserName(apply.getApplyUserName());
apply.setLoginName(apply.getApplyLoginName());
}
if (Strings.isBlank(apply.getUnitId())) {
apply.setUnitId(SecurityUtil.getUnitId());
}
if (Strings.isBlank(apply.getUnionId())) {
apply.setUnionId(SecurityUtil.getUnionId());
}
apply.setUserId(recipient.getString("id"));
apply.setUserName(recipient.getString("username"));
apply.setLoginName(recipient.getString("loginname"));
apply.setSex(recipient.getString("sex"));
apply.setUnitId(recipient.getString("unitId"));
apply.setUnitName(recipient.getString("unitName"));
apply.setUnionId(recipient.getString("unionId"));
apply.setUnionName(recipient.getString("unionName"));
}
private void normalizeFormCollections(DifficultSubsidyApply apply) {
@@ -8,31 +8,27 @@
)
})(navigator.userAgent || navigator.vendor || window.opera)
// jQuery 回调的 this 不是 Vue 实例,使用项目已有的 Element 消息入口。
// 每个请求最多提示一次;保留响应正文,页面 then/always 继续执行并关闭 loading。
$.ajaxSetup({
contentType: "application/x-www-form-urlencoded;charset=utf-8",
success: function (response, textStatus, jqXHR) {
if (response.code !== 0) {
this.$message.error(response.msg)
} else {
return response
success: (response, textStatus, jqXHR) => {
if (response && typeof response === "object" && response.code !== undefined && response.code !== 0) {
ELEMENT.Message.error(response.msg || "请求处理失败,请稍后重试");
jqXHR.platformErrorShown = true;
}
},
error: function (v1) {
this.$message.error(v1.status + " " + v1.statusText)
error: (xhr) => {
const message = xhr.responseJSON && xhr.responseJSON.msg;
ELEMENT.Message.error(message || (xhr.status === 0 ? "网络连接失败或请求超时,请稍后重试" : "请求失败,状态码:" + xhr.status));
xhr.platformErrorShown = true;
},
complete: function (XMLHttpRequest, textStatus) {
var sessionstatus = XMLHttpRequest.getResponseHeader("loginStatus") //通过XMLHttpRequest取得响应头,sessionstatus,
if (sessionstatus == "accessDenied") {
this.$message({
message: "\u767b\u5f55\u5931\u6548\uff0c\u8bf7\u5237\u65b0\u9875\u9762\u91cd\u65b0\u767b\u5f55",
type: "error"
})
}
if (sessionstatus == "unauthorized") {
this.$message({
message: "\u6ca1\u6709\u6743\u9650",
type: "error"
})
complete: (xhr) => {
const sessionStatus = xhr.getResponseHeader("loginStatus");
if (!xhr.platformErrorShown && sessionStatus === "accessDenied") {
ELEMENT.Message.error("登录失效,请刷新页面重新登录");
} else if (!xhr.platformErrorShown && sessionStatus === "unauthorized") {
ELEMENT.Message.error("没有权限");
}
}
})
@@ -69,13 +69,17 @@ const initTableMixins = {
},
pageData(data = null) {
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
this.$axios.post(address, data ? data : this.pageForm).then((res) => {
this.tableLoading = false
this.$set(this, "tableLoading", true)
// 业务错误会由公共拦截器 reject,同样需要 finally 解除列表加载状态。
return this.$axios.post(address, data ? data : this.pageForm).then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
this.$set(this, "tableData", res.data.list)
this.$set(this.pageForm, "totalCount", res.data.totalCount)
}
}).catch(() => {
// 中文错误已由统一响应拦截器提示,避免页面重复弹出。
}).finally(() => {
this.$set(this, "tableLoading", false)
})
},
notifySuccess(msg) {
@@ -175,6 +175,12 @@ module.exports = {
default: "",
required: false
},
// 默认仅由文件选择器按 accept 过滤;启用后上传前再次校验扩展名,accept 需传 .pdf,.jpg 等白名单。
validate_accept: {
type: Boolean,
default: false,
required: false
},
// 是否是完整的结果(就是文件上传返回什么,该组件返回什么,uploadResultCategory必须为array)
complete_result: {
type: Boolean,
@@ -316,7 +322,7 @@ module.exports = {
}
},
// 这是兜底逻辑,保准只让这个image类型上传图片
// 保留原有大小限制;业务显式开启 validate_accept 时,额外按扩展名拦截不符合 accept 的文件。
beforeUpload(file) {
// 需要兼容部分业务不限制附件大小,关闭后仅跳过大小校验,不影响数量和格式控制。
@@ -324,35 +330,17 @@ module.exports = {
this.$message.error("文件过大,请上传小于" + (this.upload_size / 1024 / 1024).toFixed(0) + "M的文件!")
return false
}
if (this.validate_accept && this.fileAccept) {
const acceptTypes = this.fileAccept.split(",").map((type) => type.trim().toLowerCase())
const fileName = (file.name || "").toLowerCase()
const dotIndex = fileName.lastIndexOf(".")
// 文件名可能没有扩展名或被伪装为“申请表.pdf.docx”,始终只校验最后一个扩展名。
if (dotIndex < 0 || !acceptTypes.includes(fileName.substring(dotIndex))) {
this.$message.error("文件只能是 " + this.fileAccept + " 格式!")
return false
}
}
return true
// if (!this.fileAccept || typeof this.fileAccept !== "string") {
// return true
// }
//
// // 去除前后空格并转换为数组
// const acceptTypes = this.fileAccept
// .trim()
// .split(",")
// .map((type) => type.trim().toUpperCase())
//
// let fileType
// const dotIndex = file.name.lastIndexOf(".")
// if (dotIndex !== -1) {
// fileType = file.name.substring(dotIndex).toUpperCase()
// } else {
// this.$message.error("文件必须包含有效的扩展名!")
// return false
// }
//
// const valid = acceptTypes.includes(fileType)
// if (valid) {
// return true
// }
// this.$message.error(`文件只能是 ${this.fileAccept} 格式!`)
// return false
},
// 预览图片
@@ -33,11 +33,16 @@ const applyForm = {
<el-input readonly v-model="formData.sex"></el-input>
</el-form-item>
</el-col>
<!--<el-col :span="24">
<el-form-item label="联系方式" prop="mobile">
<el-input v-model="formData.mobile"></el-input>
<el-col :span="24">
<!-- 手机号必填,格式与后端保持一致:1开头、第二位3至9,共11位数字。 -->
<el-form-item label="手机号" prop="mobile"
:rules="[
{ required: true, whitespace: true, message: '请填写手机号', trigger: 'blur' },
{ pattern: /^1[3-9][0-9]{9}$/, message: '请输入正确的11位手机号', trigger: 'blur' }
]">
<el-input v-model="formData.mobile" maxlength="32" placeholder="请填写手机号"></el-input>
</el-form-item>
</el-col>-->
</el-col>
</el-row>
<el-row v-if="courseRow.courseIsLimitApply">
@@ -103,8 +108,8 @@ const applyForm = {
</el-form>
<el-row class="mt20" justify="end" type="flex">
<el-button @click="signDialog = false">取 消</el-button>
<el-button @click="onSubmit" type="primary">提 交</el-button>
<el-button @click="$set($data, 'signDialog', false)" :disabled="formLoading">取 消</el-button>
<el-button @click="onSubmit" type="primary" :loading="formLoading" :disabled="formLoading">提 交</el-button>
</el-row>
</el-dialog>
</div>
@@ -114,60 +119,81 @@ const applyForm = {
data() {
return {
signDialog: false,
formLoading: false,
formData: {},
mobileColumn: null,
courseRow: {},
courseTimeSelectList: [],
}
},
methods: {
async getCourseTimeSelectList(o) {
const resp = await $.post('/platform/trainSignUp/apply/getCourseTimeSelectList',{ courseId: o.id })
if (resp.code === 0) {
this.courseTimeSelectList = resp.data
} else {
this.$message.warning('获取时段信息失败,请联系管理员')
}
},
async onOpen(row, course) {
this.initData(row, course)
if (row.courseIsLimitApply) {
await this.getCourseTimeSelectList(row)
}
this.courseRow = row
this.signDialog = true
this.$nextTick(() => {
this.$refs.form.clearValidate()
/**
* o.id 为当前课程 ID;接口返回 Result,data 为含 value(时段 ID)、text(时段描述)的选项数组。
*/
getCourseTimeSelectList(o) {
this.formLoading = true
return $.post('/platform/trainSignUp/apply/getCourseTimeSelectList', { courseId: o.id }).then((resp) => {
if (resp.code === 0) {
this.courseTimeSelectList = resp.data
} else {
this.$message.warning('获取时段信息失败,请联系管理员')
}
}).always(() => {
this.formLoading = false
})
},
async validateSignUp() {
// 获取家属人数
// row 为课程记录,course 为课程类型及其动态字段配置;限额课程返回时段加载完成后的 Promise。
onOpen(row, course) {
this.initData(row, course)
const openForm = () => {
this.courseRow = row
this.signDialog = true
this.$nextTick(() => {
this.$refs.form.clearValidate()
})
}
// 限额课程先取得可报名时段,再展示表单,保持原有打开顺序。
if (row.courseIsLimitApply) return this.getCourseTimeSelectList(row).then(openForm)
openForm()
},
/**
* 传入 courseId(课程 ID)、currentFamilyNumber(动态表单填写的家属总人数),返回报名资格是否通过的 Promise<boolean>。
*/
validateSignUp() {
// 家属人数继续取动态字段 xdqsrs,空值或非数值按 0 参与报名人数校验。
let familyCount = this.formData.trainMobileSignColumnList.filter(o => o.columnCode === 'xdqsrs').reduce((sum, item) => {
return sum + (Number(item.columnValue) || 0)
}, 0)
const res = await this.$axios.post("/platform/trainSignUp/apply/validateSignUp", {
return this.$axios.post("/platform/trainSignUp/apply/validateSignUp", {
courseId: this.formData.courseId,
currentFamilyNumber: familyCount
})
return res.code === 0
}).then((res) => res.code === 0)
},
async validateCourseTime() {
const res = await this.$axios.post('/platform/trainSignUp/apply/validateSourceSignUp', {
/**
* 传入 activityCourseId(所选报名时段 ID)、courseId(课程 ID),返回时段名额校验是否通过的 Promise<boolean>。
*/
validateCourseTime() {
return this.$axios.post('/platform/trainSignUp/apply/validateSourceSignUp', {
activityCourseId: this.formData.activityCourseId,
courseId: this.courseRow.id
})
return res.code === 0
}).then((res) => res.code === 0)
},
async onSubmit() {
if(!await this.validateSignUp()) return
this.$refs["form"].validate(async (valid) => {
if (valid) {
if(this.courseRow.courseIsLimitApply && !await this.validateCourseTime()) return
this.$confirm("您确定要提交吗?", "提示", {
// 返回完整校验和报名提交链路的 Promise;正在处理时直接返回,提交按钮同步显示加载状态。
onSubmit() {
// 锁定资格校验至提交完成的整个链路,避免重复点击产生多次报名请求。
if (this.formLoading) return
this.formLoading = true
return this.validateSignUp().then((valid) => {
if (!valid) return
return this.$refs.form.validate().then(() => {
return this.courseRow.courseIsLimitApply ? this.validateCourseTime() : true
}).then((validTime) => {
if (!validTime) return
return this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
}).then(() => {
const mobileColumnsValue = this.formData.trainMobileSignColumnList.map((v) => {
return {
columnName: v.columnName,
@@ -176,28 +202,51 @@ const applyForm = {
columnFormType: v.columnFormType
}
})
this.formData.mobileColumnsValue = JSON.stringify(mobileColumnsValue)
const resp = await this.$axios.post("/platform/trainSignUp/apply/doSignUp", this.formData)
if (resp.code === 0) {
this.signDialog = false
this.$message.success(resp.msg)
this.$emit('refresh')
} else {
this.$message.warning(resp.msg)
// 已配置 mobile 动态字段时同步最新手机号,兼容按动态字段读取数据的原有导出。
if (this.mobileColumn) {
mobileColumnsValue.push({
columnName: this.mobileColumn.columnName,
columnValue: this.formData.mobile,
columnCode: this.mobileColumn.columnCode,
columnFormType: this.mobileColumn.columnFormType
})
}
this.$set(this.formData, 'mobileColumnsValue', JSON.stringify(mobileColumnsValue))
// activityId、courseId、activityCourseId 分别标识活动、课程和所选时段;mobile 为本次报名联系方式。
// mobileColumnsValue 为含字段名、编码、控件类型及值的 JSON 数组,其他基础信息保持原表单内容。
// 接口返回 Result,code 为 0 表示报名成功,msg 用于结果提示。
return this.$axios.post("/platform/trainSignUp/apply/doSignUp", this.formData).then((resp) => {
if (resp.code === 0) {
this.signDialog = false
this.$message.success(resp.msg)
this.$emit('refresh')
} else {
this.$message.warning(resp.msg)
}
})
})
}
})
}).catch(() => {
// 取消确认或表单校验不通过时保留当前填写内容;网络错误由公共请求拦截器提示。
}).finally(() => {
this.formLoading = false
})
},
// row 提供活动、课程 ID,course 提供动态字段;仅准备本次报名表单及手机号元数据。
initData(row, course) {
this.$set(this.formData, 'username', this.$store.state.user.username)
this.$set(this.formData, 'loginname', this.$store.state.user.loginname)
this.$set(this.formData, 'unionName', this.$store.state.user.union.name)
this.$set(this.formData, 'unitName', this.$store.state.user.unit.name)
this.$set(this.formData, 'sex', this.$store.state.user.sex)
// 默认取登录用户手机号,可编辑且只随本次报名提交,不修改个人资料。
this.$set(this.formData, 'mobile', this.$store.state.user.mobile || '')
this.$set(this.formData, 'activityId', row.activityId)
this.$set(this.formData, 'courseId', row.id)
this.$set(this.formData, 'trainMobileSignColumnList', course.trainMobileSignColumnList)
// 复制配置以免修改课程类型原数据;手机号统一必填,不受动态 isRequired 影响,保留 mobile 元数据兼容同步提交。
const columns = clone(course.trainMobileSignColumnList)
this.mobileColumn = columns.find((item) => item.columnCode === 'mobile') || null
this.$set(this.formData, 'trainMobileSignColumnList', columns.filter((item) => item.columnCode !== 'mobile'))
this.formData.trainMobileSignColumnList.forEach((item) => {
item.columnValue = this.$store.state.user[item.columnCode] || ""
@@ -96,8 +96,8 @@ const basicForm = {
</el-col>
<el-col :span="12">
<el-form-item label="活动类型" prop="trainType" :rules="{required:true,message: '请选择活动类型', trigger: 'blur'}">
<el-select @change="typeChange" filterable placeholder="请选择活动类型" style="width: 100%" v-model="formData.trainType">
<el-option :label="item.name" :value="item.code" :key="item.code" v-for="item in dict.type.TRAIN_SIGNUP_TYPE"></el-option>
<el-select @change="typeChange" @visible-change="(visible) => visible && loadDictOptions('TRAIN_SIGNUP_TYPE')" :loading="dictOptionsLoading.TRAIN_SIGNUP_TYPE" filterable placeholder="请选择活动类型" style="width: 100%" v-model="formData.trainType">
<el-option :label="item.name" :value="item.code" :key="item.code" v-for="item in trainTypeList"></el-option>
</el-select>
</el-form-item>
</el-col>
@@ -221,8 +221,8 @@ const basicForm = {
<el-table-column label="校区" prop="campus">
<template v-slot="{row}">
<el-select size="small" v-model="row.campus" style="width: 100%">
<el-option :label="item.name" :value="item.name" :key="item.code" v-for="item in dict.type.USER_CAMPUS"></el-option>
<el-select size="small" v-model="row.campus" style="width: 100%" :loading="dictOptionsLoading.USER_CAMPUS" @visible-change="(visible) => visible && loadDictOptions('USER_CAMPUS')">
<el-option :label="item.name" :value="item.name" :key="item.code" v-for="item in campusList"></el-option>
</el-select>
</template>
</el-table-column>
@@ -324,7 +324,6 @@ const basicForm = {
<custom-form ref="customFormRef"></custom-form>
</div>
`,
dicts: ["USER_CAMPUS", "TRAIN_SIGNUP_TYPE"],
data() {
return {
signUpDialog: false,
@@ -356,6 +355,8 @@ const basicForm = {
}
},
campusList: [],
dictOptionsLoading: { USER_CAMPUS: false, TRAIN_SIGNUP_TYPE: false },
dictOptionsRequests: { USER_CAMPUS: null, TRAIN_SIGNUP_TYPE: null },
courseTypeList: [],
undertakeOptions: [],
}
@@ -523,8 +524,56 @@ const basicForm = {
})
}
},
/**
* 查询本表单的下拉字典。code 仅传 USER_CAMPUS(校区)或 TRAIN_SIGNUP_TYPE(活动类型)。
* 接口返回 {code: 0, data: [{name, code}]};name 为显示名称,校区保存 name,活动类型保存 code。
* 返回 Promise<Array>,失败时返回空数组;共用进行中的请求,失败或无选项时允许重新展开下拉框重试。
*/
loadDictOptions(code) {
const options = {
USER_CAMPUS: { field: "campusList", label: "校区" },
TRAIN_SIGNUP_TYPE: { field: "trainTypeList", label: "活动类型" }
}[code]
if (!options) {
return Promise.resolve([])
}
if (this.dictOptionsRequests[code]) {
return this.dictOptionsRequests[code]
}
if (this[options.field].length > 0) {
return Promise.resolve(this[options.field])
}
this.$set(this.dictOptionsLoading, code, true)
const request = this.$axios.post("/open/common/dictOptions", { code })
.then((res) => {
if (!res || res.code !== 0 || !Array.isArray(res.data)) {
this.$message.warning(options.label + "选项加载失败,请重新展开下拉框重试")
return []
}
this.$set(this, options.field, res.data)
if (res.data.length === 0) {
this.$message.warning(options.label + "暂无可用选项,请检查字典配置")
}
// 重试成功后按当前表单值更新业务名称,避免早于字典返回的回显停留在默认名称。
if (code === "TRAIN_SIGNUP_TYPE") {
this.typeChange(this.formData.trainType)
}
return res.data
})
.catch(() => {
// 网络及接口异常由全局 axios 拦截器提示;保留原表单值,允许下次展开时重试。
return []
})
.finally(() => {
this.$set(this.dictOptionsLoading, code, false)
this.$set(this.dictOptionsRequests, code, null)
})
this.$set(this.dictOptionsRequests, code, request)
return request
},
// val 为活动类型字典编码;按已查询的选项更新名称,未匹配时沿用原有默认名称。
typeChange(val) {
const type = this.dict.type.TRAIN_SIGNUP_TYPE.find(o => o.code === val)
const type = this.trainTypeList.find(o => o.code === val)
this.trainType = type?.name || "培训班"
},
changeActivity(val) {
@@ -669,13 +718,25 @@ const basicForm = {
}
}
},
async initData(row, copyTemplateMode = false) {
// row 为待编辑活动(新建时不传),copyTemplateMode 标识模板复制;返回完成字典及表单回显的 Promise。
initData(row, copyTemplateMode = false) {
this.copyTemplateMode = copyTemplateMode
this.activityGroupList = await this.getActivityGroup()
this.historicalActList = await this.getHistoricalActList()
this.courseTypeList = await this.getAllType()
await this.selectUnitAndClub()
await this.init(row)
// 两类字典查询完成后再初始化表单,避免活动类型回显与字典加载发生竞争。
return Promise.all([this.loadDictOptions("USER_CAMPUS"), this.loadDictOptions("TRAIN_SIGNUP_TYPE")])
.then(() => this.getActivityGroup())
.then((data) => {
this.activityGroupList = data
return this.getHistoricalActList()
})
.then((data) => {
this.historicalActList = data
return this.getAllType()
})
.then((data) => {
this.courseTypeList = data
return this.selectUnitAndClub()
})
.then(() => this.init(row))
},
async selectUnitAndClub() {
const resp = await this.$axios.post("/platform/trainSignUp/manage/selectUnitAndClub")
@@ -1,6 +1,79 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style nonce="${cspNonce!}">
.huazhu-sync-dialog { max-width: calc(100vw - 48px); max-height: 88vh; display: flex; flex-direction: column; border-radius: 8px; }
.huazhu-sync-dialog .el-dialog__header { padding: 24px 24px 0; border-bottom: 0; }
.huazhu-sync-header-copy { flex: 1; min-width: 0; }
.huazhu-sync-dialog .el-dialog__headerbtn { align-self: flex-start; margin-left: 16px; }
.huazhu-sync-dialog .el-dialog__body { padding: 0 24px; overflow-y: auto; min-height: 0; font-size: 14px; }
.huazhu-sync-dialog .el-button--small { padding: 9px 16px; font-size: 13px; border-radius: 4px; }
.huazhu-sync-dialog .el-input--small .el-input__inner { height: 36px; line-height: 36px; font-size: 14px; border-radius: 4px; }
.huazhu-sync-dialog .el-input--small .el-input__icon { line-height: 36px; }
.huazhu-sync-dialog .el-dialog__footer { padding: 16px 24px; border-top: 1px solid #ebeef5; }
.huazhu-sync-title { margin: 0 0 6px; color: #182333; font-size: 20px; font-weight: 600; line-height: 28px; }
.huazhu-sync-subtitle { margin: 0; color: #8a929f; font-size: 14px; line-height: 22px; }
.huazhu-sync-tabs > .el-tabs__header { margin: 16px 0 22px; }
.huazhu-sync-tabs .el-tabs__item { padding: 0 22px; font-size: 14px; }
.huazhu-sync-tabs .el-tabs__nav-wrap::after { height: 1px; background: #ebeef5; }
.huazhu-sync-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; text-align: left; }
.huazhu-sync-note { color: #8a929f; font-size: 12px; line-height: 20px; }
.huazhu-sync-note .el-icon-info { margin-right: 6px; color: #006bb8; }
.huazhu-sync-batch { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
.huazhu-sync-batch-label { color: #303b4a; font-weight: 600; white-space: nowrap; }
.huazhu-sync-batch .el-select { width: 330px; max-width: 100%; }
.huazhu-sync-batch .el-button { margin-left: auto; }
.huazhu-sync-batch .el-button--primary.is-plain { color: #006bb8; background: #fff; border-color: #006bb8; }
.huazhu-sync-batch .el-button--primary.is-plain:hover { background: #f1f7fc; }
.huazhu-sync-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); padding: 18px 0; margin-bottom: 14px; background: #f6f8fc; border-radius: 4px; }
.huazhu-sync-stat { text-align: center; }
.huazhu-sync-stat + .huazhu-sync-stat { border-left: 1px solid #dde3ed; }
.huazhu-sync-stat strong { display: block; color: #182333; font-size: 28px; font-weight: 600; line-height: 34px; }
.huazhu-sync-stat span { display: block; margin-top: 5px; color: #606b7b; font-size: 13px; }
.huazhu-sync-stat.success strong, .huazhu-result-success { color: #32953d; }
.huazhu-sync-stat.exists strong, .huazhu-result-exists { color: #009fb0; }
.huazhu-sync-stat.failed strong, .huazhu-result-failed { color: #e3283b; }
.huazhu-sync-stat.unknown strong, .huazhu-result-unknown { color: #ed8a00; }
.huazhu-sync-stat.pending strong, .huazhu-result-pending, .huazhu-result-sending { color: #006bb8; }
.huazhu-sync-progress { display: flex; align-items: center; gap: 16px; color: #606b7b; font-size: 13px; margin-bottom: 24px; }
.huazhu-sync-progress .el-progress { flex: 1; min-width: 100px; }
.huazhu-sync-progress .el-progress-bar__outer { background: #e0e5ec; }
.huazhu-sync-updated { margin-left: auto; color: #8a929f; white-space: nowrap; }
.huazhu-sync-filter { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
.huazhu-sync-filter > label { color: #303b4a; font-size: 13px; }
.huazhu-sync-filter .el-input { width: 260px; }
.huazhu-sync-filter .el-select { width: 170px; }
.huazhu-sync-filter .el-select .el-input { width: 100%; }
.huazhu-sync-filter .filter-status { margin-left: 12px; }
.huazhu-sync-record-table .el-table__header th { background: #f4f6fa; color: #303b4a; font-weight: 600; }
.huazhu-sync-record-table { font-size: 14px; }
.huazhu-sync-record-table .el-table__cell { padding: 7px 0; }
.huazhu-sync-record-table .el-button--text { padding: 3px 0; }
.huazhu-sync-record-table .result-icon { margin-right: 5px; }
.huazhu-sync-records .el-pagination-container { margin: 16px 0 14px !important; text-align: right; }
.huazhu-sync-empty { padding: 18px 20px; margin-bottom: 18px; color: #9099a6; background: #f6f8fc; border-radius: 4px; font-size: 13px; line-height: 22px; }
.huazhu-sync-submit { padding: 2px 0 24px; }
.huazhu-sync-submit-note { margin-bottom: 22px; }
.huazhu-sync-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; padding: 12px 0 24px; }
.huazhu-sync-option + .huazhu-sync-option { padding-left: 32px; border-left: 1px solid #ebeef5; }
.huazhu-sync-option h3 { margin: 0 0 12px; color: #303b4a; font-size: 16px; font-weight: 600; }
.huazhu-sync-option h3 i { margin-right: 8px; color: #006bb8; }
.huazhu-sync-option p { margin: 0 0 16px; min-height: 46px; color: #7a8595; font-size: 13px; line-height: 23px; }
.huazhu-sync-option .el-tag { margin: 0 6px 8px 0; }
.huazhu-sync-selected { min-height: 42px; margin-bottom: 14px; }
@media (max-width: 900px) {
.huazhu-sync-dialog { max-width: calc(100vw - 24px); }
.huazhu-sync-batch, .huazhu-sync-progress { flex-wrap: wrap; }
.huazhu-sync-batch .el-select { flex: 1; min-width: 180px; }
.huazhu-sync-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 20px; }
.huazhu-sync-stat:nth-child(4) { border-left: 0; }
.huazhu-sync-stat strong { font-size: 24px; }
.huazhu-sync-progress .el-progress { min-width: 130px; }
.huazhu-sync-updated { width: 100%; text-align: right; }
.huazhu-sync-options { grid-template-columns: 1fr; gap: 24px; }
.huazhu-sync-option + .huazhu-sync-option { padding: 24px 0 0; border-left: 0; border-top: 1px solid #ebeef5; }
}
</style>
<div id="app" v-cloak>
<guava ref="guava">
<template>
@@ -12,9 +85,8 @@ layout("/layouts/platform.html"){
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="华住员工管理">
<el-button :disabled="syncLoading || tableLoading" @click="syncSelected" icon="el-icon-refresh" size="small" type="primary">同步选中员工</el-button>
<el-button :disabled="tableLoading" :loading="syncLoading" @click="syncRuleUsers" icon="el-icon-refresh" size="small" type="primary">同步规则内人员</el-button>
<table-tool :app="this" label="待推送员工(已推送人员自动隐藏)">
<el-button @click="openSyncManage" icon="el-icon-refresh" size="small" type="primary">同步管理</el-button>
</table-tool>
<el-table :data="tableData" :size="tableSize" @selection-change="selectionChange" class="vi-table" v-loading="tableLoading">
<el-table-column type="selection" width="55"></el-table-column>
@@ -23,12 +95,57 @@ layout("/layouts/platform.html"){
<el-table-column label="姓名" min-width="120" prop="username"></el-table-column>
<el-table-column label="单位" min-width="180" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column fixed="right" label="操作" width="130">
<template slot-scope="{row}"><el-button @click="queryStatus(row)" size="mini" type="primary">查询状态</el-button></template>
<template slot-scope="{row}"><el-button :loading="queryingUserId === row.id" :disabled="!!queryingUserId" @click="queryStatus(row)" size="mini" type="primary">查询状态</el-button></template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<el-dialog :visible.sync="statusDialogVisible" title="华住同步状态" width="520px">
<el-dialog :visible.sync="syncDialogVisible" custom-class="huazhu-sync-dialog" width="1080px" top="8vh" :close-on-click-modal="false">
<template slot="title">
<div class="huazhu-sync-header-copy">
<h2 class="huazhu-sync-title">华住同步管理</h2>
<p class="huazhu-sync-subtitle">后台逐人推送,提交后可离开页面,稍后查看结果</p>
</div>
</template>
<el-tabs :value="syncTab" @tab-click="changeSyncTab" class="huazhu-sync-tabs">
<el-tab-pane label="发起推送" name="submit">
<div class="huazhu-sync-submit">
<el-alert class="huazhu-sync-submit-note" :closable="false" show-icon :type="summary.running ? 'warning' : 'info'"
:title="summary.running ? '已有未结束任务,可前往进度与记录查看后台状态;任务结束后可再次提交。' : '同步过程中时间略长,请稍后再试登录华住会。已推送、已排队和待核实的人员不会重复提交。'"></el-alert>
<div class="huazhu-sync-options">
<section class="huazhu-sync-option">
<h3><i class="el-icon-user"></i>同步选中员工</h3>
<p>将主页面已勾选的 {{selectedRows.length}} 名员工加入后台任务。需要调整人员时,关闭弹框后重新勾选。</p>
<div class="huazhu-sync-selected">
<el-tag v-for="row in selectedRows.slice(0, 6)" :key="row.id" size="small" type="info">{{row.username || row.loginname}}</el-tag>
<el-tag v-if="selectedRows.length > 6" size="small" type="info">等 {{selectedRows.length}} 人</el-tag>
<span v-if="!selectedRows.length" class="huazhu-sync-note">暂未勾选员工,请先在主页面选择。</span>
</div>
<el-button :disabled="!selectedRows.length || summary.running || summaryLoading" :loading="syncLoading" @click="syncSelected" type="primary" size="small">推送选中员工</el-button>
</section>
<section class="huazhu-sync-option">
<h3><i class="el-icon-office-building"></i>同步规则内人员</h3>
<p v-loading="ruleDescriptionLoading">{{ruleDescription || '读取同步范围后,可将全部符合规则的待推送人员加入后台任务。'}}</p>
<div class="huazhu-sync-selected huazhu-sync-note">本操作不受当前搜索、分页和勾选范围影响。</div>
<el-button :disabled="summary.running || summaryLoading || ruleDescriptionLoading || !ruleDescription" :loading="syncLoading" @click="syncRuleUsers" type="primary" size="small">推送规则内人员</el-button>
<el-button v-if="!ruleDescription && !ruleDescriptionLoading" @click="loadRuleDescription" type="text" size="small">重新读取规则</el-button>
</section>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="进度与记录" name="records">
<hua-zhu-sync-info ref="syncInfo" :key="selectedTaskId" :summary="summary" :summary-loading="summaryLoading" :updated-at="summaryUpdatedAt"
:querying-user-id="queryingUserId" @query-status="queryStatus" @task-change="changeSummaryTask" @refresh="refreshResults" v-if="syncDialogVisible"></hua-zhu-sync-info>
</el-tab-pane>
</el-tabs>
<template slot="footer">
<div class="huazhu-sync-footer">
<span class="huazhu-sync-note"><i class="el-icon-info"></i>更新只查看结果,不会重复推送;已推送人员自动从待推送列表隐藏。</span>
<el-button @click="closeSyncManage" size="small">关闭</el-button>
</div>
</template>
</el-dialog>
<el-dialog append-to-body :visible.sync="statusDialogVisible" title="华住同步状态" width="520px">
<el-descriptions :column="1" border size="small" v-if="statusData">
<el-descriptions-item label="状态码">{{statusData.statusCode || "--"}}</el-descriptions-item>
<el-descriptions-item label="工号">{{statusData.userNumber || "--"}}</el-descriptions-item>
@@ -44,82 +161,132 @@ layout("/layouts/platform.html"){
</guava>
</div>
<script nonce="${cspNonce!}">
<!--#include("./info.js"){}#-->
new Vue({
el: "#app",
mixins: [initTableMixins],
data() { return {selectedRows: [], syncLoading: false, statusDialogVisible: false, statusData: null} },
components: {HuaZhuSyncInfo: huaZhuSyncInfo},
data() {
return {
selectedRows: [], syncLoading: false, summaryLoading: false, summaryRequestId: 0,
summary: {task: null, counts: {}, tasks: [], running: false}, summaryUpdatedAt: null,
selectedTaskId: "", syncDialogVisible: false, syncTab: "records",
ruleDescription: "", ruleDescriptionLoading: false,
queryingUserId: "", statusDialogVisible: false, statusData: null
}
},
methods: {
selectionChange(rows) { this.$set(this, "selectedRows", rows) },
// 确认期间锁定同步入口,并固定本次勾选范围,防止重复点击或勾选变化影响提交。
syncSelected() {
if (this.syncLoading || this.tableLoading) { return }
if (!this.selectedRows.length) { this.$message.warning("请先选择需要同步的员工"); return }
const userIds = this.selectedRows.map((row) => row.id);
this.$set(this, "syncLoading", true);
this.confirmSync("确定同步选中的 " + userIds.length + " 名员工到华住吗?", "/syncUsers", {userIds: userIds});
// 主页面只提供一个入口;每次打开读取最新批次,关闭不会影响后台任务。
openSyncManage() {
this.$set(this, "syncDialogVisible", true);
this.$set(this, "syncTab", "records");
this.$nextTick(() => { this.loadSummary("", true) });
},
// 先读取包含实际单位名称的规则;只有确认后才调用全量规则同步接口。
syncRuleUsers() {
if (this.syncLoading || this.tableLoading) { return }
this.$set(this, "syncLoading", true);
let description = "";
$.post(loc() + "/syncRuleDescription", {}).then((res) => {
if (!res || res.code !== 0 || !res.data || !res.data.description) {
this.$message.warning((res && res.msg) || "无法获取同步规则中的单位名称,请检查单位信息");
return;
}
description = res.data.description;
}, () => {
this.$message.error("无法获取同步规则中的单位名称,请检查单位信息");
}).always(() => {
if (!description) { this.$set(this, "syncLoading", false); return }
// 项目使用旧版 jQuery,完成读取后再开启原生确认 Promise,避免提前解除同步锁。
const message = "确定将符合以下规则的人员同步至华住会吗? " + description
+ " 本次将同步全部符合规则的有效人员,不受当前搜索条件、分页或勾选范围限制。";
this.confirmSync(message, "/syncRuleUsers", {});
closeSyncManage() { this.$set(this, "syncDialogVisible", false) },
changeSyncTab(tab) {
this.$set(this, "syncTab", tab.name);
if (tab.name === "submit" && !this.ruleDescription && !this.ruleDescriptionLoading) { this.loadRuleDescription() }
},
// 只读取并展示实际单位名称和业务规则,不创建任务;提交时服务端会再次校验。
loadRuleDescription() {
this.$set(this, "ruleDescriptionLoading", true);
return $.post(loc() + "/syncRuleDescription", {}).then((res) => {
if (res.code === 0 && res.data) { this.$set(this, "ruleDescription", res.data.description || "") }
}, () => { /* 中文错误由全局回调显示。 */ }).always(() => {
this.$set(this, "ruleDescriptionLoading", false);
});
},
// message 为中文同步范围,path/params 为确认后才提交的接口及参数;取消不推送。
// 固定主页面勾选范围,确认后只创建批次,实际推送由后台逐人执行。
syncSelected() {
if (this.syncLoading || this.summary.running || this.summaryLoading) { return }
if (!this.selectedRows.length) { this.$message.warning("请先在主页面选择需要同步的员工"); return }
const userIds = this.selectedRows.map((row) => row.id);
this.$set(this, "syncLoading", true);
this.confirmSync("确定将选中的 " + userIds.length + " 名员工加入后台推送任务吗?", "/syncUsers", {userIds: userIds});
},
syncRuleUsers() {
if (this.syncLoading || this.summary.running || this.summaryLoading || !this.ruleDescription) { return }
this.$set(this, "syncLoading", true);
this.confirmSync("确定将符合以下规则的待推送人员加入后台任务吗? " + this.ruleDescription
+ " 不受当前搜索、分页及勾选影响,已推送及待核实人员不会重复提交。", "/syncRuleUsers", {});
},
// path/params 为创建任务的接口和参数;用户取消时不提交。
confirmSync(message, path, params) {
this.$confirm(message, "同步确认", {
type: "warning", confirmButtonText: "确定同步", cancelButtonText: "取消"
}).then(() => this.submitSync(path, params)).catch((reason) => {
if (reason !== "cancel" && reason !== "close") {
this.$message.error("同步请求失败,部分人员可能已同步,请核实状态后再操作");
}
// 旧版 jQuery 的网络失败仍会传到原生 Promise;jqXHR 的中文提示已由全局回调显示。
if (reason && typeof reason.status === "number") { return }
if (reason !== "cancel" && reason !== "close") { this.$message.error("提交过程异常,请更新结果确认任务是否已创建") }
}).finally(() => { this.$set(this, "syncLoading", false) });
},
// path 为本菜单的同步接口后缀;params 为选中用户主键数组或规则同步的空参数。
// 响应 data 为逐人结果数组,success 表示是否成功,message 为结果说明。
// data 为批次 id/total 等元数据;提交成功切到该批次记录,不等待后台处理完成。
submitSync(path, params) {
this.$set(this, "tableLoading", true);
return $.post(loc() + path, params).then((res) => {
if (!res || res.code !== 0) {
this.$message.warning((res && res.msg) || "同步失败");
return;
}
const results = res.data || [];
if (!results.length) { this.$message.info("没有符合规则的人员需要同步"); return }
const failed = results.filter((item) => !item.success);
const message = "同步完成,成功 " + (results.length - failed.length) + " 人,失败 " + failed.length + " 人";
if (failed.length) {
this.$message.warning(message);
this.$alert(failed.map((item) => (item.userName || "未知姓名") + "(" + (item.loginName || item.userId) + "):" + item.message).join("\n"), "失败明细")
.catch(() => { /* 关闭失败明细不影响本次同步结果。 */ });
} else { this.$message.success(message) }
}).always(() => { this.$set(this, "tableLoading", false) });
},
queryStatus(row) {
this.$set(this, "tableLoading", true);
$.post(loc() + "/queryStatus", {userId: row.id}).then((res) => {
if (res.code === 0) { this.$set(this, "statusData", res.data); this.$set(this, "statusDialogVisible", true) } else { this.$message.warning(res.msg || "查询失败") }
if (!res || res.code !== 0) { return }
this.$message.success("已创建 " + res.data.total + " 人的后台推送任务,可离开页面,稍后查看结果");
this.$set(this, "selectedRows", []);
this.$set(this, "syncTab", "records");
this.$nextTick(() => { this.loadSummary(res.data.id, true) });
this.doSearch();
}, (xhr) => {
const message = xhr && xhr.responseJSON && xhr.responseJSON.msg;
this.$message.error(message || "查询华住状态失败,请稍后重试");
}).always(() => { this.$set(this, "tableLoading", false) })
// 旧版 jQuery 保留拒绝状态;传回 jqXHR,供确认 Promise 识别已由全局提示的网络错误。
return xhr;
}).always(() => {
this.$set(this, "syncLoading", false);
});
},
// taskId 为空读取最近批次;initializeRecords 只在打开弹框或新任务提交后重置记录范围。
// 请求序号防止快速切换批次时,较慢的旧响应覆盖当前批次统计。
loadSummary(taskId, initializeRecords = false) {
const requestId = this.summaryRequestId + 1;
this.$set(this, "summaryRequestId", requestId);
this.$set(this, "summaryLoading", true);
return $.post(loc() + "/syncSummary", {taskId: taskId || ""}).then((res) => {
if (requestId !== this.summaryRequestId || !res || res.code !== 0) { return }
this.$set(this, "summary", res.data);
this.$set(this, "summaryUpdatedAt", Date.now());
if (initializeRecords) {
const selectedId = res.data.task ? res.data.task.id : "";
this.$set(this, "selectedTaskId", selectedId);
this.$nextTick(() => {
if (requestId === this.summaryRequestId && this.$refs.syncInfo) { this.$refs.syncInfo.onOpen(selectedId) }
});
}
}, () => { /* 全局回调已显示中文错误。 */ }).always(() => {
if (requestId === this.summaryRequestId) { this.$set(this, "summaryLoading", false) }
});
},
changeSummaryTask(taskId) {
// 每个批次使用独立记录组件,旧批次迟到的分页响应只会更新已销毁的实例。
this.$set(this, "selectedTaskId", taskId);
this.$nextTick(() => {
if (this.$refs.syncInfo) { this.$refs.syncInfo.onOpen(taskId) }
});
this.loadSummary(taskId);
},
// 仅刷新数据库统计、回执和主页面待推送名单,绝不调用任何推送接口。
refreshResults() {
this.loadSummary(this.selectedTaskId);
if (this.$refs.syncInfo) { this.$refs.syncInfo.pageData() }
this.doSearch();
},
// row.id 为本项目用户主键;本行独立显示进度,不阻塞列表或后台任务。
queryStatus(row) {
if (this.queryingUserId) { return }
this.$set(this, "queryingUserId", row.id);
$.post(loc() + "/queryStatus", {userId: row.id}).then((res) => {
if (res.code === 0) {
this.$set(this, "statusData", res.data);
this.$set(this, "statusDialogVisible", true);
}
}, () => { /* 中文错误由全局回调显示一次。 */ }).always(() => {
this.$set(this, "queryingUserId", "");
});
}
},
created() { this.pageData() }
created() { this.pageData(); this.loadSummary("") }
})
</script>
<!--#
@@ -0,0 +1,142 @@
const huaZhuSyncInfo = {
template: /*language=HTML*/ `
<div class="huazhu-sync-records">
<div class="huazhu-sync-batch">
<span class="huazhu-sync-batch-label">推送批次</span>
<el-select :value="pageForm.taskId" @change="changeTask" placeholder="选择批次" size="small" :disabled="summaryLoading || tableLoading" aria-label="推送批次">
<el-option label="全部历史批次" value=""></el-option>
<el-option v-for="task in summary.tasks" :key="task.id" :value="task.id" :label="batchLabel(task)"></el-option>
</el-select>
<el-tag v-if="activeTask" :type="activeTask.status === 'FAILED' ? 'danger' : activeTask.status === 'RUNNING' ? '' : 'info'" size="small" effect="light">
<i :class="activeTask.status === 'FAILED' ? 'el-icon-warning' : activeTask.status === 'RUNNING' ? 'el-icon-time' : 'el-icon-finished'"></i>
{{taskStatusText}}
</el-tag>
<el-button :loading="summaryLoading || tableLoading" @click="refreshResults" icon="el-icon-refresh" size="small" plain type="primary">更新结果</el-button>
</div>
<el-alert v-if="summary.background && summary.background.message" class="huazhu-sync-submit-note" :title="summary.background.message" type="warning" show-icon :closable="false"></el-alert>
<el-alert v-if="activeTask && activeTask.status === 'FAILED'" class="huazhu-sync-submit-note" title="本批次异常中止,已完成的结果已保留。请查看人员结果说明;未发送人员可重新提交,待核实人员请先查询华住状态。" type="error" show-icon :closable="false"></el-alert>
<p v-if="summary.background" class="huazhu-sync-note">{{summary.background.lastTriggeredAt ? '本次应用启动后最近后台触发:' + $moment(summary.background.lastTriggeredAt).format('YYYY-MM-DD HH:mm:ss') : '本次应用启动后尚未收到后台执行回报'}}</p>
<template v-if="activeTask">
<div class="huazhu-sync-stats" v-loading="summaryLoading">
<div v-for="item in statistics" :key="item.label" :class="['huazhu-sync-stat', item.type]">
<strong>{{formatCount(item.value)}}</strong><span>{{item.label}}</span>
</div>
</div>
<div class="huazhu-sync-progress">
<span>已处理 {{formatCount(processedCount)}} / {{formatCount(activeTask.total)}} 人</span>
<el-progress :percentage="progressPercentage" :show-text="false" :stroke-width="8" color="#006bb8"></el-progress>
<span>{{progressPercentage}}%</span>
<span class="huazhu-sync-updated">{{updatedAt ? '更新于 ' + $moment(updatedAt).format('HH:mm:ss') : ''}}</span>
</div>
</template>
<div v-else class="huazhu-sync-empty" v-loading="summaryLoading">
{{summary.tasks.length ? '当前查看全部历史记录。选择具体批次,可查看本批次统计和进度。' : '暂无推送任务。可切换到发起推送,选择勾选员工或规则内人员进行同步。'}}
</div>
<div class="huazhu-sync-filter">
<label>员工信息</label>
<el-input v-model="pageForm.searchKeyword" clearable placeholder="输入工号或姓名" @keyup.enter.native="doSearch" size="small" suffix-icon="el-icon-search" aria-label="员工信息"></el-input>
<label class="filter-status">处理结果</label>
<el-select :value="pageForm.status" @change="changeStatus" :disabled="tableLoading" size="small" aria-label="处理结果">
<el-option label="全部状态" value=""></el-option>
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value"></el-option>
</el-select>
<el-button @click="doSearch" :disabled="tableLoading" size="small" type="primary">查询</el-button>
</div>
<el-table :data="tableData" v-loading="tableLoading" :size="tableSize" :max-height="320" class="vi-table huazhu-sync-record-table" empty-text="暂无符合条件的推送记录">
<el-table-column label="工号" prop="loginName" min-width="110"></el-table-column>
<el-table-column label="姓名" prop="userName" min-width="90"></el-table-column>
<el-table-column label="处理结果" min-width="125">
<template slot-scope="{row}">
<span :class="'huazhu-result-' + (row.status || '').toLowerCase()"><i :class="['result-icon', statusIcons[row.status]]"></i>{{statusLabels[row.status] || row.status}}</span>
</template>
</el-table-column>
<el-table-column label="处理时间" min-width="165">
<template slot-scope="{row}">{{row.finishedAt ? $moment(row.finishedAt).format('YYYY-MM-DD HH:mm:ss') : '--'}}</template>
</el-table-column>
<el-table-column label="结果说明" min-width="270" show-overflow-tooltip>
<template slot-scope="{row}">{{resultMessage(row)}}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template slot-scope="{row}"><el-button size="mini" type="text" :loading="queryingUserId === row.userId" :disabled="!!queryingUserId" @click="queryStatus(row)">查询状态</el-button></template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
<slot></slot>
</div>
`,
mixins: [initTableMixins],
props: {
summary: {type: Object, required: true},
summaryLoading: {type: Boolean, default: false},
updatedAt: {type: Number, default: null},
queryingUserId: {type: String, default: ""}
},
data() {
return {
pageDataUrl: loc() + "/recordPageData",
statusLabels: {PENDING: "等待推送", SENDING: "正在推送", SUCCESS: "推送成功", EXISTS: "华住已存在", FAILED: "推送失败", UNKNOWN: "待核实"},
statusIcons: {PENDING: "el-icon-time", SENDING: "el-icon-loading", SUCCESS: "el-icon-success", EXISTS: "el-icon-circle-check", FAILED: "el-icon-error", UNKNOWN: "el-icon-warning"}
}
},
computed: {
// 数据库 RUNNING 只表示批次未结束,实际进入后台循环才显示正在处理。
taskStatusText() {
if (!this.activeTask) { return "" }
if (this.activeTask.status === "FAILED") { return "异常中止" }
if (this.activeTask.status !== "RUNNING") { return "处理结束" }
return this.summary.background && this.summary.background.executingTaskId === this.activeTask.id ? "后台处理中" : "等待后台执行";
},
// 全部历史记录不展示单批次统计;批次切换期间不把上一个批次的计数展示为当前结果。
activeTask() {
return this.summary.task && this.summary.task.id === this.pageForm.taskId ? this.summary.task : null;
},
statistics() {
const counts = this.summary.counts || {};
return [
{label: "总人数", value: this.activeTask ? this.activeTask.total : 0, type: "total"},
{label: "推送成功", value: counts.SUCCESS || 0, type: "success"},
{label: "华住已存在", value: counts.EXISTS || 0, type: "exists"},
{label: "失败", value: counts.FAILED || 0, type: "failed"},
{label: "待核实", value: counts.UNKNOWN || 0, type: "unknown"},
{label: "待处理", value: (counts.PENDING || 0) + (counts.SENDING || 0), type: "pending"}
];
},
processedCount() {
if (!this.activeTask) { return 0 }
const counts = this.summary.counts || {};
return Math.max(0, Math.min(this.activeTask.total, (counts.SUCCESS || 0) + (counts.EXISTS || 0) + (counts.FAILED || 0) + (counts.UNKNOWN || 0)));
},
progressPercentage() {
return this.activeTask && this.activeTask.total > 0 ? Math.round(this.processedCount * 100 / this.activeTask.total) : 0;
}
},
methods: {
// taskId 为打开时的批次主键,空值查全部;查询、分页、加载状态均复用 initTableMixins。
onOpen(taskId) {
this.$set(this.pageForm, "taskId", taskId || "");
this.$set(this.pageForm, "status", "");
this.doSearch();
},
changeTask(value) {
this.$set(this.pageForm, "taskId", value);
// 父组件按批次重建记录实例,再调用 onOpen 复用分页查询,隔离旧批次的未完成请求。
this.$emit("task-change", value);
},
changeStatus(value) { this.$set(this.pageForm, "status", value); this.doSearch() },
refreshResults() { this.$emit("refresh") },
queryStatus(row) { this.$emit("query-status", {id: row.userId}) },
batchLabel(task) {
return this.$moment(task.submittedAt).format("YYYY-MM-DD HH:mm:ss") + " · " + (task.source === "RULE" ? "规则内人员" : "选中员工");
},
formatCount(value) { return Number(value || 0).toLocaleString("zh-CN") },
// 服务端保存脱敏原始回执;表格优先显示中文 message,未知文本保留用于核实。
resultMessage(row) {
try {
const receipt = JSON.parse(row.message);
return receipt.message || receipt.msg || row.message || "--";
} catch (ignored) {
return row.message || "--";
}
}
}
}
@@ -6,7 +6,8 @@ layout("/layouts/platform.html"){
<custom-card>
<snaker-start slot="header" label="补贴申请" define_key="ZGWW">
<template slot="header-right-label">
<el-button v-if="$auth.hasRole('SYSADMIN')" type="text" class="mr10"
<!-- 系统管理员和校工会管理员均可维护全校慰问申报截止日期。 -->
<el-button v-if="$auth.hasRoleOr(['SYSADMIN', 'SCHOOL_UNION_ADMIN'])" type="text" class="mr10"
@click="openApplyPeriodDialog">申报时间设置</el-button>
</template>
</snaker-start>
@@ -138,6 +138,8 @@ layout("/layouts/platform.html"){
{prop: 'applyUnitName', label: '所属单位'},
{prop: 'typeName', label: '补贴类型'},
{prop: 'taskName', label: '当前节点'},
// 展示业务申请时间,与详情页及月份筛选使用的 createTime 一致。
{prop: 'createTime', label: '申请时间', width: 180},
{prop: 'instanceState', label: '流程状态'},
],
unionList: [],
@@ -9,8 +9,13 @@ layout("/layouts/platform.html"){
.difficult-apply .nowrap-select .el-select__tags { flex-wrap: nowrap; overflow: hidden; }
</style>
<div id="app" class="difficult-apply" v-cloak>
<el-card shadow="never">
<snaker-start slot="header" label="特困补助申请" define_key="TKBZ"></snaker-start>
<custom-card>
<snaker-start slot="header" label="特困补助申请" define_key="TKBZ">
<template slot="header-right-label">
<el-button v-if="$auth.hasRoleOr(['SYSADMIN', 'SCHOOL_UNION_ADMIN'])"
type="text" class="mr10" @click="openApplyPeriodDialog">申报时间设置</el-button>
</template>
</snaker-start>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="0"
label-suffix=":" class="flow-task-form">
<el-descriptions :column="3" border>
@@ -98,9 +103,9 @@ layout("/layouts/platform.html"){
<template slot-scope="scope">
<el-form-item :prop="'hospitalRecords.' + scope.$index + '.selfFundedAmount'"
:rules="conditionalRule(isAccidentSubsidy(scope.row))">
<el-input-number v-model="scope.row.selfFundedAmount" :min="0" :precision="2"
<el-input v-model="scope.row.selfFundedAmount" type="number"
:disabled="!isAccidentSubsidy(scope.row)"
style="width:100%"></el-input-number>
style="width:100%"></el-input>
</el-form-item>
</template>
</el-table-column>
@@ -158,6 +163,7 @@ layout("/layouts/platform.html"){
<el-form-item prop="applyFile" :rules="requiredRule('请上传纸质申请表')">
<file-upload :value.sync="formData.applyFile" upload_mode="drag"
:upload_number="1" upload_result_category="array"
accept=".pdf,.jpg,.jpeg,.png,.gif,.bmp,.webp,.tif,.tiff" :validate_accept="true"
complete_result></file-upload>
</el-form-item>
</el-descriptions-item>
@@ -188,11 +194,27 @@ layout("/layouts/platform.html"){
</el-descriptions-item>
</el-descriptions>
</el-form>
<el-row type="flex" justify="end" class="mt20">
<el-button v-if="!isSchoolAuditEdit" type="primary" plain @click="onSave">保存</el-button>
<el-button type="primary" @click="onSubmit">提交</el-button>
</el-row>
</el-card>
<template slot="footer">
<template v-if="bizId || isSchoolAuditEdit || (applyPeriodLoaded && !applyClosed)">
<el-button v-if="!isSchoolAuditEdit" type="primary" plain :loading="formLoading" @click="onSave">保存</el-button>
<el-button type="primary" :loading="formLoading" @click="onSubmit">提交</el-button>
</template>
</template>
</custom-card>
<el-dialog title="申报时间设置" :visible="applyPeriodDialogVisible" width="460px"
:close-on-click-modal="false" @close="closeApplyPeriodDialog">
<el-form ref="applyPeriodFormRef" :model="applyPeriodForm" :rules="applyPeriodRules" label-width="120px">
<el-form-item label="申报截止日期" prop="endDate">
<el-date-picker v-model="applyPeriodForm.endDate" type="date" value-format="yyyy-MM-dd"
placeholder="请选择截止日期" style="width:100%"></el-date-picker>
</el-form-item>
<el-alert title="截止日期当天起停止新增申报,已有申请可继续办理。" type="info" :closable="false"></el-alert>
</el-form>
<template slot="footer">
<el-button @click="closeApplyPeriodDialog">取消</el-button>
<el-button type="primary" :loading="applyPeriodSaving" @click="saveApplyEndDate">保存</el-button>
</template>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
@@ -224,10 +246,26 @@ layout("/layouts/platform.html"){
projectList: [],
diseaseList: [],
userOptions: [],
userLoading: false
userLoading: false,
applyEndDate: "",
applyPeriodLoaded: false,
applyPeriodDialogVisible: false,
applyPeriodSaving: false,
applyPeriodForm: {endDate: ""},
applyPeriodRules: {
endDate: [{required: true, message: "请选择申报截止日期", trigger: "change"}]
}
};
},
computed: {
// 空配置不限制申请;错误配置关闭新增入口,最终仍由后端校验日期。
applyClosed() {
if (!this.applyEndDate) {
return false;
}
var deadline = this.$moment(this.applyEndDate, "YYYY-MM-DD", true);
return !deadline.isValid() || !this.$moment().startOf("day").isBefore(deadline);
},
selectedProjects: function () {
var selected = this.formData.subsidyType || [];
return this.projectList.filter(function (item) {
@@ -236,6 +274,78 @@ layout("/layouts/platform.html"){
}
},
methods: {
// 读取 Result.data 中的 yyyy-MM-dd 日期,读取成功前不开放新申请按钮。
queryApplyEndDate() {
return this.$axios.post("/platform/difficultSubsidy/apply/getApplyEndDate").then((res) => {
if (res.code === 0) {
this.$set(this, "applyEndDate", res.data || "");
this.$set(this, "applyPeriodLoaded", true);
}
});
},
// 与慰问申请一致:截止时显示“温馨提示”,关闭弹框后留在当前页,便于管理员设置申报时间。
showApplyClosedAlert() {
this.$alert("本次申报已截止请关注下次申报", "温馨提示", {
confirmButtonText: "确定",
callback: () => {}
});
},
openApplyPeriodDialog() {
this.queryApplyEndDate().then(() => {
if (!this.applyPeriodLoaded) {
return;
}
this.$set(this.applyPeriodForm, "endDate", this.applyEndDate);
this.$set(this, "applyPeriodDialogVisible", true);
this.$nextTick(() => {
this.$refs.applyPeriodFormRef.clearValidate();
});
});
},
closeApplyPeriodDialog() {
this.$set(this, "applyPeriodDialogVisible", false);
},
// endDate 为日期字符串,后端统一校验管理角色及格式;成功后立即刷新本页截止状态。
saveApplyEndDate() {
if (this.applyPeriodSaving) {
return;
}
this.$refs.applyPeriodFormRef.validate((valid) => {
if (!valid) {
return;
}
var endDate = this.applyPeriodForm.endDate;
this.$set(this, "applyPeriodSaving", true);
this.$axios.post("/platform/difficultSubsidy/apply/saveApplyEndDate", {endDate: endDate})
.then((res) => {
if (res.code === 0) {
this.$set(this, "applyEndDate", endDate);
this.closeApplyPeriodDialog();
this.$message.success("申报时间设置成功");
}
}).finally(() => {
this.$set(this, "applyPeriodSaving", false);
});
});
},
// 已有申请和审核修改不受新申请截止限制,后端会核实申请ID确实存在。
canSaveApplication() {
if (this.formLoading) {
return false;
}
if (this.bizId || this.isSchoolAuditEdit) {
return true;
}
if (!this.applyPeriodLoaded) {
this.$message.warning("申报时间尚未加载,请刷新页面后重试");
return false;
}
if (this.applyClosed) {
this.showApplyClosedAlert();
return false;
}
return true;
},
requiredRule: function (message) {
return [{required: true, message: message, trigger: ["change", "blur"]}];
},
@@ -299,23 +409,21 @@ layout("/layouts/platform.html"){
this.$set(data, "files", []);
}
},
queryRecipients: function (keyword, selectFirst) {
var self = this;
queryRecipients(keyword, selectFirst) {
if (!keyword) {
this.$set(this, "userOptions", []);
return;
}
this.$set(this, "userLoading", true);
this.$axios.post("/platform/difficultSubsidy/apply/queryRecipients", {key: keyword}).then(function (res) {
this.$axios.post("/platform/difficultSubsidy/apply/queryRecipients", {key: keyword}).then((res) => {
var users = res.code === 0 ? (res.data || []) : [];
self.$set(self, "userOptions", users);
self.$set(self, "userLoading", false);
this.$set(this, "userOptions", users);
if (selectFirst && users.length > 0) {
self.$set(self.formData, "userId", users[0].id);
self.setSelectedUser(users[0]);
this.$set(this.formData, "userId", users[0].id);
this.setSelectedUser(users[0]);
}
}).catch(function () {
self.$set(self, "userLoading", false);
}).finally(() => {
this.$set(this, "userLoading", false);
});
},
userChange: function (userId) {
@@ -389,62 +497,71 @@ layout("/layouts/platform.html"){
removeHospitalRecord: function (index) {
this.formData.hospitalRecords.splice(index, 1);
},
onSave: function () {
var self = this;
onSave() {
if (!this.canSaveApplication()) {
return;
}
this.$confirm("您确定保存吗?", "提示", {
confirmButtonText: "确定", cancelButtonText: "取消", type: "warning"
}).then(function () {
self.$axios.post("/platform/difficultSubsidy/apply/save", {
data: JSON.stringify(self.formData)
}).then(function (res) {
if (res.code === 0) {
self.$message.success("保存成功");
commonUtil.pjaxPush("/platform/difficultSubsidy/mine");
}
});
}).then(() => {
this.sendApplication("/platform/difficultSubsidy/apply/save", {
data: JSON.stringify(this.formData)
}, "保存成功", "/platform/difficultSubsidy/mine");
});
},
onSubmit: function () {
var self = this;
this.$refs.formRef.validate(function (valid) {
onSubmit() {
if (!this.canSaveApplication()) {
return;
}
this.$refs.formRef.validate((valid) => {
if (!valid) {
return;
}
if (!self.formData.hospitalRecords || self.formData.hospitalRecords.length === 0) {
self.$message.warning("请填写住院记录");
if (!this.formData.hospitalRecords || this.formData.hospitalRecords.length === 0) {
this.$message.warning("请填写住院记录");
return;
}
self.$confirm("您确定要提交吗?", "提示", {
this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定", cancelButtonText: "取消", type: "warning"
}).then(function () {
if (self.isSchoolAuditEdit) {
self.$axios.post("/platform/difficultSubsidy/schoolAudit/modifyApply", {
processTaskId: self.taskId,
data: JSON.stringify(self.formData)
}).then(function (res) {
if (res.code === 0) {
self.$message.success("修改成功");
commonUtil.pjaxPush("/platform/difficultSubsidy/schoolAudit");
}
});
}).then(() => {
if (this.isSchoolAuditEdit) {
this.sendApplication("/platform/difficultSubsidy/schoolAudit/modifyApply", {
processTaskId: this.taskId,
data: JSON.stringify(this.formData)
}, "修改成功", "/platform/difficultSubsidy/schoolAudit");
return;
}
self.$axios.post("/platform/difficultSubsidy/apply/submit", {
data: JSON.stringify(self.formData)
}).then(function (res) {
if (res.code === 0) {
self.$message.success("提交成功");
commonUtil.pjaxPush("/platform/difficultSubsidy/mine");
}
});
this.sendApplication("/platform/difficultSubsidy/apply/submit", {
data: JSON.stringify(this.formData)
}, "提交成功", "/platform/difficultSubsidy/mine");
});
});
},
// url/params 对应草稿、提交或审核修改接口;Result.code 为0时提示并跳转到对应列表。
sendApplication(url, params, successMessage, redirectUrl) {
if (!this.canSaveApplication()) {
return;
}
this.$set(this, "formLoading", true);
this.$axios.post(url, params).then((res) => {
if (res.code === 0) {
this.$message.success(successMessage);
commonUtil.pjaxPush(redirectUrl);
}
}).finally(() => {
this.$set(this, "formLoading", false);
});
}
},
created: function () {
var self = this;
this.loadProject().then(function () {
self.loadDetail();
created() {
// 仅新申请在截止日期读取成功后弹出提示,已有申请和校工会审核修改仍可继续办理。
this.queryApplyEndDate().then(() => {
if (this.applyPeriodLoaded && !this.bizId && !this.isSchoolAuditEdit && this.applyClosed) {
this.showApplyClosedAlert();
}
});
this.loadProject().then(() => {
this.loadDetail();
});
}
});
@@ -14,6 +14,19 @@ const applyForm = {
<van-field label="所属单位" readonly v-model="formData.unitName"></van-field>
<van-field label="所属工会" readonly v-model="formData.unionName"></van-field>
<van-field label="性别" readonly v-model="formData.sex"></van-field>
<!-- 手机号必填,格式与后端保持一致:1开头、第二位3至9,共11位数字。 -->
<van-field label="手机号"
name="mobile"
type="tel"
maxlength="32"
placeholder="请输入手机号"
required
:rules="[
{ required: true, validator: (value) => !!String(value || '').trim(), message: '请填写手机号' },
{ pattern: /^1[3-9][0-9]{9}$/, message: '请输入正确的11位手机号' }
]"
v-model="formData.mobile">
</van-field>
<template v-if="row.courseIsLimitApply">
<van-field label="报名时段"
required
@@ -48,6 +61,7 @@ const applyForm = {
row: {},
activity: {},
dynamicColumnsData: [],
mobileColumn: null,
visible: false,
formLoading: false,
formData: {},
@@ -76,12 +90,17 @@ const applyForm = {
this.$set(this.formData, 'unionName', this.$store.state.user.union.name)
this.$set(this.formData, 'unitName', this.$store.state.user.unit.name)
this.$set(this.formData, 'sex', this.$store.state.user.sex)
// 默认使用个人资料中的手机号,编辑后的号码仅作为本次报名联系方式保存。
this.$set(this.formData, 'mobile', this.$store.state.user.mobile || '')
this.$set(this.formData, 'activityId', row.activityId)
this.$set(this.formData, 'courseId', row.id)
this.dynamicColumnsData = courseType ? courseType.trainMobileSignColumnList : []
// 手机号统一必填,不受动态 isRequired 影响;同名字段保留元数据兼容同步提交,避免重复输入。
const dynamicColumns = clone((courseType && courseType.trainMobileSignColumnList) || [])
this.$set(this, 'mobileColumn', dynamicColumns.find((item) => item.columnCode === 'mobile') || null)
this.$set(this, 'dynamicColumnsData', dynamicColumns.filter((item) => item.columnCode !== 'mobile'))
this.dynamicColumnsData.forEach((item) => {
item.columnValue = this.$store.state.user[item.columnCode] || ""
this.$set(item, 'columnValue', this.$store.state.user[item.columnCode] || '')
})
},
onCourseConfirm(val){
@@ -137,10 +156,14 @@ const applyForm = {
if (!validTime) return
this.$set(this, "formLoading", true)
const formData = clone(this.formData)
formData.mobileColumnsValue = JSON.stringify(this.dynamicColumnsData.map((item) => ({
columnName: item.columnName, columnValue: item.columnValue,
columnCode: item.columnCode, columnFormType: item.columnFormType
})))
// 导出会按动态字段编码回填数据,同名 mobile 必须同步本次编辑值。
const dynamicColumns = this.dynamicColumnsData.concat(this.mobileColumn ? [this.mobileColumn] : [])
formData.mobileColumnsValue = JSON.stringify(dynamicColumns.map((item) => ({
columnName: item.columnName,
columnValue: item.columnCode === 'mobile' ? formData.mobile : item.columnValue,
columnCode: item.columnCode,
columnFormType: item.columnFormType
})))
return this.$axios.post("/platform/trainSignUp/apply/doSignUp", formData).then((res) => {
if (res.code === 0) {
this.$toast(res.msg)