如何用AntV X6实现跟ProcessOn一样的思维导图?

最近有个需求,需要用x6实现一个思维导图,交互类似于processOn,并封装成组件(阅读此文章前,请了解一些x6的知识)。
技术站点:AntV x6,vue,javascript, css

注意:此文章不会提供全部源码,只是提供解决思路跟部分关键代码,很多坑,里面有提到一部分,其他的大家实践中可以自己探索一下。然后代码跟样式都是最初版本,没有经过优化,大家看着去优化优化
思维导图.png
效果图:
非编辑模式(两种连接器)
1689577997992(1).jpg1689578048081(1).jpg
编辑模式
1689578022179(1).jpg

1689578151572(1).jpg

组件具体需求

  • 1:传入数据渲染一个树形结构的数据
  • 2:传入数据支持修改节点文字颜色,文字大小等属性(简单暂不说)
  • 3:支持节点文字编辑 (双击编辑节点)
  • 4:支持几种子节点的布局算法(TB,BT,LR,RL)
  • 5:支持几种连接器类型(path曲线,连接两个节点)
  • 7:支持增加子节点,删除节点(每个节点有个+,点击可以新增节点,删除可以按backspace或者右击出现操作面板,选择删除操作)
  • 8:支持调整子节点的顺序(节点拖拽到目标节点可跟目标节点相互交换数据)
  • 9:支持节点的样式定义
  • 10:支持画布拖拽、缩放
  • 11:编辑模式跟非编辑模式(非编辑模式,仅支持查看,新增删除,编辑节点等操作都不支持)
  • 以上具体交互请参考processOn

需求一:如何渲染一个树形结构?

一棵树由传入组件的数据,节点(父节点:type = ‘parent’,子节点:type=”child”,以及另一种特殊节点,双击编辑文案时,出现的输入框),边,以及连接器组成

1.数据

<template> 
<div style="height: 300px; width: 100%"> 
   <Mindmap :mindData="mindData" lineStroke="#A2B1C3" ></Mindmap> 
</div> 
</template> 
<script> 
export default { name: "MindmapDemo", 
data() {
return 
{
mindData: { 
id: "1", 
type: "parent", 
label: "中心主题", 
children: [ 
    { 
    id: "1-1", type: "child", label: "分支主题1", 
     children: [ 
        { id: "1-1-1", type: "child", label: "子主题1" }, 
        { id: "1-1-2", type: "child", label: "子主题2" },
        ], 
     },
    { id: "1-2", type: "child", label: "分支主题2"}, 
 ], },
 } 
 }
}; 
</script>

2. 节点创建

X6是基于 SVG 的渲染引擎,内置节点有Rect,CircleHTML等各种类型,但是因为我的需求对样式要求比较高,所以我选择的是html节点进行注册

// 节点注册
// nodeName: 节点名称,对应节点类型
export const creatHtmlDom = (nodeName) => {
  if (nodeName in Shape.HTML.shapeMaps) {
    return;
  }
  Shape.HTML.register({
    shape: nodeName,
    // propsData: 组件传入的所有属性,相当于this.$props
    // data: mindData,传入组件的节点数据
    effect: ["data", "propsData"],
    html: (cell) => {
      const { nodeData, propsData } = cell.getData();
      if (nodeName == "edit") {
        // 创建编辑节点输入框,设置样式 
        return editDom(nodeData, propsData);
      } else if (nodeName == "child") {
      // 子节点样式
        return domChildContent(nodeData, propsData);
      } else if (nodeName == "parent") {
      // 父节点样式
        return domParentContent(nodeData, propsData);
      }
    },
  });

};

mounted中使用(节点名称可以加组件前缀优化,暂时先这样)

creatHtmlDom("parent");
creatHtmlDom("child");
creatHtmlDom("edit");

3. 边

注意:边要加key,key唯一,不然当你更改子组件布局方向时(比如横向布局改成纵向布局),由于你首次注册的是横向的布局,当你更改成纵向时,没有对应的连接器,会导致边的走向出现问题!!

export const registerEdge = (params, key) => {
  const { lineStroke, strokeWidth } = params;
  Graph.registerEdge(
    "mindmap-edge-" + key,
    {
      inherit: "edge",
      // 连接器名称
      connector: {
        name: "mindmap-" + key,
      },
      attrs: {
        line: {
          targetMarker: "",
          stroke: lineStroke,
          strokeWidth: strokeWidth,
        },
      },
      zIndex: 0,
    },

    true

  );

};

