Преглед на файлове

feat: 添加菜单功能

liaojiaxing преди 4 месеца
родител
ревизия
171db1e77b

+ 3 - 4
apps/er-designer/src/components/TableNode.tsx

@@ -1,9 +1,8 @@
-import React, { useEffect, useMemo, useRef } from "react";
+import React, { useEffect, useRef } from "react";
 import { register } from "@antv/x6-react-shape";
-import { Edge, Graph, Node } from "@antv/x6";
+import { Graph, Node } from "@antv/x6";
 import type { ColumnItem, TableItemType } from "@/type";
 import { DATA_TYPE_OPTIONS } from "@/constants";
-import { uuid } from "@/utils";
 
 function TableNode({ node, graph }: { node: Node; graph: Graph }) {
   const { table, tableColumnList } = node.getData<TableItemType>();
@@ -12,7 +11,7 @@ function TableNode({ node, graph }: { node: Node; graph: Graph }) {
   useEffect(() => {
     const container = containerRef.current;
     if (container?.clientHeight) {
-      node.setSize(220, container.clientHeight);
+      node.setSize(node.size().width, container.clientHeight);
     }
   }, [tableColumnList.length]);
 

+ 4 - 0
apps/er-designer/src/layouts/index.less

@@ -12,4 +12,8 @@ body {
 }
 ::-webkit-scrollbar-thumb {
   background: #a19f9f;
+}
+
+.x6-widget-selection-box {
+  border: 2px dashed #239edd;
 }

+ 197 - 54
apps/er-designer/src/models/erModel.tsx

@@ -5,6 +5,7 @@ import { Scroller } from "@antv/x6-plugin-scroller";
 import { Snapline } from "@antv/x6-plugin-snapline";
 import { Keyboard } from "@antv/x6-plugin-keyboard";
 import { Export } from "@antv/x6-plugin-export";
+import { Selection } from "@antv/x6-plugin-selection";
 import { GetAllDesignTables } from "@/api";
 import { useRequest } from "umi";
 import type {
@@ -51,6 +52,8 @@ export default function erModel() {
       showColumnDetail: true,
       showGrid: true,
       showRelation: true,
+      autoUpdate: false,
+      tableWidth: 220,
     },
   });
 
@@ -100,6 +103,18 @@ export default function erModel() {
     }
   };
 
