feat(flow): 添加动作队列节点拖拽和步骤提取功能

- 实现节点拖拽到动作队列的功能,支持将工作流节点转换为队列步骤
- 添加从动作队列中提取步骤还原为独立节点的功能,支持多种放置方式
- 新增动作队列相关的工具函数和验证逻辑
- 在流程图界面中添加队列拖拽视觉反馈效果
- 扩展动作队列参数面板,增加步骤提取操作选项
- 实现队列步骤与源节点之间的双向转换机制
This commit is contained in:
lixiaolong 2026-08-14 15:37:47 +08:00
parent 7de5b5d093
commit cad4184a2f
5 changed files with 518 additions and 39 deletions

View File

@ -1,5 +1,346 @@
const deepClone = (value) => JSON.parse(JSON.stringify(value))
const ACTION_QUEUE_ACTION = 'SYSTEM_EXECUTE_ACTION_QUEUE'
const REFERENCE_KEYS = new Set(['quote', 'nameQuote'])
const DEFAULT_TOTAL_TIMEOUT_BUFFER_MS = 5000
const createKey = (prefix) => {
const random = globalThis.crypto?.randomUUID?.().replace(/-/g, '')
|| `${Date.now()}${Math.random().toString(16).slice(2)}`
return `${prefix}_${random}`
}
const paramsToObject = (node) => Object.fromEntries(
(node?.properties?.nodeParams || []).map((param) => [param.name, param.input]),
)
const numberValue = (value, fallback = 0) => {
if (value === '' || value === null || value === undefined) return fallback
const result = Number(value)
return Number.isFinite(result) ? result : fallback
}
const arrayValue = (value) => {
if (Array.isArray(value)) return deepClone(value)
if (typeof value !== 'string') return value
try {
const result = JSON.parse(value)
return Array.isArray(result) ? result : value
} catch {
return value
}
}
const nodeToQueueStep = (node) => {
const action = node?.properties?.action
const params = paramsToObject(node)
const common = {
stepName: node?.properties?.name || '导入步骤',
sourceNode: deepClone(node),
}
if (node?.type === 'sleep' || String(action).toLowerCase() === 'sleep') {
const durationMs = numberValue(params.delayMs)
return {
...common,
type: 'DELAY',
timeoutMs: Math.max(durationMs + 1000, 5000),
params: { durationMs },
}
}
if (action === 'AGV_MOVE_TO_POINT') {
return {
...common,
type: 'AGV_NAVIGATE_TO_POSE',
timeoutMs: 60000,
params: {
x: numberValue(params.x),
y: numberValue(params.y),
theta: numberValue(params.theta),
maxSpeed: numberValue(params.maxSpeed, 0.5),
},
}
}
if (action === 'AGV_MOVE_TO_STATION') {
return {
...common,
type: 'AGV_NAVIGATE_TO_STATION',
timeoutMs: 60000,
params: {
stationId: params.stationId ?? '',
maxSpeed: numberValue(params.maxSpeed, 0.5),
},
}
}
if (action === 'ARM_MOVE_TO_POINT') {
return {
...common,
type: 'ARM_MOVE_L',
timeoutMs: 3000,
params: {
x: numberValue(params.x),
y: numberValue(params.y),
z: numberValue(params.z),
rx: numberValue(params.rx),
ry: numberValue(params.ry),
rz: numberValue(params.rz),
frame: params.frame || 'BASE',
velocity: numberValue(params.velocity, 2.1),
acceleration: numberValue(params.acceleration, 2.2),
},
}
}
if (action === 'ARM_MOVE_TO_J') {
return {
...common,
type: 'ARM_MOVE_J',
timeoutMs: 10000,
params: {
positions: arrayValue(params.target),
velocity: numberValue(params.velocity, 0.1),
acceleration: numberValue(params.acceleration, 0.2),
},
}
}
return null
}
const validateConvertibleNodeParams = (node) => {
const action = node?.properties?.action
const params = paramsToObject(node)
const requiredByAction = {
AGV_MOVE_TO_POINT: ['x', 'y', 'theta'],
AGV_MOVE_TO_STATION: ['stationId'],
ARM_MOVE_TO_POINT: ['x', 'y', 'z', 'rx', 'ry', 'rz'],
ARM_MOVE_TO_J: ['target'],
}
const missing = (requiredByAction[action] || []).find((name) => (
params[name] === '' || params[name] === null || params[name] === undefined
))
if (missing) return `节点[${node.properties?.name || node.id}]的参数 ${missing} 尚未配置`
if (action === 'ARM_MOVE_TO_J') {
const positions = arrayValue(params.target)
if (!Array.isArray(positions) || positions.length === 0) return '机械臂关节位置必须是非空 JSON 数组'
}
if ((node?.type === 'sleep' || String(action).toLowerCase() === 'sleep')
&& numberValue(params.delayMs) < 0) return '睡眠时长不能小于 0'
return ''
}
const hasReference = (value, referencedNodeId, parentKey = '') => {
if (Array.isArray(value)) {
if (REFERENCE_KEYS.has(parentKey) && value[0] === referencedNodeId) return true
return value.some((item) => hasReference(item, referencedNodeId))
}
if (!value || typeof value !== 'object') return false
return Object.entries(value).some(([key, item]) => hasReference(item, referencedNodeId, key))
}
const hasDynamicInput = (node) => (node?.properties?.nodeParams || []).some((param) => (
param?.type === 'quote'
|| (Array.isArray(param?.quote) && param.quote.length > 0)
))
const getNodeParam = (node, name, fallback) => (
node.properties?.nodeParams?.find((param) => param.name === name)?.input ?? fallback
)
const setNodeParam = (node, name, input, defaults = {}) => {
node.properties ||= {}
node.properties.nodeParams ||= []
const param = node.properties.nodeParams.find((item) => item.name === name)
if (param) param.input = input
else node.properties.nodeParams.push({ name, type: 'input', input, ...defaults })
}
const queueSteps = (queueNode) => {
const value = getNodeParam(queueNode, 'steps', [])
return Array.isArray(value) ? deepClone(value) : []
}
const edgeExists = (edges, sourceNodeId, targetNodeId) => edges.some((edge) => (
edge.sourceNodeId === sourceNodeId && edge.targetNodeId === targetNodeId
))
const createEdge = (sourceNodeId, targetNodeId, sourceAnchorId, targetAnchorId) => ({
id: createKey('edge'),
type: 'bezier',
sourceNodeId,
targetNodeId,
sourceAnchorId: sourceAnchorId || `${sourceNodeId}_1`,
targetAnchorId: targetAnchorId || `${targetNodeId}_3`,
})
const normalizeQueueStepIds = (steps) => steps.map((step, index) => ({
...step,
stepId: `step_${index + 1}`,
}))
const updateQueueDefinition = (queueNode, steps) => {
const normalizedSteps = normalizeQueueStepIds(steps)
setNodeParam(queueNode, 'steps', normalizedSteps, { required: true })
const timeoutSum = normalizedSteps.reduce((sum, step) => sum + Math.max(0, numberValue(step.timeoutMs)), 0)
const currentTimeout = numberValue(getNodeParam(queueNode, 'totalTimeoutMs', 60000), 60000)
setNodeParam(
queueNode,
'totalTimeoutMs',
Math.max(currentTimeout, timeoutSum + DEFAULT_TOTAL_TIMEOUT_BUFFER_MS),
{ componentType: 'number', required: true },
)
const bindings = normalizedSteps.filter((step) => step.deviceBindingKey && step.deviceSlotKey).map((step) => ({
parameterName: step.deviceBindingKey,
slotKey: step.deviceSlotKey,
}))
queueNode.properties.executionTarget = {
...(queueNode.properties.executionTarget || {}),
dispatchPolicy: 'ONE',
deviceBindings: bindings,
}
}
export const isNodeConvertibleToActionQueue = (node) => Boolean(nodeToQueueStep(node))
/**
* Converts one workflow node into an edge action-queue step and rewrites its former
* linear position out of the graph. The source definition is retained for extraction.
*/
export const appendNodeToActionQueue = (sourceGraph, nodeId, queueId) => {
const graph = deepClone(sourceGraph || { nodes: [], edges: [] })
const node = graph.nodes.find((item) => item.id === nodeId)
const queueNode = graph.nodes.find((item) => item.id === queueId)
if (!node || !queueNode) return { ok: false, message: '未找到要移动的节点或动作队列' }
if (node.id === queueNode.id) return { ok: false, message: '动作队列不能放入自身' }
if (queueNode.properties?.action !== ACTION_QUEUE_ACTION) return { ok: false, message: '目标节点不是动作队列' }
const step = nodeToQueueStep(node)
if (!step) return { ok: false, message: `节点[${node.properties?.name || node.id}]暂不支持放入动作队列` }
const paramError = validateConvertibleNodeParams(node)
if (paramError) return { ok: false, message: paramError }
if (node.properties?.parentId !== queueNode.properties?.parentId) {
return { ok: false, message: '节点和动作队列必须位于同一个流程或循环体中' }
}
if (hasDynamicInput(node)) return { ok: false, message: '引用其他节点参数的节点暂不能放入动作队列' }
const referencedBy = graph.nodes.find((item) => item.id !== nodeId && hasReference(item.properties, nodeId))
if (referencedBy) {
return { ok: false, message: `节点[${referencedBy.properties?.name || referencedBy.id}]正在引用该节点的输出` }
}
const incoming = graph.edges.filter((edge) => edge.targetNodeId === nodeId)
const outgoing = graph.edges.filter((edge) => edge.sourceNodeId === nodeId)
if (incoming.length > 1 || outgoing.length > 1) {
return { ok: false, message: '存在分支连线的节点不能放入动作队列' }
}
if (incoming.length === 1
&& outgoing.length === 1
&& incoming[0].sourceNodeId === outgoing[0].targetNodeId) {
return { ok: false, message: '节点处于回路中,不能放入动作队列' }
}
const nodeRoleKey = node.properties?.executionTarget?.roleKey
const queueRoleKey = queueNode.properties?.executionTarget?.roleKey
if (nodeRoleKey && queueRoleKey && nodeRoleKey !== queueRoleKey) {
return { ok: false, message: '节点与动作队列使用的机器人角色不同' }
}
if (nodeRoleKey && !queueRoleKey) queueNode.properties.executionTarget = { roleKey: nodeRoleKey }
const deviceSlotKey = node.properties?.executionTarget?.deviceSlotKey
if (deviceSlotKey) {
step.deviceBindingKey = createKey('queue_device')
step.deviceSlotKey = deviceSlotKey
}
step.stepId = `step_${queueSteps(queueNode).length + 1}`
const steps = [...queueSteps(queueNode), step]
updateQueueDefinition(queueNode, steps)
graph.nodes = graph.nodes.filter((item) => item.id !== nodeId)
graph.nodes.forEach((item) => {
if (Array.isArray(item.children)) item.children = item.children.filter((childId) => childId !== nodeId)
})
graph.edges = graph.edges.filter((edge) => edge.sourceNodeId !== nodeId && edge.targetNodeId !== nodeId)
const predecessorId = incoming[0]?.sourceNodeId
const successorId = outgoing[0]?.targetNodeId
if (predecessorId && successorId && predecessorId !== successorId
&& !edgeExists(graph.edges, predecessorId, successorId)) {
graph.edges.push(createEdge(
predecessorId,
successorId,
incoming[0].sourceAnchorId,
outgoing[0].targetAnchorId,
))
}
return { ok: true, graph, queueId, step }
}
const remapNodeAnchors = (node, previousId, nextId) => {
const properties = node.properties || (node.properties = {})
if (!Array.isArray(properties.anchor)) return
properties.anchor = properties.anchor.map((anchor) => ({
...anchor,
id: String(anchor.id || '').startsWith(`${previousId}_`)
? `${nextId}${String(anchor.id).slice(previousId.length)}`
: anchor.id,
}))
}
/** Restores an imported queue step as a workflow node. */
export const extractActionQueueStep = (sourceGraph, queueId, stepIndex, placement = 'detached') => {
const graph = deepClone(sourceGraph || { nodes: [], edges: [] })
const queueNode = graph.nodes.find((item) => item.id === queueId)
if (!queueNode) return { ok: false, message: '动作队列不存在' }
const steps = queueSteps(queueNode)
const step = steps[stepIndex]
if (!step) return { ok: false, message: '要移出的步骤不存在' }
if (!step.sourceNode) return { ok: false, message: '该步骤不是由工作流节点移入,无法还原为原节点' }
const node = deepClone(step.sourceNode)
const previousId = node.id
node.id = createKey('node')
remapNodeAnchors(node, previousId, node.id)
delete node.children
node.properties ||= {}
node.properties.parentId = queueNode.properties?.parentId
if (!node.properties.parentId) delete node.properties.parentId
const horizontalOffset = placement === 'before' ? -460 : 460
node.x = numberValue(queueNode.x) + (placement === 'detached' ? 0 : horizontalOffset)
node.y = numberValue(queueNode.y) + (placement === 'detached' ? 260 : 0)
const incoming = graph.edges.filter((edge) => edge.targetNodeId === queueId)
const outgoing = graph.edges.filter((edge) => edge.sourceNodeId === queueId)
if ((placement === 'before' && incoming.length > 1) || (placement === 'after' && outgoing.length > 1)) {
return { ok: false, message: '动作队列存在多条相关连线,请选择“仅移出,不连线”' }
}
graph.nodes.push(node)
if (steps.length === 1) {
graph.nodes = graph.nodes.filter((item) => item.id !== queueId)
graph.edges.forEach((edge) => {
if (edge.sourceNodeId === queueId) {
edge.sourceNodeId = node.id
edge.sourceAnchorId = `${node.id}_1`
}
if (edge.targetNodeId === queueId) {
edge.targetNodeId = node.id
edge.targetAnchorId = `${node.id}_3`
}
})
return { ok: true, graph, node, removedQueue: true }
}
steps.splice(stepIndex, 1)
updateQueueDefinition(queueNode, steps)
if (placement === 'before') {
graph.edges = graph.edges.filter((edge) => edge.targetNodeId !== queueId)
const previous = incoming[0]
if (previous) graph.edges.push(createEdge(previous.sourceNodeId, node.id, previous.sourceAnchorId, `${node.id}_3`))
graph.edges.push(createEdge(node.id, queueId, `${node.id}_1`, `${queueId}_3`))
} else if (placement === 'after') {
graph.edges = graph.edges.filter((edge) => edge.sourceNodeId !== queueId)
const next = outgoing[0]
graph.edges.push(createEdge(queueId, node.id, `${queueId}_1`, `${node.id}_3`))
if (next) graph.edges.push(createEdge(node.id, next.targetNodeId, `${node.id}_1`, next.targetAnchorId))
}
return { ok: true, graph, node, removedQueue: false }
}
export const getActionQueueStepName = (step, index) => {
const name = String(step?.stepName || '').trim()
return name || `步骤 ${index + 1}`

View File

@ -20,6 +20,7 @@
:data="props.data"
@save-success="handleSaveSuccess"
@save-error="handleSaveError"
@extract-step="handleExtractStep"
/>
</el-drawer>
</template>
@ -45,7 +46,7 @@ const props = defineProps({
data: Object
})
const emits = defineEmits(['close'])
const emits = defineEmits(['close', 'extract-step'])
const paramsComponentRef = ref(null)
/**
@ -106,6 +107,8 @@ const handleSaveError = () => {
emits('close')
}
const handleExtractStep = (payload) => emits('extract-step', payload)
// 组件卸载时清理事件监听
// 注意:emitter.off 应在各子组件中自行清理,父组件不再重复
</script>

View File

@ -42,6 +42,16 @@
<el-tooltip content="复制步骤" placement="top">
<el-button :icon="CopyDocument" circle size="small" aria-label="复制步骤" @click="copyStep(index)" />
</el-tooltip>
<el-dropdown v-if="step.sourceNode" trigger="click" @command="(placement) => requestExtractStep(index, placement)">
<el-button :icon="MoreFilled" circle size="small" aria-label="移出队列" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="before">移到队列前并连接</el-dropdown-item>
<el-dropdown-item command="after">移到队列后并连接</el-dropdown-item>
<el-dropdown-item command="detached" divided>仅移出,不连线</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-tooltip content="删除步骤" placement="top">
<el-button :icon="Delete" circle size="small" type="danger" plain aria-label="删除步骤" @click="removeStep(index)" />
</el-tooltip>
@ -131,7 +141,7 @@
<script setup>
import { reactive, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, CopyDocument, Delete, Location, Plus } from '@element-plus/icons-vue'
import { ArrowDown, ArrowUp, CopyDocument, Delete, Location, MoreFilled, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
@ -147,7 +157,7 @@ import { DEVICE_KIND, toDeviceOptions } from '@/utils/robotDevice'
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep } from '@/utils/actionQueue'
const props = defineProps({ data: { type: Object, required: true } })
const emit = defineEmits(['save-success', 'save-error'])
const emit = defineEmits(['save-success', 'save-error', 'extract-step'])
const actionTypes = [
{ value: 'DELAY', label: '等待' },
@ -238,6 +248,7 @@ const normalizeStep = (source = {}, index = 0) => {
deviceBindingKey: source.deviceBindingKey || createResourceKey('queue_device'),
deviceSlotKey: source.deviceSlotKey || '',
stationOptions: [],
...(source.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(source.sourceNode)) } : {}),
params,
}
}
@ -424,8 +435,7 @@ const parseJsonFields = (step) => {
return params
}
const validateAndSave = async () => {
try {
const buildQueueDefinition = () => {
if (!formData.executionTarget.roleKey) throw new Error('请选择执行机器人角色')
if (!formData.steps.length) throw new Error('请添加至少一个动作步骤')
const steps = formData.steps.map((step, index) => {
@ -444,17 +454,24 @@ const validateAndSave = async () => {
deviceBindingKey: step.deviceBindingKey,
deviceSlotKey: step.deviceSlotKey,
} : {}),
...(step.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(step.sourceNode)) } : {}),
params,
}
})
const executionTarget = {
return {
steps,
executionTarget: {
roleKey: formData.executionTarget.roleKey,
dispatchPolicy: 'ONE',
deviceBindings: steps.filter((step) => step.deviceBindingKey).map((step) => ({
parameterName: step.deviceBindingKey,
slotKey: step.deviceSlotKey,
})),
},
}
}
const persistQueueDefinition = ({ steps, executionTarget }) => {
lf.setProperties(props.data.id, {
...props.data.properties,
executionTarget,
@ -464,6 +481,20 @@ const validateAndSave = async () => {
{ name: 'steps', type: 'input', input: steps, required: true },
],
})
}
const requestExtractStep = (index, placement) => {
try {
persistQueueDefinition(buildQueueDefinition())
emit('extract-step', { queueId: props.data.id, index, placement })
} catch (error) {
ElMessage.warning(error.message)
}
}
const validateAndSave = async () => {
try {
persistQueueDefinition(buildQueueDefinition())
const cleanup = pruneUnusedNodeResources(lf.getGraphData())
if (cleanup.start) {
lf.setProperties(cleanup.start.id, {

View File

@ -132,7 +132,10 @@
@drop="drop"
>
<template #node-flow-node="nodeProps">
<FlowNode v-bind="nodeProps" />
<FlowNode
v-bind="nodeProps"
:queue-drop-active="nodeProps.id === queueDropTargetId"
/>
</template>
<template #edge-flow-edge="edgeProps">
<FlowEdge v-bind="edgeProps" />
@ -156,6 +159,7 @@
:drawer="true"
:data="paramsDrawerData"
@close="showParamsDrawer = false"
@extract-step="handleExtractQueueStep"
/>
</div>
@ -287,6 +291,10 @@ import {
pruneUnusedNodeResources,
validateFlowResources,
} from "@/utils/flowResources";
import {
appendNodeToActionQueue,
extractActionQueueStep,
} from "@/utils/actionQueue";
import {
flowDeploy,
flowView,
@ -318,6 +326,8 @@ const lfRef = ref(null);
const vueFlow = useVueFlow("main-flow");
const lf = createFlowFacade(vueFlow);
const loopChildDragContexts = new Map();
const actionQueueDragOrigins = new Map();
const queueDropTargetId = ref("");
const { onFlowEvent } = useFlowEvents();
window.lf = lf;
@ -452,12 +462,71 @@ const eventClientPoint = (event) => {
return { x: pointer.clientX, y: pointer.clientY };
};
const findActionQueueAtPoint = (point, draggedNodeId) => (
(vueFlow.getNodes.value || []).find((candidate) => {
if (candidate.id === draggedNodeId || candidate.data?.flowType !== "actionQueue") return false;
const position = candidate.computedPosition || candidate.position;
const width = Number(candidate.dimensions?.width || candidate.data?.size?.width || 372);
const height = Number(candidate.dimensions?.height || candidate.data?.size?.height || 180);
return point.x >= position.x
&& point.x <= position.x + width
&& point.y >= position.y
&& point.y <= position.y + height;
}) || null
);
const queueAtDragEvent = (nodeId, event) => {
const clientPoint = eventClientPoint(event);
if (!clientPoint) return null;
return findActionQueueAtPoint(
lf.getPointByClient(clientPoint.x, clientPoint.y).canvasOverlayPosition,
nodeId,
);
};
const renderQueueGraphChange = (graph, selectedNodeId) => {
const cleanup = pruneUnusedNodeResources(graph);
lf.render(cleanup.graph, { resetHistory: false, replaceNodes: true });
nextTick(() => {
lf.markChanged();
if (selectedNodeId) lf.selectElementById(selectedNodeId);
});
};
const restoreDraggedNodePosition = (nodeId) => {
const origin = actionQueueDragOrigins.get(nodeId);
if (!origin) return;
const graph = lf.getGraphData();
const node = graph.nodes.find((item) => item.id === nodeId);
if (!node) return;
node.x = origin.x;
node.y = origin.y;
lf.render(graph, { resetHistory: false, replaceNodes: true });
nextTick(() => lf.markChanged());
};
const moveNodeIntoActionQueue = (nodeId, queueId) => {
const result = appendNodeToActionQueue(lf.getGraphData(), nodeId, queueId);
if (!result.ok) {
restoreDraggedNodePosition(nodeId);
ElMessage.warning(result.message);
return false;
}
if (paramsDrawerData.value?.id === nodeId) showParamsDrawer.value = false;
nodeErrorList.value = nodeErrorList.value.filter((id) => id !== nodeId);
renderQueueGraphChange(result.graph, queueId);
ElMessage.success(`已将节点转换为队列第 ${result.step.stepId.replace("step_", "")} 步`);
return true;
};
/**
* 处理 handleNodeDragStart 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { node 调用方传入的 { node 参数
* @param {*} event } 调用方传入的 event } 参数
*/
const handleNodeDragStart = ({ node, event }) => {
const graphNode = lf.getGraphData().nodes.find((item) => item.id === node.id);
if (graphNode) actionQueueDragOrigins.set(node.id, { x: graphNode.x, y: graphNode.y });
const drag = lf.prepareLoopChildDrag(node.id);
const clientPoint = eventClientPoint(event);
if (!drag || !clientPoint) return;
@ -488,7 +557,10 @@ const updateLoopChildDrag = ({ node, event }) => {
* 处理 handleNodeDrag 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} payload 事件数据
*/
const handleNodeDrag = (payload) => updateLoopChildDrag(payload);
const handleNodeDrag = (payload) => {
updateLoopChildDrag(payload);
queueDropTargetId.value = queueAtDragEvent(payload.node.id, payload.event)?.id || "";
};
/**
* 处理 handleNodeDragStop 对应的数据或交互;作用范围仅限当前组件或模块。
@ -498,6 +570,14 @@ const handleNodeDragStop = (payload) => {
const { node } = payload;
updateLoopChildDrag(payload);
loopChildDragContexts.delete(node.id);
const queueNode = queueAtDragEvent(node.id, payload.event);
queueDropTargetId.value = "";
if (queueNode) {
moveNodeIntoActionQueue(node.id, queueNode.id);
actionQueueDragOrigins.delete(node.id);
return;
}
actionQueueDragOrigins.delete(node.id);
const vueNode = vueFlow.findNode(node.id);
if (
vueNode
@ -1295,6 +1375,20 @@ const execute = async () => {
const showParamsDrawer = ref(false);
const paramsDrawerData = ref({});
const handleExtractQueueStep = ({ queueId, index, placement }) => {
const result = extractActionQueueStep(lf.getGraphData(), queueId, index, placement);
if (!result.ok) {
ElMessage.warning(result.message);
return;
}
showParamsDrawer.value = false;
if (result.removedQueue) {
nodeErrorList.value = nodeErrorList.value.filter((id) => id !== queueId);
}
renderQueueGraphChange(result.graph, result.node.id);
ElMessage.success(result.removedQueue ? "已用原节点替换空队列" : "步骤已移出动作队列");
};
/**
* 清理 clearRun 对应的数据或交互;作用范围仅限当前组件或模块。
*/
@ -1751,6 +1845,7 @@ onUnmounted(() => {
flowDisposed = true;
clearFlowPollTimer();
loopChildDragContexts.clear();
actionQueueDragOrigins.clear();
if (window.lf === lf) delete window.lf;
window.removeEventListener("beforeunload", handleBeforeUnload);

View File

@ -5,7 +5,10 @@
<template>
<div
class="flow-node-shell"
:class="[`flow-node-shell--${flowType}`, { 'flow-node-shell--selected': selected }]"
:class="[`flow-node-shell--${flowType}`, {
'flow-node-shell--selected': selected,
'flow-node-shell--queue-drop-active': queueDropActive,
}]"
>
<Handle
v-if="showTarget"
@ -86,6 +89,7 @@ const props = defineProps({
id: String,
data: Object,
selected: Boolean,
queueDropActive: Boolean,
connectable: [Boolean, Number, String, Function],
});
@ -259,6 +263,11 @@ onMounted(() => bindComponent(componentRef.value));
position: relative;
box-sizing: border-box;
&--queue-drop-active .flow-node-content {
border-radius: 8px;
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.35), 0 10px 24px rgba(47, 128, 237, 0.18);
}
&--start,
&--subStart { --node-accent: #239565; }