lxl-dev #2

Merged
zhanghao merged 9 commits from lxl-dev into dev 2026-08-03 16:56:48 +08:00
28 changed files with 1622 additions and 1327 deletions
Showing only changes of commit a30903dd70 - Show all commits

View File

@ -214,13 +214,11 @@ export const getInputNumber = (nodeId) => {
export const removeSwitchEdge = (nodeId, sourceAnchorId) => { export const removeSwitchEdge = (nodeId, sourceAnchorId) => {
const { edges } = lf.getGraphData(); const { edges } = lf.getGraphData();
const edge = edges.find(item => { const matchedEdges = edges.filter(item => {
return item.sourceNodeId === nodeId && item.sourceAnchorId === sourceAnchorId return item.sourceNodeId === nodeId && item.sourceAnchorId === sourceAnchorId
}) })
if (edge) { matchedEdges.forEach(edge => lf.deleteEdge(edge.id))
lf.deleteEdge(edge.id);
}
} }
export const convertToTree = (data) => { export const convertToTree = (data) => {

View File

@ -1,76 +1,242 @@
<template> <template>
<div> <div class="component-library">
<el-collapse v-model="activeName" accordion> <div class="library-header">
<el-collapse-item v-for="(item, index) in collapseList" :title="item.collapseTitle" :name="index" class="collapseItem"> <div class="library-heading">
<!-- node.type --> <div>
<div v-for="node in item.nodeList" class="node" draggable="true" @dragstart="handleDragCustom($event, node)"> <div class="library-title">组件库</div>
<div class="title_container"> <div class="library-subtitle">{{ totalCount }} 个可用组件</div>
<img class="title_img" :src="node.icon" alt=""> </div>
{{ node.name }} </div>
</div> <el-input v-model="keyword" clearable placeholder="搜索组件" :prefix-icon="Search" />
<div class="node_desc">{{ node.desc }}</div>
</div>
</el-collapse-item>
</el-collapse>
</div> </div>
</template>
<script setup>
import { ref } from 'vue'
import { collapseList } from '../config'
const activeName = ref(0) <div class="library-content">
<el-collapse v-if="filteredGroups.length" v-model="activeNames">
<el-collapse-item
v-for="item in filteredGroups"
:key="item.collapseTitle"
:name="item.collapseTitle"
class="collapse-item"
>
<template #title>
<div class="category-title">
<span>{{ item.collapseTitle }}</span>
<span class="category-count">{{ item.nodeList.length }}</span>
</div>
</template>
<div class="node-list">
<div
v-for="node in item.nodeList"
:key="`${node.type}-${node.action || node.name}`"
class="node-item"
draggable="true"
@dragstart="handleDragCustom($event, node)"
>
<div class="node-icon"><img :src="node.icon" alt="" /></div>
<div class="node-content">
<div class="node-name">{{ node.name }}</div>
<div class="node-desc">{{ node.desc }}</div>
</div>
<el-icon class="drag-handle"><Rank /></el-icon>
</div>
</div>
</el-collapse-item>
</el-collapse>
<el-empty v-else :image-size="72" description="没有匹配的组件" />
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { Rank, Search } from "@element-plus/icons-vue";
import { collapseList } from "../config";
const keyword = ref("");
const activeNames = ref(collapseList[0] ? [collapseList[0].collapseTitle] : []);
const totalCount = computed(() => collapseList.reduce((total, group) => total + group.nodeList.length, 0));
const filteredGroups = computed(() => {
const query = keyword.value.trim().toLowerCase();
if (!query) return collapseList;
return collapseList
.map((group) => ({
...group,
nodeList: group.nodeList.filter((node) => (
[node.name, node.desc, node.action, node.type]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query))
)),
}))
.filter((group) => group.nodeList.length > 0);
});
watch(keyword, (value) => {
if (value.trim()) activeNames.value = filteredGroups.value.map((group) => group.collapseTitle);
});
const handleDragCustom = (event, node) => { const handleDragCustom = (event, node) => {
// event.dataTransfer.setData("nodeType", type); event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData("flowNode", JSON.stringify(node)); event.dataTransfer.setData("flowNode", JSON.stringify(node));
} };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.collapseItem) { .component-library {
.el-collapse-item__header { display: flex;
padding-left: 20px; height: 100%;
font-size: 14px; min-height: 0;
} flex-direction: column;
.el-collapse-item__wrap { color: #1f2937;
background-color: transparent;
.el-collapse-item__content {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 0;
.node {
width: 220px;
height: auto;
min-height: 80px;
background: #fffc;
border-radius: 8px;
margin: 12px 0;
box-shadow: 0 5px 15px rgba(0,0,0,3%);
.title_container {
display: flex;
align-items: center;
height: 40px;
padding: 8px 12px;
margin-top: 4px;
font-size: 14px;
color: #0c0d0e;
.title_img {
width: 24px;
margin-right: 12px;
}
}
.node_desc {
padding: 0 12px;
font-size: 12px;
color: #737a87;
}
}
}
}
} }
</style>
.library-header {
padding: 16px 14px 14px;
border-bottom: 1px solid #e4e8ef;
background: #fff;
}
.library-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.library-title {
font-size: 15px;
font-weight: 650;
}
.library-subtitle {
margin-top: 2px;
color: #8a94a6;
font-size: 12px;
}
.library-content {
min-height: 0;
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
}
:deep(.el-collapse) {
border: 0;
}
:deep(.collapse-item) {
.el-collapse-item__header {
height: 46px;
padding: 0 14px;
border-bottom-color: #e8ebf0;
background: #f8fafc;
color: #344054;
font-size: 13px;
font-weight: 600;
}
.el-collapse-item__wrap {
border-bottom-color: #e8ebf0;
background: #f8fafc;
}
.el-collapse-item__content {
padding: 8px 10px 10px;
}
}
.category-title {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
justify-content: space-between;
padding-right: 10px;
}
.category-count {
display: grid;
min-width: 22px;
height: 20px;
padding: 0 6px;
place-items: center;
border: 1px solid #dfe4ec;
border-radius: 4px;
background: #fff;
color: #7a8597;
font-size: 11px;
font-weight: 500;
line-height: 18px;
}
.node-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.node-item {
display: grid;
min-height: 62px;
grid-template-columns: 34px minmax(0, 1fr) 18px;
gap: 9px;
align-items: center;
padding: 9px 8px 9px 10px;
border: 1px solid #e1e6ed;
border-radius: 7px;
background: #fff;
cursor: grab;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
&:hover {
border-color: #f2a071;
box-shadow: 0 5px 14px rgba(31, 41, 55, 0.08);
transform: translateX(2px);
}
&:active { cursor: grabbing; }
}
.node-icon {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 6px;
background: #f1f5f9;
img {
width: 21px;
height: 21px;
object-fit: contain;
}
}
.node-content { min-width: 0; }
.node-name {
overflow: hidden;
color: #202938;
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-desc {
display: -webkit-box;
overflow: hidden;
margin-top: 4px;
color: #7a8597;
font-size: 11px;
line-height: 16px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.drag-handle {
color: #a8b0bf;
font-size: 16px;
}
</style>

View File

@ -137,8 +137,8 @@ const { formType, currentList, propPath, depth, isFirstLevel, endDepth, parentPa
default: 2 default: 2
}, },
parentPath: { parentPath: {
type: String, type: Array,
default: '' default: () => []
}, },
}); });
@ -218,4 +218,4 @@ const handleChildDelete = (childFullPath) => {
} }
} }
</style> </style>

View File

