新增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:
23
web/src/api/aiAlgorithm.js
Normal file
23
web/src/api/aiAlgorithm.js
Normal 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
26
web/src/api/aiConfig.js
Normal 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
64
web/src/api/aiRoi.js
Normal 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
|
||||
})
|
||||
}
|
||||
30
web/src/api/cameraConfig.js
Normal file
30
web/src/api/cameraConfig.js
Normal 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)}`
|
||||
}
|
||||
1
web/src/icons/svg/algorithm.svg
Normal file
1
web/src/icons/svg/algorithm.svg
Normal 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 |
1
web/src/icons/svg/roi.svg
Normal file
1
web/src/icons/svg/roi.svg
Normal 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 |
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
41
web/src/store/modules/aiAlgorithm.js
Normal file
41
web/src/store/modules/aiAlgorithm.js
Normal 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
|
||||
}
|
||||
93
web/src/store/modules/aiRoi.js
Normal file
93
web/src/store/modules/aiRoi.js
Normal 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
|
||||
}
|
||||
118
web/src/views/algorithmConfig/index.vue
Normal file
118
web/src/views/algorithmConfig/index.vue
Normal 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>
|
||||
157
web/src/views/cameraConfig/index.vue
Normal file
157
web/src/views/cameraConfig/index.vue
Normal 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>
|
||||
245
web/src/views/cameraConfig/roiConfig.vue
Normal file
245
web/src/views/cameraConfig/roiConfig.vue
Normal 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>
|
||||
126
web/src/views/roiConfig/components/AlgorithmParamEditor.vue
Normal file
126
web/src/views/roiConfig/components/AlgorithmParamEditor.vue
Normal 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>
|
||||
151
web/src/views/roiConfig/components/RoiAlgorithmBind.vue
Normal file
151
web/src/views/roiConfig/components/RoiAlgorithmBind.vue
Normal 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>
|
||||
294
web/src/views/roiConfig/components/RoiCanvas.vue
Normal file
294
web/src/views/roiConfig/components/RoiCanvas.vue
Normal 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>
|
||||
157
web/src/views/roiConfig/index.vue
Normal file
157
web/src/views/roiConfig/index.vue
Normal 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>
|
||||
214
web/src/views/roiConfig/roiEditor.vue
Normal file
214
web/src/views/roiConfig/roiEditor.vue
Normal 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>
|
||||
Reference in New Issue
Block a user