lxl-dev #3

Merged
zhanghao merged 122 commits from lxl-dev into dev 2026-08-24 13:55:18 +08:00
28 changed files with 1936 additions and 160 deletions
Showing only changes of commit b3dae8db39 - Show all commits

View File

@ -21,6 +21,14 @@ export function flowVersionDetail(versionId) {
return request({ url: `/flow/version/${versionId}`, method: 'get' }) 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) { export function flowRestoreDraft(versionId, data) {
return request({ url: `/flow/version/${versionId}/restore-draft`, method: 'post', data }) return request({ url: `/flow/version/${versionId}/restore-draft`, method: 'post', data })
} }

View File

@ -221,6 +221,83 @@ export const removeSwitchEdge = (nodeId, sourceAnchorId) => {
matchedEdges.forEach(edge => lf.deleteEdge(edge.id)) 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) => { export const convertToTree = (data) => {
// 创建 id 到节点的映射 // 创建 id 到节点的映射
const nodeMap = new Map(); const nodeMap = new Map();

View File

@ -247,6 +247,16 @@ export const pruneUnusedNodeResources = (sourceGraph) => {
if (binding?.slotKey) usedSlotsByRole.get(target.roleKey).add(binding.slotKey) 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 let removedSlots = 0
start.properties.resourceRoles.forEach((role) => { start.properties.resourceRoles.forEach((role) => {
@ -276,7 +286,7 @@ export const pruneUnusedNodeResources = (sourceGraph) => {
export const validateFlowResources = (sourceGraph) => { export const validateFlowResources = (sourceGraph) => {
const graph = normalizeFlowResourceSchema(sourceGraph) const graph = normalizeFlowResourceSchema(sourceGraph)
const roles = getResourceRoles(graph) const roles = getResourceRoles(graph)
const errors = [] const errors = validateFlowReferences(graph)
const roleKeys = new Set() const roleKeys = new Set()
roles.forEach((role) => { roles.forEach((role) => {
@ -316,6 +326,75 @@ export const validateFlowResources = (sourceGraph) => {
return { graph, errors } 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) => { export const sanitizeFlowResourceDefinition = (sourceGraph) => {
const graph = normalizeFlowResourceSchema(sourceGraph) const graph = normalizeFlowResourceSchema(sourceGraph)
const start = getStartNode(graph) const start = getStartNode(graph)

View 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>

View File

@ -19,9 +19,9 @@
ref="popoverRef" ref="popoverRef"
trigger="click" trigger="click"
placement="right-start" placement="right-start"
:width="480" :width="props.popoverWidth"
:teleported="true" :teleported="true"
popper-class="flow-result-popover" :popper-class="props.popperClass"
@show="popoverVisible = true" @show="popoverVisible = true"
@hide="popoverVisible = false" @hide="popoverVisible = false"
> >
@ -51,6 +51,8 @@ import { CircleCloseFilled, Loading, SuccessFilled, VideoPause, View } from "@el
const props = defineProps({ const props = defineProps({
state: { type: String, default: "NORMAL" }, state: { type: String, default: "NORMAL" },
runtimes: { type: Number, default: 0 }, runtimes: { type: Number, default: 0 },
popoverWidth: { type: Number, default: 480 },
popperClass: { type: String, default: "flow-result-popover" },
}); });
const slots = useSlots(); const slots = useSlots();
@ -189,4 +191,12 @@ defineExpose({ closePopover });
font-size: 12px; font-size: 12px;
} }
} }
.flow-loop-result-popover {
max-width: min(720px, calc(100vw - 32px));
.result-section__content {
max-height: 520px;
}
}
</style> </style>

View File

@ -98,6 +98,7 @@ const flowContextActions = new Set([
"SUB_START", "SUB_START",
"SUB_END", "SUB_END",
"START_LOOP", "START_LOOP",
"SUB_FLOW",
"STOP_LOOP", "STOP_LOOP",
"BRANCH", "BRANCH",
"GET_CURRENT_OBJECT", "GET_CURRENT_OBJECT",

View File

@ -18,6 +18,7 @@
:is="currentComponent" :is="currentComponent"
ref="paramsComponentRef" ref="paramsComponentRef"
:data="props.data" :data="props.data"
v-bind="componentExtraProps"
@save-success="handleSaveSuccess" @save-success="handleSaveSuccess"
@save-error="handleSaveError" @save-error="handleSaveError"
@extract-step="handleExtractStep" @extract-step="handleExtractStep"
@ -41,10 +42,13 @@ import SwitchNodeParams from './params/SwitchNodeParams.vue'
import ActionQueueParams from './params/ActionQueueParams.vue' import ActionQueueParams from './params/ActionQueueParams.vue'
import MediaAnalysisParams from './params/MediaAnalysisParams.vue' import MediaAnalysisParams from './params/MediaAnalysisParams.vue'
import ImageAnalysisParams from './params/ImageAnalysisParams.vue' import ImageAnalysisParams from './params/ImageAnalysisParams.vue'
import EndParams from './params/EndParams.vue'
import SubFlowParams from './params/SubFlowParams.vue'
const props = defineProps({ const props = defineProps({
drawer: Boolean, drawer: Boolean,
data: Object data: Object,
flowId: String
}) })
const emits = defineEmits(['close', 'extract-step']) const emits = defineEmits(['close', 'extract-step'])
@ -61,6 +65,8 @@ const drawerSize = computed(() => (
const currentComponent = computed(() => { const currentComponent = computed(() => {
const type = props.data?.type const type = props.data?.type
if (type === 'start') return StartParams 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 (['currentLoop', 'serviceNode', 'sleep', 'loop'].includes(type)) return BasicNodeParams
if (type === 'code') return CodeNodeParams if (type === 'code') return CodeNodeParams
if (type === 'http') return HttpNodeParams if (type === 'http') return HttpNodeParams
@ -75,6 +81,10 @@ const currentComponent = computed(() => {
return null return null
}) })
const componentExtraProps = computed(() => (
props.data?.type === 'subFlow' ? { flowId: props.flowId } : {}
))
// 这里提供一个公开方法供父组件调用 // 这里提供一个公开方法供父组件调用
/** /**
* 确认 confirmSave 对应的数据或交互;作用范围仅限当前组件或模块。 * 确认 confirmSave 对应的数据或交互;作用范围仅限当前组件或模块。

View 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>

View File

@ -3,14 +3,29 @@
<!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 --> <!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
<div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll"> <div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 --> <!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
<div v-for="(log, index) in list" :key="log.id || index" class="task-log__item"> <template v-for="(log, index) in displayLogs" :key="log.__displayKey || log.id || index">
<div class="task-log__time">{{ formatDate(log.logTime) }}</div> <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__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> <strong>失败原因</strong>
<span>{{ errorOf(log) }}</span> <span>{{ displayErrorOf(log) }}</span>
</div> </div>
<!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 --> <!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 -->
@ -20,7 +35,7 @@
<el-tag :type="analysisOf(log).tagType" size="small"> <el-tag :type="analysisOf(log).tagType" size="small">
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }} {{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
</el-tag> </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 }} 模型:{{ analysisOf(log).modelName }}
</span> </span>
<span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta"> <span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta">
@ -64,7 +79,12 @@
</div> </div>
<!-- 未被结构化消费的字段以格式化 JSON 展示,保留排查模型输出的能力。 --> <!-- 未被结构化消费的字段以格式化 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> </div>
<!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 --> <!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 -->
@ -85,6 +105,7 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
<!-- 用户离开底部后显示悬浮入口;点击后回到最新日志并恢复自动跟随。 --> <!-- 用户离开底部后显示悬浮入口;点击后回到最新日志并恢复自动跟随。 -->
@ -102,8 +123,8 @@
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Bottom } from '@element-plus/icons-vue' import { ArrowRight, Bottom } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/index' import { formatDate } from '@/utils/index'
/** /**
@ -113,8 +134,11 @@ import { formatDate } from '@/utils/index'
const props = defineProps({ const props = defineProps({
list: { type: Array, default: () => [] }, list: { type: Array, default: () => [] },
height: { type: Number, default: 150 }, height: { type: Number, default: 150 },
mode: { type: String, default: 'debug' },
}) })
const isDebugView = computed(() => props.mode !== 'user')
// 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。 // 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。
const messageContainer = ref(null) 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 追加到结果数组。 * 把一个或多个媒体 URL 追加到结果数组。
* seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留; * seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留;
@ -169,6 +389,7 @@ const errorOf = (log) => {
if (errorCache.has(log)) return errorCache.get(log) if (errorCache.has(log)) return errorCache.get(log)
const actualResult = parseObject(log.actualResult) const actualResult = parseObject(log.actualResult)
const extraInfo = parseObject(log.extraInfo) const extraInfo = parseObject(log.extraInfo)
const paramsOut = parseObject(log.paramsOut)
const status = String(log.status ?? log.logStatus ?? '').trim().toLowerCase() const status = String(log.status ?? log.logStatus ?? '').trim().toLowerCase()
const failed = ['2', 'failed', 'failure', 'error'].includes(status) const failed = ['2', 'failed', 'failure', 'error'].includes(status)
const candidates = [ const candidates = [
@ -178,6 +399,8 @@ const errorOf = (log) => {
actualResult.error, actualResult.error,
extraInfo.errorMessage, extraInfo.errorMessage,
extraInfo.error, extraInfo.error,
paramsOut.errorMessage,
paramsOut.error,
failed ? log.message : '', failed ? log.message : '',
] ]
const result = candidates.find(value => typeof value === 'string' && value.trim())?.trim() || '' const result = candidates.find(value => typeof value === 'string' && value.trim())?.trim() || ''
@ -185,6 +408,16 @@ const errorOf = (log) => {
return result 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 表示该日志不是可展示的音视频分析结果;否则返回状态、模型、置信度、结论和扩展详情等字段。 * 返回 null 表示该日志不是可展示的音视频分析结果;否则返回状态、模型、置信度、结论和扩展详情等字段。
@ -196,6 +429,7 @@ const analysisOf = (log) => {
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。 // extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
const output = { const output = {
...parseObject(log.paramsOut),
...parseObject(log.actualResult), ...parseObject(log.actualResult),
...parseObject(log.extraInfo), ...parseObject(log.extraInfo),
} }
@ -310,6 +544,7 @@ const mediaOf = (log) => {
// 与分析结果相同,extraInfo 中的同名字段优先于 actualResult。 // 与分析结果相同,extraInfo 中的同名字段优先于 actualResult。
const output = { const output = {
...parseObject(log.paramsOut),
...parseObject(log.actualResult), ...parseObject(log.actualResult),
...parseObject(log.extraInfo), ...parseObject(log.extraInfo),
} }
@ -435,6 +670,50 @@ onMounted(scrollToBottom)
margin-bottom: 10px; 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 { .task-log__time {
color: #999; color: #999;
font-size: 13px; font-size: 13px;
@ -546,6 +825,30 @@ onMounted(scrollToBottom)
word-break: break-word; 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 { .task-log__media {
display: flex; display: flex;

View File

@ -31,7 +31,7 @@
v-model="property.name" placeholder="请输入" clearable /> v-model="property.name" placeholder="请输入" clearable />
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`"> <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="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -53,10 +53,10 @@
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]" :rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
:prop="`nodeParams.${index}.quote`"> :prop="`nodeParams.${index}.quote`">
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }" <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="请选择" placeholder="请选择"
@visible-change="(visible) => visibleChange(visible, index, property.quote)" @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-form-item> </el-form-item>
<el-button :icon="Minus" circle size="small" :disabled="property?.disabled || false" <el-button :icon="Minus" circle size="small" :disabled="property?.disabled || false"

View File

@ -52,7 +52,7 @@
<el-select <el-select
v-model="property.type" v-model="property.type"
class="param-type" class="param-type"
@change="handleTypeChange(index)" @change="handleTypeChange(property)"
> >
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
@ -117,13 +117,13 @@
} }
" "
v-model="property.quote" v-model="property.quote"
:checkStrictly="true" :props="{ checkStrictly: true }"
:options="quoteOptions" :options="quoteOptions"
placeholder="请选择" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote) (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-form-item> </el-form-item>

View File

@ -53,7 +53,7 @@
<el-select <el-select
v-model="property.type" v-model="property.type"
class="param-type" class="param-type"
@change="handleTypeChange(index)" @change="handleTypeChange(property)"
> >
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
@ -120,13 +120,13 @@
} }
" "
v-model="property.quote" v-model="property.quote"
:checkStrictly="true" :props="{ checkStrictly: true }"
:options="quoteOptions" :options="quoteOptions"
placeholder="请选择" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote) (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-form-item> </el-form-item>

View 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>

View File

@ -25,7 +25,7 @@
clearable /> clearable />
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`"> <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="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -57,10 +57,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = 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-change="
(visible) => visibleChange(visible, index, property.quote) (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-form-item> </el-form-item>
</el-row> </el-row>
@ -89,7 +89,7 @@
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" <el-form-item :label="index === 0 ? '参数值' : ''"
:prop="httpNodeData.headers.length > index ? `headers.${index}.input` : undefined"> :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="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -121,10 +121,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = 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-change="
(visible) => visibleChange(visible, index, property.quote) (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-form-item> </el-form-item>
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('headers', index)" <el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('headers', index)"
@ -154,7 +154,7 @@
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" <el-form-item :label="index === 0 ? '参数值' : ''"
:prop="httpNodeData.params.length > index ? `params.${index}.input` : undefined"> :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="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -186,10 +186,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = 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-change="
(visible) => visibleChange(visible, index, property.quote) (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-form-item> </el-form-item>
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('params', index)" <el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('params', index)"
@ -243,7 +243,7 @@
<el-select <el-select
v-model="property.type" v-model="property.type"
class="param-type" class="param-type"
@change="handleTypeChange(index, 'httpBody')" @change="handleTypeChange(property)"
> >
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
@ -308,13 +308,13 @@
} }
" "
v-model="property.quote" v-model="property.quote"
:checkStrictly="true" :props="{ checkStrictly: true }"
:options="quoteOptions" :options="quoteOptions"
placeholder="请选择" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote, 'httpBody') (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>
</el-form-item> </el-form-item>

View File

@ -151,7 +151,7 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue' 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' import { IMAGE_ANALYSIS_OUTPUT_PARAMS } from '@/utils/flowResources'
const props = defineProps({ data: Object }) 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 = () => { const handleQuoteChange = () => {
formData.imageQuoteType = imageCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output' formData.imageQuoteType = imageCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
} }
@ -320,6 +320,7 @@ const legacyPrompt = (params) => {
* 将持久化 nodeParams 回填到表单,并为缺少新字段的旧节点补默认值。 * 将持久化 nodeParams 回填到表单,并为缺少新字段的旧节点补默认值。
*/ */
const initData = () => { const initData = () => {
loadQuoteOptions(true)
const params = props.data?.properties?.nodeParams || [] const params = props.data?.properties?.nodeParams || []
const images = getParam(params, 'imageUrl') const images = getParam(params, 'imageUrl')
const reference = getParam(params, 'referenceImageUrl') const reference = getParam(params, 'referenceImageUrl')

View File

@ -164,7 +164,7 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue' import { Refresh } from '@element-plus/icons-vue'
import { getInput } from '@/utils/flow' import { getUpstreamInput } from '@/utils/flow'
/** /**
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。 * data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
@ -371,7 +371,7 @@ const normalizeStoredTuning = (value, preset) => {
// getInput 根据当前节点 ID 返回可引用的上游输出,只在级联面板打开时刷新以减少无效计算。 // getInput 根据当前节点 ID 返回可引用的上游输出,只在级联面板打开时刷新以减少无效计算。
const loadQuoteOptions = (visible) => { 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 支持的精确分析回退。 * 视频高级配置经过 normalizeStoredTuning 清洗,非 AUTO 模式强制关闭仅 AUTO 支持的精确分析回退。
*/ */
const initData = () => { const initData = () => {
loadQuoteOptions(true)
const params = props.data?.properties?.nodeParams || [] const params = props.data?.properties?.nodeParams || []
const profile = params.find(item => item.name === 'profileCode') const profile = params.find(item => item.name === 'profileCode')
const expectedLabel = params.find(item => item.name === 'expectedLabel') const expectedLabel = params.find(item => item.name === 'expectedLabel')

View File

@ -16,7 +16,7 @@
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`"> <el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
<el-select v-model="property.type" class="param-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="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -38,10 +38,10 @@
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]" :rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
:prop="`nodeParams.${index}.quote`"> :prop="`nodeParams.${index}.quote`">
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }" <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="请选择" placeholder="请选择"
@visible-change="(visible) => visibleChange(visible, index, property.quote)" @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-form-item> </el-form-item>
</el-row> </el-row>

View File

@ -16,7 +16,7 @@
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable /> <el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`"> <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="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -47,10 +47,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = 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-change="
(visible) => visibleChange(visible, index, property.quote) (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-form-item> </el-form-item>
</el-row> </el-row>
@ -69,7 +69,7 @@
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable /> <el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`tts.${index}.input`"> <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="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -100,10 +100,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = 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-change="
(visible) => visibleChange(visible, index, property.quote) (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-form-item> </el-form-item>
</el-row> </el-row>

View 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>

View File

@ -141,7 +141,7 @@
import { reactive, ref, watch } from "vue"; import { reactive, ref, watch } from "vue";
import { Plus, Minus } from "@element-plus/icons-vue"; import { Plus, Minus } from "@element-plus/icons-vue";
import { v4 as randomUUID } from "uuid"; import { v4 as randomUUID } from "uuid";
import { getInput, removeSwitchEdge } from "@/utils/flow"; import { getUpstreamInput, removeSwitchEdge } from "@/utils/flow";
const props = defineProps({ data: Object }); const props = defineProps({ data: Object });
const emit = defineEmits(["save-success", "save-error"]); const emit = defineEmits(["save-success", "save-error"]);
@ -196,7 +196,7 @@ const normalizeQuotePath = (value) => {
/** 深拷贝已保存条件,确保取消编辑时不会污染节点属性。 */ /** 深拷贝已保存条件,确保取消编辑时不会污染节点属性。 */
const initData = () => { const initData = () => {
// 必须先提供选项树再赋已有值,否则级联组件无法将节点 ID 路径解析成可见标签。 // 必须先提供选项树再赋已有值,否则级联组件无法将节点 ID 路径解析成可见标签。
quoteOptions.value = getInput(props.data.id) || []; quoteOptions.value = getUpstreamInput(props.data.id) || [];
const source = props.data?.properties?.nodeParams || props.data?.properties?.conditions || []; const source = props.data?.properties?.nodeParams || props.data?.properties?.conditions || [];
formData.nodeParams = JSON.parse(JSON.stringify(source)); formData.nodeParams = JSON.parse(JSON.stringify(source));
if (!formData.nodeParams.length) formData.nodeParams.push(createCondition()); if (!formData.nodeParams.length) formData.nodeParams.push(createCondition());
@ -257,7 +257,7 @@ const resetCompareValue = (item) => {
*/ */
const refreshQuoteOptions = (visible, item, field) => { const refreshQuoteOptions = (visible, item, field) => {
if (!visible) return; if (!visible) return;
quoteOptions.value = getInput(props.data.id) || []; quoteOptions.value = getUpstreamInput(props.data.id) || [];
item[field] = item[field] || ""; item[field] = item[field] || "";
}; };

View File

@ -1,75 +1,35 @@
/**
* 文件说明:参数引用组合逻辑,维护可引用字段和级联选择状态。
* 作用范围:仅服务于流程设计器模块。
*/
import { ref } from 'vue' import { ref } from 'vue'
import { getInput } from '@/utils/flow' import { getUpstreamInput } from '@/utils/flow'
/**
* 处理 useQuote 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} nodeId 流程节点 ID
*/
export function useQuote(nodeId) { export function useQuote(nodeId) {
const quoteOptions = ref([]) const quoteOptions = ref([])
const cascaderRefs = ref([]) const cascaderRefs = ref([])
/** const refreshQuoteOptions = () => {
* 处理 handleTypeChange 对应的数据或交互;作用范围仅限当前组件或模块。 quoteOptions.value = getUpstreamInput(nodeId) || []
* @param {*} index 目标项索引 }
* @param {*} formData 参数表单数据
* @param {*} type 目标类型 // The option tree must exist before Cascader can render a persisted path.
*/ refreshQuoteOptions()
const handleTypeChange = (index, formData, type = 'default') => {
// 根据节点类型处理不同数据源 const handleTypeChange = (property) => {
// 这里简化为通用逻辑,具体实现需根据实际数据结构调整 if (!property) return
if (formData.nodeParams[index].type === 'input') { if (property.type === 'input') {
formData.nodeParams[index].quote = '' property.quote = []
} else { } else {
formData.nodeParams[index].input = '' property.input = ''
const option = getInput(nodeId) refreshQuoteOptions()
if (option) quoteOptions.value = option
} }
} }
/** const cascaderChange = (value, property) => {
* 处理 cascaderChange 对应的数据或交互;作用范围仅限当前组件或模块。 if (!property) return
* @param {*} value 待处理的新值 property.quote = Array.isArray(value) ? [...value] : []
* @param {*} index 目标项索引 property.quoteType = value?.[1] === 'input' ? 'input' : 'output'
* @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 visibleChange = (visible) => {
* 处理 visibleChange 对应的数据或交互;作用范围仅限当前组件或模块。 if (visible) refreshQuoteOptions()
* @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)
}
}
}
} }
return { return {
@ -77,6 +37,7 @@ export function useQuote(nodeId) {
cascaderRefs, cascaderRefs,
handleTypeChange, handleTypeChange,
cascaderChange, cascaderChange,
visibleChange visibleChange,
refreshQuoteOptions,
} }
} }

View File

@ -175,6 +175,18 @@ export const collapseList = [
outputParams: [], outputParams: [],
outputType: 'json' outputType: 'json'
}, },
{
icon: planSvg,
name: "子流程",
type: "subFlow",
action: 'SUB_FLOW',
nodeType: 'NONE',
desc: "执行同一项目中已发布的工作流",
subFlow: {},
nodeParams: [],
outputParams: [],
outputType: 'json'
},
{ {
icon: stopLoopSvg, icon: stopLoopSvg,
name: "结束循环", name: "结束循环",

View File

@ -186,6 +186,7 @@
v-if="showParamsDrawer" v-if="showParamsDrawer"
:drawer="true" :drawer="true"
:data="paramsDrawerData" :data="paramsDrawerData"
:flow-id="flowInfoData.itemId"
@close="showParamsDrawer = false" @close="showParamsDrawer = false"
@extract-step="handleExtractQueueStep" @extract-step="handleExtractQueueStep"
/> />
@ -600,7 +601,7 @@ const handleConnect = (connection) => {
*/ */
const handleNodeDoubleClick = ({ node }) => { const handleNodeDoubleClick = ({ node }) => {
const data = lf.getGraphData().nodes.find((item) => item.id === node.id); 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; showParamsDrawer.value = true;
paramsDrawerData.value = data; paramsDrawerData.value = data;
}; };
@ -1061,6 +1062,318 @@ const changeState = (value) => {
loopFlowView(value.instId); 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) => { const loopFlowView = async (instId, taskId = null) => {
// 轮询思路:优先使用任务 ID 查询指定执行,否则查询实例最新状态,再统一映射节点状态。 // 轮询思路:优先使用任务 ID 查询指定执行,否则查询实例最新状态,再统一映射节点状态。
// 仅在非日志模式下根据终态停止或续排定时器,日志回放保持为一次性快照。 // 仅在非日志模式下根据终态停止或续排定时器,日志回放保持为一次性快照。
@ -1082,27 +1395,50 @@ const loopFlowView = async (instId, taskId = null) => {
if (arr.length > 0) hasRunResult.value = true; if (arr.length > 0) hasRunResult.value = true;
arr.forEach((item) => { arr.forEach((item) => {
emitter.emit("changeNodeState", 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())); oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
const first = arr[0]; 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 last = arr[arr.length - 1];
const lastNodeType = String(last?.nodeType || "").toUpperCase(); const mainEndStatus = String(mainEnd?.status || "").toUpperCase();
const lastStatus = String(last?.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) { if (!flowInfoData.value.isLog) {
flowStore.updateDisableForm(false); flowStore.updateDisableForm(false);
flowState.value = "testRunFinish"; 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 (!flowInfoData.value.isLog) {
if (["PAUSED", "STOPPED"].includes(lastStatus)) {
flowState.value = lastStatus === "PAUSED" ? "pause" : "stop"; flowState.value = lastStatus === "PAUSED" ? "pause" : "stop";
} else {
flowState.value = "testRunError";
totalRunningTime.value = last.endTime - first.startTime;
flowStore.updateDisableForm(false);
}
} }
} else if (!flowInfoData.value.isLog) { } else if (!flowInfoData.value.isLog) {
// END is first persisted as RUNNING and updated to SUCCESS after the handler returns. // END is first persisted as RUNNING and updated to SUCCESS after the handler returns.

View File

@ -20,7 +20,23 @@
/> />
<div class="loop-body-title"> <div class="loop-body-title">
<span>循环体</span> <span>循环体</span>
<div class="loop-body-actions">
<span class="loop-body-count">{{ actualChildCount }} 个节点</span> <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>
<div <div
class="child__container" class="child__container"
@ -33,9 +49,15 @@
</template> </template>
<script setup> <script setup>
import NodeTitle from "../../components/NodeTitle.vue"; 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 loop from "../../icon/loop.svg";
import { emitter } from "@/utils/eventBus"; 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({ const props = defineProps({
model: Object, model: Object,
@ -49,6 +71,42 @@ const props = defineProps({
const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]); const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]);
const isDragOver = ref(false); const isDragOver = ref(false);
const actualChildCount = computed(() => props.childCount ?? props.model?.children?.size ?? 0); 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 对应的数据或交互;作用范围仅限当前组件或模块。 * 处理 handleDragover 对应的数据或交互;作用范围仅限当前组件或模块。
@ -174,6 +232,18 @@ const setNodeName = (name) => {
font-weight: 400; 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 { .child__container {
min-height: 300px; min-height: 300px;
flex: 1; flex: 1;

View 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>

View File

@ -84,6 +84,7 @@ import SdAgentNode from "../nodes/function/sdAgent.vue";
import RecognizeNode from "../nodes/function/recognize.vue"; import RecognizeNode from "../nodes/function/recognize.vue";
import DeviceUniversal from "../nodes/function/deviceUniversal.vue"; import DeviceUniversal from "../nodes/function/deviceUniversal.vue";
import ActionQueue from "../nodes/function/actionQueue.vue"; import ActionQueue from "../nodes/function/actionQueue.vue";
import SubFlowNode from "../nodes/function/subFlow.vue";
const props = defineProps({ const props = defineProps({
id: String, id: String,
@ -112,6 +113,7 @@ const componentMap = {
deviceUniversal: DeviceUniversal, deviceUniversal: DeviceUniversal,
device_universal: DeviceUniversal, device_universal: DeviceUniversal,
actionQueue: ActionQueue, actionQueue: ActionQueue,
subFlow: SubFlowNode,
}; };
const flowStore = useFlowStore(); const flowStore = useFlowStore();

View File

@ -38,16 +38,13 @@
<!-- 展开后只把当前检测项已经归类的节点日志交给通用日志组件渲染。 --> <!-- 展开后只把当前检测项已经归类的节点日志交给通用日志组件渲染。 -->
<div v-if="isExpanded(group.key)" class="task-log__details"> <div v-if="isExpanded(group.key)" class="task-log__details">
<div class="task-log__details-heading"> <div class="task-log__details-heading">
<span>节点执行日志</span> <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('、') }}
</div> </div>
<TaskExecutionLog <TaskExecutionLog
v-if="group.logs.length" v-if="group.logs.length"
:list="group.logs" :list="group.logs"
:height="logHeight(group.logs)" :height="logHeight(group.logs)"
mode="user"
/> />
<div v-else class="task-log__details-empty">该检测项暂未产生节点日志</div> <div v-else class="task-log__details-empty">该检测项暂未产生节点日志</div>
</div> </div>

View File

@ -166,36 +166,7 @@
@close="cancel" @close="cancel"
@open="getLogList" @open="getLogList"
> >
<el-timeline v-loading="logLoading"> <TaskExecutionLog v-loading="logLoading" :list="logList" :height="560" />
<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>
</el-drawer> </el-drawer>
<!-- 添加或修改检测项日志对话框 --> <!-- 添加或修改检测项日志对话框 -->
@ -247,6 +218,7 @@ import { appendParamsToPath } from "@/utils/fn.js";
import { getTaskConfig } from "@/api/test/config.js"; import { getTaskConfig } from "@/api/test/config.js";
import { onMounted } from "vue"; import { onMounted } from "vue";
import { fetchAllPagedRows } from '@/utils/pagedRequest' import { fetchAllPagedRows } from '@/utils/pagedRequest'
import TaskExecutionLog from '@/views/flow/components/TaskExecutionLog.vue'
import { useContainerHeight } from "@/hooks/tableHeight"; import { useContainerHeight } from "@/hooks/tableHeight";