@ -1,176 +1,185 @@
<template> <template>
<div> <div v-if="showState !== 'NORMAL'" class="node-state" :class="`node-state--${showState.toLowerCase()}`">
<div class="state__container" :class="`nodeState-${props.state}`"> <div class="node-state__summary">
<div class="context" v-show="showState !== 'NORMAL'"> <el-icon class="node-state__icon">
<div class="state"> <SuccessFilled v-if="props.state === 'SUCCESS'" />
<el-icon> <CircleCloseFilled v-else-if="['FAILED', 'STOPPED'].includes(props.state)" />
<SuccessFilled v-if="props.state === 'SUCCESS'" /> <VideoPause v-else-if="props.state === 'PAUSED'" />
<CircleCloseFilled v-if="['FAILED', 'STOPPED'].includes(props.state)" /> <Loading v-else />
<VideoPause v-if="props.state === 'PAUSED'" /> </el-icon>
<Loading v-if="props.state === 'RUNNING'" /> <span class="node-state__text">{{ stateText }}</span>
</el-icon> <span class="node-state__runtime">{{ props.runtimes }} ms</span>
<div class="text">{{ getStateText() }}</div>
<el-tag :type="props.state === 'SUCCESS' ? 'success' : 'danger'">
{{ props.runtimes }}ms
</el-tag>
</div>
<div
class="resultText"
v-popover="popoverRef"
@click="popoverVisible = !popoverVisible"
>
{{ popoverVisible ? "隐藏结果" : "展示结果" }}
</div>
</div>
</div> </div>
<el-popover <el-popover
ref="popoverRef" ref="popoverRef"
virtual-triggering trigger="click"
persistent
placement="right-start" placement="right-start"
:visible="popoverVisible" :width="480"
:teleported="false" :teleported="true"
popper-class="popover__container" popper-class="flow-result-popover"
@show="popoverVisible = true"
@hide="popoverVisible = false"
> >
<template #default> <template #reference>
<div class="params__container input"> <button type="button" class="node-state__result nodrag nopan" @mousedown.stop @click.stop>
<div class="paramsTitle">输入</div> <el-icon><View /></el-icon>
<div> <span>{{ popoverVisible ? "收起结果" : "查看结果" }}</span>
<slot name="input"></slot> </button>
</div>
</div>
<div class="params__container output" v-if="slots.output">
<div class="paramsTitle">输出</div>
<div>
<slot name="output"></slot>
</div>
</div>
</template> </template>
<div class="result-section">
<div class="result-section__title">输入</div>
<div class="result-section__content"><slot name="input" /></div>
</div>
<div v-if="slots.output" class="result-section result-section--output">
<div class="result-section__title">输出</div>
<div class="result-section__content"><slot name="output" /></div>
</div>
</el-popover> </el-popover>
</div> </div>
</template> </template>
<script setup lang="js">
import { ref, useSlots, watch } from 'vue' <script setup>
import { SuccessFilled, CircleCloseFilled, Loading, VideoPause } from '@element-plus/icons-vue' import { computed, ref, useSlots, watch } from "vue";
import { CircleCloseFilled, Loading, SuccessFilled, VideoPause, View } from "@element-plus/icons-vue";
const props = defineProps({ const props = defineProps({
state: { state: { type: String, default: "NORMAL" },
type: String, runtimes: { type: Number, default: 0 },
default: 'NORMAL' });
},
runtimes: {
type: Number,
default: 0
},
})
const slots = useSlots()
const getStateText = () => {
const map = {
SUCCESS: '成功',
RUNNING: '运行中',
FAILED: '失败',
PAUSED: '已暂停',
STOPPED: '已停止',
NORMAL: ''
}
return map[props.state]
}
const slots = useSlots();
const popoverRef = ref(); const popoverRef = ref();
const popoverVisible = ref(false); const popoverVisible = ref(false);
const showState = ref("NORMAL");
const stateText = computed(() => ({
SUCCESS: "执行成功",
RUNNING: "执行中",
FAILED: "执行失败",
PAUSED: "已暂停",
STOPPED: "已停止",
NORMAL: "",
}[props.state] || props.state));
const showState = ref('NORMAL') watch(() => props.state, (value) => {
showState.value = value;
watch(() => props.state, (newVal) => {
showState.value = newVal;
}, { immediate: true }); }, { immediate: true });
const closePopover = () => { const closePopover = () => {
showState.value = 'NORMAL'; showState.value = "NORMAL";
popoverVisible.value = false; popoverVisible.value = false;
popoverRef.value?.hide?.();
}; };
defineExpose({ closePopover }) defineExpose({ closePopover });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.state__container { .node-state {
display: none; display: flex;
margin-bottom: 8px; min-height: 34px;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: -4px -4px 10px;
padding: 5px 7px 5px 9px;
border: 1px solid #dbe3ee;
border-radius: 6px;
background: #f7f9fc;
color: #526071;
font-size: 12px;
}
.context { .node-state__summary {
display: flex; display: flex;
align-items: center; min-width: 0;
justify-content: space-between; align-items: center;
padding: 4px; gap: 6px;
}
.node-state__icon { font-size: 16px; }
.node-state__text { font-weight: 600; white-space: nowrap; }
.node-state__runtime {
padding-left: 6px;
border-left: 1px solid currentColor;
opacity: 0.72;
white-space: nowrap;
}
.node-state__result {
display: flex;
height: 26px;
flex: 0 0 auto;
align-items: center;
gap: 4px;
padding: 0 7px;
border: 0;
border-radius: 4px;
background: rgba(255, 255, 255, 0.72);
color: inherit;
cursor: pointer;
font-size: 11px;
font-weight: 600;
}
.node-state--success {
border-color: #c8ead5;
background: #effaf3;
color: #1e7d49;
}
.node-state--failed,
.node-state--stopped {
border-color: #f1cecb;
background: #fff3f2;
color: #c33b32;
}
.node-state--paused {
border-color: #d9dee7;
background: #f3f5f8;
color: #657084;
}
.node-state--running {
border-color: #cbdcf8;
background: #f0f6ff;
color: #2f6fca;
.node-state__icon { animation: rotate-animation 1.2s linear infinite; }
}
@keyframes rotate-animation {
to { transform: rotate(360deg); }
}
</style>
<style lang="scss">
.flow-result-popover {
max-width: min(480px, calc(100vw - 32px));
padding: 0 !important;
overflow: hidden;
.result-section {
padding: 14px 16px;
}
.result-section--output { border-top: 1px solid #e8ebf0; }
.result-section__title {
margin-bottom: 8px;
color: #344054;
font-size: 13px;
font-weight: 650;
}
.result-section__content {
max-height: 280px;
overflow: auto;
color: #475467;
font-size: 12px; font-size: 12px;
.state {
display: flex;
align-items: center;
.text {
margin: 0 8px;
}
}
.resultText {
color: #1664ff;
cursor: pointer;
}
}
}
.nodeState-SUCCESS {
display: block;
background-color: #eef9f1;
.el-icon {
font-size: 16px;
color: #309256;
}
}
.nodeState-FAILED {
display: block;
background-color: #fdf5f5;
.el-icon {
font-size: 16px;
color: #ee3f38;
}
}
.nodeState-STOPPED {
display: block;
background-color: #fdf5f5;
.el-icon {
font-size: 16px;
color: #ee3f38;
}
}
.nodeState-PAUSED {
display: block;
background-color: #cbcbcb;
.el-icon {
font-size: 16px;
color: #666;
}
}
.nodeState-RUNNING {
display: block;
background-color: #f4f7ff;
.el-icon {
color: #68a2e4;
font-size: 16px;
animation: rotateAnimation 2s linear infinite;
} }
} }
</style> </style>

View File

@ -1,271 +1,299 @@
<template> <template>
<div class="title__container"> <div class="node-title">
<div class="title"> <div class="node-title__main">
<div class="left"> <div class="node-title__identity">
<img :src="imageUrl()" alt="" /> <div class="node-title__icon">
<div class="text__container"> <img v-if="props.icon" :src="props.icon" alt="" />
<div class="text__title" v-if="showTextTitle"> <el-icon v-else><Operation /></el-icon>
<span class="text">{{ localName }}</span> </div>
<el-icon class="editIcon" @click="startEditing"><EditPen/></el-icon>
<div class="node-title__text">
<div v-if="!isEditing" class="node-title__name-row">
<span class="node-title__name" :title="localName">{{ localName }}</span>
<span v-if="typeLabel" class="node-title__type">{{ typeLabel }}</span>
<el-tooltip content="重命名" placement="top">
<button
type="button"
class="node-icon-button node-title__edit nodrag nopan"
aria-label="重命名节点"
@mousedown.stop
@click.stop="startEditing"
>
<el-icon><EditPen /></el-icon>
</button>
</el-tooltip>
</div> </div>
<div class="input_name_container" v-else >
<div v-else class="node-title__editor nodrag nopan" @mousedown.stop @click.stop>
<el-input <el-input
ref="nameInputRef"
v-model="localName" v-model="localName"
@focus="handleInputFocus" maxlength="40"
@keydown="handleInputKeydown" @keydown.enter.prevent="confirmNodeName"
@click.stop @keydown.esc.prevent="cancelNodeName"
@mousedown.stop />
/> <button type="button" class="node-icon-button is-confirm" aria-label="确认名称" @click="confirmNodeName">
<el-button
class="input_name_select"
circle
size="small"
@click="confirmNodeName"
>
<el-icon><Select /></el-icon> <el-icon><Select /></el-icon>
</el-button> </button>
<el-button <button type="button" class="node-icon-button" aria-label="取消重命名" @click="cancelNodeName">
class="input_name_closeBold"
circle
size="small"
@click="cancelNodeName"
>
<el-icon><CloseBold /></el-icon> <el-icon><CloseBold /></el-icon>
</el-button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="right">
<el-tooltip <div class="node-title__actions nodrag nopan" @mousedown.stop>
class="box-item" <el-tooltip v-if="canExecute" content="执行当前节点" placement="top">
effect="dark" <button type="button" class="node-icon-button is-run" aria-label="执行当前节点" @click.stop="execute">
content="执行" <el-icon><VideoPlay /></el-icon>
placement="top" </button>
>
<el-button v-if="nodeType && nodeType !=='NONE'" circle @click="execute">
<el-icon>
<VideoPlay />
</el-icon>
</el-button>
</el-tooltip> </el-tooltip>
<el-tooltip <el-tooltip :content="isSelectArea ? '解散分组' : '删除节点'" placement="top">
class="box-item" <button
effect="dark" type="button"
content="删除" class="node-icon-button is-danger"
placement="top" :aria-label="isSelectArea ? '解散分组' : '删除节点'"
> @click.stop="deleteNode"
<el-button circle @click="deleteNode"> >
<el-icon><Delete /></el-icon> <el-icon><FolderRemove v-if="isSelectArea" /><Delete v-else /></el-icon>
</el-button> </button>
</el-tooltip> </el-tooltip>
</div> </div>
</div> </div>
<div class="subTitle">{{ props.nodeDesc }}</div>
<div v-if="props.nodeDesc" class="node-title__description" :title="props.nodeDesc">
{{ props.nodeDesc }}
</div>
</div> </div>
</template> </template>
<script setup lang="js">
import { onUnmounted, ref } from 'vue' <script setup>
import { EditPen, Select, CloseBold, Delete, VideoPlay } from '@element-plus/icons-vue' import { computed, nextTick, onMounted, ref, watch } from "vue";
import { ElMessageBox } from 'element-plus' import { CloseBold, Delete, EditPen, FolderRemove, Operation, Select, VideoPlay } from "@element-plus/icons-vue";
import { computed } from 'vue' import { ElMessageBox } from "element-plus";
import { emitter } from "@/utils/eventBus"; import { emitter } from "@/utils/eventBus";
const props = defineProps({ const props = defineProps({
icon: { icon: { type: String, default: "" },
type: String, nodeId: { type: String, default: "" },
default: '' nodeProperties: { type: Object, default: () => ({}) },
}, nodeDesc: { type: String, default: "" },
nodeId: { nodeName: { type: String, default: "" },
type: String, nodeType: { type: String, default: "" },
default: '' });
},
nodeProperties: {
type: Object,
default: () => {}
},
nodeDesc: {
type: String,
default: ''
},
nodeName: {
type: String,
default: ''
},
nodeType: {
type: String,
default: ''
},
})
const emits = defineEmits(['setNodeName']) const emits = defineEmits(["setNodeName"]);
const isEditing = ref(false);
const localName = ref("");
const nameInputRef = ref();
const isSelectArea = computed(() => props.nodeType === "selectArea");
const canExecute = computed(() => Boolean(props.nodeType) && !["NONE", "selectArea"].includes(props.nodeType));
const typeLabel = computed(() => {
if (!props.nodeType || ["NONE", "selectArea"].includes(props.nodeType)) return "";
return { EDGE: "设备", LLM: "模型", AE: "评估" }[props.nodeType] || props.nodeType;
});
const showTextTitle = ref(true) const sourceName = () => props.nodeProperties?.name || props.nodeName || "未命名节点";
const localName = ref('')
// watch(sourceName, (value) => {
const startEditing = () => { if (!isEditing.value) localName.value = value;
showTextTitle.value = false });
}
const startEditing = async () => {
isEditing.value = true;
await nextTick();
nameInputRef.value?.focus?.();
nameInputRef.value?.select?.();
};
const confirmNodeName = () => { const confirmNodeName = () => {
emits('setNodeName', localName.value) const name = localName.value.trim();
showTextTitle.value = true if (!name) return;
} localName.value = name;
emits("setNodeName", name);
isEditing.value = false;
};
const cancelNodeName = () => { const cancelNodeName = () => {
localName.value = props.nodeProperties?.name || props.nodeName localName.value = sourceName();
showTextTitle.value = true isEditing.value = false;
} };
const deleteNode = () => { const deleteNode = async () => {
ElMessageBox.confirm( try {
'是否删除该节点?', await ElMessageBox.confirm(
'警告', isSelectArea.value
{ ? "解散后内部节点和连线会保留,是否继续?"
confirmButtonText: '确定', : "删除后相关连线也会一并移除,是否继续?",
cancelButtonText: '取消', isSelectArea.value ? "解散分组" : "删除节点",
type: 'warning', {
} confirmButtonText: isSelectArea.value ? "解散" : "删除",
) cancelButtonText: "取消",
.then(() => { confirmButtonClass: "el-button--danger",
if (props.nodeType === 'selectArea') { type: "warning",
const children = lf.getNodeModelById(props.nodeId).children });
lf.getNodeModelById(props.nodeId).children = [] if (isSelectArea.value) window.lf?.ungroup(props.nodeId);
children.forEach(item => { else window.lf?.deleteNode(props.nodeId);
lf.getNodeModelById(item).draggable = true emitter.emit("deleteNode", props.nodeId);
}) } catch (error) {
} // Cancelled by the user.
lf.deleteNode(props.nodeId); }
emitter.emit('deleteNode', props.nodeId); };
})
}
const execute = () => { const execute = () => {
const myEvent = new CustomEvent('singleNodeExecution', { document.dispatchEvent(new CustomEvent("singleNodeExecution", {
bubbles: false, // false bubbles: false,
cancelable: false, // preventDefault() false cancelable: false,
detail: { ...props.nodeProperties } // event.detail detail: { ...props.nodeProperties },
}); }));
document.dispatchEvent(myEvent); };
}
const handleInputFocus = (e) => {
//
e.stopPropagation()
e.preventDefault()
}
const handleInputKeydown = (e) => {
//
e.stopPropagation();
// Ctrl+V
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.returnValue = true; //
}
}
const imageUrl = () => {
return new URL(props.icon, import.meta.url).href;
}
onMounted(() => { onMounted(() => {
localName.value = props.nodeProperties?.name || props.nodeName localName.value = sourceName();
}) });
onUnmounted(() => {
emitter.off("deleteNode");
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.title__container { .node-title { min-width: 0; }
.title {
display: flex;
align-items: center;
justify-content: space-between;
.left { .node-title__main {
display: flex; display: flex;
align-items: center; // min-height: 38px;
align-items: center;
justify-content: space-between;
gap: 10px;
}
img { .node-title__identity {
width: 24px; display: flex;
height: 24px; min-width: 0;
} flex: 1;
align-items: center;
gap: 10px;
}
.text__container { .node-title__icon {
.text__title { display: grid;
display: flex; width: 36px;
align-items: center; height: 36px;
flex: 0 0 36px;
place-items: center;
border-radius: 7px;
background: #f1f5f9;
color: var(--node-accent, #475569);
.text { img {
font-size: 16px; width: 22px;
font-weight: bold; height: 22px;
margin: 0 12px; object-fit: contain;
}
.editIcon {
cursor: pointer;
}
}
.input_name_container {
display: flex;
align-items: center;
.el-input {
margin-left: 12px;
width: 120px;
// z-index
position: relative;
z-index: 1000;
// Element Plus
:deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px #409eff !important;
&:focus-within {
box-shadow: 0 0 0 2px #409eff !important;
}
}
}
.input_name_select {
color: #00b42a;
margin-left: 4px;
border: none;
}
.input_name_closeBold {
color: #f53f3f;
margin-left: 4px;
border: none;
}
}
}
}
.right {
cursor: pointer;
.el-button {
border: none;
font-size: 24px;
}
}
}
.subTitle {
color: #737a87;
font-size: 12px;
margin: 8px 0;
} }
} }
// .node-title__text { min-width: 0; flex: 1; }
:global(.lf-node) {
.el-input__inner { .node-title__name-row {
user-select: text !important; display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.node-title__name {
overflow: hidden;
color: #1f2937;
font-size: 15px;
font-weight: 650;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-title__type {
flex: 0 0 auto;
padding: 1px 6px;
border: 1px solid #dfe4ec;
border-radius: 4px;
background: #f8fafc;
color: #697386;
font-size: 10px;
font-weight: 600;
}
.node-title__edit {
width: 24px;
height: 24px;
flex: 0 0 24px;
opacity: 0;
}
.node-title__actions {
display: flex;
flex: 0 0 auto;
gap: 4px;
opacity: 0;
transition: opacity 0.16s ease;
}
.node-title:hover,
.node-title:focus-within {
.node-title__actions,
.node-title__edit { opacity: 1; }
}
.node-icon-button {
display: grid;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: #7b8494;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
&:hover {
border-color: #dfe4ec;
background: #f8fafc;
color: #344054;
} }
&.is-run:hover {
border-color: #b7dfc7;
background: #effaf3;
color: #168447;
}
&.is-danger:hover {
border-color: #f2c5c2;
background: #fff3f2;
color: #d93025;
}
&.is-confirm { color: #168447; }
}
.node-title__editor {
display: grid;
grid-template-columns: minmax(100px, 1fr) 28px 28px;
gap: 4px;
align-items: center;
}
.node-title__description {
display: -webkit-box;
overflow: hidden;
margin-top: 8px;
color: #7a8494;
font-size: 12px;
line-height: 18px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (hover: none) {
.node-title__actions,
.node-title__edit { opacity: 1; }
} }
</style> </style>

View File

@ -1,9 +1,13 @@
<template> <template>
<el-drawer <el-drawer
v-model="props.drawer" :model-value="props.drawer"
custom-class="custom-drawer-header" custom-class="flow-params-drawer"
:title="props.data.properties?.name || '参数配置'" :title="props.data.properties?.name || '参数配置'"
class="no-header-margin" class="flow-params-drawer"
size="min(560px, 92vw)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
:before-close="confirmSave" :before-close="confirmSave"
> >
<component <component
@ -17,7 +21,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed } from 'vue'
import { emitter } from '@/utils/eventBus' import { emitter } from '@/utils/eventBus'
// //
@ -52,6 +56,8 @@ const currentComponent = computed(() => {
const confirmSave = async () => { const confirmSave = async () => {
if (paramsComponentRef.value && typeof paramsComponentRef.value.validateAndSave === 'function') { if (paramsComponentRef.value && typeof paramsComponentRef.value.validateAndSave === 'function') {
await paramsComponentRef.value.validateAndSave() await paramsComponentRef.value.validateAndSave()
} else {
emits('close')
} }
} }
@ -77,7 +83,35 @@ const handleSaveError = () => {
</script> </script>
<style lang="scss"> <style lang="scss">
.no-header-margin .el-drawer__header { .flow-params-drawer {
margin-bottom: 0 !important; .el-drawer__header {
min-height: 62px;
margin-bottom: 0 !important;
padding: 0 20px;
border-bottom: 1px solid #e4e8ef;
color: #1f2937;
font-size: 16px;
font-weight: 650;
}
.el-drawer__body {
padding: 0 20px 24px;
overflow-x: hidden;
}
.el-collapse {
border-top: 0;
}
.el-collapse-item__header {
height: 50px;
color: #344054;
font-size: 13px;
font-weight: 650;
}
.form__container {
padding: 4px 0 10px;
}
} }
</style> </style>

View File

@ -1,6 +1,6 @@
<template> <template>
<el-drawer <el-drawer
v-model="props.drawer" :model-value="props.drawer"
title="试运行" title="试运行"
:before-close="handleClose" :before-close="handleClose"
> >
@ -60,7 +60,7 @@
</el-drawer> </el-drawer>
</template> </template>
<script setup> <script setup>
import { ref, watch, onUnmounted } from 'vue' import { ref, watch } from 'vue'
import { getStartNodeFormData, formatTableData } from '@/utils/flow' import { getStartNodeFormData, formatTableData } from '@/utils/flow'
// import { Plus, Minus } from '@element-plus/icons-vue' // import { Plus, Minus } from '@element-plus/icons-vue'
import { flowExecuteTrial } from '@/api/device/flow' import { flowExecuteTrial } from '@/api/device/flow'
@ -80,7 +80,7 @@ const tableData = ref([])
const emits = defineEmits(['close', 'changeState']) const emits = defineEmits(['close', 'changeState'])
const handleClose = () => { const handleClose = () => {
ruleFormRef.value.resetFields() ruleFormRef.value?.resetFields()
emits('close') emits('close')
} }
@ -120,9 +120,7 @@ const confirm = () => {
ElMessage.error(res.msg) ElMessage.error(res.msg)
} }
}) })
.catch((err) => { .catch(() => {})
console.log(err)
})
} }
const terminalIdOptions = ref([]) const terminalIdOptions = ref([])
@ -140,7 +138,4 @@ const handleGetTerminalGroup = async (row) => {
} }
} }
onUnmounted(() => { </script>
emitter.off('contentChange')
})
</script>

