CMVR-IOT-UI/src/views/flow/vue-flow/FlowNode.vue

379 lines
12 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 }]"
>
<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 && flowType !== 'branch' }">
<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>
<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/device_universal.vue";
const props = defineProps({
id: String,
data: Object,
selected: 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,
device_universal: DeviceUniversal,
};
const flowStore = useFlowStore();
const vueFlow = useVueFlow("main-flow");
const componentRef = ref();
const flowType = computed(() => props.data.flowType);
const properties = computed(() => props.data.properties || {});
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;
&--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-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>