Vue3+TypeScript+AntVX6实现Web组态(从技术层面与实现层面进行分析)内含实际案例教学

server/2024/10/17 19:38:16/

摘要

      用Vue3+TypeScript+AntVX6实现Web组态(从技术层面与实现层面进行分析),包含画布创建、节点设计、拖拽实现(实际案例)、节点连线、交互功能,后续文章持续更新。

注:本文章可以根据目录进行导航

文档支持

AntVX6使用文档

https://x6.antv.antgroup.com/tutorial/getting-started

AntVX6接口参数文档

https://x6.antv.antgroup.com/api/graph/graph

SVG基础文档

https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Introduction

大致描述

个人认为以下图片为AntVX6的一些基础关键(详细请见官方文档)

1.提供了画布的参数修改=>方便面板的构建

2.提供了节点的修改=>可以对节点进行增、删、改,并且可以定制化操作(增代表增加节点、删代表删除节点、改代表修改节点的属性)

3.元素式Cell是节点Node、边Edge的基类,也就是Node、Edge继承于Cell(Cell有的属性Node、Edge都有)

元素、节点、边对应参数截图(节点的学习关键是学习元素的参数,详细见API文档):

具体实现

步骤一:绘制画布

 完整代码如下(使用Vue3+TypeScript构建)

