diff --git a/src/api/flow/flow.js b/src/api/flow/flow.js index 572a89b..d8ea37b 100644 --- a/src/api/flow/flow.js +++ b/src/api/flow/flow.js @@ -21,6 +21,14 @@ export function flowVersionDetail(versionId) { return request({ url: `/flow/version/${versionId}`, method: 'get' }) } +export function flowSubFlowOptions(currentItemId) { + return request({ + url: '/flow/subflows', + method: 'get', + params: { currentItemId } + }) +} + export function flowRestoreDraft(versionId, data) { return request({ url: `/flow/version/${versionId}/restore-draft`, method: 'post', data }) } diff --git a/src/utils/flow.js b/src/utils/flow.js index 1f56eff..5344c54 100644 --- a/src/utils/flow.js +++ b/src/utils/flow.js @@ -221,6 +221,83 @@ export const removeSwitchEdge = (nodeId, sourceAnchorId) => { matchedEdges.forEach(edge => lf.deleteEdge(edge.id)) } +const buildParentByChild = (data) => { + const result = new Map(); + ;(data.nodes || []).forEach((node) => { + ;(node.children || []).forEach((childId) => result.set(String(childId), String(node.id))); + }); + ;(data.nodes || []).forEach((node) => { + if (node.properties?.parentId) result.set(String(node.id), String(node.properties.parentId)); + }); + return result; +}; + +const collectPredecessors = (data, targetId, result) => { + const pending = [String(targetId)]; + while (pending.length) { + const currentId = pending.pop(); + ;(data.edges || []).forEach((edge) => { + if (String(edge.targetNodeId) !== currentId) return; + const sourceId = String(edge.sourceNodeId); + if (result.has(sourceId)) return; + result.add(sourceId); + pending.push(sourceId); + }); + } +}; + +/** + * Returns nodes whose values are available before the target executes. Loop children + * may read earlier siblings and nodes before their loop, but never later children, + * another loop body, or nodes after the loop. + */ +export const getUpstreamNodeIds = (data, nodeId) => { + const graph = data || { nodes: [], edges: [] }; + const targetId = String(nodeId); + const nodeById = new Map((graph.nodes || []).map((node) => [String(node.id), node])); + const parentByChild = buildParentByChild(graph); + const result = new Set( + (graph.nodes || []).filter((node) => node.type === 'start').map((node) => String(node.id)) + ); + + // Walk from the node through every containing loop. At each level, only nodes in + // that exact scope are visible; this also supports legacy nested-loop workflows. + let currentTargetId = targetId; + let currentScopeId = parentByChild.get(targetId) || ''; + const visitedScopes = new Set(); + while (!visitedScopes.has(currentTargetId)) { + visitedScopes.add(currentTargetId); + const upstream = new Set(); + collectPredecessors(graph, currentTargetId, upstream); + upstream.forEach((sourceId) => { + const sourceScopeId = parentByChild.get(sourceId) || ''; + if (sourceScopeId === currentScopeId && nodeById.has(sourceId)) result.add(sourceId); + }); + if (!currentScopeId) break; + currentTargetId = currentScopeId; + currentScopeId = parentByChild.get(currentScopeId) || ''; + } + result.delete(targetId); + return result; +}; + +export const getUpstreamInput = (nodeId) => { + const data = lf.getGraphData(); + const startIds = new Set( + data.nodes.filter((node) => node.type === 'start').map((node) => String(node.id)) + ); + const upstreamIds = getUpstreamNodeIds(data, nodeId); + return getInput(nodeId) + .filter((option) => upstreamIds.has(String(option.value))) + .map((option) => ({ + ...option, + children: option.children.filter((group) => + startIds.has(String(option.value)) ? group.value === 'input' : group.value === 'output' + ) + })) + .filter((option) => option.children.some((group) => group.children?.length)); +} + export const convertToTree = (data) => { // 创建 id 到节点的映射 const nodeMap = new Map(); diff --git a/src/utils/flowResources.js b/src/utils/flowResources.js index 4f28e38..e5e9392 100644 --- a/src/utils/flowResources.js +++ b/src/utils/flowResources.js @@ -247,6 +247,16 @@ export const pruneUnusedNodeResources = (sourceGraph) => { if (binding?.slotKey) usedSlotsByRole.get(target.roleKey).add(binding.slotKey) }) }) + graph.nodes.filter((node) => node?.properties?.action === 'SUB_FLOW').forEach((node) => { + ;(node.properties?.subFlow?.resourceRoles || []).forEach((role) => { + if (!role?.roleKey) return + usedRoleKeys.add(role.roleKey) + if (!usedSlotsByRole.has(role.roleKey)) usedSlotsByRole.set(role.roleKey, new Set()) + ;(role.deviceSlots || []).forEach((slot) => { + if (slot?.slotKey) usedSlotsByRole.get(role.roleKey).add(slot.slotKey) + }) + }) + }) let removedSlots = 0 start.properties.resourceRoles.forEach((role) => { @@ -276,7 +286,7 @@ export const pruneUnusedNodeResources = (sourceGraph) => { export const validateFlowResources = (sourceGraph) => { const graph = normalizeFlowResourceSchema(sourceGraph) const roles = getResourceRoles(graph) - const errors = [] + const errors = validateFlowReferences(graph) const roleKeys = new Set() roles.forEach((role) => { @@ -316,6 +326,75 @@ export const validateFlowResources = (sourceGraph) => { return { graph, errors } } +const collectActiveQuotes = (value, result = []) => { + if (Array.isArray(value)) { + value.forEach((item) => collectActiveQuotes(item, result)) + return result + } + if (!value || typeof value !== 'object') return result + if (value.type === 'quote' && Array.isArray(value.quote) && value.quote.length >= 3) { + result.push(value.quote) + } + if (value.nameType === 'quote' && Array.isArray(value.nameQuote) && value.nameQuote.length >= 3) { + result.push(value.nameQuote) + } + Object.values(value).forEach((item) => collectActiveQuotes(item, result)) + return result +} + +const findReferenceCycle = (dependencies) => { + const states = new Map() + const stack = [] + const visit = (nodeId) => { + if (states.get(nodeId) === 2) return null + if (states.get(nodeId) === 1) { + const index = stack.indexOf(nodeId) + return [...stack.slice(Math.max(0, index)), nodeId] + } + states.set(nodeId, 1) + stack.push(nodeId) + for (const sourceId of dependencies.get(nodeId) || []) { + if (!dependencies.has(sourceId)) continue + const cycle = visit(sourceId) + if (cycle) return cycle + } + stack.pop() + states.set(nodeId, 2) + return null + } + for (const nodeId of dependencies.keys()) { + const cycle = visit(nodeId) + if (cycle) return cycle + } + return null +} + +/** Validates persisted references as well as choices made through the parameter panels. */ +export const validateFlowReferences = (graph) => { + const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [] + const nodeById = new Map(nodes.map((node) => [String(node.id), node])) + const referencesByNode = new Map() + const dependencies = new Map() + + nodes.forEach((node) => { + const references = collectActiveQuotes(node.properties || []) + .map((quote) => quote.map((part) => String(part))) + referencesByNode.set(String(node.id), references) + dependencies.set(String(node.id), new Set(references.map((quote) => quote[0]))) + }) + + const cycle = findReferenceCycle(dependencies) + if (cycle) { + const names = cycle.map((nodeId) => nodeById.get(nodeId)?.properties?.name || nodeId) + return [{ + nodeId: cycle[0], + message: `节点参数存在循环引用:${names.join(' -> ')}`, + }] + } + + return [] +} + export const sanitizeFlowResourceDefinition = (sourceGraph) => { const graph = normalizeFlowResourceSchema(sourceGraph) const start = getStartNode(graph) diff --git a/src/views/flow/components/LoopIterationResults.vue b/src/views/flow/components/LoopIterationResults.vue new file mode 100644 index 0000000..cb311a6 --- /dev/null +++ b/src/views/flow/components/LoopIterationResults.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/src/views/flow/components/NodeState.vue b/src/views/flow/components/NodeState.vue index e1dbaae..d53e966 100644 --- a/src/views/flow/components/NodeState.vue +++ b/src/views/flow/components/NodeState.vue @@ -19,9 +19,9 @@ ref="popoverRef" trigger="click" placement="right-start" - :width="480" + :width="props.popoverWidth" :teleported="true" - popper-class="flow-result-popover" + :popper-class="props.popperClass" @show="popoverVisible = true" @hide="popoverVisible = false" > @@ -51,6 +51,8 @@ import { CircleCloseFilled, Loading, SuccessFilled, VideoPause, View } from "@el const props = defineProps({ state: { type: String, default: "NORMAL" }, runtimes: { type: Number, default: 0 }, + popoverWidth: { type: Number, default: 480 }, + popperClass: { type: String, default: "flow-result-popover" }, }); const slots = useSlots(); @@ -189,4 +191,12 @@ defineExpose({ closePopover }); font-size: 12px; } } + +.flow-loop-result-popover { + max-width: min(720px, calc(100vw - 32px)); + + .result-section__content { + max-height: 520px; + } +} diff --git a/src/views/flow/components/NodeTitle.vue b/src/views/flow/components/NodeTitle.vue index e83ebdf..f8e4e76 100644 --- a/src/views/flow/components/NodeTitle.vue +++ b/src/views/flow/components/NodeTitle.vue @@ -98,6 +98,7 @@ const flowContextActions = new Set([ "SUB_START", "SUB_END", "START_LOOP", + "SUB_FLOW", "STOP_LOOP", "BRANCH", "GET_CURRENT_OBJECT", diff --git a/src/views/flow/components/ParamsDrawer.vue b/src/views/flow/components/ParamsDrawer.vue index f88c393..d26cd31 100644 --- a/src/views/flow/components/ParamsDrawer.vue +++ b/src/views/flow/components/ParamsDrawer.vue @@ -18,6 +18,7 @@ :is="currentComponent" ref="paramsComponentRef" :data="props.data" + v-bind="componentExtraProps" @save-success="handleSaveSuccess" @save-error="handleSaveError" @extract-step="handleExtractStep" @@ -41,10 +42,13 @@ import SwitchNodeParams from './params/SwitchNodeParams.vue' import ActionQueueParams from './params/ActionQueueParams.vue' import MediaAnalysisParams from './params/MediaAnalysisParams.vue' import ImageAnalysisParams from './params/ImageAnalysisParams.vue' +import EndParams from './params/EndParams.vue' +import SubFlowParams from './params/SubFlowParams.vue' const props = defineProps({ drawer: Boolean, - data: Object + data: Object, + flowId: String }) const emits = defineEmits(['close', 'extract-step']) @@ -61,6 +65,8 @@ const drawerSize = computed(() => ( const currentComponent = computed(() => { const type = props.data?.type if (type === 'start') return StartParams + if (type === 'end') return EndParams + if (type === 'subFlow') return SubFlowParams if (['currentLoop', 'serviceNode', 'sleep', 'loop'].includes(type)) return BasicNodeParams if (type === 'code') return CodeNodeParams if (type === 'http') return HttpNodeParams @@ -75,6 +81,10 @@ const currentComponent = computed(() => { return null }) +const componentExtraProps = computed(() => ( + props.data?.type === 'subFlow' ? { flowId: props.flowId } : {} +)) + // 这里提供一个公开方法供父组件调用 /** * 确认 confirmSave 对应的数据或交互;作用范围仅限当前组件或模块。 diff --git a/src/views/flow/components/SubFlowExecutionResults.vue b/src/views/flow/components/SubFlowExecutionResults.vue new file mode 100644 index 0000000..54f6c45 --- /dev/null +++ b/src/views/flow/components/SubFlowExecutionResults.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/src/views/flow/components/TaskExecutionLog.vue b/src/views/flow/components/TaskExecutionLog.vue index ac73bba..c698d7d 100644 --- a/src/views/flow/components/TaskExecutionLog.vue +++ b/src/views/flow/components/TaskExecutionLog.vue @@ -3,14 +3,29 @@
-
-
{{ formatDate(log.logTime) }}
+
@@ -102,8 +123,8 @@ + + diff --git a/src/views/flow/components/params/HttpNodeParams.vue b/src/views/flow/components/params/HttpNodeParams.vue index 11cee0a..b3417fb 100644 --- a/src/views/flow/components/params/HttpNodeParams.vue +++ b/src/views/flow/components/params/HttpNodeParams.vue @@ -25,7 +25,7 @@ clearable /> - + @@ -57,10 +57,10 @@ + " @change="(value) => cascaderChange(value, property)" /> @@ -89,7 +89,7 @@ - + @@ -121,10 +121,10 @@ + " @change="(value) => cascaderChange(value, property)" /> - + @@ -186,10 +186,10 @@ + " @change="(value) => cascaderChange(value, property)" /> @@ -308,13 +308,13 @@ } " v-model="property.quote" - :checkStrictly="true" + :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择" @visible-change=" (visible) => visibleChange(visible, index, property.quote, 'httpBody') " - @change="(value) => cascaderChange(value, index, httpNodeData, 'body')" + @change="(value) => cascaderChange(value, property)" /> diff --git a/src/views/flow/components/params/ImageAnalysisParams.vue b/src/views/flow/components/params/ImageAnalysisParams.vue index 9540b51..779cfad 100644 --- a/src/views/flow/components/params/ImageAnalysisParams.vue +++ b/src/views/flow/components/params/ImageAnalysisParams.vue @@ -151,7 +151,7 @@ + + diff --git a/src/views/flow/components/params/SwitchNodeParams.vue b/src/views/flow/components/params/SwitchNodeParams.vue index 8bb799c..2073e29 100644 --- a/src/views/flow/components/params/SwitchNodeParams.vue +++ b/src/views/flow/components/params/SwitchNodeParams.vue @@ -141,7 +141,7 @@ import { reactive, ref, watch } from "vue"; import { Plus, Minus } from "@element-plus/icons-vue"; import { v4 as randomUUID } from "uuid"; -import { getInput, removeSwitchEdge } from "@/utils/flow"; +import { getUpstreamInput, removeSwitchEdge } from "@/utils/flow"; const props = defineProps({ data: Object }); const emit = defineEmits(["save-success", "save-error"]); @@ -196,7 +196,7 @@ const normalizeQuotePath = (value) => { /** 深拷贝已保存条件,确保取消编辑时不会污染节点属性。 */ const initData = () => { // 必须先提供选项树再赋已有值,否则级联组件无法将节点 ID 路径解析成可见标签。 - quoteOptions.value = getInput(props.data.id) || []; + quoteOptions.value = getUpstreamInput(props.data.id) || []; const source = props.data?.properties?.nodeParams || props.data?.properties?.conditions || []; formData.nodeParams = JSON.parse(JSON.stringify(source)); if (!formData.nodeParams.length) formData.nodeParams.push(createCondition()); @@ -257,7 +257,7 @@ const resetCompareValue = (item) => { */ const refreshQuoteOptions = (visible, item, field) => { if (!visible) return; - quoteOptions.value = getInput(props.data.id) || []; + quoteOptions.value = getUpstreamInput(props.data.id) || []; item[field] = item[field] || ""; }; diff --git a/src/views/flow/components/params/useQuote.js b/src/views/flow/components/params/useQuote.js index 8007e51..35c9861 100644 --- a/src/views/flow/components/params/useQuote.js +++ b/src/views/flow/components/params/useQuote.js @@ -1,75 +1,35 @@ -/** - * 文件说明:参数引用组合逻辑,维护可引用字段和级联选择状态。 - * 作用范围:仅服务于流程设计器模块。 - */ import { ref } from 'vue' -import { getInput } from '@/utils/flow' +import { getUpstreamInput } from '@/utils/flow' -/** - * 处理 useQuote 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} nodeId 流程节点 ID - */ export function useQuote(nodeId) { const quoteOptions = ref([]) const cascaderRefs = ref([]) - /** - * 处理 handleTypeChange 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} index 目标项索引 - * @param {*} formData 参数表单数据 - * @param {*} type 目标类型 - */ - const handleTypeChange = (index, formData, type = 'default') => { - // 根据节点类型处理不同数据源 - // 这里简化为通用逻辑,具体实现需根据实际数据结构调整 - if (formData.nodeParams[index].type === 'input') { - formData.nodeParams[index].quote = '' + const refreshQuoteOptions = () => { + quoteOptions.value = getUpstreamInput(nodeId) || [] + } + + // The option tree must exist before Cascader can render a persisted path. + refreshQuoteOptions() + + const handleTypeChange = (property) => { + if (!property) return + if (property.type === 'input') { + property.quote = [] } else { - formData.nodeParams[index].input = '' - const option = getInput(nodeId) - if (option) quoteOptions.value = option + property.input = '' + refreshQuoteOptions() } } - /** - * 处理 cascaderChange 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} value 待处理的新值 - * @param {*} index 目标项索引 - * @param {*} formData 参数表单数据 - * @param {*} field 调用方传入的 field 参数 - */ - const cascaderChange = (value, index, formData, field = 'nodeParams') => { - const selectedOptions = cascaderRefs.value[index]?.getCheckedNodes(true) - if (selectedOptions && selectedOptions.length) { - formData[field][index].quote = value - formData[field][index].quoteType = selectedOptions[0].data.type - } + const cascaderChange = (value, property) => { + if (!property) return + property.quote = Array.isArray(value) ? [...value] : [] + property.quoteType = value?.[1] === 'input' ? 'input' : 'output' } - /** - * 处理 visibleChange 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} visible 是否可见 - * @param {*} index 目标项索引 - * @param {*} currentQuote 调用方传入的 currentQuote 参数 - * @param {*} formData 参数表单数据 - * @param {*} field 调用方传入的 field 参数 - */ - const visibleChange = (visible, index, currentQuote, formData, field = 'nodeParams') => { - if (visible) { - const option = getInput(nodeId) - if (option) { - quoteOptions.value = option - const currentValue = [...currentQuote] - if (cascaderRefs.value[index] && currentValue.length > 0) { - setTimeout(() => { - formData[field][index].quote = [] - setTimeout(() => { - formData[field][index].quote = currentValue - }, 0) - }, 0) - } - } - } + const visibleChange = (visible) => { + if (visible) refreshQuoteOptions() } return { @@ -77,6 +37,7 @@ export function useQuote(nodeId) { cascaderRefs, handleTypeChange, cascaderChange, - visibleChange + visibleChange, + refreshQuoteOptions, } } diff --git a/src/views/flow/config.js b/src/views/flow/config.js index c4e2edf..cb7451c 100644 --- a/src/views/flow/config.js +++ b/src/views/flow/config.js @@ -175,6 +175,18 @@ export const collapseList = [ outputParams: [], outputType: 'json' }, + { + icon: planSvg, + name: "子流程", + type: "subFlow", + action: 'SUB_FLOW', + nodeType: 'NONE', + desc: "执行同一项目中已发布的工作流", + subFlow: {}, + nodeParams: [], + outputParams: [], + outputType: 'json' + }, { icon: stopLoopSvg, name: "结束循环", diff --git a/src/views/flow/index.vue b/src/views/flow/index.vue index 76f4bb2..5825ca8 100644 --- a/src/views/flow/index.vue +++ b/src/views/flow/index.vue @@ -186,6 +186,7 @@ v-if="showParamsDrawer" :drawer="true" :data="paramsDrawerData" + :flow-id="flowInfoData.itemId" @close="showParamsDrawer = false" @extract-step="handleExtractQueueStep" /> @@ -600,7 +601,7 @@ const handleConnect = (connection) => { */ const handleNodeDoubleClick = ({ node }) => { const data = lf.getGraphData().nodes.find((item) => item.id === node.id); - if (!data || ["selectArea", "stopLoop", "subStart", "subEnd", "end"].includes(data.type)) return; + if (!data || ["selectArea", "stopLoop", "subStart", "subEnd"].includes(data.type)) return; showParamsDrawer.value = true; paramsDrawerData.value = data; }; @@ -1061,6 +1062,318 @@ const changeState = (value) => { loopFlowView(value.instId); }; +const resolveSubFlowParentNodeId = (runtimeNodeId) => { + const markerIndexes = [runtimeNodeId.indexOf("__sf__"), runtimeNodeId.indexOf("__subflow__")] + .filter((index) => index > 0); + if (markerIndexes.length) { + return runtimeNodeId.slice(0, Math.min(...markerIndexes)); + } + if (!runtimeNodeId.startsWith("sf_")) return null; + const separatorIndex = runtimeNodeId.lastIndexOf("_"); + if (separatorIndex <= 3) return null; + const rootPrefix = runtimeNodeId.slice(3, separatorIndex); + const parentNode = lf.getGraphData().nodes?.find( + (node) => node.type === "subFlow" && String(node.id || "").startsWith(rootPrefix), + ); + return parentNode?.id || null; +}; + +const parseRuntimeValue = (value) => { + if (!value) return {}; + if (typeof value === "object") return value; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +}; + +const runtimeIterationPath = (item) => { + const value = item?.iterations ?? item?.iteration; + if (Array.isArray(value)) return value.filter(part => part !== null && part !== ""); + if (value === null || value === undefined || value === "") return []; + return String(value).split(/[-.,/]/).map(part => part.trim()).filter(Boolean); +}; + +const emitLoopIterationOutputs = (logs) => { + const grouped = new Map(); + logs.forEach((log) => { + const iterations = runtimeIterationPath(log); + if (!iterations.length || !log?.nodeId) return; + const key = String(log.nodeId); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push({ log, iterations }); + }); + + grouped.forEach((entries, nodeId) => { + const latest = entries[entries.length - 1].log; + const failed = entries.find(({ log }) => String(log?.status || "").toUpperCase() === "FAILED"); + const running = entries.find(({ log }) => String(log?.status || "").toUpperCase() === "RUNNING"); + const iterationOutputs = entries.map(({ log, iterations }) => ({ + iteration: iterations.join("."), + status: log.status, + startTime: log.startTime, + endTime: log.endTime, + durationMs: log.endTime && log.startTime ? log.endTime - log.startTime : null, + input: parseRuntimeValue(log.paramsIn), + output: parseRuntimeValue(log.paramsOut), + error: String(log.status || "").toUpperCase() === "FAILED" ? (log.message || "") : "", + })); + emitter.emit("changeNodeState", { + ...latest, + nodeId, + status: failed ? "FAILED" : running ? "RUNNING" : latest.status, + message: failed?.log?.message || latest.message, + startTime: entries[0].log.startTime, + endTime: latest.endTime, + paramsOut: JSON.stringify({ + ...parseRuntimeValue(latest.paramsOut), + iterationHistory: iterationOutputs, + }), + }); + }); +}; + +const runtimeBoolean = (value) => { + if (value === true || value === false) return value; + if (String(value).toLowerCase() === "true") return true; + if (String(value).toLowerCase() === "false") return false; + return null; +}; + +const decisionAnalysisOf = (log, output) => { + const action = String(log?.action || "").toUpperCase(); + const analysisType = String(output?.analysisType || "").toUpperCase(); + const isAnalysis = ["AUDIO_EVENT_CLASSIFY", "VIDEO_ANALYZE"].includes(action) + || analysisType.includes("AUDIO") + || analysisType.includes("VIDEO"); + return { + isAnalysis, + passed: isAnalysis ? runtimeBoolean(output?.passed) : null, + }; +}; + +const SUB_FLOW_ACTION_LABELS = Object.freeze({ + START: "开始", + END: "结束", + SLEEP: "等待", + START_LOOP: "循环", + SUB_FLOW: "子流程", + CAMERA_RECORDING_START: "开始录像", + CAMERA_RECORDING_STOP: "结束录像", + CAMERA_GET_IMAGE: "获取图片", + MICROPHONE_START: "开始录音", + MICROPHONE_STOP: "结束录音", + AUDIO_EVENT_CLASSIFY: "声音事件检测", + VIDEO_ANALYZE: "视频分析", + IMAGE_ANALYZE: "图片分析", + ARM_MOVE_TO_POINT: "机械臂运动", + AGV_MOVE_TO_POINT: "底盘导航", + AGV_MOVE_TO_STATION: "站点导航", + SYSTEM_EXECUTE_ACTION_QUEUE: "执行动作队列", + DEVICE_EXECUTE_JSON_COMMAND: "通用设备指令", +}); + +const subFlowActionLabel = (log) => SUB_FLOW_ACTION_LABELS[String(log?.action || "").toUpperCase()] + || String(log?.action || log?.nodeType || "内部节点"); + +const subFlowNodeName = (log) => { + const action = String(log?.action || ""); + const match = String(log?.message || "").match(/^\[([^\]]+)]\s*执行/); + const messageName = match?.[1]?.trim(); + if (messageName && messageName.toUpperCase() !== action.toUpperCase()) return messageName; + return subFlowActionLabel(log); +}; + +const emitSubFlowContainerOutputs = (logs) => { + const subFlowNodes = (lf.getGraphData().nodes || []).filter(node => node.type === "subFlow"); + subFlowNodes.forEach((subFlowNode) => { + const nodeId = String(subFlowNode.id); + const parentLog = [...logs].reverse().find(log => String(log?.nodeId || "") === nodeId); + const internalLogs = logs.filter(log => resolveSubFlowParentNodeId(String(log?.nodeId || "")) === nodeId); + if (!parentLog && !internalLogs.length) return; + + const grouped = new Map(); + internalLogs.forEach((log) => { + const nodeType = String(log?.nodeType || "").toUpperCase(); + if (["START", "END", "SUB_START", "SUB_END"].includes(nodeType) + && String(log?.status || "").toUpperCase() !== "FAILED") return; + const iteration = runtimeIterationPath(log); + const key = iteration.length ? iteration.join(".") : "root"; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(log); + }); + + const executions = [...grouped.entries()].map(([key, entries]) => { + const nodes = entries.map((log) => { + const output = parseRuntimeValue(log.paramsOut); + const analysis = decisionAnalysisOf(log, output); + return { + nodeId: log.nodeId, + nodeName: subFlowNodeName(log), + actionLabel: subFlowActionLabel(log), + action: log.action, + status: log.status, + startTime: log.startTime, + endTime: log.endTime, + durationMs: log.endTime && log.startTime ? log.endTime - log.startTime : null, + input: parseRuntimeValue(log.paramsIn), + output, + error: String(log?.status || "").toUpperCase() === "FAILED" ? (log.message || "") : "", + passed: analysis.passed, + isAnalysis: analysis.isAnalysis, + }; + }); + const failed = nodes.some(node => String(node.status || "").toUpperCase() === "FAILED"); + const running = nodes.some(node => String(node.status || "").toUpperCase() === "RUNNING"); + return { + key, + label: key === "root" ? "流程步骤" : `第 ${key} 次循环`, + status: failed ? "FAILED" : running ? "RUNNING" : "SUCCESS", + nodes, + }; + }).filter(execution => execution.nodes.length); + + const allNodes = executions.flatMap(execution => execution.nodes); + const analyses = allNodes.filter(node => node.isAnalysis); + const analysisStatus = analyses.some(node => node.passed === false || String(node.status || "").toUpperCase() === "FAILED") + ? "FAILED" + : analyses.some(node => node.passed === null || String(node.status || "").toUpperCase() === "RUNNING") + ? "RUNNING" + : analyses.length ? "PASSED" : null; + const failed = allNodes.find(node => String(node.status || "").toUpperCase() === "FAILED"); + const running = allNodes.some(node => String(node.status || "").toUpperCase() === "RUNNING"); + const parentStatus = String(parentLog?.status || "").toUpperCase(); + const status = failed || parentStatus === "FAILED" + ? "FAILED" + : running || parentStatus === "RUNNING" ? "RUNNING" : parentStatus || "SUCCESS"; + const startTimes = [parentLog?.startTime, ...allNodes.map(node => node.startTime)].map(Number).filter(Number.isFinite); + const endTimes = [parentLog?.endTime, ...allNodes.map(node => node.endTime)].map(Number).filter(Number.isFinite); + const startTime = startTimes.length ? Math.min(...startTimes) : null; + const endTime = status === "RUNNING" || !endTimes.length ? null : Math.max(...endTimes); + const parentOutput = parseRuntimeValue(parentLog?.paramsOut); + + emitter.emit("changeNodeState", { + ...(parentLog || {}), + nodeId, + nodeType: "SUB_FLOW", + status, + startTime, + endTime, + paramsIn: parentLog?.paramsIn || "{}", + paramsOut: JSON.stringify({ + subFlowExecution: { + name: subFlowNode.properties?.subFlow?.detectName || subFlowNode.properties?.name || "子流程", + versionNo: subFlowNode.properties?.subFlow?.versionNo, + status, + analysisStatus, + durationMs: startTime ? (endTime || Date.now()) - startTime : null, + nodeCount: allNodes.length, + executions, + output: parentOutput, + }, + }), + message: failed + ? `子流程内部节点[${failed.nodeName}]执行失败:${failed.error || "未知原因"}` + : parentLog?.message, + }); + }); +}; + +const emitLoopContainerOutputs = (logs) => { + const graph = lf.getGraphData(); + const graphNodes = graph.nodes || []; + const nodeById = new Map(graphNodes.map(node => [String(node.id), node])); + + graphNodes.filter(node => node.type === "loop").forEach((loopNode) => { + const childIds = new Set([ + ...(loopNode.children || []), + ...graphNodes + .filter(node => String(node.properties?.parentId || "") === String(loopNode.id)) + .map(node => node.id), + ].map(String)); + const loopHasDecisionAnalysis = [...childIds].some((childId) => { + const action = String(nodeById.get(childId)?.properties?.action || "").toUpperCase(); + return ["AUDIO_EVENT_CLASSIFY", "VIDEO_ANALYZE"].includes(action); + }); + const loopEntries = logs.filter(log => childIds.has(String(log?.nodeId || "")) + && runtimeIterationPath(log).length); + if (!loopEntries.length) return; + + const grouped = new Map(); + loopEntries.forEach((log) => { + const iterations = runtimeIterationPath(log); + const key = iterations.join("."); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push({ log, iterations }); + }); + + const iterationHistory = [...grouped.entries()].map(([iteration, entries]) => { + const failed = entries.some(({ log }) => String(log?.status || "").toUpperCase() === "FAILED"); + const running = entries.some(({ log }) => String(log?.status || "").toUpperCase() === "RUNNING"); + const nodes = entries.map(({ log }) => { + const output = parseRuntimeValue(log.paramsOut); + const analysis = decisionAnalysisOf(log, output); + const definition = nodeById.get(String(log.nodeId)); + return { + nodeId: log.nodeId, + nodeName: definition?.properties?.name || log.action || log.nodeType || log.nodeId, + status: log.status, + startTime: log.startTime, + endTime: log.endTime, + durationMs: log.endTime && log.startTime ? log.endTime - log.startTime : null, + input: parseRuntimeValue(log.paramsIn), + output, + error: String(log?.status || "").toUpperCase() === "FAILED" ? (log.message || "") : "", + isAnalysis: analysis.isAnalysis, + passed: analysis.passed, + }; + }); + const analyses = nodes.filter(node => node.isAnalysis); + const analysisStatus = analyses.some(node => String(node.status || "").toUpperCase() === "FAILED" || node.passed === false) + ? "FAILED" + : analyses.some(node => String(node.status || "").toUpperCase() === "RUNNING" || node.passed === null) + ? "RUNNING" + : analyses.length ? "PASSED" : loopHasDecisionAnalysis && running ? "RUNNING" : null; + const startTime = Math.min(...entries.map(({ log }) => Number(log.startTime)).filter(Number.isFinite)); + const completedEndTimes = entries.map(({ log }) => Number(log.endTime)).filter(Number.isFinite); + const endTime = !running && completedEndTimes.length ? Math.max(...completedEndTimes) : null; + return { + iteration, + status: failed ? "FAILED" : running ? "RUNNING" : "SUCCESS", + analysisStatus, + startTime: Number.isFinite(startTime) ? startTime : null, + endTime, + durationMs: Number.isFinite(startTime) ? (endTime || Date.now()) - startTime : null, + nodes, + }; + }); + + const controllerLog = [...logs].reverse().find(log => String(log?.nodeId || "") === String(loopNode.id)); + const failedIteration = iterationHistory.find(item => item.status === "FAILED"); + const runningIteration = iterationHistory.find(item => item.status === "RUNNING"); + const loopStatus = failedIteration + ? "FAILED" + : runningIteration || String(controllerLog?.status || "").toUpperCase() === "RUNNING" + ? "RUNNING" + : String(controllerLog?.status || "SUCCESS").toUpperCase(); + const startTime = controllerLog?.startTime || iterationHistory[0]?.startTime; + const endTime = controllerLog?.endTime || iterationHistory[iterationHistory.length - 1]?.endTime; + emitter.emit("changeNodeState", { + ...(controllerLog || {}), + nodeId: loopNode.id, + nodeType: "LOOP", + status: loopStatus, + startTime, + endTime, + paramsIn: controllerLog?.paramsIn || "{}", + paramsOut: JSON.stringify({ iterationHistory }), + message: failedIteration ? `第 ${failedIteration.iteration} 次循环执行失败` : controllerLog?.message, + }); + }); +}; + const loopFlowView = async (instId, taskId = null) => { // 轮询思路:优先使用任务 ID 查询指定执行,否则查询实例最新状态,再统一映射节点状态。 // 仅在非日志模式下根据终态停止或续排定时器,日志回放保持为一次性快照。 @@ -1082,27 +1395,50 @@ const loopFlowView = async (instId, taskId = null) => { 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 lastNodeType = String(last?.nodeType || "").toUpperCase(); + const mainEndStatus = String(mainEnd?.status || "").toUpperCase(); const lastStatus = String(last?.status || "").toUpperCase(); - if (lastNodeType === "END" && lastStatus === "SUCCESS") { + 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 = last.endTime - first.startTime; + totalRunningTime.value = mainEnd.endTime - first.startTime; } - } else if (["FAILED", "STOPPED", "PAUSED"].includes(lastStatus)) { + } else if (["STOPPED", "PAUSED"].includes(lastStatus)) { if (!flowInfoData.value.isLog) { - if (["PAUSED", "STOPPED"].includes(lastStatus)) { - flowState.value = lastStatus === "PAUSED" ? "pause" : "stop"; - } else { - flowState.value = "testRunError"; - totalRunningTime.value = last.endTime - first.startTime; - flowStore.updateDisableForm(false); - } + 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. diff --git a/src/views/flow/nodes/function/loop.vue b/src/views/flow/nodes/function/loop.vue index a0e2316..702416b 100644 --- a/src/views/flow/nodes/function/loop.vue +++ b/src/views/flow/nodes/function/loop.vue @@ -20,7 +20,23 @@ />
循环体 - {{ actualChildCount }} 个节点 +
+ {{ actualChildCount }} 个节点 + + + + +
+ + + + diff --git a/src/views/flow/vue-flow/FlowNode.vue b/src/views/flow/vue-flow/FlowNode.vue index 921ef9a..a44b597 100644 --- a/src/views/flow/vue-flow/FlowNode.vue +++ b/src/views/flow/vue-flow/FlowNode.vue @@ -84,6 +84,7 @@ import SdAgentNode from "../nodes/function/sdAgent.vue"; import RecognizeNode from "../nodes/function/recognize.vue"; import DeviceUniversal from "../nodes/function/deviceUniversal.vue"; import ActionQueue from "../nodes/function/actionQueue.vue"; +import SubFlowNode from "../nodes/function/subFlow.vue"; const props = defineProps({ id: String, @@ -112,6 +113,7 @@ const componentMap = { deviceUniversal: DeviceUniversal, device_universal: DeviceUniversal, actionQueue: ActionQueue, + subFlow: SubFlowNode, }; const flowStore = useFlowStore(); diff --git a/src/views/intelligenceTest/runningTask/task-log.vue b/src/views/intelligenceTest/runningTask/task-log.vue index c2707f2..562bff7 100644 --- a/src/views/intelligenceTest/runningTask/task-log.vue +++ b/src/views/intelligenceTest/runningTask/task-log.vue @@ -38,16 +38,13 @@
- 节点执行日志 - {{ group.logs.length }} 条 -
-
- 节点:{{ group.nodeNames.join('、') }} + 执行过程
该检测项暂未产生节点日志
diff --git a/src/views/test/log/index.vue b/src/views/test/log/index.vue index 6952199..e3a4d99 100644 --- a/src/views/test/log/index.vue +++ b/src/views/test/log/index.vue @@ -166,36 +166,7 @@ @close="cancel" @open="getLogList" > - - - - {{ - item.nodeType - }} - {{ - item.message - }} - -
{{ JSON.parse(item.paramsIn) }}
-
- -
{{ JSON.parse(item.paramsOut) }}
-
-
-
-
+ @@ -247,6 +218,7 @@ import { appendParamsToPath } from "@/utils/fn.js"; import { getTaskConfig } from "@/api/test/config.js"; import { onMounted } from "vue"; import { fetchAllPagedRows } from '@/utils/pagedRequest' +import TaskExecutionLog from '@/views/flow/components/TaskExecutionLog.vue' import { useContainerHeight } from "@/hooks/tableHeight";