Bladeren bron

feat: 添加mermaid代码导入功能

liaojiaxing 2 maanden geleden
bovenliggende
commit
93a180653c

+ 2 - 2
apps/designer/.umirc.ts

@@ -8,7 +8,7 @@ export default defineConfig({
     '/favicon.ico'
   ],
   styles: [
-    '//at.alicdn.com/t/c/font_4676747_4jkbw9dya3f.css'
+    '//at.alicdn.com/t/c/font_4676747_xihmn5nmv9h.css'
   ],
   metas: [
     { name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' }
@@ -16,7 +16,7 @@ export default defineConfig({
   scripts: [
     // 字体加载
     // '//ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js'
-    '//at.alicdn.com/t/c/font_4676747_4jkbw9dya3f.js'
+    '//at.alicdn.com/t/c/font_4676747_xihmn5nmv9h.js'
   ],
   plugins: [
     require.resolve('@umijs/plugins/dist/unocss'),

+ 28 - 8
apps/designer/src/events/flowEvent.ts

@@ -1,5 +1,5 @@
 import { Graph, Node, EventArgs } from "@antv/x6";
-import { AddFlowchartElement, BatchEditFlowchartElement, BatchDeleteFlowchartElement } from "@/api/systemDesigner";
+import { BatchAddFlowchartElement, BatchEditFlowchartElement, BatchDeleteFlowchartElement } from "@/api/systemDesigner";
 export const handleGraphEvent = (graph: Graph) => {
   // 边开始拖拽点
   const sourceArrowhead = {
@@ -92,16 +92,36 @@ export const handleGraphEvent = (graph: Graph) => {
 };
 
 export const handleGraphApiEvent = (graph: Graph) => {
+  let timer2: any;
+  let map2: Record<string, any> = {};
   graph.on("cell:added", (args) => {
     if(args.cell?.data?.isPage) return;
-
-    const graphId = sessionStorage.getItem("projectId");
     const data = args.cell.toJSON();
-    graphId && AddFlowchartElement({
-      ...data,
-      graphId,
-      tools: ''
-    });
+    
+    const setData = () => {
+      const id = args.cell.id;
+      delete data.tools;
+      map2[id] = data;
+    }
+    if(timer2) {
+      setData();
+      return;
+    }
+    setData();
+    timer2 = setTimeout(() => {
+      const graphId = sessionStorage.getItem("projectId");
+      const list = Object.values(map2);
+      if(graphId && list.length > 0) {
+        BatchAddFlowchartElement(list.map(item => {
+          return {
+            ...item,
+            graphId
+          }
+        }));
+      }
+      timer2 = null;
+      map2 = {};
+    }, 1000);
   });
 
   // 批量处理节点更新

+ 4 - 5
apps/designer/src/models/graphModel.ts

@@ -87,7 +87,7 @@ export default function GraphModel() {
         if (item.shape !== "edge")  {
           return {
             ...item,
-            data: JSON.parse(item?.data),
+            data: JSON.parse(item?.data || "{}"),
             ports: JSON.parse(item?.ports  || "{}"),
             size: JSON.parse(item?.size || "{}"),
             position: JSON.parse(item?.position || "{}"),
@@ -102,14 +102,14 @@ export default function GraphModel() {
         if (item.shape === "edge") {
           return {
             ...item,
-            data: JSON.parse(item?.data),
+            data: JSON.parse(item?.data || "{}"),
             attrs: JSON.parse(item?.attrs as unknown as string || "{}"),
             source: JSON.parse(item?.source),
             target: JSON.parse(item?.target),
             ...(item?.connector ? {connector: JSON.parse(item.connector)} : {}),
             ...(item?.labels ? {labels: JSON.parse(item.labels)} : {}),
-            // router: 'manhattan',
-            router: item.router,
+            router: item?.router ? 'manhattan' : '',
+            // router: item.router,
             tools: [
               {
                 name: "contextmenu",
@@ -252,7 +252,6 @@ export default function GraphModel() {
     node: Node.Metadata
   ) => {
     if (!dndRef.current || !graphRef.current) return;
-
     // 往画布添加节点
     const n = graphRef.current.createNode(node);
     // 右键菜单

+ 5 - 5
apps/designer/src/pages/flow/components/Content/index.tsx

@@ -91,11 +91,11 @@ export default function Content() {
         allowPort: true,
         allowMulti: true,
         highlight: false,
-        anchor: "center",
-        connectionPoint: "anchor",
-        snap: {
-          radius: 20,
-        },
+        // anchor: "center",
+        // connectionPoint: "anchor",
+        // snap: {
+        //   radius: 20,
+        // },
         createEdge() {
           return new Shape.Edge({
             ...BaseEdge,

+ 266 - 0
apps/designer/src/pages/flow/components/ToolBar/MermaidModal.tsx

@@ -0,0 +1,266 @@
+import {
+  useState,
+  useImperativeHandle,
+  forwardRef,
+  useEffect,
+  useRef,
+} from "react";
+import { Modal, Input, Button } from "antd";
+import "./mermaid.less";
+import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
+import { Graph } from "@antv/x6";
+import { uuid } from "@repo/utils";
+
+import Rectangle from "@/components/flowchart/process";
+import Round from "@/components/basic/roundRectangle";
+import Stadium from "@/components/flowchart/terminator";
+import DoubleCircle from "@/components/er/multivaluedAttribute";
+import Circle from "@/components/flowchart/onPageReference";
+import Diamond from "@/components/flowchart/decision";
+import { useModel } from "umi";
+
+export enum VERTEX_TYPE {
+  RECTANGLE = "rectangle", // 矩形
+  ROUND = "round", // 圆角矩形
+  STADIUM = "stadium", // 椭圆
+  DOUBLECIRCLE = "doublecircle", // 双圆
+  CIRCLE = "circle", // 圆
+  DIAMOND = "diamond", // 菱形
+}
+
+export type MermaidResult = {
+  type: "image" | "cell";
+  data: any;
+  width?: number;
+  height?: number;
+};
+
+export default forwardRef(function MermaidModal(
+  { onChange }: { onChange?: (res?: MermaidResult) => void },
+  ref
+) {
+  const [open, setOpen] = useState(false);
+  const [mermaidCode, setMermaidCode] = useState("");
+  const { graph } = useModel("graphModel");
+  const resultRef = useRef<MermaidResult>();
+
+  useImperativeHandle(ref, () => ({
+    open: () => {
+      setOpen(true);
+    },
+    close: () => {
+      setOpen(false);
+    },
+  }));
+
+  const mermaidRef = useRef<HTMLDivElement>(null);
+
+  /**
+   * 转换成x6 json
+   * @param list
+   */
+  const toX6Json = (list: any[]) => {
+    const cells: any[] = [];
+    let comp = Rectangle;
+    const idMap: Record<string, string> = {};
+    list.forEach((item) => {
+      // 节点处理
+      if (item.type !== "arrow") {
+        switch (item.type) {
+          case VERTEX_TYPE.ROUND: {
+            comp = Round;
+            break;
+          }
+          case VERTEX_TYPE.STADIUM: {
+            comp = Stadium;
+            break;
+          }
+          case VERTEX_TYPE.DOUBLECIRCLE: {
+            comp = DoubleCircle;
+            break;
+          }
+          case VERTEX_TYPE.CIRCLE: {
+            comp = Circle;
+            break;
+          }
+          case VERTEX_TYPE.DIAMOND: {
+            comp = Diamond;
+            break;
+          }
+          default: {
+            comp = Rectangle;
+            break;
+          }
+        }
+        const id = uuid();
+        const node = {
+          ...comp.node,
+          id,
+          position: {
+            x: item.x,
+            y: item.y,
+          },
+          width: parseInt(item.width),
+          height: parseInt(item.height),
+          data: {
+            ...comp.node.data,
+            text: {
+              ...comp.node.data.text,
+              fontSize: item.label.fontSize,
+            },
+            label: item.label.text,
+          }
+        };
+        idMap[item.id] = id;
+        cells.push(node);
+      } else {
+        // 连线处理
+        const edge = {
+          source: { cell: idMap[item.start.id] },
+          target: { cell: idMap[item.end.id] },
+          labels: item?.label
+            ? [
+                {
+                  attrs: {
+                    label: {
+                      text: item.label.text,
+                    },
+                  },
+                },
+              ]
+            : [],
+        };
+        cells.push(edge);
+      }
+    });
+
+    return cells;
+  };
+
+  /**
+   * 绘制图标
+   */
+  const drawDiagram = async () => {
+    // 获取渲染后的 svg
+    try {
+      resultRef.current = undefined;
+      const { elements, files } = await parseMermaidToExcalidraw(mermaidCode);
+      console.log("parse elements:", elements, files);
+      // 转换失败,加载图片
+      if (files) {
+        resultRef.current = {
+          type: "image",
+          data: files[elements[0].fileId]?.dataURL,
+          width: elements[0].width,
+          height: elements[0].height,
+        };
+        const img = new Image();
+        img.src = files[elements[0].fileId]?.dataURL;
+        img.onload = () => {
+          if (mermaidRef.current) {
+            mermaidRef.current.innerHTML = "";
+            mermaidRef.current.appendChild(img);
+          }
+        };
+      } else {
+        // 转换json
+        if (mermaidRef.current) {
+          mermaidRef.current.innerHTML = "";
+          const cells = toX6Json(elements);
+          console.log("parse cells:", cells);
+          resultRef.current = { type: "cell", data: cells };
+          const graphInstance = new Graph({
+            container: mermaidRef.current,
+            grid: false,
+            width: mermaidRef.current.clientWidth,
+            height: mermaidRef.current.clientHeight,
+          });
+          cells.forEach((cell) => {
+            if(cell?.source && cell?.target) {
+              graphInstance.addEdge(cell);
+            } else {
+              graphInstance.addNode(cell);
+            }
+          });
+          graphInstance.zoomToFit({
+            padding: 20,
+          });
+        }
+      }
+    } catch (error) {
+      console.error("mermaid 格式校验失败:错误信息如下:\n", error);
+      let html = `<div class='bl-preview-analysis-fail-block'>
+          <div class="fail-title">Mermaid 语法解析失败!</div><br/>
+          ${error}<br/><br/>
+          你可以尝试前往 Mermaid 官网来校验你的内容, 或者查看<a href='https://mermaid.live/edit' target='_blank'>相关文档</a>
+          </div>`;
+      if (mermaidRef.current) {
+        mermaidRef.current.innerHTML = html;
+      }
+    }
+  };
+
+  useEffect(() => {
+    if (mermaidCode) {
+      drawDiagram();
+    } else {
+      if (mermaidRef.current) {
+        mermaidRef.current.innerHTML = "";
+      }
+    }
+  }, [mermaidCode]);
+
+  return (
+    <Modal
+      open={open}
+      width="80%"
+      title="Mermaid导入"
+      footer={() => {
+        return (
+          <div className="flex justify-end">
+            <Button
+              type="primary"
+              onClick={() => {
+                if (onChange) {
+                  onChange(resultRef.current);
+                }
+                setOpen(false);
+              }}
+            >
+              插入
+            </Button>
+          </div>
+        );
+      }}
+      onCancel={() => setOpen(false)}
+    >
+      <div className="tip">
+        <i>
+          目前仅支持
+          <a
+            className="text-blue"
+            href="https://mermaid.js.org/syntax/flowchart.html"
+          >
+            流程图
+          </a>
+          导入,其他图以图形方式引入。
+        </i>
+      </div>
+      <div className="flex gap-24px">
+        <div className="left flex-1 rounded-4px h-500px border-solid border-gray-200">
+          <Input.TextArea
+            autoSize={{ minRows: 10 }}
+            placeholder="请输入Mermaid代码"
+            className="h-full"
+            variant="borderless"
+            value={mermaidCode}
+            onChange={(e) => setMermaidCode(e.target.value)}
+          />
+        </div>
+        <div className="left flex-1 rounded-4px h-500px border-solid border-gray-200">
+          <div className="mermaid-container" ref={mermaidRef}></div>
+        </div>
+      </div>
+    </Modal>
+  );
+});

+ 87 - 0
apps/designer/src/pages/flow/components/ToolBar/index.tsx

@@ -31,6 +31,10 @@ import { ConnectorType } from "@/enum";
 import { set, cloneDeep } from "lodash-es";
 import FindReplaceModal from "@/components/FindReplaceModal";
 import { useFindReplace } from "@/hooks/useFindReplace";
+import MermaidModal, { MermaidResult } from "./MermaidModal";
+import { nodeMenu, edgeMenu } from "@/utils/contentMenu";
+import BaseNode from "@/components/base";
+import { Cell } from "@antv/x6";
 
 export default function ToolBar() {
   const {
@@ -76,6 +80,7 @@ export default function ToolBar() {
   }, [graph])
 
   const findModalRef = useRef<any>();
+  const mermaidModelRef = useRef<any>();
 
   const hasNode = useMemo(() => {
     return selectedCell?.find((cell) => cell.isNode());
@@ -157,6 +162,61 @@ export default function ToolBar() {
     setCellZIndex(type, selectedCell);
   };
 
+  // 插入mermaid代码结果数据
+  const handleInsertMermaid = (res?: MermaidResult) => {
+    if(res) {
+      if(res.type === 'image') {
+        const node = graph?.addNode({
+          ...BaseNode,
+          data: {
+            ...BaseNode.data,
+            fill: {
+              ...BaseNode.data.fill,
+              fillType: "image",
+              imageUrl: res.data,
+            },
+          },
+          size: {
+            width: parseInt((res.width || 100)+''),
+            height: parseInt((res.height || 100) + ''),
+          },
+        });
+        // 右键菜单
+        node?.addTools({
+          name: "contextmenu",
+          args: {
+            menu: nodeMenu,
+          },
+        });
+      } else {
+        graph?.cleanSelection();
+        // cell数据
+        res.data?.forEach((cell: Cell.Metadata) => {
+          if(cell?.source && cell?.target) {
+            const node = graph?.addEdge(cell);
+            // 右键菜单
+            node?.addTools({
+              name: "contextmenu",
+              args: {
+                menu: edgeMenu,
+              },
+            });
+          } else {
+            const edge = graph?.addNode(cell);
+            // 右键菜单
+            edge?.addTools({
+              name: "contextmenu",
+              args: {
+                menu: nodeMenu,
+              },
+            });
+          }
+          cell.id && graph?.select(cell.id);
+        });
+      }
+    }
+  }
+
   return (
     <div className={styles.toolBar}>
       <div className="flex justify-between items-center">
@@ -678,6 +738,32 @@ export default function ToolBar() {
             </Tooltip>
           </Dropdown>
 
+          <Dropdown
+            menu={{
+              items: [
+                {
+                  key: "mermaid",
+                  label: (
+                    <div>
+                      <i className="iconfont icon-Mermaid mr-8px" />
+                      Mermaid导入
+                    </div>
+                  ),
+                  onClick: () =>
+                    mermaidModelRef.current?.open()
+                },
+              ],
+            }}
+          >
+            <Button
+              type="text"
+              className="w-50px"
+            >
+              <i className="iconfont icon-ai"></i>
+              <CaretDownOutlined className="text-12px" />
+            </Button>
+          </Dropdown>
+
           {/* <Dropdown menu={{ items: [] }}>
             <Button type="text" className="w-50px">
               <span>更多</span>
@@ -698,6 +784,7 @@ export default function ToolBar() {
           right={30}
           top={110}
         />
+        <MermaidModal ref={mermaidModelRef} onChange={handleInsertMermaid}/>
         <div>
           <Tooltip placement="bottom" title="替换">
             <Button

+ 10 - 0
apps/designer/src/pages/flow/components/ToolBar/mermaid.less

@@ -0,0 +1,10 @@
+.mermaid-container {
+  width: 100%;
+  height: 100%;
+  img {
+    max-width: 100%;
+    max-height: 100%;
+    margin: 0 auto;
+    display: block;
+  }
+}

+ 175 - 0
apps/designer/src/utils/mermaidToX6Json.ts

@@ -0,0 +1,175 @@
+import dagre from 'dagre';
+
+interface MermaidNode {
+  id: string;
+  label: string;
+  shape: 'rect' | 'diamond' | 'ellipse' | 'circle' | 'cylinder';
+}
+
+interface MermaidEdge {
+  source: string;
+  target: string;
+  label: string;
+}
+
+// 在节点解析中添加样式处理
+const shapeMapping: Record<string, string> = {
+  '[': '矩形节点',
+  '(': '圆边节点',
+  '([': '体育场形状节点',
+  '[[': '子程序',
+  '[(': '圆柱形状',
+  '((': '圆形节点',
+  '>': '右箭头形状',
+  '{': '菱形节点',
+  '{{': '六边形节点',
+  '[/': '平行四边形',
+  '(((': '双圆圈'
+};
+
+// 增强的正则表达式,支持更多节点类型
+const NODE_REGEX = /^(\w+)(?:\[(.*?)\]|\{(.*?)\}|\((.*?)\)|\((\((.*?)\))\)|\[\[(.*?)\]\])(?!\S)/;
+const EDGE_REGEX = /^(\w+(?:\[.*?\]|\{.*?\}|\(.*?\)|\(\(.*?\)\)|\[\[.*?\]\])?)\s*(-->|->)\s*(\w+(?:\[.*?\]|\{.*?\}|\(.*?\)|\(\(.*?\)\)|\[\[.*?\]\])?)(?:\s*\|(.*?)\|)?/;
+
+function parseNode(str: string): MermaidNode | null {
+  const match = str.match(NODE_REGEX);
+  if (!match) return null;
+
+  const [, id, rect, diamond, ellipse, , circle, cylinder] = match;
+  
+  return {
+    id,
+    label: rect || diamond || ellipse || circle || cylinder || id,
+    shape: rect ? 'rect' 
+         : diamond ? 'diamond' 
+         : ellipse ? 'ellipse' 
+         : circle ? 'circle' 
+         : cylinder ? 'cylinder' 
+         : 'rect'
+  };
+}
+
+function parseMermaid(code: string): { nodes: MermaidNode[]; edges: MermaidEdge[] } {
+  const nodes: MermaidNode[] = [];
+  const edges: MermaidEdge[] = [];
+  const nodeCache = new Set<string>();
+
+  const ensureNode = (nodeStr: string) => {
+    const node = parseNode(nodeStr) || { id: nodeStr, label: nodeStr, shape: 'rect' };
+    if (!nodeCache.has(node.id)) {
+      nodes.push(node);
+      nodeCache.add(node.id);
+    }
+    return node.id;
+  };
+
+  code.split('\n').forEach(line => {
+    line = line.trim();
+    if (!line || line.startsWith('%%')) return;
+
+    // 解析独立节点定义
+    const node = parseNode(line);
+    if (node) {
+      if (!nodeCache.has(node.id)) {
+        nodes.push(node);
+        nodeCache.add(node.id);
+      }
+      return;
+    }
+
+    // 解析边定义
+    const edgeMatch = line.match(EDGE_REGEX);
+    if (edgeMatch) {
+      const [, sourceStr, , targetStr, label] = edgeMatch;
+      const source = ensureNode(sourceStr);
+      const target = ensureNode(targetStr);
+      edges.push({ source, target, label: label || '' });
+    }
+  });
+
+  console.log('nodes', nodes, edges);
+  return { nodes, edges };
+}
+
+function convertToX6Format(nodes: MermaidNode[], edges: MermaidEdge[]): any[] {
+  const graph = new dagre.graphlib.Graph();
+  graph.setGraph({ rankdir: 'TB', nodesep: 50, ranksep: 70 });
+  graph.setDefaultEdgeLabel(() => ({}));
+
+  // Add nodes to dagre graph
+  nodes.forEach(node => {
+    graph.setNode(node.id, {
+      label: node.label,
+      width: node.shape === 'diamond' ? 80 : 100,
+      height: node.shape === 'diamond' ? 80 : 40,
+      shape: node.shape
+    });
+  });
+
+  // Add edges to dagre graph
+  edges.forEach(edge => {
+    graph.setEdge(edge.source, edge.target, { label: edge.label });
+  });
+
+  dagre.layout(graph);
+
+  // Convert to X6 format
+  const x6Nodes = graph.nodes().map((id: string) => {
+    const dagreNode = graph.node(id);
+    return {
+      id,
+      shape: dagreNode.shape,
+      position: { x: dagreNode.x - dagreNode.width / 2, y: dagreNode.y - dagreNode.height / 2 },
+      size: { width: dagreNode.width, height: dagreNode.height },
+      attrs: { 
+        label: { 
+          text: dagreNode.label,
+          fontSize: 14,
+          fill: '#333'
+        }
+      }
+    };
+  });
+
+  const x6Edges = graph.edges().map((edge: any, index: number) => {
+    const dagreEdge = graph.edge(edge);
+    return {
+      id: `edge-${index}`,
+      shape: 'edge',
+      source: { cell: edge.v },
+      target: { cell: edge.w },
+      attrs: {
+        line: {
+          stroke: '#808080',
+          strokeWidth: 2,
+          targetMarker: 'classic'
+        },
+        label: {
+          text: dagreEdge.label,
+          fontSize: 12,
+          fill: '#666'
+        }
+      }
+    };
+  });
+
+  return [...x6Nodes, ...x6Edges];
+}
+
+// Mermaid 流程图检测
+function isMermaidFlowchart(code: string): boolean {
+  const flowchartRegex = /^\s*graph|flowchart\s+(TD|LR|RL|TB)\s*\n/i;
+  const nodeRegex = /(\w+)\[(.*?)\]|\{|\}/;
+  return flowchartRegex.test(code) && nodeRegex.test(code);
+}
+
+export function mermaidToX6Json(mermaidCode: string): { isFlowchart: boolean, raw?: string, cells?: any[] } {
+  if (!isMermaidFlowchart(mermaidCode)) {
+    return { isFlowchart: false, raw: mermaidCode };
+  }
+  const { nodes, edges } = parseMermaid(mermaidCode);
+  return {
+    isFlowchart: true,
+    cells: convertToX6Format(nodes, edges)
+  }
+}

+ 18 - 1
apps/er-designer/src/models/erModel.tsx

@@ -427,7 +427,12 @@ export default function erModel() {
     });
     setTabActiveKey("1");
     setTableActive(newTable.table.id);
-    graphRef.current?.select(graphRef.current?.getCellById(newTable.table.id));
+
+    const cell = graphRef.current?.getCellById(newTable.table.id);
+    if(cell) {
+      graphRef.current?.select(cell);
+      graphRef.current?.centerCell(cell);
+    }
   };
 
   /**
@@ -498,6 +503,12 @@ export default function erModel() {
       topicAreas: [...project.topicAreas, newTopicArea],
     });
     setTabActiveKey("3");
+    
+    const cell = graphRef.current?.getCellById(topicAreaId);
+    if(cell) {
+      graphRef.current?.select(cell);
+      graphRef.current?.centerCell(cell);
+    }
   };
 
   /**
@@ -552,6 +563,12 @@ export default function erModel() {
       remarkInfos: [...project.remarkInfos, newRemark],
     });
     setTabActiveKey("4");
+
+    const cell = graphRef.current?.getCellById(remarkId);
+    if(cell) {
+      graphRef.current?.select(cell);
+      graphRef.current?.centerCell(cell);
+    }
   };
 
   /**

+ 5 - 2
package.json

@@ -52,20 +52,23 @@
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@emotion/css": "^11.13.0",
+    "@excalidraw/mermaid-to-excalidraw": "^1.1.2",
+    "@repo/utils": "workspace:*",
     "@types/lodash-es": "^4.17.12",
     "@uiw/react-codemirror": "^4.23.3",
     "@unocss/cli": "^0.62.3",
     "ahooks": "^3.8.1",
     "antd": "^5.23.0",
     "axios": "^1.7.7",
+    "dagre": "^0.8.5",
     "dayjs": "^1.11.13",
     "insert-css": "^2.0.0",
     "lodash-es": "^4.17.21",
+    "mermaid": "^11.4.1",
     "react-draggable": "^4.4.6",
     "thememirror": "^2.0.1",
     "umi": "^4.3.18",
     "unocss": "^0.62.3",
-    "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
-    "@repo/utils": "workspace:*"
+    "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
   }
 }

File diff suppressed because it is too large
+ 1092 - 0
pnpm-lock.yaml