diff --git a/frontend/src/pages/Workflow/Design/components/CustomEdge.tsx b/frontend/src/pages/Workflow/Design/components/CustomEdge.tsx index dfe02bbc..778e6330 100644 --- a/frontend/src/pages/Workflow/Design/components/CustomEdge.tsx +++ b/frontend/src/pages/Workflow/Design/components/CustomEdge.tsx @@ -1,11 +1,11 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { BaseEdge, EdgeLabelRenderer, EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react'; -import type { FlowNode } from '../types'; +import type { FlowNode, ControlPoint } from '../types'; import { convertToDisplayName } from '@/utils/workflow/variableConversion'; /** * 自定义边组件 - * 支持hover高亮和样式优化 + * 支持hover高亮、样式优化和拖动改变路径 */ const CustomEdge: React.FC = ({ id, @@ -19,28 +19,107 @@ const CustomEdge: React.FC = ({ markerEnd, label, selected, + data, }) => { const [isHovered, setIsHovered] = useState(false); - const { getNodes } = useReactFlow(); - + const [isDragging, setIsDragging] = useState(false); + const { getNodes, setEdges, screenToFlowPosition } = useReactFlow(); + + // 从 data 中获取自定义的控制点 + const customControlPoint = data?.controlPoint as ControlPoint | undefined; + // 将 label 中的 UUID 转换为节点名 const displayLabel = useMemo(() => { if (!label || typeof label !== 'string') { return label; } - + const allNodes = getNodes() as FlowNode[]; return convertToDisplayName(label, allNodes); }, [label, getNodes]); - const [edgePath, labelX, labelY] = getSmoothStepPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition, - }); + // 计算三个拐点(vertices):优先用数据中的 vertices;否则从旧的 controlPoint 推导;最后用默认1/4、1/2、3/4 + const vertices: ControlPoint[] = useMemo(() => { + const dataVertices = (data as any)?.vertices as ControlPoint[] | undefined; + if (Array.isArray(dataVertices) && dataVertices.length === 3) return dataVertices; + const cp = customControlPoint; + if (cp) { + return [ + { x: (sourceX + cp.x) / 2, y: (sourceY + cp.y) / 2 }, + { x: cp.x, y: cp.y }, + { x: (cp.x + targetX) / 2, y: (cp.y + targetY) / 2 }, + ]; + } + return [ + { x: sourceX + (targetX - sourceX) * 0.25, y: sourceY + (targetY - sourceY) * 0.25 }, + { x: (sourceX + targetX) / 2, y: (sourceY + targetY) / 2 }, + { x: sourceX + (targetX - sourceX) * 0.75, y: sourceY + (targetY - sourceY) * 0.75 }, + ]; + }, [data, customControlPoint, sourceX, sourceY, targetX, targetY]); + + // 若数据中缺少 vertices,则在首次渲染时写回,确保后续保存能持久化 + React.useEffect(() => { + const hasDataVertices = Array.isArray((data as any)?.vertices) && (data as any).vertices.length === 3; + if (!hasDataVertices) { + setEdges((eds) => eds.map(ed => ed.id === id ? { ...ed, data: { ...ed.data, vertices } } : ed)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 生成路径:使用 Catmull-Rom -> Cubic Bezier 插值,曲线严格经过拐点 + const [edgePath, labelX, labelY] = useMemo(() => { + // 拼接点序列:起点、3个拐点、终点 + const pts = [ + { x: sourceX, y: sourceY }, + vertices[0], + vertices[1], + vertices[2], + { x: targetX, y: targetY }, + ]; + // Catmull-Rom 转三次贝塞尔:C1 = P0 + (P1 - P_{-1})/6, C2 = P1 - (P2 - P0)/6 + const segs: string[] = []; + for (let i = 0; i < pts.length - 1; i++) { + const p_1 = i === 0 ? pts[i] : pts[i - 1]; + const p0 = pts[i]; + const p1 = pts[i + 1]; + const p2 = i + 2 < pts.length ? pts[i + 2] : p1; + const c1x = p0.x + (p1.x - p_1.x) / 6; + const c1y = p0.y + (p1.y - p_1.y) / 6; + const c2x = p1.x - (p2.x - p0.x) / 6; + const c2y = p1.y - (p2.y - p0.y) / 6; + segs.push(`C ${c1x},${c1y} ${c2x},${c2y} ${p1.x},${p1.y}`); + } + const path = `M ${pts[0].x},${pts[0].y} ${segs.join(' ')}`; + // 标签显示在第二个拐点 + return [path, vertices[1].x, vertices[1].y] as [string, number, number]; + }, [vertices, sourceX, sourceY, targetX, targetY]); + + // 拖动处理(支持第 idx 个拐点) + const handleVertexMouseDown = useCallback((idx: number, e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setIsDragging(true); + + const handleMove = (moveEvent: MouseEvent) => { + const flowPosition = screenToFlowPosition({ x: moveEvent.clientX, y: moveEvent.clientY }); + setEdges((eds) => eds.map(ed => { + if (ed.id !== id) return ed; + const old = (ed as any).data?.vertices as ControlPoint[] | undefined; + const base = Array.isArray(old) && old.length === 3 ? [...old] : vertices.slice(); + base[idx] = { x: flowPosition.x, y: flowPosition.y }; + return { ...ed, data: { ...ed.data, vertices: base } } as any; + })); + }; + + const handleUp = () => { + setIsDragging(false); + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleUp); + }; + + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleUp); + }, [id, setEdges, screenToFlowPosition, vertices]); // 根据状态确定样式 const edgeStyle = { @@ -74,11 +153,39 @@ const CustomEdge: React.FC = ({ fill="none" stroke="transparent" strokeWidth={20} - style={{ cursor: 'pointer' }} + style={{ cursor: isDragging ? 'grabbing' : 'grab' }} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} /> + {/* 控制点(三个拐点,直接在SVG中渲染,可拖动) */} + {(selected || isHovered) && ( + + {vertices.map((pt, idx) => ( + + handleVertexMouseDown(idx, e)} + /> + handleVertexMouseDown(idx, e)} + /> + + ))} + + )} + {/* 边标签 */} {displayLabel && ( diff --git a/frontend/src/pages/Workflow/Design/components/FlowCanvas.tsx b/frontend/src/pages/Workflow/Design/components/FlowCanvas.tsx index 09006542..964fc46d 100644 --- a/frontend/src/pages/Workflow/Design/components/FlowCanvas.tsx +++ b/frontend/src/pages/Workflow/Design/components/FlowCanvas.tsx @@ -47,6 +47,109 @@ const FlowCanvas: React.FC = ({ const [nodes, , onNodesStateChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesStateChange] = useEdgesState(initialEdges); + // --- Auto adjust edge vertices when nodes move (preserve shape, avoid excessive length) --- + useEffect(() => { + // helper: get node position by id + const getNodePos = (id: string) => { + const n = nodes.find((nn) => nn.id === id); + return n?.position; + }; + + // geometry helpers + const recomputeVertices = ( + oldVerts: Array<{ x: number; y: number }> | undefined, + oldA: { x: number; y: number } | undefined, + oldB: { x: number; y: number } | undefined, + newA: { x: number; y: number }, + newB: { x: number; y: number } + ) => { + // default three vertices placed along the baseline + const defaultThree = () => [ + { x: newA.x + (newB.x - newA.x) * 0.25, y: newA.y + (newB.y - newA.y) * 0.25 }, + { x: (newA.x + newB.x) / 2, y: (newA.y + newB.y) / 2 }, + { x: newA.x + (newB.x - newA.x) * 0.75, y: newA.y + (newB.y - newA.y) * 0.75 }, + ]; + + // if no old endpoints or old vertices, return default + if (!oldA || !oldB || !oldVerts || oldVerts.length !== 3) { + return defaultThree(); + } + + const projOnLine = (p: { x: number; y: number }, a: { x: number; y: number }, b: { x: number; y: number }) => { + const dx = b.x - a.x, dy = b.y - a.y; + const len2 = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2)); + const px = a.x + t * dx, py = a.y + t * dy; + const offx = p.x - px, offy = p.y - py; + // sign: which side of baseline (using cross product sign) + const cross = dx * (p.y - a.y) - dy * (p.x - a.x); + const sign = cross >= 0 ? 1 : -1; + const offMag = Math.hypot(offx, offy); + return { t, offMag, sign }; + }; + + const mapToNew = (t: number, offMag: number, sign: number, a: { x: number; y: number }, b: { x: number; y: number }) => { + const dx = b.x - a.x, dy = b.y - a.y; + const len = Math.hypot(dx, dy) || 1; + // unit normal for new baseline + const nx = -dy / len, ny = dx / len; + // clamp offset to avoid huge bulges (auto shrink if nodes far apart) + const maxOffset = Math.max(20, 0.35 * len); + const useOff = Math.min(offMag, maxOffset); + const baseX = a.x + t * dx, baseY = a.y + t * dy; + return { x: baseX + sign * useOff * nx, y: baseY + sign * useOff * ny }; + }; + + // Compute mapping (t, offset) from old baseline, then apply to new baseline + const tOff = oldVerts.map((v) => projOnLine(v, oldA, oldB)); + // enforce monotonic t order to keep vertex order stable + tOff.sort((a, b) => a.t - b.t); + const mapped = tOff.map((m) => mapToNew(m.t, m.offMag, m.sign, newA, newB)); + // ensure exactly 3 vertices + if (mapped.length === 3) return mapped as Array<{ x: number; y: number }>; + // fallback + return defaultThree(); + }; + + setEdges((eds) => { + return eds.map((ed) => { + const s = getNodePos(ed.source); + const t = getNodePos(ed.target); + if (!s || !t) return ed; + + const dataAny = (ed as any).data || {}; + const last = dataAny._lastEndpoints as { sx: number; sy: number; tx: number; ty: number } | undefined; + const current = { sx: s.x, sy: s.y, tx: t.x, ty: t.y }; + + // Initialize vertices if missing + const curVerts = (dataAny.vertices as any) as Array<{ x: number; y: number }> | undefined; + let vertices = curVerts; + let changed = false; + + if (!Array.isArray(vertices) || vertices.length !== 3) { + vertices = recomputeVertices(undefined, undefined, undefined, { x: current.sx, y: current.sy }, { x: current.tx, y: current.ty }); + changed = true; + } + + // Recompute when endpoints moved + if (!last || last.sx !== current.sx || last.sy !== current.sy || last.tx !== current.tx || last.ty !== current.ty) { + vertices = recomputeVertices(vertices, last ? { x: last.sx, y: last.sy } : undefined, last ? { x: last.tx, y: last.ty } : undefined, { x: current.sx, y: current.sy }, { x: current.tx, y: current.ty }); + changed = true; + } + + if (!changed) return ed; + return { + ...ed, + data: { + ...ed.data, + vertices, + _lastEndpoints: current, + }, + } as any; + }); + }); + }, [nodes, setEdges]); + // 处理连接 const onConnect = useCallback( (params: Connection | Edge) => { @@ -142,7 +245,7 @@ const FlowCanvas: React.FC = ({ // 处理拖拽放置 const handleDrop = useCallback((event: React.DragEvent) => { event.preventDefault(); - + if (onDrop) { onDrop(event); } @@ -152,7 +255,7 @@ const FlowCanvas: React.FC = ({ const handleDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; - + if (onDragOver) { onDragOver(event); } @@ -204,19 +307,19 @@ const FlowCanvas: React.FC = ({ elevateEdgesOnSelect style={{ width: '100%', height: '100%' }} > - - - = ({ borderRadius: '8px', }} /> - + {/* 状态面板 */}
{ // ⚠️ 不再设置默认值,边线初始状态为 undefined,由用户主动配置 const condition = edgeConfig.condition; const label = condition?.expression || edge.name || ''; - + // 从后端读取保存的拐点(支持多个) + const verticesArr = (edge as any).vertices as Array<{ x: number; y: number }> | undefined; + const vertices = Array.isArray(verticesArr) ? verticesArr.map(p => ({ x: p.x, y: p.y })) : undefined; + // 兼容旧数据:若有旧的单点,用作中间点 + const controlPoint = vertices && vertices.length > 0 ? undefined : (undefined as any); + return { id: edge.id, source: edge.from, // 后端使用from字段作为source @@ -99,7 +104,9 @@ export const useWorkflowLoad = () => { label, data: { label, - condition + condition, + vertices, + controlPoint } }; }); diff --git a/frontend/src/pages/Workflow/Design/hooks/useWorkflowSave.ts b/frontend/src/pages/Workflow/Design/hooks/useWorkflowSave.ts index 2dcff472..dd08dec9 100644 --- a/frontend/src/pages/Workflow/Design/hooks/useWorkflowSave.ts +++ b/frontend/src/pages/Workflow/Design/hooks/useWorkflowSave.ts @@ -107,7 +107,12 @@ export const useWorkflowSave = () => { // ⚠️ 并行网关的边线不传递条件配置(BPMN 规范不允许) condition: isFromParallelGateway ? undefined : edge.data?.condition }, - vertices: [] // 暂时为空数组 + // 优先保存多个拐点;若无则回退到单个控制点;否则为空 + vertices: Array.isArray((edge as any)?.data?.vertices) && (edge as any).data.vertices.length > 0 + ? (edge as any).data.vertices.map((p: any) => ({ x: Math.round(p.x), y: Math.round(p.y) })) + : ((edge as any)?.data?.controlPoint + ? [{ x: Math.round((edge as any).data.controlPoint.x), y: Math.round((edge as any).data.controlPoint.y) }] + : []) }; }) }; diff --git a/frontend/src/pages/Workflow/Design/types.ts b/frontend/src/pages/Workflow/Design/types.ts index fd6c46e4..a49abc33 100644 --- a/frontend/src/pages/Workflow/Design/types.ts +++ b/frontend/src/pages/Workflow/Design/types.ts @@ -20,6 +20,12 @@ export interface FlowNodeData extends Record { nodeDefinition?: import('./nodes/types').WorkflowNodeDefinition; } +// 控制点类型(用于可拖动的连接线) +export interface ControlPoint { + x: number; + y: number; +} + // React Flow 边数据 - 添加索引签名以满足React Flow的类型约束 export interface FlowEdgeData extends Record { label?: string; @@ -29,6 +35,10 @@ export interface FlowEdgeData extends Record { script?: string; priority: number; }; + // 兼容旧数据:单个控制点 + controlPoint?: ControlPoint; + // 新增:多个拐点(当前需求为3个) + vertices?: ControlPoint[]; } // 扩展的React Flow节点类型