From b3dae8db396228cc4e5aa9ce127461ffe05b6953 Mon Sep 17 00:00:00 2001
From: lixiaolong <702156524@qq.com>
Date: Fri, 21 Aug 2026 11:48:10 +0800
Subject: [PATCH] =?UTF-8?q?feat(flow):=20=E6=B7=BB=E5=8A=A0=E5=AD=90?=
=?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=E5=8F=82=E6=95=B0=E5=BC=95=E7=94=A8=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增子流程节点类型,支持在同一项目中执行已发布工作流
- 添加子流程节点UI组件和相关配置选项
- 重构参数引用逻辑,使用getUpstreamInput替代getInput方法
- 优化cascader组件属性设置,统一使用props配置checkStrictly
- 修正多个参数组件中的handleTypeChange和cascaderChange函数参数传递
- 添加流程输出参数管理功能,支持引用来源选择和类型识别
- 实现子流程运行时状态管理和迭代输出处理
- 增加循环引用检测和资源验证功能
- 更新API接口,添加子流程选项获取方法
- 修复流程引用和依赖关系处理逻辑
---
src/api/flow/flow.js | 8 +
src/utils/flow.js | 77 ++++
src/utils/flowResources.js | 81 +++-
.../flow/components/LoopIterationResults.vue | 217 +++++++++++
src/views/flow/components/NodeState.vue | 14 +-
src/views/flow/components/NodeTitle.vue | 1 +
src/views/flow/components/ParamsDrawer.vue | 12 +-
.../components/SubFlowExecutionResults.vue | 173 +++++++++
.../flow/components/TaskExecutionLog.vue | 321 +++++++++++++++-
.../components/params/BasicNodeParams.vue | 6 +-
.../flow/components/params/CodeNodeParams.vue | 6 +-
.../components/params/DeviceUniversal.vue | 6 +-
.../flow/components/params/EndParams.vue | 158 ++++++++
.../flow/components/params/HttpNodeParams.vue | 24 +-
.../components/params/ImageAnalysisParams.vue | 5 +-
.../components/params/MediaAnalysisParams.vue | 5 +-
.../components/params/RecognizeNodeParams.vue | 6 +-
.../components/params/SdAgentNodeParams.vue | 12 +-
.../flow/components/params/SubFlowParams.vue | 291 ++++++++++++++
.../components/params/SwitchNodeParams.vue | 6 +-
src/views/flow/components/params/useQuote.js | 83 ++--
src/views/flow/config.js | 12 +
src/views/flow/index.vue | 360 +++++++++++++++++-
src/views/flow/nodes/function/loop.vue | 74 +++-
src/views/flow/nodes/function/subFlow.vue | 97 +++++
src/views/flow/vue-flow/FlowNode.vue | 2 +
.../intelligenceTest/runningTask/task-log.vue | 7 +-
src/views/test/log/index.vue | 32 +-
28 files changed, 1936 insertions(+), 160 deletions(-)
create mode 100644 src/views/flow/components/LoopIterationResults.vue
create mode 100644 src/views/flow/components/SubFlowExecutionResults.vue
create mode 100644 src/views/flow/components/params/EndParams.vue
create mode 100644 src/views/flow/components/params/SubFlowParams.vue
create mode 100644 src/views/flow/nodes/function/subFlow.vue
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 @@
+
+
+
+ {{ history.length }} 次循环
+
+ {{ analysisSummary.label }}
+
+
+
+
+
+
+
+
+ 第 {{ iteration.iteration }} 次
+
+ {{ statusMeta(iteration.status).label }}
+
+
+ {{ analysisMeta(iteration.analysisStatus).label }}
+
+ {{ formatDuration(iteration.durationMs) }}
+
+
+
+
+
+
+ {{ node.nodeName }}
+
+ {{ statusMeta(node.status).label }}
+
+
+ {{ node.passed ? '通过' : '未通过' }}
+
+ {{ formatDuration(node.durationMs) }}
+
+ {{ node.error }}
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{ data.name || '子流程' }}
+ V{{ data.versionNo }}
+
+
+ {{ analysisMeta.label }}
+ {{ statusMeta(data.status).label }}
+ {{ formatDuration(data.durationMs) }}
+
+
+
+
+
+
+
+
+ {{ execution.label }}
+
+ {{ statusMeta(execution.status).label }}
+
+ {{ execution.nodes?.length || 0 }} 个节点
+
+
+
+
+
+
+ {{ index + 1 }}
+ {{ node.nodeName }}
+
+ {{ node.actionLabel }}
+
+ {{ statusMeta(node.status).label }}
+
+ {{ node.passed ? '通过' : '未通过' }}
+
+ {{ formatDuration(node.durationMs) }}
+
+ {{ node.error }}
+
+
+
+
+
+
+
+
+ 子流程最终输出
+
+
+
+
+
+
+
+
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 @@
+
@@ -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 @@
visibleChange(visible, index, property.quote)
- " @change="(value) => cascaderChange(value, index, httpNodeData, 'config')" />
+ " @change="(value) => cascaderChange(value, property)" />
@@ -89,7 +89,7 @@
-
+
@@ -121,10 +121,10 @@
visibleChange(visible, index, property.quote)
- " @change="(value) => cascaderChange(value, index, httpNodeData, 'headers')" />
+ " @change="(value) => cascaderChange(value, property)" />
-
+
@@ -186,10 +186,10 @@
visibleChange(visible, index, property.quote)
- " @change="(value) => cascaderChange(value, index, httpNodeData, 'params')" />
+ " @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";