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

分享 AI自定义字段  收藏
2 / 84

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