diff --git a/src/views/flow/index.vue b/src/views/flow/index.vue index f87c03b..b2faa8e 100644 --- a/src/views/flow/index.vue +++ b/src/views/flow/index.vue @@ -977,7 +977,10 @@ let flowPollTimer = null; let flowDisposed = false; /** - * 清理 clearFlowPollTimer 对应的数据或交互;作用范围仅限当前组件或模块。 + * 清理当前试运行结果轮询定时器。 + * + * 实现思路:每次重新调度、终止流程或销毁页面前都先清除旧定时器,确保同一时刻 + * 只有一条轮询链继续执行,避免多个请求竞争修改流程状态。 */ const clearFlowPollTimer = () => { if (flowPollTimer !== null) { @@ -987,16 +990,36 @@ const clearFlowPollTimer = () => { }; /** - * 调度 scheduleFlowView 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} currentInstId 当前流程实例 ID - * @param {*} taskId 任务 ID - * @param {*} delay 延迟毫秒数 + * 判断轮询结果是否仍属于当前页面正在关注的流程实例。 + * + * 实现思路:普通试运行只接受当前 instId 的响应,防止上一次请求较晚返回后覆盖 + * 新一次试运行状态;日志模式是指定任务的一次性查询,不受当前试运行实例限制。 + * + * @param {string | number | null} currentInstId 本次轮询对应的流程实例 ID + * @returns {boolean} 页面仍有效且允许处理该实例结果时返回 true + */ +const isCurrentFlowPoll = (currentInstId) => { + if (flowDisposed) return false; + if (flowInfoData.value.isLog) return true; + return String(currentInstId ?? "") === String(instId.value ?? ""); +}; + +/** + * 调度下一次试运行结果查询。 + * + * 实现思路:调度前再次校验实例归属,并替换已有定时器。这样接口临时异常时可以 + * 继续等待最终状态,同时旧实例或已销毁页面不会重新启动轮询。 + * + * @param {string | number | null} currentInstId 当前流程实例 ID + * @param {string | number | null} taskId 指定的任务 ID + * @param {number} delay 轮询间隔,单位毫秒 */ const scheduleFlowView = (currentInstId, taskId = null, delay = 2000) => { + if (!isCurrentFlowPoll(currentInstId)) return; clearFlowPollTimer(); flowPollTimer = window.setTimeout(() => { flowPollTimer = null; - if (!flowDisposed) loopFlowView(currentInstId, taskId); + if (isCurrentFlowPoll(currentInstId)) loopFlowView(currentInstId, taskId); }, delay); }; @@ -1448,76 +1471,131 @@ const emitLoopContainerOutputs = (logs) => { }); }; -const loopFlowView = async (instId, taskId = null) => { - // 轮询思路:优先使用任务 ID 查询指定执行,否则查询实例最新状态,再统一映射节点状态。 - // 仅在非日志模式下根据终态停止或续排定时器,日志回放保持为一次性快照。 +/** + * 判断执行日志是否属于主流程层级。 + * + * 实现思路:兼容后端可能返回的 null、空数组、空对象及其 JSON 字符串形式;非空 + * iteration 表示循环或子流程内部执行,不能用来判定整个试运行已经结束。 + * + * @param {*} iteration 执行日志携带的迭代层级 + * @returns {boolean} 日志属于主流程层级时返回 true + */ +const isMainFlowIteration = (iteration) => { + if (iteration == null) return true; + if (Array.isArray(iteration)) return iteration.length === 0; + if (typeof iteration === "object") return Object.keys(iteration).length === 0; + if (typeof iteration !== "string") return false; + return ["", "[]", "{}", "null"].includes(iteration.trim().toLowerCase()); +}; + +/** + * 从执行日志中查找主流程结束节点的最新记录。 + * + * 实现思路:优先兼容运行日志的 nodeType/action 标识,同时使用当前画布结束节点 ID + * 兜底,避免后端字段缺失或格式差异导致成功状态无法同步到页面。 + * + * @param {Array} logs 当前流程实例的执行日志 + * @returns {Object | undefined} 主流程结束节点的最新日志,不存在时返回 undefined + */ +const findMainFlowEndLog = (logs) => { + const endNodeIds = new Set( + (lf.getGraphData().nodes || []) + .filter((node) => String(node?.type || "").toLowerCase() === "end") + .map((node) => String(node.id)), + ); + return [...logs].reverse().find((item) => { + const nodeType = String(item?.nodeType || "").toUpperCase(); + const action = String(item?.action || "").toUpperCase(); + const isEndNode = nodeType === "END" || action === "END" || endNodeIds.has(String(item?.nodeId || "")); + return isEndNode && isMainFlowIteration(item?.iteration); + }); +}; + +/** + * 查询并同步试运行结果,直到当前实例进入成功、失败、暂停或终止状态。 + * + * 实现思路:每次响应先校验实例归属,再更新节点运行态;只有主流程结束节点成功时 + * 才开放发布。请求异常或业务响应暂不可用时继续轮询,避免一次临时失败让页面永久 + * 停留在“试运行中”;日志回放仍保持一次性查询。 + * + * @param {string | number | null} currentInstId 当前流程实例 ID + * @param {string | number | null} taskId 指定的任务 ID + * @returns {Promise} 状态同步完成后结束 + */ +const loopFlowView = async (currentInstId, taskId = null) => { const params = { itemId: flowInfoData.value.itemId, - instId, + instId: currentInstId, }; if (taskId) { params.taskId = taskId; } - const res = await flowView(params); - if (flowDisposed) return; - if (res.code === 200) { - const arr = res.data || []; - if (arr.length === 0) { - if (!flowInfoData.value.isLog) scheduleFlowView(instId, taskId); - return; + let res; + try { + res = await flowView(params); + } catch { + // 查询异常不代表试运行失败;保留当前节点结果并继续查询,直到拿到后端明确终态。 + if (!flowInfoData.value.isLog && isCurrentFlowPoll(currentInstId)) { + scheduleFlowView(currentInstId, taskId); } - if (arr.length > 0) hasRunResult.value = true; - arr.forEach((item) => { - emitter.emit("changeNodeState", item); - const runtimeNodeId = String(item?.nodeId || ""); - const parentNodeId = resolveSubFlowParentNodeId(runtimeNodeId); - if (parentNodeId && String(item?.status || "").toUpperCase() === "FAILED") { - emitter.emit("changeNodeState", { - ...item, - nodeId: parentNodeId, - message: `子流程内部节点[${item.action || item.nodeType || "未知节点"}]执行失败:${item.message || "未知原因"}`, - }); - } - }); - emitLoopIterationOutputs(arr); - emitLoopContainerOutputs(arr); - emitSubFlowContainerOutputs(arr); - oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData())); - const first = arr[0]; - const reversed = [...arr].reverse(); - const mainEnd = reversed.find( - (item) => - String(item?.nodeType || "").toUpperCase() === "END" && - (item?.iteration == null || - item.iteration === "" || - (Array.isArray(item.iteration) && item.iteration.length === 0)), - ); - const failed = reversed.find( - (item) => String(item?.status || "").toUpperCase() === "FAILED", - ); - const last = arr[arr.length - 1]; - const mainEndStatus = String(mainEnd?.status || "").toUpperCase(); - const lastStatus = String(last?.status || "").toUpperCase(); - if (failed) { - if (!flowInfoData.value.isLog) { - flowState.value = "testRunError"; - totalRunningTime.value = (failed.endTime || Date.now()) - first.startTime; - flowStore.updateDisableForm(false); - } - } else if (mainEndStatus === "SUCCESS") { - if (!flowInfoData.value.isLog) { - flowStore.updateDisableForm(false); - flowState.value = "testRunFinish"; - totalRunningTime.value = mainEnd.endTime - first.startTime; - } - } else if (["STOPPED", "PAUSED"].includes(lastStatus)) { - if (!flowInfoData.value.isLog) { - flowState.value = lastStatus === "PAUSED" ? "pause" : "stop"; - } - } else if (!flowInfoData.value.isLog) { - // END is first persisted as RUNNING and updated to SUCCESS after the handler returns. - scheduleFlowView(instId, taskId); + return; + } + if (!isCurrentFlowPoll(currentInstId)) return; + if (res.code !== 200) { + if (!flowInfoData.value.isLog) scheduleFlowView(currentInstId, taskId); + return; + } + + const arr = res.data || []; + if (arr.length === 0) { + if (!flowInfoData.value.isLog) scheduleFlowView(currentInstId, taskId); + return; + } + if (arr.length > 0) hasRunResult.value = true; + arr.forEach((item) => { + emitter.emit("changeNodeState", item); + const runtimeNodeId = String(item?.nodeId || ""); + const parentNodeId = resolveSubFlowParentNodeId(runtimeNodeId); + if (parentNodeId && String(item?.status || "").toUpperCase() === "FAILED") { + emitter.emit("changeNodeState", { + ...item, + nodeId: parentNodeId, + message: `子流程内部节点[${item.action || item.nodeType || "未知节点"}]执行失败:${item.message || "未知原因"}`, + }); } + }); + emitLoopIterationOutputs(arr); + emitLoopContainerOutputs(arr); + emitSubFlowContainerOutputs(arr); + oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData())); + const first = arr[0]; + const reversed = [...arr].reverse(); + const mainEnd = findMainFlowEndLog(arr); + const failed = reversed.find( + (item) => String(item?.status || "").toUpperCase() === "FAILED", + ); + const last = arr[arr.length - 1]; + const mainEndStatus = String(mainEnd?.status || "").toUpperCase(); + const lastStatus = String(last?.status || "").toUpperCase(); + if (failed) { + if (!flowInfoData.value.isLog) { + flowState.value = "testRunError"; + totalRunningTime.value = (failed.endTime || Date.now()) - first.startTime; + flowStore.updateDisableForm(false); + } + } else if (mainEndStatus === "SUCCESS") { + if (!flowInfoData.value.isLog) { + flowStore.updateDisableForm(false); + flowState.value = "testRunFinish"; + totalRunningTime.value = mainEnd.endTime - first.startTime; + } + } else if (["STOPPED", "PAUSED"].includes(lastStatus)) { + if (!flowInfoData.value.isLog) { + flowState.value = lastStatus === "PAUSED" ? "pause" : "stop"; + } + } else if (!flowInfoData.value.isLog) { + // END is first persisted as RUNNING and updated to SUCCESS after the handler returns. + scheduleFlowView(currentInstId, taskId); } };