Ver código fonte

pref: 优化节点连接

jiaxing.liao 1 mês atrás
pai
commit
ac6baa89a8

+ 47 - 47
apps/web/src/i18n/locales/zh-cn.ts

@@ -50,53 +50,6 @@ export default {
 			title: '提示',
 			message: '确定要删除吗?'
 		},
-		httpSetter: {
-			urlRequired: '请输入 URL'
-		},
-		scheduleSetter: {
-			title: '定时触发',
-			modeCron: '使用 Cron 表达式',
-			modeVisual: '使用可视化配置',
-			frequency: '频率',
-			minute: '分钟',
-			time: '时间',
-			weekday: '星期',
-			monthDay: '天',
-			lastDay: '最后一天',
-			lastDayTip: '按每个月的自然月最后一天执行',
-			nextRuns: '接下来 5 次执行时间',
-			previewEmpty: '当前配置暂时无法推导执行时间',
-			cronLabel: 'Cron 表达式',
-			cronPlaceholder: '例如:0 0 0 * * ? *',
-			cronSupport: '支持 5 到 7 段 Cron 表达式',
-			cronUsing: '将按输入的 Cron 表达式触发',
-			cronInvalid: 'Cron 表达式格式不正确,请输入 5 到 7 段',
-			hourly: '每小时',
-			daily: '每日',
-			weekly: '每周',
-			monthly: '每月'
-		},
-		listSetter: {
-			inputVariable: '输入变量',
-			selectInputVariable: '请选择输入变量',
-			filterConditions: '过滤条件',
-			insertVariable: "键入 '/' 键快速插入变量",
-			takeNth: '取第 N 项',
-			takeFirstN: '取前 N 项',
-			sort: '排序',
-			ascending: '升序',
-			descending: '降序',
-			fileFieldId: 'ID',
-			fileFieldName: '名称',
-			fileFieldExtensionName: '扩展名',
-			fileFieldSize: '大小',
-			fileFieldPath: '路径'
-		},
-		conditionSetter: {
-			casePrefix: '条件_',
-			delete: '删除',
-			elseDescription: '用于定义当所有条件都不满足时的处理逻辑'
-		},
 		nodeBase: {
 			retryConfig: {
 				title: '失败时重试',
@@ -1383,6 +1336,53 @@ export default {
 			emptySuffix: '为空',
 			copiedSuffix: '已复制',
 			copyFailedSuffix: '复制失败'
+		},
+		httpSetter: {
+			urlRequired: '请输入 URL'
+		},
+		scheduleSetter: {
+			title: '定时触发',
+			modeCron: '使用 Cron 表达式',
+			modeVisual: '使用可视化配置',
+			frequency: '频率',
+			minute: '分钟',
+			time: '时间',
+			weekday: '星期',
+			monthDay: '天',
+			lastDay: '最后一天',
+			lastDayTip: '按每个月的自然月最后一天执行',
+			nextRuns: '接下来 5 次执行时间',
+			previewEmpty: '当前配置暂时无法推导执行时间',
+			cronLabel: 'Cron 表达式',
+			cronPlaceholder: '例如:0 0 0 * * ? *',
+			cronSupport: '支持 5 到 7 段 Cron 表达式',
+			cronUsing: '将按输入的 Cron 表达式触发',
+			cronInvalid: 'Cron 表达式格式不正确,请输入 5 到 7 段',
+			hourly: '每小时',
+			daily: '每日',
+			weekly: '每周',
+			monthly: '每月'
+		},
+		listSetter: {
+			inputVariable: '输入变量',
+			selectInputVariable: '请选择输入变量',
+			filterConditions: '过滤条件',
+			insertVariable: "键入 '/' 键快速插入变量",
+			takeNth: '取第 N 项',
+			takeFirstN: '取前 N 项',
+			sort: '排序',
+			ascending: '升序',
+			descending: '降序',
+			fileFieldId: 'ID',
+			fileFieldName: '名称',
+			fileFieldExtensionName: '扩展名',
+			fileFieldSize: '大小',
+			fileFieldPath: '路径'
+		},
+		conditionSetter: {
+			casePrefix: '条件_',
+			delete: '删除',
+			elseDescription: '用于定义当所有条件都不满足时的处理逻辑'
 		}
 	},
 	nodes: {

+ 42 - 2
apps/web/src/views/editor/NodeView.vue

@@ -147,6 +147,9 @@ const peddingHandlePayload = ref<{
 
 const runningStatusStartedAt = new Map<string, number>()
 const pendingNodeStatusTimers = new Map<string, number>()
+const pendingEdges = ref<
+	Array<Connection & { id: string; type?: string; data?: Record<string, unknown> }>
+>([])
 
 const removeNodeLibaryPopoverAnchor = () => {
 	nodeLibaryPopoverAnchorRef.value?.remove()
@@ -276,9 +279,17 @@ const workflowWithExecutionState = computed(() => {
 		}
 	})
 
+	const stableEdgeKeys = new Set(
+		(baseWorkflow.edges || []).map((edge) => `${edge.source}->${edge.target}->${edge.sourceHandle || ''}`)
+	)
+	const displayPendingEdges = pendingEdges.value.filter(
+		(edge) => !stableEdgeKeys.has(`${edge.source}->${edge.target}->${edge.sourceHandle || ''}`)
+	)
+
 	return {
 		...baseWorkflow,
-		nodes
+		nodes,
+		edges: [...(baseWorkflow.edges || []), ...displayPendingEdges]
 	} as IWorkflow
 })
 
@@ -677,6 +688,7 @@ const handleDrop = (position: XYPosition, event: DragEvent) => {
 const onCreateConnection = async (connection: Connection) => {
 	const { sourceHandle } = connection
 	const { source, target } = normalizeConnectionEndpoints(connection)
+	const edgeKey = `${source}->${target}->${sourceHandle || ''}`
 
 	const params: {
 		appAgentId: string
@@ -695,7 +707,30 @@ const onCreateConnection = async (connection: Connection) => {
 		params.sourceHandle = sourceHandle
 	}
 
-	if (!props.workflow?.edges.some((edge) => edge.source === source && edge.target === target)) {
+	const existsInWorkflow = props.workflow?.edges.some(
+		(edge) => `${edge.source}->${edge.target}->${edge.sourceHandle || ''}` === edgeKey
+	)
+	const existsInPending = pendingEdges.value.some(
+		(edge) => `${edge.source}->${edge.target}->${edge.sourceHandle || ''}` === edgeKey
+	)
+	if (existsInWorkflow || existsInPending) {
+		return
+	}
+
+	const pendingId = `pending-edge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+	pendingEdges.value.push({
+		id: pendingId,
+		type: 'canvas-edge',
+		source,
+		target,
+		sourceHandle: params.sourceHandle,
+		targetHandle: connection.targetHandle,
+		data: {
+			pending: true
+		}
+	})
+
+	try {
 		const response = await agent.postAgentDoNewEdge(params)
 
 		if (
@@ -707,6 +742,11 @@ const onCreateConnection = async (connection: Connection) => {
 		) {
 			await props.reloadWorkflow(props.workflow.id)
 		}
+	} catch (error) {
+		console.error('postAgentDoNewEdge error', error)
+		ElMessage.error(t('pages.nodeView.messages.createEdgeFailed'))
+	} finally {
+		pendingEdges.value = pendingEdges.value.filter((edge) => edge.id !== pendingId)
 	}
 }
 

+ 29 - 4
packages/workflow/src/components/elements/edges/CanvasEdge.vue

@@ -31,15 +31,17 @@ const path = computed(() => getBezierPath(props))
 const executionStatus = computed<CanvasExecutionStatus>(
 	() => (props.data?.executionStatus as CanvasExecutionStatus) || 'idle'
 )
+const isPendingEdge = computed(() => !!props.data?.pending)
 const edgeStyle = computed(() => {
 	const status = executionStatus.value
 	const color = getExecutionStatusColor(status)
 
 	return {
-		stroke: color,
+		stroke: isPendingEdge.value ? '#409EFF' : color,
 		strokeWidth: 1.6,
+		opacity: isPendingEdge.value ? 0.7 : 1,
 		filter: status === 'idle' ? 'none' : `drop-shadow(0 0 8px ${color}55)`,
-		transition: 'stroke 180ms ease, stroke-width 180ms ease, filter 180ms ease'
+		transition: 'stroke 180ms ease, stroke-width 180ms ease, filter 180ms ease, opacity 180ms ease'
 	}
 })
 
@@ -82,7 +84,13 @@ const onDelete = () => {
 </script>
 
 <template>
-	<BaseEdge :path="path[0]" :interaction-width="40" :marker-end="markerEnd" :style="edgeStyle" />
+	<BaseEdge
+		:path="path[0]"
+		:interaction-width="40"
+		:marker-end="markerEnd"
+		:style="edgeStyle"
+		:class="['canvas-edge-path', { 'is-pending': isPendingEdge }]"
+	/>
 
 	<EdgeLabelRenderer>
 		<div
@@ -93,10 +101,27 @@ const onDelete = () => {
 			}"
 			class="nodrag nopan"
 		>
-			<div v-if="renderToolbar" class="flex">
+			<div v-if="isPendingEdge" class="text-sm text-gray-500">loading...</div>
+			<div v-if="renderToolbar && !isPendingEdge" class="flex">
 				<IconButton :edge-add-btn="id" icon="lucide:plus" size="small" square @click="onAdd" />
 				<IconButton icon="lucide:brush-cleaning" size="small" square @click="onDelete" />
 			</div>
 		</div>
 	</EdgeLabelRenderer>
 </template>
+
+<style>
+@keyframes edge-loading-dash {
+	from {
+		stroke-dashoffset: 0;
+	}
+	to {
+		stroke-dashoffset: -28;
+	}
+}
+
+.canvas-edge-path.is-pending {
+	stroke-dasharray: 8 6;
+	animation: edge-loading-dash 900ms linear infinite;
+}
+</style>