379 lines
12 KiB
Vue
379 lines
12 KiB
Vue
<!--
|
||
* 文件说明: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>
|