mounted中使用:

registerEdge(this.$props, this.key);

4. 连接器

x6中其实有现成的连接器,但是因为需求需要的线条跟提供的不太一致,所以只能自己写,哭
可以自己去学习一下path

我定义的连接器类型:compact:紧凑连接器(默认),loose:疏松连接器

export const registerConnector = (params, key) => {
  const { direction, connector } = params;
  // 连接器
  Graph.registerConnector(
    "mindmap-" + key,
    (sourcePoint, targetPoint, routerPoints, options) => {
      const params = {
        sourcePoint: sourcePoint,
        targetPoint: targetPoint,
        routerPoints: routerPoints,
        options: options,
        direction,
      };
      if (connector == "loose") {
        return looseConnector(params);
      } else {
        return compactConnector(params);
      }
    },

    true

  );

};

export const looseConnector = (params) => {
  const { sourcePoint, targetPoint, options, direction } = params;
  const midX = sourcePoint.x;
  const midY = sourcePoint.y;
  let ctrX = (targetPoint.x - midX) / 5 + midX;
  let ctrY = targetPoint.y;

  if (direction == "TB" || direction == "BT") {
    ctrX = targetPoint.x;
    ctrY = (targetPoint.y - midY) / 5 + midY;
  }

  const pathData = `
   M ${sourcePoint.x} ${sourcePoint.y}
   L ${midX} ${midY}
   Q ${ctrX} ${ctrY} ${targetPoint.x} ${targetPoint.y}
  `;
  return options.raw ? Path.parse(pathData) : pathData;
};

export const compactConnector = (params) => {
  const { sourcePoint, targetPoint, direction } = params;
  let hgap = Math.abs(targetPoint.x - sourcePoint.x);

  const path = new Path();
  path.appendSegment(Path.createSegment("M", sourcePoint.x, sourcePoint.y));
  path.appendSegment(Path.createSegment("L", sourcePoint.x, sourcePoint.y));
  let x1 =
    sourcePoint.x < targetPoint.x
      ? sourcePoint.x + hgap / 2
      : sourcePoint.x - hgap / 2;
  let y1 = sourcePoint.y;
  let x2 =
    sourcePoint.x < targetPoint.x
      ? targetPoint.x - hgap / 2
      : targetPoint.x + hgap / 2;
  let y2 = targetPoint.y;

  if (direction == "TB" || direction == "BT") {
    hgap = Math.abs(targetPoint.y - sourcePoint.y);
    x1 = sourcePoint.x;
    y1 =
      sourcePoint.y < targetPoint.y
        ? sourcePoint.y + hgap / 2
        : sourcePoint.y - hgap / 2;
    x2 = targetPoint.x;
    y2 =
      sourcePoint.y < targetPoint.y
        ? targetPoint.y - hgap / 2
        : targetPoint.y + hgap / 2;
  }
  // 水平三阶贝塞尔曲线
  path.appendSegment(
    Path.createSegment("C", x1, y1, x2, y2, targetPoint.x, targetPoint.y)
  );
  // path.appendSegment(Path.createSegment("L", targetPoint.x + 2, targetPoint.y));

  return path.serialize();
};

5. 如何渲染组件接收的数据?

