Vue3 图片标框功能实现方案

基于 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

代码可直接运行,坐标实时输出,支持多框、清空

只需要替换图片地址、接入接口就能用于项目

相关推荐
JiaWen技术圈18 小时前
React 19 Fiber 架构 深度解析
前端·react.js·架构
暗冰ཏོ18 小时前
《Vue + React + Java + PHP 项目部署到服务器完整指南》
java·服务器·vue.js·react.js·项目部署
大阳光男孩18 小时前
【UniApp小程序开发】解决无法使用Vue自定义指令的完美替代方案:权限组件封装
前端·vue.js·uni-app
武当王丶也18 小时前
React Native Turbo Module 实战:从 0 封装一个 PDA 扫码模块
android·前端·react native
只要微微辣18 小时前
Uniapp 微信小程序 Canvas画框标注:拖拽缩放全攻略
前端·微信小程序·uni-app·canvas·canva可画
希冀12318 小时前
【CSS学习第十三篇】
前端·css·学习
踏歌~19 小时前
个人简历网站搭建:2 解析原有结构并构建首页
前端
Moment19 小时前
面试官:上下文过长导致语义偏移,工程上怎么优化
前端·后端·面试
IT_陈寒19 小时前
React hooks依赖数组坑得我差点重写整个组件
前端·人工智能·后端