新增AIoT边缘智能模块:摄像头ROI配置、算法管理、配置推送

- 后端:新增aiot模块(bean/dao/service/controller),支持ROI区域CRUD、
  算法注册表管理、ROI-算法绑定、配置推送到FastAPI边缘端、变更日志
- 前端:新增摄像头配置页(列表+ROI子页面)、算法配置页、Canvas绘图组件
  (矩形/多边形)、动态算法参数编辑器、ZLM截图作为ROI编辑背景
- 数据库:新建4张表(wvp_ai_roi/algorithm/roi_algo_bind/config_log)
  字段与FastAPI端SQLite兼容,含2个预置算法
- 路由裁剪:隐藏无关菜单(地图/部标/推流/录制计划等)
- 修复cameraId含/导致REST路径解析错误(改用query参数)
- 新增ai.service配置项(边缘端地址/超时/开关)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 08:59:20 +08:00
parent 6c471cdfd7
commit d7bf969694
44 changed files with 2943 additions and 12 deletions

View File

@@ -0,0 +1,294 @@
<template>
<div class="roi-canvas-wrapper" ref="wrapper">
<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"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@dblclick="onDoubleClick"
@contextmenu.prevent="onContextMenu"
></canvas>
</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,
isDrawing: false,
startPoint: null,
currentPoint: null,
polygonPoints: [],
loading: true,
errorMsg: ''
}
},
watch: {
rois: { handler() { this.redraw() }, deep: true },
selectedRoiId() { this.redraw() },
drawMode() {
this.polygonPoints = []
this.isDrawing = false
this.redraw()
},
snapUrl() {
this.loading = true
this.errorMsg = ''
}
},
mounted() {
this.$nextTick(() => {
this.initCanvas()
window.addEventListener('resize', this.handleResize)
})
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
},
methods: {
onImageLoad() {
this.loading = false
this.$nextTick(() => {
this.initCanvas()
})
},
onImageError() {
this.loading = false
this.errorMsg = '截图加载失败,请确认摄像头正在拉流'
},
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 === 'rectangle') {
this.isDrawing = true
this.startPoint = pt
this.currentPoint = pt
} else if (this.drawMode === 'polygon') {
this.polygonPoints.push(pt)
this.redraw()
this.drawPolygonInProgress()
} else {
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
this.redraw()
},
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()
}
},
onContextMenu(e) {
const pt = this.getCanvasPoint(e)
const roi = this.findRoiAtPoint(pt)
if (roi) {
this.$emit('roi-deleted', roi.roiId || roi.roi_id)
}
},
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') {
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') {
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
this.ctx.strokeStyle = color
this.ctx.lineWidth = isSelected ? 3 : 2
this.ctx.fillStyle = color + '33'
if (roi.roiType === 'rectangle' || roi.roi_type === 'rectangle') {
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 (roi.roiType === 'polygon' || roi.roi_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 */ }
})
},
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'
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 - 3, py - 3, 6, 6)
})
this.ctx.stroke()
this.ctx.setLineDash([])
}
}
}
</script>
<style scoped>
.roi-canvas-wrapper {
position: relative;
width: 100%;
height: 100%;
min-height: 400px;
background: #000;
}
.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%;
cursor: crosshair;
}
</style>