diff --git a/src/main/java/com/budwk/app/MainLauncher.java b/src/main/java/com/budwk/app/MainLauncher.java index 07e198c..5650dcf 100644 --- a/src/main/java/com/budwk/app/MainLauncher.java +++ b/src/main/java/com/budwk/app/MainLauncher.java @@ -63,17 +63,29 @@ public class MainLauncher { private Dao dao; public static void main(String[] args) throws Exception { + configureDnsCache(); NbApp nb = new NbApp().setArgs(args).setPrintProcDoc(true); nb.getAppContext().setMainPackage("com.budwk"); nb.run(); } public static NbApp warMain(ServletContext sc) { + configureDnsCache(); NbApp nb = new NbApp().setPrintProcDoc(true); nb.getAppContext().setMainPackage("com.budwk"); return nb; } + /** + * 在启动框架及网络依赖之前设置 JVM 域名缓存:成功缓存 60 秒,失败不缓存。 + * 此策略作用于整个 JVM;共享 WAR 容器若已初始化解析器,需在容器启动配置中设置。 + * 无入参、无返回值;不清理操作系统或 DNS 服务器的缓存。 + */ + private static void configureDnsCache() { + java.security.Security.setProperty("networkaddress.cache.ttl", "60"); + java.security.Security.setProperty("networkaddress.cache.negative.ttl", "0"); + } + public void init() { Mvcs.DISABLE_X_POWERED_BY = true; Globals.AppBase = Mvcs.getServletContext().getContextPath(); diff --git a/src/main/java/com/budwk/app/flow/service/FlowCommonService.java b/src/main/java/com/budwk/app/flow/service/FlowCommonService.java index bd6b901..d25c8dd 100644 --- a/src/main/java/com/budwk/app/flow/service/FlowCommonService.java +++ b/src/main/java/com/budwk/app/flow/service/FlowCommonService.java @@ -7,6 +7,8 @@ import com.budwk.app.flow.constant.FlowConst; import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; import com.budwk.app.web.commons.auth.utils.SecurityUtil; +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; @@ -17,9 +19,12 @@ public class FlowCommonService { private FlowEngine flowEngine; /** - * 执行任务 - * @param args + * 执行 WF 任务,任务状态、业务监听器和后续任务写入共用本地事务;OA 异常向外抛出触发回滚。 + * @param args 包含 processTaskId、submitType 和 tf_ 前缀表单变量;submitType 缺省为同意。 + * 方法会移除任务 ID 并补充办理人信息,批量办理应为每个任务传入独立副本。 + * 返回值为 void;失败抛出异常,由调用入口返回失败结果 */ + @Aop(TransAop.READ_COMMITTED) public void executeTask(Dict args){ Long processTaskId = args.getLong(FlowConst.PROCESS_TASK_ID_KEY); String operator = SecurityUtil.getUserId(); diff --git a/src/main/java/com/budwk/app/flow/service/ProcessTaskService.java b/src/main/java/com/budwk/app/flow/service/ProcessTaskService.java index 44b298a..3e13a7a 100644 --- a/src/main/java/com/budwk/app/flow/service/ProcessTaskService.java +++ b/src/main/java/com/budwk/app/flow/service/ProcessTaskService.java @@ -195,10 +195,12 @@ public interface ProcessTaskService extends BaseService { ProcessTaskVO latest(Long processInstanceId); /** - * 转办任务 + * 转办正在办理的 WF 任务:旧 OA 待办转已办,新任务向目标办理人发送 OA 待办。 + * 无返回值;任务无效、参与人创建失败或 OA 同步异常时抛出异常,回滚本地事务。 + * OA 同步遵循系统开关;本地回滚不能撤销已经成功的远程 OA 操作。 * - * @param taskId 任务ID - * @param transferUserId 转办用户ID + * @param taskId 当前仍在办理的 WF 任务 ID,不是流程实例 ID + * @param transferUserId 新办理人的系统用户 ID,不是工号 */ void transfer(Long taskId, String transferUserId); diff --git a/src/main/java/com/budwk/app/flow/service/impl/ProcessTaskServiceImpl.java b/src/main/java/com/budwk/app/flow/service/impl/ProcessTaskServiceImpl.java index f57a071..3ed4aaa 100644 --- a/src/main/java/com/budwk/app/flow/service/impl/ProcessTaskServiceImpl.java +++ b/src/main/java/com/budwk/app/flow/service/impl/ProcessTaskServiceImpl.java @@ -463,8 +463,22 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme @Override @Aop(TransAop.READ_COMMITTED) public void transfer(Long taskId, String transferUserId) { - // 设置原任务状态为已转办 + // taskId 为正在办理的 WF 任务,transferUserId 为目标用户 ID;无返回值,失败抛异常回滚。 + if (taskId == null || StrUtil.isBlank(transferUserId)) { + throw new BaseException("转办失败:任务和新办理人不能为空"); + } ProcessTask task = fetch(taskId); + if (task == null || Boolean.TRUE.equals(task.getDelFlag()) + || !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) { + throw new BaseException("当前任务已处理或不存在,请刷新后重试"); + } + // 使用与任务参与人创建相同的用户视图,提前拒绝无法创建参与人的目标用户。 + Sql transferUserSql = Sqls.create("select id from vw_user where id=@userId"); + transferUserSql.setParam("userId", transferUserId); + if (listMap(transferUserSql).isEmpty()) { + throw new BaseException("转办失败:新办理人不存在"); + } + // 本系统保留已转办状态,OA 中旧办理人的待办在下方同步为已办。 task.setTaskState(ProcessTaskStateEnum.TRANSFER.getCode()); Dict variable = FlowUtil.variableToDict(task.getVariable()); variable.put(FlowConst.TASK_FORM_DATA_PREFIX + "transferUserId", transferUserId); @@ -487,11 +501,18 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl impleme // 创建转办任务参与者 addTaskActor(newTask.getId(), List.of(transferUserId)); - // 发布流程任务开始事件 - List processTaskList = List.of(task); - processTaskList.forEach(processTask -> { - ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_START).sourceId(processTask.getId()).build()); - }); + // 参与人创建失败时禁止关闭旧 OA 待办;事务回滚新任务及旧任务状态。 + if (!getTaskActors(newTask.getId()).contains(transferUserId)) { + throw new BaseException("转办失败:未能创建新办理人的任务参与记录"); + } + // 两次 OA 调用均须成功才允许本地事务提交;异常继续向外抛出,不吞掉同步失败。 + // 本地事务不能撤销已成功的远程 OA 请求,因此仍需保留异常日志用于对账。 + schoolOaTodoService.completeTodo(task); + schoolOaTodoService.createTodo(newTask); + + // OA 同步成功后通知新任务的办理人,避免使用旧任务 ID 通知原办理人。 + ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_START) + .sourceId(newTask.getId()).build()); } diff --git a/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java b/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java index 44410e9..c300e60 100644 --- a/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java +++ b/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java @@ -3,6 +3,7 @@ package com.budwk.app.flow.service.impl; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; import cn.hutool.http.HttpUtil; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; @@ -28,8 +29,9 @@ import org.nutz.ioc.impl.PropertiesProxy; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import java.util.Date; +import java.net.UnknownHostException; import java.util.ArrayList; +import java.util.Date; import java.util.List; /** @@ -39,6 +41,12 @@ import java.util.List; @IocBean public class SchoolOaTodoServiceImpl implements SchoolOaTodoService { + // 总次数包含首次请求;只重试尚未连接到 OA 的域名解析失败。 + private static final int MAX_REQUEST_ATTEMPTS = 3; + private static final int CONNECT_TIMEOUT_MS = 2000; + private static final int READ_TIMEOUT_MS = 10000; + private static final long DNS_RETRY_DELAY_MS = 2000L; + private static final String DATE_PATTERN = "yyyy-MM-dd HH:mm:ss"; private static final String DEFAULT_APP_TODO_URL = "zhgh.ypi.edu.cn"; @@ -491,31 +499,92 @@ public class SchoolOaTodoServiceImpl implements SchoolOaTodoService { return sendTodoRequest(url, body, failMessage, errorMessage, false); } + /** + * 发送 OA 请求。DNS 失败间隔两秒、最多尝试三次;业务错误和结果不确定的网络异常直接抛出。 + * @param url 已替换任务标识的 OA 接口地址 + * @param body 请求 JSON,null 表示无请求体 + * @param failMessage OA 业务失败提示前缀 + * @param errorMessage 请求异常提示前缀 + * @param skipMissingTodo 仅完成待办接口允许 state=1131(记录不存在)继续办理 + * @return OA 原始 JSON 字符串,state=200 为成功,允许缺失时也可能为 1131 + */ private String sendTodoRequest(String url, JSONObject body, String failMessage, String errorMessage, boolean skipMissingTodo) { String requestBody = body == null ? "" : JSONUtil.toJsonStr(body); - log.info("学校OA请求开始,url={},body={}", url, requestBody); - try { - HttpRequest request = HttpUtil.createPost(url); - setTodoRequestHeaders(request); - if (body != null) { - request.body(requestBody); - } + for (int attempt = 1; attempt <= MAX_REQUEST_ATTEMPTS; attempt++) { + long attemptStarted = System.nanoTime(); + log.info("学校OA请求开始,url={},attempt={}/{}", url, attempt, MAX_REQUEST_ATTEMPTS); + try { + // 每次重建请求并关闭响应;仅 DNS 失败后等待重试,不引入共享锁。 + HttpRequest request = HttpUtil.createPost(url) + .setConnectionTimeout(CONNECT_TIMEOUT_MS) + .setReadTimeout(READ_TIMEOUT_MS); + setTodoRequestHeaders(request); + if (body != null) { + request.body(requestBody); + } - String responseBody = request.execute().body(); - log.info("学校OA请求响应,url={},response={}", url, responseBody); - JSONObject response = JSONUtil.parseObj(responseBody); - int state = response.getInt("state", 0); - // 仅完成待办接口允许 OA 返回“uniqueId 不存在”;创建、删除和其他修改仍按原规则报错。 - if (state != 200 && !(skipMissingTodo && state == 1131)) { - log.warn("学校OA请求失败响应,url={},body={},response={}", url, requestBody, responseBody); - throw new BaseException("{}:{}", failMessage, response.getStr("message", responseBody)); + String responseBody; + try (HttpResponse httpResponse = request.execute()) { + responseBody = httpResponse.body(); + } + log.info("学校OA请求响应,url={},attempt={},elapsedMs={},response={}", url, attempt, + (System.nanoTime() - attemptStarted) / 1_000_000L, responseBody); + JSONObject response = JSONUtil.parseObj(responseBody); + int state = response.getInt("state", 0); + // 保留历史待办缺失的兼容规则;其他业务失败不重试。 + if (state != 200 && !(skipMissingTodo && state == 1131)) { + throw new BaseException("{}:{}", failMessage, response.getStr("message", responseBody)); + } + return responseBody; + } catch (BaseException e) { + throw e; + } catch (Exception e) { + if (isUnknownHostFailure(e)) { + log.warn("学校OA域名解析失败,url={},attempt={}/{},elapsedMs={}", url, attempt, MAX_REQUEST_ATTEMPTS, + (System.nanoTime() - attemptStarted) / 1_000_000L, e); + if (attempt < MAX_REQUEST_ATTEMPTS) { + // 只暂停当前请求;中断时终止重试并抛出异常,交由外层事务回滚。 + log.info("学校OA等待域名解析重试,url={},nextAttempt={},delayMs={}", + url, attempt + 1, DNS_RETRY_DELAY_MS); + try { + Thread.sleep(DNS_RETRY_DELAY_MS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + BaseException failure = new BaseException("学校OA请求已中断,请稍后重试"); + failure.initCause(interrupted); + failure.addSuppressed(e); + throw failure; + } + continue; + } + BaseException failure = new BaseException("学校OA暂时无法连接,请稍后重试"); + failure.initCause(e); + throw failure; + } + // 读取超时等异常可能发生在 OA 已处理之后,自动重试可能导致重复待办。 + log.error("学校OA请求异常,url={},attempt={},elapsedMs={}", url, attempt, + (System.nanoTime() - attemptStarted) / 1_000_000L, e); + BaseException failure = new BaseException("{}:{}", errorMessage, e.getMessage()); + failure.initCause(e); + throw failure; } - return responseBody; - } catch (BaseException e) { - throw e; - } catch (Exception e) { - log.error("学校OA请求异常,url={},body={}", url, requestBody, e); - throw new BaseException("{}:{}", errorMessage, e.getMessage()); } + throw new BaseException("学校OA暂时无法连接,请稍后重试"); + } + + /** + * 检查 HTTP 库包装的底层 DNS 异常;限制遍历深度,避免异常原因链循环导致无限等待。 + * @param error 请求抛出的异常 + * @return true 表示异常链包含 UnknownHostException,可安全重试域名解析失败的请求 + */ + private boolean isUnknownHostFailure(Throwable error) { + Throwable cause = error; + for (int depth = 0; cause != null && depth < 32; depth++) { + if (cause instanceof UnknownHostException) { + return true; + } + cause = cause.getCause(); + } + return false; } } diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalPositiveSecondedController.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalPositiveSecondedController.java index 91e5cc6..0601381 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalPositiveSecondedController.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalPositiveSecondedController.java @@ -190,8 +190,22 @@ public class ProposalPositiveSecondedController { return Result.success(); } + /** + * 供附议及七个办理节点的公共详情读取主动附议记录;读取权限不授予实际附议操作权限。 + * @param bizId 提案 ID,对应 proposal_second.proposalId + * @return Result,data 为主动附议记录列表,包含附议人、附议意见、结果及时间;无记录时为空列表 + */ @At - @SaCheckPermission(value = {"proposal.seconded", "h5.proposal.seconded", "proposal.commissioner", "h5.proposal.commissioner", "proposal.committeeFiling", "h5.proposal.committeeFiling", "proposal.schoolLeaderApproval", "h5.proposal.schoolLeaderApproval"}, mode = SaMode.OR) + @SaCheckPermission(value = { + "proposal.seconded", "h5.proposal.seconded", + "proposal.delegation", "h5.proposal.delegation", + "proposal.commissioner", "h5.proposal.commissioner", + "proposal.committeeFiling", "h5.proposal.committeeFiling", + "proposal.unitReply", "h5.proposal.unitReply", + "proposal.suggestion", "h5.proposal.suggestion", + "proposal.schoolLeaderApproval", "h5.proposal.schoolLeaderApproval", + "proposal.feedbackEvaluation", "h5.proposal.feedbackEvaluation" + }, mode = SaMode.OR) public Result listPositiveSeconded(String bizId) { List list = dao.query(ProposalSecond.class, Cnd.where(ProposalSecond::getProposalId, "=", bizId).and(ProposalSecond::getMode, "=", false)); return Result.success(list); diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalUnderTakeReplyController.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalUnderTakeReplyController.java index 8db1264..6b9098d 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalUnderTakeReplyController.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/controller/transact/ProposalUnderTakeReplyController.java @@ -6,10 +6,8 @@ import cn.hutool.core.util.ObjectUtil; import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.result.Result; -import com.budwk.app.flow.constant.FlowConst; import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.enums.ProcessTaskStateEnum; -import com.budwk.app.flow.service.FlowCommonService; import com.budwk.app.flow.service.ProcessTaskService; import com.budwk.app.sys.models.Sys_role; import com.budwk.app.sys.models.Sys_user_role; @@ -18,6 +16,7 @@ import com.budwk.app.sys.services.SysUserService; import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.zhgh.democratic.proposal.param.ProposalSearchParam; +import com.budwk.app.zhgh.democratic.proposal.service.ProposalUnderTakeReplyService; import com.budwk.app.zhgh.democratic.proposal.service.common.ProposalCommonService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -37,9 +36,9 @@ import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; -import javax.validation.Valid; import java.util.List; import java.util.stream.Stream; +import javax.validation.Valid; @IocBean @At("/platform/proposal/unitReply") @@ -53,7 +52,7 @@ public class ProposalUnderTakeReplyController { @Inject private ProposalCommonService proposalCommonService; @Inject - private FlowCommonService flowCommonService; + private ProposalUnderTakeReplyService proposalUnderTakeReplyService; @Inject private ProcessTaskService processTaskService; @Inject @@ -154,30 +153,18 @@ public class ProposalUnderTakeReplyController { return Result.success(pagination); } + /** + * 接收承办答复参数,委托 Service 完成整批 WF 办理。 + * @param data JSON 字符串,包含 proposalId、processTaskId、submitType 及 tf_ 表单变量 + * @return Result 成功结果;业务或 OA 异常由统一异常处理返回失败信息 + */ @At @SaCheckPermission("proposal.unitReply") @ApiOperation("执行任务") + @Aop(TransAop.READ_COMMITTED) public Result executeTask(@Param("data") String data) { Dict args = Json.fromJson(Dict.class, data); - String proposalId = args.getStr("proposalId"); - - // 查询是否提案 - - // 单条审核 - List mergeProposalIds = proposalCommonService.mergeProposal(proposalId); - if (ObjectUtil.isEmpty(mergeProposalIds)) { - flowCommonService.executeTask(args); - return Result.success(); - } - - // 并案审核 - ProcessTask thisTask = dao.fetch(ProcessTask.class, args.getLong(FlowConst.PROCESS_TASK_ID_KEY)); - List mergeTasks = processTaskService.getDoingTaskByBizIdTaskName(mergeProposalIds, thisTask.getTaskName()); - for (ProcessTask mergeTask : mergeTasks) { - Dict cloneArgs = args.clone(); - cloneArgs.put(FlowConst.PROCESS_TASK_ID_KEY, mergeTask.getId()); - flowCommonService.executeTask(cloneArgs); - } + proposalUnderTakeReplyService.executeTask(args); return Result.success(); } diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalUnderTakeReplyService.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalUnderTakeReplyService.java index e40eea9..ad85344 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalUnderTakeReplyService.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalUnderTakeReplyService.java @@ -1,5 +1,6 @@ package com.budwk.app.zhgh.democratic.proposal.service; +import cn.hutool.core.lang.Dict; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.service.BaseService; import com.budwk.app.zhgh.democratic.proposal.models.ProposalInfo; @@ -8,11 +9,18 @@ import com.budwk.app.zhgh.democratic.proposal.param.ProposalUnderTakeReplyApprov import com.budwk.app.zhgh.democratic.proposal.param.ProposalUnderTakeReplyTransferParam; import org.nutz.lang.util.NutMap; -import javax.validation.Valid; import java.util.List; +import javax.validation.Valid; public interface ProposalUnderTakeReplyService extends BaseService { + /** + * 在同一事务中办理单条或并案提案的 WF 承办答复。 + * @param args proposalId 为提案 ID,processTaskId 为当前任务 ID,submitType 和 tf_ 变量沿用页面参数 + * 返回值为 void;办理失败抛出异常,整批本地修改回滚 + */ + void executeTask(Dict args); + Pagination pageData(ProposalSearchParam param); void approval(ProposalUnderTakeReplyApprovalParam param); diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalSchoolLeaderApprovalServiceImpl.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalSchoolLeaderApprovalServiceImpl.java index 16fbe0f..9eca7b5 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalSchoolLeaderApprovalServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalSchoolLeaderApprovalServiceImpl.java @@ -26,8 +26,10 @@ import com.budwk.app.zhgh.democratic.proposal.models.ProposalUndertake; import com.budwk.app.zhgh.democratic.proposal.param.ProposalUnderTakeSchoolLeaderApprovalParam; import com.budwk.app.zhgh.democratic.proposal.service.ProposalSchoolLeaderApprovalService; import com.budwk.app.zhgh.democratic.proposal.service.common.ProposalCommonService; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; @@ -65,7 +67,13 @@ public class ProposalSchoolLeaderApprovalServiceImpl extends BaseServiceImpl implements ProposalUnderTakeReplyService { + @Inject + private FlowCommonService flowCommonService; + @Inject + private ProcessTaskService processTaskService; @Inject private BpmService bpmService; @Inject @@ -70,6 +80,33 @@ public class ProposalUnderTakeReplyServiceImpl extends BaseServiceImpl mergeProposalIds = proposalCommonService.mergeProposal(proposalId); + if (ObjectUtil.isEmpty(mergeProposalIds)) { + flowCommonService.executeTask(args); + return; + } + + // 每个任务使用独立参数副本,避免公共执行方法移除任务 ID 后影响下一条。 + ProcessTask thisTask = dao().fetch(ProcessTask.class, args.getLong(FlowConst.PROCESS_TASK_ID_KEY)); + List mergeTasks = processTaskService.getDoingTaskByBizIdTaskName(mergeProposalIds, thisTask.getTaskName()); + for (ProcessTask mergeTask : mergeTasks) { + Dict cloneArgs = args.clone(); + cloneArgs.put(FlowConst.PROCESS_TASK_ID_KEY, mergeTask.getId()); + flowCommonService.executeTask(cloneArgs); + } + } + @Override public Pagination pageData(ProposalSearchParam param) { return null; diff --git a/src/main/resources/views/layouts/platform_h5.html b/src/main/resources/views/layouts/platform_h5.html index b1db10c..a140d88 100644 --- a/src/main/resources/views/layouts/platform_h5.html +++ b/src/main/resources/views/layouts/platform_h5.html @@ -99,6 +99,8 @@ let active = null let serial = 0 let pending = null + let registered = null + let releasing = Promise.resolve() let skippingClosedHistory = false const sync = (closedKeys) => { if (active) active.onChange(active.keys.slice(), closedKeys) @@ -146,37 +148,55 @@ return { /** 注册页面同步回调,参数为仍打开的弹框 key 数组;返回页面专用操作句柄。 */ register(onChange) { - if (active) throw new Error("上一个页面的弹框历史尚未注销") - const owner = { id: Date.now() + "-" + (++serial), keys: [], onChange } - active = owner + if (registered) throw new Error("上一个页面的弹框历史尚未注销") + const owner = { id: Date.now() + "-" + (++serial), keys: [], onChange, disposed: false } + registered = owner + // 旧页面注销时先释放注册资格;新页面的弹框操作等待旧历史回退完成,不能接管其 popstate。 + const ready = active ? releasing.then(() => { + if (!owner.disposed) active = owner + }) : Promise.resolve() + if (!active) active = owner + const runOwned = (work) => { + const run = () => active === owner && !owner.disposed ? work() : undefined + return active === owner ? run() : ready.then(run) + } + let destroyed = null return { // key 是页面内唯一弹框名称;相同弹框重复打开不会重复压栈。 open(key) { const open = () => { - if (active !== owner || owner.keys.includes(key)) return + if (active !== owner || owner.disposed || owner.keys.includes(key)) return owner.keys.push(key) window.history.pushState({ h5Popup: { owner: owner.id, depth: owner.keys.length } }, "", window.location.href) } - return pending ? pending.promise.then(open) : Promise.resolve(open()) + return Promise.resolve(runOwned(() => pending ? pending.promise.then(open) : open())) }, // 关闭指定弹框及其上层弹框,返回 Promise,保持后进先出。 close(key) { - if (active !== owner) return Promise.resolve() - const index = owner.keys.indexOf(key) - return index < 0 ? Promise.resolve() : rewind(index) + return Promise.resolve(runOwned(() => { + const index = owner.keys.indexOf(key) + return index < 0 ? undefined : rewind(index) + })) }, clear() { - return active === owner ? rewind(0) : Promise.resolve() + return Promise.resolve(runOwned(() => rewind(0))) }, destroy() { + if (destroyed) return destroyed + owner.disposed = true + if (registered === owner) registered = null // 无弹框时同步注销,保证 PJAX 同次替换中下一个页面可以立即注册。 if (active === owner && !owner.keys.length && !pending) { active = null - return Promise.resolve() + destroyed = Promise.resolve() + return destroyed } - return this.clear().then(() => { + // 新页面可能尚在等待更早页面释放历史;连续切换也必须按顺序完成注销。 + destroyed = ready.then(() => active === owner ? rewind(0) : undefined).then(() => { if (active === owner) active = null }) + releasing = destroyed + return destroyed } } }, @@ -215,27 +235,17 @@ $(document).on("pjax:complete", function () { if (window.customStyleList) { window.customStyleList.forEach((style) => { - style.parentNode.removeChild(style) + // 页面替换或前一次清理可能已移除节点,仅清理仍挂载的旧样式。 + if (style.parentNode) style.parentNode.removeChild(style) }) + window.customStyleList = null } NProgress.done() toggleShowBar() }) - // 监听浏览器的前进和后退操作 - $(window).on("popstate", function (event) { - if (event.originalEvent.state) { - // 如果存在历史记录状态,触发 Pjax 加载对应内容 - $.pjax({ - url: event.originalEvent.state.url, - container: "#container", - fragment: "#container", - timeout: 8000, - push: false, - scrollTo: false - }) - } - }) + // 普通页面前进/后退交给 PJAX 自带的 popstate.pjax 处理。 + // 弹框历史只有 h5Popup 标记,没有页面 url/container,不能再次发起页面请求。 } })() diff --git a/src/main/resources/views/platform/zhgh/democratic/proposal/common/submit.js b/src/main/resources/views/platform/zhgh/democratic/proposal/common/submit.js new file mode 100644 index 0000000..c35942e --- /dev/null +++ b/src/main/resources/views/platform/zhgh/democratic/proposal/common/submit.js @@ -0,0 +1,125 @@ +// 仅供提案办理页面使用:同一页面的表单、并案和转办共用办理锁,查询与分页仍使用原 mixin。 +const PROPOSAL_SUBMIT_MIXIN = { + data() { + return { + formLoading: false, + formAction: '', + proposalSubmitting: false + } + }, + created() { + // H5 每个页面只注册一个历史栈所有者,子组件共享它,避免多层弹框重复监听 popstate。 + if (this === this.$root && this.$options.proposalH5) { + this.proposalPopupRecords = new Map() + this.proposalPopupSerial = 0 + this.proposalPopupHandle = window.h5PopupHistory.register((keys, closedKeys) => { + closedKeys.slice().reverse().forEach((key) => { + const record = this.proposalPopupRecords.get(key) + if (record) { + record.fromHistory = true + record.close() + record.fromHistory = false + } + }) + }) + // PJAX 只替换 DOM,不会自动销毁 Vue;替换所属容器前释放旧页面的历史栈所有权。 + this.proposalBeforeReplace = (event) => { + if (this.$el && (event.target === this.$el || event.target.contains(this.$el))) { + this.$destroy() + } + } + $(document).on('pjax:beforeReplace', this.proposalBeforeReplace) + } + // options 中只列出本页面已有的弹框状态,不创建新弹框或改变业务字段。 + ;(this.$options.proposalPopupFields || []).forEach((field) => { + this.$root.trackProposalPopup(this, field) + }) + }, + beforeDestroy() { + this.proposalDisposed = true + // 每个 H5 根实例仅监听自己的页面替换;主动销毁或 PJAX 离开后均移除监听,避免残留实例重复响应。 + if (this.proposalBeforeReplace) { + $(document).off('pjax:beforeReplace', this.proposalBeforeReplace) + } + if (this.proposalPopupUnwatch) this.proposalPopupUnwatch.forEach((stop) => stop()) + if (this !== this.$root && this.$root.proposalPopupRecords) { + this.$root.proposalPopupRecords.forEach((record, key) => { + if (record.vm === this) { + this.$root.proposalPopupHandle.close(key) + this.$root.proposalPopupRecords.delete(key) + } + }) + } + if (this === this.$root && this.proposalPopupHandle) this.proposalPopupHandle.destroy() + }, + methods: { + /** action 为按钮标识,work 返回包含校验、确认和所有提交请求的 Promise;结束后统一释放锁。 */ + runProposalSubmit(action, work) { + if (this.proposalDisposed || this.$root.proposalDisposed || this.formLoading || this.$root.proposalSubmitting) return Promise.resolve() + this.$set(this, 'formLoading', true) + this.$set(this, 'formAction', action) + this.$set(this.$root, 'proposalSubmitting', true) + return Promise.resolve().then(() => { + if (!this.proposalDisposed && !this.$root.proposalDisposed) return work() + }).catch(() => { + // 表单校验、确认取消以及 axios 拦截器已负责提示;不重复提示或重发流程请求。 + }).finally(() => { + if (this.proposalDisposed || this.$root.proposalDisposed) return + this.$set(this, 'formLoading', false) + this.$set(this, 'formAction', '') + this.$set(this.$root, 'proposalSubmitting', false) + }) + }, + /** 将已有 v-model 与全局历史栈同步;返回键只关闭最上层,程序关闭也同步回退历史。 */ + trackProposalPopup(vm, field) { + const root = this.$root + if (!root.proposalPopupHandle) return + const key = 'proposal-popup-' + (++root.proposalPopupSerial) + const record = { vm, field, fromHistory: false, close: () => vm.$set(vm, field, false) } + root.proposalPopupRecords.set(key, record) + const stop = vm.$watch(field, (visible) => { + if (record.fromHistory) return + if (visible) root.proposalPopupHandle.open(key) + else root.proposalPopupHandle.close(key) + }, { sync: true }) + if (!vm.proposalPopupUnwatch) vm.proposalPopupUnwatch = [] + vm.proposalPopupUnwatch.push(stop) + if (vm[field]) root.proposalPopupHandle.open(key) + }, + /** options 沿用 Vant 确认框参数;确认/取消均先回退确认框历史,再向调用方传递结果。 */ + proposalConfirm(options) { + const root = this.$root + if (this.proposalDisposed || root.proposalDisposed) return Promise.reject('cancel') + const key = 'proposal-confirm-' + (++root.proposalPopupSerial) + let settled = false + let cancelConfirmation + const cancelled = new Promise((resolve, reject) => { cancelConfirmation = reject }) + root.proposalPopupRecords.set(key, { close: () => { + if (!settled) { + // Vant Dialog.close() 只隐藏界面,不会拒绝 confirm Promise,必须同时结束等待。 + cancelConfirmation('cancel') + this.$dialog.close() + } + } }) + // 等待旧页面历史释放期间也监听取消,避免页面已销毁后才打开确认框。 + const confirmation = root.proposalPopupHandle.open(key).then(() => { + if (this.proposalDisposed || root.proposalDisposed) return Promise.reject('cancel') + return this.$dialog.confirm(Object.assign({}, options, { closeOnPopstate: false })) + }) + return Promise.race([confirmation, cancelled]).finally(() => { + settled = true + return root.proposalPopupHandle.close(key).then(() => { + root.proposalPopupRecords.delete(key) + }) + }) + }, + /** 关闭本页面所有提案弹框后执行成功回调,防止提交完成后返回到已关闭的审核弹层。 */ + finishProposalSubmit(work) { + const root = this.$root + if (this.proposalDisposed || root.proposalDisposed) return Promise.resolve() + return (root.proposalPopupHandle ? root.proposalPopupHandle.clear() : Promise.resolve()).then(() => { + if (!this.proposalDisposed && !root.proposalDisposed) return work() + }) + } + } +} diff --git a/src/main/resources/views/platform/zhgh/democratic/proposal/transact/commissioner/index.html b/src/main/resources/views/platform/zhgh/democratic/proposal/transact/commissioner/index.html index fe02781..8662d9f 100644 --- a/src/main/resources/views/platform/zhgh/democratic/proposal/transact/commissioner/index.html +++ b/src/main/resources/views/platform/zhgh/democratic/proposal/transact/commissioner/index.html @@ -84,8 +84,8 @@ layout("/layouts/platform.html"){ @@ -98,7 +98,7 @@ layout("/layouts/platform.html"){
提案委员会成员意见
- +
@@ -122,8 +122,8 @@ layout("/layouts/platform.html"){ - 取消 - 提交 + 取消 + 提交
@@ -133,13 +133,15 @@ layout("/layouts/platform.html"){