<div id="container"></div>const graph = ref<Graph | null>(null);
onMounted(() => {graph.value = new Graph({width: 1800,height: 1200,panning:true,mousewheel:true,background: {color: '#F2F7FA',},container: document.getElementById('container')!, // 断言该值不为 nullgrid: {visible: true,type: 'doubleMesh',args: [{color: '#eee', // 主网格线颜色thickness: 1, // 主网格线宽度},{color: '#ddd', // 次网格线颜色thickness: 1, // 次网格线宽度factor: 4, // 主次网格线间隔},],},});
});

代码解释:

1.我把graph画布单独定义出来,这样就可以定义更多的自定义属性(要记住单独定义完以后要通过graph.value才可以访问里面的属性)。

2.设置画布的大小width、height(官方提供了自动大小autoResize属性,但是在我代码上一直有一些小bug所以就用自定义的宽和高,没有用自动设置的这个参数,需要的可自行研究)

3.Graph 中通过 panning 和 mousewheel 配置来实现缩放与平移,鼠标按下画布后移动时会拖拽画布,滚动鼠标滚轮会缩放画布。

4.background为背景色(官方提供自定义背景,并且可以放置图片)

5.配置绘制画布对应的页面区域,并且加上!断言不为空(解决TS报可能为空的错误)

container: document.getElementById('container')!, // 断言该值不为 null

6.设置网格grid(可以直接复制,目前已知作用是让画布更好看)

7.附上对画布尺寸、位置进行操作一些常用的 API

最终的画布效果


                                                                                                                                                        

步骤二:节点设计

节点本身构造

        节点本身构造难点:markup与attrs两个参数,所以我们重点分析。

以下为官方对markup与attrs的解释:

以下是作者本人对这两个参数的理解:

        1.首先两者关系是:attrs⊂markup(attrs包含于markup,也就是首先要记住attrs是markup中的属性)

        2.举个形象的例子来说明 attrsmarkup 的作用,可以想象你正在搭建一个房子,而这个房子的结构(墙壁、窗户、门等)就是 markup,而你为这些结构上色、装饰的细节(颜色、边框、材质等)就是 attrs

  • markup:定义了房子的组成部分,比如墙、窗户、门等。你可以通过它告诉 X6:房子有哪些部分,每个部分是什么类型(是矩形?是图片?是文本?)。
  • attrs:用来决定这些部分的样子。你可以为墙刷上白色油漆、为窗户加上边框、为门安装一个红色的把手。

       3. 其实简单理解就是:markup就是定义当前节点或边具有哪些部分,attrs就是改的markup中的对应部分。

        4.注意:若加上了markup参数,在 AntV X6 中,markup 是用来定义节点的结构和内容的,控制着节点渲染时使用的 SVG 或 HTML 元素。如果你在 markup 中传递了空数组([]),X6 不会自动生成任何内容,因此即使你定义了 shape 和 imageUrl,也不会有任何元素被渲染出来。

      attrs: {},markup: [],

以下代码则正确显示节点。若移除 markup: 如果你移除 markup 属性,X6 将使用默认的标记来渲染节点,这样 shape: 'image' 和 imageUrl 的配置会生效,图像将会被渲染出来。

markup对应参数如下

官方对markup参数的解释

 

作者本人理解如下:

1.tagName

        tagName:就比如这个代码例子,可以这么理解,tagName代表创建一个 <rect> 元素,所以如果你要创建一个矩形,你会使用 tagName: 'rect',要创建文本,则使用 tagName: 'text'。

        站在html上理解:也就是相当于tagName:'rect' = <rect></rect>,tagName:'text' = <text></text>。

      markup: [{tagName: 'rect',},],
2.selector

        selector:​该元素的唯一选择器,通过选择器为该元素指定属性样式。​

  • markup 部分定义了节点的结构,规定了有哪些元素,比如 rect, image, text等
  • 每个元素通过 selector 连接到 attrs 对象中对应的属性。
  const commonAttrs = {btnText: {fontSize: 14,fill: 'red',text: 'x',refX: '88%',y: -35,cursor: 'pointer',pointerEvent: 'none',},};attrs: commonAttrs,markup: [{tagName: 'text',selector: 'btnText',  // 用 `btnText` 作为选择器},]

节点设置流程(此处以添加节点的方式解析节点设置):

1.设置节点大小与位置

根据Api的说明,节点的大小与位置设置为position、size

    const node = graph.value.addNode({position:{x: 290,y: 150,},size:{width: 150,height: 150,},})

但是根据使用说明文档发现,可以直接使用x、y、width、height字段(亲测两个都可以实现,并且效果是一样的)

测试方法:console.log(source.prop())两个写法输出都一致。

2.设置节点类型(此处用自定义图片方式)

以下为官方提供的节点形状:

以下为图片形状的设计代码:

        首先设置shape为image,让节点为图片状,而后在markup上注册image区域(因为有后续自定义需要所以定义了markup,不定义也可以,但是需删除markup字段)。

        而后设置图片的路径,并且定义自定义标签,此处在外部定义label,会导致而后所有在markup上注册的text都为相同的标签,但是可以在attrs自定义标签,并用selector选择器进行选择即可解决。

 import Ceyear4082PImg from '@/assets/InstrumentLibImage'const node = graph.value.addNode({position:{x: 290,y: 150,},size:{width: 150,height: 150,},shape: 'image',imageUrl: Ceyear4082PImg,label:'图片名',markup: [{tagName: 'image',selector: 'image',},{tagName: 'text',selector: 'label',},],attrs: {label:{refX: 0.5,refY: '100%',refY2: 4,textAnchor: 'middle',textVerticalAnchor: 'top',},},})

规范写法如下: 

    const node = graph.value.addNode({position:{x: 290,y: 150,},size:{width: 150,height: 150,},shape: 'image',markup: [{tagName: 'image',selector: 'image',},{tagName: 'text',selector: 'label',},],attrs: {image: {href: Ceyear4082PImg // 设置图片的 URL},label: {text: "图片名", // 设置文本内容refX: 0.5, // 文本相对于节点位置的 X 坐标refY: '100%', // 文本相对于节点位置的 Y 坐标,100% 表示节点的底部refY2: 4, // Y 坐标偏移量textAnchor: 'middle', // 文本水平对齐方式textVerticalAnchor: 'top', // 文本垂直对齐方式},},})

上述有个小bug(当图片的高度不同的时候,label是显示在节点大小的底部,如果图片很矮则会间隔很大,改节点的大小则会显得很小。)

步骤三(实际案例):拖拽外部图片进入画布

实现原理:在图片上加入拖拽监听,在放置于画布区域的时候检测放入的内容,计算位置后生成对应节点。

图片区域加入draggable="true", @dragend="ondragEnd($event, item)",两个代码,首先让图片为可以拖拽,然后在拖拽结束调用自定义方法。

@dragover.prevent 是 Vue.js 中的指令,用于监听 dragover 事件并阻止其默认行为。

作用解释:

  1. dragover 事件

    • 当某个元素或节点正在被拖动,并且鼠标指针进入到某个目标元素上时,会触发 dragover 事件。这个事件默认情况下会阻止元素作为拖拽目标的行为。
  2. .prevent 修饰符

    • Vue.js 提供的 .prevent 修饰符会调用 event.preventDefault(),即阻止默认行为。在 dragover 事件中,默认行为是浏览器不允许该元素作为放置目标。
  3. 为什么使用 @dragover.prevent

    • 拖放操作中,目标元素必须明确表示它可以接受拖动的内容。默认情况下,dragover 事件是不会允许放置行为的,必须通过 event.preventDefault() 来阻止默认行为,使目标元素能够正确接收拖动操作。
    • 比如,当你希望将某个元素拖动到目标区域时,需要通过 @dragover.prevent 来告诉浏览器:该元素可以作为有效的拖放目标,从而允许你后续使用 drop 事件进行拖放。
                    <el-image:src="item.imgSrc"style="object-fit: contain; cursor: grab;height: 100px;"draggable="true"@dragend="ondragEnd($event, item)"></el-image>
            <div id="container" @dragover.prevent></div>

拖拽后放置调用ondragEnd方法,获取当前拖动物体在页面的位置,并通过AntVX6画布的pageToLocal(...)将页面坐标转换为画布本地坐标(目前只实现以鼠标的位置为坐标系原点方法,若有更好的方法,欢迎讨论)。
注:HTML的坐标系是原点往负半轴延申,也就是常规坐标系的反着。

在拖拽放置的方法里调用添加节点方法,将传入的X、Y、图片信息,设置到添加节点方法里,实现拖拽功能。

    //****拖拽后放置****//
const ondragEnd = (event:DragEvent,item:any) => {const { x, y } = graph.value!.pageToLocal(event.pageX, event.pageY); // 将页面坐标转换为画布本地坐标。addDragNode(x, y, item);
}//****添加节点进画布****//
const addDragNode=(x:number,y:number,item:any)=>{const node = graph.value!.addNode({id:item.title,position:{x: x,y: y,},size:{width: 150,height: 100,},shape: 'image',markup: [{tagName: 'image',selector: 'image',},{tagName: 'text',selector: 'label',},],attrs: {image:{href:item.imgSrc},label: {fontSize:10,text: item.title, // 设置文本内容refX: 0.5, // 文本相对于节点位置的 X 坐标refY: '100%', // 文本相对于节点位置的 Y 坐标,100% 表示节点的底部refY2: 1, // Y 坐标偏移量},},})
}

步骤四(实际案例):画布内的节点相互连线

效果图如下

1.配置连接桩 

要实现连线功能,首先要理解AntVX6的连接桩属性,在添加节点的时候,为节点配置上连接桩

官方解释:

首先我们将具有相同行为和外观的连接桩归为同一组,并通过 groups 选项来设置分组,该选项是一个对象 { [groupName: string]: PortGroupMetadata },组名为键,值为每组连接桩的默认选项。

然后我们配置 itemsitems 是一个数组 PortMetadata[],数组的每一项表示一个连接桩,连接桩支持的选项如下。

个人理解:

group就是设置连接桩的属性,通过groups可以统一管理,记得要magnet: true,才可以进行连线,然后items是就是把设置好的连接桩放置在节点上(然后可以设置样式),个人认为也是相当于注册了一个HTML在节点上。

    ports: {groups: {group1: {position: {name: 'left',args: { x: 0, y: 0 },},attrs: {circle: {magnet: true, // 允许连线stroke: '#8f8f8f', // 设置连接桩的样式r: 5,},},},group2: {position: {name: 'right',args: { x: 0, y: 0 },},attrs: {circle: {magnet: true, // 允许连线stroke: '#8f8f8f', // 设置连接桩的样式r: 5,},},},},items: [{group: 'group1',args: {x: '60%',y: 32,angle: 45,},},{group: 'group2',args: {x: '60%',y: 32,angle: 45,},},],},

添加节点完整代码: 

const addDragNode=(x:number,y:number,item:any)=>{const node = graph.value!.addNode({id:item.title,position:{x: x,y: y,},size:{width: 150,height: 100,},shape: 'image',markup: [{tagName: 'image',selector: 'image',},{tagName: 'text',selector: 'label',},],attrs: {image:{href:item.imgSrc},label: {fontSize:10,text: item.title, // 设置文本内容refX: 0.5, // 文本相对于节点位置的 X 坐标refY: '100%', // 文本相对于节点位置的 Y 坐标,100% 表示节点的底部refY2: 1, // Y 坐标偏移量},},ports: {groups: {group1: {position: {name: 'left',args: { x: 0, y: 0 },},attrs: {circle: {magnet: true, // 允许连线stroke: '#8f8f8f', // 设置连接桩的样式r: 5,},},},group2: {position: {name: 'right',args: { x: 0, y: 0 },},attrs: {circle: {magnet: true, // 允许连线stroke: '#8f8f8f', // 设置连接桩的样式r: 5,},},},},items: [{group: 'group1',args: {x: '60%',y: 32,angle: 45,},},{group: 'group2',args: {x: '60%',y: 32,angle: 45,},},],},})
}

2.在画布上设置连线交互 

官方对连线的解释见链接:

https://x6.antv.antgroup.com/api/model/interaction#%E8%BF%9E%E7%BA%BF

连线交互的代码解释我已经写在注释,具体请看注释。

    //连线交互connecting: {snap: {radius: 50, //自动吸附,并设置自动吸附路径},allowBlank: false, // 是否允许连接到画布空白位置的点(就是能不能拉线连空白的地方)allowLoop: false, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点,就是能不能自我连线(箭头不能穿过仪器)allowNode:false, //是否允许边连接到节点(非节点上的连接桩),默认为 true 。(就是要让它必须连接到连接桩,连接到节点不行)allowEdge:false, //是否可以同一个起点终点,在箭头的线中间加一个箭头,就是一条线能一直加箭头allowMulti: true, // 是否可以一个起点连多个终点highlight: true, // 拖动边时,是否高亮显示所有可用的连接桩或节点,默认值为 false 。一般都会与 highlighting 联合使用。},//高亮器highlighting:{// 当连接桩可以被链接时,在连接桩外围渲染一个 2px 宽的红色矩形框magnetAvailable: {name: 'stroke',args: {padding: 4,attrs: {'stroke-width': 2,stroke: 'red',},},}}

 完整代码如下:

onMounted(() => {graph.value = new Graph({width: 1800,height: 1200,panning:true,mousewheel:true,background: {color: '#F2F7FA',},container: document.getElementById('container')!, // 断言该值不为 nullgrid: {visible: true,type: 'doubleMesh',args: [{color: '#eee', // 主网格线颜色thickness: 1, // 主网格线宽度},{color: '#ddd', // 次网格线颜色thickness: 1, // 次网格线宽度factor: 4, // 主次网格线间隔},],},//连线交互connecting: {snap: {radius: 50, //自动吸附,并设置自动吸附路径},allowBlank: false, // 是否允许连接到画布空白位置的点(就是能不能拉线连空白的地方)allowLoop: false, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点,就是能不能自我连线(箭头不能穿过仪器)allowNode:false, //是否允许边连接到节点(非节点上的连接桩),默认为 true 。(就是要让它必须连接到连接桩,连接到节点不行)allowEdge:false, //是否可以同一个起点终点,在箭头的线中间加一个箭头,就是一条线能一直加箭头allowMulti: true, // 是否可以一个起点连多个终点highlight: true, // 拖动边时,是否高亮显示所有可用的连接桩或节点,默认值为 false 。一般都会与 highlighting 联合使用。},//高亮器highlighting:{// 当连接桩可以被链接时,在连接桩外围渲染一个 2px 宽的红色矩形框magnetAvailable: {name: 'stroke',args: {padding: 4,attrs: {'stroke-width': 2,stroke: 'red',},},}}});//画布开启对齐线功能graph.value.use(new Snapline({enabled:true}))// 监听节点的鼠标进入事件,显示连接桩graph.value.on('node:mouseenter', () => {changePortsVisible(true);});// 节点点击事件graph.value.on('node:click', ({ node }) => {console.log('点击!!!', node);if (curSelectNode.value) {// 移除当前选中节点的工具curSelectNode.value.removeTools();if (curSelectNode.value !== node) {// 为当前点击的节点添加工具node.addTools([{name: 'boundary',args: {attrs: {fill: '#16B8AA',stroke: '#2F80EB',strokeWidth: 1,fillOpacity: 0.1,},},},{name: 'button-remove',args: {x: '100%',y: 0,offset: {x: 0,y: 0,},},},]);curSelectNode.value = node; // 更新当前选中的节点} else {curSelectNode.value = null; // 如果点击相同节点,取消选中}} else {curSelectNode.value = node;// 添加工具到当前点击的节点node.addTools([{name: 'boundary',args: {attrs: {fill: '#16B8AA',stroke: '#2F80EB',strokeWidth: 1,fillOpacity: 0.1,},},},{name: 'button-remove',args: {x: '100%',y: 0,offset: {x: 0,y: 0,},},},]);}});// 监听节点的鼠标离开事件,隐藏连接桩graph.value.on('node:mouseleave', () => {changePortsVisible(false);});// 监听连线悬浮进入事件graph.value.on('cell:mouseenter', ({ cell }) => {if (cell.shape === 'edge') {// 添加删除按钮工具cell.addTools([{name: 'button-remove',args: {x: '100%',y: 0,offset: {x: 0,y: 0,},},},]);// 改变连线颜色cell.setAttrs({line: {stroke: '#409EFF',},});// 设置连线的层级,使其在最上层cell.setZIndex(99);}});// 监听连线悬浮离开事件graph.value.on('cell:mouseleave', ({ cell }) => {if (cell.shape === 'edge') {// 移除工具按钮cell.removeTools();// 恢复连线的颜色cell.setAttrs({line: {stroke: 'black',},});// 恢复连线的层级cell.setZIndex(1);}});// 将画布中元素缩小或者放大一定级别,让画布正好容纳所有元素,可以通过 maxScale 配置最大缩放级别// graph.value.zoomToFit({ maxScale: 4 })
});

 3.设置节点删除与连线删除

声明:以下代码改写于CSDN博主:先知demons

直接添加至项目即可,开箱即用,博主还在研究addTools,解释敬请期待....

  const curSelectNode = ref<any>(null); // 当前选中的节点
//画布开启对齐线功能graph.value.use(new Snapline({enabled:true}))// 监听节点的鼠标进入事件,显示连接桩graph.value.on('node:mouseenter', () => {changePortsVisible(true);});// 节点点击事件graph.value.on('node:click', ({ node }) => {console.log('点击!!!', node);if (curSelectNode.value) {// 移除当前选中节点的工具curSelectNode.value.removeTools();if (curSelectNode.value !== node) {// 为当前点击的节点添加工具node.addTools([{name: 'boundary',args: {attrs: {fill: '#16B8AA',stroke: '#2F80EB',strokeWidth: 1,fillOpacity: 0.1,},},},{name: 'button-remove',args: {x: '100%',y: 0,offset: {x: 0,y: 0,},},},]);curSelectNode.value = node; // 更新当前选中的节点} else {curSelectNode.value = null; // 如果点击相同节点,取消选中}} else {curSelectNode.value = node;// 添加工具到当前点击的节点node.addTools([{name: 'boundary',args: {attrs: {fill: '#16B8AA',stroke: '#2F80EB',strokeWidth: 1,fillOpacity: 0.1,},},},{name: 'button-remove',args: {x: '100%',y: 0,offset: {x: 0,y: 0,},},},]);}});// 监听节点的鼠标离开事件,隐藏连接桩graph.value.on('node:mouseleave', () => {changePortsVisible(false);});// 监听连线悬浮进入事件graph.value.on('cell:mouseenter', ({ cell }) => {if (cell.shape === 'edge') {// 添加删除按钮工具cell.addTools([{name: 'button-remove',args: {x: '100%',y: 0,offset: {x: 0,y: 0,},},},]);// 改变连线颜色cell.setAttrs({line: {stroke: '#409EFF',},});// 设置连线的层级,使其在最上层cell.setZIndex(99);}});// 监听连线悬浮离开事件graph.value.on('cell:mouseleave', ({ cell }) => {if (cell.shape === 'edge') {// 移除工具按钮cell.removeTools();// 恢复连线的颜色cell.setAttrs({line: {stroke: 'black',},});// 恢复连线的层级cell.setZIndex(1);}});

技术与工具分析:

工具一:对齐线工具

以下图片为官方的效果图,功能就是放置节点的时候有个对齐线。

实现步骤 :根据官方的描述,对齐线是移动节点排版的辅助工具,我们提供了一个独立的插件包 @antv/x6-plugin-snapline 来使用这个功能,所以先导包,将对应所需包导入。

npm install @antv/x6-plugin-snapline --save

具体代码如下,要在画布设置的时候将其添加进去。 

  graph.value.use(new Snapline({
    enabled:true
  }))

import { Snapline } from '@antv/x6-plugin-snapline'const graph = ref<Graph | null>(null);
onMounted(() => {graph.value = new Graph({width: 1800,height: 1200,panning:true,mousewheel:true,background: {color: '#F2F7FA',},container: document.getElementById('container')!, // 断言该值不为 nullgrid: {visible: true,type: 'doubleMesh',args: [{color: '#eee', // 主网格线颜色thickness: 1, // 主网格线宽度},{color: '#ddd', // 次网格线颜色thickness: 1, // 次网格线宽度factor: 4, // 主次网格线间隔},],},});graph.value.use(new Snapline({enabled:true}))// 将画布中元素缩小或者放大一定级别,让画布正好容纳所有元素,可以通过 maxScale 配置最大缩放级别// graph.value.zoomToFit({ maxScale: 4 })
});

 

技术分析一:

节点如果设置了id属性,那么添加相同id的节点时候会添加失败,因为已存在相同的id。

文章持续更新,敬请期待.............


http://www.ppmy.cn/server/132563.html

相关文章

Golang Slice扩容机制及注意事项

Golang Slice扩容机制及注意事项&#xff1a; 在 Go语言中&#xff0c;Slice&#xff08;切片&#xff09;是一种非常灵活且强大的数据结构&#xff0c;它是对数组的抽象&#xff0c;提供了动态数组的功能。Slice 的扩容机制是自动的&#xff0c;但了解其背后的原理对于编写高…

Qt-系统QThread多线程介绍使用(62)

目录 描述 相关函数 使用 准备工作 重写run 发送信号 创建一个线程 启动线程 计时器运行流程 多线程运用场景 描述 qt多线程和Linux多线程类似 Linux有自己的一套多线程 API&#xff0c;Qt 也有着自己封装的多线程 API QT多线程参考了JAVA中的设计方式 QThread创建…

云服务器磁盘满了,清理docker无用缓存、容器等清理

docker system prune 命令用于清理 Docker 系统中的各种未使用资源。根据你提供的警告信息&#xff0c;这条命令将会移除以下内容&#xff1a; 所有已停止的容器&#xff08;all stopped containers&#xff09; 所有未被至少一个容器使用的网络&#xff08;all networks no…

AI赋能安全运营 | 赛宁网安深度参与四川省网络安全沙龙

为促进四川省、市网络安全公共服务领域的经验交流与深入探讨&#xff0c;打通网络安全供需上下游&#xff0c;加速汇聚省、市优质网络安全设备和服务资源&#xff0c;提升巴中市乃至四川省网络安全防护水平&#xff0c;共同推动四川省网络安全事业的蓬勃发展。 2024年10月15日…

Java【代码 19】含有换行符\r\n的字符串匹配(源码分享)处理Word文档里的Excel表格数据

含有换行符的字符串匹配 1.问题说明2.问题分析3.问题解决 1.问题说明 Java 后台读取包含 Excel 表格的 Word 文档&#xff0c;此时正文数据字符串包含 \r\n也就是换行符&#xff0c;想要通过 yaml 配置文件匹配 Excel 表格的表头&#xff0c;但是无论如何都是匹配不上&#xf…

CSS @规则(At-rules)系列详解___@font-face规则使用方法

CSS 规则(At-rules)系列详解 ___font-face规则使用方法 本文目录&#xff1a; 零、时光宝盒 一、CSSfont-face规则定义和用法 二、font-face语法 三、font-face使用方法例子 3.1、指定一种字体 3.2、font-face 里添加文本的描述符 3.3、设置多个 font-face 规则。 3.4…

第十五届蓝桥杯C/C++学B组(解)

1.握手问题 解题思路一 数学方法 50个人互相握手 &#xff08;491&#xff09;*49/2 &#xff0c;减去7个人没有互相握手&#xff08;61&#xff09;*6/2 答案&#xff1a;1024 解题思路二 思路&#xff1a; 模拟 将50个人从1到50标号&#xff0c;对于每两个人之间只握一…

产品更新|DuoPlus云手机APP预装、批量管理功能新上线!

前言&#xff1a;在这个日新月异的时代&#xff0c;每一个微小的变化都可能引领行业新潮流&#xff0c;DuoPlus云手机基于不断创新的原则&#xff0c;把用户的体验放在第一位&#xff0c;不断对产品进行调整优化&#xff0c;力求提升用户的工作效率。 我们通过收集用户反馈&am…