Browse Source

perf: 优化节点内容

jiaxing.liao 1 week ago
parent
commit
63cac299db

+ 1 - 1
apps/web/src/features/RunWorkflow/components/InputTab.vue

@@ -123,7 +123,7 @@ const getSelectPlaceholder = (label?: string) =>
 			</el-form-item>
 		</el-form>
 
-		<div v-if="showActionBar !== false" class="action-bar">
+		<div v-if="showActionBar" class="action-bar">
 			<el-button type="primary" class="run-button" :loading="isRunning" @click="$emit('run')">
 				{{ t('pages.runWorkflow.inputPanel.run') }}
 			</el-button>

+ 1 - 0
apps/web/src/features/RunWorkflow/index.vue

@@ -494,6 +494,7 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 							:json-drafts="jsonDrafts"
 							:validation-errors="validationErrors"
 							:is-running="isRunning"
+							show-action-bar
 							@run="handleRunWorkflow"
 						/>
 					</el-tab-pane>

+ 14 - 1
apps/web/src/views/editor/NodeView.vue

@@ -74,13 +74,15 @@
 		v-if="libaryRefferenceRef"
 		:visible="showNodeLibary"
 		trigger="manual"
+		placement="bottom-start"
 		:show-arrow="false"
 		:append-to="workflowWrapperRef"
 		:virtual-ref="libaryRefferenceRef"
+		:popper-options="nodeLibaryPopperOptions"
 		width="360px"
 		virtual-triggering
 	>
-		<div ref="nodeLibraryPanelRef">
+		<div ref="nodeLibraryPanelRef" class="node-library-popover-panel">
 			<NodeLibary
 				@add-node="handleNodeCreateFromLibrary"
 				:parent-node-type="nodeLibaryParentType"
@@ -185,6 +187,12 @@ const showNodeLibary = ref(false)
 const libaryRefferenceRef = ref<HTMLElement>()
 const nodeLibaryPopoverAnchorRef = ref<HTMLElement>()
 const nodeLibraryPanelRef = ref<HTMLElement>()
+const nodeLibaryPopperOptions = {
+	modifiers: [
+		{ name: 'flip', enabled: false },
+		{ name: 'preventOverflow', enabled: false }
+	]
+}
 const contextMenuRef = ref<HTMLElement>()
 const contextMenuVisible = ref(false)
 const contextMenuPosition = ref({ x: 0, y: 0 })