renderChart(params = {}) {
     // isFirst:首次调用  flag: 
      const { flag = false, isFirst = false } = params;
      if (!this.graph) return;
      const cells = [];

      const traverse = (data) => {
        if (data) {
          // newType包含编辑输入框节点
          let newType = flag && data.newType ? data.newType : data.type;
          // 注意:getNodeLabelWidth:是自动计算节点的宽度
          const newWidth = getNodeLabelWidth(newType, data, this.$props);
          // 注意:displayTextHeight:是自动计算节点的长度
          const newHeight = displayTextHeight(newType, data, this.$props);
          cells.push(
            this.graph.createNode({
            // 节点id,唯一
              id: data.id,
              // 原类型
              type: data.type,
              // 添加新类型(原类型只有child,parent,双击会添加edit)
              newType: newType,
              // 节点类型(child,parent,edit)
              shape: newType,
              // 创建节点,并设置对应的宽高
              width: newWidth,
              // strokeWidth节点下划线宽度
              height:
                newType == "parent" ? newHeight : newHeight + this.strokeWidth,
              label: data.label,
              // 传入节点的数据
              data: {
                nodeData: data,
                propsData: {
                  ...this.$props,
                  // dropType: this.getDropIndicator,
                },
              },
            })
          );
          if (data.children) {
            data.children.forEach((item) => {
              const {
                sourceDirection,
                targetDirection,
                sourceArgs,
                targetArgs,
              } = getConfiguration(this.direction);
              cells.push(
                this.graph.createEdge({
                  shape: "mindmap-edge-" + this.key,
                  source: {
                    cell: data.id,
                    anchor: {
                      name: sourceDirection,
                      args: sourceArgs,
                    },
                  },
                  target: {
                    cell: item.id,
                    anchor: {
                      name: targetDirection,
                      args: targetArgs,
                    },
                  },
                })
              );
              traverse(item);
            });
          }
        }
      };
      // 递归创建节点
      traverse(this.mindData);
      // 设置节点数据
      this.graph.resetCells(cells);
      // 布局算法(后面)
      layout({
        graph: this.graph,
        direction: this.direction,
        nodesep: this.nodesep,
        ranksep: this.ranksep,
        autoCalculate: true, // 自动计算节点宽度高度
        flag: flag,
        strokeWidth: this.strokeWidth, // 子节点计算高度时会用到
      });
    },

6. 如何处理布局算法,支持(LR左到右,RL右到左,TB上到下,BT下到上)?

1689576211078.png

// 自动布局
// graph:Graph实例,direction布局方向,nodesep节点间距,
// ranksep层间距,nodeWidth节点宽度,nodeHeight节点高度
// autoCalculate是否自动计算节点宽度
export const layout = (params) => {
  const {
    graph,
    direction,
    nodesep,
    ranksep,
    nodeWidth,
    nodeHeight,
    autoCalculate = false
  } = params;
  if (!graph) return;
  const dir = direction || "TB"; // LR RL TB BT
  const nodes = graph.getNodes();
  const edges = graph.getEdges();
  const g = new dagre.graphlib.Graph();
  // 设置图
  g.setGraph({
    rankdir: dir,
    nodesep: nodesep,
    ranksep: ranksep,
  });

  // 设置一个新的默认值,以便于在没有指定标签创建节点时,分配过去。
  // 如果val不是一个函数,将会作为标签分配。 如果是一个函数, 正被创建的节点的id将会调用此函数。
  // 创建边的初始对象,便于布局中的最终信息复制回输入图表,不然,输入图表的对象为空,赋值时,会报取值错误
  g.setDefaultEdgeLabel(() => ({}));
  // or:g.setDefaultEdgeLabel({});

  let width = nodeWidth || 170;
  let height = nodeHeight || 75;
  // 设置节点
   nodes.forEach((node) => {
    if (autoCalculate) {
      const pos = node.size();
      width = pos.width;
      height = pos.height;
    }

    g.setNode(node.id, { width: width, height });
  });

  edges.forEach((edge) => {
    const source = edge.getSource();
    const target = edge.getTarget();
    g.setEdge(source.cell, target.cell);
  });

  dagre.layout(g);

  g.nodes().forEach((id) => {
    const node = graph.getCellById(id);
    if (node) {
      const pos = g.node(id);
      if (autoCalculate) {
        // 纵向布局,点要定在节点宽度中间,所以将x - 节点宽度一半
        // 横向布局时,点要定在节点高度中间,所以将y - 节点高度一半
        node.position(pos.x - pos.width / 2, pos.y - pos.height / 2);
      } else {
        node.position(pos.x - width, pos.y - height);
      }
    }
  });
};

需求二:如何处理双击编辑节点功能

