feat(flow): 添加子流程功能并优化参数引用逻辑
- 新增子流程节点类型,支持在同一项目中执行已发布工作流 - 添加子流程节点UI组件和相关配置选项 - 重构参数引用逻辑,使用getUpstreamInput替代getInput方法 - 优化cascader组件属性设置,统一使用props配置checkStrictly - 修正多个参数组件中的handleTypeChange和cascaderChange函数参数传递 - 添加流程输出参数管理功能,支持引用来源选择和类型识别 - 实现子流程运行时状态管理和迭代输出处理 - 增加循环引用检测和资源验证功能 - 更新API接口,添加子流程选项获取方法 - 修复流程引用和依赖关系处理逻辑
This commit is contained in:
parent
e136cf8a31
commit
b3dae8db39
@ -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 })
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
|
||||
217
src/views/flow/components/LoopIterationResults.vue
Normal file
217
src/views/flow/components/LoopIterationResults.vue
Normal file
@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="loop-results">
|
||||
<div class="loop-results__summary">
|
||||
<span>{{ history.length }} 次循环</span>
|
||||
<el-tag v-if="analysisSummary" :type="analysisSummary.type" size="small">
|
||||
{{ analysisSummary.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!history.length" description="暂无循环结果" :image-size="56" />
|
||||
<el-collapse v-else v-model="expandedIterations" class="loop-results__iterations">
|
||||
<el-collapse-item v-for="iteration in history" :key="iteration.iteration" :name="iteration.iteration">
|
||||
<template #title>
|
||||
<div class="loop-results__iteration-title">
|
||||
<strong>第 {{ iteration.iteration }} 次</strong>
|
||||
<el-tag :type="statusMeta(iteration.status).type" size="small">
|
||||
{{ statusMeta(iteration.status).label }}
|
||||
</el-tag>
|
||||
<el-tag v-if="analysisMeta(iteration.analysisStatus)" :type="analysisMeta(iteration.analysisStatus).type" size="small">
|
||||
{{ analysisMeta(iteration.analysisStatus).label }}
|
||||
</el-tag>
|
||||
<span>{{ formatDuration(iteration.durationMs) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="loop-results__nodes">
|
||||
<details v-for="node in iteration.nodes || []" :key="`${iteration.iteration}:${node.nodeId}`" class="loop-results__node">
|
||||
<summary>
|
||||
<span class="loop-results__node-name">{{ node.nodeName }}</span>
|
||||
<el-tag :type="statusMeta(node.status).type" size="small">
|
||||
{{ statusMeta(node.status).label }}
|
||||
</el-tag>
|
||||
<el-tag v-if="node.passed !== null && node.passed !== undefined" :type="node.passed ? 'success' : 'danger'" size="small">
|
||||
{{ node.passed ? '通过' : '未通过' }}
|
||||
</el-tag>
|
||||
<span class="loop-results__duration">{{ formatDuration(node.durationMs) }}</span>
|
||||
</summary>
|
||||
<div v-if="node.error" class="loop-results__error">{{ node.error }}</div>
|
||||
<div v-if="hasValue(node.input)" class="loop-results__data">
|
||||
<div class="loop-results__label">输入</div>
|
||||
<JsonViewer :value="node.input" copyable boxed sort theme="light" />
|
||||
</div>
|
||||
<div v-if="hasValue(node.output)" class="loop-results__data">
|
||||
<div class="loop-results__label">输出</div>
|
||||
<JsonViewer :value="node.output" copyable boxed sort theme="light" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
history: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const expandedIterations = ref([])
|
||||
let initialized = false
|
||||
|
||||
watch(() => props.history, (history) => {
|
||||
if (initialized || !history?.length) return
|
||||
const active = history.find(item => String(item.status || '').toUpperCase() === 'RUNNING')
|
||||
expandedIterations.value = [active?.iteration || history[history.length - 1].iteration]
|
||||
initialized = true
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
const statusMeta = (status) => {
|
||||
const value = String(status || '').toUpperCase()
|
||||
if (value === 'SUCCESS') return { label: '执行完成', type: 'success' }
|
||||
if (value === 'FAILED') return { label: '执行失败', type: 'danger' }
|
||||
if (value === 'STOPPED') return { label: '已停止', type: 'info' }
|
||||
if (value === 'PAUSED') return { label: '已暂停', type: 'warning' }
|
||||
return { label: '执行中', type: 'warning' }
|
||||
}
|
||||
|
||||
const analysisMeta = (status) => {
|
||||
if (status === 'PASSED') return { label: '分析通过', type: 'success' }
|
||||
if (status === 'FAILED') return { label: '分析未通过', type: 'danger' }
|
||||
if (status === 'RUNNING') return { label: '分析中', type: 'warning' }
|
||||
return null
|
||||
}
|
||||
|
||||
const analysisSummary = computed(() => {
|
||||
const statuses = props.history.map(item => item.analysisStatus).filter(Boolean)
|
||||
if (!statuses.length) return null
|
||||
if (statuses.includes('FAILED')) return { label: '存在未通过', type: 'danger' }
|
||||
if (statuses.includes('RUNNING')) return { label: '分析中', type: 'warning' }
|
||||
return { label: '全部通过', type: 'success' }
|
||||
})
|
||||
|
||||
const formatDuration = (value) => {
|
||||
const duration = Number(value)
|
||||
if (!Number.isFinite(duration) || duration < 0) return '-'
|
||||
if (duration < 1000) return `${duration} ms`
|
||||
return `${(duration / 1000).toFixed(2)} s`
|
||||
}
|
||||
|
||||
const hasValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') return false
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
if (typeof value === 'object') return Object.keys(value).length > 0
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loop-results__summary {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 2px 8px;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loop-results__iterations {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.loop-results__iteration-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.loop-results__iteration-title > span:last-child {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loop-results__nodes {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 4px 0 10px;
|
||||
}
|
||||
|
||||
.loop-results__node {
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.loop-results__node summary {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 3px 4px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.loop-results__node summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loop-results__node summary::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 6px solid #909399;
|
||||
content: '';
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.loop-results__node[open] summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.loop-results__node-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loop-results__duration {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loop-results__error {
|
||||
margin: 4px 4px 8px 18px;
|
||||
padding: 7px 9px;
|
||||
border-left: 3px solid var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger-dark-2);
|
||||
line-height: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.loop-results__data {
|
||||
margin: 6px 4px 10px 18px;
|
||||
}
|
||||
|
||||
.loop-results__label {
|
||||
margin-bottom: 5px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -98,6 +98,7 @@ const flowContextActions = new Set([
|
||||
"SUB_START",
|
||||
"SUB_END",
|
||||
"START_LOOP",
|
||||
"SUB_FLOW",
|
||||
"STOP_LOOP",
|
||||
"BRANCH",
|
||||
"GET_CURRENT_OBJECT",
|
||||
|
||||
@ -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 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
|
||||
173
src/views/flow/components/SubFlowExecutionResults.vue
Normal file
173
src/views/flow/components/SubFlowExecutionResults.vue
Normal file
@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="subflow-results">
|
||||
<div class="subflow-results__summary">
|
||||
<div>
|
||||
<strong>{{ data.name || '子流程' }}</strong>
|
||||
<span v-if="data.versionNo">V{{ data.versionNo }}</span>
|
||||
</div>
|
||||
<div class="subflow-results__summary-status">
|
||||
<el-tag v-if="analysisMeta" :type="analysisMeta.type" size="small">{{ analysisMeta.label }}</el-tag>
|
||||
<el-tag :type="statusMeta(data.status).type" size="small">{{ statusMeta(data.status).label }}</el-tag>
|
||||
<span>{{ formatDuration(data.durationMs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!executions.length" description="暂无内部节点日志" :image-size="56" />
|
||||
<el-collapse v-else v-model="expandedExecutions" class="subflow-results__groups">
|
||||
<el-collapse-item v-for="execution in executions" :key="execution.key" :name="execution.key">
|
||||
<template #title>
|
||||
<div class="subflow-results__group-title">
|
||||
<strong>{{ execution.label }}</strong>
|
||||
<el-tag :type="statusMeta(execution.status).type" size="small">
|
||||
{{ statusMeta(execution.status).label }}
|
||||
</el-tag>
|
||||
<span>{{ execution.nodes?.length || 0 }} 个节点</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="subflow-results__nodes">
|
||||
<details v-for="(node, index) in execution.nodes || []" :key="`${execution.key}:${node.nodeId}:${index}`">
|
||||
<summary>
|
||||
<span class="subflow-results__order">{{ index + 1 }}</span>
|
||||
<span class="subflow-results__node-name">{{ node.nodeName }}</span>
|
||||
<span v-if="node.actionLabel && node.actionLabel !== node.nodeName" class="subflow-results__action">
|
||||
{{ node.actionLabel }}
|
||||
</span>
|
||||
<el-tag :type="statusMeta(node.status).type" size="small">{{ statusMeta(node.status).label }}</el-tag>
|
||||
<el-tag v-if="node.passed !== null && node.passed !== undefined" :type="node.passed ? 'success' : 'danger'" size="small">
|
||||
{{ node.passed ? '通过' : '未通过' }}
|
||||
</el-tag>
|
||||
<span class="subflow-results__duration">{{ formatDuration(node.durationMs) }}</span>
|
||||
</summary>
|
||||
<div v-if="node.error" class="subflow-results__error">{{ node.error }}</div>
|
||||
<div v-if="hasValue(node.input)" class="subflow-results__data">
|
||||
<div class="subflow-results__label">输入</div>
|
||||
<JsonViewer :value="node.input" copyable boxed sort theme="light" />
|
||||
</div>
|
||||
<div v-if="hasValue(node.output)" class="subflow-results__data">
|
||||
<div class="subflow-results__label">输出</div>
|
||||
<JsonViewer :value="node.output" copyable boxed sort theme="light" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<details v-if="hasValue(data.output)" class="subflow-results__final-output">
|
||||
<summary>子流程最终输出</summary>
|
||||
<JsonViewer :value="data.output" copyable boxed sort theme="light" />
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({ data: { type: Object, default: () => ({}) } })
|
||||
const executions = computed(() => Array.isArray(props.data.executions) ? props.data.executions : [])
|
||||
const expandedExecutions = ref([])
|
||||
let initialized = false
|
||||
|
||||
watch(executions, (groups) => {
|
||||
if (initialized || !groups.length) return
|
||||
const active = groups.find((group) => String(group.status || '').toUpperCase() === 'RUNNING')
|
||||
expandedExecutions.value = [active?.key || groups[groups.length - 1].key]
|
||||
initialized = true
|
||||
}, { immediate: true })
|
||||
|
||||
const statusMeta = (status) => {
|
||||
const value = String(status || '').toUpperCase()
|
||||
if (value === 'SUCCESS') return { label: '执行完成', type: 'success' }
|
||||
if (value === 'FAILED') return { label: '执行失败', type: 'danger' }
|
||||
if (value === 'STOPPED') return { label: '已停止', type: 'info' }
|
||||
if (value === 'PAUSED') return { label: '已暂停', type: 'warning' }
|
||||
return { label: '执行中', type: 'warning' }
|
||||
}
|
||||
|
||||
const analysisMeta = computed(() => {
|
||||
if (props.data.analysisStatus === 'PASSED') return { label: '分析通过', type: 'success' }
|
||||
if (props.data.analysisStatus === 'FAILED') return { label: '分析未通过', type: 'danger' }
|
||||
if (props.data.analysisStatus === 'RUNNING') return { label: '分析中', type: 'warning' }
|
||||
return null
|
||||
})
|
||||
|
||||
const formatDuration = (value) => {
|
||||
const duration = Number(value)
|
||||
if (!Number.isFinite(duration) || duration < 0) return '-'
|
||||
if (duration < 1000) return `${duration} ms`
|
||||
return `${(duration / 1000).toFixed(2)} s`
|
||||
}
|
||||
|
||||
const hasValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') return false
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
if (typeof value === 'object') return Object.keys(value).length > 0
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subflow-results__summary,
|
||||
.subflow-results__summary-status,
|
||||
.subflow-results__group-title,
|
||||
.subflow-results__nodes summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subflow-results__summary {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 2px 12px;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.subflow-results__summary > div,
|
||||
.subflow-results__summary-status { display: flex; align-items: center; gap: 8px; }
|
||||
.subflow-results__summary span { color: #667085; font-size: 12px; }
|
||||
.subflow-results__groups { border-top: 0; }
|
||||
|
||||
.subflow-results__group-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.subflow-results__group-title > span:last-child {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subflow-results__nodes { display: grid; gap: 5px; padding-bottom: 10px; }
|
||||
.subflow-results__nodes details { border-bottom: 1px solid #ebeef5; }
|
||||
|
||||
.subflow-results__nodes summary {
|
||||
min-height: 36px;
|
||||
gap: 7px;
|
||||
padding: 3px 4px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.subflow-results__nodes summary::-webkit-details-marker { display: none; }
|
||||
.subflow-results__order { width: 20px; color: #98a2b3; font-size: 11px; text-align: center; }
|
||||
.subflow-results__node-name { min-width: 0; overflow: hidden; color: #303133; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.subflow-results__action { color: #98a2b3; font-size: 11px; white-space: nowrap; }
|
||||
.subflow-results__duration { margin-left: auto; color: #909399; font-size: 12px; white-space: nowrap; }
|
||||
|
||||
.subflow-results__error {
|
||||
margin: 4px 4px 8px 31px;
|
||||
padding: 7px 9px;
|
||||
border-left: 3px solid var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger-dark-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subflow-results__data { margin: 6px 4px 10px 31px; }
|
||||
.subflow-results__label { margin-bottom: 5px; color: #667085; font-size: 12px; font-weight: 600; }
|
||||
.subflow-results__final-output { margin-top: 10px; padding-top: 10px; border-top: 1px solid #ebeef5; }
|
||||
.subflow-results__final-output summary { margin-bottom: 8px; color: #344054; cursor: pointer; font-size: 12px; font-weight: 600; }
|
||||
</style>
|
||||
@ -3,14 +3,29 @@
|
||||
<!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
|
||||
<div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
|
||||
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
|
||||
<div v-for="(log, index) in list" :key="log.id || index" class="task-log__item">
|
||||
<div class="task-log__time">{{ formatDate(log.logTime) }}</div>
|
||||
<template v-for="(log, index) in displayLogs" :key="log.__displayKey || log.id || index">
|
||||
<button
|
||||
v-if="log.__iterationHeader"
|
||||
type="button"
|
||||
class="task-log__iteration-header"
|
||||
@click="toggleIteration(log.__iterationKey)"
|
||||
>
|
||||
<el-icon :class="{ 'is-expanded': isIterationExpanded(log.__iterationKey) }"><ArrowRight /></el-icon>
|
||||
<span class="task-log__iteration-title">{{ log.__iterationLabel }}</span>
|
||||
<el-tag :type="log.__statusType" size="small">{{ log.__statusLabel }}</el-tag>
|
||||
<el-tag v-if="log.__analysisLabel" :type="log.__analysisType" size="small">
|
||||
{{ log.__analysisLabel }}
|
||||
</el-tag>
|
||||
<span class="task-log__iteration-count">{{ log.__count }} 条日志</span>
|
||||
</button>
|
||||
<div v-else class="task-log__item" :class="{ 'is-iteration-child': iterationPathOf(log).length }">
|
||||
<div class="task-log__time">{{ formatDate(logTimeOf(log)) }}</div>
|
||||
<div class="task-log__body">
|
||||
<div class="task-log__content">{{ log.logContent }}</div>
|
||||
<div class="task-log__content">{{ logContentOf(log) }}</div>
|
||||
|
||||
<div v-if="errorOf(log)" class="task-log__error">
|
||||
<div v-if="displayErrorOf(log)" class="task-log__error">
|
||||
<strong>失败原因</strong>
|
||||
<span>{{ errorOf(log) }}</span>
|
||||
<span>{{ displayErrorOf(log) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 -->
|
||||
@ -20,7 +35,7 @@
|
||||
<el-tag :type="analysisOf(log).tagType" size="small">
|
||||
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
|
||||
</el-tag>
|
||||
<span v-if="analysisOf(log).modelName" class="task-log__analysis-meta">
|
||||
<span v-if="isDebugView && analysisOf(log).modelName" class="task-log__analysis-meta">
|
||||
模型:{{ analysisOf(log).modelName }}
|
||||
</span>
|
||||
<span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta">
|
||||
@ -64,7 +79,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 未被结构化消费的字段以格式化 JSON 展示,保留排查模型输出的能力。 -->
|
||||
<pre v-if="analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
|
||||
<pre v-if="isDebugView && analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
|
||||
</div>
|
||||
|
||||
<pre v-if="isDebugView && genericOutputOf(log)" class="task-log__output">{{ genericOutputOf(log) }}</pre>
|
||||
<div v-else-if="!isDebugView && businessSummaryOf(log)" class="task-log__business-summary">
|
||||
{{ businessSummaryOf(log) }}
|
||||
</div>
|
||||
|
||||
<!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 -->
|
||||
@ -85,6 +105,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 用户离开底部后显示悬浮入口;点击后回到最新日志并恢复自动跟随。 -->
|
||||
@ -102,8 +123,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { Bottom } from '@element-plus/icons-vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { ArrowRight, Bottom } from '@element-plus/icons-vue'
|
||||
import { formatDate } from '@/utils/index'
|
||||
|
||||
/**
|
||||
@ -113,8 +134,11 @@ import { formatDate } from '@/utils/index'
|
||||
const props = defineProps({
|
||||
list: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 150 },
|
||||
mode: { type: String, default: 'debug' },
|
||||
})
|
||||
|
||||
const isDebugView = computed(() => props.mode !== 'user')
|
||||
|
||||
// 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。
|
||||
const messageContainer = ref(null)
|
||||
|
||||
@ -143,6 +167,202 @@ const parseObject = (value) => {
|
||||
}
|
||||
}
|
||||
|
||||
const outputOf = (log) => ({
|
||||
...parseObject(log?.paramsOut),
|
||||
...parseObject(log?.actualResult),
|
||||
...parseObject(log?.extraInfo),
|
||||
})
|
||||
|
||||
const executionContextOf = (log) => outputOf(log)._executionContext || {}
|
||||
|
||||
const rawIterationPathOf = (log) => {
|
||||
const values = [log?.iterations, executionContextOf(log).iterations, log?.iteration]
|
||||
for (const value of values) {
|
||||
const path = Array.isArray(value)
|
||||
? value.filter(item => item !== null && item !== '')
|
||||
: value === null || value === undefined || value === ''
|
||||
? []
|
||||
: String(value).split(/[-.,/]/).map(item => item.trim()).filter(Boolean)
|
||||
if (path.length) return path
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Historical NODE_STARTED records were persisted before the loop path was copied
|
||||
// into the event. Pair them with the next terminal event of the same node so old
|
||||
// task logs can be folded without rewriting database rows.
|
||||
const inferredIterationPaths = computed(() => {
|
||||
const result = new WeakMap()
|
||||
const pendingStarts = new Map()
|
||||
;(Array.isArray(props.list) ? props.list : []).forEach((log) => {
|
||||
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
|
||||
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || '')
|
||||
if (!nodeKey) return
|
||||
if (eventType === 'NODE_STARTED') {
|
||||
if (!rawIterationPathOf(log).length) {
|
||||
if (!pendingStarts.has(nodeKey)) pendingStarts.set(nodeKey, [])
|
||||
pendingStarts.get(nodeKey).push(log)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!['NODE_COMPLETED', 'NODE_FAILED'].includes(eventType)) return
|
||||
const pending = pendingStarts.get(nodeKey)
|
||||
if (!pending?.length) return
|
||||
const startedLog = pending.shift()
|
||||
const path = rawIterationPathOf(log)
|
||||
if (path.length) result.set(startedLog, path)
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const iterationPathOf = (log) => {
|
||||
const path = rawIterationPathOf(log)
|
||||
return path.length ? path : inferredIterationPaths.value.get(log) || []
|
||||
}
|
||||
|
||||
const executionKeyOf = (log) => {
|
||||
const context = executionContextOf(log)
|
||||
const nodeKey = log?.nodeId || log?.executionStep || log?.nodeName || log?.logContent || ''
|
||||
return `${nodeKey}:${iterationPathOf(log).join('.')}:${context.itemOccurrence || ''}`
|
||||
}
|
||||
|
||||
// User-facing task logs keep an unmatched start event while a node is running, then
|
||||
// replace it with the terminal event. Structural wrapper nodes do not add business value.
|
||||
const visibleLogs = computed(() => {
|
||||
const source = Array.isArray(props.list) ? props.list : []
|
||||
if (isDebugView.value) return source
|
||||
const terminalKeys = new Set(source
|
||||
.filter((log) => ['NODE_COMPLETED', 'NODE_FAILED'].includes(
|
||||
String(executionContextOf(log).eventType || '').toUpperCase(),
|
||||
))
|
||||
.map(executionKeyOf))
|
||||
const structuralTypes = new Set(['START', 'END', 'SUB_START', 'SUB_END', 'SUB_FLOW', 'LOOP'])
|
||||
return source.filter((log) => {
|
||||
const context = executionContextOf(log)
|
||||
const eventType = String(context.eventType || '').toUpperCase()
|
||||
const nodeType = String(context.nodeType || log?.nodeType || '').toUpperCase()
|
||||
if (eventType === 'NODE_STARTED' && terminalKeys.has(executionKeyOf(log))) return false
|
||||
if (structuralTypes.has(nodeType) && !errorOf(log)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const logTimeOf = (log) => log?.logTime || log?.createTime || log?.startTime || log?.endTime
|
||||
const logContentOf = (log) => log?.logContent || log?.message
|
||||
|| [log?.action || log?.nodeType, log?.status].filter(Boolean).join(' - ')
|
||||
|| '节点执行记录'
|
||||
|
||||
const analysisPassedOf = (log) => {
|
||||
const output = outputOf(log)
|
||||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||||
const isDecisionAnalysis = analysisType.includes('AUDIO') || analysisType.includes('VIDEO')
|
||||
|| profileCode.includes('audio') || profileCode.includes('video')
|
||||
|| [3, 4].includes(Number(log?.logType))
|
||||
if (!isDecisionAnalysis) return null
|
||||
if (['FAILED', 'ERROR'].includes(String(output.analysisStatus || '').toUpperCase())) return false
|
||||
if (output.passed === undefined || output.passed === null) return null
|
||||
return output.passed === true || String(output.passed).toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
const expandedIterations = ref(new Set())
|
||||
const isIterationExpanded = key => expandedIterations.value.has(key)
|
||||
const toggleIteration = (key) => {
|
||||
const next = new Set(expandedIterations.value)
|
||||
next.has(key) ? next.delete(key) : next.add(key)
|
||||
expandedIterations.value = next
|
||||
}
|
||||
|
||||
const statusOfIteration = (logs) => {
|
||||
const statuses = logs.map(log => String(log?.status ?? log?.logStatus ?? '').toUpperCase())
|
||||
const contexts = logs.map(log => String(executionContextOf(log).eventType || '').toUpperCase())
|
||||
if (statuses.some(status => ['2', 'FAILED', 'FAILURE', 'ERROR'].includes(status))
|
||||
|| contexts.includes('NODE_FAILED')) {
|
||||
return { label: '执行失败', type: 'danger' }
|
||||
}
|
||||
|
||||
const latestEventByNode = new Map()
|
||||
logs.forEach((log, index) => {
|
||||
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
|
||||
if (!eventType) return
|
||||
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || index)
|
||||
latestEventByNode.set(nodeKey, eventType)
|
||||
})
|
||||
if ([...latestEventByNode.values()].some(eventType => eventType === 'NODE_STARTED')) {
|
||||
return { label: '执行中', type: 'warning' }
|
||||
}
|
||||
if (latestEventByNode.size) return { label: '执行完成', type: 'success' }
|
||||
if (statuses.some(status => ['RUNNING', '1'].includes(status))) {
|
||||
return { label: '执行中', type: 'warning' }
|
||||
}
|
||||
return { label: '执行完成', type: 'success' }
|
||||
}
|
||||
|
||||
const displayLogs = computed(() => {
|
||||
const source = visibleLogs.value
|
||||
const groups = new Map()
|
||||
source.forEach((log) => {
|
||||
const path = iterationPathOf(log)
|
||||
if (!path.length) return
|
||||
const key = path.join('.')
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key).push(log)
|
||||
})
|
||||
|
||||
const emitted = new Set()
|
||||
const result = []
|
||||
source.forEach((log, sourceIndex) => {
|
||||
const path = iterationPathOf(log)
|
||||
if (!path.length) {
|
||||
result.push(log)
|
||||
return
|
||||
}
|
||||
const key = path.join('.')
|
||||
if (emitted.has(key)) return
|
||||
emitted.add(key)
|
||||
const logs = groups.get(key) || []
|
||||
const status = statusOfIteration(logs)
|
||||
const decisions = logs.map(analysisPassedOf).filter(value => value !== null)
|
||||
const analysisPassed = decisions.length ? decisions.every(Boolean) : null
|
||||
result.push({
|
||||
__iterationHeader: true,
|
||||
__displayKey: `iteration:${key}:${sourceIndex}`,
|
||||
__iterationKey: key,
|
||||
__iterationLabel: `第 ${path.join('.')} 次循环`,
|
||||
__count: logs.length,
|
||||
__statusLabel: status.label,
|
||||
__statusType: status.type,
|
||||
__analysisLabel: analysisPassed === null ? '' : analysisPassed ? '分析通过' : '分析未通过',
|
||||
__analysisType: analysisPassed ? 'success' : 'danger',
|
||||
})
|
||||
if (isIterationExpanded(key)) result.push(...logs)
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const genericOutputOf = (log) => {
|
||||
if (analysisOf(log)) return ''
|
||||
const output = { ...outputOf(log) }
|
||||
delete output._executionContext
|
||||
delete output.errorMessage
|
||||
if (!Object.keys(output).length) return ''
|
||||
return JSON.stringify(output, null, 2)
|
||||
}
|
||||
|
||||
const businessSummaryOf = (log) => {
|
||||
if (analysisOf(log) || mediaOf(log).length) return ''
|
||||
const output = { ...outputOf(log) }
|
||||
delete output._executionContext
|
||||
if (typeof output.result === 'string' && output.result.trim()) return output.result.trim()
|
||||
if (['number', 'boolean'].includes(typeof output.result)) return `结果:${output.result}`
|
||||
if (Array.isArray(output.coordinates) && output.coordinates.length) {
|
||||
return `坐标:${output.coordinates.join(', ')}`
|
||||
}
|
||||
if (Number.isFinite(Number(output.completedSteps))) return `已完成 ${output.completedSteps} 个步骤`
|
||||
if (output.success === true) return '操作成功'
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个或多个媒体 URL 追加到结果数组。
|
||||
* seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留;
|
||||
@ -169,6 +389,7 @@ const errorOf = (log) => {
|
||||
if (errorCache.has(log)) return errorCache.get(log)
|
||||
const actualResult = parseObject(log.actualResult)
|
||||
const extraInfo = parseObject(log.extraInfo)
|
||||
const paramsOut = parseObject(log.paramsOut)
|
||||
const status = String(log.status ?? log.logStatus ?? '').trim().toLowerCase()
|
||||
const failed = ['2', 'failed', 'failure', 'error'].includes(status)
|
||||
const candidates = [
|
||||
@ -178,6 +399,8 @@ const errorOf = (log) => {
|
||||
actualResult.error,
|
||||
extraInfo.errorMessage,
|
||||
extraInfo.error,
|
||||
paramsOut.errorMessage,
|
||||
paramsOut.error,
|
||||
failed ? log.message : '',
|
||||
]
|
||||
const result = candidates.find(value => typeof value === 'string' && value.trim())?.trim() || ''
|
||||
@ -185,6 +408,16 @@ const errorOf = (log) => {
|
||||
return result
|
||||
}
|
||||
|
||||
const displayErrorOf = (log) => {
|
||||
const error = errorOf(log)
|
||||
if (!error || isDebugView.value) return error
|
||||
return error
|
||||
.split(/\r?\n/)[0]
|
||||
.replace(/^(?:[\w$.]+(?:Exception|Error):\s*)+/, '')
|
||||
.replace(/^执行异常[::]\s*/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 把日志中的音视频分析结果转换成模板直接使用的展示模型。
|
||||
* 返回 null 表示该日志不是可展示的音视频分析结果;否则返回状态、模型、置信度、结论和扩展详情等字段。
|
||||
@ -196,6 +429,7 @@ const analysisOf = (log) => {
|
||||
|
||||
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
|
||||
const output = {
|
||||
...parseObject(log.paramsOut),
|
||||
...parseObject(log.actualResult),
|
||||
...parseObject(log.extraInfo),
|
||||
}
|
||||
@ -310,6 +544,7 @@ const mediaOf = (log) => {
|
||||
|
||||
// 与分析结果相同,extraInfo 中的同名字段优先于 actualResult。
|
||||
const output = {
|
||||
...parseObject(log.paramsOut),
|
||||
...parseObject(log.actualResult),
|
||||
...parseObject(log.extraInfo),
|
||||
}
|
||||
@ -435,6 +670,50 @@ onMounted(scrollToBottom)
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-log__iteration-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
background: #f7f8fa;
|
||||
color: #303133;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-log__iteration-header .el-icon {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.task-log__iteration-header .el-icon.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.task-log__iteration-title {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-log__iteration-count {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-log__item.is-iteration-child {
|
||||
margin-left: 18px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.task-log__time {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
@ -546,6 +825,30 @@ onMounted(scrollToBottom)
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-log__output {
|
||||
max-height: 180px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #67c23a;
|
||||
background: #f5f7fa;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-log__business-summary {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #67c23a;
|
||||
background: #f3faf0;
|
||||
color: #3f5f39;
|
||||
line-height: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 媒体资源允许自动换行;每种播放器使用稳定尺寸,资源加载后不会造成明显布局跳动。 */
|
||||
.task-log__media {
|
||||
display: flex;
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
v-model="property.name" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
|
||||
<el-select v-model="property.type" class="param-type" :disabled="property?.forceSelect || false" @change="handleTypeChange(index, formData, property.type)">
|
||||
<el-select v-model="property.type" class="param-type" :disabled="property?.forceSelect || false" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
@ -53,10 +53,10 @@
|
||||
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
|
||||
:prop="`nodeParams.${index}.quote`">
|
||||
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }"
|
||||
v-model="property.quote" :checkStrictly="true" :options="quoteOptions"
|
||||
v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions"
|
||||
placeholder="请选择"
|
||||
@visible-change="(visible) => visibleChange(visible, index, property.quote)"
|
||||
@change="(value) => cascaderChange(value, index, formData, 'nodeParams')" />
|
||||
@change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-button :icon="Minus" circle size="small" :disabled="property?.disabled || false"
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
<el-select
|
||||
v-model="property.type"
|
||||
class="param-type"
|
||||
@change="handleTypeChange(index)"
|
||||
@change="handleTypeChange(property)"
|
||||
>
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
@ -117,13 +117,13 @@
|
||||
}
|
||||
"
|
||||
v-model="property.quote"
|
||||
:checkStrictly="true"
|
||||
:props="{ checkStrictly: true }"
|
||||
:options="quoteOptions"
|
||||
placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
"
|
||||
@change="(value) => cascaderChange(value, index, formData, 'nodeParams')"
|
||||
@change="(value) => cascaderChange(value, property)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
<el-select
|
||||
v-model="property.type"
|
||||
class="param-type"
|
||||
@change="handleTypeChange(index)"
|
||||
@change="handleTypeChange(property)"
|
||||
>
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
@ -120,13 +120,13 @@
|
||||
}
|
||||
"
|
||||
v-model="property.quote"
|
||||
:checkStrictly="true"
|
||||
:props="{ checkStrictly: true }"
|
||||
:options="quoteOptions"
|
||||
placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
"
|
||||
@change="(value) => cascaderChange(value, index, formData, 'nodeParams')"
|
||||
@change="(value) => cascaderChange(value, property)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
|
||||
158
src/views/flow/components/params/EndParams.vue
Normal file
158
src/views/flow/components/params/EndParams.vue
Normal file
@ -0,0 +1,158 @@
|
||||
<!-- End node output contract and value references. -->
|
||||
<template>
|
||||
<div class="end-params">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<div class="section-title">流程输出</div>
|
||||
<div class="section-note">输出值来自开始参数或前面节点的输出。</div>
|
||||
</div>
|
||||
<el-button type="primary" plain :icon="Plus" @click="addOutput">添加输出</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!outputs.length" description="当前流程没有输出" :image-size="72" />
|
||||
<div v-for="(item, index) in outputs" :key="item.key" class="output-row">
|
||||
<el-form-item label="输出名称" required>
|
||||
<el-input v-model="item.name" maxlength="40" placeholder="例如:result" />
|
||||
</el-form-item>
|
||||
<el-form-item label="引用来源" required>
|
||||
<el-cascader
|
||||
v-model="item.quote"
|
||||
:options="quoteOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="选择开始参数或节点输出"
|
||||
style="width: 100%"
|
||||
@visible-change="refreshQuoteOptions"
|
||||
@change="(value) => changeQuote(item, value)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="output-row__meta">
|
||||
<el-tag effect="plain" type="info">{{ item.valueType || '自动识别' }}</el-tag>
|
||||
<el-input v-model="item.desc" placeholder="输出说明(可选)" />
|
||||
<el-button circle :icon="Delete" aria-label="删除输出" @click="outputs.splice(index, 1)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
|
||||
const props = defineProps({ data: Object })
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
const outputs = ref([])
|
||||
const quoteOptions = ref([])
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
|
||||
const key = () => `output_${Date.now()}_${Math.random().toString(16).slice(2)}`
|
||||
|
||||
const refreshQuoteOptions = (visible = true) => {
|
||||
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
|
||||
}
|
||||
|
||||
const findDefinition = (quote) => {
|
||||
if (!Array.isArray(quote) || quote.length < 3) return null
|
||||
const graph = globalThis.lf.getGraphData()
|
||||
const source = graph.nodes.find((node) => node.id === quote[0])
|
||||
let definitions = quote[1] === 'input'
|
||||
? [...(source?.properties?.inputParams || []), ...(source?.properties?.nodeParams || [])]
|
||||
: source?.properties?.outputParams || []
|
||||
let definition = null
|
||||
quote.slice(2).forEach((name) => {
|
||||
definition = definitions.find((item) => item.name === name)
|
||||
definitions = definition?.children || []
|
||||
})
|
||||
return definition
|
||||
}
|
||||
|
||||
const changeQuote = (item, value) => {
|
||||
item.quoteType = value?.[1] || ''
|
||||
const definition = findDefinition(value)
|
||||
if (!definition) return
|
||||
item.valueType = definition.type || 'string'
|
||||
item.children = clone(definition.children || [])
|
||||
if (!item.name) item.name = definition.name
|
||||
if (!item.desc) item.desc = definition.desc || ''
|
||||
}
|
||||
|
||||
const addOutput = () => outputs.value.push({
|
||||
key: key(),
|
||||
name: '',
|
||||
valueType: 'string',
|
||||
quote: [],
|
||||
quoteType: '',
|
||||
desc: '',
|
||||
children: [],
|
||||
})
|
||||
|
||||
const init = () => {
|
||||
const paramsByName = new Map((props.data.properties?.nodeParams || []).map((item) => [item.name, item]))
|
||||
outputs.value = (props.data.properties?.outputParams || []).map((output) => {
|
||||
const param = paramsByName.get(output.name) || {}
|
||||
return {
|
||||
key: key(),
|
||||
name: output.name || '',
|
||||
valueType: output.type || 'string',
|
||||
quote: Array.isArray(param.quote) ? [...param.quote] : [],
|
||||
quoteType: param.quoteType || param.quote?.[1] || '',
|
||||
desc: output.desc || '',
|
||||
children: clone(output.children || []),
|
||||
}
|
||||
})
|
||||
refreshQuoteOptions()
|
||||
}
|
||||
|
||||
const validateAndSave = () => {
|
||||
const names = outputs.value.map((item) => item.name.trim())
|
||||
if (names.some((name) => !name)) {
|
||||
ElMessage.warning('请填写流程输出名称')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
if (new Set(names).size !== names.length) {
|
||||
ElMessage.warning('流程输出名称不能重复')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
if (outputs.value.some((item) => !Array.isArray(item.quote) || item.quote.length < 3)) {
|
||||
ElMessage.warning('每个流程输出都必须选择引用来源')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
const nodeParams = outputs.value.map((item) => ({
|
||||
name: item.name.trim(),
|
||||
type: 'quote',
|
||||
input: '',
|
||||
quote: [...item.quote],
|
||||
quoteType: item.quoteType || item.quote[1],
|
||||
required: true,
|
||||
}))
|
||||
const outputParams = outputs.value.map((item) => ({
|
||||
name: item.name.trim(),
|
||||
type: item.valueType || 'string',
|
||||
desc: item.desc || '',
|
||||
children: clone(item.children || []),
|
||||
}))
|
||||
globalThis.lf.setProperties(props.data.id, {
|
||||
...props.data.properties,
|
||||
nodeParams,
|
||||
outputParams,
|
||||
outputType: 'json',
|
||||
})
|
||||
emit('save-success')
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.end-params { padding-top: 14px; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.section-title { color: #1f2937; font-size: 14px; font-weight: 650; }
|
||||
.section-note { margin-top: 4px; color: #7a8494; font-size: 12px; }
|
||||
.output-row { margin-bottom: 12px; padding: 14px; border: 1px solid #e4e8ef; border-radius: 6px; background: #fff; }
|
||||
.output-row__meta { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; }
|
||||
</style>
|
||||
@ -25,7 +25,7 @@
|
||||
clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
@ -57,10 +57,10 @@
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择"
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, index, httpNodeData, 'config')" />
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
@ -89,7 +89,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''"
|
||||
:prop="httpNodeData.headers.length > index ? `headers.${index}.input` : undefined">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
@ -121,10 +121,10 @@
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择"
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, index, httpNodeData, 'headers')" />
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('headers', index)"
|
||||
@ -154,7 +154,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''"
|
||||
:prop="httpNodeData.params.length > index ? `params.${index}.input` : undefined">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
@ -186,10 +186,10 @@
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择"
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, index, httpNodeData, 'params')" />
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('params', index)"
|
||||
@ -243,7 +243,7 @@
|
||||
<el-select
|
||||
v-model="property.type"
|
||||
class="param-type"
|
||||
@change="handleTypeChange(index, 'httpBody')"
|
||||
@change="handleTypeChange(property)"
|
||||
>
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
@ -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)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
|
||||
@ -151,7 +151,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { getInput } from '@/utils/flow'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
import { IMAGE_ANALYSIS_OUTPUT_PARAMS } from '@/utils/flowResources'
|
||||
|
||||
const props = defineProps({ data: Object })
|
||||
@ -292,7 +292,7 @@ const rules = {
|
||||
}
|
||||
|
||||
// 引用选项按需加载,上游流程较大时避免每次渲染都遍历画布。
|
||||
const loadQuoteOptions = visible => { if (visible) quoteOptions.value = getInput(props.data.id) || [] }
|
||||
const loadQuoteOptions = visible => { if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || [] }
|
||||
const handleQuoteChange = () => {
|
||||
formData.imageQuoteType = imageCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
|
||||
}
|
||||
@ -320,6 +320,7 @@ const legacyPrompt = (params) => {
|
||||
* 将持久化 nodeParams 回填到表单,并为缺少新字段的旧节点补默认值。
|
||||
*/
|
||||
const initData = () => {
|
||||
loadQuoteOptions(true)
|
||||
const params = props.data?.properties?.nodeParams || []
|
||||
const images = getParam(params, 'imageUrl')
|
||||
const reference = getParam(params, 'referenceImageUrl')
|
||||
|
||||
@ -164,7 +164,7 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getInput } from '@/utils/flow'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
|
||||
/**
|
||||
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
|
||||
@ -371,7 +371,7 @@ const normalizeStoredTuning = (value, preset) => {
|
||||
|
||||
// getInput 根据当前节点 ID 返回可引用的上游输出,只在级联面板打开时刷新以减少无效计算。
|
||||
const loadQuoteOptions = (visible) => {
|
||||
if (visible) quoteOptions.value = getInput(props.data.id) || []
|
||||
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
|
||||
}
|
||||
|
||||
/**
|
||||
@ -392,6 +392,7 @@ const handleQuoteChange = () => {
|
||||
* 视频高级配置经过 normalizeStoredTuning 清洗,非 AUTO 模式强制关闭仅 AUTO 支持的精确分析回退。
|
||||
*/
|
||||
const initData = () => {
|
||||
loadQuoteOptions(true)
|
||||
const params = props.data?.properties?.nodeParams || []
|
||||
const profile = params.find(item => item.name === 'profileCode')
|
||||
const expectedLabel = params.find(item => item.name === 'expectedLabel')
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
|
||||
<el-select v-model="property.type" class="param-type"
|
||||
@change="handleTypeChange(index, RecognizeNodeData, property.type)">
|
||||
@change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
@ -38,10 +38,10 @@
|
||||
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
|
||||
:prop="`nodeParams.${index}.quote`">
|
||||
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }"
|
||||
v-model="property.quote" :checkStrictly="true" :options="quoteOptions"
|
||||
v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions"
|
||||
placeholder="请选择"
|
||||
@visible-change="(visible) => visibleChange(visible, index, property.quote)"
|
||||
@change="(value) => cascaderChange(value, index, RecognizeNodeData, 'nodeParams')" />
|
||||
@change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
@ -47,10 +47,10 @@
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择"
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, index, sdAgentNodeData, 'config')" />
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
@ -69,7 +69,7 @@
|
||||
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`tts.${index}.input`">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
@ -100,10 +100,10 @@
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择"
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, index, sdAgentNodeData, 'tts')" />
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
|
||||
291
src/views/flow/components/params/SubFlowParams.vue
Normal file
291
src/views/flow/components/params/SubFlowParams.vue
Normal file
@ -0,0 +1,291 @@
|
||||
<!-- Published sub-flow selector and synchronized contract editor. -->
|
||||
<template>
|
||||
<div class="subflow-params">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="已发布工作流" required>
|
||||
<el-select
|
||||
v-model="selectedVersionId"
|
||||
filterable
|
||||
:loading="loading"
|
||||
placeholder="选择同一项目的已发布工作流"
|
||||
style="width: 100%"
|
||||
@change="selectVersion"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.versionId"
|
||||
:label="`${item.detectName} · V${item.versionNo}`"
|
||||
:value="item.versionId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="latestOption && selection.versionId && latestOption.versionId !== selection.versionId"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="version-alert"
|
||||
>
|
||||
<template #title>
|
||||
已发布 V{{ latestOption.versionNo }},当前节点仍使用 V{{ selection.versionNo }}
|
||||
</template>
|
||||
<el-button size="small" type="warning" plain @click="applyOption(latestOption)">同步最新发布版本</el-button>
|
||||
</el-alert>
|
||||
<el-alert
|
||||
v-else-if="optionsLoaded && selection.versionId && !selectedOptionAvailable"
|
||||
title="当前子流程已不可用或会形成循环引用,请重新选择"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="version-alert"
|
||||
/>
|
||||
|
||||
<template v-if="selection.versionId">
|
||||
<div class="contract-header">
|
||||
<div>
|
||||
<div class="contract-title">{{ selection.detectName }}</div>
|
||||
<div class="contract-meta">{{ projectLabel }} · 发布版本 V{{ selection.versionNo }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contract-section">
|
||||
<div class="contract-section__title">子流程输入</div>
|
||||
<div class="contract-section__note">所有输入必须引用当前流程的开始参数或前面节点输出。</div>
|
||||
<el-empty v-if="!inputs.length" description="子流程没有输入参数" :image-size="64" />
|
||||
<div v-for="item in inputs" :key="item.name" class="contract-row">
|
||||
<div class="contract-row__name">
|
||||
<span>{{ item.name }}</span>
|
||||
<el-tag size="small" effect="plain" type="info">{{ item.dataType }}</el-tag>
|
||||
</div>
|
||||
<el-cascader
|
||||
v-model="item.quote"
|
||||
:options="quoteOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="选择引用来源"
|
||||
style="width: 100%"
|
||||
@visible-change="refreshQuoteOptions"
|
||||
@change="(value) => { item.quoteType = value?.[1] || '' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contract-section">
|
||||
<div class="contract-section__title">子流程输出</div>
|
||||
<el-empty v-if="!outputs.length" description="子流程没有输出参数" :image-size="64" />
|
||||
<div v-for="item in outputs" :key="item.name" class="output-contract">
|
||||
<span>{{ item.name }}</span>
|
||||
<el-tag size="small" effect="plain" type="info">{{ item.type }}</el-tag>
|
||||
<span class="output-contract__desc">{{ item.desc || '无说明' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { flowSubFlowOptions } from '@/api/flow/flow'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
import {
|
||||
FLOW_NODE_RESOURCE_OWNER,
|
||||
pruneUnusedNodeResources,
|
||||
} from '@/utils/flowResources'
|
||||
|
||||
const props = defineProps({ data: Object, flowId: String })
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
const loading = ref(false)
|
||||
const optionsLoaded = ref(false)
|
||||
const options = ref([])
|
||||
const selectedVersionId = ref('')
|
||||
const inputs = ref([])
|
||||
const outputs = ref([])
|
||||
const quoteOptions = ref([])
|
||||
const selection = reactive({ itemId: '', detectName: '', projectCode: '', versionId: '', versionNo: null, resourceRoles: [] })
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
|
||||
|
||||
const latestOption = computed(() => options.value.find((item) => item.itemId === selection.itemId))
|
||||
const selectedOptionAvailable = computed(() => options.value.some((item) => item.versionId === selection.versionId))
|
||||
const projectLabel = computed(() => selection.projectCode === 'AIMA' ? '爱玛' : '长安')
|
||||
|
||||
const refreshQuoteOptions = (visible = true) => {
|
||||
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
const stored = props.data.properties?.subFlow || {}
|
||||
Object.assign(selection, {
|
||||
itemId: stored.itemId || '',
|
||||
detectName: stored.detectName || '',
|
||||
projectCode: stored.projectCode || '',
|
||||
versionId: stored.versionId || '',
|
||||
versionNo: stored.versionNo ?? null,
|
||||
resourceRoles: clone(stored.resourceRoles || []),
|
||||
})
|
||||
selectedVersionId.value = selection.versionId
|
||||
const outputByName = new Map((props.data.properties?.outputParams || []).map((item) => [item.name, item]))
|
||||
inputs.value = (props.data.properties?.nodeParams || []).map((item) => ({
|
||||
name: item.name,
|
||||
dataType: item.dataType || 'string',
|
||||
quote: Array.isArray(item.quote) ? [...item.quote] : [],
|
||||
quoteType: item.quoteType || item.quote?.[1] || '',
|
||||
required: true,
|
||||
}))
|
||||
outputs.value = [...outputByName.values()].map(clone)
|
||||
refreshQuoteOptions()
|
||||
}
|
||||
|
||||
const applyOption = (option) => {
|
||||
if (!option) return
|
||||
const existing = new Map(inputs.value.map((item) => [item.name, item]))
|
||||
Object.assign(selection, {
|
||||
itemId: option.itemId,
|
||||
detectName: option.detectName,
|
||||
projectCode: option.projectCode,
|
||||
versionId: option.versionId,
|
||||
versionNo: option.versionNo,
|
||||
resourceRoles: clone(option.resourceRoles || []),
|
||||
})
|
||||
selectedVersionId.value = option.versionId
|
||||
inputs.value = (option.inputParams || []).map((item) => ({
|
||||
name: item.name,
|
||||
dataType: item.type || 'string',
|
||||
quote: Array.isArray(existing.get(item.name)?.quote) ? [...existing.get(item.name).quote] : [],
|
||||
quoteType: existing.get(item.name)?.quoteType || '',
|
||||
required: true,
|
||||
}))
|
||||
outputs.value = clone(option.outputParams || [])
|
||||
}
|
||||
|
||||
const selectVersion = (versionId) => applyOption(options.value.find((item) => item.versionId === versionId))
|
||||
|
||||
const loadOptions = async () => {
|
||||
if (!props.flowId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await flowSubFlowOptions(props.flowId)
|
||||
options.value = response.data || []
|
||||
if (!selection.versionId && options.value.length === 1) applyOption(options.value[0])
|
||||
} finally {
|
||||
loading.value = false
|
||||
optionsLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const mergeResourceRoles = () => {
|
||||
const graph = globalThis.lf.getGraphData()
|
||||
const start = graph.nodes.find((node) => node.type === 'start')
|
||||
if (!start) throw new Error('当前流程缺少开始节点')
|
||||
const roles = clone(start.properties?.resourceRoles || [])
|
||||
for (const childRole of selection.resourceRoles || []) {
|
||||
let parentRole = roles.find((role) => role.roleKey === childRole.roleKey)
|
||||
if (!parentRole) {
|
||||
const addedRole = clone(childRole)
|
||||
addedRole.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||||
;(addedRole.deviceSlots || []).forEach((slot) => { slot.managedBy = FLOW_NODE_RESOURCE_OWNER })
|
||||
roles.push(addedRole)
|
||||
continue
|
||||
}
|
||||
parentRole.required = parentRole.required !== false || childRole.required !== false
|
||||
parentRole.quantity ||= { min: 1, max: 1 }
|
||||
parentRole.quantity.min = Math.max(Number(parentRole.quantity.min || 0), Number(childRole.quantity?.min || 0))
|
||||
parentRole.quantity.max = Math.max(Number(parentRole.quantity.max || 0), Number(childRole.quantity?.max || 0))
|
||||
parentRole.deviceSlots ||= []
|
||||
for (const childSlot of childRole.deviceSlots || []) {
|
||||
const parentSlot = parentRole.deviceSlots.find((slot) => slot.slotKey === childSlot.slotKey)
|
||||
if (!parentSlot) {
|
||||
parentRole.deviceSlots.push({
|
||||
...clone(childSlot),
|
||||
managedBy: FLOW_NODE_RESOURCE_OWNER,
|
||||
})
|
||||
} else if (Number(parentSlot.deviceKind) !== Number(childSlot.deviceKind)) {
|
||||
throw new Error(`机器人角色“${childRole.displayName}”的设备用途“${childSlot.displayName}”类型冲突`)
|
||||
} else {
|
||||
parentSlot.required = parentSlot.required !== false || childSlot.required !== false
|
||||
parentSlot.allowReuse = parentSlot.allowReuse === true && childSlot.allowReuse === true
|
||||
}
|
||||
}
|
||||
}
|
||||
return { graph, start, roles }
|
||||
}
|
||||
|
||||
const validateAndSave = () => {
|
||||
if (!selection.versionId) {
|
||||
ElMessage.warning('请选择已发布工作流')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
if (optionsLoaded.value && !selectedOptionAvailable.value) {
|
||||
ElMessage.warning('当前子流程已不可用或会形成循环引用,请重新选择')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
if (inputs.value.some((item) => !Array.isArray(item.quote) || item.quote.length < 3)) {
|
||||
ElMessage.warning('子流程的所有输入参数都必须选择引用来源')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
let resources
|
||||
try {
|
||||
resources = mergeResourceRoles()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message)
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
const nodeParams = inputs.value.map((item) => ({
|
||||
name: item.name,
|
||||
type: 'quote',
|
||||
input: '',
|
||||
quote: [...item.quote],
|
||||
quoteType: item.quoteType || item.quote[1],
|
||||
dataType: item.dataType,
|
||||
required: true,
|
||||
disabled: true,
|
||||
}))
|
||||
const nodeProperties = {
|
||||
...props.data.properties,
|
||||
subFlow: clone(selection),
|
||||
nodeParams,
|
||||
outputParams: clone(outputs.value),
|
||||
outputType: 'json',
|
||||
}
|
||||
const prospectiveGraph = clone(resources.graph)
|
||||
const prospectiveStart = prospectiveGraph.nodes.find((node) => node.id === resources.start.id)
|
||||
const prospectiveNode = prospectiveGraph.nodes.find((node) => node.id === props.data.id)
|
||||
prospectiveStart.properties = { ...prospectiveStart.properties, resourceRoles: resources.roles }
|
||||
prospectiveNode.properties = nodeProperties
|
||||
const cleanup = pruneUnusedNodeResources(prospectiveGraph)
|
||||
globalThis.lf.setProperties(resources.start.id, {
|
||||
...resources.start.properties,
|
||||
resourceRoles: cleanup.start.properties.resourceRoles,
|
||||
})
|
||||
globalThis.lf.setProperties(props.data.id, nodeProperties)
|
||||
emit('save-success')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
restore()
|
||||
await loadOptions()
|
||||
})
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subflow-params { padding-top: 14px; }
|
||||
.version-alert { margin-bottom: 16px; }
|
||||
.version-alert :deep(.el-alert__content) { width: 100%; }
|
||||
.version-alert .el-button { margin-top: 8px; }
|
||||
.contract-header { margin-bottom: 16px; padding: 14px; border: 1px solid #dce5ef; border-radius: 6px; background: #f8fafc; }
|
||||
.contract-title { color: #1f2937; font-size: 15px; font-weight: 650; }
|
||||
.contract-meta { margin-top: 4px; color: #697386; font-size: 12px; }
|
||||
.contract-section { margin-top: 18px; }
|
||||
.contract-section__title { color: #344054; font-size: 13px; font-weight: 650; }
|
||||
.contract-section__note { margin: 4px 0 10px; color: #7a8494; font-size: 12px; }
|
||||
.contract-row { margin-top: 10px; padding: 12px; border: 1px solid #e4e8ef; border-radius: 6px; }
|
||||
.contract-row__name { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: #344054; font-size: 13px; font-weight: 600; }
|
||||
.output-contract { display: grid; grid-template-columns: minmax(100px, auto) auto minmax(0, 1fr); align-items: center; gap: 10px; min-height: 38px; border-bottom: 1px solid #edf0f4; color: #344054; font-size: 13px; }
|
||||
.output-contract__desc { overflow: hidden; color: #7a8494; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
@ -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] || "";
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: "结束循环",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
} else if (!flowInfoData.value.isLog) {
|
||||
// END is first persisted as RUNNING and updated to SUCCESS after the handler returns.
|
||||
|
||||
@ -20,7 +20,23 @@
|
||||
/>
|
||||
<div class="loop-body-title">
|
||||
<span>循环体</span>
|
||||
<div class="loop-body-actions">
|
||||
<span class="loop-body-count">{{ actualChildCount }} 个节点</span>
|
||||
<NodeState
|
||||
ref="nodeStateRef"
|
||||
:state="nodeOperatingStatus"
|
||||
:runtimes="runtimes"
|
||||
:popover-width="720"
|
||||
popper-class="flow-result-popover flow-loop-result-popover"
|
||||
>
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<LoopIterationResults :history="iterationHistory" />
|
||||
</template>
|
||||
</NodeState>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="child__container"
|
||||
@ -33,9 +49,15 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import LoopIterationResults from "../../components/LoopIterationResults.vue";
|
||||
import loop from "../../icon/loop.svg";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
@ -49,6 +71,42 @@ const props = defineProps({
|
||||
const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]);
|
||||
const isDragOver = ref(false);
|
||||
const actualChildCount = computed(() => props.childCount ?? props.model?.children?.size ?? 0);
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
const nodeStateRef = ref(null);
|
||||
const iterationHistory = computed(() => Array.isArray(outputJsonData.value?.iterationHistory)
|
||||
? outputJsonData.value.iterationHistory
|
||||
: []);
|
||||
|
||||
const parseRuntimeValue = (value) => {
|
||||
if (!value) return {};
|
||||
if (typeof value === "object") return value;
|
||||
try {
|
||||
return JSON.parse(value) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (String(data?.nodeId || "") !== String(props.model.id)) return;
|
||||
nodeOperatingStatus.value = data.status || "NORMAL";
|
||||
if (data.endTime && data.startTime) runtimes.value = data.endTime - data.startTime;
|
||||
inputJsonData.value = parseRuntimeValue(data.paramsIn);
|
||||
outputJsonData.value = parseRuntimeValue(data.paramsOut);
|
||||
emits("contentChange");
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (String(data?.id || "") !== String(props.model.id)) return;
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理 handleDragover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
@ -174,6 +232,18 @@ const setNodeName = (name) => {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.loop-body-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loop-body-actions :deep(.node-state) {
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
padding: 2px 5px 2px 7px;
|
||||
}
|
||||
|
||||
.child__container {
|
||||
min-height: 300px;
|
||||
flex: 1;
|
||||
|
||||
97
src/views/flow/nodes/function/subFlow.vue
Normal file
97
src/views/flow/nodes/function/subFlow.vue
Normal file
@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState
|
||||
ref="nodeStateRef"
|
||||
:state="nodeOperatingStatus"
|
||||
:runtimes="runtimes"
|
||||
:popover-width="760"
|
||||
popper-class="flow-result-popover flow-subflow-result-popover"
|
||||
>
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<SubFlowExecutionResults v-if="nodeOperatingStatus !== 'FAILED'" :data="subFlowExecution" />
|
||||
<div v-else class="subflow-error">{{ errorInfoData }}</div>
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:node-id="props.model.id"
|
||||
:node-properties="props.properties"
|
||||
:node-type="props.properties.nodeType || 'NONE'"
|
||||
:node-name="props.properties.name"
|
||||
:node-desc="props.properties.desc"
|
||||
@set-node-name="setNodeName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import NodeState from '../../components/NodeState.vue'
|
||||
import NodeTitle from '../../components/NodeTitle.vue'
|
||||
import SubFlowExecutionResults from '../../components/SubFlowExecutionResults.vue'
|
||||
import { useFlowEvents } from '../../composables/useFlowEvents'
|
||||
|
||||
const props = defineProps({ model: Object, properties: Object })
|
||||
const emits = defineEmits(['contentChange'])
|
||||
const { onFlowEvent } = useFlowEvents()
|
||||
const nodeStateRef = ref(null)
|
||||
const nodeOperatingStatus = ref('NORMAL')
|
||||
const runtimes = ref(0)
|
||||
const inputJsonData = ref({})
|
||||
const outputJsonData = ref({})
|
||||
const errorInfoData = ref('')
|
||||
const subFlowExecution = computed(() => outputJsonData.value?.subFlowExecution || {
|
||||
name: props.properties?.subFlow?.detectName || props.properties?.name,
|
||||
versionNo: props.properties?.subFlow?.versionNo,
|
||||
output: outputJsonData.value,
|
||||
executions: [],
|
||||
})
|
||||
|
||||
const parseValue = (value) => {
|
||||
if (!value) return {}
|
||||
if (typeof value === 'object') return value
|
||||
try { return JSON.parse(value) || {} } catch { return value }
|
||||
}
|
||||
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id)
|
||||
lf.setProperties(props.model.id, { ...properties, name })
|
||||
emits('contentChange')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent('changeNodeState', (data) => {
|
||||
if (data.nodeId !== props.model.id) return
|
||||
nodeOperatingStatus.value = data.status
|
||||
runtimes.value = data.endTime && data.startTime ? data.endTime - data.startTime : 0
|
||||
inputJsonData.value = parseValue(data.paramsIn)
|
||||
outputJsonData.value = parseValue(data.paramsOut)
|
||||
errorInfoData.value = data?.message || ''
|
||||
emits('contentChange')
|
||||
})
|
||||
onFlowEvent('contentChange', (data) => {
|
||||
if (data.id !== props.model.id) return
|
||||
nodeOperatingStatus.value = 'NORMAL'
|
||||
inputJsonData.value = {}
|
||||
outputJsonData.value = {}
|
||||
emits('contentChange')
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({ closePopover: () => nodeStateRef.value?.closePopover?.() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.node__container { width: 100%; height: auto; }
|
||||
.subflow-error { color: var(--el-color-danger); line-height: 20px; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.flow-subflow-result-popover {
|
||||
max-width: min(760px, calc(100vw - 32px));
|
||||
.result-section__content { max-height: 560px; }
|
||||
}
|
||||
</style>
|
||||
@ -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();
|
||||
|
||||
@ -38,16 +38,13 @@
|
||||
<!-- 展开后只把当前检测项已经归类的节点日志交给通用日志组件渲染。 -->
|
||||
<div v-if="isExpanded(group.key)" class="task-log__details">
|
||||
<div class="task-log__details-heading">
|
||||
<span>节点执行日志</span>
|
||||
<span class="task-log__details-count">{{ group.logs.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="group.nodeNames.length" class="task-log__nodes">
|
||||
节点:{{ group.nodeNames.join('、') }}
|
||||
<span>执行过程</span>
|
||||
</div>
|
||||
<TaskExecutionLog
|
||||
v-if="group.logs.length"
|
||||
:list="group.logs"
|
||||
:height="logHeight(group.logs)"
|
||||
mode="user"
|
||||
/>
|
||||
<div v-else class="task-log__details-empty">该检测项暂未产生节点日志</div>
|
||||
</div>
|
||||
|
||||
@ -166,36 +166,7 @@
|
||||
@close="cancel"
|
||||
@open="getLogList"
|
||||
>
|
||||
<el-timeline v-loading="logLoading">
|
||||
<el-timeline-item
|
||||
v-for="(item, index) in logList"
|
||||
:key="index.id"
|
||||
:hollow="true"
|
||||
:timestamp="item.createTime"
|
||||
placement="top"
|
||||
type="primary"
|
||||
>
|
||||
<el-descriptions
|
||||
:column="1"
|
||||
:title="item.taskName"
|
||||
border
|
||||
direction="vertical"
|
||||
>
|
||||
<el-descriptions-item label="节点类型">{{
|
||||
item.nodeType
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="消息">{{
|
||||
item.message
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="执行参数">
|
||||
<pre>{{ JSON.parse(item.paramsIn) }}</pre>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="返回结果">
|
||||
<pre>{{ JSON.parse(item.paramsOut) }}</pre>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<TaskExecutionLog v-loading="logLoading" :list="logList" :height="560" />
|
||||
</el-drawer>
|
||||
|
||||
<!-- 添加或修改检测项日志对话框 -->
|
||||
@ -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";
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user