@@ -1415,6 +1423,11 @@ onMounted(() => {
 </script>
 
 <style scoped lang="less">
+.node-library-popover-panel {
+	max-height: min(520px, calc(100vh - 48px));
+	overflow-y: auto;
+}
+
 .workflow-context-menu {
 	position: fixed;
 	z-index: 3000;

+ 98 - 39
packages/workflow/src/components/elements/edges/CanvasEdge.vue

@@ -1,15 +1,11 @@
 <script setup lang="ts">
 import { ref, watch, computed } from 'vue'
-import {
-	BaseEdge,
-	EdgeLabelRenderer,
-	getBezierPath,
-	type EdgeProps,
-	type Connection
-} from '@vue-flow/core'
+import { BaseEdge, EdgeLabelRenderer, type EdgeProps, type Connection } from '@vue-flow/core'
 import { IconButton } from '@repo/ui'
 import type { CanvasExecutionStatus } from '../../../Interface'
 import { getExecutionStatusColor } from '../../../utils/execution-status'
+import { getEdgeRenderData } from './utils/getEdgeRenderData'
+import { useVueFlowContext } from '../../../hooks/useVueFlowContext'
 
 defineOptions({
 	inheritAttrs: false
@@ -23,28 +19,64 @@ const emit = defineEmits<{
 type CanvasEdgeProps = EdgeProps & {
 	readOnly?: boolean
 	hovered?: boolean
-	bringToFront?: boolean // Determines if entire edges layer should be brought to front
+	bringToFront?: boolean
 }
 
 const props = defineProps<CanvasEdgeProps>()
-const path = computed(() => getBezierPath(props))
+const { vueFlow } = useVueFlowContext()
+
+// 缩放补偿:工具栏大小随缩放调整,保持视觉一致
+const zoom = computed(() => vueFlow.viewport.value.zoom)
+const zoomCompensationFactor = computed(() => 1 / zoom.value)
+const EDGE_STROKE_WIDTH = '1.5px'
+
+// 执行状态
 const executionStatus = computed<CanvasExecutionStatus>(
 	() => (props.data?.executionStatus as CanvasExecutionStatus) || 'idle'
 )
 const isPendingEdge = computed(() => !!props.data?.pending)
-const edgeStyle = computed(() => {
+const isRunningEdge = computed(() => executionStatus.value === 'running')
+
+// 边线路径数据(含反向连接绕行处理)
+const renderData = computed(() =>
+	getEdgeRenderData({
+		sourceX: props.sourceX,
+		sourceY: props.sourceY,
+		sourcePosition: props.sourcePosition,
+		targetX: props.targetX,
+		targetY: props.targetY,
+		targetPosition: props.targetPosition
+	})
+)
+
+const segments = computed(() => renderData.value.segments)
+const labelPosition = computed(() => renderData.value.labelPosition)
+
+// CSS 变量驱动的颜色,便于暗黑模式适配
+const edgeColor = computed(() => {
+	if (isPendingEdge.value) return '#409EFF'
+	return getExecutionStatusColor(executionStatus.value)
+})
+
+const edgeStyles = computed(() => {
 	const status = executionStatus.value
-	const color = getExecutionStatusColor(status)
-
-	return {
-		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, opacity 180ms ease'
+	const styles: Record<string, string> = {
+		'--canvas-edge-color': edgeColor.value,
+		'--canvas-edge-stroke-width': EDGE_STROKE_WIDTH
+	}
+	if (status !== 'idle' && !isPendingEdge.value) {
+		styles['--canvas-edge-filter'] = `drop-shadow(0 0 6px ${edgeColor.value}55)`
 	}
+	return styles
 })
 
+const edgeClasses = computed(() => ({
+	'canvas-edge-path': true,
+	'is-pending': isPendingEdge.value,
+	'is-running': isRunningEdge.value
+}))
+
+// 延迟 hover(避免鼠标快速划过时闪烁)
 const delayedHovered = ref(props.hovered)
 const delayedHoveredSetTimeoutRef = ref<ReturnType<typeof setTimeout> | null>(null)
 const delayedHoveredTimeout = 600
@@ -74,33 +106,30 @@ watch(
 
 const renderToolbar = computed(() => delayedHovered.value && !props.readOnly)
 
-const onAdd = () => {
-	emit('add', connection.value)
-}
+const toolbarStyle = computed(() => ({
+	pointerEvents: 'all' as const,
+	position: 'absolute' as const,
+	transform: `translate(-50%, -50%) translate(${labelPosition.value[0]}px, ${labelPosition.value[1]}px) scale(${zoomCompensationFactor.value})`
+}))
 
-const onDelete = () => {
-	emit('delete', connection.value)
-}
+const onAdd = () => emit('add', connection.value)
+const onDelete = () => emit('delete', connection.value)
 </script>
 
 <template>
+	<!-- 多段路径(反向连接时会有两段) -->
 	<BaseEdge
-		:path="path[0]"
-		:interaction-width="40"
+		v-for="(segment, index) in segments"
+		:key="`${id}-${index}`"
+		:path="segment[0]"
 		:marker-end="markerEnd"
-		:style="edgeStyle"
-		:class="['canvas-edge-path', { 'is-pending': isPendingEdge }]"
+		:interaction-width="40"
+		:style="edgeStyles"
+		:class="edgeClasses"
 	/>
 
 	<EdgeLabelRenderer>
-		<div
-			:style="{
-				pointerEvents: 'all',
-				position: 'absolute',
-				transform: `translate(-50%, -50%) translate(${path[1]}px,${path[2]}px)`
-			}"
-			class="nodrag nopan"
-		>
+		<div :style="toolbarStyle" class="nodrag nopan">
 			<div v-if="isPendingEdge" class="text-sm text-gray-500">pendding...</div>
 			<div v-if="renderToolbar && !isPendingEdge" class="flex">
 				<IconButton :edge-add-btn="id" icon="lucide:plus" size="small" square @click="onAdd" />
@@ -111,6 +140,32 @@ const onDelete = () => {
 </template>
 
 <style>
+/* ── 基础边线样式 ── */
+.canvas-edge-path {
+	stroke: var(--canvas-edge-color, #b1b1b7);
+	stroke-width: var(--canvas-edge-stroke-width, 2px);
+	stroke-linecap: round;
+	filter: var(--canvas-edge-filter, none);
+	transition:
+		stroke 180ms ease,
+		stroke-width 180ms ease,
+		filter 180ms ease,
+		opacity 180ms ease;
+}
+
+/* ── Pending: 虚线流动 ── */
+.canvas-edge-path.is-pending {
+	opacity: 0.7;
+	stroke-dasharray: 8 6;
+	animation: edge-loading-dash 3.6s linear infinite;
+}
+
+/* ── Running: 快速流动虚线 ── */
+.canvas-edge-path.is-running {
+	stroke-dasharray: 6 4;
+	animation: edge-running-dash 1s linear infinite;
+}
+
 @keyframes edge-loading-dash {
 	from {
 		stroke-dashoffset: 0;
@@ -120,8 +175,12 @@ const onDelete = () => {
 	}
 }
 
-.canvas-edge-path.is-pending {
-	stroke-dasharray: 8 6;
-	animation: edge-loading-dash 3.6s linear infinite;
+@keyframes edge-running-dash {
+	from {
+		stroke-dashoffset: 0;
+	}
+	to {
+		stroke-dashoffset: -20;
+	}
 }
 </style>

+ 67 - 0
packages/workflow/src/components/elements/edges/utils/getEdgeRenderData.ts

@@ -0,0 +1,67 @@
+import { getBezierPath, getSmoothStepPath, Position, type EdgeProps } from '@vue-flow/core'
+
+const EDGE_PADDING_BOTTOM = 130
+const EDGE_PADDING_X = 40
+const EDGE_BORDER_RADIUS = 16
+const HANDLE_SIZE = 20
+
+const isRightOfSourceHandle = (sourceX: number, targetX: number) =>
+	sourceX - HANDLE_SIZE > targetX
+
+export type EdgeRenderData = {
+	segments: ReturnType<typeof getBezierPath>[]
+	labelPosition: [number, number]
+	isConnectorStraight: boolean
+}
+
+export function getEdgeRenderData(
+	props: Pick<
+		EdgeProps,
+		'sourceX' | 'sourceY' | 'sourcePosition' | 'targetX' | 'targetY' | 'targetPosition'
+	>
+): EdgeRenderData {
+	const { targetX, targetY, sourceX, sourceY, sourcePosition, targetPosition } = props
+	const isConnectorStraight = sourceY === targetY
+
+	// 正向连接或连接不在源节点右侧 → 使用 Bezier
+	if (!isRightOfSourceHandle(sourceX, targetX)) {
+		const segment = getBezierPath(props)
+		return {
+			segments: [segment],
+			labelPosition: [segment[1], segment[2]],
+			isConnectorStraight
+		}
+	}
+
+	// 反向连接(目标在源左侧)→ 两段 SmoothStep 绕行
+	const midX = (sourceX + targetX) / 2
+	const midY = sourceY + EDGE_PADDING_BOTTOM
+
+	const firstSegment = getSmoothStepPath({
+		sourceX,
+		sourceY,
+		targetX: midX,
+		targetY: midY,
+		sourcePosition,
+		targetPosition: Position.Right,
+		borderRadius: EDGE_BORDER_RADIUS,
+		offset: EDGE_PADDING_X
+	})
+
+	const secondSegment = getSmoothStepPath({
+		sourceX: midX,
+		sourceY: midY,
+		targetX,
+		targetY,
+		sourcePosition: Position.Left,
+		targetPosition,
+		borderRadius: EDGE_BORDER_RADIUS,
+		offset: EDGE_PADDING_X
+	})
+
+	return {
+		segments: [firstSegment, secondSegment],
+		labelPosition: [midX, midY],
+		isConnectorStraight
+	}
+}

+ 72 - 67
packages/workflow/src/components/elements/nodes/CanvasNode.vue

@@ -238,47 +238,43 @@ provideCanvasNodeContext({
 	isolation: isolate;
 }
 
+/* ── Running: 旋转圆锥渐变边框 ── */
 .canvas-node-status-shell[data-execution-status='running'] {
-	border-color: #3b82f6 !important;
-	box-shadow:
-		0 0 0 1px rgb(59 130 246 / 20%),
-		0 0 18px rgb(59 130 246 / 22%);
-	animation: canvas-node-status-pulse 1.8s ease-in-out infinite;
+	border-color: transparent !important;
+	isolation: isolate;
 }
 
-.canvas-node-status-shell[data-execution-status='running']::before {
+.canvas-node-status-shell[data-execution-status='running']::after {
 	content: '';
 	position: absolute;
-	top: 0;
-	left: 8%;
-	width: 28%;
-	height: 3px;
-	border-radius: 999px;
-	background: linear-gradient(
-		90deg,
-		rgb(59 130 246 / 0%),
-		rgb(147 197 253 / 95%),
-		rgb(59 130 246 / 0%)
+	inset: -3px;
+	border-radius: 11px;
+	z-index: -1;
+	background: conic-gradient(
+		from var(--node-gradient-angle),
+		rgb(59 130 246 / 1),
+		rgb(59 130 246 / 1) 20%,
+		rgb(59 130 246 / 0.2) 35%,
+		rgb(59 130 246 / 0.2) 65%,
+		rgb(59 130 246 / 1) 90%,
+		rgb(59 130 246 / 1)
 	);
-	box-shadow: 0 0 10px rgb(59 130 246 / 45%);
-	pointer-events: none;
-	animation: canvas-node-status-scan 1.6s ease-in-out infinite;
+	animation: canvas-node-border-rotate 1.5s linear infinite;
 }
 
-.canvas-node-status-shell[data-execution-status='running']::after {
-	content: '';
-	position: absolute;
-	inset: 0;
-	border-radius: inherit;
-	background: linear-gradient(
-		120deg,
-		rgb(255 255 255 / 0%),
-		rgb(255 255 255 / 10%),
-		rgb(255 255 255 / 0%)
-	);
-	opacity: 0.65;
-	pointer-events: none;
-	animation: canvas-node-status-sheen 2.2s ease-in-out infinite;
+/* 起始节点(无输入,左侧 36px 圆角)*/
+.canvas-node-status-shell.rounded-l-36px[data-execution-status='running']::after {
+	border-radius: 39px 11px 11px 39px;
+}
+
+/* 结束节点(无输出,右侧 36px 圆角)*/
+.canvas-node-status-shell.rounded-r-36px[data-execution-status='running']::after {
+	border-radius: 11px 39px 39px 11px;
+}
+
+/* 孤立节点(无输入且无输出,完全药丸形)*/
+.canvas-node-status-shell.rounded-l-36px.rounded-r-36px[data-execution-status='running']::after {
+	border-radius: 39px;
 }
 
 .canvas-node-status-shell[data-execution-status='success'] {
@@ -289,11 +285,43 @@ provideCanvasNodeContext({
 	border-color: #f59e0b !important;
 }
 
+/* ── Suspended: 慢速旋转渐变边框(等待状态)── */
 .canvas-node-status-shell[data-execution-status='suspended'] {
-	border-color: #64748b !important;
-	box-shadow:
-		0 0 0 1px rgb(100 116 139 / 20%),
-		0 0 14px rgb(100 116 139 / 22%);
+	border-color: transparent !important;
+	isolation: isolate;
+}
+
+.canvas-node-status-shell[data-execution-status='suspended']::after {
+	content: '';
+	position: absolute;
+	inset: -3px;
+	border-radius: 11px;
+	z-index: -1;
+	background: conic-gradient(
+		from var(--node-gradient-angle),
+		rgb(100 116 139 / 1),
+		rgb(100 116 139 / 1) 20%,
+		rgb(100 116 139 / 0.2) 35%,
+		rgb(100 116 139 / 0.2) 65%,
+		rgb(100 116 139 / 1) 90%,
+		rgb(100 116 139 / 1)
+	);
+	animation: canvas-node-border-rotate 4.5s linear infinite;
+}
+
+/* 起始节点(无输入,左侧 36px 圆角)*/
+.canvas-node-status-shell.rounded-l-36px[data-execution-status='suspended']::after {
+	border-radius: 39px 11px 11px 39px;
+}
+
+/* 结束节点(无输出,右侧 36px 圆角)*/
+.canvas-node-status-shell.rounded-r-36px[data-execution-status='suspended']::after {
+	border-radius: 11px 39px 39px 11px;
+}
+
+/* 孤立节点(无输入且无输出,完全药丸形)*/
+.canvas-node-status-shell.rounded-l-36px.rounded-r-36px[data-execution-status='suspended']::after {
+	border-radius: 39px;
 }
 
 .canvas-node-status-shell[data-pending='true'] {
@@ -322,41 +350,18 @@ provideCanvasNodeContext({
 	animation: canvas-node-pending-dash-flow 8s linear infinite;
 }
 
-@keyframes canvas-node-status-pulse {
-	0%,
-	100% {
-		box-shadow:
-			0 0 0 1px rgb(59 130 246 / 18%),
-			0 0 12px rgb(59 130 246 / 14%);
-	}
-
-	50% {
-		box-shadow:
-			0 0 0 1px rgb(59 130 246 / 26%),
-			0 0 22px rgb(59 130 246 / 26%);
-	}
+@property --node-gradient-angle {
+	syntax: '<angle>';
+	initial-value: 0deg;
+	inherits: false;
 }
 
-@keyframes canvas-node-status-scan {
+@keyframes canvas-node-border-rotate {
 	from {
-		transform: translateX(-10%);
-		opacity: 0.35;
+		--node-gradient-angle: 0deg;
 	}
-
 	to {
-		transform: translateX(250%);
-		opacity: 1;
-	}
-}
-
-@keyframes canvas-node-status-sheen {
-	0%,
-	100% {
-		opacity: 0.2;
-	}
-
-	50% {
-		opacity: 0.7;
+		--node-gradient-angle: 360deg;
 	}
 }
 

+ 4 - 1
packages/workflow/src/components/elements/nodes/render-types/NodeDefault.vue

@@ -62,7 +62,10 @@ const isImageIcon = computed(() => resolvedIcon.value.startsWith('data:image/'))
 		:class="nodeClass"
 		class="canvas-node-status-shell default-node w-full h-full bg-#fff box-border border-1.5px border-solid border-#dcdcdc rounded-8px relative"
 	>
-		<div className="w-full h-full relative flex items-center justify-center">
+		<div
+			:class="nodeClass"
+			class="w-full h-full relative flex items-center justify-center bg-#fff rounded-8px"
+		>
 			<div
 				class="relative flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg"
 				:style="{ background: nodeType?.iconColor }"