思路:监听双击事件,双击到目标节点时,将节点的类型替换成edit(前面edit类型节点已经注册,所以替换newType就行)

    // 编辑节点文字
    handleEditNode(evt) {
      // 清理定时器的原因,后面会说到 
      this.timeout && clearTimeout(this.timeout);
      const id = evt.target.dataset.id;
      if (id) {
        if (id) {
          // 寻找目标元素的数据
          const res = findItem(this.mindData, id);
          const dataItem = res && res.node;
          if (dataItem) {
            dataItem.newType = "edit";
            // 计算实际宽度
            dataItem.width = getNodeLabelWidth("edit", dataItem, this.$props);
            const params = {
              flag: true,
            };
            this.renderChart(params);
            setTimeout(() => {
              // 视图渲染完成后自动聚焦
              const params = {
                data: dataItem,
                el: this.$el,
              };
              const target = findTargetNode(params);
              target && target.focus();
            }, 100);
            // 编辑节点还原
            const clickEdit = (event) => {
              if (
                !(
                  event.target &&
                  event.target.className &&
                  event.target.className.includes &&
                  event.target.className.includes("edit-input")
                )
              ) {
                if (dataItem && event.target.tagName !== "I") {
                  this.$el.removeEventListener("mousedown", clickEdit);
                  const params = {
                    data: dataItem,
                    el: this.$el,
                  };
                  const target = findTargetNode(params);
                  const value = target && target.value;
                  if (value) {
                    dataItem.label = value;
                    // 原节点替换
                    dataItem.newType = dataItem.type;
                  }
                  this.renderChart();
                }
              }
            };
            this.$el.addEventListener("mousedown", clickEdit);
          }
        }
      }
    },

需求三:节点拖拽交换位置(重点!!!)

其实X6是支持拖拽节点的,所以我原先的想法是想利用x6的节点拖拽,直接将节点拖到目标节点后,给目标节点添加对应的类名,设置对应的样式,但是这方案被老大否了,要求是要跟processOn一样的交互

仔细观察processOn的交互,鼠标按下选中节点,会产生一个透明度不高的节点(图一),并随着鼠标的移动而移动,如果拖动到目标节点的上部,节点上边会产生指示器,拖到下面会产生指示器,拖到目标节点中间,只是边框变色(图二)。

1. 思路

    1. 监听节点mousedown事件,生成一个新节点(虚拟节点),绝对定位,节点插入到当前选中节点之前,拥有共同父节点。
  • 2.监听鼠标的mousemove事件,更改虚拟节点的left,top的定位 ,
  • 3.寻找目标节点(离当前虚拟节点最近的节点)
  • 4.分析拖动到目标节点的位置dropType,前面(before),后面(after),自己(inner),这部分需要注意横向跟纵向布局
  • 5.针对不同的dropType,对目标节点添加不同的兄弟节点,兄弟节点的样式覆盖目标节点,产生一个位置指示器
  • 6.拖动完成后,监听mouseup事件,分析如何换数据(交换数据规则:1. 子节点拖至父节点只能作为父节点的子级,不可成为兄弟节点 2. dropType为inner,拖动节点成为目标节点的子级;dropType为before,拖动节点插入到目标节点之前;dropType为after,拖动节点插入到目标节点之后 2.同级且相邻节点交换时,比如左右布局时,后节点拖动到前节点下面,不予处理,相对的,前节点拖动到后节点上面不予处理,因为本来拖动节点就在dropType相对应的位置 )3.当拖动节点为父节点,目标节点为子节点时,不予处理

事件流程
1689145077381.png

图一:

1689061848202.jpg
图二:

image.png
效果图(样式有点丑,哈哈哈):

1689576350582.png

2. 监听mousedown

