fix(aiot): 截图持久化适配 + 告警 API 切换到 WVP + 图片代理

截图 / ROI:
- getSnapUrl: 非 force 模式直接返回 /snap/image 代理 URL(从 DB 读持久化截图,
  不触发 Edge),force 模式先请求 Edge 截图再返回代理 URL
- RoiCanvas: 添加 ResizeObserver 确保容器尺寸变化时重新初始化 canvas,
  onImageError 兜底初始化 canvas(截图失败仍可绘制/查看 ROI),
  snapUrl watcher 触发 canvas 重初始化

告警:
- alarm/index.ts: requestClient → wvpRequestClient,路径从
  /aiot/alarm/alert/* 切换到 /aiot/device/alert/*(走 WVP 后端)
- Alert 类型新增 imagePath、receivedAt、extraData 字段
- alarm list: 缩略图和详情图片通过 WVP /alert/image 代理端点显示,
  避免 COS presigned URL 过期问题
- device/index.ts: 新增 getAlertImageUrl() 构造告警图片代理 URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 09:21:48 +08:00
parent 71fd2a8576
commit 0d56b2f221
4 changed files with 91 additions and 34 deletions

View File

@@ -1,11 +1,12 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
import { wvpRequestClient } from '#/api/aiot/request';
export namespace AiotAlarmApi {
/** AI 告警 VO */
export interface Alert {
id?: number | string;
alertId?: string;
alarmId?: string;
alertNo?: string;
cameraId?: string;
@@ -23,6 +24,7 @@ export namespace AiotAlarmApi {
triggerTime?: string;
message?: string;
bbox?: string;
imagePath?: string;
snapshotUrl?: string;
ossUrl?: string;
status?: string;
@@ -34,9 +36,11 @@ export namespace AiotAlarmApi {
lastFrameTime?: string;
workOrderId?: number;
aiAnalysis?: Record<string, any>;
receivedAt?: string;
createdAt?: string;
updatedAt?: string;
logInfo?: AlertLogInfo;
extraData?: string;
}
/** 告警日志信息 */
@@ -74,20 +78,20 @@ export namespace AiotAlarmApi {
}
}
// ==================== 告警管理 API ====================
// ==================== 告警管理 APIWVP 后端)====================
/** 分页查询告警列表 */
export function getAlertPage(params: PageParam) {
return requestClient.get<PageResult<AiotAlarmApi.Alert>>(
'/aiot/alarm/alert/page',
{ params },
return wvpRequestClient.get<PageResult<AiotAlarmApi.Alert>>(
'/aiot/device/alert/list',
{ params: { page: params.pageNo, count: params.pageSize, ...params } },
);
}
/** 获取告警详情 */
export function getAlert(id: number | string) {
return requestClient.get<AiotAlarmApi.Alert>(
`/aiot/alarm/alert/get?id=${id}`,
return wvpRequestClient.get<AiotAlarmApi.Alert>(
`/aiot/device/alert/${id}`,
);
}
@@ -97,20 +101,22 @@ export function handleAlert(
status: string,
remark?: string,
) {
return requestClient.put('/aiot/alarm/alert/handle', null, {
return wvpRequestClient.put('/aiot/device/alert/handle', null, {
params: { id, status, remark },
});
}
/** 删除告警 */
export function deleteAlert(id: number | string) {
return requestClient.delete(`/aiot/alarm/alert/delete?id=${id}`);
return wvpRequestClient.delete('/aiot/device/alert/delete', {
params: { alertId: id },
});
}
/** 获取告警统计 */
export function getAlertStatistics(startTime?: string, endTime?: string) {
return requestClient.get<AiotAlarmApi.AlertStatistics>(
'/aiot/alarm/alert/statistics',
return wvpRequestClient.get<AiotAlarmApi.AlertStatistics>(
'/aiot/device/alert/statistics',
{ params: { startTime, endTime } },
);
}
@@ -119,8 +125,8 @@ export function getAlertStatistics(startTime?: string, endTime?: string) {
/** 以摄像头维度获取告警汇总 */
export function getCameraAlertSummary(params: PageParam) {
return requestClient.get<PageResult<AiotAlarmApi.CameraAlertSummary>>(
'/aiot/alarm/device-summary/page',
return wvpRequestClient.get<PageResult<AiotAlarmApi.CameraAlertSummary>>(
'/aiot/device/alert/statistics',
{ params },
);
}

View File

@@ -188,16 +188,28 @@ export function deleteRoi(roiId: string) {
/**
* 获取摄像头截图 URL
* 截图接口需要认证,通过 query param 传递 access-token
*
* 非 force 模式:直接返回 /snap/image 代理 URL从 DB 读取持久化截图,不触发 Edge
* force 模式:先触发 Edge 截新图(更新 DB再返回代理 URL
*/
export async function getSnapUrl(cameraCode: string, force = false): Promise<string> {
const token = await getWvpToken();
if (force) {
// force 时先触发一次截图请求(确保 Edge 截新图并更新 DB
try {
await wvpRequestClient.get('/aiot/device/roi/snap', {
params: { cameraCode, force: true },
});
} catch {
/* 截图请求可能超时,但 DB 会被更新 */
}
}
// 返回代理 URLWVP 从 DB 读 cos_key → 生成 presigned URL → 下载返回)
return (
`${apiURL}/aiot/device/roi/snap` +
`${apiURL}/aiot/device/roi/snap/image` +
`?cameraCode=${encodeURIComponent(cameraCode)}` +
`&force=${force}` +
`&access-token=${encodeURIComponent(token)}` +
`&t=${Date.now()}`
(force ? `&t=${Date.now()}` : '')
);
}
@@ -256,3 +268,19 @@ export function exportConfig(cameraId: string) {
{ params: { cameraId } },
);
}
// ==================== 告警图片代理 ====================
/**
* 构造告警图片代理 URL通过 WVP 下载 COS 图片后返回字节流)
* @param imagePath COS 对象路径或完整 URL
*/
export async function getAlertImageUrl(imagePath: string): Promise<string> {
if (!imagePath) return '';
const token = await getWvpToken();
return (
`${apiURL}/aiot/device/alert/image` +
`?imagePath=${encodeURIComponent(imagePath)}` +
`&access-token=${encodeURIComponent(token)}`
);
}

View File

@@ -2,7 +2,7 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiotAlarmApi } from '#/api/aiot/alarm';
import { h, ref } from 'vue';
import { h, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
@@ -10,6 +10,7 @@ import { Button, Image, message, Modal, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAlert, getAlertPage, handleAlert } from '#/api/aiot/alarm';
import { getAlertImageUrl } from '#/api/aiot/device';
import {
ALERT_LEVEL_OPTIONS,
@@ -21,6 +22,21 @@ import {
defineOptions({ name: 'AiotAlarmList' });
/** 告警图片 URL 缓存imagePath → 代理 URL */
const imageUrlCache = ref<Record<string, string>>({});
/** 获取告警图片代理 URL异步构造后缓存 */
function getImageProxyUrl(row: AiotAlarmApi.Alert): string {
const imagePath = row.imagePath || row.ossUrl || row.snapshotUrl;
if (!imagePath) return '';
if (imageUrlCache.value[imagePath]) return imageUrlCache.value[imagePath]!;
// 异步构造并缓存
getAlertImageUrl(imagePath).then((url) => {
imageUrlCache.value[imagePath] = url;
});
return '';
}
/** 格式化持续时长(毫秒 → 可读文本) */
function formatDuration(ms: number | null | undefined): string {
// 当 duration_ms 为 null 时,说明告警仍在进行中
@@ -247,11 +263,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
<!-- 截图缩略图列 -->
<template #snapshot="{ row }">
<Image
v-if="row.snapshotUrl || row.ossUrl"
:src="row.ossUrl || row.snapshotUrl"
v-if="getImageProxyUrl(row)"
:src="getImageProxyUrl(row)"
:width="40"
:height="40"
:preview="{ src: row.ossUrl || row.snapshotUrl }"
:preview="{ src: getImageProxyUrl(row) }"
:fallback="`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'><rect width='40' height='40' fill='%23f0f0f0'/><text x='50%25' y='55%25' dominant-baseline='middle' text-anchor='middle' fill='%23bbb' font-size='12'>-</text></svg>`"
style="object-fit: cover; border-radius: 4px; cursor: pointer"
/>
@@ -350,26 +366,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
</div>
<!-- 告警截图 -->
<div v-if="currentAlert.ossUrl || currentAlert.snapshotUrl">
<div v-if="getImageProxyUrl(currentAlert)">
<span class="text-gray-500">告警截图</span>
<div class="mt-2">
<Image
:src="currentAlert.ossUrl || currentAlert.snapshotUrl"
:src="getImageProxyUrl(currentAlert)"
:width="300"
:preview="{ src: currentAlert.ossUrl || currentAlert.snapshotUrl }"
:preview="{ src: getImageProxyUrl(currentAlert) }"
:fallback="`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='300' height='200'><rect width='300' height='200' fill='%23f5f5f5'/><text x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23bbb' font-size='14'>图片加载失败</text></svg>`"
/>
</div>
<div class="mt-1">
<a
:href="currentAlert.ossUrl || currentAlert.snapshotUrl"
target="_blank"
rel="noopener noreferrer"
class="text-blue-500 text-xs hover:underline"
>
{{ currentAlert.ossUrl || currentAlert.snapshotUrl }}
</a>
</div>
</div>
<!-- 检测区域 -->

View File

@@ -47,17 +47,32 @@ watch(() => props.drawMode, () => {
watch(() => props.snapUrl, () => {
loading.value = true;
errorMsg.value = '';
nextTick(() => initCanvas());
});
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
nextTick(() => {
initCanvas();
if (wrapper.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
if (wrapper.value && wrapper.value.clientWidth > 0) {
initCanvas();
}
});
resizeObserver.observe(wrapper.value);
}
window.addEventListener('resize', handleResize);
});
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
function onImageLoad() {
@@ -68,6 +83,8 @@ function onImageLoad() {
function onImageError() {
loading.value = false;
errorMsg.value = '截图加载失败,请确认摄像头正在拉流';
// 关键:截图失败也初始化 canvas使 ROI 区域可见可操作
nextTick(() => initCanvas());
}
function initCanvas() {