-
+
-
-
-
- ROI列表 ({{ roiList.length }}) + +
+
+
-
暂无ROI,请在左侧画面上绘制
-
-
- - {{ roi.name || '未命名' }} - - {{ roi.roiType === 'rectangle' ? '矩形' : '多边形' }} - - - + +
+
+ ROI列表 ({{ roiList.length }}) +
+
暂无ROI,请使用上方按钮添加
+
+
+ + {{ roi.name || '未命名' }} + + {{ getRoiTagLabel(roi) }} + + + +
-
- - - -
-

ROI属性

- - - - - - - - - - - - - - - +
+

ROI属性

+ + + + + + + + + + + + + + + + + + +
+
点击左侧ROI区域或列表项查看详情
-
点击左侧ROI区域或列表项查看详情
-
+
@@ -96,28 +112,31 @@ export default { data() { return { cameraId: '', + cameraName: '', srcUrl: '', - app: '', - stream: '', drawMode: null, roiList: [], selectedRoiId: null, selectedRoiBindings: [], snapUrl: '', - snapLoading: false + snapLoading: false, + panelVisible: false, + polygonPointCount: 0 } }, computed: { selectedRoi() { if (!this.selectedRoiId) return null return this.roiList.find(r => r.roiId === this.selectedRoiId) || null + }, + isDrawing() { + return this.drawMode === 'polygon' } }, mounted() { this.cameraId = decodeURIComponent(this.$route.params.cameraId) + this.cameraName = this.$route.query.cameraName || '' this.srcUrl = this.$route.query.srcUrl || '' - this.app = this.$route.query.app || '' - this.stream = this.$route.query.stream || '' this.fetchSnap() this.loadRois() }, @@ -125,6 +144,21 @@ export default { goBack() { this.$router.push('/cameraConfig') }, + + // ---- ROI 类型标签 ---- + getRoiTagType(roi) { + const type = roi.roiType || roi.roi_type + if (type === 'fullscreen') return 'warning' + return 'success' + }, + getRoiTagLabel(roi) { + const type = roi.roiType || roi.roi_type + if (type === 'fullscreen') return '全图' + if (type === 'rectangle') return '矩形' + return '自定义' + }, + + // ---- 数据加载 ---- loadRois() { queryRoiByCameraId(this.cameraId).then(res => { this.roiList = res.data || [] @@ -144,9 +178,8 @@ export default { } }).catch(() => {}) }, - startDraw(mode) { - this.drawMode = mode - }, + + // ---- 截图 ---- fetchSnap(force = false) { if (!this.cameraId) return this.snapLoading = true @@ -161,39 +194,121 @@ export default { refreshSnap() { this.fetchSnap(true) }, + + // ---- 全图 ---- + addFullscreen() { + // 检查是否已有全图 ROI + const hasFullscreen = this.roiList.some(r => (r.roiType || r.roi_type) === 'fullscreen') + if (hasFullscreen) { + this.$message.warning('已存在全图选区') + return + } + this.$prompt('请输入选区名称', '新建全图选区', { + confirmButtonText: '确定', + cancelButtonText: '取消', + inputValue: '全图-' + (this.roiList.length + 1) + }).then(({ value }) => { + const newRoi = { + cameraId: this.cameraId, + name: value || '全图', + roiType: 'fullscreen', + coordinates: JSON.stringify({ x: 0, y: 0, w: 1, h: 1 }), + color: '#FF0000', + priority: 0, + enabled: 1, + description: '' + } + saveRoi(newRoi).then(() => { + this.$message.success('全图选区已创建') + this.loadRois() + }).catch(() => { + this.$message.error('保存失败') + }) + }).catch(() => {}) + }, + + // ---- 自定义选区(多边形绘制) ---- + startDraw(mode) { + this.drawMode = mode + this.polygonPointCount = 0 + }, + finishDraw() { + // 通过 ref 调用 Canvas 的完成方法 + if (this.$refs.roiCanvas) { + this.$refs.roiCanvas.finishPolygon() + } + }, + undoPoint() { + if (this.$refs.roiCanvas && this.$refs.roiCanvas.polygonPoints.length > 0) { + this.$refs.roiCanvas.polygonPoints.pop() + this.polygonPointCount = this.$refs.roiCanvas.polygonPoints.length + this.$refs.roiCanvas.redraw() + if (this.$refs.roiCanvas.polygonPoints.length > 0) { + this.$refs.roiCanvas.drawPolygonInProgress() + } + } + }, + cancelDraw() { + this.drawMode = null + this.polygonPointCount = 0 + }, + onDrawCancelled() { + this.drawMode = null + this.polygonPointCount = 0 + }, + onRoiDrawn(data) { this.drawMode = null - const newRoi = { - cameraId: this.cameraId, - name: 'ROI-' + (this.roiList.length + 1), - roiType: data.roi_type, - coordinates: data.coordinates, - color: '#FF0000', - priority: 0, - enabled: 1, - description: '' - } - saveRoi(newRoi).then(() => { - this.$message.success('ROI已保存') - this.loadRois() + this.polygonPointCount = 0 + this.$prompt('请输入选区名称', '新建自定义选区', { + confirmButtonText: '确定', + cancelButtonText: '取消', + inputValue: 'ROI-' + (this.roiList.length + 1) + }).then(({ value }) => { + const newRoi = { + cameraId: this.cameraId, + name: value || 'ROI-' + (this.roiList.length + 1), + roiType: data.roi_type, + coordinates: data.coordinates, + color: '#FF0000', + priority: 0, + enabled: 1, + description: '' + } + saveRoi(newRoi).then(() => { + this.$message.success('选区已保存') + this.loadRois() + }).catch(() => { + this.$message.error('保存失败') + }) }).catch(() => { - this.$message.error('保存失败') + // 用户取消命名,不保存 }) }, + + // ---- ROI 选中与面板 ---- onRoiSelected(roiId) { this.selectedRoiId = roiId if (roiId) { + this.panelVisible = true this.loadRoiDetail() - } else { - this.selectedRoiBindings = [] } }, selectRoi(roi) { this.selectedRoiId = roi.roiId this.loadRoiDetail() }, + closePanel() { + this.panelVisible = false + this.selectedRoiId = null + this.selectedRoiBindings = [] + }, + + // ---- ROI 删除 ---- onRoiDeleted(roiId) { - this.doDeleteRoi(roiId) + this.$confirm('确定删除该ROI?关联的算法绑定也将删除。', '提示', { type: 'warning' }).then(() => { + this.doDeleteRoi(roiId) + }).catch(() => {}) }, deleteRoi(roi) { this.$confirm('确定删除该ROI?关联的算法绑定也将删除。', '提示', { type: 'warning' }).then(() => { @@ -208,10 +323,18 @@ export default { this.selectedRoiBindings = [] } this.loadRois() + // 删除后无 ROI 时自动收起面板 + this.$nextTick(() => { + if (this.roiList.length === 0) { + this.panelVisible = false + } + }) }).catch(() => { this.$message.error('删除失败') }) }, + + // ---- ROI 更新 ---- updateRoi(roi) { saveRoi(roi).then(() => { this.loadRois() @@ -219,6 +342,8 @@ export default { this.$message.error('更新失败') }) }, + + // ---- 推送配置 ---- handlePush() { this.$confirm('确定将此摄像头的所有ROI配置推送到边缘端?', '推送配置', { type: 'info' }).then(() => { pushConfig(this.cameraId).then(() => { @@ -228,6 +353,12 @@ export default { }) }).catch(() => {}) } + }, + // 监听 Canvas 中的顶点数量变化(用于工具栏按钮禁用状态) + updated() { + if (this.isDrawing && this.$refs.roiCanvas) { + this.polygonPointCount = this.$refs.roiCanvas.polygonPoints.length + } } } @@ -236,11 +367,47 @@ export default { .roi-config-page { padding: 15px; height: calc(100vh - 90px); display: flex; flex-direction: column; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } .header-left { display: flex; align-items: center; gap: 10px; } -.header-left h3 { margin: 0; } +.header-left h3 { margin: 0; font-size: 15px; } .header-right { display: flex; gap: 8px; } -.main-content { display: flex; flex: 1; gap: 15px; overflow: hidden; } -.canvas-panel { flex: 6; background: #000; border-radius: 4px; overflow: hidden; } -.side-panel { flex: 4; overflow-y: auto; background: #fff; border: 1px solid #eee; border-radius: 4px; padding: 12px; } + +.main-content { display: flex; flex: 1; gap: 0; overflow: hidden; position: relative; } + +.canvas-panel { + flex: 1; + background: #000; + border-radius: 4px; + overflow: hidden; + transition: flex 0.3s ease; +} +.canvas-panel.panel-open { + flex: 6; +} + +.side-panel { + flex: 4; + overflow-y: auto; + background: #fff; + border: 1px solid #eee; + border-radius: 0 4px 4px 0; + padding: 12px; + margin-left: 1px; +} +.panel-close { + text-align: right; + margin-bottom: 8px; +} + +/* 面板滑入动画 */ +.slide-panel-enter-active, .slide-panel-leave-active { + transition: all 0.3s ease; +} +.slide-panel-enter, .slide-panel-leave-to { + flex: 0 !important; + padding: 0 !important; + overflow: hidden; + opacity: 0; +} + .section-header { font-weight: bold; font-size: 14px; margin-bottom: 10px; } .empty-tip { color: #999; text-align: center; padding: 20px 0; font-size: 13px; } .roi-item { padding: 8px 10px; border: 1px solid #eee; border-radius: 4px; margin-bottom: 6px; cursor: pointer; transition: all 0.2s; } diff --git a/web/src/views/roiConfig/components/RoiCanvas.vue b/web/src/views/roiConfig/components/RoiCanvas.vue index a732b97f1..d108c33eb 100644 --- a/web/src/views/roiConfig/components/RoiCanvas.vue +++ b/web/src/views/roiConfig/components/RoiCanvas.vue @@ -1,5 +1,5 @@ @@ -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; +}