+  useEffect(() => {
+    graphRef.current?.setGridSize(project.setting.showGrid ? 10 : 0);
+  }, [project.setting.showGrid]);
+
+  useEffect(() => {
+    graphRef.current && render(graphRef.current, project);
+  }, [project.setting.showRelation]);
+
+  /**
+   * 初始化画布
+   * @param container
+   */
   const initGraph = (container: HTMLElement) => {
     const instance = new Graph({
       container,
@@ -181,6 +196,13 @@ export default function erModel() {
     instance.use(new Scroller());
     instance.use(new Keyboard());
     instance.use(new Export());
+    instance.use(
+      new Selection({
+        enabled: true,
+        showNodeSelectionBox: true,
+        multiple: false,
+      })
+    );
 
     setGraph(instance);
     graphRef.current = instance;
@@ -199,7 +221,7 @@ export default function erModel() {
     instance.on(
       "node:change:update:remark",
       function (args: EventArgs["cell:change:*"]) {
-        console.log('修改备注:', args.current)
+        console.log("修改备注:", args.current);
         updateRemark(args.current);
       }
     );
@@ -259,6 +281,7 @@ export default function erModel() {
     const tableId = uuid();
     const columnId = uuid();
     const newTable: TableItemType = {
+      isTable: true,
       table: {
         aliasName: "newtable",
         creationTime: "",
@@ -279,8 +302,8 @@ export default function erModel() {
         style: {
           // 随机颜色
           color: "#" + Math.floor(Math.random() * 0x666666).toString(16),
-          x: 0,
-          y: 0,
+          x: 300,
+          y: 300,
         },
       },
       tableColumnList: [
@@ -343,8 +366,6 @@ export default function erModel() {
       ...project,
       tables: project.tables.map((item) => {
         if (item.table.id === table.table.id) {
-          const tableNode = graphRef.current?.getCellById(table.table.id);
-          tableNode?.setData(table);
           return table;
         }
         return item;
@@ -384,10 +405,13 @@ export default function erModel() {
   const addTopicArea = () => {
     const topicAreaId = uuid();
     const newTopicArea = {
+      isTopicArea: true,
       id: topicAreaId,
       name: "主题域_" + (project.topicAreas.length + 1),
       style: {
         background: "#175e7a",
+        x: 300,
+        y: 300,
       },
     };
     setProject({
@@ -404,8 +428,6 @@ export default function erModel() {
       ...project,
       topicAreas: project.topicAreas.map((item) => {
         if (item.id === topicArea.id) {
-          const topicAreaNode = graphRef.current?.getCellById(topicArea.id);
-          topicAreaNode?.setData(topicArea);
           return topicArea;
         }
         return item;
@@ -431,12 +453,13 @@ export default function erModel() {
   const addRemark = () => {
     const remarkId = uuid();
     const newRemark = {
+      isRemark: true,
       id: remarkId,
       name: "备注_" + (project.remarks.length + 1),
       text: "",
       style: {
-        x: 0,
-        y: 0,
+        x: 300,
+        y: 300,
         width: 200,
         height: 100,
         background: "#fcf7ac",
@@ -456,8 +479,6 @@ export default function erModel() {
       ...(state || {}),
       remarks: state.remarks.map((item) => {
         if (item.id === remark.id) {
-          const remarkNode = graphRef.current?.getCellById(remark.id);
-          remarkNode?.setData(remark);
           return remark;
         }
         return item;
@@ -574,49 +595,6 @@ export default function erModel() {
         }),
       };
     });
-    // 更新连线
-    const relationEdge = graphRef.current?.getCellById(relation.id);
-    if (relationEdge?.isEdge()) {
-      relationEdge.setSource({
-        cell: relation.primaryTable,
-        port: relation.primaryKey + "_port2",
-        anchor: "left",
-      });
-      relationEdge.setTarget({
-        cell: relation.foreignTable,
-        port: relation.foreignKey + "_port2",
-        anchor: "left",
-      });
-      // 更新标签
-      relationEdge.setLabelAt(0, {
-        attrs: {
-          txt: {
-            text:
-              relation.relationType === RelationType.OneToOne ||
-              relation.relationType === RelationType.OneToMany
-                ? "1"
-                : "n",
-          },
-        },
-        position: {
-          distance: 25,
-        },
-      });
-      relationEdge.setLabelAt(1, {
-        attrs: {
-          txt: {
-            text:
-              relation.relationType === RelationType.OneToMany ||
-              relation.relationType === RelationType.ManyToMany
-                ? "n"
-                : "1",
-          },
-        },
-        position: {
-          distance: -25,
-        },
-      });
-    }
   };
 
   /**
@@ -631,6 +609,166 @@ export default function erModel() {
     graphRef.current?.removeCell(relationId);
   };
 
+  /**
+   * 清空画布
+   */
+  const onClean = () => {
+    setProject(
+      {
+        ...project,
+        tables: [],
+        relations: [],
+        topicAreas: [],
+        remarks: [],
+      },
+      true,
+      true
+    );
+    graph?.clearCells();
+  };
+
+  const [clipboardCache, setClipboardCache] = useState<any>(null);
+
+  /**
+   * 剪切
+   */
+  const onCut = () => {
+    const cells = graphRef.current?.getSelectedCells();
+    if (cells?.[0]?.isNode) {
+      const cell = cells[0];
+      const data = cell.data;
+      setClipboardCache(data);
+      if (data?.isTable) {
+        setProject({
+          ...project,
+          tables: project.tables.filter((item) => item.table.id !== cell.id),
+        });
+      }
+      if (data?.isTopicArea) {
+        setProject({
+          ...project,
+          topicAreas: project.topicAreas.filter((item) => item.id !== cell.id),
+        });
+      }
+      if (data?.isRemark) {
+        setProject({
+          ...project,
+          remarks: project.remarks.filter((item) => item.id !== cell.id),
+        });
+      }
+    }
+  };
+
+  /**
+   * 复制
+   */
+  const onCopy = () => {
+    const cells = graphRef.current?.getSelectedCells();
+    if (cells?.[0]?.isNode) {
+      const cell = cells[0];
+      const data = cell.data;
+      setClipboardCache(data);
+    }
+  };
+
+  /**
+   * 粘贴
+   */
+  const onPaste = () => {
+    if (clipboardCache) {
+      const data = clipboardCache;
+      // 表格
+      if (data?.isTable) {
+        const tableId = uuid();
+        const newTable = {
+          ...data,
+          table: {
+            ...data.table,
+            id: tableId,
+            style: {
+              ...data.table.style,
+              x: data.table.style.x + 20,
+              y: data.table.style.y + 20,
+            }
+          },
+          tableColumnList: data.tableColumnList.map((item: ColumnItem) => {
+            return {
+              ...item,
+              id: uuid(),
+              parentBusinessTableId: tableId
+            }
+          })
+        }
+        setProject({
+          ...project,
+          tables: [...project.tables, newTable],
+        });
+      }
+      // 主题区域
+      if (data?.isTopicArea) {
+        const topicAreaId = uuid();
+        const newTopicArea = {
+          ...data,
+          id: topicAreaId,
+          style: {
+            ...data.style,
+            x: data.style.x + 20,
+            y: data.style.y + 20,
+          }
+        };
+        setProject({
+          ...project,
+          topicAreas: [...project.topicAreas, newTopicArea],
+        });
+      }
+      // 注释节点
+      if (data?.isRemark) {
+        const remarkId = uuid();
+        const newRemark = {
+          ...data,
+          id: remarkId,
+          style: {
+            ...data.style,
+            x: data.style.x + 20,
+            y: data.style.y + 20,
+          }
+        };
+        setProject({
+          ...project,
+          remarks: [...project.remarks, newRemark],
+        });
+      }
+    }
+  };
+
+  /**
+   * 删除
+   */
+  const onDelete = () => {
+    const cell = graphRef.current?.getSelectedCells();
+    if (cell?.[0]?.isNode) {
+      const data = cell[0].data;
+      if (data?.isTable) {
+        setProject({
+          ...project,
+          tables: project.tables.filter((item) => item.table.id !== cell[0].id),
+        });
+      }
+      if (data?.isTopicArea) {
+        setProject({
+         ...project,
+         topicAreas: project.topicAreas.filter((item) => item.id !== cell[0].id),
+        })
+      }
+      if (data?.isRemark) {
+        setProject({
+          ...project,
+          remarks: project.remarks.filter((item) => item.id !== cell[0].id),
+        });
+      }
+    }
+  };
+
   return {
     initGraph,
     graph,
@@ -653,5 +791,10 @@ export default function erModel() {
     canUndo,
     onRedo,
     onUndo,
+    onClean,
+    onCut,
+    onCopy,
+    onPaste,
+    onDelete,
   };
 }

+ 82 - 26
apps/er-designer/src/models/renderer.ts

@@ -1,3 +1,4 @@
+import { RelationType } from "@/enum";
 import { ProjectInfo, RemarkInfo, TableItemType, TopicAreaInfo } from "@/type";
 import { Graph } from "@antv/x6";
 
@@ -7,8 +8,8 @@ export const render = (graph: Graph, project: ProjectInfo) => {
   const renderTable = (tableItem: TableItemType) => {
     graph.addNode({
       shape: "table-node",
-      x: 300,
-      y: 100,
+      x: tableItem.table.style?.x || 1000,
+      y: tableItem.table.style?.y || 1000,
       width: 220,
       height: 69,
       id: tableItem.table.id,
@@ -44,8 +45,8 @@ export const render = (graph: Graph, project: ProjectInfo) => {
   const renderTopicArea = (topicArea: TopicAreaInfo) => {
     graph.addNode({
       shape: "topic-node",
-      x: 300,
-      y: 100,
+      x: topicArea.style?.x || 1000,
+      y: topicArea.style?.y || 1000,
       width: 200,
       height: 200,
       id: topicArea.id,
@@ -55,10 +56,10 @@ export const render = (graph: Graph, project: ProjectInfo) => {
   };
   // 渲染备注
   const renderRemark = (remark: RemarkInfo) => {
-    const notice = graph?.addNode({
+    graph?.addNode({
       shape: "notice-node",
-      x: 300,
-      y: 100,
+      x: remark.style?.x || 1000,
+      y: remark.style?.y || 1000,
       width: 200,
       height: 200,
       id: remark.id,
@@ -135,26 +136,28 @@ export const render = (graph: Graph, project: ProjectInfo) => {
         },
       },
     });
-    relationEdge?.appendLabel({
-      attrs: {
-        txt: {
-          text: 1,
+    if (project.setting.showRelation) {
+      relationEdge?.appendLabel({
+        attrs: {
+          txt: {
+            text: 1,
+          },
         },
-      },
-      position: {
-        distance: 25,
-      },
-    });
-    relationEdge?.appendLabel({
-      attrs: {
-        txt: {
-          text: 1,
+        position: {
+          distance: 25,
         },
-      },
-      position: {
-        distance: -25,
-      },
-    });
+      });
+      relationEdge?.appendLabel({
+        attrs: {
+          txt: {
+            text: 1,
+          },
+        },
+        position: {
+          distance: -25,
+        },
+      });
+    }
   };
 
   const cells = graph.getCells();
@@ -174,20 +177,36 @@ export const render = (graph: Graph, project: ProjectInfo) => {
   tables.forEach((tableItem) => {
     if (!graph.getCellById(tableItem.table.id)) {
       renderTable(tableItem);
+    } else {
+      const cell = graph.getCellById(tableItem.table.id);
+      if (cell?.isNode()) {
+        cell.setSize(project.setting.tableWidth, cell.size().height);
+        cell.setData(tableItem);
+      }
     }
   });
   topicAreas.forEach((topicArea) => {
     if (!graph.getCellById(topicArea.id)) {
       renderTopicArea(topicArea);
+    } else {
+      const cell = graph.getCellById(topicArea.id);
+      if (cell) {
+        cell.setData(topicArea);
+      }
     }
   });
   remarks.forEach((remark) => {
     if (!graph.getCellById(remark.id)) {
       renderRemark(remark);
+    } else {
+      const cell = graph.getCellById(remark.id);
+      if (cell) {
+        cell.setData(remark);
+      }
     }
   });
   // setTimeout(() => {
-    
+
   // }, 100);
   relations.forEach((relation) => {
     if (!graph.getCellById(relation.id)) {
@@ -202,6 +221,43 @@ export const render = (graph: Graph, project: ProjectInfo) => {
           columnId: relation.foreignKey,
         }
       );
+    } else {
+      const relationEdge = graph.getCellById(relation.id);
+      if (relationEdge.isEdge()) {
+        if (project.setting.showRelation) {
+          // 更新标签
+          relationEdge.setLabelAt(0, {
+            attrs: {
+              txt: {
+                text:
+                  relation.relationType === RelationType.OneToOne ||
+                  relation.relationType === RelationType.OneToMany
+                    ? "1"
+                    : "n",
+              },
+            },
+            position: {
+              distance: 25,
+            },
+          });
+          relationEdge.setLabelAt(1, {
+            attrs: {
+              txt: {
+                text:
+                  relation.relationType === RelationType.OneToMany ||
+                  relation.relationType === RelationType.ManyToMany
+                    ? "n"
+                    : "1",
+              },
+            },
+            position: {
+              distance: -25,
+            },
+          });
+        } else {
+          relationEdge.setLabels([]);
+        }
+      }
     }
   });
 };

+ 195 - 24
apps/er-designer/src/pages/er/components/Menu.tsx

@@ -1,7 +1,53 @@
-import React from "react";
-import { Button, Dropdown, Input } from "antd";
-import type { MenuProps } from "antd";
+import React, { useState } from "react";
+import { Button, Dropdown, Input, Modal, Switch } from "antd";
+import type { DropDownProps, MenuProps } from "antd";
+import { useModel } from "umi";
+import { useFullscreen } from "ahooks";
 export default function Menu() {
+  const {
+    project,
+    canRedo,
+    canUndo,
+    onRedo,
+    onUndo,
+    graph,
+    setProject,
+    onClean,
+    onCut,
+    onCopy,
+    onPaste,
+    onDelete,
+  } = useModel("erModel");
+  const [modal, contextHolder] = Modal.useModal();
+  const [isFullscreen, { toggleFullscreen }] = useFullscreen(document.body);
+  const [openKey, setOpenKey] = useState("");
+  const handleClean = () => {
+    modal.confirm({
+      title: "确认清除画布全部内容?",
+      content: "清空后将无法恢复",
+      cancelText: "取消",
+      okText: "确定",
+      onOk() {
+        onClean();
+      },
+    });
+  };
+
+  const handleChangeSetting = (key: string, value: boolean) => {
+    setProject({ ...project, setting: { ...project.setting, [key]: value } });
+  };
+
+  const handleZoom = (value: number) => {
+    if (value < 0.2) {
+      graph?.zoomTo(0.2);
+      return;
+    } else if (value > 2) {
+      graph?.zoomTo(2);
+      return;
+    }
+    graph?.zoomTo(value);
+  };
+
   const menuData: {
     key: string;
     label: string;
@@ -14,12 +60,16 @@ export default function Menu() {
       type: "group",
       children: [
         { key: "1-1", label: "新建" },
-        { key: "1-2", label: "新标签页打开" },
+        { key: "1-2", label: "新标签页打开" },
         { key: "1-3", label: "保存" },
         { key: "1-4", label: "保存为模版" },
         { key: "1-5", label: "发布模版" },
         { key: "1-6", label: "同步到数据表" },
-        { key: "1-7", label: "导出为图片" },
+        {
+          key: "1-7",
+          label: "导出为图片",
+          onClick: () => graph && graph?.exportPNG(),
+        },
       ],
     },
     {
@@ -27,13 +77,13 @@ export default function Menu() {
       label: "编辑",
       type: "group",
       children: [
-        { key: "1-1", label: "撤销" },
-        { key: "1-2", label: "恢复" },
-        { key: "1-3", label: "清楚" },
-        { key: "1-4", label: "剪切" },
-        { key: "1-5", label: "复制" },
-        { key: "1-6", label: "粘贴" },
-        { key: "1-7", label: "删除" },
+        { key: "1-1", label: "撤销", onClick: () => canUndo && onUndo() },
+        { key: "1-2", label: "恢复", onClick: () => canRedo && onRedo() },
+        { key: "1-3", label: "清除", onClick: handleClean },
+        { key: "1-4", label: "剪切", onClick: onCut },
+        { key: "1-5", label: "复制", onClick: onCopy },
+        { key: "1-6", label: "粘贴", onClick: onPaste },
+        { key: "1-7", label: "删除", onClick: onDelete },
       ],
     },
     {
@@ -41,16 +91,94 @@ export default function Menu() {
       label: "视图",
       type: "group",
       children: [
-        { key: "1-1", label: "菜单栏隐藏" },
-        { key: "1-2", label: "侧边栏隐藏" },
+        {
+          key: "1-1",
+          label: (
+            <span className="flex items-center justify-between">
+              <span>菜单栏隐藏</span>
+              <Switch
+                size="small"
+                checked={project.setting.showMenu}
+                onChange={(checked) => handleChangeSetting("showMenu", checked)}
+              />
+            </span>
+          ),
+        },
+        {
+          key: "1-2",
+          label: (
+            <span className="flex items-center justify-between">
+              <span>侧边栏隐藏</span>
+              <Switch
+                size="small"
+                checked={project.setting.showSidebar}
+                onChange={(checked) =>
+                  handleChangeSetting("showSidebar", checked)
+                }
+              />
+            </span>
+          ),
+        },
         { key: "1-3", label: "演示模式" },
-        { key: "1-4", label: "字段详情" },
-        { key: "1-5", label: "重置视图" },
-        { key: "1-6", label: "显示网格" },
-        { key: "1-7", label: "显示关系" },
-        { key: "1-8", label: "放大" },
-        { key: "1-9", label: "缩小" },
-        { key: "1-10", label: "全屏" },
+        {
+          key: "1-4",
+          label: (
+            <span className="flex items-center justify-between">
+              <span>字段详情</span>
+              <Switch
+                size="small"
+                checked={project.setting.showColumnDetail}
+                onChange={(checked) =>
+                  handleChangeSetting("showColumnDetail", checked)
+                }
+              />
+            </span>
+          ),
+        },
+        {
+          key: "1-5",
+          label: (
+            <span className="flex items-center justify-between">
+              <span>显示网格</span>
+              <Switch
+                size="small"
+                checked={project.setting.showGrid}
+                onChange={(checked) => handleChangeSetting("showGrid", checked)}
+              />
+            </span>
+          ),
+        },
+        {
+          key: "1-6",
+          label: (
+            <span className="flex items-center justify-between">
+              <span>显示关系</span>
+              <Switch
+                size="small"
+                checked={project.setting.showRelation}
+                onChange={(checked) =>
+                  handleChangeSetting("showRelation", checked)
+                }
+              />
+            </span>
+          ),
+        },
+        { key: "1-7", label: "重置视图", onClick: () => graph?.zoomTo(1) },
+        {
+          key: "1-8",
+          label: "放大",
+          onClick: () => handleZoom((graph?.zoom() || 1) + 0.2),
+        },
+        {
+          key: "1-9",
+          label: "缩小",
+          onClick: () => handleZoom((graph?.zoom() || 1) - 0.2),
+        },
+        {
+          key: "1-10",
+          label: isFullscreen ? "退出全屏" : "全屏",
+          onClick: () => toggleFullscreen(),
+        },
       ],
     },
     {
@@ -59,8 +187,34 @@ export default function Menu() {
       type: "group",
       children: [
         { key: "1-1", label: "修改记录" },
-        { key: "1-2", label: "自动保存" },
-        { key: "1-3", label: "自动同步" },
+        {
+          key: "1-2",
+          label: (
+            <span className="flex items-center justify-between">
+              <span>自动保存</span>
+              <Switch
+                size="small"
+                checked={project.setting.autoUpdate}
+                onChange={(checked) =>
+                  handleChangeSetting("autoUpdate", checked)
+                }
+              />
+            </span>
+          ),
+        },
+        {
+          key: "1-3",
+          label: (
+            <span className="flex items-center justify-between">
+              <span>自动同步</span>
+              <Switch size="small" />
+            </span>
+          ),
+        },
+        {
+          key: "1-4",
+          label: "表格宽度",
+        },
       ],
     },
     {
@@ -74,8 +228,19 @@ export default function Menu() {
     },
   ];
 
+  const handleOpenChange = (
+    nextOpen: boolean,
+    info: { source: "trigger" | "menu" },
+    key: string
+  ) => {
+    if (info.source === "trigger" || nextOpen) {
+      setOpenKey(nextOpen ? key : "");
+    }
+  };
+
   return (
     <div className="flex-1 flex items-center">
+      {contextHolder}
       <div className="logo h-48px m-l-12px">
         <svg className="icon h-48px w-48px" aria-hidden="true">
           <use xlinkHref="#icon-shujujianmo"></use>
@@ -88,7 +253,9 @@ export default function Menu() {
             variant="borderless"
             value={"模型名称"}
           />
-          <div className="bg-#eee text-12px leading-20px color-#666 rounded-4px p-x-4px p-y-2px">上次保存时间:2024-12-13 13:21:23</div>
+          <div className="bg-#eee text-12px leading-20px color-#666 rounded-4px p-x-4px p-y-2px">
+            上次保存时间:2024-12-13 13:21:23
+          </div>
         </div>
         <div className="flex">
           {menuData.map((item) => {
@@ -97,6 +264,10 @@ export default function Menu() {
                 key={item.key}
                 menu={{ items: item.children, style: { width: 200 } }}
                 placement="bottomLeft"
+                open={openKey === item.key}
+                onOpenChange={(nextOpen, info) =>
+                  handleOpenChange(nextOpen, info, item.key)
+                }
               >
                 <Button type="text" size="small">
                   {item.label}

+ 2 - 0
apps/er-designer/src/pages/er/components/Navigator.tsx

@@ -15,6 +15,8 @@ export default function Navigator() {
           width: mapRef.current.offsetWidth || 300,
           height: mapRef.current.offsetHeight || 200,
           padding: 10,
+          maxScale: 2,
+          minScale: 0.2
         })
       );
     }

+ 77 - 29
apps/er-designer/src/pages/er/components/RelationPanel.tsx

@@ -1,5 +1,13 @@
 import { SearchOutlined, SwapOutlined } from "@ant-design/icons";
-import { Button, Descriptions, Form, Input, Popconfirm, Popover, Select } from "antd";
+import {
+  Button,
+  Descriptions,
+  Form,
+  Input,
+  Popconfirm,
+  Popover,
+  Select,
+} from "antd";
 import React from "react";
 import { RELATION_TYPE_OPTIONS } from "@/constants";
 import { useModel } from "umi";
@@ -32,25 +40,32 @@ export default function RelationPanel() {
       primaryKey: record.foreignKey,
       foreignTable: record.primaryTable,
       foreignKey: record.primaryKey,
-    })
-  }
+    });
+  };
 
   const getPrimaryColumn = (item: ColumnRelation) => {
-    const tableItem = project.tables.find((table) => table.table.id === item.primaryTable);
-      
+    const tableItem = project.tables.find(
+      (table) => table.table.id === item.primaryTable
+    );
+
     return {
       table: tableItem?.table,
-      column: tableItem?.tableColumnList.find((column) => column.id === item.primaryKey)
-    }
+      column: tableItem?.tableColumnList.find(
+        (column) => column.id === item.primaryKey
+      ),
+    };
   };
 
   const getForeignColumn = (item: ColumnRelation) => {
-    const tableItem = project.tables
-      .find((table) => table.table.id === item.foreignTable);
+    const tableItem = project.tables.find(
+      (table) => table.table.id === item.foreignTable
+    );
     return {
       table: tableItem?.table,
-      column: tableItem?.tableColumnList.find((column) => column.id === item.foreignKey)
-    }
+      column: tableItem?.tableColumnList.find(
+        (column) => column.id === item.foreignKey
+      ),
+    };
   };
 
   return (
@@ -81,16 +96,26 @@ export default function RelationPanel() {
               hover:bg-[#fafafa] 
               cursor-pointer 
               m-b-[10px]"
-              onClick={() => setActive(active === item.id ? '' : item.id)}
+              onClick={() => setActive(active === item.id ? "" : item.id)}
             >
               <div className="font-bold truncate">{item.name}</div>
               <div>
-                <i className="iconfont icon-open m-r-10px" />
+                <i
+                  className="iconfont icon-open m-r-10px inline-block"
+                  style={{
+                    transform:
+                      active === item.id ? "rotate(180deg)" : "rotate(0deg)",
+                    transition: "all 0.3s",
+                  }}
+                />
               </div>
             </div>
             <div
               className="content overflow-hidden grid"
-              style={{ gridTemplateRows: active === item.id ? "1fr" : '0fr', transition: 'all 0.3s' }}
+              style={{
+                gridTemplateRows: active === item.id ? "1fr" : "0fr",
+                transition: "all 0.3s",
+              }}
             >
               <Form layout="vertical" className="overflow-hidden">
                 <div className="flex justify-between">
@@ -103,21 +128,44 @@ export default function RelationPanel() {
                   <Popover
                     trigger="click"
                     placement="right"
-                    content={<div className="min-w-200px">
-                      <Descriptions layout="vertical" bordered items={[
-                        {
-                          key: '1',
-                          label: '主键',
-                          children: <span>{getPrimaryColumn(item)?.table?.schemaName}({getPrimaryColumn(item)?.column?.schemaName})</span>
-                        },
-                        {
-                          key: '2',
-                          label: '外键',
-                          children: <span>{getForeignColumn(item)?.table?.schemaName}({getForeignColumn(item)?.column?.schemaName})</span>
-                        }
-                      ]}/>
-                      <Button className="w-full m-y-4px" icon={<SwapOutlined/>} type="primary" onClick={() => handleSwitchChange(item)}>交换</Button>
-                    </div>}
+                    content={
+                      <div className="min-w-200px">
+                        <Descriptions
+                          layout="vertical"
+                          bordered
+                          items={[
+                            {
+                              key: "1",
+                              label: "主键",
+                              children: (
+                                <span>
+                                  {getPrimaryColumn(item)?.table?.schemaName}(
+                                  {getPrimaryColumn(item)?.column?.schemaName})
+                                </span>
+                              ),
+                            },
+                            {
+                              key: "2",
+                              label: "外键",
+                              children: (
+                                <span>
+                                  {getForeignColumn(item)?.table?.schemaName}(
+                                  {getForeignColumn(item)?.column?.schemaName})
+                                </span>
+                              ),
+                            },
+                          ]}
+                        />
+                        <Button
+                          className="w-full m-y-4px"
+                          icon={<SwapOutlined />}
+                          type="primary"
+                          onClick={() => handleSwitchChange(item)}
+                        >
+                          交换
+                        </Button>
+                      </div>
+                    }
                   >
                     <div className="rounded-4px cus-btn w-32px h-32px bg-#eee flex-none text-center leading-32px cursor-pointer hover:bg-#ddd">
                       <i className="iconfont icon-gengduo" />

+ 8 - 1
apps/er-designer/src/pages/er/components/RemarkPanel.tsx

@@ -61,7 +61,14 @@ export default function RemarkPanel() {
             >
               <div className="font-bold truncate">{item.name}</div>
               <div>
-                <i className="iconfont icon-open m-r-10px" />
+                <i
+                  className="iconfont icon-open m-r-10px inline-block"
+                  style={{
+                    transform:
+                      activeKey === item.id ? "rotate(180deg)" : "rotate(0deg)",
+                    transition: "all 0.3s",
+                  }}
+                />
               </div>
             </div>
             <div

+ 8 - 1
apps/er-designer/src/pages/er/components/TableItem.tsx

@@ -39,12 +39,14 @@ export default function TableItem({
     onChange({
       tableColumnList,
       table: { ...table, [key]: value },
+      isTable: true,
     });
     setTable({ ...table, [key]: value });
   };
 
   const handleChangeColumn = (index: number, key: string, value: any) => {
     onChange({
+      isTable: true,
       table,
       tableColumnList: tableColumnList.map((item, i) => {
         if (index === i) {
@@ -80,6 +82,7 @@ export default function TableItem({
     onChange({
       table,
       tableColumnList: [...tableColumnList, newColumn],
+      isTable: true,
     });
   };
 
@@ -87,6 +90,7 @@ export default function TableItem({
     onChange({
       table,
       tableColumnList: tableColumnList.filter((item) => item.id !== columnId),
+      isTable: true,
     });
   };
 
@@ -161,7 +165,10 @@ export default function TableItem({
               onClick={(e) => e.stopPropagation()}
             />
           </Popover>
-          <i className="iconfont icon-open" />
+          <i className="iconfont icon-open inline-block" style={{
+            transform: active === table.id ? "rotate(180deg)" : "rotate(0deg)",
+            transition: "all 0.3s",
+          }} />
         </div>
       </div>
       <div

+ 190 - 108
apps/er-designer/src/pages/er/components/Toolbar.tsx

@@ -1,10 +1,20 @@
-import React from "react";
+import React, { useEffect, useState } from "react";
 import { Button, Tooltip, Divider, Dropdown } from "antd";
 import { DownOutlined } from "@ant-design/icons";
 import { useModel } from "umi";
 export default function Toolbar() {
-  const { addTable, addTopicArea, addRemark, graph, canRedo, canUndo, onRedo, onUndo } =
-    useModel("erModel");
+  const {
+    addTable,
+    addTopicArea,
+    addRemark,
+    graph,
+    canRedo,
+    canUndo,
+    onRedo,
+    onUndo,
+    project,
+    setProject,
+  } = useModel("erModel");
   const scaleMenu = {
     style: {
       width: 200,
@@ -13,145 +23,217 @@ export default function Toolbar() {
       {
         key: "1",
         label: "25%",
+        onClick: () => {
+          handleZoom(25);
+        },
       },
       {
         key: "2",
         label: "50%",
+        onClick: () => {
+          handleZoom(50);
+        },
       },
       {
         key: "3",
         label: "75%",
+        onClick: () => {
+          handleZoom(75);
+        },
       },
       {
         key: "4",
         label: "100%",
+        onClick: () => {
+          handleZoom(100);
+        },
       },
       {
         key: "5",
         label: "150%",
+        onClick: () => {
+          handleZoom(150);
+        },
       },
       {
         key: "6",
         label: "200%",
+        onClick: () => {
+          handleZoom(200);
+        },
       },
     ],
   };
 
+  const [scale, setScale] = useState(100);
+
+  useEffect(() => {
+    graph?.on("scale", (scaleInfo) => {
+      setScale(parseInt(scaleInfo.sx * 100 + ""));
+    });
+  }, [graph]);
+
+  const handleZoom = (value: number) => {
+    if (value > 200) {
+      graph?.zoomTo(2);
+      return;
+    }
+    if (value < 20) {
+      graph?.zoomTo(0.2);
+      return;
+    }
+    graph?.zoomTo(value / 100);
+  };
+
+  const handleChangeShowMenu = () => {
+    setProject({
+      ...project,
+      setting: { ...project.setting, showMenu: !project.setting.showMenu },
+    });
+  };
+
   return (
-    <div className="flex items-center py-0px bg-#fafafa justify-center">
-      <div className="group">
-        <div className="flex items-center">
-          <Tooltip title="撤销上一步操作">
-            <div
-              className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
-              style={{
-                opacity: canUndo ? 1 : 0.5,
-              }}
-              onClick={() => canUndo && onUndo()}
-            >
-              <svg className="icon h-24px w-24px" aria-hidden="true">
-                <use xlinkHref="#icon-undo"></use>
-              </svg>
-            </div>
-          </Tooltip>
-          <Tooltip title="恢复上一步操作">
-            <div
-              className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
-              style={{
-                opacity: canRedo ? 1 : 0.5,
-              }}
-              onClick={() => canRedo && onRedo()}
-            >
-              <svg className="icon h-24px w-24px" aria-hidden="true">
-                <use xlinkHref="#icon-redo"></use>
-              </svg>
-            </div>
-          </Tooltip>
+    <div className="flex justify-between bg-#fafafa">
+      <div></div>
+      <div className="flex items-center py-0px justify-center h-32px">
+        <div className="group">
+          <div className="flex items-center">
+            <Tooltip title="撤销上一步操作">
+              <div
+                className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
+                style={{
+                  opacity: canUndo ? 1 : 0.5,
+                }}
+                onClick={() => canUndo && onUndo()}
+              >
+                <svg className="icon h-24px w-24px" aria-hidden="true">
+                  <use xlinkHref="#icon-undo"></use>
+                </svg>
+              </div>
+            </Tooltip>
+            <Tooltip title="恢复上一步操作">
+              <div
+                className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
+                style={{
+                  opacity: canRedo ? 1 : 0.5,
+                }}
+                onClick={() => canRedo && onRedo()}
+              >
+                <svg className="icon h-24px w-24px" aria-hidden="true">
+                  <use xlinkHref="#icon-redo"></use>
+                </svg>
+              </div>
+            </Tooltip>
+          </div>
         </div>
-      </div>
 
-      <div className="h-30px border-transparent border-r-1px border-solid border-r-#eee m-x-8px"></div>
+        <div className="h-30px border-transparent border-r-1px border-solid border-r-#eee m-x-8px"></div>
 
-      <div className="group">
-        <div className="flex items-center">
-          <Tooltip title="创建表">
-            <div
-              className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
-              onClick={() => addTable()}
-            >
-              <svg className="icon h-24px w-24px" aria-hidden="true">
-                <use xlinkHref="#icon-biaoge"></use>
-              </svg>
-            </div>
-          </Tooltip>
-          <Tooltip title="创建主题域">
-            <div
-              className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
-              onClick={addTopicArea}
-            >
-              <svg className="icon h-24px w-24px" aria-hidden="true">
-                <use xlinkHref="#icon-ditukuangxuan"></use>
-              </svg>
-            </div>
-          </Tooltip>
-          <Tooltip title="创建备注">
-            <div
-              className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
-              onClick={addRemark}
-            >
-              <svg className="icon h-24px w-24px" aria-hidden="true">
-                <use xlinkHref="#icon-beizhu"></use>
-              </svg>
-            </div>
-          </Tooltip>
+        <div className="group">
+          <div className="flex items-center">
+            <Tooltip title="创建表">
+              <div
+                className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
+                onClick={() => addTable()}
+              >
+                <svg className="icon h-24px w-24px" aria-hidden="true">
+                  <use xlinkHref="#icon-biaoge"></use>
+                </svg>
+              </div>
+            </Tooltip>
+            <Tooltip title="创建主题域">
+              <div
+                className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
+                onClick={addTopicArea}
+              >
+                <svg className="icon h-24px w-24px" aria-hidden="true">
+                  <use xlinkHref="#icon-ditukuangxuan"></use>
+                </svg>
+              </div>
+            </Tooltip>
+            <Tooltip title="创建备注">
+              <div
+                className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
+                onClick={addRemark}
+              >
+                <svg className="icon h-24px w-24px" aria-hidden="true">
+                  <use xlinkHref="#icon-beizhu"></use>
+                </svg>
+              </div>
+            </Tooltip>
+          </div>
         </div>
-      </div>
 
-      <div className="h-30px border-transparent border-r-1px border-solid border-r-#eee m-x-8px"></div>
+        <div className="h-30px border-transparent border-r-1px border-solid border-r-#eee m-x-8px"></div>
 
-      <div className="group">
-        <div className="flex items-center">
-          <Tooltip title="保存模型">
-            <div className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200">
-              <svg className="icon h-24px w-24px" aria-hidden="true">
-                <use xlinkHref="#icon-baocun"></use>
-              </svg>
-            </div>
-          </Tooltip>
-          <Tooltip title="待办项">
-            <div className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200 opacity-50">
-              <svg className="icon h-24px w-24px" aria-hidden="true">
-                <use xlinkHref="#icon-daiban"></use>
-              </svg>
-            </div>
-          </Tooltip>
+        <div className="group">
+          <div className="flex items-center">
+            <Tooltip title="保存模型">
+              <div className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200">
+                <svg className="icon h-24px w-24px" aria-hidden="true">
+                  <use xlinkHref="#icon-baocun"></use>
+                </svg>
+              </div>
+            </Tooltip>
+            <Tooltip title="待办项">
+              <div className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200 opacity-50">
+                <svg className="icon h-24px w-24px" aria-hidden="true">
+                  <use xlinkHref="#icon-daiban"></use>
+                </svg>
+              </div>
+            </Tooltip>
+          </div>
         </div>
-      </div>
 
-      <div className="h-30px border-transparent border-r-1px border-solid border-r-#eee m-x-8px"></div>
+        <div className="h-30px border-transparent border-r-1px border-solid border-r-#eee m-x-8px"></div>
 
-      <div className="group">
-        <div className="flex items-center">
-          <Tooltip title="放大">
-            <div className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200">
-              <svg className="icon h-24px w-24px" aria-hidden="true">
-                <use xlinkHref="#icon-suofangda"></use>
-              </svg>
-            </div>
-          </Tooltip>
-          <Tooltip title="缩小">
-            <div className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200 opacity-50">
-              <svg className="icon h-24px w-24px" aria-hidden="true">
-                <use xlinkHref="#icon-suofangxiao"></use>
-              </svg>
-            </div>
-          </Tooltip>
-          <Dropdown menu={scaleMenu}>
-            <span className="text-14px leading-18px">
-              <span>100%</span>
-              <DownOutlined className="text-12px ml-4px" />
-            </span>
-          </Dropdown>
+        <div className="group">
+          <div className="flex items-center">
+            <Tooltip title="放大">
+              <div
+                className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
+                style={{ opacity: scale >= 200 ? 0.5 : 1 }}
+                onClick={() => handleZoom(scale + 25)}
+              >
+                <svg className="icon h-24px w-24px" aria-hidden="true">
+                  <use xlinkHref="#icon-suofangda"></use>
+                </svg>
+              </div>
+            </Tooltip>
+            <Tooltip title="缩小">
+              <div
+                className="btn flex flex-col items-center cursor-pointer py-4px px-10px hover:bg-gray-200"
+                style={{ opacity: scale <= 20 ? 0.5 : 1 }}
+                onClick={() => handleZoom(scale - 25)}
+              >
+                <svg className="icon h-24px w-24px" aria-hidden="true">
+                  <use xlinkHref="#icon-suofangxiao"></use>
+                </svg>
+              </div>
+            </Tooltip>
+            <Dropdown menu={scaleMenu}>
+              <span className="text-14px leading-18px w-120px">
+                <span>{scale}%</span>
+                <DownOutlined className="text-12px ml-4px" />
+              </span>
+            </Dropdown>
+          </div>
+        </div>
+      </div>
+      <div>
+        <div
+          className="btn py-4px px-10px hover:bg-gray-200 opacity-50 flex items-center justify-center cursor-pointer"
+          onClick={handleChangeShowMenu}
+        >
+          <i
+            className="iconfont icon-open leading-24px inline-block"
+            style={{
+              transition: "all 0.3s",
+              transform: project.setting.showMenu
+                ? "rotate(180deg)"
+                : "rotate(0deg)",
+            }}
+          ></i>
         </div>
       </div>
     </div>

+ 35 - 17
apps/er-designer/src/pages/er/index.tsx

@@ -15,7 +15,7 @@ const { Header, Content, Sider } = Layout;
 
 const App: React.FC = () => {
   const containerRef = React.useRef(null);
-  const { initGraph } = useModel("erModel");
+  const { initGraph, project } = useModel("erModel");
 
   useEffect(() => {
     if (containerRef.current) {
@@ -27,50 +27,68 @@ const App: React.FC = () => {
     {
       key: "1",
       label: "表",
-      children: <TablePanel />
+      children: <TablePanel />,
     },
     {
       key: "2",
       label: "关系",
-      children: <RelationPanel />
+      children: <RelationPanel />,
     },
     {
       key: "3",
       label: "主题区域",
-      children: <ThemePanel />
+      children: <ThemePanel />,
     },
     {
       key: "4",
       label: "注释",
-      children: <RemarkPanel/>
+      children: <RemarkPanel />,
     },
   ];
 
   return (
     <Layout className="h-100vh">
-      <Header className="bg-white h-100px border-b-1px border-b-solid border-b-gray-200 p-x-0 flex flex-col">
-        <Menu/>
+      <Header
+        className="bg-white h-100px border-b-1px border-b-solid border-b-gray-200 p-x-0 flex flex-col"
+        style={{
+          height: project.setting.showMenu ? "100px" : "32px",
+          transition: "all 0.3s ease-in-out",
+        }}
+      >
+        <div
+          className="grid"
+          style={{ 
+            gridTemplateRows: project.setting.showMenu ? "1fr" : "0fr",
+            transition: 'all 0.3s'
+          }}
+        >
+          <div className="overflow-hidden">
+            <Menu />
+          </div>
+        </div>
         <Toolbar />
       </Header>
       <Layout>
         <Sider
-          width={360}
+          width={project.setting.showSidebar ? 360 : 0}
           style={{ background: "#fff", borderRight: "1px solid #eee" }}
         >
-          <ConfigProvider theme={{
-            components: {
-              Tabs: {
-                colorPrimary: "#000"
-              }
-            }
-          }}>
-            <Tabs  animated items={tabItems} centered/>
+          <ConfigProvider
+            theme={{
+              components: {
+                Tabs: {
+                  colorPrimary: "#000",
+                },
+              },
+            }}
+          >
+            <Tabs animated items={tabItems} centered />
           </ConfigProvider>
         </Sider>
         <Layout>
           <Content>
             <div id="graph-container" ref={containerRef}></div>
-            <Navigator/>
+            <Navigator />
           </Content>
         </Layout>
       </Layout>

+ 8 - 1
apps/er-designer/src/type.d.ts

@@ -88,6 +88,7 @@ export interface TopicAreaInfo {
   name: string;
   // 主题区域样式
   style: Record<string, any>;
+  isTopicArea: boolean;
 }
 
 /**
@@ -107,6 +108,7 @@ export interface RemarkInfo {
     height: number;
     background: string;
   };
+  isRemark: boolean;
 }
 
 /**
@@ -114,7 +116,8 @@ export interface RemarkInfo {
  */
 export type TableItemType = {
   table: ViewTable & {openSync: boolean; style: Record<string, any>},
-  tableColumnList: ColumnItem[]
+  tableColumnList: ColumnItem[],
+  isTable: boolean;
 };
 
 /**
@@ -162,5 +165,9 @@ export interface ProjectInfo {
     showGrid: boolean;
     // 展示关系
     showRelation: boolean;
+    // 自动更新
+    autoUpdate: boolean;
+    // 表格宽度
+    tableWidth: number;
   }
 }

+ 1 - 0
package.json

@@ -29,6 +29,7 @@
     "@antv/x6-plugin-keyboard": "^2.2.3",
     "@antv/x6-plugin-minimap": "^2.0.7",
     "@antv/x6-plugin-scroller": "^2.0.10",
+    "@antv/x6-plugin-selection": "^2.2.2",
     "@antv/x6-plugin-snapline": "^2.1.7",
     "@antv/x6-plugin-transform": "^2.1.8",
     "@antv/x6-react-shape": "^2.2.3",

+ 11 - 0
pnpm-lock.yaml

@@ -41,6 +41,9 @@ importers:
       '@antv/x6-plugin-scroller':
         specifier: ^2.0.10
         version: 2.0.10(@antv/x6@2.18.1)
+      '@antv/x6-plugin-selection':
+        specifier: ^2.2.2
+        version: 2.2.2(@antv/x6@2.18.1)
       '@antv/x6-plugin-snapline':
         specifier: ^2.1.7
         version: 2.1.7(@antv/x6@2.18.1)
@@ -765,6 +768,14 @@ packages:
       '@antv/x6': 2.18.1
     dev: false
 
+  /@antv/x6-plugin-selection@2.2.2(@antv/x6@2.18.1):
+    resolution: {integrity: sha512-s2gtR9Onlhr7HOHqyqg0d+4sG76JCcQEbvrZZ64XmSChlvieIPlC3YtH4dg1KMNhYIuBmBmpSum6S0eVTEiPQw==}
+    peerDependencies:
+      '@antv/x6': ^2.x
+    dependencies:
+      '@antv/x6': 2.18.1
+    dev: false
+
   /@antv/x6-plugin-snapline@2.1.7(@antv/x6@2.18.1):
     resolution: {integrity: sha512-AsysoCb9vES0U2USNhEpYuO/W8I0aYfkhlbee5Kt4NYiMfQfZKQyqW/YjDVaS2pm38C1NKu1LdPVk/BBr4CasA==}
     peerDependencies: