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:
@@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.Parameter;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -60,6 +62,19 @@ public class AiAlertController {
|
|||||||
return alertService.statistics(startTime);
|
return alertService.statistics(startTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "告警图片代理(服务端从 COS 下载后返回)")
|
||||||
|
@GetMapping("/image")
|
||||||
|
public ResponseEntity<byte[]> getAlertImage(@RequestParam String imagePath) {
|
||||||
|
byte[] image = alertService.proxyAlertImage(imagePath);
|
||||||
|
if (image == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.IMAGE_JPEG)
|
||||||
|
.header("Cache-Control", "public, max-age=3600")
|
||||||
|
.body(image);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Edge 告警上报 ====================
|
// ==================== Edge 告警上报 ====================
|
||||||
|
|
||||||
@Operation(summary = "Edge 告警上报")
|
@Operation(summary = "Edge 告警上报")
|
||||||
|
|||||||
@@ -21,4 +21,12 @@ public interface IAiAlertService {
|
|||||||
Map<String, Object> statistics(String startTime);
|
Map<String, Object> statistics(String startTime);
|
||||||
|
|
||||||
void updateDuration(String alertId, double durationMinutes);
|
void updateDuration(String alertId, double durationMinutes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理获取告警图片(通过 COS presigned URL 下载后返回字节)
|
||||||
|
*
|
||||||
|
* @param imagePath COS 对象路径
|
||||||
|
* @return JPEG 图片字节,失败返回 null
|
||||||
|
*/
|
||||||
|
byte[] proxyAlertImage(String imagePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package com.genersoft.iot.vmp.aiot.service.impl;
|
|||||||
import com.genersoft.iot.vmp.aiot.bean.AiAlert;
|
import com.genersoft.iot.vmp.aiot.bean.AiAlert;
|
||||||
import com.genersoft.iot.vmp.aiot.dao.AiAlertMapper;
|
import com.genersoft.iot.vmp.aiot.dao.AiAlertMapper;
|
||||||
import com.genersoft.iot.vmp.aiot.service.IAiAlertService;
|
import com.genersoft.iot.vmp.aiot.service.IAiAlertService;
|
||||||
|
import com.genersoft.iot.vmp.aiot.util.CosUtil;
|
||||||
import com.github.pagehelper.PageHelper;
|
import com.github.pagehelper.PageHelper;
|
||||||
import com.github.pagehelper.PageInfo;
|
import com.github.pagehelper.PageInfo;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -20,6 +23,9 @@ public class AiAlertServiceImpl implements IAiAlertService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AiAlertMapper alertMapper;
|
private AiAlertMapper alertMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CosUtil cosUtil;
|
||||||
|
|
||||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -72,4 +78,42 @@ public class AiAlertServiceImpl implements IAiAlertService {
|
|||||||
public void updateDuration(String alertId, double durationMinutes) {
|
public void updateDuration(String alertId, double durationMinutes) {
|
||||||
alertMapper.updateDuration(alertId, durationMinutes);
|
alertMapper.updateDuration(alertId, durationMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] proxyAlertImage(String imagePath) {
|
||||||
|
if (imagePath == null || imagePath.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是完整 URL(https://),直接下载
|
||||||
|
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||||
|
try {
|
||||||
|
RestTemplate restTemplate = new RestTemplate();
|
||||||
|
return restTemplate.getForObject(URI.create(imagePath), byte[].class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[AiAlert] 直接下载告警图片失败: path={}, error={}", imagePath, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// COS object key → 生成 presigned URL → 下载
|
||||||
|
if (!cosUtil.isAvailable()) {
|
||||||
|
log.warn("[AiAlert] COS 客户端未初始化,无法代理告警图片");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String presignedUrl = cosUtil.generatePresignedUrl(imagePath);
|
||||||
|
if (presignedUrl == null) {
|
||||||
|
log.error("[AiAlert] 生成 presigned URL 失败: imagePath={}", imagePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
RestTemplate restTemplate = new RestTemplate();
|
||||||
|
return restTemplate.getForObject(URI.create(presignedUrl), byte[].class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[AiAlert] 代理告警图片失败: path={}, error={}", imagePath, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ public class WebSecurityConfig {
|
|||||||
defaultExcludes.add("/api/ai/roi/snap/image");
|
defaultExcludes.add("/api/ai/roi/snap/image");
|
||||||
defaultExcludes.add("/api/ai/camera/get");
|
defaultExcludes.add("/api/ai/camera/get");
|
||||||
defaultExcludes.add("/api/ai/alert/edge/**");
|
defaultExcludes.add("/api/ai/alert/edge/**");
|
||||||
|
defaultExcludes.add("/api/ai/alert/image");
|
||||||
defaultExcludes.add("/api/ai/device/edge/**");
|
defaultExcludes.add("/api/ai/device/edge/**");
|
||||||
|
|
||||||
if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) {
|
if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) {
|
||||||
|
|||||||
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',
|
path: '/cameraConfig',
|
||||||
component: Layout,
|
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