feat(aiot): 告警图片代理 + 告警列表页面
后端: - IAiAlertService / AiAlertServiceImpl: 新增 proxyAlertImage() 支持 COS object key(通过 CosUtil 生成 presigned URL)和完整 URL - AiAlertController: 新增 GET /api/ai/alert/image 图片代理端点 - WebSecurityConfig: 白名单加 /api/ai/alert/image 前端: - 新建 aiAlert.js API(列表查询、删除、统计、图片 URL 构造) - 新建 alertList/index.vue 告警列表页面 · 分页表格 + 类型/时间筛选 · 缩略图通过 WVP 图片代理显示 · 详情弹窗 + 删除功能 - router/index.js: 添加告警记录路由 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
51
web/src/api/aiAlert.js
Normal file
51
web/src/api/aiAlert.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function queryAlertList(params) {
|
||||
const { page, count, cameraId, alertType, startTime, endTime } = params
|
||||
return request({
|
||||
method: 'get',
|
||||
url: '/api/ai/alert/list',
|
||||
params: { page, count, cameraId, alertType, startTime, endTime }
|
||||
})
|
||||
}
|
||||
|
||||
export function queryAlertDetail(alertId) {
|
||||
return request({
|
||||
method: 'get',
|
||||
url: `/api/ai/alert/${alertId}`
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAlert(alertId) {
|
||||
return request({
|
||||
method: 'delete',
|
||||
url: '/api/ai/alert/delete',
|
||||
params: { alertId }
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAlertBatch(alertIds) {
|
||||
return request({
|
||||
method: 'delete',
|
||||
url: '/api/ai/alert/delete',
|
||||
params: { alertIds }
|
||||
})
|
||||
}
|
||||
|
||||
export function queryAlertStatistics(startTime) {
|
||||
return request({
|
||||
method: 'get',
|
||||
url: '/api/ai/alert/statistics',
|
||||
params: { startTime }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造告警图片代理 URL
|
||||
* @param {string} imagePath COS 对象路径
|
||||
* @returns {string} 图片代理 URL
|
||||
*/
|
||||
export function getAlertImageUrl(imagePath) {
|
||||
if (!imagePath) return ''
|
||||
return '/api/ai/alert/image?imagePath=' + encodeURIComponent(imagePath)
|
||||
}
|
||||
@@ -233,6 +233,17 @@ export const constantRoutes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/alertList',
|
||||
component: Layout,
|
||||
redirect: '/alertList',
|
||||
children: [{
|
||||
path: '',
|
||||
name: 'AlertList',
|
||||
component: () => import('@/views/alertList/index'),
|
||||
meta: { title: '告警记录', icon: 'alarm' }
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: '/cameraConfig',
|
||||
component: Layout,
|
||||
|
||||
216
web/src/views/alertList/index.vue
Normal file
216
web/src/views/alertList/index.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div class="alert-list-page">
|
||||
<div class="page-header">
|
||||
<h3>告警记录</h3>
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filter.alertType" placeholder="告警类型" clearable size="small" style="width: 140px">
|
||||
<el-option label="离岗检测" value="leave_post"></el-option>
|
||||
<el-option label="入侵检测" value="intrusion"></el-option>
|
||||
<el-option label="人群聚集" value="crowd_detection"></el-option>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filter.dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
size="small"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
style="width: 360px"
|
||||
></el-date-picker>
|
||||
<el-button size="small" type="primary" icon="el-icon-search" @click="fetchList">查询</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="resetFilter">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%" @sort-change="handleSortChange">
|
||||
<el-table-column label="告警图片" width="120" align="center">
|
||||
<template slot-scope="{ row }">
|
||||
<el-image
|
||||
v-if="row.imagePath"
|
||||
:src="getImageUrl(row.imagePath)"
|
||||
:preview-src-list="[getImageUrl(row.imagePath)]"
|
||||
style="width: 80px; height: 60px"
|
||||
fit="cover"
|
||||
>
|
||||
<div slot="error" class="image-error">
|
||||
<i class="el-icon-picture-outline"></i>
|
||||
</div>
|
||||
</el-image>
|
||||
<span v-else class="no-image">无图片</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alertId" label="告警ID" width="140" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="alertType" label="告警类型" width="120">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tag :type="getAlertTagType(row.alertType)" size="mini">{{ getAlertTypeName(row.alertType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cameraId" label="摄像头" width="160" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="roiId" label="ROI区域" width="120" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="confidence" label="置信度" width="90" align="center">
|
||||
<template slot-scope="{ row }">
|
||||
<span v-if="row.confidence">{{ (row.confidence * 100).toFixed(1) }}%</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="durationMinutes" label="持续时长" width="100" align="center">
|
||||
<template slot-scope="{ row }">
|
||||
<span v-if="row.durationMinutes">{{ row.durationMinutes.toFixed(1) }}分</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receivedAt" label="告警时间" width="170" sortable="custom"></el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<template slot-scope="{ row }">
|
||||
<el-button size="mini" type="text" icon="el-icon-view" @click="viewDetail(row)">详情</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" style="color: #F56C6C" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top: 15px; text-align: right"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
:current-page="pagination.page"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:page-size="pagination.count"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
></el-pagination>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog title="告警详情" :visible.sync="detailVisible" width="600px">
|
||||
<div v-if="currentAlert" class="alert-detail">
|
||||
<el-image
|
||||
v-if="currentAlert.imagePath"
|
||||
:src="getImageUrl(currentAlert.imagePath)"
|
||||
style="width: 100%; max-height: 400px; margin-bottom: 15px"
|
||||
fit="contain"
|
||||
></el-image>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="告警ID">{{ currentAlert.alertId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="告警类型">{{ getAlertTypeName(currentAlert.alertType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="摄像头">{{ currentAlert.cameraId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="ROI区域">{{ currentAlert.roiId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="置信度">{{ currentAlert.confidence ? (currentAlert.confidence * 100).toFixed(1) + '%' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="持续时长">{{ currentAlert.durationMinutes ? currentAlert.durationMinutes.toFixed(1) + '分' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="告警时间" :span="2">{{ currentAlert.receivedAt }}</el-descriptions-item>
|
||||
<el-descriptions-item label="告警消息" :span="2">{{ currentAlert.message || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { queryAlertList, deleteAlert, getAlertImageUrl } from '@/api/aiAlert'
|
||||
|
||||
const ALERT_TYPE_MAP = {
|
||||
leave_post: '离岗检测',
|
||||
intrusion: '入侵检测',
|
||||
crowd_detection: '人群聚集'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'AlertList',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
tableData: [],
|
||||
filter: {
|
||||
alertType: '',
|
||||
dateRange: null
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
count: 20,
|
||||
total: 0
|
||||
},
|
||||
detailVisible: false,
|
||||
currentAlert: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchList()
|
||||
},
|
||||
methods: {
|
||||
fetchList() {
|
||||
this.loading = true
|
||||
const params = {
|
||||
page: this.pagination.page,
|
||||
count: this.pagination.count,
|
||||
alertType: this.filter.alertType || undefined,
|
||||
startTime: this.filter.dateRange ? this.filter.dateRange[0] : undefined,
|
||||
endTime: this.filter.dateRange ? this.filter.dateRange[1] : undefined
|
||||
}
|
||||
queryAlertList(params).then(res => {
|
||||
const data = res.data || res
|
||||
this.tableData = data.list || []
|
||||
this.pagination.total = data.total || 0
|
||||
}).catch(() => {
|
||||
this.$message.error('查询告警列表失败')
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
resetFilter() {
|
||||
this.filter.alertType = ''
|
||||
this.filter.dateRange = null
|
||||
this.pagination.page = 1
|
||||
this.fetchList()
|
||||
},
|
||||
handlePageChange(page) {
|
||||
this.pagination.page = page
|
||||
this.fetchList()
|
||||
},
|
||||
handleSizeChange(size) {
|
||||
this.pagination.count = size
|
||||
this.pagination.page = 1
|
||||
this.fetchList()
|
||||
},
|
||||
handleSortChange() {
|
||||
this.fetchList()
|
||||
},
|
||||
getImageUrl(imagePath) {
|
||||
return getAlertImageUrl(imagePath)
|
||||
},
|
||||
getAlertTypeName(type) {
|
||||
return ALERT_TYPE_MAP[type] || type || '未知'
|
||||
},
|
||||
getAlertTagType(type) {
|
||||
switch (type) {
|
||||
case 'leave_post': return 'warning'
|
||||
case 'intrusion': return 'danger'
|
||||
case 'crowd_detection': return ''
|
||||
default: return 'info'
|
||||
}
|
||||
},
|
||||
viewDetail(row) {
|
||||
this.currentAlert = row
|
||||
this.detailVisible = true
|
||||
},
|
||||
handleDelete(row) {
|
||||
this.$confirm('确定删除该告警记录?', '提示', { type: 'warning' }).then(() => {
|
||||
deleteAlert(row.alertId).then(() => {
|
||||
this.$message.success('已删除')
|
||||
this.fetchList()
|
||||
}).catch(() => {
|
||||
this.$message.error('删除失败')
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alert-list-page { padding: 15px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
|
||||
.page-header h3 { margin: 0; }
|
||||
.filter-bar { display: flex; gap: 10px; align-items: center; }
|
||||
.no-image { color: #999; font-size: 12px; }
|
||||
.image-error { display: flex; align-items: center; justify-content: center; width: 80px; height: 60px; background: #f5f7fa; color: #c0c4cc; font-size: 20px; }
|
||||
.alert-detail { padding: 10px 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user