View File

@ -203,7 +203,6 @@ const initData = () => {
...transformedData, ...transformedData,
outputParams outputParams
}; };
console.log('RecognizeNodeData', JSON.stringify(RecognizeNodeData.value))
} }
watch( watch(

View File

@ -230,7 +230,6 @@ const initData = () => {
...transformedData, ...transformedData,
outputParams outputParams
}; };
console.log('sdAgentNodeData', sdAgentNodeData.value)
} }
watch( watch(
@ -357,4 +356,4 @@ defineExpose({ validateAndSave })
cursor: pointer; cursor: pointer;
} }
} }
</style> </style>

View File

@ -0,0 +1,18 @@
import { onUnmounted } from "vue";
import { emitter } from "@/utils/eventBus";
export const useFlowEvents = () => {
const listeners = [];
const onFlowEvent = (type, handler) => {
emitter.on(type, handler);
listeners.push([type, handler]);
};
onUnmounted(() => {
listeners.forEach(([type, handler]) => emitter.off(type, handler));
listeners.length = 0;
});
return { onFlowEvent };
};

View File

@ -2,23 +2,38 @@
<div class="page"> <div class="page">
<el-container class="page__container"> <el-container class="page__container">
<el-header v-if="!flowInfoData.isLog"> <el-header v-if="!flowInfoData.isLog">
<div> <div class="flow-heading">
<div>{{ flowInfoData.name }}</div> <div class="flow-heading__name">{{ flowInfoData.name || "未命名流程" }}</div>
<div>{{ stateMap[flowState] }}</div> <div class="flow-status" :class="`flow-status--${flowState}`">
<span class="flow-status__dot"></span>
<span>{{ stateMap[flowState] }}</span>
</div>
</div> </div>
<div class="btn__container"> <div class="btn__container">
<div class="btn"> <div class="btn flow-actions">
<el-tag style="margin-right: 12px;" v-if="['testRunError', 'testRunFinish'].includes(flowState)" :type="flowState === 'testRunFinish' ? 'success' : 'danger'"> <el-tag v-if="hasRunResult && ['testRunError', 'testRunFinish'].includes(flowState)" :type="flowState === 'testRunFinish' ? 'success' : 'danger'">
{{ totalRunningTime }}ms {{ totalRunningTime }}ms
</el-tag> </el-tag>
<el-button v-if="['testRunError', 'testRunFinish', 'stop'].includes(flowState)" @click="clearRun">清除上一次运行结果</el-button> <el-button v-if="hasRunResult" @click="clearRun">
<el-button @click="group">分组</el-button> <el-icon><Delete /></el-icon>
<el-button v-if="flowState !== 'testRunning'" @click="testRun">试运行</el-button> </el-button>
<el-button v-if="flowState === 'testRunning'" @click="flowPauseFn">暂停</el-button> <el-button :type="groupSelectionPending ? 'primary' : 'default'" @click="group">
<el-button v-if="flowState === 'pause'" @click="flowResumeFn">恢复</el-button> <el-icon><CollectionTag /></el-icon>{{ groupSelectionPending ? "" : "" }}
<el-button v-if="['testRunning', 'pause'].includes(flowState)" @click="flowStopFn">终止</el-button> </el-button>
<el-button v-if="flowState !== 'testRunning'" @click="testRun">
<el-icon><VideoPlay /></el-icon>
</el-button>
<el-button v-if="flowState === 'testRunning'" @click="flowPauseFn">
<el-icon><VideoPause /></el-icon>
</el-button>
<el-button v-if="flowState === 'pause'" @click="flowResumeFn">
<el-icon><VideoPlay /></el-icon>
</el-button>
<el-button v-if="['testRunning', 'pause'].includes(flowState)" @click="flowStopFn">
<el-icon><CircleClose /></el-icon>
</el-button>
</div> </div>
<div class="btn"> <div class="btn publish-actions">
<el-tooltip <el-tooltip
class="box-item" class="box-item"
effect="dark" effect="dark"
@ -26,9 +41,9 @@
placement="top" placement="top"
v-if="flowState !== 'testRunFinish'" v-if="flowState !== 'testRunFinish'"
> >
<el-button type="primary" :disabled="true">发布</el-button> <el-button type="primary" :disabled="true"><el-icon><Upload /></el-icon></el-button>
</el-tooltip> </el-tooltip>
<el-button v-else @click="deployFlow" type="primary">发布</el-button> <el-button v-else @click="deployFlow" type="primary"><el-icon><Upload /></el-icon></el-button>
<el-popover <el-popover
:width="260" :width="260"
@ -36,9 +51,7 @@
popper-class="node-relationship-network-popover" popper-class="node-relationship-network-popover"
> >
<template #reference> <template #reference>
<el-button style="margin-right: 16px" <el-button circle title="节点关系" aria-label="节点关系"><el-icon><Share /></el-icon></el-button>
><el-icon><Location /></el-icon
></el-button>
</template> </template>
<div> <div>
<div class="title">节点关系网</div> <div class="title">节点关系网</div>
@ -71,6 +84,18 @@
<Aside /> <Aside />
</el-aside> </el-aside>
<el-main> <el-main>
<div class="canvas-toolbar">
<el-tooltip v-if="!flowStore.disableForm" content="撤销" placement="bottom">
<el-button aria-label="撤销" @click="lf.undo()"><el-icon><RefreshLeft /></el-icon></el-button>
</el-tooltip>
<el-tooltip v-if="!flowStore.disableForm" content="重做" placement="bottom">
<el-button aria-label="重做" @click="lf.redo()"><el-icon><RefreshRight /></el-icon></el-button>
</el-tooltip>
<span v-if="!flowStore.disableForm" class="canvas-toolbar__divider"></span>
<el-tooltip content="适应视图" placement="bottom">
<el-button aria-label="适应视图" @click="fitView"><el-icon><FullScreen /></el-icon></el-button>
</el-tooltip>
</div>
<VueFlow <VueFlow
id="main-flow" id="main-flow"
class="flow__container" class="flow__container"
@ -82,9 +107,10 @@
:nodes-connectable="!flowStore.disableForm" :nodes-connectable="!flowStore.disableForm"
:edges-updatable="!flowStore.disableForm" :edges-updatable="!flowStore.disableForm"
:elements-selectable="true" :elements-selectable="true"
:elevate-nodes-on-select="false"
:delete-key-code="null" :delete-key-code="null"
:multi-selection-key-code="['Control', 'Meta']" :multi-selection-key-code="['Control', 'Meta']"
:selection-key-code="groupSelectionPending ? true : 'Shift'" :selection-key-code="groupSelectionPending"
:pan-on-drag="!groupSelectionPending" :pan-on-drag="!groupSelectionPending"
:zoom-on-double-click="false" :zoom-on-double-click="false"
:default-edge-options="defaultEdgeOptions" :default-edge-options="defaultEdgeOptions"
@ -122,7 +148,8 @@
/> />
<ParamsDrawer <ParamsDrawer
:drawer="showParamsDrawer" v-if="showParamsDrawer"
:drawer="true"
:data="paramsDrawerData" :data="paramsDrawerData"
@close="showParamsDrawer = false" @close="showParamsDrawer = false"
/> />
@ -213,13 +240,14 @@ import "@vue-flow/controls/dist/style.css";
import FlowNode from "./vue-flow/FlowNode.vue"; import FlowNode from "./vue-flow/FlowNode.vue";
import FlowEdge from "./vue-flow/FlowEdge.vue"; import FlowEdge from "./vue-flow/FlowEdge.vue";
import { createFlowFacade } from "./vue-flow/adapter"; import { createFlowFacade } from "./vue-flow/adapter";
import { useFlowEvents } from "./composables/useFlowEvents";
import TestRun from "./components/TestRun.vue"; import TestRun from "./components/TestRun.vue";
import ParamsDrawer from "./components/ParamsDrawer.vue"; import ParamsDrawer from "./components/ParamsDrawer.vue";
import Aside from "./components/Aside.vue"; import Aside from "./components/Aside.vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useFlowStore } from "@/store/modules/flow"; import { useFlowStore } from "@/store/modules/flow";
import { emitter } from "@/utils/eventBus"; import { emitter } from "@/utils/eventBus";
import { recursiveFilter, newEdge, convertToTree } from "@/utils/flow"; import { convertToTree } from "@/utils/flow";
import { import {
flowDeploy, flowDeploy,
flowView, flowView,
@ -229,13 +257,26 @@ import {
flowAction, flowAction,
} from "@/api/device/flow"; } from "@/api/device/flow";
import { getDetect } from "@/api/test/detect"; import { getDetect } from "@/api/test/detect";
import { Location } from "@element-plus/icons-vue"; import {
CircleClose,
CollectionTag,
Delete,
FullScreen,
Location,
RefreshLeft,
RefreshRight,
Share,
Upload,
VideoPause,
VideoPlay,
} from "@element-plus/icons-vue";
const flowContainerRef = ref(null); const flowContainerRef = ref(null);
const lfRef = ref(null); const lfRef = ref(null);
const vueFlow = useVueFlow("main-flow"); const vueFlow = useVueFlow("main-flow");
const lf = createFlowFacade(vueFlow); const lf = createFlowFacade(vueFlow);
const loopChildDragContexts = new Map(); const loopChildDragContexts = new Map();
const { onFlowEvent } = useFlowEvents();
window.lf = lf; window.lf = lf;
const defaultEdgeOptions = { const defaultEdgeOptions = {
@ -372,9 +413,6 @@ const handleNodeDragStop = (payload) => {
if (loopNode) lf.addToGroup(loopNode.id, vueNode.id); if (loopNode) lf.addToGroup(loopNode.id, vueNode.id);
} }
const graph = lf.getGraphData();
const groups = recursiveFilter(graph.nodes, node.id, ["loop", "selectArea"]);
operationEdge(groups);
lf.markChanged(); lf.markChanged();
}; };
@ -460,11 +498,30 @@ const flowPauseFn = async () => {
}; };
const totalRunningTime = ref(0); const totalRunningTime = ref(0);
const hasRunResult = ref(false);
let flowPollTimer = null;
let flowDisposed = false;
const clearFlowPollTimer = () => {
if (flowPollTimer !== null) {
window.clearTimeout(flowPollTimer);
flowPollTimer = null;
}
};
const scheduleFlowView = (currentInstId, taskId = null, delay = 2000) => {
clearFlowPollTimer();
flowPollTimer = window.setTimeout(() => {
flowPollTimer = null;
if (!flowDisposed) loopFlowView(currentInstId, taskId);
}, delay);
};
const flowResumeFn = async () => { const flowResumeFn = async () => {
const res = await flowResume(instId.value); const res = await flowResume(instId.value);
if (res.code === 200) { if (res.code === 200) {
flowState.value = "testRunning"; flowState.value = "testRunning";
clearFlowPollTimer();
loopFlowView(instId.value); loopFlowView(instId.value);
ElMessage.success(res.msg); ElMessage.success(res.msg);
} }
@ -473,7 +530,9 @@ const flowResumeFn = async () => {
const flowStopFn = async () => { const flowStopFn = async () => {
const res = await flowStop(instId.value); const res = await flowStop(instId.value);
if (res.code === 200) { if (res.code === 200) {
clearFlowPollTimer();
flowState.value = "stop"; flowState.value = "stop";
hasRunResult.value = true;
flowStore.updateDisableForm(false); flowStore.updateDisableForm(false);
ElMessage.success(res.msg); ElMessage.success(res.msg);
} }
@ -504,8 +563,23 @@ const initFlowData = {
edges: [], edges: [],
}; };
const hasSameEdge = (edge, ignoredId) => lf.getGraphData().edges.some((item) => (
item.id !== ignoredId
&& item.sourceNodeId === edge.sourceNodeId
&& item.targetNodeId === edge.targetNodeId
&& item.sourceAnchorId === edge.sourceAnchorId
&& item.targetAnchorId === edge.targetAnchorId
));
const validEdge = (data) => { const validEdge = (data) => {
const sourceAnchorArr = lf.getNodeModelById(data.sourceNodeId).anchors; const sourceModel = lf.getNodeModelById(data.sourceNodeId);
const targetModel = lf.getNodeModelById(data.targetNodeId);
if (!sourceModel || !targetModel) {
lf.deleteEdge(data.id);
return;
}
const sourceAnchorArr = sourceModel.anchors;
const [sourceName] = sourceAnchorArr const [sourceName] = sourceAnchorArr
.filter((item) => { .filter((item) => {
return item.id === data.sourceAnchorId; return item.id === data.sourceAnchorId;
@ -514,7 +588,7 @@ const validEdge = (data) => {
return item.name; return item.name;
}); });
const targetAnchorArr = lf.getNodeModelById(data.targetNodeId).anchors; const targetAnchorArr = targetModel.anchors;
const [targetName] = targetAnchorArr const [targetName] = targetAnchorArr
.filter((item) => { .filter((item) => {
return item.id === data.targetAnchorId; return item.id === data.targetAnchorId;
@ -523,7 +597,13 @@ const validEdge = (data) => {
return item.name; return item.name;
}); });
if (!sourceName || !targetName) {
lf.deleteEdge(data.id);
return;
}
if (sourceName === "right" && targetName === "left") { if (sourceName === "right" && targetName === "left") {
if (hasSameEdge(data, data.id)) lf.deleteEdge(data.id);
return; return;
} else if ( } else if (
(sourceName === "right" && targetName === "right") || (sourceName === "right" && targetName === "right") ||
@ -536,6 +616,7 @@ const validEdge = (data) => {
const targetNodeId = data.sourceNodeId; const targetNodeId = data.sourceNodeId;
const targetAnchorId = data.sourceAnchorId; const targetAnchorId = data.sourceAnchorId;
lf.deleteEdge(data.id); lf.deleteEdge(data.id);
if (hasSameEdge({ sourceNodeId, targetNodeId, sourceAnchorId, targetAnchorId })) return;
lf.addEdge({ lf.addEdge({
type: "bezier", type: "bezier",
sourceNodeId, sourceNodeId,
@ -553,8 +634,10 @@ const changeState = (value) => {
isOpen.value = false; isOpen.value = false;
flowStore.updateDisableForm(true); flowStore.updateDisableForm(true);
flowState.value = value.type; flowState.value = value.type;
hasRunResult.value = false;
instId.value = value.instId; instId.value = value.instId;
clearFlowPollTimer();
loopFlowView(value.instId); loopFlowView(value.instId);
}; };
@ -567,8 +650,10 @@ const loopFlowView = async (instId, taskId = null) => {
params.taskId = taskId; params.taskId = taskId;
} }
const res = await flowView(params); const res = await flowView(params);
if (flowDisposed) return;
if (res.code === 200) { if (res.code === 200) {
const arr = res.data || []; const arr = res.data || [];
if (arr.length > 0) hasRunResult.value = true;
arr.forEach((item) => { arr.forEach((item) => {
emitter.emit("changeNodeState", item); emitter.emit("changeNodeState", item);
}); });
@ -577,9 +662,7 @@ const loopFlowView = async (instId, taskId = null) => {
arr[arr.length - 1]?.nodeType !== "END" && arr[arr.length - 1]?.nodeType !== "END" &&
!["FAILED", "STOPPED"].includes(arr[arr.length - 1]?.status) !["FAILED", "STOPPED"].includes(arr[arr.length - 1]?.status)
) { ) {
setTimeout(() => { scheduleFlowView(instId);
loopFlowView(instId);
}, 2000);
} else if ( } else if (
arr[arr.length - 1]?.nodeType === "END" && arr[arr.length - 1]?.nodeType === "END" &&
arr[arr.length - 1]?.status === "SUCCESS" arr[arr.length - 1]?.status === "SUCCESS"
@ -632,19 +715,9 @@ const loadFlowData = async (id, lf) => {
const res = await getDetect(id); const res = await getDetect(id);
if (res.data && res.data.flowData) { if (res.data && res.data.flowData) {
const flowData = JSON.parse(res.data.flowData); const flowData = JSON.parse(res.data.flowData);
const selectArea = [];
flowData.nodes.forEach((item) => {
if (item.type === "selectArea") {
selectArea.push(...item.children);
}
});
lf.render(flowData); lf.render(flowData);
nextTick(() => { nextTick(() => {
selectArea.forEach((item) => {
lf.getNodeModelById(item).draggable = false;
});
oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData())); oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
lf.fitView(); lf.fitView();
}); });
@ -686,9 +759,7 @@ const justLoadFlow = () => {
if (flowInfoData.value.isLog) { if (flowInfoData.value.isLog) {
flowStore.updateDisableForm(true); flowStore.updateDisableForm(true);
setTimeout(() => { scheduleFlowView(flowInfoData.value.instId, flowInfoData.value.taskId, 1000);
loopFlowView(flowInfoData.value.instId, flowInfoData.value.taskId);
}, 1000);
} }
} }
}; };
@ -713,13 +784,17 @@ const initFlow = () => {
const groupSelectionPending = ref(false); const groupSelectionPending = ref(false);
const createGroup = (selectedNodes) => { const createGroup = (selectedNodes) => {
const nodes = selectedNodes.filter((item) => !["selectArea", "loop"].includes(item.type)); const nodes = selectedNodes.filter((item) => (
if (!nodes.length) { !["selectArea", "loop"].includes(item.type)
&& !lf.getNodeModelById(item.id)?.parentId
));
if (nodes.length < 2) {
ElMessage.warning("请至少选择两个未分组节点");
groupSelectionPending.value = false; groupSelectionPending.value = false;
return; return;
} }
const PADDING = 200; const INSET = { left: 52, top: 120, right: 52, bottom: 52 };
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
@ -730,32 +805,38 @@ const createGroup = (selectedNodes) => {
const model = lf.getNodeModelById(item.id); const model = lf.getNodeModelById(item.id);
const width = model?.width || 372; const width = model?.width || 372;
const height = model?.height || 180; const height = model?.height || 180;
minX = Math.min(minX, item.x - width / 2); const left = Number.isFinite(model?.left) ? model.left : item.x - width / 2;
minY = Math.min(minY, item.y - height / 2); const top = Number.isFinite(model?.top) ? model.top : item.y - height / 2;
maxX = Math.max(maxX, item.x + width / 2); minX = Math.min(minX, left);
maxY = Math.max(maxY, item.y + height / 2); minY = Math.min(minY, top);
maxX = Math.max(maxX, left + width);
maxY = Math.max(maxY, top + height);
children.push(item.id); children.push(item.id);
if (model) model.draggable = false;
}); });
lf.addNode({ const width = maxX - minX + INSET.left + INSET.right;
const height = maxY - minY + INSET.top + INSET.bottom;
lf.groupNodes({
type: "selectArea", type: "selectArea",
x: minX + (maxX - minX) / 2, x: minX - INSET.left + width / 2,
y: minY + (maxY - minY) / 2, y: minY - INSET.top + height / 2,
properties: { properties: {
name: "分组", name: "分组",
action: "NONE", action: "NONE",
width: maxX - minX + PADDING, width,
height: maxY - minY + PADDING, height,
}, },
children, }, children);
});
lf.clearSelectElements(); lf.clearSelectElements();
groupSelectionPending.value = false; groupSelectionPending.value = false;
nextTick(() => lf.fitView());
}; };
const group = () => { const group = () => {
if (groupSelectionPending.value) {
groupSelectionPending.value = false;
lf.clearSelectElements();
return;
}
const { nodes } = lf.getSelectElements(); const { nodes } = lf.getSelectElements();
if (nodes.length) createGroup(nodes); if (nodes.length) createGroup(nodes);
else groupSelectionPending.value = true; else groupSelectionPending.value = true;
@ -847,6 +928,8 @@ const clearRun = () => {
node.closePopover(); node.closePopover();
} }
}); });
hasRunResult.value = false;
totalRunningTime.value = 0;
} }
const handleDeleteKey = (event) => { const handleDeleteKey = (event) => {
@ -871,15 +954,6 @@ const handleDeleteKey = (event) => {
if (nodes.length) { if (nodes.length) {
event.preventDefault(); event.preventDefault();
nodes.forEach((node) => { nodes.forEach((node) => {
if (node.type === "selectArea") {
const model = lf.getNodeModelById(node.id);
const children = Array.from(model?.children || []);
model.children = [];
children.forEach((childId) => {
const child = lf.getNodeModelById(childId);
if (child) child.draggable = true;
});
}
lf.deleteNode(node.id); lf.deleteNode(node.id);
emitter.emit("deleteNode", node.id); emitter.emit("deleteNode", node.id);
}); });
@ -900,12 +974,12 @@ onMounted(() => {
registerBeforeUnload(); registerBeforeUnload();
window.addEventListener("keydown", handleDeleteKey); window.addEventListener("keydown", handleDeleteKey);
emitter.on("openParamsDrawer", (node) => { onFlowEvent("openParamsDrawer", (node) => {
showParamsDrawer.value = true; showParamsDrawer.value = true;
paramsDrawerData.value = node; paramsDrawerData.value = node;
}); });
emitter.on("throwAnError", (data) => { onFlowEvent("throwAnError", (data) => {
// //
const { id, hasError, type } = data; const { id, hasError, type } = data;
let element let element
@ -935,7 +1009,7 @@ onMounted(() => {
} }
}); });
emitter.on("deleteNode", (nodeId) => { onFlowEvent("deleteNode", (nodeId) => {
const index = nodeErrorList.value.findIndex((item) => item === nodeId); const index = nodeErrorList.value.findIndex((item) => item === nodeId);
if (index !== -1) { if (index !== -1) {
nodeErrorList.value.splice(index, 1); nodeErrorList.value.splice(index, 1);
@ -943,18 +1017,11 @@ onMounted(() => {
}) })
}); });
const operationEdge = (arr) => {
arr.forEach((item) => {
newEdge(item.id);
});
};
onUnmounted(() => { onUnmounted(() => {
flowDisposed = true;
clearFlowPollTimer();
loopChildDragContexts.clear(); loopChildDragContexts.clear();
emitter.off("changeNodeState"); if (window.lf === lf) delete window.lf;
emitter.off("openParamsDrawer");
emitter.off("throwAnError");
emitter.off("deleteNode");
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("keydown", handleDeleteKey); window.removeEventListener("keydown", handleDeleteKey);
@ -981,12 +1048,90 @@ $flow-canvas-cursor: url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org
padding: 0 16px; padding: 0 16px;
border-bottom: 1px solid #e3e7ee; border-bottom: 1px solid #e3e7ee;
background: #fff; background: #fff;
gap: 18px;
.flow-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.flow-heading__name {
overflow: hidden;
max-width: 320px;
color: #1f2937;
font-size: 16px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-status {
display: flex;
height: 24px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid #dfe4ec;
border-radius: 5px;
background: #f8fafc;
color: #687386;
font-size: 11px;
white-space: nowrap;
}
.flow-status__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.flow-status--release,
.flow-status--testRunFinish {
border-color: #c8ead5;
background: #effaf3;
color: #1e7d49;
}
.flow-status--testRunning,
.flow-status--updateData {
border-color: #cbdcf8;
background: #f0f6ff;
color: #2f6fca;
}
.flow-status--testRunError,
.flow-status--stop {
border-color: #f1cecb;
background: #fff3f2;
color: #c33b32;
}
.flow-status--pause {
border-color: #ead9b8;
background: #fff9eb;
color: #9a6700;
}
.btn__container { .btn__container {
display: flex; display: flex;
min-width: 0;
align-items: center;
gap: 10px;
.btn { .btn {
margin-right: 12px; display: flex;
align-items: center;
gap: 8px;
.el-button + .el-button { margin-left: 0; }
}
.publish-actions {
padding-left: 10px;
border-left: 1px solid #e4e8ef;
} }
} }
} }
@ -997,16 +1142,56 @@ $flow-canvas-cursor: url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org
.el-main { .el-main {
padding: 0; padding: 0;
position: relative;
overflow: hidden;
} }
.el-aside { .el-aside {
margin: 0; margin: 0;
width: 260px; width: 272px;
padding: 0; padding: 0;
border-right: 1px solid #e3e7ee; border-right: 1px solid #e3e7ee;
background: #f8fafc; background: #f8fafc;
} }
.canvas-toolbar {
display: flex;
position: absolute;
top: 14px;
left: 14px;
z-index: 20;
align-items: center;
padding: 4px;
border: 1px solid #dfe4ec;
border-radius: 7px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 6px 18px rgba(31, 41, 55, 0.1);
.el-button {
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: #566174;
}
.el-button + .el-button { margin-left: 2px; }
.el-button:hover {
background: #f1f5f9;
color: #1f2937;
}
}
.canvas-toolbar__divider {
width: 1px;
height: 18px;
margin: 0 4px;
background: #e1e6ed;
}
.flow__container { .flow__container {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@ -1,246 +1,20 @@
<template> <template>
<div class="node__container" > <div class="node__container">
<!-- 状态栏 --> <div class="terminal-node">
<div class="title__container"> <div class="terminal-node__icon"><img src="../../icon/end.svg" alt="" /></div>
<div class="title"> <div class="terminal-node__content">
<img src="../../icon/end.svg" alt=""> <div class="terminal-node__title">流程结束</div>
<span class="text">end</span> <div class="terminal-node__description">结束当前工作流并输出最终结果</div>
</div> </div>
<div class="subTitle">工作流的终止节点结束整个流程</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({
const props = defineProps({
model: Object, model: Object,
graphModel: Object, graphModel: Object,
properties: Object, properties: Object,
text: String, text: String,
}); });
</script> </script>
<style lang="scss" scoped>
.node__container {
width: 300px;
height: auto;
background: #fff;
padding: 12px;
cursor: default;
border-radius: 12px;
border: 2px solid transparent;
box-shadow: 0 5px 15px 0#00000008;
position: relative;
.state__container {
display: none;
margin-bottom: 8px;
.context {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px;
font-size: 12px;
.state {
display: flex;
align-items: center;
.text {
margin: 0 8px;
}
}
.resultText {
color: #1664ff;
cursor: pointer;
}
}
}
.nodeState-success {
display: block;
background-color: #eef9f1;
.el-icon {
font-size: 16px;
color: #309256;
}
}
.nodeState-error{
display: block;
background-color: #fdf5f5;
.el-icon {
font-size: 16px;
color: #ee3f38;
}
}
.nodeState-running{
display: block;
background-color: #f4f7ff;
.el-icon {
color: #68a2e4;
font-size: 16px;
}
}
.title__container {
.title {
display: flex;
align-items: center;
img {
width: 24px;
height: 24px;
}
.text {
font-size: 16px;
font-weight: bold;
margin-left: 12px;
}
}
.subTitle {
color: #737a87;
font-size: 12px;
margin: 8px 0;
}
}
.input__container {
width: 100%;
background-color: #fafbfc;
padding: 0 16px;
border-radius: 8px;
box-sizing: border-box;
.title {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
.left {
display: flex;
align-items: center;
.tag {
width: 3px;
height: 16px;
background: #1664ff;
border-radius: 0 4px 4px 0;
margin-right: 12px;
}
.text {
font-size: 16px;
font-weight: 700;
color: #0c0d0e;
}
}
.right {
.addFormItem {
border: none;
background: transparent;
cursor: pointer;
}
}
}
.form__container {
margin: 12px 0;
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.deleteBtn {
margin-bottom: 22px;
cursor: pointer;
}
.addBtn {
margin-bottom: 22px;
cursor: pointer;
}
:deep(.el-row) {
align-items: end;
.el-input {
--el-input-width: 140px;
}
.el-select {
--el-select-width: 140px;
}
.el-cascader {
--el-form-inline-content-width: 140px;
}
}
}
}
}
</style>
<style lang="scss">
.popover__container {
min-width: 300px !important;
.params__container {
margin-bottom: 12px;
.paramsTitle {
color: #0c0d0e;
font-size: 14px;
font-weight: 500;
}
}
}
</style>

View File

@ -1,9 +1,20 @@
<template> <template>
<div class="group__container"> <div class="group__container">
<NodeTitle :icon="group" :nodeId="props.model.id" :nodeProperties="props.properties" :nodeName="props.properties?.name || '分组'" nodeType="selectArea" nodeDesc="将一些组件进行分组" /> <NodeTitle
:icon="group"
:nodeId="props.model.id"
:nodeProperties="props.properties"
:nodeName="props.properties?.name || '分组'"
nodeType="selectArea"
nodeDesc="组织相关流程节点"
@setNodeName="setNodeName"
/>
<div class="group-summary">
<span>分组区域</span>
<span class="group-summary__count">{{ props.childCount || 0 }} 个节点</span>
</div>
<div class="child__container"> <div class="child__container">
<slot></slot> <slot></slot>
</div> </div>
</div> </div>
</template> </template>
@ -17,13 +28,21 @@ const props = defineProps({
graphModel: Object, graphModel: Object,
properties: Object, properties: Object,
nowTime: Number, nowTime: Number,
childCount: Number,
}); });
const emits = defineEmits(["contentChange"]);
const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { ...properties, name });
emits("contentChange");
};
onMounted(() => { onMounted(() => {
lf.setProperties(props.model.id, { if (props.properties?.action !== "NONE") {
...props.properties, lf.setProperties(props.model.id, { ...props.properties, action: "NONE" });
action: "NONE" }
});
}) })
</script> </script>
@ -31,29 +50,71 @@ onMounted(() => {
.group__container { .group__container {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #fff; background: rgba(255, 255, 255, 0.96);
padding: 12px; padding: 16px;
cursor: default; cursor: default;
border-radius: 12px; border-radius: 8px;
border: 2px solid white; border: 1px solid #b8c5d8;
box-shadow: 0 5px 15px 0#00000008; box-shadow: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
box-sizing: border-box;
overflow: hidden;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
&::before {
content: "";
width: 5px;
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: #64748b;
}
&:hover {
border-color: #8fa0b8;
box-shadow: 0 8px 24px rgba(31, 41, 55, 0.08);
}
.title { .title {
height: 40px; height: 40px;
} }
.group-summary {
display: flex;
height: 30px;
align-items: center;
justify-content: space-between;
margin-top: 4px;
color: #475569;
font-size: 13px;
font-weight: 600;
}
.group-summary__count {
padding: 2px 8px;
border: 1px solid #d9e0ea;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 400;
}
.child__container { .child__container {
min-height: 80px; min-height: 80px;
flex: 1; flex: 1;
background-color: #f6f8fa; background-color: #f6f8fb;
border-radius: 8px; background-image: radial-gradient(#d6dde8 0.7px, transparent 0.7px);
background-size: 16px 16px;
border: 1px dashed #aeb9c9;
border-radius: 6px;
} }
.el-row { .el-row {
align-items: end; align-items: end;
} }
} }
</style> </style>

View File

@ -40,8 +40,6 @@
:nodeType="props.properties.nodeType || 'NONE'" :nodeType="props.properties.nodeType || 'NONE'"
:nodeName="props.properties.name" :nodeName="props.properties.name"
:nodeDesc="props.properties.desc" :nodeDesc="props.properties.desc"
:zoom-state="nodeZoom"
@setNodeName="setNodeName" @setNodeName="setNodeName"
/> />
<!-- @zoom="zoom" --> <!-- @zoom="zoom" -->
@ -49,13 +47,15 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from "vue"; import { ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css"; import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus"; import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
properties: Object, properties: Object,
@ -88,7 +88,7 @@ const closePopover = () => {
onMounted(() => { onMounted(() => {
emitter.on("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
@ -110,7 +110,7 @@ onMounted(() => {
} }
}); });
emitter.on("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
@ -119,19 +119,13 @@ onMounted(() => {
} }
}); });
emitter.on("setProperties", (data) => { onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
emits("contentChange"); emits("contentChange");
} }
}); });
}); });
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
emitter.off("setProperties");
});
defineExpose({ closePopover }) defineExpose({ closePopover })
</script> </script>

View File

@ -10,23 +10,23 @@
</template> --> </template> -->
</NodeState> </NodeState>
</keep-alive> </keep-alive>
<div class="title__container"> <div class="terminal-node">
<div class="title"> <div class="terminal-node__icon"><img src="../../icon/start.svg" alt="" /></div>
<div class="left"> <div class="terminal-node__content">
<img src="../../icon/start.svg" alt="" /> <div class="terminal-node__title">流程开始</div>
<span class="text">Start</span> <div class="terminal-node__description">接收流程输入并启动工作流</div>
</div>
</div> </div>
<div class="subTitle">工作流的起始节点用于设定启动工作流需要的信息</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from "vue"; import { ref, onMounted } from "vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css"; import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus"; import { useFlowEvents } from "../../composables/useFlowEvents";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
@ -49,7 +49,7 @@ const closePopover = () => {
} }
onMounted(() => { onMounted(() => {
emitter.on("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
@ -71,7 +71,7 @@ onMounted(() => {
} }
}); });
emitter.on("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
@ -81,7 +81,7 @@ onMounted(() => {
} }
}); });
emitter.on("setProperties", (data) => { onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
emits("contentChange"); emits("contentChange");
} }
@ -89,69 +89,6 @@ onMounted(() => {
}); });
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
emitter.off("setProperties");
});
defineExpose({ closePopover }) defineExpose({ closePopover })
</script> </script>
<style lang="scss" scoped>
.node__container {
width: 100%;
height: auto;
.title__container {
.title {
display: flex;
align-items: center;
justify-content: space-between;
.left {
img {
width: 24px;
height: 24px;
}
.text {
font-size: 16px;
font-weight: bold;
margin-left: 12px;
}
}
.right {
.el-button {
border: none;
font-size: 24px;
}
}
}
.subTitle {
color: #737a87;
font-size: 12px;
margin: 8px 0;
}
}
}
</style>
<style lang="scss">
.popover__container {
min-width: 300px !important;
.params__container {
margin-bottom: 12px;
.paramsTitle {
color: #0c0d0e;
font-size: 14px;
font-weight: 500;
}
}
}
</style>

View File

@ -46,12 +46,14 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from "vue"; import { ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import { emitter } from "@/utils/eventBus"; import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
properties: Object, properties: Object,
@ -84,7 +86,7 @@ const closePopover = () => {
onMounted(() => { onMounted(() => {
emitter.on("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
@ -106,7 +108,7 @@ onMounted(() => {
} }
}); });
emitter.on("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
@ -115,19 +117,13 @@ onMounted(() => {
} }
}); });
emitter.on("setProperties", (data) => { onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
emits("contentChange"); emits("contentChange");
} }
}); });
}) })
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
emitter.off("setProperties");
});
defineExpose({ closePopover }) defineExpose({ closePopover })
</script> </script>

