自定义字段还是很好用👍。做一个流程图编辑器。可以添加节点,连线,标记。下面直接粘贴自定义字段即可见效果。

分享 AI自定义字段  收藏
6 / 329

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>
  );
}