重构:ROI选区界面改造 — 全图+自定义选区+可收起面板

RoiCanvas.vue:
- 移除矩形绘制模式,保留多边形模式
- 添加鼠标跟随线、首尾闭合预览、绘制提示条
- 键盘事件:Esc取消、Ctrl+Z撤销上一顶点
- 支持 fullscreen 类型渲染和点击检测

roiConfig.vue:
- 工具栏状态机:默认/绘制中两套按钮
- 全图按钮一键创建覆盖整张图的ROI
- 初始Canvas全宽,点击ROI后右侧面板滑出
- 绘制完成/全图创建时弹出命名输入框
- 删除最后ROI时面板自动收起
This commit is contained in:
2026-03-26 18:04:02 +08:00
parent 8f8b4a2e97
commit e2d404749a
2 changed files with 366 additions and 147 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div class="roi-canvas-wrapper" ref="wrapper">
<div class="roi-canvas-wrapper" ref="wrapper" tabindex="0" @keydown="onKeyDown">
<img
v-if="snapUrl"
ref="bgImage"
@@ -15,12 +15,16 @@
<canvas
ref="canvas"
class="roi-overlay"
:class="{ drawing: drawMode === 'polygon' }"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@dblclick="onDoubleClick"
@contextmenu.prevent="onContextMenu"
></canvas>
<!-- 绘制中浮动提示条 -->
<div v-if="drawMode === 'polygon'" class="draw-hint-bar">
单击添加顶点 | 双击或右键完成选区 | Esc取消 | Ctrl+Z撤销
</div>
</div>
</template>
@@ -38,10 +42,8 @@ export default {
ctx: null,
canvasWidth: 0,
canvasHeight: 0,
isDrawing: false,
startPoint: null,
currentPoint: null,
polygonPoints: [],
mouseMovePoint: null,
loading: true,
errorMsg: '',
resizeObserver: null
@@ -50,10 +52,14 @@ export default {
watch: {
rois: { handler() { this.redraw() }, deep: true },
selectedRoiId() { this.redraw() },
drawMode() {
drawMode(newVal) {
this.polygonPoints = []
this.isDrawing = false
this.mouseMovePoint = null
this.redraw()
// 进入绘制模式时聚焦 wrapper 以接收键盘事件
if (newVal && this.$refs.wrapper) {
this.$refs.wrapper.focus()
}
},
snapUrl() {
this.loading = true
@@ -64,7 +70,6 @@ export default {
mounted() {
this.$nextTick(() => {
this.initCanvas()
// ResizeObserver 确保容器尺寸变化时重新初始化 canvas
if (this.$refs.wrapper && typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => {
if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) {
@@ -86,14 +91,11 @@ export default {
methods: {
onImageLoad() {
this.loading = false
this.$nextTick(() => {
this.initCanvas()
})
this.$nextTick(() => this.initCanvas())
},
onImageError() {
this.loading = false
this.errorMsg = '截图加载失败,请确认摄像头正在拉流'
// 关键:截图失败也初始化 canvas使 ROI 区域可见可操作
this.$nextTick(() => this.initCanvas())
},
initCanvas() {
@@ -117,74 +119,99 @@ export default {
y: (e.clientY - rect.top) / this.canvasHeight
}
},
// ---- 鼠标事件 ----
onMouseDown(e) {
if (e.button !== 0) return
const pt = this.getCanvasPoint(e)
if (this.drawMode === 'rectangle') {
this.isDrawing = true
this.startPoint = pt
this.currentPoint = pt
} else if (this.drawMode === 'polygon') {
if (this.drawMode === 'polygon') {
// 多边形模式:添加顶点
this.polygonPoints.push(pt)
this.redraw()
this.drawPolygonInProgress()
} else {
} else if (!this.drawMode) {
// 非绘制模式:点击选中 ROI
const clickedRoi = this.findRoiAtPoint(pt)
this.$emit('roi-selected', clickedRoi ? clickedRoi.roiId || clickedRoi.roi_id : null)
}
},
onMouseMove(e) {
if (!this.isDrawing || this.drawMode !== 'rectangle') return
this.currentPoint = this.getCanvasPoint(e)
this.redraw()
this.drawRectInProgress()
},
onMouseUp(e) {
if (!this.isDrawing || this.drawMode !== 'rectangle') return
this.isDrawing = false
const endPoint = this.getCanvasPoint(e)
const x = Math.min(this.startPoint.x, endPoint.x)
const y = Math.min(this.startPoint.y, endPoint.y)
const w = Math.abs(endPoint.x - this.startPoint.x)
const h = Math.abs(endPoint.y - this.startPoint.y)
if (w > 0.01 && h > 0.01) {
this.$emit('roi-drawn', {
roi_type: 'rectangle',
coordinates: JSON.stringify({ x, y, w, h })
})
}
this.startPoint = null
this.currentPoint = null
if (this.drawMode !== 'polygon' || this.polygonPoints.length === 0) return
this.mouseMovePoint = this.getCanvasPoint(e)
this.redraw()
this.drawPolygonInProgress()
},
onDoubleClick() {
if (this.drawMode === 'polygon' && this.polygonPoints.length >= 3) {
this.$emit('roi-drawn', {
roi_type: 'polygon',
coordinates: JSON.stringify(this.polygonPoints.map(p => ({ x: p.x, y: p.y })))
})
this.polygonPoints = []
this.redraw()
// 双击完成:移除最后一个重复点(双击会触发两次 mousedown
this.finishPolygon()
}
},
onContextMenu(e) {
const pt = this.getCanvasPoint(e)
const roi = this.findRoiAtPoint(pt)
if (roi) {
this.$emit('roi-deleted', roi.roiId || roi.roi_id)
if (this.drawMode === 'polygon' && this.polygonPoints.length >= 3) {
// 右键完成选区
this.finishPolygon()
return
}
// 非绘制模式:右键删除 ROI
if (!this.drawMode) {
const pt = this.getCanvasPoint(e)
const roi = this.findRoiAtPoint(pt)
if (roi) {
this.$emit('roi-deleted', roi.roiId || roi.roi_id)
}
}
},
// ---- 键盘事件 ----
onKeyDown(e) {
if (this.drawMode === 'polygon') {
if (e.key === 'Escape') {
// Esc 取消绘制
this.polygonPoints = []
this.mouseMovePoint = null
this.$emit('draw-cancelled')
this.redraw()
} else if ((e.ctrlKey && e.key === 'z') || e.key === 'Backspace') {
// Ctrl+Z 或 Backspace 撤销上一个顶点
e.preventDefault()
if (this.polygonPoints.length > 0) {
this.polygonPoints.pop()
this.redraw()
if (this.polygonPoints.length > 0) {
this.drawPolygonInProgress()
}
}
}
}
},
// ---- 多边形完成 ----
finishPolygon() {
const points = this.polygonPoints.map(p => ({ x: p.x, y: p.y }))
this.$emit('roi-drawn', {
roi_type: 'polygon',
coordinates: JSON.stringify(points)
})
this.polygonPoints = []
this.mouseMovePoint = null
this.redraw()
},
// ---- ROI 查找 ----
findRoiAtPoint(pt) {
for (let i = this.rois.length - 1; i >= 0; i--) {
const roi = this.rois[i]
try {
const coords = typeof roi.coordinates === 'string' ? JSON.parse(roi.coordinates) : roi.coordinates
if (roi.roiType === 'rectangle' || roi.roi_type === 'rectangle') {
const type = roi.roiType || roi.roi_type
if (type === 'rectangle' || type === 'fullscreen') {
if (pt.x >= coords.x && pt.x <= coords.x + coords.w &&
pt.y >= coords.y && pt.y <= coords.y + coords.h) {
return roi
}
} else if (roi.roiType === 'polygon' || roi.roi_type === 'polygon') {
} else if (type === 'polygon') {
if (this.isPointInPolygon(pt, coords)) return roi
}
} catch (e) { /* skip */ }
@@ -202,6 +229,8 @@ export default {
}
return inside
},
// ---- 绘制 ----
redraw() {
if (!this.ctx) return
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
@@ -210,10 +239,13 @@ export default {
const coords = typeof roi.coordinates === 'string' ? JSON.parse(roi.coordinates) : roi.coordinates
const color = roi.color || '#FF0000'
const isSelected = (roi.roiId || roi.roi_id) === this.selectedRoiId
const type = roi.roiType || roi.roi_type
this.ctx.strokeStyle = color
this.ctx.lineWidth = isSelected ? 3 : 2
this.ctx.fillStyle = color + '33'
if (roi.roiType === 'rectangle' || roi.roi_type === 'rectangle') {
if (type === 'rectangle' || type === 'fullscreen') {
const rx = coords.x * this.canvasWidth
const ry = coords.y * this.canvasHeight
const rw = coords.w * this.canvasWidth
@@ -225,7 +257,7 @@ export default {
this.ctx.font = '12px Arial'
this.ctx.fillText(roi.name, rx + 4, ry + 14)
}
} else if (roi.roiType === 'polygon' || roi.roi_type === 'polygon') {
} else if (type === 'polygon') {
this.ctx.beginPath()
coords.forEach((p, idx) => {
const px = p.x * this.canvasWidth
@@ -244,18 +276,6 @@ export default {
} catch (e) { /* skip */ }
})
},
drawRectInProgress() {
if (!this.startPoint || !this.currentPoint) return
const x = Math.min(this.startPoint.x, this.currentPoint.x) * this.canvasWidth
const y = Math.min(this.startPoint.y, this.currentPoint.y) * this.canvasHeight
const w = Math.abs(this.currentPoint.x - this.startPoint.x) * this.canvasWidth
const h = Math.abs(this.currentPoint.y - this.startPoint.y) * this.canvasHeight
this.ctx.strokeStyle = '#00FF00'
this.ctx.lineWidth = 2
this.ctx.setLineDash([5, 5])
this.ctx.strokeRect(x, y, w, h)
this.ctx.setLineDash([])
},
drawPolygonInProgress() {
if (this.polygonPoints.length < 1) return
this.ctx.strokeStyle = '#00FF00'
@@ -266,9 +286,24 @@ export default {
const px = p.x * this.canvasWidth
const py = p.y * this.canvasHeight
idx === 0 ? this.ctx.moveTo(px, py) : this.ctx.lineTo(px, py)
// 顶点圆点
this.ctx.fillStyle = '#00FF00'
this.ctx.fillRect(px - 3, py - 3, 6, 6)
this.ctx.fillRect(px - 4, py - 4, 8, 8)
})
// 鼠标跟随线(到当前鼠标位置)
if (this.mouseMovePoint) {
this.ctx.lineTo(
this.mouseMovePoint.x * this.canvasWidth,
this.mouseMovePoint.y * this.canvasHeight
)
}
// 首尾闭合预览线(淡色)
if (this.polygonPoints.length >= 3) {
const first = this.polygonPoints[0]
const last = this.mouseMovePoint || this.polygonPoints[this.polygonPoints.length - 1]
this.ctx.moveTo(last.x * this.canvasWidth, last.y * this.canvasHeight)
this.ctx.lineTo(first.x * this.canvasWidth, first.y * this.canvasHeight)
}
this.ctx.stroke()
this.ctx.setLineDash([])
}
@@ -283,6 +318,7 @@ export default {
height: 100%;
min-height: 400px;
background: #000;
outline: none;
}
.bg-image {
width: 100%;
@@ -306,6 +342,22 @@ export default {
left: 0;
width: 100%;
height: 100%;
}
.roi-overlay.drawing {
cursor: crosshair;
}
.draw-hint-bar {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 6px 16px;
border-radius: 4px;
font-size: 13px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
}
</style>