View File

@ -25,20 +25,21 @@
:nodeProperties="props.properties" :nodeProperties="props.properties"
:nodeName="props.properties.name" :nodeName="props.properties.name"
:nodeDesc="props.properties.desc" :nodeDesc="props.properties.desc"
:zoom-state="nodeZoom"
@setNodeName="setNodeName" @setNodeName="setNodeName"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, onUnmounted } from "vue"; import { ref, reactive, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css"; import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus"; import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
properties: Object, properties: Object,
@ -69,7 +70,7 @@ const closePopover = () => {
} }
onMounted(() => { onMounted(() => {
emitter.on("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
@ -90,7 +91,7 @@ onMounted(() => {
} }
}); });
emitter.on("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
@ -99,19 +100,13 @@ onMounted(() => {
} }
}); });
emitter.on("setProperties", (data) => { onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
emits("contentChange"); emits("contentChange");
} }
}); });
}); });
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
emitter.off("setProperties");
});
defineExpose({ closePopover }) defineExpose({ closePopover })
</script> </script>

View File

@ -40,21 +40,21 @@
:nodeType="props.properties.nodeType || 'NONE'" :nodeType="props.properties.nodeType || 'NONE'"
:nodeName="props.properties.name" :nodeName="props.properties.name"
:nodeDesc="props.properties.desc" :nodeDesc="props.properties.desc"
:zoom-state="nodeZoom"
@zoom="zoom"
@setNodeName="setNodeName" @setNodeName="setNodeName"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from "vue"; import { ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css"; import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus"; import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
properties: Object, properties: Object,
@ -86,7 +86,7 @@ const closePopover = () => {
} }
onMounted(() => { onMounted(() => {
emitter.on("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
@ -108,7 +108,7 @@ onMounted(() => {
} }
}); });
emitter.on("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
@ -117,19 +117,13 @@ onMounted(() => {
} }
}); });
emitter.on("setProperties", (data) => { onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
emits("contentChange"); emits("contentChange");
} }
}); });
}); });
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
emitter.off("setProperties");
});
defineExpose({ closePopover }) defineExpose({ closePopover })
</script> </script>

