
1.组件代码
<template>
<div class="annot-image">
<div class="annot-toolbar">
<button class="annot-btn" :class="{ active: drawMode === 'rect' }" @click="handleStartRect">
<img src="@/assets/images/icon/icon-annotation-rect.png" alt="rect" />
<span>矩形标记</span>
</button>
<button class="annot-btn" :class="{ active: drawMode === 'polygon' }" @click="handleStartPolygon">
<img src="@/assets/images/icon/icon-annotation-rect.png" alt="rect" />
<span>多边形标记</span>
</button>
<button class="annot-btn" :class="{ active: drawMode === 'multi' }" @click="handleStartMulti">
<img src="@/assets/images/icon/icon-annotation-points.png" alt="multi" />
<span>点标记</span>
</button>
<button class="annot-btn" @click="handleReplaceImage">
<img src="@/assets/images/icon/icon-annotation-img.png" alt="replace" />
<span>替换图片</span>
</button>
<button class="annot-btn" @click="handleUndo" :disabled="!canUndo">
<img src="@/assets/images/icon/icon-annotation-back.png" alt="undo" />
<span>撤销</span>
</button>
<button class="annot-btn" @click="handleClear" :disabled="shapes.length === 0">
<img src="@/assets/images/icon/icon-annotation-clear.png" alt="clear" />
<span>清空</span>
</button>
</div>
<div class="canvas-wrapper" ref="canvasWrapper">
<canvas ref="canvasRef"
:class="{ 'drawing-mode': drawMode === 'rect' || drawMode === 'multi' || drawMode === 'polygon' }"
@mousedown="handleMouseDown" @mousemove="handleMouseMove" @mouseup="handleMouseUp"
@click="handleCanvasClick" @dblclick="handleDoubleClick"
@contextmenu.prevent="handleRightClick"></canvas>
<input type="file" ref="fileInputRef" accept="image/*" style="display: none"
@change="handleFileChange" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, defineExpose } from 'vue'
import markerUrl from '@/assets/images/icon/mark.png'
const props = defineProps({
image: {
type: String,
default: ''
}
})
const emit = defineEmits(['imageChange'])
// Canvas 相关
const canvasRef = ref(null)
const canvasWrapper = ref(null)
const fileInputRef = ref(null)
const currentImage = ref('')
const drawMode = ref('') // 'rect' | 'multi' | 'polygon' | ''
const shapes = ref([]) // 存储所有绘制的图形
const history = ref([]) // 操作历史,用于撤销
// 缓存图片,避免每次重绘触发异步加载造成闪烁
const bgImg = ref(null)
const bgImgLoaded = ref(false)
const markerImg = new Image()
let markerImgLoaded = false
markerImg.onload = () => { markerImgLoaded = true }
markerImg.src = markerUrl
// 矩形绘制状态
const isDrawingRect = ref(false)
const rectStart = ref({ x: 0, y: 0 })
const currentRect = ref(null)
// 多点绘制状态
const multiPoints = ref([])
const isDrawingMulti = ref(false)
// 多边形绘制状态(预览点位为画布绝对坐标,保存为归一化坐标)
const polygonTempPoints = ref([])
const isDrawingPolygon = ref(false)
// 计算是否可以撤销
const canUndo = ref(false)
// 初始化 canvas
const initCanvas = () => {
if (!canvasRef.value || !props.image) return
nextTick(() => {
const canvas = canvasRef.value
const wrapper = canvasWrapper.value
if (!wrapper) return
// 确保 canvas 的实际像素尺寸和 CSS 显示尺寸一致,避免缩放导致的坐标偏移
const cssWidth = wrapper.clientWidth
const cssHeight = wrapper.clientHeight
canvas.width = cssWidth
canvas.height = cssHeight
// 确保 CSS 尺寸也明确设置,避免浏览器自动缩放
canvas.style.width = cssWidth + 'px'
canvas.style.height = cssHeight + 'px'
currentImage.value = props.image
// 初始化历史记录(空状态)
if (history.value.length === 0) {
history.value.push([])
}
drawImage()
})
}
// 绘制背景图片(使用缓存图片,避免频繁 onload 导致闪烁)
const drawImage = () => {
if (!canvasRef.value || !currentImage.value) return
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!bgImg.value || bgImg.value.src !== currentImage.value) {
bgImgLoaded.value = false
bgImg.value = new Image()
bgImg.value.onload = () => {
bgImgLoaded.value = true
// 计算图片显示尺寸(保持宽高比)并记录
const canvasWidth = canvas.width
const canvasHeight = canvas.height
const imgWidth = bgImg.value.width
const imgHeight = bgImg.value.height
const scale = Math.min(canvasWidth / imgWidth, canvasHeight / imgHeight)
const displayWidth = imgWidth * scale
const displayHeight = imgHeight * scale
const x = (canvasWidth - displayWidth) / 2
const y = (canvasHeight - displayHeight) / 2
canvas.dataset.imageX = x
canvas.dataset.imageY = y
canvas.dataset.imageWidth = displayWidth
canvas.dataset.imageHeight = displayHeight
// 首次绘制
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(bgImg.value, x, y, displayWidth, displayHeight)
redrawShapes()
}
bgImg.value.src = currentImage.value
} else if (bgImgLoaded.value) {
const x = Number(canvas.dataset.imageX || 0)
const y = Number(canvas.dataset.imageY || 0)
const w = Number(canvas.dataset.imageWidth || canvas.width)
const h = Number(canvas.dataset.imageHeight || canvas.height)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(bgImg.value, x, y, w, h)
redrawShapes()
}
}
// 双击:多边形闭合;若点数不足三则清空当前临时点
const handleDoubleClick = (e) => {
if (drawMode.value === 'polygon') {
if (polygonTempPoints.value.length >= 3) {
finishPolygonDrawing()
} else {
polygonTempPoints.value = []
isDrawingPolygon.value = true
redrawShapes()
}
}
}
// 重新绘制所有图形(同步使用已缓存的背景图和标记图)
const redrawShapes = () => {
if (!canvasRef.value) return
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
// 先清除 canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 绘制背景图片
if (currentImage.value && bgImg.value && bgImgLoaded.value) {
const x = Number(canvas.dataset.imageX || 0)
const y = Number(canvas.dataset.imageY || 0)
const w = Number(canvas.dataset.imageWidth || canvas.width)
const h = Number(canvas.dataset.imageHeight || canvas.height)
ctx.drawImage(bgImg.value, x, y, w, h)
// 绘制所有图形
shapes.value.forEach(shape => {
drawShape(ctx, shape)
})
// 多边形预览折线
if (drawMode.value === 'polygon' && polygonTempPoints.value.length > 0) {
ctx.save()
ctx.strokeStyle = '#C73520'
ctx.lineWidth = 2
ctx.setLineDash([6, 6])
ctx.beginPath()
ctx.moveTo(polygonTempPoints.value[0].x, polygonTempPoints.value[0].y)
for (let i = 1; i < polygonTempPoints.value.length; i++) {
ctx.lineTo(polygonTempPoints.value[i].x, polygonTempPoints.value[i].y)
}
ctx.stroke()
ctx.restore()
}
}
}
// 绘制单个图形
const drawShape = (ctx, shape) => {
ctx.strokeStyle = '#C73520'
ctx.fillStyle = 'rgba(199, 53, 32, 0.2)'
ctx.lineWidth = 2
if (shape.type === 'rect') {
const { x, y, width, height } = shape
ctx.fillRect(x, y, width, height)
ctx.strokeRect(x, y, width, height)
} else if (shape.type === 'point') {
const size = shape.size || 20
// 计算当前图片在画布中的显示区域
const canvas = canvasRef.value
const imgX = Number(canvas?.dataset.imageX || 0)
const imgY = Number(canvas?.dataset.imageY || 0)
const imgW = Number(canvas?.dataset.imageWidth || canvas.width)
const imgH = Number(canvas?.dataset.imageHeight || canvas.height)
// 兼容:优先使用归一化坐标 nx, ny;无则使用旧的绝对坐标 x, y
const cx = typeof shape.nx === 'number' ? imgX + shape.nx * imgW : shape.x
const cy = typeof shape.ny === 'number' ? imgY + shape.ny * imgH : shape.y
// 标记锚点:底部居中
const drawX = cx - size / 2
const drawY = cy - size
if (markerImgLoaded) {
ctx.drawImage(markerImg, drawX, drawY, size, size)
} else {
// 图标未加载完成时,等加载后重绘
markerImg.onload = () => {
markerImgLoaded = true
ctx.drawImage(markerImg, drawX, drawY, size, size)
}
}
} else if (shape.type === 'polygon') {
const { points } = shape // [{nx,ny}] or [{x,y}]
const canvas = canvasRef.value
const imgX = Number(canvas?.dataset.imageX || 0)
const imgY = Number(canvas?.dataset.imageY || 0)
const imgW = Number(canvas?.dataset.imageWidth || canvas.width)
const imgH = Number(canvas?.dataset.imageHeight || canvas.height)
const absPoints = points.map(p => ({
x: typeof p.nx === 'number' ? imgX + p.nx * imgW : p.x,
y: typeof p.ny === 'number' ? imgY + p.ny * imgH : p.y
}))
if (absPoints.length >= 2) {
ctx.beginPath()
ctx.moveTo(absPoints[0].x, absPoints[0].y)
for (let i = 1; i < absPoints.length; i++) {
ctx.lineTo(absPoints[i].x, absPoints[i].y)
}
if (absPoints.length >= 3) {
ctx.closePath()
ctx.fill()
}
ctx.stroke()
}
}
}
// 获取鼠标在 canvas 中的坐标(考虑 canvas 的实际像素尺寸和显示尺寸的缩放比例)
const getCanvasPoint = (e) => {
const canvas = canvasRef.value
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
// 计算 canvas 的实际像素尺寸和显示尺寸的比例
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
// 将鼠标坐标转换为 canvas 实际像素坐标
const x = (e.clientX - rect.left) * scaleX
const y = (e.clientY - rect.top) * scaleY
return { x, y }
}
// 矩形绘制:鼠标按下
const handleMouseDown = (e) => {
if (drawMode.value !== 'rect') return
isDrawingRect.value = true
const point = getCanvasPoint(e)
rectStart.value = point
currentRect.value = { ...point, width: 0, height: 0 }
}
// 矩形绘制:鼠标移动
const handleMouseMove = (e) => {
if (drawMode.value === 'rect' && isDrawingRect.value && currentRect.value) {
const point = getCanvasPoint(e)
currentRect.value.width = point.x - rectStart.value.x
currentRect.value.height = point.y - rectStart.value.y
// 实时绘制
redrawShapes()
const ctx = canvasRef.value.getContext('2d')
drawShape(ctx, { type: 'rect', ...currentRect.value })
}
}
// 矩形绘制:鼠标释放
const handleMouseUp = (e) => {
if (drawMode.value === 'rect' && isDrawingRect.value) {
isDrawingRect.value = false
if (currentRect.value && Math.abs(currentRect.value.width) > 5 && Math.abs(currentRect.value.height) > 5) {
// 保存矩形
const rect = {
type: 'rect',
x: rectStart.value.x,
y: rectStart.value.y,
width: currentRect.value.width,
height: currentRect.value.height
}
// 确保宽高为正
if (rect.width < 0) {
rect.x += rect.width
rect.width = Math.abs(rect.width)
}
if (rect.height < 0) {
rect.y += rect.height
rect.height = Math.abs(rect.height)
}
saveShape(rect)
currentRect.value = null
}
}
}
// 画布点击:点标记添加点;多边形采点预览
const handleCanvasClick = (e) => {
const point = getCanvasPoint(e)
if (drawMode.value === 'multi') {
// 计算当前图片显示区域,转为归一化坐标,保证缩放/重绘精准
const canvas = canvasRef.value
const imgX = Number(canvas.dataset.imageX || 0)
const imgY = Number(canvas.dataset.imageY || 0)
const imgW = Number(canvas.dataset.imageWidth || canvas.width)
const imgH = Number(canvas.dataset.imageHeight || canvas.height)
const nx = (point.x - imgX) / imgW
const ny = (point.y - imgY) / imgH
const pointShape = { type: 'point', nx, ny, x: point.x, y: point.y, size: 20 }
saveShape(pointShape)
} else if (drawMode.value === 'polygon') {
polygonTempPoints.value.push({ x: point.x, y: point.y })
redrawShapes()
}
}
// 右键点击:多点模式无操作;多边形用于完成闭合
const handleRightClick = (e) => {
if (drawMode.value === 'polygon') {
if (polygonTempPoints.value.length >= 3) {
finishPolygonDrawing()
} else {
// 小于3个点则清空正在绘制的多边形,但保持在多边形模式
polygonTempPoints.value = []
isDrawingPolygon.value = true
redrawShapes()
}
}
}
// 保存图形
const saveShape = (shape) => {
shapes.value.push(shape)
// 保存当前状态到历史记录
history.value.push(shapes.value.map(s => JSON.parse(JSON.stringify(s))))
canUndo.value = history.value.length > 1 || shapes.value.length > 0
redrawShapes()
}
// 完成多点绘制(双击或右键)
const finishMultiDrawing = () => {
multiPoints.value = []
isDrawingMulti.value = false
}
// 完成多边形绘制:将临时采集的画布坐标转为归一化坐标后保存
const finishPolygonDrawing = () => {
if (polygonTempPoints.value.length >= 3) {
const canvas = canvasRef.value
const imgX = Number(canvas.dataset.imageX || 0)
const imgY = Number(canvas.dataset.imageY || 0)
const imgW = Number(canvas.dataset.imageWidth || canvas.width)
const imgH = Number(canvas.dataset.imageHeight || canvas.height)
const points = polygonTempPoints.value.map(p => ({
nx: (p.x - imgX) / imgW,
ny: (p.y - imgY) / imgH
}))
saveShape({ type: 'polygon', points })
}
polygonTempPoints.value = []
isDrawingPolygon.value = false
}
// 工具栏功能
const handleStartRect = () => {
if (drawMode.value === 'rect') {
drawMode.value = ''
} else {
drawMode.value = 'rect'
finishMultiDrawing() // 如果正在多点绘制,先完成
}
}
const handleStartMulti = () => {
if (drawMode.value === 'multi') {
// 如果已经在多点模式,完成当前绘制
finishMultiDrawing()
drawMode.value = ''
} else {
drawMode.value = 'multi'
isDrawingRect.value = false
if (!isDrawingMulti.value) {
multiPoints.value = []
isDrawingMulti.value = true
}
}
}
const handleStartPolygon = () => {
if (drawMode.value === 'polygon') {
finishPolygonDrawing()
drawMode.value = ''
} else {
drawMode.value = 'polygon'
isDrawingRect.value = false
isDrawingMulti.value = false
polygonTempPoints.value = []
isDrawingPolygon.value = true
}
}
const handleReplaceImage = () => {
fileInputRef.value?.click()
}
const handleFileChange = (e) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (event) => {
currentImage.value = event.target.result
shapes.value = []
history.value = [[]] // 初始化空状态
canUndo.value = false
emit('imageChange', currentImage.value)
drawImage()
}
reader.readAsDataURL(file)
}
// 清空 input,允许重复选择同一文件
e.target.value = ''
}
const handleUndo = () => {
if (history.value.length <= 1) {
shapes.value = []
history.value = [[]] // 保留初始空状态
canUndo.value = false
} else {
history.value.pop()
const lastState = history.value[history.value.length - 1]
shapes.value = lastState ? lastState.map(s => JSON.parse(JSON.stringify(s))) : []
canUndo.value = history.value.length > 1
}
redrawShapes()
}
const handleClear = () => {
shapes.value = []
history.value = [[]] // 初始化空状态
canUndo.value = false
multiPoints.value = []
isDrawingMulti.value = false
isDrawingRect.value = false
isDrawingPolygon.value = false
polygonTempPoints.value = []
drawMode.value = ''
redrawShapes()
}
// 导出画布为图片的方法(供父组件调用)
const exportCanvas = async () => {
redrawShapes()
const canvas = canvasRef.value
if (canvas) {
try {
// 预览用的 base64(用于显示)
const dataUrl = canvas.toDataURL('image/png')
return dataUrl
} catch (err) {
console.error('导出画布失败', err)
return null
}
}
return null
}
// 导出画布为 Blob(用于上传)
const exportCanvasAsBlob = async () => {
redrawShapes()
const canvas = canvasRef.value
if (canvas) {
try {
const blob = await new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), 'image/png')
})
return blob
} catch (err) {
console.error('导出画布为Blob失败', err)
return null
}
}
return null
}
// 监听图片变化
watch(() => props.image, (newImage) => {
if (newImage) {
initCanvas()
}
}, { immediate: true })
// 暴露方法供父组件调用
defineExpose({
exportCanvas,// 导出画布为图片
exportCanvasAsBlob, // 导出画布为Blob
redrawShapes // 重绘图形
})
onMounted(() => {
if (props.image) {
initCanvas()
}
})
</script>
<style scoped>
/* 工具栏样式 */
.annot-toolbar {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
z-index: 10;
}
.annot-btn {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 16px;
color: #FFFFFF;
width: 110px;
height: 32px;
background: linear-gradient(180deg, #257D8F 0%, #3193A0 100%);
border: 1px solid;
border-image: linear-gradient(180deg, rgba(52.000000700354576, 116.00000068545341, 165.00000536441803, 1), rgba(142.00000673532486, 245.00000059604645, 254.00000005960464, 1)) 1 1;
transition: all 0.3s;
}
.annot-btn img {
width: 16px;
height: 16px;
}
.annot-btn.active {
background: linear-gradient(180deg, #02D4C6 0%, #02B8AB 100%);
border-image: linear-gradient(180deg, rgba(2, 212, 198, 1), rgba(2, 184, 171, 1)) 1 1;
}
.annot-btn:disabled {
cursor: not-allowed;
}
.annot-image {
width: 100%;
height: 460px;
background: rgba(9, 39, 54, 0.6);
position: relative;
margin: 20px auto 0;
}
.canvas-wrapper {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.canvas-wrapper canvas {
width: 100%;
height: 100%;
cursor: default;
display: block;
}
.canvas-wrapper canvas.drawing-mode {
cursor: crosshair;
}
</style>
2.使用组件
<ImageAnnotation ref="imageAnnotationRef" :image="image" @image-change="handleImageChange" />
3.通过暴露方法最终拿到图片
imageAnnotationRef.value.exportCanvasAsBlob()