新增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,23 @@
import request from '@/utils/request'
export function queryAlgorithmList() {
return request({
method: 'get',
url: '/api/ai/algorithm/list'
})
}
export function toggleAlgorithmActive(id, isActive) {
return request({
method: 'post',
url: `/api/ai/algorithm/toggle/${id}`,
params: { isActive }
})
}
export function syncAlgorithmsFromEdge() {
return request({
method: 'post',
url: '/api/ai/algorithm/sync'
})
}

26
web/src/api/aiConfig.js Normal file
View File

@@ -0,0 +1,26 @@
import request from '@/utils/request'
export function pushConfig(cameraId) {
return request({
method: 'post',
url: '/api/ai/config/push',
params: { cameraId }
})
}
export function exportConfig(cameraId) {
return request({
method: 'get',
url: '/api/ai/config/export',
params: { cameraId }
})
}
export function queryConfigLogs(params) {
const { page, count, configType, configId } = params
return request({
method: 'get',
url: '/api/ai/log/list',
params: { page, count, configType, configId }
})
}

64
web/src/api/aiRoi.js Normal file
View File

@@ -0,0 +1,64 @@
import request from '@/utils/request'
export function queryRoiList(params) {
const { page, count, cameraId, deviceId, query } = params
return request({
method: 'get',
url: '/api/ai/roi/list',
params: { page, count, cameraId, deviceId, query }
})
}
export function queryRoiDetail(id) {
return request({
method: 'get',
url: `/api/ai/roi/${id}`
})
}
export function queryRoiByCameraId(cameraId) {
return request({
method: 'get',
url: '/api/ai/roi/channel',
params: { cameraId }
})
}
export function saveRoi(data) {
return request({
method: 'post',
url: '/api/ai/roi/save',
data: data
})
}
export function deleteRoi(roiId) {
return request({
method: 'delete',
url: `/api/ai/roi/delete/${roiId}`
})
}
export function bindAlgo(data) {
return request({
method: 'post',
url: '/api/ai/roi/bindAlgo',
data: data
})
}
export function unbindAlgo(bindId) {
return request({
method: 'delete',
url: '/api/ai/roi/unbindAlgo',
params: { bindId }
})
}
export function updateAlgoParams(data) {
return request({
method: 'post',
url: '/api/ai/roi/updateAlgoParams',
data: data
})
}

View File