// isEdit是否为编辑模式,非编辑模式不予监听
this.isEdit &&
this.graph.on("node:mousedown", (evt) => {
// 注意:鼠标进行拖动之前需要关闭画布平移操作,不然画布平移事件会导致节点移动功能冲突
  this.graph.disablePanning();
  this.mousedownHandler(evt);
});
mousedownHandler(evt) {
  const { node, e } = evt;
  // const id = e.target.dataset.id;
  // 注意:设置定时器,因为节点上绑定了双击事件,
  // 如果mousedown先触发会产生虚拟节点,此节点会将原节点覆盖,导致双击事件无法被触发
  const id = node.id;
  this.timeout && clearTimeout(this.timeout);
  this.timeout = setTimeout(() => {
    if (id) {
      // 节点属性赋值
      const el_ = e.target;
      const type = el_.dataset.type;

      if (type == "child") {
        // 处理节点拖拽
        this.dropType = "";
        this.dragState.dragging = true;
        const childNodes = this.$el.querySelectorAll(
          ".mindmap__node-child-wrap-node"
        );
        // 子节点查找
        const target = Array.from(childNodes).find((item) => {
          return item.dataset.id == id;
        });

        const cloneEl = target && target.cloneNode(true);
        cloneEl.classList.add("flutter"); // 浮动
        cloneEl.style.cursor = "move";
        cloneEl.style.height = "100%";
        cloneEl.style.width = "100%";
        // 不能直接设置宽高,不然当鼠标缩放时,选中的节点会缩放,样式异常!!
        // cloneEl.style.height =
        //   target.getBoundingClientRect().height - this.strokeWidth + "px";
        // cloneEl.style.width = target.getBoundingClientRect().width + "px";
        cloneEl.style.top = "-50%";
        cloneEl.display = "inline-block";
        e.target.parentElement.appendChild(cloneEl); // 加入节点
        this.dragState.cloneNode = cloneEl;
        // 记录位置
        this.dragState.initial.clientX = e.clientX;
        this.dragState.initial.clientY = e.clientY;
      }
    }
  }, 500);

3. 监听mousemove

image.png

this.isEdit &&
        wrapper.addEventListener("mousemove", this.mousemoveHandler);
  
mousemoveHandler(e) {
  if (
    this.dragState.dragging &&
    this.dragState.cloneNode) {
    // 处理元素的移动:改变 left top 定位
    let zoom = this.graph.zoom();
    // 注意,放大缩小时,路径变化
    zoom = 1 / zoom;
    this.dragState.cloneNode.style.left =
      (e.clientX - this.dragState.initial.clientX) * zoom + "px";
    this.dragState.cloneNode.style.top =
      (e.clientY - this.dragState.initial.clientY) * zoom + "px";

    // 找到离拖动节点最近节点
    this.targetNode = findNearestNode2(this.dragState.cloneNode, this.$el);
    if (this.targetNode) {
      // 计算拖动类型,获取dropType
      const dropType = calculateDropType2(
        this.dragState.cloneNode,
        this.direction,
        this.targetNode
      );
      this.dropType = dropType;
      // 创建指示器节点(虚拟节点)
      const indicatorNode_ = createIndicator(
        this.dropType,
        this.dragState.indicatorNode,
        this.targetNode,
        this.direction,
        this.strokeWidth
      );

      this.dragState.indicatorNode = indicatorNode_;

      indicatorNode_ &&
        this.targetNode.node.parentNode.insertBefore(
          indicatorNode_,
          this.targetNode.node
        );
    }
  }
},

4. 监听mouseup

this.isEdit && wrapper.addEventListener("mouseup", this.mouseupHandler);
mouseupHandler(e) {
  // 注意:因为mousedown设置了延迟,所以mouseup比mousedown先执行,
  // 会造成鼠标迅速点击时,直接生成虚拟节点,而这个虚拟节点无法被删除
  // 所以当存在延迟事件时,清除mousedown的延迟事件,不允许虚拟节点生成
  this.timeout && clearTimeout(this.timeout);
  // 结束拖动时,需要开启平移
  this.graph.enablePanning();
  this.dragState.dragging = false;
  if (this.dragState.cloneNode && this.targetNode && this.dropType) {
  // 交换节点数据
    this.changeNode(
      this.dropType,
      this.dragState.cloneNode.dataset.id,
      this.targetNode.node.dataset.id
    );
  }
  if (this.dragState.cloneNode) {
    this.dragState.cloneNode.remove();
    this.dragState.cloneNode = null;
  }
  if (this.dragState.indicatorNode) {
    this.dragState.indicatorNode.remove();
    this.dragState.indicatorNode = null;
  }

  this.dropType = "";
},

5. 寻找最近节点

export const findNearestNode2 = (draggingNode, el) => {
  const distances = [];
  // 这个节点可以不要,父节点可以不计算在内
  const node1 = Array.from(el.querySelectorAll(".mindmap__node-parent-wrap"));
  // 注意:跟document相关的,全部替换成this.$el,不然当一个页面有多个这个组件时,获取的节点会拿到所有组件的相同类节点
  const node2 = Array.from(
    el.querySelectorAll(".mindmap__node-child-wrap-node")
  ).filter((node) => !node.classList.toString().includes("flutter"));
  const nodes = node1.concat(node2);
  const draggingNodeConfig = draggingNode.getBoundingClientRect();

  nodes.forEach((node) => {
    const nodeConfig = node.getBoundingClientRect();
    const a = Math.abs(draggingNodeConfig.x - nodeConfig.x);
    const b = Math.abs(draggingNodeConfig.y - nodeConfig.y);
    let c = Math.sqrt(a * a + b * b);
    c = Number(c.toFixed(3));
    if (node.dataset.id !== draggingNode.dataset.id) {
      distances.push({
        node: node,
        distance: c,
      });
    }
  });
  // 找出离拖动节点最近的节点(即目标节点)
  let targetNode = distances[0];
  distances.forEach((item) => {
    if (item.distance < targetNode.distance) {
      targetNode = item;
    }
  });
  return targetNode;
};

6. 获取droptype

这边需要注意的是,横向跟纵向布局的区别,纵向布局时,拖动节点在目标节点左侧,即为before,在目标节点右侧即为after,其他规则一致

思路就是:用拖动节点跟目标节点的x还有y的位置做判断,得到想要的类型
before,after,inner,有一个值得注意的地方,x越往右数值越大,y越往上数值越小,越往下数据越大
此部分不贴代码了

7. 节点交换

// 拖动节点进行数据交换
// dropNodeId 拖动节点id
// targetNodeId目标节点id
changeNode(dropType, dropNodeId, targetNodeId) {
  // 找到对应的节点数据
  const _dropNode = findItem(this.mindData, dropNodeId);
  const _targetNode = findItem(this.mindData, targetNodeId);
  const dropParent = _dropNode && _dropNode.parent;
  const targetParent = _targetNode && _targetNode.parent;
  if (dropParent && targetParent) {
    const { children: dropChildren } = dropParent;
    const { children: targetChildren } = targetParent;
    const _dropChildren = cloneDeep(dropChildren);
    const _targetChildren = cloneDeep(targetChildren);
    // 找出各自节点对应在父节点子级的位置,并进行交换数据
    const dropIndex = dropChildren.findIndex(
      (item) => item.id === dropNodeId
    );
    const targetIndex = targetChildren.findIndex(
      (item) => item.id === targetNodeId
    );
    // 目标节点是移动节点的父级时,不处理
    const child = findItem(_dropNode.node, targetNodeId);
    if (child) {
      return;
    }
    if (dropType == "inner") {
      targetChildren[targetIndex].children =
        targetChildren[targetIndex].children || [];
      targetChildren[targetIndex].children.push(_dropChildren[dropIndex]);
      dropChildren.splice(dropIndex, 1);
    } else if (dropType == "before" || dropType == "after") {
      // targetChildren中是否包含当前拖动节点
      const index = targetChildren.findIndex(
        (item) => item.id === dropNodeId
      );
      let flag = false;
      // 判断是否同级节点,并且是相邻节点,往下移动,无法移动到目标节点之上
      // 判断是否同级节点,并且是相邻节点,往上移动,无法移动到目标节点之下
      if (dropType == "after") {
        dropIndex - targetIndex == 1 ? (flag = true) : (flag = false);
      } else {
        dropIndex - targetIndex == -1 ? (flag = true) : (flag = false);
      }
      if (index > -1 && !flag) {
        dropChildren.splice(dropIndex, 1, _targetChildren[targetIndex]);
        targetChildren.splice(targetIndex, 1, _dropChildren[dropIndex]);
      } else {
        // 不包含时,在同级添加节点
        targetParent.children.splice(
          targetIndex + (dropType == "after" ? 1 : 0),
          0,
          _dropChildren[dropIndex]
        );
        dropChildren.splice(dropIndex, 1);
      }
    }

    this.renderChart();
  }
},

这地方不知道怎么插入视频,就不放了,只有上面的截图

需求四:节点样式动态处理

1689153756517.pngimage.png
细看会发现,点击一个节点,会有个蓝色边框将节点包裹住,这个不是直接设置节点border就行

思路:选中节点时,创建一个新节点插入到当前节点之前,进行样式覆盖,拿到当前样式数据,传入主题样式列表中进行渲染(写的比较简单,大家按这个思路来就行,就不贴代码了,这部分交互比较复杂,具体参考processOn,代码比较多)

效果图:
1689576105171.png

需求五:组件封装

组件支持画布拖动,缩放,节点样式定义,画布背景,布局方向等参数支持,这部分需要自己去考虑哪些是否需要,我列了一点
1689576703545.png1689577677255(1).jpg

最后,感谢大家阅读我的小文章,请帮我点亮一下我的小心心吧!!!

1689577155763(1).png1689577224795.png1689577345238(1).png

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MY4mSx9r' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片