基于 Vue3 + 组合式 API 的图片标框(画框、标注、选框)完整实现,核心逻辑封装在 GetBoxes 组件里,复制就能用
一、功能说明
✅ 在图片上鼠标拖拽画矩形框
✅ 实时显示框坐标(x, y, width, height)
✅ 支持多个框同时显示
✅ 支持清空所有框
✅ 框可渲染在图片上方,不破坏原图
✅ 框可拖动移动位置
✅ 框可拖拽右下角调整大小
✅ 单个删除框
✅ 每个框自定义输入标注文字
✅ 每个框自动随机不同颜色
✅ 回显已保存的标框(直接传数组即可)
✅ 保留原有:多框、拖拽画框、坐标实时输出
二、使用方式
2.1把代码保存为 GetBoxes.vue
GetBoxes.vue
typescript
<template>
<div class="box-container">
<div
class="image-wrapper"
ref="wrapperRef"
@mousedown="startDraw"
@mousemove="handleMouseMove"
@mouseup="stopDraw"
@mouseleave="stopDraw"
>
<img ref="imgRef" :src="imgUrl" alt="标框底图" @load="initCanvas" />
<canvas ref="canvasRef" class="draw-canvas"></canvas>
<div
v-for="(box, index) in boxes"
:key="index"
class="box-label"
:style="{
left: `${box.x}px`,
top: `${box.y - 28}px`,
color: box.color,
}"
>
<input
v-model="box.label"
type="text"
placeholder="输入标注"
@mousedown.stop
/>
<button @click.stop="deleteBox(index)">×</button>
</div>
</div>
<div class="tool-bar">
<button @click="clearAllBoxes">清空所有框</button>
<button @click="consoleLogBoxes">打印所有框数据</button>
<div class="box-list">
<h4>已标框({{ boxes.length }}):</h4>
<div
v-for="(box, index) in boxes"
:key="index"
class="box-item"
:style="{ borderLeftColor: box.color }"
>
框{{ index + 1 }}:{{ box.label || '未命名' }}
<br />
x:{{ box.x }}, y:{{ box.y }},
w:{{ box.width }}, h:{{ box.height }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, onUnmounted } from 'vue'
const imgUrl = ref('https://picsum.photos/900/600')
const wrapperRef = ref(null)
const canvasRef = ref(null)
const imgRef = ref(null)
let ctx = null
const boxes = ref([])
const currentBox = ref(null)
const isDrawing = ref(false)
const isDragging = ref(false)
const isResizing = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const activeIndex = ref(-1)
// 随机边框颜色
const randomColor = () => {
const colors = [
'#FF4757', '#FF6B35', '#F79F1F', '#A3CB38', '#00D2D3', '#3742fa',
'#FDA7DF', '#ED4C67', '#1B9CFC', '#F8EFBA', '#58B19F', '#D6A2E8',
]
return colors[Math.floor(Math.random() * colors.length)]
}
// 初始化画布
const initCanvas = async () => {
await nextTick()
const c = canvasRef.value
const img = imgRef.value
c.width = img.offsetWidth
c.height = img.offsetHeight
ctx = c.getContext('2d')
redrawCanvas()
}
// 鼠标移动 切换指针样式
const handleMouseMove = (e) => {
if (!ctx) return
const rect = canvasRef.value.getBoundingClientRect()
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
canvasRef.value.style.cursor = 'crosshair'
for (let i = boxes.value.length - 1; i >= 0; i--) {
const b = boxes.value[i]
const right = b.x + b.width
const bottom = b.y + b.height
// 缩放控制点
if (mx >= right - 12 && mx <= right && my >= bottom - 12 && my <= bottom) {
canvasRef.value.style.cursor = 'se-resize'
break
}
// 边框区域
if (
(mx >= b.x - 2 && mx <= b.x + 2 && my >= b.y && my <= bottom) ||
(mx >= right - 2 && mx <= right + 2 && my >= b.y && my <= bottom) ||
(my >= b.y - 2 && my <= b.y + 2 && mx >= b.x && mx <= right) ||
(my >= bottom - 2 && my <= bottom + 2 && mx >= b.x && mx <= right)
) {
canvasRef.value.style.cursor = 'move'
break
}
// 框内部
if (mx >= b.x && mx <= right && my >= b.y && my <= bottom) {
canvasRef.value.style.cursor = 'pointer'
break
}
}
drawing(e)
}
// 开始绘制、拖动、缩放
const startDraw = (e) => {
const rect = canvasRef.value.getBoundingClientRect()
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
for (let i = boxes.value.length - 1; i >= 0; i--) {
const b = boxes.value[i]
const right = b.x + b.width
const bottom = b.y + b.height
if (mx >= right - 12 && mx <= right && my >= bottom - 12 && my <= bottom) {
isResizing.value = true
activeIndex.value = i
return
}
if (mx >= b.x && mx <= right && my >= b.y && my <= bottom) {
isDragging.value = true
activeIndex.value = i
dragStart.value = { x: mx - b.x, y: my - b.y }
return
}
}
isDrawing.value = true
currentBox.value = { x: mx, y: my, width: 0, height: 0, color: randomColor(), label: '' }
}
// 绘制拖拽逻辑
const drawing = (e) => {
if (!ctx) return
const rect = canvasRef.value.getBoundingClientRect()
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
if (isDrawing.value && currentBox.value) {
currentBox.value.width = mx - currentBox.value.x
currentBox.value.height = my - currentBox.value.y
}
if (isDragging.value && activeIndex.value > -1) {
const box = boxes.value[activeIndex.value]
box.x = mx - dragStart.value.x
box.y = my - dragStart.value.y
}
if (isResizing.value && activeIndex.value > -1) {
const box = boxes.value[activeIndex.value]
box.width = mx - box.x
box.height = my - box.y
}
redrawCanvas()
}
// 结束操作
const stopDraw = () => {
if (isDrawing.value && currentBox.value) {
const { width, height } = currentBox.value
if (Math.abs(width) > 8 && Math.abs(height) > 8) {
boxes.value.push({ ...currentBox.value })
}
}
isDrawing.value = false
isDragging.value = false
isResizing.value = false
currentBox.value = null
activeIndex.value = -1
redrawCanvas()
}
// 重绘画布
const redrawCanvas = () => {
if (!ctx) return
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
boxes.value.forEach((box) => {
ctx.strokeStyle = box.color
ctx.lineWidth = 2
ctx.strokeRect(box.x, box.y, box.width, box.height)
ctx.fillStyle = box.color
const rx = box.x + box.width
const ry = box.y + box.height
ctx.fillRect(rx - 6, ry - 6, 12, 12)
})
if (currentBox.value) {
ctx.strokeStyle = currentBox.value.color
ctx.strokeRect(currentBox.value.x, currentBox.value.y, currentBox.value.width, currentBox.value.height)
}
}
// 删除单个框
const deleteBox = (index) => {
boxes.value.splice(index, 1)
redrawCanvas()
}
// 清空全部
const clearAllBoxes = () => {
boxes.value = []
redrawCanvas()
}
// 打印框数据
const consoleLogBoxes = () => {
console.log('所有框数据:', JSON.parse(JSON.stringify(boxes.value)))
}
// 回显历史标注示例
const loadSavedBoxes = () => {
const savedData = [
{ x: 50, y: 50, width: 120, height: 100, color: '#FF4757', label: '人物' },
{ x: 200, y: 150, width: 180, height: 140, color: '#3742fa', label: '车辆' },
]
boxes.value = savedData
redrawCanvas()
}
//四舍五入函数,保留小数点后n位
const customRound = (number, decimals) => {
const factor = Math.pow(10, decimals);
return Math.round(number * factor) / factor;
};
onMounted(() => {
if (imgRef.value.complete) initCanvas()
// loadSavedBoxes()
})
onUnmounted(() => {
ctx = null
})
</script>
<style 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: 4px 0;
font-size: 13px;
border-left: 4px solid #ddd;
background: #f9f9f9;
}
</style>
2.2在你的页面中直接引入使用:
typescript
<template>
<div>
<h3>图片标框工具</h3>
<GetBoxes />
</div>
</template>
<script setup>
import GetBoxes from './GetBoxes.vue'
</script>
三、总结
Vue3 标准组合式 API写法
基于 Canvas 实现标框,性能好、不操作 DOM
代码可直接运行,坐标实时输出,支持多框、清空
只需要替换图片地址、接入接口就能用于项目