feat(aiot): 截图持久化 + ROI 显示修复 + 告警图片代理
截图策略改为截一次持久化到 DB,打开 ROI 页面不再触发 Edge 截图: - 新增 wvp_ai_camera_snapshot 表存储 cos_key - AiScreenshotServiceImpl 回调时持久化 cos_key,图片代理增加 DB 回退 - 前端 getSnapUrl 直接返回代理 URL,force=true 才触发 Edge RoiCanvas 修复: - ResizeObserver 替代 nextTick 初始化 canvas - 图片加载失败时仍初始化 canvas 以显示 ROI 告警图片代理: - AiAlertController 新增 /image 端点通过 presign URL 代理 COS 图片 - 新增告警列表前端页面 alertList Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
8
sql/wvp_ai_camera_snapshot.sql
Normal file
8
sql/wvp_ai_camera_snapshot.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Screenshot persistence table for storing COS object keys
|
||||
-- Run this SQL on the WVP database before deploying the new version
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wvp_ai_camera_snapshot (
|
||||
camera_code VARCHAR(64) PRIMARY KEY,
|
||||
cos_key VARCHAR(512) NOT NULL COMMENT 'COS 对象键',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -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 = "告警图片代理")
|
||||
@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 告警上报")
|
||||
|
||||
@@ -124,7 +124,7 @@ public class AiRoiController {
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.IMAGE_JPEG)
|
||||
.header("Cache-Control", "max-age=60")
|
||||
.header("Cache-Control", "public, max-age=300")
|
||||
.body(image);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.genersoft.iot.vmp.aiot.dao;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@Mapper
|
||||
public interface AiCameraSnapshotMapper {
|
||||
|
||||
@Select("SELECT cos_key FROM wvp_ai_camera_snapshot WHERE camera_code=#{cameraCode}")
|
||||
String getCosKey(@Param("cameraCode") String cameraCode);
|
||||
|
||||
@Insert("INSERT INTO wvp_ai_camera_snapshot (camera_code, cos_key) " +
|
||||
"VALUES (#{cameraCode}, #{cosKey}) " +
|
||||
"ON DUPLICATE KEY UPDATE cos_key=#{cosKey}, updated_at=NOW()")
|
||||
int upsert(@Param("cameraCode") String cameraCode, @Param("cosKey") String cosKey);
|
||||
}
|
||||
@@ -21,4 +21,6 @@ public interface IAiAlertService {
|
||||
Map<String, Object> statistics(String startTime);
|
||||
|
||||
void updateDuration(String alertId, double durationMinutes);
|
||||
|
||||
byte[] proxyAlertImage(String imagePath);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.genersoft.iot.vmp.aiot.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.genersoft.iot.vmp.aiot.bean.AiAlert;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiAlertMapper;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiAlertService;
|
||||
@@ -7,8 +9,13 @@ 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.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
@@ -20,6 +27,9 @@ public class AiAlertServiceImpl implements IAiAlertService {
|
||||
@Autowired
|
||||
private AiAlertMapper alertMapper;
|
||||
|
||||
@Value("${ai.alert.presign-url:http://127.0.0.1:8000/admin-api/aiot/storage/presign}")
|
||||
private String presignUrl;
|
||||
|
||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Override
|
||||
@@ -72,4 +82,20 @@ public class AiAlertServiceImpl implements IAiAlertService {
|
||||
public void updateDuration(String alertId, double durationMinutes) {
|
||||
alertMapper.updateDuration(alertId, durationMinutes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] proxyAlertImage(String imagePath) {
|
||||
try {
|
||||
RestTemplate rest = new RestTemplate();
|
||||
String endpoint = presignUrl + "?objectKey=" +
|
||||
URLEncoder.encode(imagePath, StandardCharsets.UTF_8);
|
||||
String json = rest.getForObject(endpoint, String.class);
|
||||
JSONObject resp = JSON.parseObject(json);
|
||||
String cosUrl = resp.getJSONObject("data").getString("url");
|
||||
return rest.getForObject(URI.create(cosUrl), byte[].class);
|
||||
} catch (Exception e) {
|
||||
log.error("[AiAlert] 代理告警图片失败: path={}, error={}", imagePath, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.genersoft.iot.vmp.aiot.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiCameraSnapshotMapper;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService;
|
||||
import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy;
|
||||
import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper;
|
||||
@@ -16,6 +17,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
@@ -41,15 +44,24 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
/** 等待 Edge 回调的 pending 请求表 */
|
||||
private final ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>> pendingRequests = new ConcurrentHashMap<>();
|
||||
|
||||
/** requestId → cosPath 映射,用于回调时持久化 cos_key */
|
||||
private final ConcurrentHashMap<String, String> pendingCosKeys = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Autowired
|
||||
private StreamProxyMapper streamProxyMapper;
|
||||
|
||||
@Autowired
|
||||
private AiCameraSnapshotMapper snapshotMapper;
|
||||
|
||||
@Value("${ai.screenshot.callback-url:}")
|
||||
private String callbackUrl;
|
||||
|
||||
@Value("${ai.alert.presign-url:http://127.0.0.1:8000/admin-api/aiot/storage/presign}")
|
||||
private String presignUrl;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> requestScreenshot(String cameraCode, boolean force) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
@@ -100,6 +112,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
try {
|
||||
MapRecord<String, String, String> record = MapRecord.create(SNAP_REQUEST_STREAM, fields);
|
||||
RecordId recordId = stringRedisTemplate.opsForStream().add(record);
|
||||
pendingCosKeys.put(requestId, cosPath);
|
||||
log.info("[AI截图] 发送截图请求: requestId={}, cameraCode={}, streamId={}", requestId, cameraCode, recordId);
|
||||
} catch (Exception e) {
|
||||
log.error("[AI截图] 发送截图请求失败: {}", e.getMessage());
|
||||
@@ -166,6 +179,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
return result;
|
||||
} finally {
|
||||
pendingRequests.remove(requestId);
|
||||
pendingCosKeys.remove(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +196,19 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
if ("ok".equals(status) && cameraCode != null) {
|
||||
String url = (String) data.get("url");
|
||||
writeCache(cameraCode, url);
|
||||
|
||||
// 持久化 cos_key 到数据库
|
||||
String cosKey = pendingCosKeys.remove(requestId);
|
||||
if (cosKey != null) {
|
||||
try {
|
||||
snapshotMapper.upsert(cameraCode, cosKey);
|
||||
log.info("[AI截图] cos_key 已持久化: cameraCode={}, cosKey={}", cameraCode, cosKey);
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI截图] cos_key 持久化失败: cameraCode={}, error={}", cameraCode, e.getMessage());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pendingCosKeys.remove(requestId);
|
||||
}
|
||||
|
||||
CompletableFuture<Map<String, Object>> future = pendingRequests.get(requestId);
|
||||
@@ -218,27 +245,49 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
|
||||
@Override
|
||||
public byte[] proxyScreenshotImage(String cameraCode) {
|
||||
// 1. 先查 Redis 缓存(5分钟内的 presigned URL)
|
||||
String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode);
|
||||
if (cacheJson == null) {
|
||||
log.warn("[AI截图] 代理图片: 缓存不存在 cameraCode={}", cameraCode);
|
||||
if (cacheJson != null) {
|
||||
try {
|
||||
JSONObject cached = JSON.parseObject(cacheJson);
|
||||
String cosUrl = cached.getString("url");
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
RestTemplate rest = new RestTemplate();
|
||||
byte[] bytes = rest.getForObject(URI.create(cosUrl), byte[].class);
|
||||
if (bytes != null) return bytes;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI截图] Redis 缓存 URL 下载失败,尝试 DB: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查 DB 持久化的 cos_key(永不过期)
|
||||
String cosKey = snapshotMapper.getCosKey(cameraCode);
|
||||
if (cosKey == null) {
|
||||
log.warn("[AI截图] 代理图片: 无缓存也无持久化记录 cameraCode={}", cameraCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 调 FastAPI 生成新的 presigned URL
|
||||
try {
|
||||
JSONObject cached = JSON.parseObject(cacheJson);
|
||||
String cosUrl = cached.getString("url");
|
||||
if (cosUrl == null || cosUrl.isEmpty()) {
|
||||
log.warn("[AI截图] 代理图片: 缓存中无 URL cameraCode={}", cameraCode);
|
||||
return null;
|
||||
}
|
||||
RestTemplate rest = new RestTemplate();
|
||||
String endpoint = presignUrl + "?objectKey=" +
|
||||
URLEncoder.encode(cosKey, StandardCharsets.UTF_8);
|
||||
String json = rest.getForObject(endpoint, String.class);
|
||||
JSONObject resp = JSON.parseObject(json);
|
||||
String cosUrl = resp.getJSONObject("data").getString("url");
|
||||
|
||||
// 使用 URI.create 避免 RestTemplate 对已编码的预签名 URL 做二次编码
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
byte[] imageBytes = restTemplate.getForObject(URI.create(cosUrl), byte[].class);
|
||||
log.debug("[AI截图] 代理图片成功: cameraCode={}, size={}", cameraCode, imageBytes != null ? imageBytes.length : 0);
|
||||
// 4. 下载并返回图片
|
||||
byte[] imageBytes = rest.getForObject(URI.create(cosUrl), byte[].class);
|
||||
|
||||
// 5. 更新 Redis 缓存(加速后续请求)
|
||||
if (imageBytes != null) {
|
||||
writeCache(cameraCode, cosUrl);
|
||||
}
|
||||
return imageBytes;
|
||||
} catch (Exception e) {
|
||||
log.error("[AI截图] 代理下载图片失败: cameraCode={}, error={}", cameraCode, e.getMessage());
|
||||
log.error("[AI截图] 通过 DB cos_key 下载图片失败: cameraCode={}, cosKey={}, error={}",
|
||||
cameraCode, cosKey, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -124,6 +124,8 @@ ai:
|
||||
screenshot:
|
||||
# Edge截图回调地址(WVP外部可访问地址,Edge通过此地址回调截图结果)
|
||||
callback-url: http://124.221.55.225:18080
|
||||
alert:
|
||||
presign-url: http://127.0.0.1:8000/admin-api/aiot/storage/presign
|
||||
mqtt:
|
||||
# MQTT推送开关
|
||||
enabled: false
|
||||
|
||||
@@ -86,6 +86,8 @@ ai:
|
||||
enabled: ${AI_SERVICE_ENABLED:false}
|
||||
screenshot:
|
||||
callback-url: ${AI_SCREENSHOT_CALLBACK_URL:}
|
||||
alert:
|
||||
presign-url: ${AI_ALERT_PRESIGN_URL:http://vsp-service:8000/admin-api/aiot/storage/presign}
|
||||
mqtt:
|
||||
enabled: ${AI_MQTT_ENABLED:false}
|
||||
broker: ${AI_MQTT_BROKER:tcp://127.0.0.1:1883}
|
||||
|
||||
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 对象键(image_path)
|
||||
* @returns {string} 代理图片 URL
|
||||
*/
|
||||
export function getAlertImageUrl(imagePath) {
|
||||
if (!imagePath) return ''
|
||||
return '/api/ai/alert/image?imagePath=' + encodeURIComponent(imagePath)
|
||||
}
|
||||
@@ -63,10 +63,27 @@ export function updateAlgoParams(data) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建截图代理 URL。
|
||||
* 指向 /api/ai/roi/snap/image 代理端点,优先从 DB 读持久化 COS key。
|
||||
* force=true 时先触发 Edge 截新图并更新 DB。
|
||||
*/
|
||||
export function getSnapUrl(cameraCode, force = false) {
|
||||
return request({
|
||||
method: 'get',
|
||||
url: '/api/ai/roi/snap',
|
||||
params: { cameraCode, force }
|
||||
if (force) {
|
||||
// force 时先触发一次截图请求(确保 Edge 截新图并更新 DB)
|
||||
return request({
|
||||
method: 'get',
|
||||
url: '/api/ai/roi/snap',
|
||||
params: { cameraCode, force: true }
|
||||
}).then(() => {
|
||||
return { data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() } }
|
||||
}).catch(() => {
|
||||
// 截图请求可能超时,但 DB 会被更新,仍返回代理 URL
|
||||
return { data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() } }
|
||||
})
|
||||
}
|
||||
// 非 force:直接返回代理 URL(从 DB 读已有截图,不触发 Edge)
|
||||
return Promise.resolve({
|
||||
data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: 'el-icon-warning' }
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: '/cameraConfig',
|
||||
component: Layout,
|
||||
|
||||
166
web/src/views/alertList/index.vue
Normal file
166
web/src/views/alertList/index.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="alert-list-page">
|
||||
<div class="page-header">
|
||||
<h3>告警记录</h3>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchCameraId" placeholder="摄像头编号" size="small" style="width: 180px" clearable @clear="loadData"></el-input>
|
||||
<el-select v-model="searchAlertType" placeholder="告警类型" size="small" style="width: 150px; margin-left: 10px" clearable @change="loadData">
|
||||
<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="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" size="small" style="margin-left: 10px" value-format="yyyy-MM-dd HH:mm:ss" @change="loadData"></el-date-picker>
|
||||
<el-button size="small" type="primary" style="margin-left: 10px" @click="loadData">查询</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="alertList" border stripe style="width: 100%; margin-top: 15px" v-loading="loading">
|
||||
<el-table-column label="截图" width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-image
|
||||
v-if="scope.row.imagePath"
|
||||
:src="getImageUrl(scope.row.imagePath)"
|
||||
:preview-src-list="[getImageUrl(scope.row.imagePath)]"
|
||||
style="width: 80px; height: 60px"
|
||||
fit="cover"
|
||||
>
|
||||
<div slot="error" style="display:flex;align-items:center;justify-content:center;width:80px;height:60px;background:#f5f7fa;color:#999;font-size:12px">
|
||||
暂无图片
|
||||
</div>
|
||||
</el-image>
|
||||
<span v-else style="color: #999; font-size: 12px">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alertType" label="告警类型" width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" :type="alertTypeTag(scope.row.alertType)">
|
||||
{{ alertTypeLabel(scope.row.alertType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cameraName" label="摄像头" min-width="140" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.cameraName || scope.row.cameraId || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="roiName" label="ROI区域" width="120" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.roiName || scope.row.roiId || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="confidence" label="置信度" width="90">
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.confidence ? (scope.row.confidence * 100).toFixed(1) + '%' : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="durationMinutes" label="持续(分钟)" width="110">
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.durationMinutes ? scope.row.durationMinutes.toFixed(1) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receivedAt" label="时间" min-width="160"></el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { queryAlertList, deleteAlert, getAlertImageUrl } from '@/api/aiAlert'
|
||||
|
||||
const ALERT_TYPE_MAP = {
|
||||
leave_post: { label: '离岗', tag: 'warning' },
|
||||
intrusion: { label: '入侵', tag: 'danger' },
|
||||
crowd_detection: { label: '人群聚集', tag: '' }
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'AlertList',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
alertList: [],
|
||||
page: 1,
|
||||
count: 15,
|
||||
total: 0,
|
||||
searchCameraId: '',
|
||||
searchAlertType: '',
|
||||
dateRange: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadData()
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true
|
||||
queryAlertList({
|
||||
page: this.page,
|
||||
count: this.count,
|
||||
cameraId: this.searchCameraId || null,
|
||||
alertType: this.searchAlertType || null,
|
||||
startTime: this.dateRange ? this.dateRange[0] : null,
|
||||
endTime: this.dateRange ? this.dateRange[1] : null
|
||||
}).then(res => {
|
||||
const data = res.data
|
||||
this.alertList = 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()
|
||||
},
|
||||
handleDelete(row) {
|
||||
this.$confirm('确定删除该告警记录?', '提示', { type: 'warning' }).then(() => {
|
||||
deleteAlert(row.alertId).then(() => {
|
||||
this.$message.success('已删除')
|
||||
this.loadData()
|
||||
}).catch(() => {
|
||||
this.$message.error('删除失败')
|
||||
})
|
||||
}).catch(() => {})
|
||||
},
|
||||
getImageUrl(imagePath) {
|
||||
return getAlertImageUrl(imagePath)
|
||||
},
|
||||
alertTypeLabel(type) {
|
||||
return (ALERT_TYPE_MAP[type] || {}).label || type || '未知'
|
||||
},
|
||||
alertTypeTag(type) {
|
||||
return (ALERT_TYPE_MAP[type] || {}).tag || 'info'
|
||||
}
|
||||
}
|
||||
}
|
||||
</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; }
|
||||
.search-bar { display: flex; align-items: center; flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -153,9 +153,7 @@ export default {
|
||||
getSnapUrl(this.cameraId, force).then(res => {
|
||||
const data = res.data || res
|
||||
if (data.status === 'ok' && data.url) {
|
||||
// 添加时间戳防止浏览器缓存旧截图
|
||||
const url = data.url
|
||||
this.snapUrl = url + (url.includes('?') ? '&' : '?') + '_t=' + Date.now()
|
||||
this.snapUrl = data.url
|
||||
if (data.stale) {
|
||||
this.$message.warning('截图为缓存数据,边缘设备可能离线')
|
||||
}
|
||||
|
||||
@@ -57,16 +57,26 @@ export default {
|
||||
snapUrl() {
|
||||
this.loading = true
|
||||
this.errorMsg = ''
|
||||
this.$nextTick(() => this.initCanvas())
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.initCanvas()
|
||||
window.addEventListener('resize', this.handleResize)
|
||||
})
|
||||
if (this.$refs.wrapper) {
|
||||
this._resizeObserver = new ResizeObserver(() => {
|
||||
if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) {
|
||||
this.initCanvas()
|
||||
}
|
||||
})
|
||||
this._resizeObserver.observe(this.$refs.wrapper)
|
||||
}
|
||||
window.addEventListener('resize', this.handleResize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect()
|
||||
this._resizeObserver = null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onImageLoad() {
|
||||
@@ -78,6 +88,7 @@ export default {
|
||||
onImageError() {
|
||||
this.loading = false
|
||||
this.errorMsg = '截图加载失败,请确认摄像头正在拉流'
|
||||
this.$nextTick(() => this.initCanvas())
|
||||
},
|
||||
initCanvas() {
|
||||
const canvas = this.$refs.canvas
|
||||
|
||||
Reference in New Issue
Block a user