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:
2026-03-03 20:12:19 +08:00
parent 80f0275216
commit 90e9c1c896
7 changed files with 346 additions and 0 deletions

View File

@@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -60,6 +62,19 @@ public class AiAlertController {
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 告警上报 ====================
@Operation(summary = "Edge 告警上报")

View File

@@ -21,4 +21,12 @@ public interface IAiAlertService {
Map<String, Object> statistics(String startTime);
void updateDuration(String alertId, double durationMinutes);
/**
* 代理获取告警图片(通过 COS presigned URL 下载后返回字节)
*
* @param imagePath COS 对象路径
* @return JPEG 图片字节,失败返回 null
*/
byte[] proxyAlertImage(String imagePath);
}

View File

@@ -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.dao.AiAlertMapper;
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.PageInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@@ -20,6 +23,9 @@ public class AiAlertServiceImpl implements IAiAlertService {
@Autowired
private AiAlertMapper alertMapper;
@Autowired
private CosUtil cosUtil;
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override
@@ -72,4 +78,42 @@ public class AiAlertServiceImpl implements IAiAlertService {
public void updateDuration(String alertId, double durationMinutes) {
alertMapper.updateDuration(alertId, durationMinutes);
}
@Override
public byte[] proxyAlertImage(String imagePath) {
if (imagePath == null || imagePath.isEmpty()) {
return null;
}
// 如果是完整 URLhttps://),直接下载
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;
}
}
}

View File

@@ -106,6 +106,7 @@ public class WebSecurityConfig {
defaultExcludes.add("/api/ai/roi/snap/image");
defaultExcludes.add("/api/ai/camera/get");
defaultExcludes.add("/api/ai/alert/edge/**");
defaultExcludes.add("/api/ai/alert/image");
defaultExcludes.add("/api/ai/device/edge/**");
if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) {

51
web/src/api/aiAlert.js Normal file
View 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)
}

View File

@@ -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,

View 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>