CMVR-BID/src/views/flow/vue-flow/FlowNode.vue
lixiaolong cad4184a2f feat(flow): 添加动作队列节点拖拽和步骤提取功能
- 实现节点拖拽到动作队列的功能,支持将工作流节点转换为队列步骤
- 添加从动作队列中提取步骤还原为独立节点的功能,支持多种放置方式
- 新增动作队列相关的工具函数和验证逻辑
- 在流程图界面中添加队列拖拽视觉反馈效果
- 扩展动作队列参数面板,增加步骤提取操作选项
- 实现队列步骤与源节点之间的双向转换机制
2026-08-14 15:37:47 +08:00

418 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
* 文件说明:Vue Flow 节点壳组件,负责锚点、连接规则、分支位置及分组交互适配。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div
class="flow-node-shell"
:class="[`flow-node-shell--${flowType}`, {
'flow-node-shell--selected': selected,
'flow-node-shell--queue-drop-active': queueDropActive,
}]"
>
<Handle
v-if="showTarget"
:id="targetHandleId"
type="target"
:position="Position.Left"
:connectable="connectable && !flowStore.disableForm"
:is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--target"
/>
<div class="flow-node-content" :class="{ 'flow-node-card': !isGroup }">
<component
:is="nodeComponent"
ref="componentRef"
:model="model"
:graph-model="model"
:properties="properties"
:child-count="isGroup ? childNodeCount : undefined"
@content-change="contentChanged"
@bind-ref="bindComponent"
@add-anchor="addAnchor"
@remove-anchor="removeAnchor"
@change-anchor="changeAnchor"
@sync-anchors="syncBranchAnchors"
@add-to-group="addToGroup"
/>
<div v-if="executionLabel" class="flow-node-executor">{{ executionLabel }}</div>
</div>
<template v-if="flowType === 'branch'">
<template v-for="anchor in branchAnchors" :key="anchor.id">
<Handle
:id="anchor.id"
type="source"
:position="Position.Right"
:connectable="connectable && !flowStore.disableForm"
:is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--source flow-handle--branch"
:style="{ top: `${Math.max(40, Number(anchor.height || 0))}px` }"
/>
</template>
</template>
<Handle
v-else-if="showSource"
:id="`${id}_1`"
type="source"
:position="Position.Right"
:connectable="connectable && !flowStore.disableForm"
:is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--source"
/>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { Handle, Position, useVueFlow } from "@vue-flow/core";
import { useFlowStore } from "@/store/modules/flow";
import StartNode from "../nodes/common/startNode.vue";
import EndNode from "../nodes/common/endNode.vue";
import ServiceNode from "../nodes/common/serviceNode.vue";
import SelectAreaNode from "../nodes/common/selectAreaNode.vue";
import LoopNode from "../nodes/function/loop.vue";
import BranchNode from "../nodes/function/switch.vue";
import StopLoopNode from "../nodes/function/stopLoop.vue";
import SubStartNode from "../nodes/function/subStart.vue";
import SubEndNode from "../nodes/function/subEnd.vue";
import SleepNode from "../nodes/function/sleep.vue";
import HttpNode from "../nodes/function/httpNode.vue";
import CodeNode from "../nodes/function/codeNode.vue";
import CurrentLoopNode from "../nodes/function/currentLoop.vue";
import SdAgentNode from "../nodes/function/sdAgent.vue";
import RecognizeNode from "../nodes/function/recognize.vue";
import DeviceUniversal from "../nodes/function/deviceUniversal.vue";
import ActionQueue from "../nodes/function/actionQueue.vue";
const props = defineProps({
id: String,
data: Object,
selected: Boolean,
queueDropActive: Boolean,
connectable: [Boolean, Number, String, Function],
});
const componentMap = {
start: StartNode,
end: EndNode,
serviceNode: ServiceNode,
selectArea: SelectAreaNode,
loop: LoopNode,
branch: BranchNode,
stopLoop: StopLoopNode,
subStart: SubStartNode,
subEnd: SubEndNode,
sleep: SleepNode,
http: HttpNode,
code: CodeNode,
currentLoop: CurrentLoopNode,
sdAgent: SdAgentNode,
recognize: RecognizeNode,
deviceUniversal: DeviceUniversal,
device_universal: DeviceUniversal,
actionQueue: ActionQueue,
};
const flowStore = useFlowStore();
const vueFlow = useVueFlow("main-flow");
const componentRef = ref();
const flowType = computed(() => props.data.flowType);
const properties = computed(() => props.data.properties || {});
const executionLabel = computed(() => {
const roleKey = properties.value.executionTarget?.roleKey;
if (!roleKey) return "";
const graph = window.lf?.getGraphData?.();
const start = graph?.nodes?.find((node) => node.type === "start");
const role = start?.properties?.resourceRoles?.find((item) => item.roleKey === roleKey);
if (!role) return "未配置机器人角色";
const bindings = properties.value.executionTarget?.deviceBindings || [];
if (bindings.length > 1) return `${role.displayName} · ${bindings.length} 个设备用途`;
const slotKey = properties.value.executionTarget?.deviceSlotKey
|| properties.value.executionTarget?.deviceBindings?.[0]?.slotKey;
const slot = role.deviceSlots?.find((item) => item.slotKey === slotKey);
return slot ? `${role.displayName} · ${slot.displayName}` : role.displayName;
});
const nodeComponent = computed(() => componentMap[flowType.value] || ServiceNode);
const isGroup = computed(() => ["loop", "selectArea"].includes(flowType.value));
const childNodeCount = computed(() => (
(vueFlow.getNodes.value || []).filter((node) => node.parentNode === props.id).length
));
const showTarget = computed(() => !["start", "subStart", "selectArea"].includes(flowType.value));
const showSource = computed(() => !["end", "stopLoop", "subEnd", "selectArea", "branch"].includes(flowType.value));
const targetHandleId = computed(() => flowType.value === "branch" ? `${props.id}_entry` : `${props.id}_3`);
const branchAnchors = computed(() => {
const reservedIds = new Set([`${props.id}_1`, `${props.id}_3`, `${props.id}_entry`]);
const conditionDefs = properties.value.nodeParams || properties.value.conditions || [];
const conditionIds = new Set(conditionDefs.map((condition) => condition?.id).filter(Boolean));
const elseId = `${props.id}_else`;
const seen = new Set();
return (properties.value.anchor || []).filter((anchor) => {
if (!anchor?.id || reservedIds.has(anchor.id) || seen.has(anchor.id)) return false;
if (conditionIds.size && anchor.id !== elseId && !conditionIds.has(anchor.id)) return false;
seen.add(anchor.id);
return true;
});
});
const model = computed(() => window.lf?.getNodeModelById(props.id));
/**
* 获取与 loop scope 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} nodeId 流程节点 ID
*/
const getLoopScope = (nodeId) => {
const node = vueFlow.findNode(nodeId);
const parent = node?.parentNode ? vueFlow.findNode(node.parentNode) : null;
return parent?.data?.flowType === "loop" ? parent.id : null;
};
/**
* 判断与 valid loop connection 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} connection 待校验或创建的连接信息
*/
const isValidLoopConnection = (connection) => {
if (!connection.source || !connection.target || connection.source === connection.target) return false;
return getLoopScope(connection.source) === getLoopScope(connection.target);
};
/**
* 同步与 anchors 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} anchors 需要同步的锚点集合
*/
const syncAnchors = (anchors) => {
window.lf?.setProperties(props.id, { ...window.lf.getProperties(props.id), anchor: anchors });
vueFlow.updateNodeInternals([props.id]);
};
/**
* 新增与 anchor 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} height 锚点纵向位置
* @param {*} anchorId 分支锚点 ID
*/
const addAnchor = (height, anchorId) => {
const anchors = [...branchAnchors.value];
if (!anchors.some((anchor) => anchor.id === anchorId)) {
anchors.push({ id: anchorId, name: "right", height });
syncAnchors(anchors);
}
};
/**
* 移除与 anchor 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} anchorId 分支锚点 ID
*/
const removeAnchor = (anchorId) => syncAnchors(branchAnchors.value.filter((anchor) => anchor.id !== anchorId));
/**
* 更新与 anchor 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} height 锚点纵向位置
* @param {*} anchorId 分支锚点 ID
*/
const changeAnchor = (height, anchorId) => syncAnchors(branchAnchors.value.map((anchor) => anchor.id === anchorId ? { ...anchor, height } : anchor));
/**
* 同步与 branch anchors 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} positions 分支锚点位置集合
*/
const syncBranchAnchors = (positions = []) => {
if (!positions.length) return;
const positionMap = new Map(positions.map((item) => [item.id, Number(item.height || 0)]));
const current = [...(properties.value.anchor || [])];
let changed = false;
const anchors = current.map((anchor) => {
if (!positionMap.has(anchor.id)) return anchor;
const height = positionMap.get(anchor.id);
positionMap.delete(anchor.id);
if (Math.abs(Number(anchor.height || 0) - height) < 0.5) return anchor;
changed = true;
return { ...anchor, height };
});
positionMap.forEach((height, id) => {
anchors.push({ id, name: "right", height });
changed = true;
});
if (changed) syncAnchors(anchors);
else vueFlow.updateNodeInternals([props.id]);
};
/**
* 处理与 content changed 相关的状态或数据,该影响范围仅限当前组件。
*/
const contentChanged = () => {
vueFlow.updateNodeInternals([props.id]);
window.lf?.markChanged();
};
/**
* 绑定与 component 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} instance 节点组件实例
*/
const bindComponent = (instance) => window.lf?.registerComponentInstance(props.id, instance);
/**
* 新增与 to group 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} childId 子节点 ID
* @param {*} dropCenter 拖放中心点坐标
*/
const addToGroup = (childId, dropCenter) => window.lf?.addToGroup(props.id, childId, dropCenter);
onMounted(() => bindComponent(componentRef.value));
</script>
<style scoped lang="scss">
.flow-node-shell {
--node-accent: #4f6b8a;
width: 100%;
min-height: 100%;
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; }
&--end,
&--subEnd,
&--stopLoop { --node-accent: #d45252; }
&--branch { --node-accent: #b7791f; }
&--loop,
&--currentLoop { --node-accent: #df6b35; }
&--http,
&--code { --node-accent: #3978c5; }
&--sdAgent,
&--recognize { --node-accent: #7a5ab8; }
&--loop,
&--selectArea {
height: 100%;
}
&:hover .flow-node-card {
border-color: #cbd3df;
box-shadow: 0 10px 26px rgba(31, 41, 55, 0.11);
}
&--selected .flow-node-card {
border-color: #2f80ed;
box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.2), 0 10px 26px rgba(31, 41, 55, 0.12);
}
&--selected.flow-node-shell--branch :deep(.node__container),
&--selected.flow-node-shell--loop :deep(.group__container),
&--selected.flow-node-shell--selectArea :deep(.group__container) {
border-color: #2f80ed;
box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.18);
}
}
.flow-node-content {
width: 100%;
height: 100%;
box-sizing: border-box;
}
.flow-node-executor {
overflow: hidden;
margin-top: 8px;
color: #667085;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-node-card {
width: 100%;
height: auto;
padding: 13px 14px 13px 16px;
position: relative;
background: #fff;
border: 1px solid #dfe4ec;
border-left: 4px solid var(--node-accent);
border-radius: 8px;
box-shadow: 0 7px 20px rgba(31, 41, 55, 0.08);
box-sizing: border-box;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
:deep(.node__container) {
width: 100%;
height: auto;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
box-sizing: border-box;
}
:deep(.terminal-node) {
display: flex;
min-width: 0;
align-items: center;
gap: 11px;
}
:deep(.terminal-node__icon) {
display: grid;
width: 38px;
height: 38px;
flex: 0 0 38px;
place-items: center;
border-radius: 50%;
background: #f1f5f9;
}
:deep(.terminal-node__icon img) {
width: 22px;
height: 22px;
}
:deep(.terminal-node__content) { min-width: 0; }
:deep(.terminal-node__title) {
color: #1f2937;
font-size: 15px;
font-weight: 650;
line-height: 21px;
}
:deep(.terminal-node__description) {
overflow: hidden;
margin-top: 3px;
color: #7a8494;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.flow-handle {
width: 12px;
height: 12px;
border: 2px solid #fff;
background: var(--node-accent);
box-shadow: 0 0 0 1px var(--node-accent), 0 2px 5px rgba(31, 41, 55, 0.18);
z-index: 10;
transition: transform 0.14s ease, box-shadow 0.14s ease;
&:hover {
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.18), 0 2px 6px rgba(31, 41, 55, 0.2);
}
&--target {
left: -6px;
background: #fff;
border-color: var(--node-accent);
}
&--source { right: -6px; }
&--branch { transform: translate(50%, -50%); }
&--branch:hover { transform: translate(50%, -50%) scale(1.18); }
}
</style>