View File

@ -16,7 +16,7 @@
/> />
<div class="loop-body-title"> <div class="loop-body-title">
<span>循环体</span> <span>循环体</span>
<span class="loop-body-count">{{ childCount }} 个节点</span> <span class="loop-body-count">{{ actualChildCount }} 个节点</span>
</div> </div>
<div <div
class="child__container" class="child__container"
@ -30,21 +30,21 @@
<script setup> <script setup>
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import loop from "../../icon/loop.svg"; import loop from "../../icon/loop.svg";
import { addNewEdge } from "@/utils/flow";
import { emitter } from "@/utils/eventBus"; import { emitter } from "@/utils/eventBus";
import { computed, onUnmounted, ref } from "vue"; import { computed, ref } from "vue";
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
graphModel: Object, graphModel: Object,
properties: Object, properties: Object,
nowTime: Number, nowTime: Number,
childCount: Number,
}); });
const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]); const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]);
const isDragOver = ref(false); const isDragOver = ref(false);
const childCount = computed(() => props.model?.children?.size || 0); const actualChildCount = computed(() => props.childCount ?? props.model?.children?.size ?? 0);
const handleDragover = (e) => { const handleDragover = (e) => {
e.preventDefault(); e.preventDefault();
@ -82,9 +82,6 @@ const handleDrop = (e) => {
} }
lf.addToGroup(props.model.id, node.id, point.canvasOverlayPosition); lf.addToGroup(props.model.id, node.id, point.canvasOverlayPosition);
setTimeout(() => {
addNewEdge(props.model.id);
}, 50);
}; };
const setNodeName = (name) => { const setNodeName = (name) => {
@ -96,10 +93,6 @@ const setNodeName = (name) => {
emits("contentChange"); emits("contentChange");
} }
onUnmounted(() => {
emitter.off("openParamsDrawer");
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.group__container { .group__container {
@ -118,6 +111,8 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
transition: border-color 0.18s ease, box-shadow 0.18s ease; transition: border-color 0.18s ease, box-shadow 0.18s ease;
&:hover { border-color: #aeb9c9; }
&::before { &::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -142,8 +137,8 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 28px; height: 30px;
margin-top: 2px; margin-top: 4px;
color: #344054; color: #344054;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@ -152,7 +147,8 @@ onUnmounted(() => {
.loop-body-count { .loop-body-count {
padding: 2px 8px; padding: 2px 8px;
color: #667085; color: #667085;
background: #eef1f6; border: 1px solid #dfe4ec;
background: #f8fafc;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
font-weight: 400; font-weight: 400;
@ -161,14 +157,14 @@ onUnmounted(() => {
.child__container { .child__container {
min-height: 300px; min-height: 300px;
flex: 1; flex: 1;
background-color: #f8fafc; background-color: #f7f9fc;
border: 1px dashed #b8c2d2; border: 1px dashed #aeb9c9;
border-radius: 6px; border-radius: 6px;
transition: background-color 0.18s ease, border-color 0.18s ease; transition: background-color 0.18s ease, border-color 0.18s ease;
} }
&--dragover .child__container { &--dragover .child__container {
background-color: #fff7f2; background-color: #fff8f3;
border-color: #ff6b35; border-color: #ff6b35;
} }

View File

@ -32,13 +32,15 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from "vue"; import { ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css"; import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus"; import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
properties: Object, properties: Object,
@ -69,7 +71,7 @@ const closePopover = () => {
} }
onMounted(() => { onMounted(() => {
emitter.on("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
@ -90,7 +92,7 @@ onMounted(() => {
} }
}); });
emitter.on("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
@ -99,20 +101,13 @@ onMounted(() => {
} }
}); });
emitter.on("setProperties", (data) => { onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
emits("contentChange"); emits("contentChange");
} }
}); });
}); });
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
emitter.off("setProperties");
});
defineExpose({ closePopover }) defineExpose({ closePopover })
</script> </script>

