diff --git a/apps/web-antd/src/api/aiot/device/index.ts b/apps/web-antd/src/api/aiot/device/index.ts index 7066818f4..ba1c7e3fd 100644 --- a/apps/web-antd/src/api/aiot/device/index.ts +++ b/apps/web-antd/src/api/aiot/device/index.ts @@ -188,14 +188,19 @@ export function deleteRoi(roiId: string) { /** * 获取摄像头截图 URL - * 截图接口需要认证,通过 query param 传递 access-token + * + * /snap 端点会自动处理缓存逻辑: + * - 有 Redis 缓存时直接 302 重定向到 COS presigned URL(快) + * - 无缓存时触发 Edge 截图,等待完成后重定向(首次较慢) + * - force=true 时强制触发 Edge 截新图 */ -export async function getSnapUrl(cameraCode: string): Promise { +export async function getSnapUrl(cameraCode: string, force = false): Promise { const token = await getWvpToken(); return ( `${apiURL}/aiot/device/roi/snap` + `?cameraCode=${encodeURIComponent(cameraCode)}` + `&access-token=${encodeURIComponent(token)}` + + (force ? `&force=true` : '') + `&t=${Date.now()}` ); } @@ -255,3 +260,19 @@ export function exportConfig(cameraId: string) { { params: { cameraId } }, ); } + +// ==================== 告警图片代理 ==================== + +/** + * 构造告警图片代理 URL(通过 WVP 下载 COS 图片后返回字节流) + * @param imagePath COS 对象路径或完整 URL + */ +export async function getAlertImageUrl(imagePath: string): Promise { + if (!imagePath) return ''; + const token = await getWvpToken(); + return ( + `${apiURL}/aiot/device/alert/image` + + `?imagePath=${encodeURIComponent(imagePath)}` + + `&access-token=${encodeURIComponent(token)}` + ); +} diff --git a/apps/web-antd/src/api/aiot/edge/index.ts b/apps/web-antd/src/api/aiot/edge/index.ts index 39cf9843c..3abc1091d 100644 --- a/apps/web-antd/src/api/aiot/edge/index.ts +++ b/apps/web-antd/src/api/aiot/edge/index.ts @@ -1,6 +1,6 @@ import type { PageParam, PageResult } from '@vben/request'; -import { requestClient } from '#/api/request'; +import { wvpRequestClient } from '#/api/aiot/request'; export namespace AiotEdgeApi { /** 边缘设备 VO */ @@ -34,22 +34,23 @@ export namespace AiotEdgeApi { /** 分页查询边缘设备列表 */ export function getDevicePage(params: PageParam) { - return requestClient.get>( - '/aiot/edge/device/page', + return wvpRequestClient.get>( + '/api/ai/device/page', { params }, ); } /** 获取设备详情 */ -export function getDevice(id: string) { - return requestClient.get( - `/aiot/edge/device/get?id=${id}`, +export function getDevice(deviceId: string) { + return wvpRequestClient.get( + '/api/ai/device/get', + { params: { deviceId } }, ); } /** 获取设备统计 */ export function getDeviceStatistics() { - return requestClient.get( - '/aiot/edge/device/statistics', + return wvpRequestClient.get( + '/api/ai/device/statistics', ); } diff --git a/apps/web-antd/src/views/aiot/device/roi/components/RoiCanvas.vue b/apps/web-antd/src/views/aiot/device/roi/components/RoiCanvas.vue index d4080fee1..f32621952 100644 --- a/apps/web-antd/src/views/aiot/device/roi/components/RoiCanvas.vue +++ b/apps/web-antd/src/views/aiot/device/roi/components/RoiCanvas.vue @@ -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() { diff --git a/apps/web-antd/src/views/aiot/device/roi/index.vue b/apps/web-antd/src/views/aiot/device/roi/index.vue index 617dc7168..e5dd122a0 100644 --- a/apps/web-antd/src/views/aiot/device/roi/index.vue +++ b/apps/web-antd/src/views/aiot/device/roi/index.vue @@ -35,6 +35,7 @@ import { pushConfig, saveRoi, } from '#/api/aiot/device'; +import { wvpRequestClient } from '#/api/aiot/request'; import RoiAlgorithmBind from './components/RoiAlgorithmBind.vue'; import RoiCanvas from './components/RoiCanvas.vue'; @@ -64,6 +65,17 @@ const selectedRoiId = ref(null); const selectedRoiBindings = ref([]); const snapUrl = ref(''); +const edgeDevices = ref>([]); + +async function loadEdgeDevices() { + try { + const list = await wvpRequestClient.get>('/api/ai/device/list'); + edgeDevices.value = (list as any) || []; + } catch { + edgeDevices.value = [{ deviceId: 'edge' }]; + } +} + const selectedRoi = computed(() => { if (!selectedRoiId.value) return null; return roiList.value.find((r) => r.roiId === selectedRoiId.value) || null; @@ -72,6 +84,7 @@ const selectedRoi = computed(() => { // ==================== 初始化 ==================== onMounted(async () => { + loadEdgeDevices(); const q = route.query; if (q.cameraCode) { cameraCode.value = String(q.cameraCode); @@ -134,14 +147,14 @@ function goBack() { // ==================== 截图 ==================== -async function buildSnapUrl() { +async function buildSnapUrl(force = false) { if (cameraCode.value) { - snapUrl.value = await getSnapUrl(cameraCode.value); + snapUrl.value = await getSnapUrl(cameraCode.value, force); } } async function refreshSnap() { - await buildSnapUrl(); + await buildSnapUrl(true); } // ==================== ROI 数据加载 ==================== @@ -187,7 +200,7 @@ async function onRoiDrawn(data: { coordinates: string; roi_type: string }) { priority: 0, enabled: 1, description: '', - deviceId: 'edge-001', // 默认关联边缘设备 + deviceId: edgeDevices.value[0]?.deviceId || 'edge', // 默认关联边缘设备 }; try { await saveRoi(newRoi); @@ -416,11 +429,12 @@ function handlePush() { placeholder="选择边缘设备" @change="updateRoiData(selectedRoi!)" > - edge-001(默认) - + + {{ dev.deviceId }} +
- 关联的边缘推理节点,默认 edge-001 + 关联的边缘推理节点