![image.png]()
// <free_field_name>关系流程图编辑器</free_field_name>
// <file_name>FlowRelationEditor_v2.jsx</file_name>
function FlowRelationEditor({ value, currentControl, formData, onChange }) {
const canvasRef = useRef(null);
const dragRef = 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);
const edges = [
{ id: createEdgeId(), from: "A", to: "B", label: "10%" },
{ id: createEdgeId(), from: "A", to: "C", label: "15%" },
];
return {
nodes: [nodeA, nodeB, nodeC],
edges,
};
};
const parseValue = (raw) => {
if (!raw || typeof raw !== "string") {
return buildDefaultData();
}
try {
const parsed = JSON.parse(raw);
// 兼容旧格式:[{from,to,label}]
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 };
}
// 新格式:{nodes, edges} 或 {nodes, edges, relations}
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 [connectFrom, setConnectFrom] = useState("");
const [connectTo, setConnectTo] = useState("");
useEffect(() => {
const parsed = parseValue(value);
setNodes(parsed.nodes);
setEdges(parsed.edges);
setSelectedNodeId((prev) => {
const exists = parsed.nodes.some((n) => n.id === prev);
return exists ? prev : parsed.nodes?.[0]?.id || "";
});
setSelectedEdgeId((prev) => {
const exists = parsed.edges.some((e) => e.id === prev);
return exists ? 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;
const payload = {
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),
};
onChange(JSON.stringify(payload));
};
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 getNodeById = (id) => nodes.find((n) => n.id === id);
const getNodeByName = (name) => nodes.find((n) => n.name === name);
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);
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 addEdge = () => {
if (!connectFrom || !connectTo || connectFrom === connectTo) return;
const exists = edges.some((e) => e.from === connectFrom && e.to === connectTo);
if (exists) return;
const nextEdges = [
...edges,
{
id: createEdgeId(),
from: connectFrom,
to: connectTo,
label: "",
},
];
setEdges(nextEdges);
setSelectedEdgeId(nextEdges[nextEdges.length - 1].id);
emitChange(nodes, 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("");
};
useEffect(() => {
const handleMove = (e) => {
if (!dragRef.current || !canvasRef.current) return;
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)),
};
})
);
};
const handleUp = () => {
if (!dragRef.current) return;
const currentDrag = dragRef.current;
dragRef.current = null;
setNodes((prev) => {
emitChange(prev, edges);
return prev;
});
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [edges]);
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
const selectedEdge = edges.find((e) => e.id === selectedEdgeId);
const getAnchor = (node, side) => {
if (!node) return { x: 0, y: 0 };
if (side === "right") return { x: node.x + node.width, y: node.y + node.height / 2 };
if (side === "left") return { x: node.x, y: node.y + node.height / 2 };
return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
};
const renderEdge = (edge) => {
const fromNode = getNodeByName(edge.from);
const toNode = getNodeByName(edge.to);
if (!fromNode || !toNode) return null;
const isLeftToRight = fromNode.x <= toNode.x;
const start = getAnchor(fromNode, isLeftToRight ? "right" : "left");
const end = getAnchor(toNode, isLeftToRight ? "left" : "right");
const dx = Math.abs(end.x - start.x);
const offset = Math.max(40, dx * 0.35);
const c1x = start.x + (isLeftToRight ? offset : -offset);
const c2x = end.x + (isLeftToRight ? -offset : offset);
const path = `M ${start.x} ${start.y} C ${c1x} ${start.y}, ${c2x} ${end.y}, ${end.x} ${end.y}`;
const midX = (start.x + end.x) / 2;
const midY = (start.y + end.y) / 2;
const isSelected = selectedEdgeId === edge.id;
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("");
}}
/>
<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>
<div className="flex gap-2 items-center">
<select
className="flex-1 h-[36px] px-2 border border-[#eee] rounded-md outline-none"
value={connectFrom}
onChange={(e) => setConnectFrom(e.target.value)}
>
<option value="">起点</option>
{nodes.map((node) => (
<option key={node.id} value={node.name}>
{node.name}
</option>
))}
</select>
<LucideIcon name="ArrowRight" size="16" className="text-[#999]" />
<select
className="flex-1 h-[36px] px-2 border border-[#eee] rounded-md outline-none"
value={connectTo}
onChange={(e) => setConnectTo(e.target.value)}
>
<option value="">终点</option>
{nodes.map((node) => (
<option key={node.id} value={node.name}>
{node.name}
</option>
))}
</select>
</div>
<button
type="button"
className="mt-2 w-full h-[36px] rounded-md border border-[#2196f3] text-[#2196f3] hover:bg-[#f0f8ff] transition disabled:opacity-50"
onClick={addEdge}
disabled={!connectFrom || !connectTo || connectFrom === connectTo}
>
添加连线
</button>
</div>
<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]">
{selectedEdge.from} → {selectedEdge.to}
</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]">请选择一条连线</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)}</svg>
{nodes.map((node) => {
const isSelected = selectedNodeId === 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] cursor-move transition ${
isSelected
? "border-[#2196f3] ring-2 ring-[#dbeafe]"
: "border-[#e5e7eb] hover:border-[#bfdbfe]"
}`}
style={{
left: node.x,
top: node.y,
width: node.width,
height: node.height,
}}
onMouseDown={(e) => {
e.stopPropagation();
startDrag(e, node.id);
}}
onClick={(e) => {
e.stopPropagation();
setSelectedNodeId(node.id);
setSelectedEdgeId("");
}}
>
<div className="flex items-center gap-2 truncate">
<LucideIcon name="Square" size="14" className="text-[#2196f3] shrink-0" />
<span className="truncate">{node.name}</span>
</div>
</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="Move" size="14" className="text-[#2196f3]" />
节点位置会随完整图数据一并保存
</div>
</div>
</div>
</div>
</div>
);
}