@@ -0,0 +1,30 @@
import request from '@/utils/request'
export function queryCameraList(params) {
const { page, count, query, pulling, mediaServerId } = params
return request({
method: 'get',
url: '/api/proxy/list',
params: { page, count, query, pulling, mediaServerId }
})
}
export function startCamera(id) {
return request({
method: 'get',
url: '/api/proxy/start',
params: { id }
})
}
export function stopCamera(id) {
return request({
method: 'get',
url: '/api/proxy/stop',
params: { id }
})
}
export function getSnapUrl(cameraId) {
return `/api/ai/roi/snap?cameraId=${encodeURIComponent(cameraId)}`
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><circle cx="64" cy="40" r="20" fill="none" stroke="currentColor" stroke-width="5"/><path d="M30 110c0-22 15-38 34-38s34 16 34 38" fill="none" stroke="currentColor" stroke-width="5"/><path d="M85 30l20-10v30l-20-10" fill="none" stroke="currentColor" stroke-width="4"/><rect x="88" y="70" width="24" height="24" rx="4" fill="none" stroke="currentColor" stroke-width="3"/><line x1="95" y1="82" x2="106" y2="82" stroke="currentColor" stroke-width="2"/><line x1="95" y1="88" x2="103" y2="88" stroke="currentColor" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path d="M16 16h96v96H16z" fill="none" stroke="currentColor" stroke-width="6" stroke-dasharray="8 4"/><rect x="32" y="32" width="40" height="30" rx="3" fill="none" stroke="currentColor" stroke-width="4"/><polygon points="80,60 110,50 110,85 80,75" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="52" cy="90" r="8" fill="none" stroke="currentColor" stroke-width="3"/><line x1="58" y1="96" x2="70" y2="108" stroke="currentColor" stroke-width="3"/></svg>

After

Width:  |  Height:  |  Size: 530 B

View File

@@ -89,6 +89,7 @@ export const constantRoutes = [
path: '/map',
component: Layout,
redirect: '/map',
hidden: true,
children: [{
path: '',
name: 'Map',
@@ -116,6 +117,7 @@ export const constantRoutes = [
meta: { title: '国标录像' }
},
{
hidden: true,
path: '/jtDevice',
name: 'JTDevice',
component: () => import('@/views/jtDevice/index'),
@@ -129,6 +131,7 @@ export const constantRoutes = [
meta: { title: '部标录像' }
},
{
hidden: true,
path: '/push',
name: 'PushList',
component: () => import('@/views/streamPush/index'),
@@ -167,6 +170,7 @@ export const constantRoutes = [
path: '/recordPlan',
component: Layout,
redirect: '/recordPlan',
hidden: true,
children: [
{
path: '',
@@ -180,6 +184,7 @@ export const constantRoutes = [
path: '/cloudRecord',
component: Layout,
redirect: '/cloudRecord',
hidden: true,
onlyIndex: 0,
children: [
{
@@ -200,6 +205,7 @@ export const constantRoutes = [
path: '/mediaServer',
component: Layout,
redirect: '/mediaServer',
hidden: true,
children: [
{
path: '',
@@ -213,6 +219,7 @@ export const constantRoutes = [
path: '/platform',
component: Layout,
redirect: '/platform',
hidden: true,
children: [
{
path: '',
@@ -222,6 +229,38 @@ export const constantRoutes = [
}
]
},
{
path: '/cameraConfig',
component: Layout,
redirect: '/cameraConfig',
onlyIndex: 0,
children: [
{
path: '/cameraConfig',
name: 'CameraConfig',
component: () => import('@/views/cameraConfig/index'),
meta: { title: '摄像头配置', icon: 'roi' }
},
{
hidden: true,
path: '/cameraConfig/roi/:cameraId',
name: 'CameraRoiConfig',
component: () => import('@/views/cameraConfig/roiConfig'),
meta: { title: 'ROI配置' }
}
]
},
{
path: '/algorithmConfig',
component: Layout,
redirect: '/algorithmConfig',
children: [{
path: '',
name: 'AlgorithmConfig',
component: () => import('@/views/algorithmConfig/index'),
meta: { title: '算法配置', icon: 'algorithm' }
}]
},
{
path: '/user',
component: Layout,
@@ -251,6 +290,7 @@ export const constantRoutes = [
{
path: '/operations',
component: Layout,
hidden: true,
meta: { title: '运维中心', icon: 'operations' },
redirect: '/operations/systemInfo',
children: [

View File

@@ -23,6 +23,8 @@ import gbRecord from './modules/gbRecord'
import log from './modules/log'
import frontEnd from './modules/frontEnd'
import jtDevice from './modules/jtDevice'
import aiRoi from './modules/aiRoi'
import aiAlgorithm from './modules/aiAlgorithm'
Vue.use(Vuex)
@@ -49,7 +51,9 @@ const store = new Vuex.Store({
gbRecord,
log,
frontEnd,
jtDevice
jtDevice,
aiRoi,
aiAlgorithm
},
getters
})

View File

@@ -0,0 +1,41 @@
import {
queryAlgorithmList,
toggleAlgorithmActive,
syncAlgorithmsFromEdge
} from '@/api/aiAlgorithm'
const actions = {
queryAlgorithmList({ commit }) {
return new Promise((resolve, reject) => {
queryAlgorithmList().then(response => {
const { data } = response
resolve(data)
}).catch(error => {
reject(error)
})
})
},
toggleAlgorithmActive({ commit }, [id, isActive]) {
return new Promise((resolve, reject) => {
toggleAlgorithmActive(id, isActive).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
},
syncAlgorithmsFromEdge({ commit }) {
return new Promise((resolve, reject) => {
syncAlgorithmsFromEdge().then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
}
}
export default {
namespaced: true,
actions
}

View File

@@ -0,0 +1,93 @@
import {
queryRoiList,
queryRoiDetail,
queryRoiByCameraId,
saveRoi,
deleteRoi,
bindAlgo,
unbindAlgo,
updateAlgoParams
} from '@/api/aiRoi'
const actions = {
queryRoiList({ commit }, params) {
return new Promise((resolve, reject) => {
queryRoiList(params).then(response => {
const { data } = response
resolve(data)
}).catch(error => {
reject(error)
})
})
},
queryRoiDetail({ commit }, id) {
return new Promise((resolve, reject) => {
queryRoiDetail(id).then(response => {
const { data } = response
resolve(data)
}).catch(error => {
reject(error)
})
})
},
queryRoiByCameraId({ commit }, cameraId) {
return new Promise((resolve, reject) => {
queryRoiByCameraId(cameraId).then(response => {
const { data } = response
resolve(data)
}).catch(error => {
reject(error)
})
})
},
saveRoi({ commit }, data) {
return new Promise((resolve, reject) => {
saveRoi(data).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
},
deleteRoi({ commit }, roiId) {
return new Promise((resolve, reject) => {
deleteRoi(roiId).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
},
bindAlgo({ commit }, data) {
return new Promise((resolve, reject) => {
bindAlgo(data).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
},
unbindAlgo({ commit }, bindId) {
return new Promise((resolve, reject) => {
unbindAlgo(bindId).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
},
updateAlgoParams({ commit }, data) {
return new Promise((resolve, reject) => {
updateAlgoParams(data).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
}
}
export default {
namespaced: true,
actions
}

View File

@@ -0,0 +1,118 @@
<template>
<div class="algorithm-config-page">
<div class="page-header">
<h3>算法配置管理</h3>
<el-button size="small" type="warning" icon="el-icon-refresh" @click="handleSync">从边缘端同步</el-button>
</div>
<el-table :data="algorithmList" border stripe style="width: 100%; margin-top: 15px" v-loading="loading">
<el-table-column prop="algoCode" label="算法编码" min-width="130"></el-table-column>
<el-table-column prop="algoName" label="算法名称" min-width="130"></el-table-column>
<el-table-column prop="targetClass" label="目标类别" width="100"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
<el-table-column label="状态" width="100">
<template slot-scope="scope">
<el-switch
v-model="scope.row.isActive"
:active-value="1"
:inactive-value="0"
size="mini"
@change="handleToggle(scope.row)"
></el-switch>
</template>
</el-table-column>
<el-table-column prop="syncTime" label="同步时间" min-width="160"></el-table-column>
<el-table-column label="参数模板" width="100">
<template slot-scope="scope">
<el-button size="mini" @click="showSchema(scope.row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog title="参数模板" :visible.sync="schemaDialogVisible" width="600px">
<el-table :data="schemaTableData" border size="small">
<el-table-column prop="name" label="参数名" min-width="150"></el-table-column>
<el-table-column prop="type" label="类型" width="80"></el-table-column>
<el-table-column prop="default" label="默认值" width="120">
<template slot-scope="scope">
{{ typeof scope.row.default === 'object' ? JSON.stringify(scope.row.default) : scope.row.default }}
</template>
</el-table-column>
<el-table-column prop="min" label="最小值" width="80"></el-table-column>
</el-table>
<div slot="footer">
<el-button size="small" @click="schemaDialogVisible = false">关闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
name: 'AlgorithmConfig',
data() {
return {
loading: false,
algorithmList: [],
schemaDialogVisible: false,
schemaTableData: []
}
},
mounted() {
this.loadData()
},
methods: {
loadData() {
this.loading = true
this.$store.dispatch('aiAlgorithm/queryAlgorithmList').then(data => {
this.algorithmList = data || []
}).catch(() => {
this.$message.error('加载失败')
}).finally(() => {
this.loading = false
})
},
handleToggle(row) {
this.$store.dispatch('aiAlgorithm/toggleAlgorithmActive', [row.id, row.isActive]).then(() => {
this.$message.success('更新成功')
}).catch(() => {
this.$message.error('更新失败')
row.isActive = row.isActive === 1 ? 0 : 1
})
},
handleSync() {
this.$confirm('确定从边缘端同步算法列表?', '提示', { type: 'info' }).then(() => {
this.loading = true
this.$store.dispatch('aiAlgorithm/syncAlgorithmsFromEdge').then(() => {
this.$message.success('同步成功')
this.loadData()
}).catch(err => {
this.$message.error(err.message || '同步失败请检查AI服务是否启用')
}).finally(() => {
this.loading = false
})
}).catch(() => {})
},
showSchema(row) {
try {
const schema = JSON.parse(row.paramSchema)
this.schemaTableData = Object.keys(schema).map(key => ({
name: key,
type: schema[key].type || '-',
default: schema[key].default !== undefined ? schema[key].default : '-',
min: schema[key].min !== undefined ? schema[key].min : '-'
}))
} catch (e) {
this.schemaTableData = []
}
this.schemaDialogVisible = true
}
}
}
</script>
<style scoped>
.algorithm-config-page { padding: 15px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.page-header h3 { margin: 0; }
</style>

View File

@@ -0,0 +1,157 @@
<template>
<div class="camera-config-page">
<div class="page-header">
<h3>摄像头配置</h3>
</div>
<div class="search-bar">
<el-input v-model="searchQuery" placeholder="搜索摄像头名称/地址" size="small" style="width: 250px" clearable @clear="loadData" @keyup.enter.native="loadData"></el-input>
<el-select v-model="searchPulling" placeholder="拉流状态" size="small" style="width: 130px; margin-left: 10px" clearable @change="loadData">
<el-option label="拉流中" :value="true"></el-option>
<el-option label="未拉流" :value="false"></el-option>
</el-select>
<el-button size="small" type="primary" style="margin-left: 10px" @click="loadData">查询</el-button>
</div>
<el-table :data="cameraList" border stripe style="width: 100%; margin-top: 15px" v-loading="loading">
<el-table-column prop="app" label="应用名" width="100"></el-table-column>
<el-table-column prop="stream" label="流ID" width="120"></el-table-column>
<el-table-column prop="srcUrl" label="拉流地址" min-width="280" show-overflow-tooltip></el-table-column>
<el-table-column label="状态" width="100">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.pulling ? 'success' : 'info'">
{{ scope.row.pulling ? '拉流中' : '未拉流' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="ROI数量" width="90">
<template slot-scope="scope">
<el-badge :value="roiCounts[scope.row.app + '/' + scope.row.stream] || 0" type="primary" class="roi-badge">
<span>{{ roiCounts[scope.row.app + '/' + scope.row.stream] || 0 }}</span>
</el-badge>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="success" @click="handleRoiConfig(scope.row)">ROI配置</el-button>
<el-button size="mini" :type="scope.row.pulling ? 'warning' : 'primary'" @click="toggleStream(scope.row)">
{{ scope.row.pulling ? '停止' : '拉流' }}
</el-button>
<el-button size="mini" type="info" @click="handleExport(scope.row)">导出</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 15px; text-align: right"
@current-change="handlePageChange"
@size-change="handleSizeChange"
:current-page="page"
:page-sizes="[15, 25, 50]"
:page-size="count"
:total="total"
layout="total, sizes, prev, pager, next"
></el-pagination>
</div>
</template>
<script>
import { queryCameraList, startCamera, stopCamera } from '@/api/cameraConfig'
import { queryRoiByCameraId } from '@/api/aiRoi'
import { exportConfig } from '@/api/aiConfig'
export default {
name: 'CameraConfig',
data() {
return {
loading: false,
cameraList: [],
roiCounts: {},
page: 1,
count: 15,
total: 0,
searchQuery: '',
searchPulling: null
}
},
mounted() {
this.loadData()
},
methods: {
loadData() {
this.loading = true
queryCameraList({
page: this.page,
count: this.count,
query: this.searchQuery || null,
pulling: this.searchPulling
}).then(res => {
const data = res.data
this.cameraList = data.list || []
this.total = data.total || 0
this.loadRoiCounts()
}).catch(() => {
this.$message.error('加载失败')
}).finally(() => {
this.loading = false
})
},
loadRoiCounts() {
this.cameraList.forEach(cam => {
const cameraId = cam.app + '/' + cam.stream
queryRoiByCameraId(cameraId).then(res => {
const list = res.data || []
this.$set(this.roiCounts, cameraId, list.length)
}).catch(() => {})
})
},
handlePageChange(page) {
this.page = page
this.loadData()
},
handleSizeChange(size) {
this.count = size
this.page = 1
this.loadData()
},
handleRoiConfig(row) {
const cameraId = row.app + '/' + row.stream
this.$router.push({ path: `/cameraConfig/roi/${encodeURIComponent(cameraId)}`, query: { srcUrl: row.srcUrl, app: row.app, stream: row.stream } })
},
toggleStream(row) {
if (row.pulling) {
stopCamera(row.id).then(() => {
this.$message.success('已停止')
this.loadData()
}).catch(() => { this.$message.error('操作失败') })
} else {
startCamera(row.id).then(() => {
this.$message.success('开始拉流')
this.loadData()
}).catch(() => { this.$message.error('操作失败') })
}
},
handleExport(row) {
const cameraId = row.app + '/' + row.stream
exportConfig(cameraId).then(res => {
const json = JSON.stringify(res.data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `config_${row.app}_${row.stream}.json`
a.click()
URL.revokeObjectURL(url)
}).catch(() => { this.$message.error('导出失败') })
}
}
}
</script>
<style scoped>
.camera-config-page { padding: 15px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.page-header h3 { margin: 0; }
.search-bar { display: flex; align-items: center; }
.roi-badge { margin: 0; }
</style>

View File

@@ -0,0 +1,245 @@
<template>
<div class="roi-config-page">
<div class="page-header">
<div class="header-left">
<el-button size="small" icon="el-icon-back" @click="goBack">返回</el-button>
<h3>{{ cameraId }} - ROI配置</h3>
</div>
<div class="header-right">
<el-button size="small" type="primary" icon="el-icon-plus" @click="startDraw('rectangle')">画矩形</el-button>
<el-button size="small" type="primary" icon="el-icon-plus" @click="startDraw('polygon')">画多边形</el-button>
<el-button size="small" icon="el-icon-refresh" @click="refreshSnap">刷新截图</el-button>
<el-button size="small" type="info" @click="handlePush">推送到边缘端</el-button>
</div>
</div>
<div class="main-content">
<div class="canvas-panel">
<RoiCanvas
ref="roiCanvas"
:rois="roiList"
:draw-mode="drawMode"
:selected-roi-id="selectedRoiId"
:snap-url="snapUrl"
@roi-drawn="onRoiDrawn"
@roi-selected="onRoiSelected"
@roi-deleted="onRoiDeleted"
/>
</div>
<div class="side-panel">
<div class="roi-list-section">
<div class="section-header">
<span>ROI列表 ({{ roiList.length }})</span>
</div>
<div v-if="roiList.length === 0" class="empty-tip">暂无ROI请在左侧画面上绘制</div>
<div
v-for="roi in roiList"
:key="roi.roiId"
:class="['roi-item', { active: selectedRoiId === roi.roiId }]"
@click="selectRoi(roi)"
>
<div class="roi-item-header">
<span class="roi-color" :style="{ background: roi.color || '#FF0000' }"></span>
<span class="roi-name">{{ roi.name || '未命名' }}</span>
<el-tag size="mini" :type="roi.roiType === 'rectangle' ? '' : 'success'">
{{ roi.roiType === 'rectangle' ? '矩形' : '多边形' }}
</el-tag>
<el-switch v-model="roi.enabled" :active-value="1" :inactive-value="0" size="mini" style="margin-left: auto" @change="updateRoi(roi)"></el-switch>
<el-button size="mini" type="danger" icon="el-icon-delete" circle style="margin-left: 5px" @click.stop="deleteRoi(roi)"></el-button>
</div>
</div>
</div>
<el-divider></el-divider>
<div v-if="selectedRoi" class="roi-detail-section">
<h4>ROI属性</h4>
<el-form :model="selectedRoi" label-width="70px" size="mini">
<el-form-item label="名称">
<el-input v-model="selectedRoi.name" @blur="updateRoi(selectedRoi)"></el-input>
</el-form-item>
<el-form-item label="颜色">
<el-color-picker v-model="selectedRoi.color" size="mini" @change="updateRoi(selectedRoi)"></el-color-picker>
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="selectedRoi.priority" :min="0" size="mini" @change="updateRoi(selectedRoi)"></el-input-number>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="selectedRoi.description" type="textarea" :rows="2" @blur="updateRoi(selectedRoi)"></el-input>
</el-form-item>
</el-form>
<el-divider></el-divider>
<RoiAlgorithmBind
:roi-id="selectedRoi.roiId"
:bindings="selectedRoiBindings"
@changed="loadRoiDetail"
/>
</div>
<div v-else class="empty-tip" style="margin-top: 20px">点击左侧ROI区域或列表项查看详情</div>
</div>
</div>
</div>
</template>
<script>
import RoiCanvas from '@/views/roiConfig/components/RoiCanvas.vue'
import RoiAlgorithmBind from '@/views/roiConfig/components/RoiAlgorithmBind.vue'
import { queryRoiByCameraId, saveRoi, deleteRoi, queryRoiDetail } from '@/api/aiRoi'
import { pushConfig } from '@/api/aiConfig'
export default {
name: 'CameraRoiConfig',
components: { RoiCanvas, RoiAlgorithmBind },
data() {
return {
cameraId: '',
srcUrl: '',
drawMode: null,
roiList: [],
selectedRoiId: null,
selectedRoiBindings: [],
snapUrl: ''
}
},
computed: {
selectedRoi() {
if (!this.selectedRoiId) return null
return this.roiList.find(r => r.roiId === this.selectedRoiId) || null
}
},
mounted() {
this.cameraId = decodeURIComponent(this.$route.params.cameraId)
this.srcUrl = this.$route.query.srcUrl || ''
const app = this.$route.query.app || ''
const stream = this.$route.query.stream || ''
if (app && stream) {
this.snapUrl = `/api/ai/roi/snap?app=${encodeURIComponent(app)}&stream=${encodeURIComponent(stream)}`
}
this.loadRois()
},
methods: {
goBack() {
this.$router.push('/cameraConfig')
},
loadRois() {
queryRoiByCameraId(this.cameraId).then(res => {
this.roiList = res.data || []
if (this.selectedRoiId) {
this.loadRoiDetail()
}
}).catch(() => {
this.$message.error('加载ROI失败')
})
},
loadRoiDetail() {
if (!this.selectedRoi || !this.selectedRoi.id) return
queryRoiDetail(this.selectedRoi.id).then(res => {
const data = res.data
if (data) {
this.selectedRoiBindings = data.algorithms || []
}
}).catch(() => {})
},
startDraw(mode) {
this.drawMode = mode
},
refreshSnap() {
const app = this.$route.query.app || ''
const stream = this.$route.query.stream || ''
if (app && stream) {
this.snapUrl = `/api/ai/roi/snap?app=${encodeURIComponent(app)}&stream=${encodeURIComponent(stream)}&t=${Date.now()}`
}
},
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()
}).catch(() => {
this.$message.error('保存失败')
})
},
onRoiSelected(roiId) {
this.selectedRoiId = roiId
if (roiId) {
this.loadRoiDetail()
} else {
this.selectedRoiBindings = []
}
},
selectRoi(roi) {
this.selectedRoiId = roi.roiId
this.loadRoiDetail()
},
onRoiDeleted(roiId) {
this.doDeleteRoi(roiId)
},
deleteRoi(roi) {
this.$confirm('确定删除该ROI关联的算法绑定也将删除。', '提示', { type: 'warning' }).then(() => {
this.doDeleteRoi(roi.roiId)
}).catch(() => {})
},
doDeleteRoi(roiId) {
deleteRoi(roiId).then(() => {
this.$message.success('已删除')
if (this.selectedRoiId === roiId) {
this.selectedRoiId = null
this.selectedRoiBindings = []
}
this.loadRois()
}).catch(() => {
this.$message.error('删除失败')
})
},
updateRoi(roi) {
saveRoi(roi).then(() => {
this.loadRois()
}).catch(() => {
this.$message.error('更新失败')
})
},
handlePush() {
this.$confirm('确定将此摄像头的所有ROI配置推送到边缘端', '推送配置', { type: 'info' }).then(() => {
pushConfig(this.cameraId).then(() => {
this.$message.success('推送成功')
}).catch(err => {
this.$message.error(err.message || '推送失败请检查AI服务是否启用')
})
}).catch(() => {})
}
}
}
</script>
<style scoped>
.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-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; }
.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; }
.roi-item:hover { border-color: #409EFF; }
.roi-item.active { border-color: #409EFF; background: #ecf5ff; }
.roi-item-header { display: flex; align-items: center; gap: 6px; }
.roi-color { width: 12px; height: 12px; border-radius: 2px; display: inline-block; }
.roi-name { font-size: 13px; font-weight: 500; }
.roi-detail-section h4 { margin: 0 0 10px 0; font-size: 14px; }
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="algorithm-param-editor">
<el-dialog
title="参数配置"
:visible.sync="dialogVisible"
width="500px"
append-to-body
@close="handleClose"
>
<el-form label-width="140px" size="small">
<el-form-item
v-for="(schema, key) in parsedSchema"
:key="key"
:label="key"
>
<el-input-number
v-if="schema.type === 'int'"
v-model="formData[key]"
:min="schema.min !== undefined ? schema.min : undefined"
:placeholder="'默认: ' + schema.default"
style="width: 100%"
></el-input-number>
<div v-else-if="schema.type === 'list'">
<el-tag
v-for="(item, idx) in formData[key]"
:key="idx"
closable
size="small"
style="margin-right: 5px; margin-bottom: 5px"
@close="formData[key].splice(idx, 1)"
>{{ item }}</el-tag>
<el-input
v-model="newListItem"
size="mini"
style="width: 120px"
placeholder="添加项"
@keyup.enter.native="addListItem(key)"
>
<el-button slot="append" icon="el-icon-plus" @click="addListItem(key)"></el-button>
</el-input>
</div>
<el-input
v-else
v-model="formData[key]"
:placeholder="'默认: ' + schema.default"
></el-input>
</el-form-item>
</el-form>
<div slot="footer">
<el-button size="small" @click="dialogVisible = false">取消</el-button>
<el-button size="small" type="primary" @click="handleSave">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
name: 'AlgorithmParamEditor',
props: {
visible: { type: Boolean, default: false },
paramSchema: { type: String, default: '{}' },
currentParams: { type: String, default: '{}' },
bindId: { type: String, default: '' }
},
data() {
return {
formData: {},
newListItem: ''
}
},
computed: {
dialogVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
},
parsedSchema() {
try { return JSON.parse(this.paramSchema) } catch (e) { return {} }
}
},
watch: {
visible(val) {
if (val) this.initForm()
}
},
methods: {
initForm() {
const schema = this.parsedSchema
let current = {}
try { current = JSON.parse(this.currentParams) || {} } catch (e) { /* empty */ }
const form = {}
Object.keys(schema).forEach(key => {
if (current[key] !== undefined) {
form[key] = current[key]
} else {
form[key] = schema[key].type === 'list' ? (schema[key].default || []) : schema[key].default
}
})
this.formData = form
},
addListItem(key) {
if (!this.newListItem) return
if (!Array.isArray(this.formData[key])) {
this.$set(this.formData, key, [])
}
this.formData[key].push(this.newListItem)
this.newListItem = ''
},
handleSave() {
this.$store.dispatch('aiRoi/updateAlgoParams', {
bindId: this.bindId,
params: JSON.stringify(this.formData)
}).then(() => {
this.$message.success('参数保存成功')
this.$emit('saved', this.formData)
this.dialogVisible = false
}).catch(() => {
this.$message.error('参数保存失败')
})
},
handleClose() {
this.$emit('update:visible', false)
}
}
}
</script>

View File

@@ -0,0 +1,151 @@
<template>
<div class="roi-algorithm-bind">
<div class="bind-header">
<span class="title">绑定算法</span>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="showAddDialog = true">添加算法</el-button>
</div>
<div v-if="bindings.length === 0" class="empty-tip">暂未绑定算法</div>
<el-card v-for="item in bindings" :key="item.bind.bindId" class="bind-card" shadow="hover">
<div class="bind-info">
<div class="bind-left">
<el-tag size="small" type="primary">{{ item.algorithm ? item.algorithm.algoName : item.bind.algoCode }}</el-tag>
<span class="algo-code">{{ item.bind.algoCode }}</span>
</div>
<div class="bind-right">
<el-switch
v-model="item.bind.enabled"
:active-value="1"
:inactive-value="0"
active-text="启用"
size="mini"
@change="onToggleEnabled(item.bind)"
></el-switch>
<el-button size="mini" @click="openParamEditor(item)">参数配置</el-button>
<el-button size="mini" type="danger" @click="handleUnbind(item.bind.bindId)">解绑</el-button>
</div>
</div>
</el-card>
<el-dialog title="添加算法" :visible.sync="showAddDialog" width="400px" append-to-body>
<el-select v-model="selectedAlgoCode" placeholder="选择算法" style="width: 100%">
<el-option
v-for="algo in availableAlgorithms"
:key="algo.algoCode"
:label="algo.algoName"
:value="algo.algoCode"
:disabled="isBound(algo.algoCode)"
></el-option>
</el-select>
<div slot="footer">
<el-button size="small" @click="showAddDialog = false">取消</el-button>
<el-button size="small" type="primary" @click="handleBind">确定</el-button>
</div>
</el-dialog>
<AlgorithmParamEditor
:visible.sync="paramEditorVisible"
:param-schema="currentParamSchema"
:current-params="currentParams"
:bind-id="currentBindId"
@saved="onParamSaved"
/>
</div>
</template>
<script>
import AlgorithmParamEditor from './AlgorithmParamEditor.vue'
export default {
name: 'RoiAlgorithmBind',
components: { AlgorithmParamEditor },
props: {
roiId: { type: String, default: '' },
bindings: { type: Array, default: () => [] }
},
data() {
return {
showAddDialog: false,
selectedAlgoCode: '',
availableAlgorithms: [],
paramEditorVisible: false,
currentParamSchema: '{}',
currentParams: '{}',
currentBindId: ''
}
},
watch: {
roiId: {
immediate: true,
handler() {
this.loadAlgorithms()
}
}
},
methods: {
loadAlgorithms() {
this.$store.dispatch('aiAlgorithm/queryAlgorithmList').then(data => {
this.availableAlgorithms = data || []
}).catch(() => {})
},
isBound(algoCode) {
return this.bindings.some(b => b.bind.algoCode === algoCode)
},
handleBind() {
if (!this.selectedAlgoCode) {
this.$message.warning('请选择算法')
return
}
this.$store.dispatch('aiRoi/bindAlgo', {
roiId: this.roiId,
algoCode: this.selectedAlgoCode
}).then(() => {
this.$message.success('绑定成功')
this.showAddDialog = false
this.selectedAlgoCode = ''
this.$emit('changed')
}).catch(err => {
this.$message.error(err.message || '绑定失败')
})
},
handleUnbind(bindId) {
this.$confirm('确定解绑此算法?', '提示', { type: 'warning' }).then(() => {
this.$store.dispatch('aiRoi/unbindAlgo', bindId).then(() => {
this.$message.success('解绑成功')
this.$emit('changed')
}).catch(() => {
this.$message.error('解绑失败')
})
}).catch(() => {})
},
openParamEditor(item) {
this.currentBindId = item.bind.bindId
this.currentParams = item.bind.params || '{}'
this.currentParamSchema = item.algorithm ? item.algorithm.paramSchema || '{}' : '{}'
this.paramEditorVisible = true
},
onToggleEnabled(bind) {
this.$store.dispatch('aiRoi/updateAlgoParams', {
bindId: bind.bindId,
enabled: bind.enabled
}).catch(() => {
this.$message.error('更新失败')
})
},
onParamSaved() {
this.$emit('changed')
}
}
}
</script>
<style scoped>
.roi-algorithm-bind { margin-top: 10px; }
.bind-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.bind-header .title { font-weight: bold; font-size: 14px; }
.empty-tip { color: #999; font-size: 13px; text-align: center; padding: 20px 0; }
.bind-card { margin-bottom: 8px; }
.bind-info { display: flex; justify-content: space-between; align-items: center; }
.bind-left { display: flex; align-items: center; gap: 8px; }
.algo-code { color: #999; font-size: 12px; }
.bind-right { display: flex; align-items: center; gap: 8px; }
</style>

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>

View File

@@ -0,0 +1,157 @@
<template>
<div class="roi-config-page">
<div class="page-header">
<h3>ROI配置管理</h3>
<el-button type="primary" size="small" icon="el-icon-plus" @click="handleAdd">新增ROI</el-button>
</div>
<div class="search-bar">
<el-input v-model="searchQuery" placeholder="名称/通道编号" size="small" style="width: 200px" clearable @clear="loadData" @keyup.enter.native="loadData"></el-input>
<el-input v-model="searchCameraId" placeholder="通道国标编号" size="small" style="width: 200px; margin-left: 10px" clearable @clear="loadData"></el-input>
<el-input v-model="searchDeviceId" placeholder="设备国标编号" size="small" style="width: 200px; margin-left: 10px" clearable @clear="loadData"></el-input>
<el-button size="small" type="primary" style="margin-left: 10px" @click="loadData">查询</el-button>
</div>
<el-table :data="roiList" border stripe style="width: 100%; margin-top: 15px" v-loading="loading">
<el-table-column prop="name" label="ROI名称" min-width="120"></el-table-column>
<el-table-column prop="cameraId" label="通道编号" min-width="150"></el-table-column>
<el-table-column prop="deviceId" label="设备编号" min-width="150"></el-table-column>
<el-table-column prop="roiType" label="形状" width="100">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.roiType === 'rectangle' ? '' : 'success'">
{{ scope.row.roiType === 'rectangle' ? '矩形' : '多边形' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template slot-scope="scope">
<el-switch v-model="scope.row.enabled" :active-value="1" :inactive-value="0" size="mini" disabled></el-switch>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="80"></el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="160"></el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="primary" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="mini" type="warning" @click="handleExport(scope.row)">导出</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 15px; text-align: right"
@current-change="handlePageChange"
@size-change="handleSizeChange"
:current-page="page"
:page-sizes="[15, 25, 50]"
:page-size="count"
:total="total"
layout="total, sizes, prev, pager, next"
></el-pagination>
<RoiEditor
:visible.sync="editorVisible"
:roi-data="currentRoi"
:camera-id="currentRoi ? currentRoi.cameraId : ''"
@saved="onSaved"
/>
</div>
</template>
<script>
import RoiEditor from './roiEditor.vue'
export default {
name: 'RoiConfig',
components: { RoiEditor },
data() {
return {
loading: false,
roiList: [],
page: 1,
count: 15,
total: 0,
searchQuery: '',
searchCameraId: '',
searchDeviceId: '',
editorVisible: false,
currentRoi: null
}
},
mounted() {
this.loadData()
},
methods: {
loadData() {
this.loading = true
this.$store.dispatch('aiRoi/queryRoiList', {
page: this.page,
count: this.count,
query: this.searchQuery || null,
cameraId: this.searchCameraId || null,
deviceId: this.searchDeviceId || null
}).then(data => {
this.roiList = data.list || []
this.total = data.total || 0
}).catch(() => {
this.$message.error('加载失败')
}).finally(() => {
this.loading = false
})
},
handlePageChange(page) {
this.page = page
this.loadData()
},
handleSizeChange(size) {
this.count = size
this.page = 1
this.loadData()
},
handleAdd() {
this.currentRoi = null
this.editorVisible = true
},
handleEdit(row) {
this.currentRoi = row
this.editorVisible = true
},
handleExport(row) {
this.$store.dispatch('aiRoi/queryRoiByCameraId', row.cameraId).then(data => {
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `roi_${row.cameraId}.json`
a.click()
URL.revokeObjectURL(url)
})
},
handleDelete(row) {
this.$confirm('确定删除该ROI配置关联的算法绑定也将被删除。', '提示', {
type: 'warning'
}).then(() => {
this.$store.dispatch('aiRoi/deleteRoi', row.roiId).then(() => {
this.$message.success('删除成功')
this.loadData()
}).catch(() => {
this.$message.error('删除失败')
})
}).catch(() => {})
},
onSaved() {
this.editorVisible = false
this.loadData()
}
}
}
</script>
<style scoped>
.roi-config-page { padding: 15px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.page-header h3 { margin: 0; }
.search-bar { display: flex; align-items: center; }
</style>

View File

@@ -0,0 +1,214 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
title="ROI配置编辑"
fullscreen
:before-close="handleClose"
class="roi-editor-dialog"
>
<div class="editor-container">
<div class="editor-left">
<div class="toolbar">
<el-radio-group v-model="drawMode" size="mini">
<el-radio-button :label="null">选择</el-radio-button>
<el-radio-button label="rectangle">矩形</el-radio-button>
<el-radio-button label="polygon">多边形</el-radio-button>
</el-radio-group>
<el-color-picker v-model="form.color" size="mini" style="margin-left: 10px"></el-color-picker>
<span style="margin-left: 10px; font-size: 12px; color: #999">
矩形拖拽绘制 | 多边形点击添加点双击闭合
</span>
</div>
<RoiCanvas
ref="roiCanvas"
:rois="canvasRois"
:draw-mode="drawMode"
:selected-roi-id="form.roiId"
@roi-drawn="onRoiDrawn"
@roi-selected="onRoiSelected"
@roi-deleted="onRoiDeleted"
/>
</div>
<div class="editor-right">
<h4>ROI属性</h4>
<el-form :model="form" label-width="90px" size="small">
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="ROI名称"></el-input>
</el-form-item>
<el-form-item label="形状">
<el-input :value="form.roiType === 'rectangle' ? '矩形' : form.roiType === 'polygon' ? '多边形' : '未绘制'" disabled></el-input>
</el-form-item>
<el-form-item label="通道编号">
<el-input v-model="form.cameraId" placeholder="通道国标编号"></el-input>
</el-form-item>
<el-form-item label="设备编号">
<el-input v-model="form.deviceId" placeholder="设备国标编号"></el-input>
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="form.priority" :min="0" :max="999"></el-input-number>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2"></el-input>
</el-form-item>
</el-form>
<el-divider></el-divider>
<RoiAlgorithmBind
v-if="form.roiId"
:roi-id="form.roiId"
:bindings="algorithmBindings"
@changed="loadDetail"
/>
<div v-else style="color: #999; font-size: 13px; text-align: center">请先保存ROI后再绑定算法</div>
</div>
</div>
<div slot="footer">
<el-button size="small" @click="handleClose">取消</el-button>
<el-button size="small" type="primary" @click="handleSave">保存</el-button>
</div>
</el-dialog>
</template>
<script>
import RoiCanvas from './components/RoiCanvas.vue'
import RoiAlgorithmBind from './components/RoiAlgorithmBind.vue'
export default {
name: 'RoiEditor',
components: { RoiCanvas, RoiAlgorithmBind },
props: {
visible: { type: Boolean, default: false },
roiData: { type: Object, default: null },
cameraId: { type: String, default: '' }
},
data() {
return {
drawMode: null,
form: {
id: null,
roiId: '',
cameraId: '',
deviceId: '',
name: '',
roiType: '',
coordinates: '',
color: '#FF0000',
priority: 0,
enabled: 1,
description: ''
},
algorithmBindings: [],
canvasRois: []
}
},
computed: {
dialogVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
}
},
watch: {
visible(val) {
if (val) {
this.initForm()
}
}
},
methods: {
initForm() {
if (this.roiData) {
this.form = { ...this.roiData }
this.canvasRois = [this.roiData]
this.loadDetail()
} else {
this.form = {
id: null, roiId: '', cameraId: this.cameraId || '', deviceId: '',
name: '', roiType: '', coordinates: '', color: '#FF0000',
priority: 0, enabled: 1, description: ''
}
this.canvasRois = []
this.algorithmBindings = []
}
},
loadDetail() {
if (!this.form.id) return
this.$store.dispatch('aiRoi/queryRoiDetail', this.form.id).then(data => {
if (data) {
this.algorithmBindings = data.algorithms || []
}
}).catch(() => {})
},
onRoiDrawn(data) {
this.form.roiType = data.roi_type
this.form.coordinates = data.coordinates
this.canvasRois = [{
...this.form,
coordinates: data.coordinates,
roiType: data.roi_type
}]
this.drawMode = null
},
onRoiSelected(roiId) {
// single ROI editing, no action needed
},
onRoiDeleted() {
this.form.coordinates = ''
this.form.roiType = ''
this.canvasRois = []
},
handleSave() {
if (!this.form.cameraId) {
this.$message.warning('请填写通道编号')
return
}
if (!this.form.coordinates) {
this.$message.warning('请在视频区域绘制ROI')
return
}
if (!this.form.name) {
this.$message.warning('请填写ROI名称')
return
}
this.$store.dispatch('aiRoi/saveRoi', this.form).then(() => {
this.$message.success('保存成功')
this.$emit('saved')
}).catch(() => {
this.$message.error('保存失败')
})
},
handleClose() {
this.dialogVisible = false
}
}
}
</script>
<style scoped>
.editor-container {
display: flex;
height: calc(100vh - 140px);
gap: 15px;
}
.editor-left {
flex: 6;
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
padding: 8px 0;
}
.editor-right {
flex: 4;
overflow-y: auto;
padding: 0 10px;
}
.editor-right h4 {
margin: 0 0 15px 0;
}
</style>