View File

@ -32,13 +32,15 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from "vue"; import { ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css"; import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus"; import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
properties: Object, properties: Object,
@ -69,7 +71,7 @@ const closePopover = () => {
} }
onMounted(() => { onMounted(() => {
emitter.on("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
@ -90,7 +92,7 @@ onMounted(() => {
} }
}); });
emitter.on("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
@ -99,20 +101,13 @@ onMounted(() => {
} }
}); });
emitter.on("setProperties", (data) => { onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
emits("contentChange"); emits("contentChange");
} }
}); });
}); });
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
emitter.off("setProperties");
});
defineExpose({ closePopover }) defineExpose({ closePopover })
</script> </script>

View File

@ -25,21 +25,21 @@
:nodeProperties="props.properties" :nodeProperties="props.properties"
:nodeName="props.properties.name" :nodeName="props.properties.name"
:nodeDesc="props.properties.desc" :nodeDesc="props.properties.desc"
:zoom-state="nodeZoom"
@zoom="zoom"
@setNodeName="setNodeName" @setNodeName="setNodeName"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from "vue"; import { ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css"; import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus"; import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
properties: Object, properties: Object,
@ -70,7 +70,7 @@ const closePopover = () => {
} }
onMounted(() => { onMounted(() => {
emitter.on("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
@ -91,7 +91,7 @@ onMounted(() => {
} }
}); });
emitter.on("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
@ -100,20 +100,13 @@ onMounted(() => {
} }
}); });
emitter.on("setProperties", (data) => { onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
emits("contentChange"); emits("contentChange");
} }
}); });
}); });
onUnmounted(() => {
emitter.off("changeNodeState");
emitter.off("contentChange");
emitter.off("setProperties");
});
defineExpose({ closePopover }) defineExpose({ closePopover })
</script> </script>

View File

@ -1,145 +1,15 @@
<template> <template>
<div class="node__container" > <div class="node__container">
<!-- 状态栏 --> <div class="terminal-node">
<div class="title__container"> <div class="terminal-node__icon"><img src="../../icon/end.svg" alt="" /></div>
<div class="title"> <div class="terminal-node__content">
<img src="../../icon/end.svg" alt=""> <div class="terminal-node__title">单次循环结束</div>
<span class="text">单次循环结束</span> <div class="terminal-node__description">结束循环体内的本次执行</div>
</div> </div>
<div class="subTitle">循环体内部工作流的终止节点结束循环体内部的单次流程</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({ model: Object, graphModel: Object, properties: Object });
const props = defineProps({
model: Object,
graphModel: Object,
properties: Object
});
</script> </script>
<style lang="scss" scoped>
@keyframes rotateAnimation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.node__container {
width: 300px;
height: auto;
background: #fff;
padding: 12px;
cursor: default;
border-radius: 12px;
border: 2px solid white;
box-shadow: 0 5px 15px 0#00000008;
position: relative;
.state__container {
display: none;
margin-bottom: 8px;
.context {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px;
font-size: 12px;
.state {
display: flex;
align-items: center;
.text {
margin: 0 8px;
}
}
.resultText {
color: #1664ff;
cursor: pointer;
}
}
}
.nodeState-success {
display: block;
background-color: #eef9f1;
.el-icon {
font-size: 16px;
color: #309256;
}
}
.nodeState-error{
display: block;
background-color: #fdf5f5;
.el-icon {
font-size: 16px;
color: #ee3f38;
}
}
.nodeState-running{
display: block;
background-color: #f4f7ff;
.el-icon {
color: #68a2e4;
font-size: 16px;
animation: rotateAnimation 2s linear infinite;
}
}
.title__container {
.title {
display: flex;
align-items: center;
img {
width: 24px;
height: 24px;
}
.text {
font-size: 16px;
font-weight: bold;
margin-left: 12px;
}
}
.subTitle {
color: #737a87;
font-size: 12px;
margin: 8px 0;
}
}
}
</style>
<style lang="scss">
.popover__container {
min-width: 300px !important;
.params__container {
margin-bottom: 12px;
.paramsTitle {
color: #0c0d0e;
font-size: 14px;
font-weight: 500;
}
}
}
</style>

View File

@ -1,106 +1,15 @@
<template> <template>
<div class="node__container" > <div class="node__container">
<!-- 状态栏 --> <div class="terminal-node">
<div class="title__container"> <div class="terminal-node__icon"><img src="../../icon/start.svg" alt="" /></div>
<div class="title"> <div class="terminal-node__content">
<img src="../../icon/end.svg" alt=""> <div class="terminal-node__title">单次循环开始</div>
<span class="text">单次循环开始</span> <div class="terminal-node__description">开始循环体内的一次执行</div>
</div> </div>
<div class="subTitle">循环体内部工作流的开始节点开始循环体内部的单次流程</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({ model: Object, graphModel: Object, properties: Object });
const props = defineProps({
model: Object,
graphModel: Object,
properties: Object
});
</script> </script>
<style lang="scss" scoped>
.node__container {
width: 300px;
height: auto;
background: #fff;
padding: 12px;
cursor: default;
border-radius: 12px;
border: 2px solid white;
box-shadow: 0 5px 15px 0#00000008;
position: relative;
.state__container {
display: none;
margin-bottom: 8px;
.context {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px;
font-size: 12px;
.state {
display: flex;
align-items: center;
.text {
margin: 0 8px;
}
}
.resultText {
color: #1664ff;
cursor: pointer;
}
}
}
.title__container {
.title {
display: flex;
align-items: center;
img {
width: 24px;
height: 24px;
}
.text {
font-size: 16px;
font-weight: bold;
margin-left: 12px;
}
}
.subTitle {
color: #737a87;
font-size: 12px;
margin: 8px 0;
}
}
}
</style>
<style lang="scss">
.popover__container {
min-width: 300px !important;
.params__container {
margin-bottom: 12px;
.paramsTitle {
color: #0c0d0e;
font-size: 14px;
font-weight: 500;
}
}
}
</style>

View File

