Files
wvp-platform/web/src/views/roiConfig/components/RoiCanvas.vue
16337 e2d404749a 重构:ROI选区界面改造 — 全图+自定义选区+可收起面板
RoiCanvas.vue:
- 移除矩形绘制模式,保留多边形模式
- 添加鼠标跟随线、首尾闭合预览、绘制提示条
- 键盘事件:Esc取消、Ctrl+Z撤销上一顶点
- 支持 fullscreen 类型渲染和点击检测

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

364 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="roi-canvas-wrapper" ref="wrapper" tabindex="0" @keydown="onKeyDown">
<img
v-if="snapUrl"
ref="bgImage"
:src="snapUrl"
class="bg-image"
@load="onImageLoad"
@error="onImageError"
/>
<div v-else class="video-placeholder">
<span v-if="loading">正在截取画面...</span>
<span v-else>{{ errorMsg || '暂无画面' }}</span>
</div>
<canvas
ref="canvas"
class="roi-overlay"
:class="{ drawing: drawMode === 'polygon' }"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@dblclick="onDoubleClick"
@contextmenu.prevent="onContextMenu"
></canvas>
<!-- 绘制中浮动提示条 -->
<div v-if="drawMode === 'polygon'" class="draw-hint-bar">
单击添加顶点 | 双击或右键完成选区 | Esc取消 | Ctrl+Z撤销
</div>
</div>
</template>
<script>
export default {
name: 'RoiCanvas',
props: {
rois: { type: Array, default: () => [] },
drawMode: { type: String, default: null },
selectedRoiId: { type: String, default: null },
snapUrl: { type: String, default: '' }
},
data() {
return {
ctx: null,
canvasWidth: 0,
canvasHeight: 0,
polygonPoints: [],
mouseMovePoint: null,
loading: true,
errorMsg: '',
resizeObserver: null
}
},
watch: {
rois: { handler() { this.redraw() }, deep: true },
selectedRoiId() { this.redraw() },
drawMode(newVal) {
this.polygonPoints = []
this.mouseMovePoint = null
this.redraw()
// 进入绘制模式时聚焦 wrapper 以接收键盘事件
if (newVal && this.$refs.wrapper) {
this.$refs.wrapper.focus()
}
},
snapUrl() {
this.loading = true
this.errorMsg = ''
this.$nextTick(() => this.initCanvas())
}
},
mounted() {
this.$nextTick(() => {
this.initCanvas()
if (this.$refs.wrapper && typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => {
if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) {
this.initCanvas()
}
})
this.resizeObserver.observe(this.$refs.wrapper)
}
window.addEventListener('resize', this.handleResize)
})
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
},
methods: {
onImageLoad() {
this.loading = false
this.$nextTick(() => this.initCanvas())
},
onImageError() {
this.loading = false
this.errorMsg = '截图加载失败,请确认摄像头正在拉流'
this.$nextTick(() => this.initCanvas())
},
initCanvas() {
const canvas = this.$refs.canvas
const wrapper = this.$refs.wrapper
if (!canvas || !wrapper) return
this.canvasWidth = wrapper.clientWidth
this.canvasHeight = wrapper.clientHeight
canvas.width = this.canvasWidth
canvas.height = this.canvasHeight
this.ctx = canvas.getContext('2d')
this.redraw()
},
handleResize() {
this.initCanvas()
},
getCanvasPoint(e) {
const rect = this.$refs.canvas.getBoundingClientRect()
return {
x: (e.clientX - rect.left) / this.canvasWidth,
y: (e.clientY - rect.top) / this.canvasHeight
}
},
// ---- 鼠标事件 ----
onMouseDown(e) {
if (e.button !== 0) return
const pt = this.getCanvasPoint(e)
if (this.drawMode === 'polygon') {
// 多边形模式:添加顶点
this.polygonPoints.push(pt)
this.redraw()
this.drawPolygonInProgress()
} 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.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) {
// 双击完成:移除最后一个重复点(双击会触发两次 mousedown
this.finishPolygon()
}
},
onContextMenu(e) {
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
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 (type === 'polygon') {
if (this.isPointInPolygon(pt, coords)) return roi
}
} catch (e) { /* skip */ }
}
return null
},
isPointInPolygon(pt, polygon) {
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y
const xj = polygon[j].x, yj = polygon[j].y
const intersect = ((yi > pt.y) !== (yj > pt.y)) &&
(pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi)
if (intersect) inside = !inside
}
return inside
},
// ---- 绘制 ----
redraw() {
if (!this.ctx) return
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.rois.forEach(roi => {
try {
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 (type === 'rectangle' || type === 'fullscreen') {
const rx = coords.x * this.canvasWidth
const ry = coords.y * this.canvasHeight
const rw = coords.w * this.canvasWidth
const rh = coords.h * this.canvasHeight
this.ctx.fillRect(rx, ry, rw, rh)
this.ctx.strokeRect(rx, ry, rw, rh)
if (roi.name) {
this.ctx.fillStyle = color
this.ctx.font = '12px Arial'
this.ctx.fillText(roi.name, rx + 4, ry + 14)
}
} else if (type === 'polygon') {
this.ctx.beginPath()
coords.forEach((p, idx) => {
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.closePath()
this.ctx.fill()
this.ctx.stroke()
if (roi.name && coords.length > 0) {
this.ctx.fillStyle = color
this.ctx.font = '12px Arial'
this.ctx.fillText(roi.name, coords[0].x * this.canvasWidth + 4, coords[0].y * this.canvasHeight + 14)
}
}
} catch (e) { /* skip */ }
})
},
drawPolygonInProgress() {
if (this.polygonPoints.length < 1) return
this.ctx.strokeStyle = '#00FF00'
this.ctx.lineWidth = 2
this.ctx.setLineDash([5, 5])
this.ctx.beginPath()
this.polygonPoints.forEach((p, idx) => {
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 - 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([])
}
}
}
</script>
<style scoped>
.roi-canvas-wrapper {
position: relative;
width: 100%;
height: 100%;
min-height: 400px;
background: #000;
outline: none;
}
.bg-image {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 18px;
background: #1a1a2e;
}
.roi-overlay {
position: absolute;
top: 0;
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>