![image.png]()
// <free_field_name>关系流程图编辑器</free_field_name>
// <file_name>FlowRelationEditor_v3.jsx</file_name>
function FlowRelationEditor({ value, currentControl, formData, onChange }) {
const canvasRef = useRef(null);
const dragRef = useRef(null);
const connectRef = useRef(null);
const idRef = useRef(1);
const createNodeId = () => `node_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const createEdgeId = () => `edge_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const createNode = (name, x, y) => ({
id: createNodeId(),
name: name || `节点${idRef.current++}`,
x: typeof x === "number" ? x : 80,
y: typeof y === "number" ? y : 80,
width: 110,
height: 44,
});
const normalizeNode = (n, index) => ({
id: n?.id || `node_restore_${index}_${Date.now()}`,
name: n?.name || `节点${index + 1}`,
x: typeof n?.x === "number" ? n.x : 80 + (index % 4) * 140,
y: typeof n?.y === "number" ? n.y : 80 + Math.floor(index / 4) * 90,
width: typeof n?.width === "number" ? n.width : 110,
height: typeof n?.height === "number" ? n.height : 44,
});
const normalizeEdge = (e, index) => ({
id: e?.id || `edge_restore_${index}_${Date.now()}`,
from: e?.from || "",
to: e?.to || "",
label: e?.label || "",
});
const buildDefaultData = () => {
const nodeA = createNode("A", 80, 100);
const nodeB = createNode("B", 320, 80);
const nodeC = createNode("C", 320, 180);
return {
nodes: [nodeA, nodeB, nodeC],
edges: [
{ id: createEdgeId(), from: "A", to: "B", label: "10%" },
{ id: createEdgeId(), from: "A", to: "C", label: "15%" },
],
};
};
const parseValue = (raw) => {
if (!raw || typeof raw !== "string") return buildDefaultData();
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const nameSet = new Set();
parsed.forEach((item) => {
if (item?.from) nameSet.add(item.from);
if (item?.to) nameSet.add(item.to);
});
const names = Array.from(nameSet);
const nodes = names.map((name, index) =>
normalizeNode(
{
id: `node_init_${index}`,
name,
x: 80 + (index % 4) * 160,
y: 80 + Math.floor(index / 4) * 100,
width: 110,
height: 44,
},
index
)
);
const edges = parsed.map((item, index) =>
normalizeEdge(
{
id: `edge_init_${index}`,
from: item?.from || "",
to: item?.to || "",
label: item?.label || "",
},
index
)
);
return { nodes, edges };
}
if (parsed && typeof parsed === "object") {
const nodes = Array.isArray(parsed?.nodes) ? parsed.nodes.map(normalizeNode) : [];
const edges = Array.isArray(parsed?.edges) ? parsed.edges.map(normalizeEdge) : [];
if (nodes.length || edges.length) {
return { nodes, edges };
}
}
} catch (e) {}
return buildDefaultData();
};
const initialData = parseValue(value);
const [nodes, setNodes] = useState(initialData.nodes);
const [edges, setEdges] = useState(initialData.edges);
const [selectedNodeId, setSelectedNodeId] = useState(initialData.nodes?.[0]?.id || "");
const [selectedEdgeId, setSelectedEdgeId] = useState("");
const [draftNodeName, setDraftNodeName] = useState("");
const [draftEdgeLabel, setDraftEdgeLabel] = useState("");
const [isNodeComposing, setIsNodeComposing] = useState(false);
const [isEdgeComposing, setIsEdgeComposing] = useState(false);
const [hoverNodeId, setHoverNodeId] = useState("");
const [connectingLine, setConnectingLine] = useState(null);
useEffect(() => {
const parsed = parseValue(value);
setNodes(parsed.nodes);
setEdges(parsed.edges);
setSelectedNodeId((prev) => (parsed.nodes.some((n) => n.id === prev) ? prev : parsed.nodes?.[0]?.id || ""));
setSelectedEdgeId((prev) => (parsed.edges.some((e) => e.id === prev) ? prev : ""));
}, [value]);
const exportRelations = (nextNodes, nextEdges) => {
const validNames = new Set((nextNodes || []).map((n) => n.name).filter(Boolean));
return (nextEdges || [])
.filter((e) => e?.from && e?.to && validNames.has(e.from) && validNames.has(e.to))
.map((e) => ({
from: e.from,
to: e.to,
label: e.label || "",
}));
};
const emitChange = (nextNodes, nextEdges) => {
if (typeof onChange !== "function") return;
onChange(
JSON.stringify({
nodes: (nextNodes || []).map((n) => ({
id: n.id,
name: n.name,
x: n.x,
y: n.y,
width: n.width,
height: n.height,
})),
edges: (nextEdges || []).map((e) => ({
id: e.id,
from: e.from,
to: e.to,
label: e.label || "",
})),
relations: exportRelations(nextNodes, nextEdges),
})
);
};
const getNodeById = (id) => nodes.find((n) => n.id === id);
const getNodeByName = (name) => nodes.find((n) => n.name === name);
useEffect(() => {
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
setDraftNodeName(selectedNode?.name || "");
}, [selectedNodeId, nodes]);
useEffect(() => {
const selectedEdge = edges.find((e) => e.id === selectedEdgeId);
setDraftEdgeLabel(selectedEdge?.label || "");
}, [selectedEdgeId, edges]);
const addNode = () => {
const count = nodes.length + 1;
let name = `节点${count}`;
if (nodes.some((n) => n.name === name)) {
name = `节点${Date.now().toString().slice(-4)}`;
}
const newNode = createNode(name, 80 + (nodes.length % 4) * 140, 80 + Math.floor(nodes.length / 4) * 90);
const nextNodes = [...nodes, newNode];
setNodes(nextNodes);
setSelectedNodeId(newNode.id);
setSelectedEdgeId("");
emitChange(nextNodes, edges);
};
const deleteNode = (nodeId) => {
const target = getNodeById(nodeId);
if (!target) return;
const nextNodes = nodes.filter((n) => n.id !== nodeId);
const nextEdges = edges.filter((e) => e.from !== target.name && e.to !== target.name);
setNodes(nextNodes);
setEdges(nextEdges);
if (selectedNodeId === nodeId) setSelectedNodeId(nextNodes?.[0]?.id || "");
if (selectedEdgeId && !nextEdges.some((e) => e.id === selectedEdgeId)) setSelectedEdgeId("");
emitChange(nextNodes, nextEdges);
};
const updateNodeName = (nodeId, newName, commit) => {
if (!nodeId) return;
setDraftNodeName(newName);
if (!commit) return;
const target = getNodeById(nodeId);
if (!target) return;
const trimmed = (newName || "").trim();
const finalName = trimmed || target.name;
const duplicated = nodes.some((n) => n.id !== nodeId && n.name === finalName);
if (duplicated) {
setDraftNodeName(target.name);
return;
}
const oldName = target.name;
const nextNodes = nodes.map((n) => (n.id === nodeId ? { ...n, name: finalName } : n));
const nextEdges = edges.map((e) => ({
...e,
from: e.from === oldName ? finalName : e.from,
to: e.to === oldName ? finalName : e.to,
}));
setNodes(nextNodes);
setEdges(nextEdges);
emitChange(nextNodes, nextEdges);
};
const deleteEdge = (edgeId) => {
const nextEdges = edges.filter((e) => e.id !== edgeId);
setEdges(nextEdges);
if (selectedEdgeId === edgeId) setSelectedEdgeId("");
emitChange(nodes, nextEdges);
};
const updateEdgeLabel = (edgeId, newLabel, commit) => {
setDraftEdgeLabel(newLabel);
if (!commit) return;
const nextEdges = edges.map((e) => (e.id === edgeId ? { ...e, label: newLabel } : e));
setEdges(nextEdges);
emitChange(nodes, nextEdges);
};
const startDrag = (e, nodeId) => {
const rect = canvasRef.current?.getBoundingClientRect();
const node = getNodeById(nodeId);
if (!rect || !node) return;
dragRef.current = {
nodeId,
offsetX: e.clientX - rect.left - node.x,
offsetY: e.clientY - rect.top - node.y,
};
setSelectedNodeId(nodeId);
setSelectedEdgeId("");
};
const getNodeCenter = (node) => ({
x: node.x + node.width / 2,
y: node.y + node.height / 2,
});
const getNodeRightAnchor = (node) => ({
x: node.x + node.width,
y: node.y + node.height / 2,
});
const getNodeLeftAnchor = (node) => ({
x: node.x,
y: node.y + node.height / 2,
});
const buildCurvePath = (start, end) => {
const leftToRight = start.x <= end.x;
const dx = Math.abs(end.x - start.x);
const offset = Math.max(40, dx * 0.35);
const c1x = start.x + (leftToRight ? offset : -offset);
const c2x = end.x + (leftToRight ? -offset : offset);
return `M ${start.x} ${start.y} C ${c1x} ${start.y}, ${c2x} ${end.y}, ${end.x} ${end.y}`;
};
const addEdgeByNodeId = (fromNodeId, toNodeId) => {
if (!fromNodeId || !toNodeId || fromNodeId === toNodeId) return;
const fromNode = getNodeById(fromNodeId);
const toNode = getNodeById(toNodeId);
if (!fromNode || !toNode) return;
const exists = edges.some((e) => e.from === fromNode.name && e.to === toNode.name);
if (exists) return;
const nextEdges = [
...edges,
{
id: createEdgeId(),
from: fromNode.name,
to: toNode.name,
label: "",
},
];
setEdges(nextEdges);
setSelectedEdgeId(nextEdges[nextEdges.length - 1].id);
setSelectedNodeId("");
emitChange(nodes, nextEdges);
};
const startConnect = (e, nodeId) => {
e.stopPropagation();
e.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect();
const node = getNodeById(nodeId);
if (!rect || !node) return;
const start = getNodeRightAnchor(node);
connectRef.current = { fromNodeId: nodeId };
setConnectingLine({
fromNodeId: nodeId,
startX: start.x,
startY: start.y,
endX: start.x,
endY: start.y,
});
setSelectedNodeId(nodeId);
setSelectedEdgeId("");
};
useEffect(() => {
const handleMove = (e) => {
if (dragRef.current && canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const { nodeId, offsetX, offsetY } = dragRef.current;
setNodes((prev) =>
prev.map((n) => {
if (n.id !== nodeId) return n;
const nextX = e.clientX - rect.left - offsetX;
const nextY = e.clientY - rect.top - offsetY;
return {
...n,
x: Math.max(8, Math.min(nextX, rect.width - n.width - 8)),
y: Math.max(8, Math.min(nextY, rect.height - n.height - 8)),
};
})
);
return;
}
if (connectRef.current && canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setConnectingLine((prev) =>
prev
? {
...prev,
endX: x,
endY: y,
}
: prev
);
}
};
const handleUp = () => {
if (dragRef.current) {
dragRef.current = null;
setNodes((prev) => {
emitChange(prev, edges);
return prev;
});
}
if (connectRef.current) {
const fromNodeId = connectRef.current.fromNodeId;
const toNodeId = hoverNodeId;
connectRef.current = null;
setConnectingLine(null);
setHoverNodeId("");
if (toNodeId && toNodeId !== fromNodeId) {
addEdgeByNodeId(fromNodeId, toNodeId);
}
}
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [edges, hoverNodeId, nodes]);
useEffect(() => {
const handleKeyDown = (e) => {
const tag = e.target?.tagName?.toLowerCase();
const isInputLike = tag === "input" || tag === "textarea" || tag === "select";
if (isInputLike) return;
if ((e.key === "Delete" || e.key === "Backspace") && selectedEdgeId) {
e.preventDefault();
deleteEdge(selectedEdgeId);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedEdgeId, edges, nodes]);
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
const selectedEdge = edges.find((e) => e.id === selectedEdgeId);
const renderEdge = (edge) => {
const fromNode = getNodeByName(edge.from);
const toNode = getNodeByName(edge.to);
if (!fromNode || !toNode) return null;
const start = fromNode.x <= toNode.x ? getNodeRightAnchor(fromNode) : getNodeLeftAnchor(fromNode);
const end = fromNode.x <= toNode.x ? getNodeLeftAnchor(toNode) : getNodeRightAnchor(toNode);
const path = buildCurvePath(start, end);
const midX = (start.x + end.x) / 2;
const midY = (start.y + end.y) / 2;
const isSelected = selectedEdgeId === edge.id;
const isLeftToRight = start.x <= end.x;
return (
<g key={edge.id}>
<path
d={path}
fill="none"
stroke={isSelected ? "#2196f3" : "#94a3b8"}
strokeWidth={isSelected ? 2.5 : 2}
className="cursor-pointer"
onClick={(ev) => {
ev.stopPropagation();
setSelectedEdgeId(edge.id);
setSelectedNodeId("");
}}
/>
<path
d={path}
fill="none"
stroke="transparent"
strokeWidth="12"
className="cursor-pointer"
onClick={(ev) => {
ev.stopPropagation();
setSelectedEdgeId(edge.id);
setSelectedNodeId("");
}}
/>
<polygon
points={
isLeftToRight
? `${end.x},${end.y} ${end.x - 10},${end.y - 5} ${end.x - 10},${end.y + 5}`
: `${end.x},${end.y} ${end.x + 10},${end.y - 5} ${end.x + 10},${end.y + 5}`
}
fill={isSelected ? "#2196f3" : "#94a3b8"}
/>
<g
className="cursor-pointer"
onClick={(ev) => {
ev.stopPropagation();
setSelectedEdgeId(edge.id);
setSelectedNodeId("");
}}
>
<rect
x={midX - 28}
y={midY - 12}
width="56"
height="24"
rx="12"
fill={isSelected ? "#e3f2fd" : "#ffffff"}
stroke={isSelected ? "#2196f3" : "#e5e7eb"}
/>
<text x={midX} y={midY + 4} textAnchor="middle" fontSize="12" fill="#333">
{edge.label || "未设值"}
</text>
</g>
</g>
);
};
const currentRelations = exportRelations(nodes, edges);
return (
<div className="w-full text-[13px] text-[#333]">
<div className="flex gap-3">
<div className="w-[280px] shrink-0 border border-[#eee] rounded-md p-3 bg-white">
<div className="flex items-center justify-between mb-3">
<div className="font-medium flex items-center gap-2">
<LucideIcon name="Workflow" size="16" className="text-[#2196f3]" />
关系配置
</div>
<button
type="button"
className="h-[30px] px-3 rounded-md bg-[#2196f3] text-white hover:bg-[#1e88e5] transition"
onClick={addNode}
>
新增节点
</button>
</div>
<div className="space-y-3">
<div className="border border-[#eee] rounded-md p-2">
<div className="mb-2 text-[#666]">节点编辑</div>
{selectedNode ? (
<div className="space-y-2">
<input
className="w-full h-[36px] px-2 border border-[#eee] rounded-md outline-none focus:border-[#2196f3]"
value={draftNodeName}
placeholder="输入节点名称"
onChange={(e) => {
setDraftNodeName(e.target.value);
if (!isNodeComposing) {
updateNodeName(selectedNode.id, e.target.value, false);
}
}}
onCompositionStart={() => setIsNodeComposing(true)}
onCompositionEnd={(e) => {
setIsNodeComposing(false);
updateNodeName(selectedNode.id, e.target.value, true);
}}
onBlur={(e) => updateNodeName(selectedNode.id, e.target.value, true)}
/>
<button
type="button"
className="w-full h-[36px] rounded-md border border-[#ef4444] text-[#ef4444] hover:bg-[#fff5f5] transition"
onClick={() => deleteNode(selectedNode.id)}
>
删除当前节点
</button>
</div>
) : (
<div className="text-[#999]">请选择一个节点</div>
)}
</div>
<div className="border border-[#eee] rounded-md p-2">
<div className="mb-2 text-[#666]">连线编辑</div>
{selectedEdge ? (
<div className="space-y-2">
<div className="text-[#666] flex items-center justify-between">
<span>
{selectedEdge.from} → {selectedEdge.to}
</span>
<span className="text-[#ef4444] text-[12px]">按 Delete 可删除</span>
</div>
<input
className="w-full h-[36px] px-2 border border-[#eee] rounded-md outline-none focus:border-[#2196f3]"
value={draftEdgeLabel}
placeholder="输入数字/比例,如 10%"
onChange={(e) => {
setDraftEdgeLabel(e.target.value);
if (!isEdgeComposing) {
updateEdgeLabel(selectedEdge.id, e.target.value, false);
}
}}
onCompositionStart={() => setIsEdgeComposing(true)}
onCompositionEnd={(e) => {
setIsEdgeComposing(false);
updateEdgeLabel(selectedEdge.id, e.target.value, true);
}}
onBlur={(e) => updateEdgeLabel(selectedEdge.id, e.target.value, true)}
/>
<button
type="button"
className="w-full h-[36px] rounded-md border border-[#ef4444] text-[#ef4444] hover:bg-[#fff5f5] transition"
onClick={() => deleteEdge(selectedEdge.id)}
>
删除当前连线
</button>
</div>
) : (
<div className="text-[#999]">点击一条连线后可编辑或按 Delete 删除</div>
)}
</div>
<div className="border border-[#eee] rounded-md p-2">
<div className="mb-2 text-[#666] flex items-center gap-2">
<LucideIcon name="TableProperties" size="15" className="text-[#2196f3]" />
当前关系表
</div>
<div className="max-h-[220px] overflow-auto space-y-1">
{currentRelations.length ? (
currentRelations.map((item, index) => (
<div
key={`${item.from}_${item.to}_${index}`}
className="px-2 py-2 rounded bg-[#f8fafc] border border-[#eef2f7] flex items-center justify-between gap-2"
>
<div className="truncate">
<span className="font-medium">{item.from}</span>
<span className="mx-1 text-[#999]">→</span>
<span className="font-medium">{item.to}</span>
</div>
<div className="text-[#2196f3] shrink-0">{item.label || "-"}</div>
</div>
))
) : (
<div className="text-[#999]">暂无关系数据</div>
)}
</div>
</div>
</div>
</div>
<div
ref={canvasRef}
className="relative flex-1 h-[560px] border border-[#eee] rounded-md bg-[linear-gradient(to_right,#f8fafc_1px,transparent_1px),linear-gradient(to_bottom,#f8fafc_1px,transparent_1px)] bg-[size:24px_24px] overflow-hidden"
onClick={() => {
setSelectedNodeId("");
setSelectedEdgeId("");
}}
>
<svg className="absolute inset-0 w-full h-full">
{edges.map(renderEdge)}
{connectingLine ? (
<path
d={buildCurvePath(
{ x: connectingLine.startX, y: connectingLine.startY },
{ x: connectingLine.endX, y: connectingLine.endY }
)}
fill="none"
stroke="#2196f3"
strokeWidth="2"
strokeDasharray="6 4"
opacity="0.9"
/>
) : null}
</svg>
{nodes.map((node) => {
const isSelected = selectedNodeId === node.id;
const isHoverTarget = hoverNodeId === node.id && connectRef.current?.fromNodeId !== node.id;
return (
<div
key={node.id}
className={`absolute select-none rounded-md border shadow-sm bg-white flex items-center justify-center px-3 h-[44px] min-w-[110px] transition ${
isSelected
? "border-[#2196f3] ring-2 ring-[#dbeafe]"
: "border-[#e5e7eb] hover:border-[#bfdbfe]"
} ${dragRef.current?.nodeId === node.id ? "cursor-grabbing" : "cursor-move"}`}
style={{
left: node.x,
top: node.y,
width: node.width,
height: node.height,
}}
onMouseDown={(e) => {
e.stopPropagation();
startDrag(e, node.id);
}}
onMouseEnter={() => {
if (connectRef.current) setHoverNodeId(node.id);
}}
onMouseLeave={() => {
if (hoverNodeId === node.id) setHoverNodeId("");
}}
onClick={(e) => {
e.stopPropagation();
setSelectedNodeId(node.id);
setSelectedEdgeId("");
}}
>
<div className="flex items-center gap-2 truncate pr-3">
<LucideIcon name="Square" size="14" className="text-[#2196f3] shrink-0" />
<span className="truncate">{node.name}</span>
</div>
<button
type="button"
className={`absolute -right-[8px] top-1/2 -translate-y-1/2 w-[16px] h-[16px] rounded-full border-2 border-white shadow ${
isHoverTarget ? "bg-[#22c55e]" : "bg-[#2196f3]"
} hover:scale-110 transition`}
onMouseDown={(e) => startConnect(e, node.id)}
onClick={(e) => e.stopPropagation()}
title="从这里拖出建立连线"
/>
{isHoverTarget ? (
<div className="absolute inset-0 rounded-md ring-2 ring-[#bbf7d0] pointer-events-none" />
) : null}
</div>
);
})}
<div className="absolute right-3 bottom-3 px-3 py-2 rounded-md bg-white/95 border border-[#eee] text-[#666] shadow-sm">
<div className="flex items-center gap-2">
<LucideIcon name="MousePointer2" size="14" className="text-[#2196f3]" />
拖动节点移动位置;从右侧圆点拖到目标节点建立连接;选中连线后按 Delete 删除
</div>
</div>
</div>
</div>
</div>
);
}