@ -47,7 +47,9 @@
<div <div
class="condition" class="condition"
v-for="(params, index) in formData.nodeParams" v-for="(params, index) in formData.nodeParams"
:key="params.id"
:id="params.id" :id="params.id"
:data-branch-anchor-id="params.id"
> >
<div class="title__container"> <div class="title__container">
<div class="left"> <div class="left">
@ -82,7 +84,7 @@
</div> </div>
</div> </div>
<div class="form__container"> <div class="form__container">
<el-row v-for="(property, pIndex) in params.list"> <el-row v-for="(property, pIndex) in params.list" :key="`${params.id}-${pIndex}`">
<el-form-item <el-form-item
:label="pIndex === 0 ? '引用变量' : ''" :label="pIndex === 0 ? '引用变量' : ''"
:prop="`nodeParams.${index}.list.${pIndex}.nameType`" :prop="`nodeParams.${index}.list.${pIndex}.nameType`"
@ -251,22 +253,26 @@
</div> </div>
</el-form> </el-form>
</div> </div>
<div class="else" :id="''.concat(props.model.id, '_else')"> <div
class="else"
:id="''.concat(props.model.id, '_else')"
:data-branch-anchor-id="''.concat(props.model.id, '_else')"
>
<div class="space"></div> <div class="space"></div>
<div class="title">Else</div> <div class="title">Else</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, reactive, ref } from "vue"; import { nextTick, onMounted, onUnmounted, reactive, ref, toRaw, watch } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import switchSvg from "../../icon/switch.svg"; import switchSvg from "../../icon/switch.svg";
import { v4 as randomUUID } from "uuid"; import { v4 as randomUUID } from "uuid";
import { useFlowStore } from "@/store/modules/flow"; import { useFlowStore } from "@/store/modules/flow";
import { Plus, Minus } from "@element-plus/icons-vue"; import { Plus, Minus } from "@element-plus/icons-vue";
import { getInput, addNewEdge, removeSwitchEdge } from "@/utils/flow"; import { getInput, removeSwitchEdge } from "@/utils/flow";
import { emitter } from "@/utils/eventBus"; import { useFlowEvents } from "../../composables/useFlowEvents";
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
@ -280,19 +286,21 @@ const emits = defineEmits([
"removeAnchor", "removeAnchor",
"bindRef", "bindRef",
"changeAnchor", "changeAnchor",
"syncAnchors",
]); ]);
const switchRef = ref(); const switchRef = ref();
const dynamicForm = ref(); const dynamicForm = ref();
const flowStore = useFlowStore(); const flowStore = useFlowStore();
const rules = reactive({});
const { onFlowEvent } = useFlowEvents();
const formData = reactive({ const formData = reactive({
nodeParams: props.properties?.conditions || [], nodeParams: props.properties?.conditions || [],
}); });
const addFormItem = () => { const addFormItem = () => {
const rect = switchRef.value.getBoundingClientRect(); addCondition(0);
addCondition(rect.height - 60);
}; };
const deleteFormItem = (id, index) => { const deleteFormItem = (id, index) => {
@ -303,7 +311,7 @@ const deleteFormItem = (id, index) => {
emits("removeAnchor", id); emits("removeAnchor", id);
removeSwitchEdge(props.model.id, id); removeSwitchEdge(props.model.id, id);
nextTick(() => { nextTick(() => {
changeFormItemHeight(); scheduleAnchorSync();
}); });
}; };
@ -338,12 +346,8 @@ const addCondition = (height) => {
}); });
emits("addAnchor", height, id); emits("addAnchor", height, id);
setTimeout(() => {
addNewEdge(props.model.id);
}, 50);
nextTick(() => { nextTick(() => {
changeFormItemHeight(); scheduleAnchorSync();
}); });
}; };
@ -357,7 +361,7 @@ const addListItem = (pIndex, cIndex) => {
quote: "", quote: "",
}); });
nextTick(() => { nextTick(() => {
changeFormItemHeight(); scheduleAnchorSync();
}); });
}; };
@ -367,18 +371,34 @@ const removeListItem = (pIndex, cIndex) => {
} }
formData.nodeParams[pIndex].list.splice(cIndex, 1); formData.nodeParams[pIndex].list.splice(cIndex, 1);
nextTick(() => { nextTick(() => {
changeFormItemHeight(); scheduleAnchorSync();
}); });
}; };
const changeFormItemHeight = () => { let anchorSyncFrame = null;
formData.nodeParams.forEach((item) => { let resizeObserver = null;
const node = document.getElementById(item.id);
emits("changeAnchor", node.offsetTop, item.id);
});
const node = document.getElementById("".concat(props.model.id, "_else")); const syncAnchorPositions = () => {
emits("changeAnchor", node.offsetTop, "".concat(props.model.id, "_else")); const root = switchRef.value;
if (!root) return;
const rootRect = root.getBoundingClientRect();
const scale = root.offsetWidth > 0 ? rootRect.width / root.offsetWidth : 1;
const positions = [...root.querySelectorAll("[data-branch-anchor-id]")].map((element) => {
const rect = element.getBoundingClientRect();
return {
id: element.dataset.branchAnchorId,
height: Math.round(((rect.top - rootRect.top + rect.height / 2) / scale) * 100) / 100,
};
});
emits("syncAnchors", positions);
};
const scheduleAnchorSync = () => {
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
anchorSyncFrame = requestAnimationFrame(() => {
anchorSyncFrame = null;
syncAnchorPositions();
});
}; };
const setNodeProperties = async () => { const setNodeProperties = async () => {
@ -438,6 +458,7 @@ watch(
addCondition(100); addCondition(100);
} }
} }
nextTick(scheduleAnchorSync);
}, },
{ {
immediate: true, immediate: true,
@ -471,11 +492,16 @@ const handleInputKeydown = (e) => {
} }
onMounted(() => { onMounted(() => {
emits("addAnchor", 280, "".concat(props.model.id, "_else")); emits("addAnchor", 0, "".concat(props.model.id, "_else"));
emits("bindRef", dynamicForm.value); emits("bindRef", dynamicForm.value);
nextTick(() => {
scheduleAnchorSync();
resizeObserver = new ResizeObserver(scheduleAnchorSync);
if (switchRef.value) resizeObserver.observe(switchRef.value);
});
emitter.on("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) {
@ -498,7 +524,7 @@ onMounted(() => {
} }
}); });
emitter.on("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
@ -510,60 +536,83 @@ onMounted(() => {
}); });
onUnmounted(() => { onUnmounted(() => {
emitter.off("changeNodeState"); resizeObserver?.disconnect();
emitter.off("contentChange"); if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
}); });
defineExpose({ closePopover }) defineExpose({ closePopover })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.node__container { .node__container {
width: 720px; width: 100%;
height: auto; height: auto;
background: #fff; background: #fff;
padding: 12px; padding: 16px;
cursor: default; cursor: default;
border-radius: 12px; border: 1px solid #dfe4ec;
border: 2px solid white; border-left: 4px solid var(--node-accent, #b7791f);
box-shadow: 0 5px 15px 0#00000008; border-radius: 8px;
box-shadow: 0 8px 24px rgba(31, 41, 55, 0.09);
position: relative; position: relative;
box-sizing: border-box;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
.condition_title_container { .condition_title_container {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #e8ebf0;
.left { .left {
display: flex; display: flex;
align-items: center; align-items: center;
.space { .space {
width: 4px; width: 3px;
height: 14px; height: 15px;
background: rgb(22, 100, 255); background: var(--node-accent, #b7791f);
border-radius: 0px 4px 4px 0px; border-radius: 3px;
} }
.title { .title {
margin-left: 8px; margin-left: 8px;
font-size: 14px; color: #344054;
font-weight: 500; font-size: 13px;
font-weight: 650;
} }
} }
.el-button { .el-button {
border: none; width: 28px;
height: 28px;
padding: 0;
border-color: #dfe4ec;
border-radius: 5px;
} }
} }
.condition { .condition {
width: 100%; width: 100%;
height: auto; height: auto;
margin-bottom: 10px; margin: 8px 0;
background-color: #fafbfc; padding: 11px 12px 5px;
padding: 8px 16px; border: 1px solid #e4e8ef;
border-radius: 8px; border-radius: 7px;
background-color: #f8fafc;
box-sizing: border-box; box-sizing: border-box;
position: relative;
&::after {
content: "";
width: 17px;
position: absolute;
top: 50%;
right: -18px;
border-top: 1px solid var(--node-accent, #b7791f);
pointer-events: none;
}
.title__container { .title__container {
display: flex; display: flex;
@ -575,45 +624,58 @@ defineExpose({ closePopover })
align-items: center; align-items: center;
.space { .space {
width: 4px; width: 3px;
height: 14px; height: 14px;
background: rgb(22, 100, 255); background: #3978c5;
border-radius: 0px 4px 4px 0px; border-radius: 3px;
} }
.title { .title {
margin-left: 8px; margin-left: 8px;
font-size: 14px; color: #344054;
font-weight: bold; font-size: 12px;
font-weight: 650;
} }
} }
.el-button {
width: 26px;
height: 26px;
padding: 0;
border-radius: 5px;
}
} }
.withOr { .withOr {
margin-bottom: 12px; display: flex;
align-items: center;
gap: 10px;
margin: 9px 0 7px;
.title { .title {
font-size: 14px; color: #7a8494;
color: #737a87; font-size: 12px;
margin-bottom: 4px;
} }
:deep(.el-select) { width: 120px !important; }
} }
:deep(.form__container) { :deep(.form__container) {
.el-form-item { .el-form-item {
margin-right: 20px; margin-right: 12px;
margin-bottom: 8px;
} }
.el-input { .el-input {
width: 92px; width: 105px;
} }
.el-select { .el-select {
width: 92px; width: 105px;
} }
.el-cascader { .el-cascader {
width: 92px; width: 105px;
} }
} }
} }
@ -621,21 +683,35 @@ defineExpose({ closePopover })
.else { .else {
display: flex; display: flex;
align-items: center; align-items: center;
background-color: #fafbfc; min-height: 40px;
padding: 8px 16px; padding: 8px 12px;
border: 1px solid #e4e8ef;
border-radius: 7px;
background-color: #f8fafc;
position: relative;
&::after {
content: "";
width: 17px;
position: absolute;
top: 50%;
right: -18px;
border-top: 1px solid var(--node-accent, #b7791f);
pointer-events: none;
}
.space { .space {
width: 4px; width: 3px;
height: 14px; height: 14px;
background: rgb(22, 100, 255); background: #697386;
border-radius: 0px 4px 4px 0px; border-radius: 3px;
} }
.title { .title {
margin-left: 8px; margin-left: 8px;
font-size: 14px; color: #344054;
font-weight: 500; font-size: 12px;
font-weight: bold; font-weight: 650;
} }
} }
} }

View File

@ -24,6 +24,8 @@ import { BaseEdge, EdgeLabelRenderer, getBezierPath } from "@vue-flow/core";
import { Delete } from "@element-plus/icons-vue"; import { Delete } from "@element-plus/icons-vue";
import { useFlowStore } from "@/store/modules/flow"; import { useFlowStore } from "@/store/modules/flow";
defineOptions({ inheritAttrs: false });
const props = defineProps({ const props = defineProps({
id: String, id: String,
sourceX: Number, sourceX: Number,
@ -49,7 +51,7 @@ const pathData = computed(() => getBezierPath({
})); }));
const edgePath = computed(() => pathData.value[0]); const edgePath = computed(() => pathData.value[0]);
const deleteActionStyle = computed(() => ({ const deleteActionStyle = computed(() => ({
transform: `translate(-50%, -50%) translate(${pathData.value[1]}px, ${pathData.value[2] - 24}px)`, transform: `translate(-50%, -50%) translate(${pathData.value[1]}px, ${pathData.value[2]}px)`,
})); }));
const deleteEdge = () => { const deleteEdge = () => {
@ -70,12 +72,12 @@ const deleteEdge = () => {
height: 30px; height: 30px;
padding: 0; padding: 0;
place-items: center; place-items: center;
border: 1px solid #f2b8b5; border: 1px solid #efb8b4;
border-radius: 50%; border-radius: 50%;
background: #fff; background: #fff;
color: #d93025; color: #d93025;
cursor: pointer; cursor: pointer;
box-shadow: 0 4px 12px rgba(20, 33, 61, 0.18); box-shadow: 0 4px 12px rgba(31, 41, 55, 0.2);
&:hover { &:hover {
border-color: #d93025; border-color: #d93025;

View File

@ -9,6 +9,7 @@
type="target" type="target"
:position="Position.Left" :position="Position.Left"
:connectable="connectable && !flowStore.disableForm" :connectable="connectable && !flowStore.disableForm"
:is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--target" class="flow-handle flow-handle--target"
/> />
<div class="flow-node-content" :class="{ 'flow-node-card': !isGroup && flowType !== 'branch' }"> <div class="flow-node-content" :class="{ 'flow-node-card': !isGroup && flowType !== 'branch' }">
@ -18,11 +19,13 @@
:model="model" :model="model"
:graph-model="model" :graph-model="model"
:properties="properties" :properties="properties"
:child-count="isGroup ? childNodeCount : undefined"
@content-change="contentChanged" @content-change="contentChanged"
@bind-ref="bindComponent" @bind-ref="bindComponent"
@add-anchor="addAnchor" @add-anchor="addAnchor"
@remove-anchor="removeAnchor" @remove-anchor="removeAnchor"
@change-anchor="changeAnchor" @change-anchor="changeAnchor"
@sync-anchors="syncBranchAnchors"
@add-to-group="addToGroup" @add-to-group="addToGroup"
/> />
</div> </div>
@ -34,8 +37,9 @@
type="source" type="source"
:position="Position.Right" :position="Position.Right"
:connectable="connectable && !flowStore.disableForm" :connectable="connectable && !flowStore.disableForm"
:is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--source flow-handle--branch" class="flow-handle flow-handle--source flow-handle--branch"
:style="{ top: `${Math.max(76, Number(anchor.height || 0) + 60)}px` }" :style="{ top: `${Math.max(40, Number(anchor.height || 0))}px` }"
/> />
</template> </template>
</template> </template>
@ -45,6 +49,7 @@
type="source" type="source"
:position="Position.Right" :position="Position.Right"
:connectable="connectable && !flowStore.disableForm" :connectable="connectable && !flowStore.disableForm"
:is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--source" class="flow-handle flow-handle--source"
/> />
</div> </div>
@ -102,6 +107,9 @@ const flowType = computed(() => props.data.flowType);
const properties = computed(() => props.data.properties || {}); const properties = computed(() => props.data.properties || {});
const nodeComponent = computed(() => componentMap[flowType.value] || ServiceNode); const nodeComponent = computed(() => componentMap[flowType.value] || ServiceNode);
const isGroup = computed(() => ["loop", "selectArea"].includes(flowType.value)); 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 showTarget = computed(() => !["start", "subStart", "selectArea"].includes(flowType.value));
const showSource = computed(() => !["end", "stopLoop", "subEnd", "selectArea", "branch"].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 targetHandleId = computed(() => flowType.value === "branch" ? `${props.id}_entry` : `${props.id}_3`);
@ -120,6 +128,17 @@ const branchAnchors = computed(() => {
}); });
const model = computed(() => window.lf?.getNodeModelById(props.id)); const model = computed(() => window.lf?.getNodeModelById(props.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;
};
const isValidLoopConnection = (connection) => {
if (!connection.source || !connection.target || connection.source === connection.target) return false;
return getLoopScope(connection.source) === getLoopScope(connection.target);
};
const syncAnchors = (anchors) => { const syncAnchors = (anchors) => {
window.lf?.setProperties(props.id, { ...window.lf.getProperties(props.id), anchor: anchors }); window.lf?.setProperties(props.id, { ...window.lf.getProperties(props.id), anchor: anchors });
vueFlow.updateNodeInternals([props.id]); vueFlow.updateNodeInternals([props.id]);
@ -135,6 +154,26 @@ const addAnchor = (height, anchorId) => {
const removeAnchor = (anchorId) => syncAnchors(branchAnchors.value.filter((anchor) => anchor.id !== anchorId)); const removeAnchor = (anchorId) => syncAnchors(branchAnchors.value.filter((anchor) => anchor.id !== anchorId));
const changeAnchor = (height, anchorId) => syncAnchors(branchAnchors.value.map((anchor) => anchor.id === anchorId ? { ...anchor, height } : anchor)); const changeAnchor = (height, anchorId) => syncAnchors(branchAnchors.value.map((anchor) => anchor.id === anchorId ? { ...anchor, height } : anchor));
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]);
};
const contentChanged = () => { const contentChanged = () => {
vueFlow.updateNodeInternals([props.id]); vueFlow.updateNodeInternals([props.id]);
window.lf?.markChanged(); window.lf?.markChanged();
@ -147,25 +186,47 @@ onMounted(() => bindComponent(componentRef.value));
<style scoped lang="scss"> <style scoped lang="scss">
.flow-node-shell { .flow-node-shell {
--node-accent: #4f6b8a;
width: 100%; width: 100%;
min-height: 100%; min-height: 100%;
position: relative; position: relative;
box-sizing: border-box; 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, &--loop,
&--selectArea { &--selectArea {
height: 100%; height: 100%;
} }
&--loop.flow-node-shell--selected .flow-node-content { &:hover .flow-node-card {
box-shadow: none; border-color: #cbd3df;
box-shadow: 0 10px 26px rgba(31, 41, 55, 0.11);
} }
&--selected .flow-node-content { &--selected .flow-node-card {
outline: none; border-color: #2f80ed;
border: 2px solid #2f80ed; box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.2), 0 10px 26px rgba(31, 41, 55, 0.12);
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.1); }
border-radius: 8px;
&--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);
} }
} }
@ -178,25 +239,92 @@ onMounted(() => bindComponent(componentRef.value));
.flow-node-card { .flow-node-card {
width: 100%; width: 100%;
height: auto; height: auto;
padding: 12px; padding: 13px 14px 13px 16px;
position: relative;
background: #fff; background: #fff;
border: 1px solid #e4e8ef; border: 1px solid #dfe4ec;
border-left: 4px solid var(--node-accent);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.08); box-shadow: 0 7px 20px rgba(31, 41, 55, 0.08);
box-sizing: border-box; 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 { .flow-handle {
width: 14px; width: 12px;
height: 14px; height: 12px;
border: 3px solid #fff; border: 2px solid #fff;
background: #ff6b35; background: var(--node-accent);
box-shadow: 0 0 0 1px #ff6b35; box-shadow: 0 0 0 1px var(--node-accent), 0 2px 5px rgba(31, 41, 55, 0.18);
z-index: 8; z-index: 10;
transition: transform 0.14s ease, box-shadow 0.14s ease;
&--target { left: -7px; } &:hover {
&--source { right: -7px; } 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 { transform: translate(50%, -50%); }
&--branch:hover { transform: translate(50%, -50%) scale(1.18); }
} }
</style> </style>

View File

@ -8,6 +8,7 @@ const GROUP_TYPES = new Set(["loop", "selectArea"]);
const TARGET_ONLY_TYPES = new Set(["end", "stopLoop", "subEnd"]); const TARGET_ONLY_TYPES = new Set(["end", "stopLoop", "subEnd"]);
const SOURCE_ONLY_TYPES = new Set(["start", "subStart"]); const SOURCE_ONLY_TYPES = new Set(["start", "subStart"]);
const LOOP_BODY_INSET = { left: 24, top: 112, right: 24, bottom: 24 }; const LOOP_BODY_INSET = { left: 24, top: 112, right: 24, bottom: 24 };
const GROUP_BODY_INSET = { left: 24, top: 120, right: 24, bottom: 24 };
const LOOP_MIN_SIZE = { width: 520, height: 360 }; const LOOP_MIN_SIZE = { width: 520, height: 360 };
const loopChildExtent = () => ({ const loopChildExtent = () => ({
@ -20,6 +21,16 @@ const loopChildExtent = () => ({
], ],
}); });
const groupChildExtent = () => ({
range: "parent",
padding: [
GROUP_BODY_INSET.top,
GROUP_BODY_INSET.right,
GROUP_BODY_INSET.bottom,
GROUP_BODY_INSET.left,
],
});
const nodeSize = (node) => { const nodeSize = (node) => {
const width = Number(node.properties?.width || node.width); const width = Number(node.properties?.width || node.width);
const height = Number(node.properties?.height || node.height); const height = Number(node.properties?.height || node.height);
@ -37,19 +48,28 @@ const nodeSize = (node) => {
const edgeId = () => `edge_${uuid().replace(/-/g, "")}`; const edgeId = () => `edge_${uuid().replace(/-/g, "")}`;
const nodeId = () => uuid().replace(/-/g, ""); const nodeId = () => uuid().replace(/-/g, "");
const toVueNode = (node) => { const toVueNode = (node, sizeOverride, positionOverride) => {
const normalized = clone(node); const normalized = clone(node);
normalized.id ||= nodeId(); normalized.id ||= nodeId();
normalized.properties ||= {}; normalized.properties ||= {};
const size = nodeSize(normalized); const overrideWidth = Number(sizeOverride?.width);
const overrideHeight = Number(sizeOverride?.height);
const size = Number.isFinite(overrideWidth) && overrideWidth > 0
&& Number.isFinite(overrideHeight) && overrideHeight > 0
? { width: overrideWidth, height: overrideHeight }
: nodeSize(normalized);
const center = { const center = {
x: Number(normalized.x) || 0, x: Number(normalized.x) || 0,
y: Number(normalized.y) || 0, y: Number(normalized.y) || 0,
}; };
const position = { const overrideX = Number(positionOverride?.x);
x: center.x - size.width / 2, const overrideY = Number(positionOverride?.y);
y: center.y - size.height / 2, const position = Number.isFinite(overrideX) && Number.isFinite(overrideY)
}; ? { x: overrideX, y: overrideY }
: {
x: center.x - size.width / 2,
y: center.y - size.height / 2,
};
const dimensions = GROUP_TYPES.has(normalized.type) const dimensions = GROUP_TYPES.has(normalized.type)
? { width: size.width, height: size.height } ? { width: size.width, height: size.height }
@ -106,9 +126,16 @@ const toVueEdge = (edge) => {
const attachChildrenToGroups = (nodes) => { const attachChildrenToGroups = (nodes) => {
const nodeLookup = new Map(nodes.map((node) => [node.id, node])); const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
const declaredParents = new Map();
nodes.filter((node) => GROUP_TYPES.has(node.data.flowType)).forEach((group) => {
(group.data.children || []).forEach((childId) => {
if (!declaredParents.has(childId)) declaredParents.set(childId, group.id);
});
});
nodes.forEach((child) => { nodes.forEach((child) => {
const parentId = child.data.properties?.parentId; const parentId = child.data.properties?.parentId || declaredParents.get(child.id);
const parent = nodeLookup.get(parentId); const parent = nodeLookup.get(parentId);
if (!parent || !GROUP_TYPES.has(parent.data.flowType) || GROUP_TYPES.has(child.data.flowType)) return; if (!parent || !GROUP_TYPES.has(parent.data.flowType) || GROUP_TYPES.has(child.data.flowType)) return;
@ -122,10 +149,12 @@ const attachChildrenToGroups = (nodes) => {
: child.position.y - parent.position.y, : child.position.y - parent.position.y,
}; };
child.parentNode = parentId; child.parentNode = parentId;
child.extent = isLoop ? loopChildExtent() : "parent"; child.extent = isLoop ? loopChildExtent() : groupChildExtent();
child.expandParent = false; child.expandParent = false;
child.position = relativePosition; child.position = relativePosition;
child.zIndex = 2; child.zIndex = 5;
child.data.properties = { ...child.data.properties, parentId };
parent.data.children = Array.from(new Set([...(parent.data.children || []), child.id]));
}); });
nodes.filter((node) => node.data.flowType === "loop").forEach((group) => { nodes.filter((node) => node.data.flowType === "loop").forEach((group) => {
@ -148,10 +177,20 @@ const attachChildrenToGroups = (nodes) => {
return nodes.sort((left, right) => Number(Boolean(left.parentNode)) - Number(Boolean(right.parentNode))); return nodes.sort((left, right) => Number(Boolean(left.parentNode)) - Number(Boolean(right.parentNode)));
}; };
export const toVueFlowData = (graph = {}) => ({ export const toVueFlowData = (graph = {}, options = {}) => {
nodes: attachChildrenToGroups((graph.nodes || []).map(toVueNode)), const nodes = attachChildrenToGroups((graph.nodes || []).map((node) => (
edges: (graph.edges || []).map(toVueEdge), toVueNode(node, options.nodeSizes?.get(node.id), options.nodePositions?.get(node.id))
}); )));
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
const edges = (graph.edges || []).map((edge) => {
const vueEdge = toVueEdge(edge);
const source = nodeLookup.get(vueEdge.source);
const target = nodeLookup.get(vueEdge.target);
if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4;
return vueEdge;
});
return { nodes, edges };
};
export const createFlowFacade = (store) => { export const createFlowFacade = (store) => {
const handlers = new Map(); const handlers = new Map();
@ -177,9 +216,20 @@ export const createFlowFacade = (store) => {
: (node.position || node.computedPosition); : (node.position || node.computedPosition);
result.id = node.id; result.id = node.id;
result.type = node.data.flowType; result.type = node.data.flowType;
result.x = absolutePosition.x + size.width / 2; if (GROUP_TYPES.has(result.type)) {
result.y = absolutePosition.y + size.height / 2; result.x = absolutePosition.x + size.width / 2;
result.y = absolutePosition.y + size.height / 2;
} else {
const originalCenter = node.data.originalCenter || {
x: absolutePosition.x + size.width / 2,
y: absolutePosition.y + size.height / 2,
};
const originalPosition = node.data.originalPosition || absolutePosition;
result.x = originalCenter.x + absolutePosition.x - originalPosition.x;
result.y = originalCenter.y + absolutePosition.y - originalPosition.y;
}
result.properties = clone(node.data.properties || {}); result.properties = clone(node.data.properties || {});
if (parent?.data?.flowType === "selectArea") delete result.properties.parentId;
if (GROUP_TYPES.has(result.type)) { if (GROUP_TYPES.has(result.type)) {
result.properties.width = size.width; result.properties.width = size.width;
result.properties.height = size.height; result.properties.height = size.height;
@ -342,6 +392,9 @@ export const createFlowFacade = (store) => {
get y() { return backendNode(node).y; }, get y() { return backendNode(node).y; },
get width() { return node.dimensions?.width || node.data.size?.width || 0; }, get width() { return node.dimensions?.width || node.data.size?.width || 0; },
get height() { return node.dimensions?.height || node.data.size?.height || 0; }, get height() { return node.dimensions?.height || node.data.size?.height || 0; },
get left() { return getNodeRect(node).x; },
get top() { return getNodeRect(node).y; },
get parentId() { return node.parentNode || null; },
get anchors() { return getAnchors(node); }, get anchors() { return getAnchors(node); },
get children() { return new Set(node.data.children || []); }, get children() { return new Set(node.data.children || []); },
set children(value) { set children(value) {
@ -390,18 +443,44 @@ export const createFlowFacade = (store) => {
render(graph, options = {}) { render(graph, options = {}) {
historySuspended = true; historySuspended = true;
loopLayoutStates.clear(); loopLayoutStates.clear();
const data = toVueFlowData(graph); const nodeSizes = options.preserveNodeSizes === true
store.setNodes(data.nodes); ? new Map(getNodes().map((node) => [node.id, {
store.setEdges(data.edges); width: Number(node.dimensions?.width || node.width || node.data.size?.width || 0),
nextTick(() => { height: Number(node.dimensions?.height || node.height || node.data.size?.height || 0),
store.updateNodeInternals(); }]))
: options.nodeSizes;
const nodePositions = options.preserveNodePositions === true
? new Map(getNodes().map((node) => {
const rect = getNodeRect(node);
return [node.id, { x: rect.x, y: rect.y }];
}))
: options.nodePositions;
const data = toVueFlowData(graph, { nodeSizes, nodePositions });
const finishRender = () => {
historySuspended = false; historySuspended = false;
if (options.resetHistory !== false) { if (options.resetHistory !== false) {
history = []; history = [];
historyIndex = -1; historyIndex = -1;
recordHistory(); recordHistory();
} }
}); nextTick(() => store.updateNodeInternals());
};
const applyData = () => {
store.setNodes(data.nodes);
store.setEdges(data.edges);
finishRender();
};
if (options.replaceNodes === true) {
// Changing parentNode is structural in Vue Flow. Unmount in one tick
// and remount in the next so node wrappers and edges cannot retain
// different graph-node instances for the same id.
store.setEdges([]);
store.setNodes([]);
nextTick(applyData);
} else {
applyData();
}
}, },
addNode(node) { addNode(node) {
const vueNode = toVueNode(node); const vueNode = toVueNode(node);
@ -411,6 +490,10 @@ export const createFlowFacade = (store) => {
}, },
deleteNode(id) { deleteNode(id) {
const target = store.findNode(id); const target = store.findNode(id);
if (target?.data?.flowType === "selectArea") {
facade.ungroup(id);
return;
}
const parentId = target?.parentNode; const parentId = target?.parentNode;
const childIds = target?.data?.children || []; const childIds = target?.data?.children || [];
const removeChildren = target?.data?.flowType === "loop" && childIds.length > 0; const removeChildren = target?.data?.flowType === "loop" && childIds.length > 0;
@ -432,6 +515,9 @@ export const createFlowFacade = (store) => {
}, },
addEdge(edge) { addEdge(edge) {
const vueEdge = toVueEdge(edge); const vueEdge = toVueEdge(edge);
const source = store.findNode(vueEdge.source);
const target = store.findNode(vueEdge.target);
if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4;
store.addEdges(vueEdge); store.addEdges(vueEdge);
changed(); changed();
return backendEdge(vueEdge); return backendEdge(vueEdge);
@ -508,12 +594,75 @@ export const createFlowFacade = (store) => {
childData.x = dropCenter.x; childData.x = dropCenter.x;
childData.y = dropCenter.y; childData.y = dropCenter.y;
} }
facade.render(graph, { resetHistory: false }); facade.render(graph, {
resetHistory: false,
replaceNodes: true,
preserveNodeSizes: true,
preserveNodePositions: true,
});
nextTick(() => { nextTick(() => {
store.updateNodeInternals([groupId, childId]); store.updateNodeInternals([groupId, childId]);
changed(); changed();
}); });
}, },
groupNodes(groupNode, childIds = []) {
const graph = facade.getGraphData();
const selectedIds = new Set(childIds);
const children = graph.nodes.filter((node) => (
selectedIds.has(node.id)
&& !GROUP_TYPES.has(node.type)
&& !store.findNode(node.id)?.parentNode
));
if (!children.length) return null;
const group = clone(groupNode);
group.id ||= nodeId();
group.type = "selectArea";
group.properties = { ...(group.properties || {}), action: "NONE" };
group.children = children.map((node) => node.id);
children.forEach((node) => {
node.properties = { ...(node.properties || {}), parentId: group.id };
});
graph.nodes.push(group);
facade.render(graph, {
resetHistory: false,
replaceNodes: true,
preserveNodeSizes: true,
preserveNodePositions: true,
});
nextTick(() => {
store.updateNodeInternals([group.id, ...group.children]);
changed();
});
return clone(group);
},
ungroup(groupId) {
const graph = facade.getGraphData();
const group = graph.nodes.find((node) => node.id === groupId && node.type === "selectArea");
if (!group) return false;
const childIds = new Set([
...(group.children || []),
...graph.nodes.filter((node) => node.properties?.parentId === groupId).map((node) => node.id),
]);
graph.nodes = graph.nodes.filter((node) => node.id !== groupId);
graph.nodes.forEach((node) => {
if (!childIds.has(node.id)) return;
const properties = { ...(node.properties || {}) };
delete properties.parentId;
node.properties = properties;
});
facade.render(graph, {
resetHistory: false,
replaceNodes: true,
preserveNodeSizes: true,
preserveNodePositions: true,
});
nextTick(() => {
store.updateNodeInternals([...childIds]);
changed();
});
return true;
},
prepareLoopChildDrag(childId) { prepareLoopChildDrag(childId) {
const child = store.findNode(childId); const child = store.findNode(childId);
const group = child?.parentNode ? store.findNode(child.parentNode) : null; const group = child?.parentNode ? store.findNode(child.parentNode) : null;
@ -551,7 +700,7 @@ export const createFlowFacade = (store) => {
if (historyIndex <= 0) return false; if (historyIndex <= 0) return false;
historyIndex -= 1; historyIndex -= 1;
const snapshot = clone(history[historyIndex].graph); const snapshot = clone(history[historyIndex].graph);
facade.render(snapshot, { resetHistory: false }); facade.render(snapshot, { resetHistory: false, replaceNodes: true });
nextTick(() => emit("history:change", { data: facade.getGraphData() })); nextTick(() => emit("history:change", { data: facade.getGraphData() }));
return true; return true;
}, },
@ -559,7 +708,7 @@ export const createFlowFacade = (store) => {
if (historyIndex >= history.length - 1) return false; if (historyIndex >= history.length - 1) return false;
historyIndex += 1; historyIndex += 1;
const snapshot = clone(history[historyIndex].graph); const snapshot = clone(history[historyIndex].graph);
facade.render(snapshot, { resetHistory: false }); facade.render(snapshot, { resetHistory: false, replaceNodes: true });
nextTick(() => emit("history:change", { data: facade.getGraphData() })); nextTick(() => emit("history:change", { data: facade.getGraphData() }));
return true; return true;
}, },