+
-
-
-
- ROI列表 ({{ roiList.length }})
+
+
@@ -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 @@
-
+
+
- 暂无ROI,请在左侧画面上绘制
-
-
-
-
+
-
- {{ roi.name || '未命名' }}
-
- {{ roi.roiType === 'rectangle' ? '矩形' : '多边形' }}
-
-
-
+
+
-
-
+
-
+ ROI列表 ({{ roiList.length }})
+
+ 暂无ROI,请使用上方按钮添加
+
+
+
+ {{ roi.name || '未命名' }}
+
+ {{ getRoiTagLabel(roi) }}
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
- ROI属性
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ROI属性
+点击左侧ROI区域或列表项查看详情
点击左侧ROI区域或列表项查看详情
-
+
+
+
@@ -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;
+}