基于 Vue3 组合式 API 的图片标框画框、标注、选框完整实现核心逻辑封装在 GetBoxes 组件里复制就能用一、功能说明✅ 在图片上鼠标拖拽画矩形框✅ 实时显示框坐标x, y, width, height✅ 支持多个框同时显示✅ 支持清空所有框✅ 框可渲染在图片上方不破坏原图✅ 框可拖动移动位置✅ 框可拖拽右下角调整大小✅ 单个删除框✅ 每个框自定义输入标注文字✅ 每个框自动随机不同颜色✅ 回显已保存的标框直接传数组即可✅ 保留原有多框、拖拽画框、坐标实时输出二、使用方式2.1把代码保存为 GetBoxes.vueGetBoxes.vuetemplatedivclassbox-containerdivclassimage-wrapperrefwrapperRefmousedownstartDrawmousemovehandleMouseMovemouseupstopDrawmouseleavestopDrawimg refimgRef:srcimgUrlalt标框底图loadinitCanvas/canvas refcanvasRefclassdraw-canvas/canvasdiv v-for(box, index) in boxes:keyindexclassbox-label:style{left:${box.x}px,top:${box.y-28}px,color:box.color,}input v-modelbox.labeltypetextplaceholder输入标注mousedown.stop/buttonclick.stopdeleteBox(index)×/button/div/divdivclasstool-barbuttonclickclearAllBoxes清空所有框/buttonbuttonclickconsoleLogBoxes打印所有框数据/buttondivclassbox-listh4已标框{{boxes.length}}/h4div v-for(box, index) in boxes:keyindexclassbox-item:style{ borderLeftColor: box.color }框{{index1}}{{box.label||未命名}}br/x:{{box.x}},y:{{box.y}},w:{{box.width}},h:{{box.height}}/div/div/div/div/templatescript setupimport{ref,onMounted,nextTick,onUnmounted}fromvueconstimgUrlref(https://picsum.photos/900/600)constwrapperRefref(null)constcanvasRefref(null)constimgRefref(null)letctxnullconstboxesref([])constcurrentBoxref(null)constisDrawingref(false)constisDraggingref(false)constisResizingref(false)constdragStartref({x:0,y:0})constactiveIndexref(-1)// 随机边框颜色constrandomColor(){constcolors[#FF4757,#FF6B35,#F79F1F,#A3CB38,#00D2D3,#3742fa,#FDA7DF,#ED4C67,#1B9CFC,#F8EFBA,#58B19F,#D6A2E8,]returncolors[Math.floor(Math.random()*colors.length)]}// 初始化画布constinitCanvasasync(){awaitnextTick()constccanvasRef.valueconstimgimgRef.value c.widthimg.offsetWidth c.heightimg.offsetHeight ctxc.getContext(2d)redrawCanvas()}// 鼠标移动 切换指针样式consthandleMouseMove(e){if(!ctx)returnconstrectcanvasRef.value.getBoundingClientRect()constmxe.clientX-rect.leftconstmye.clientY-rect.top canvasRef.value.style.cursorcrosshairfor(letiboxes.value.length-1;i0;i--){constbboxes.value[i]constrightb.xb.widthconstbottomb.yb.height// 缩放控制点if(mxright-12mxrightmybottom-12mybottom){canvasRef.value.style.cursorse-resizebreak}// 边框区域if((mxb.x-2mxb.x2myb.ymybottom)||(mxright-2mxright2myb.ymybottom)||(myb.y-2myb.y2mxb.xmxright)||(mybottom-2mybottom2mxb.xmxright)){canvasRef.value.style.cursormovebreak}// 框内部if(mxb.xmxrightmyb.ymybottom){canvasRef.value.style.cursorpointerbreak}}drawing(e)}// 开始绘制、拖动、缩放conststartDraw(e){constrectcanvasRef.value.getBoundingClientRect()constmxe.clientX-rect.leftconstmye.clientY-rect.topfor(letiboxes.value.length-1;i0;i--){constbboxes.value[i]constrightb.xb.widthconstbottomb.yb.heightif(mxright-12mxrightmybottom-12mybottom){isResizing.valuetrueactiveIndex.valueireturn}if(mxb.xmxrightmyb.ymybottom){isDragging.valuetrueactiveIndex.valuei dragStart.value{x:mx-b.x,y:my-b.y}return}}isDrawing.valuetruecurrentBox.value{x:mx,y:my,width:0,height:0,color:randomColor(),label:}}// 绘制拖拽逻辑constdrawing(e){if(!ctx)returnconstrectcanvasRef.value.getBoundingClientRect()constmxe.clientX-rect.leftconstmye.clientY-rect.topif(isDrawing.valuecurrentBox.value){currentBox.value.widthmx-currentBox.value.x currentBox.value.heightmy-currentBox.value.y}if(isDragging.valueactiveIndex.value-1){constboxboxes.value[activeIndex.value]box.xmx-dragStart.value.x box.ymy-dragStart.value.y}if(isResizing.valueactiveIndex.value-1){constboxboxes.value[activeIndex.value]box.widthmx-box.x box.heightmy-box.y}redrawCanvas()}// 结束操作conststopDraw(){if(isDrawing.valuecurrentBox.value){const{width,height}currentBox.valueif(Math.abs(width)8Math.abs(height)8){boxes.value.push({...currentBox.value})}}isDrawing.valuefalseisDragging.valuefalseisResizing.valuefalsecurrentBox.valuenullactiveIndex.value-1redrawCanvas()}// 重绘画布constredrawCanvas(){if(!ctx)returnctx.clearRect(0,0,canvasRef.value.width,canvasRef.value.height)boxes.value.forEach((box){ctx.strokeStylebox.color ctx.lineWidth2ctx.strokeRect(box.x,box.y,box.width,box.height)ctx.fillStylebox.colorconstrxbox.xbox.widthconstrybox.ybox.height ctx.fillRect(rx-6,ry-6,12,12)})if(currentBox.value){ctx.strokeStylecurrentBox.value.color ctx.strokeRect(currentBox.value.x,currentBox.value.y,currentBox.value.width,currentBox.value.height)}}// 删除单个框constdeleteBox(index){boxes.value.splice(index,1)redrawCanvas()}// 清空全部constclearAllBoxes(){boxes.value[]redrawCanvas()}// 打印框数据constconsoleLogBoxes(){console.log(所有框数据,JSON.parse(JSON.stringify(boxes.value)))}// 回显历史标注示例constloadSavedBoxes(){constsavedData[{x:50,y:50,width:120,height:100,color:#FF4757,label:人物},{x:200,y:150,width:180,height:140,color:#3742fa,label:车辆},]boxes.valuesavedDataredrawCanvas()}//四舍五入函数保留小数点后n位constcustomRound(number,decimals){constfactorMath.pow(10,decimals);returnMath.round(number*factor)/factor;};onMounted((){if(imgRef.value.complete)initCanvas()// loadSavedBoxes()})onUnmounted((){ctxnull})/scriptstyle scoped.box-container{width:100%;max-width:900px;margin:20px auto;}.image-wrapper{position:relative;width:fit-content;}.draw-canvas{position:absolute;top:0;left:0;z-index:10;}img{display:block;max-width:100%;}.box-label{position:absolute;z-index:20;display:flex;gap:6px;align-items:center;}.box-label input{width:100px;padding:2px 6px;font-size:12px;border:1px solid #ddd;border-radius:4px;}.box-label button{background:#ff4757;color:white;border:none;width:18px;height:18px;font-size:12px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;}.tool-bar{margin-top:12px;display:flex;gap:10px;align-items:center;}button{padding:6px 12px;background:#3742fa;color:#fff;border:none;border-radius:4px;cursor:pointer;}.box-list{margin-top:10px;}.box-item{padding:6px 10px;margin:4px0;font-size:13px;border-left:4px solid #ddd;background:#f9f9f9;}/style2.2在你的页面中直接引入使用templatedivh3图片标框工具/h3GetBoxes//div/templatescript setupimportGetBoxesfrom./GetBoxes.vue/script三、总结Vue3 标准组合式 API写法基于 Canvas 实现标框性能好、不操作 DOM代码可直接运行坐标实时输出支持多框、清空只需要替换图片地址、接入接口就能用于项目
Vue3 图片标框功能实现方案
发布时间:2026/5/26 1:50:43
基于 Vue3 组合式 API 的图片标框画框、标注、选框完整实现核心逻辑封装在 GetBoxes 组件里复制就能用一、功能说明✅ 在图片上鼠标拖拽画矩形框✅ 实时显示框坐标x, y, width, height✅ 支持多个框同时显示✅ 支持清空所有框✅ 框可渲染在图片上方不破坏原图✅ 框可拖动移动位置✅ 框可拖拽右下角调整大小✅ 单个删除框✅ 每个框自定义输入标注文字✅ 每个框自动随机不同颜色✅ 回显已保存的标框直接传数组即可✅ 保留原有多框、拖拽画框、坐标实时输出二、使用方式2.1把代码保存为 GetBoxes.vueGetBoxes.vuetemplatedivclassbox-containerdivclassimage-wrapperrefwrapperRefmousedownstartDrawmousemovehandleMouseMovemouseupstopDrawmouseleavestopDrawimg refimgRef:srcimgUrlalt标框底图loadinitCanvas/canvas refcanvasRefclassdraw-canvas/canvasdiv v-for(box, index) in boxes:keyindexclassbox-label:style{left:${box.x}px,top:${box.y-28}px,color:box.color,}input v-modelbox.labeltypetextplaceholder输入标注mousedown.stop/buttonclick.stopdeleteBox(index)×/button/div/divdivclasstool-barbuttonclickclearAllBoxes清空所有框/buttonbuttonclickconsoleLogBoxes打印所有框数据/buttondivclassbox-listh4已标框{{boxes.length}}/h4div v-for(box, index) in boxes:keyindexclassbox-item:style{ borderLeftColor: box.color }框{{index1}}{{box.label||未命名}}br/x:{{box.x}},y:{{box.y}},w:{{box.width}},h:{{box.height}}/div/div/div/div/templatescript setupimport{ref,onMounted,nextTick,onUnmounted}fromvueconstimgUrlref(https://picsum.photos/900/600)constwrapperRefref(null)constcanvasRefref(null)constimgRefref(null)letctxnullconstboxesref([])constcurrentBoxref(null)constisDrawingref(false)constisDraggingref(false)constisResizingref(false)constdragStartref({x:0,y:0})constactiveIndexref(-1)// 随机边框颜色constrandomColor(){constcolors[#FF4757,#FF6B35,#F79F1F,#A3CB38,#00D2D3,#3742fa,#FDA7DF,#ED4C67,#1B9CFC,#F8EFBA,#58B19F,#D6A2E8,]returncolors[Math.floor(Math.random()*colors.length)]}// 初始化画布constinitCanvasasync(){awaitnextTick()constccanvasRef.valueconstimgimgRef.value c.widthimg.offsetWidth c.heightimg.offsetHeight ctxc.getContext(2d)redrawCanvas()}// 鼠标移动 切换指针样式consthandleMouseMove(e){if(!ctx)returnconstrectcanvasRef.value.getBoundingClientRect()constmxe.clientX-rect.leftconstmye.clientY-rect.top canvasRef.value.style.cursorcrosshairfor(letiboxes.value.length-1;i0;i--){constbboxes.value[i]constrightb.xb.widthconstbottomb.yb.height// 缩放控制点if(mxright-12mxrightmybottom-12mybottom){canvasRef.value.style.cursorse-resizebreak}// 边框区域if((mxb.x-2mxb.x2myb.ymybottom)||(mxright-2mxright2myb.ymybottom)||(myb.y-2myb.y2mxb.xmxright)||(mybottom-2mybottom2mxb.xmxright)){canvasRef.value.style.cursormovebreak}// 框内部if(mxb.xmxrightmyb.ymybottom){canvasRef.value.style.cursorpointerbreak}}drawing(e)}// 开始绘制、拖动、缩放conststartDraw(e){constrectcanvasRef.value.getBoundingClientRect()constmxe.clientX-rect.leftconstmye.clientY-rect.topfor(letiboxes.value.length-1;i0;i--){constbboxes.value[i]constrightb.xb.widthconstbottomb.yb.heightif(mxright-12mxrightmybottom-12mybottom){isResizing.valuetrueactiveIndex.valueireturn}if(mxb.xmxrightmyb.ymybottom){isDragging.valuetrueactiveIndex.valuei dragStart.value{x:mx-b.x,y:my-b.y}return}}isDrawing.valuetruecurrentBox.value{x:mx,y:my,width:0,height:0,color:randomColor(),label:}}// 绘制拖拽逻辑constdrawing(e){if(!ctx)returnconstrectcanvasRef.value.getBoundingClientRect()constmxe.clientX-rect.leftconstmye.clientY-rect.topif(isDrawing.valuecurrentBox.value){currentBox.value.widthmx-currentBox.value.x currentBox.value.heightmy-currentBox.value.y}if(isDragging.valueactiveIndex.value-1){constboxboxes.value[activeIndex.value]box.xmx-dragStart.value.x box.ymy-dragStart.value.y}if(isResizing.valueactiveIndex.value-1){constboxboxes.value[activeIndex.value]box.widthmx-box.x box.heightmy-box.y}redrawCanvas()}// 结束操作conststopDraw(){if(isDrawing.valuecurrentBox.value){const{width,height}currentBox.valueif(Math.abs(width)8Math.abs(height)8){boxes.value.push({...currentBox.value})}}isDrawing.valuefalseisDragging.valuefalseisResizing.valuefalsecurrentBox.valuenullactiveIndex.value-1redrawCanvas()}// 重绘画布constredrawCanvas(){if(!ctx)returnctx.clearRect(0,0,canvasRef.value.width,canvasRef.value.height)boxes.value.forEach((box){ctx.strokeStylebox.color ctx.lineWidth2ctx.strokeRect(box.x,box.y,box.width,box.height)ctx.fillStylebox.colorconstrxbox.xbox.widthconstrybox.ybox.height ctx.fillRect(rx-6,ry-6,12,12)})if(currentBox.value){ctx.strokeStylecurrentBox.value.color ctx.strokeRect(currentBox.value.x,currentBox.value.y,currentBox.value.width,currentBox.value.height)}}// 删除单个框constdeleteBox(index){boxes.value.splice(index,1)redrawCanvas()}// 清空全部constclearAllBoxes(){boxes.value[]redrawCanvas()}// 打印框数据constconsoleLogBoxes(){console.log(所有框数据,JSON.parse(JSON.stringify(boxes.value)))}// 回显历史标注示例constloadSavedBoxes(){constsavedData[{x:50,y:50,width:120,height:100,color:#FF4757,label:人物},{x:200,y:150,width:180,height:140,color:#3742fa,label:车辆},]boxes.valuesavedDataredrawCanvas()}//四舍五入函数保留小数点后n位constcustomRound(number,decimals){constfactorMath.pow(10,decimals);returnMath.round(number*factor)/factor;};onMounted((){if(imgRef.value.complete)initCanvas()// loadSavedBoxes()})onUnmounted((){ctxnull})/scriptstyle scoped.box-container{width:100%;max-width:900px;margin:20px auto;}.image-wrapper{position:relative;width:fit-content;}.draw-canvas{position:absolute;top:0;left:0;z-index:10;}img{display:block;max-width:100%;}.box-label{position:absolute;z-index:20;display:flex;gap:6px;align-items:center;}.box-label input{width:100px;padding:2px 6px;font-size:12px;border:1px solid #ddd;border-radius:4px;}.box-label button{background:#ff4757;color:white;border:none;width:18px;height:18px;font-size:12px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;}.tool-bar{margin-top:12px;display:flex;gap:10px;align-items:center;}button{padding:6px 12px;background:#3742fa;color:#fff;border:none;border-radius:4px;cursor:pointer;}.box-list{margin-top:10px;}.box-item{padding:6px 10px;margin:4px0;font-size:13px;border-left:4px solid #ddd;background:#f9f9f9;}/style2.2在你的页面中直接引入使用templatedivh3图片标框工具/h3GetBoxes//div/templatescript setupimportGetBoxesfrom./GetBoxes.vue/script三、总结Vue3 标准组合式 API写法基于 Canvas 实现标框性能好、不操作 DOM代码可直接运行坐标实时输出支持多框、清空只需要替换图片地址、接入接口就能用于项目