From fae585f5e943ae38b420ae60d2d9219a46f598dd Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Tue, 10 Mar 2026 16:02:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=E7=8A=B6=E6=80=81=E6=8C=87=E7=A4=BA=E5=99=A8=20+=20?= =?UTF-8?q?=E6=88=AA=E5=9B=BE=E7=BC=93=E5=AD=98=E4=BC=98=E5=8C=96=20+=20?= =?UTF-8?q?=E6=88=AA=E5=9B=BE=E9=A2=84=E7=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 摄像头列表状态指示器(绿/红点) - 新增状态列显示摄像头截图可用性 - 绿色=截图正常,红色=截图失败,灰色=加载中 - 使用 /snap/image 代理端点检测状态 2. 截图缓存优化(getSnapUrl 重写) - 非强制模式:返回稳定URL(无时间戳),浏览器缓存5分钟 - 强制模式:先触发边缘截图,再返回带时间戳URL破缓存 - 使用 /snap/image 代理端点,避免COS预签名URL过期问题 3. 截图预热(新增摄像头时) - 添加摄像头成功后自动触发首次截图 - 预热失败不影响主流程(非阻塞) Co-Authored-By: Claude Opus 4.6 --- apps/web-antd/src/api/aiot/device/index.ts | 30 ++++++++--- .../src/views/aiot/device/camera/index.vue | 51 ++++++++++++++++++- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/apps/web-antd/src/api/aiot/device/index.ts b/apps/web-antd/src/api/aiot/device/index.ts index ba1c7e3fd..9b52aa2de 100644 --- a/apps/web-antd/src/api/aiot/device/index.ts +++ b/apps/web-antd/src/api/aiot/device/index.ts @@ -189,19 +189,33 @@ export function deleteRoi(roiId: string) { /** * 获取摄像头截图 URL * - * /snap 端点会自动处理缓存逻辑: - * - 有 Redis 缓存时直接 302 重定向到 COS presigned URL(快) - * - 无缓存时触发 Edge 截图,等待完成后重定向(首次较慢) - * - force=true 时强制触发 Edge 截新图 + * 非 force 模式:直接返回 /snap/image 代理 URL(无时间戳,浏览器自动缓存) + * force 模式:先触发边缘端截图,再返回带时间戳的代理 URL 破缓存 */ export async function getSnapUrl(cameraCode: string, force = false): Promise { const token = await getWvpToken(); + if (force) { + // 强制刷新:先触发边缘端截图(等待完成) + try { + await wvpRequestClient.get('/aiot/device/roi/snap', { + params: { cameraCode, force: true }, + }); + } catch { + // 截图请求可能超时,但 COS 上可能已有图片,继续返回代理 URL + } + // 加时间戳破浏览器缓存 + return ( + `${apiURL}/aiot/device/roi/snap/image` + + `?cameraCode=${encodeURIComponent(cameraCode)}` + + `&access-token=${encodeURIComponent(token)}` + + `&t=${Date.now()}` + ); + } + // 非 force:使用代理端点,不加时间戳,浏览器自动缓存 return ( - `${apiURL}/aiot/device/roi/snap` + + `${apiURL}/aiot/device/roi/snap/image` + `?cameraCode=${encodeURIComponent(cameraCode)}` + - `&access-token=${encodeURIComponent(token)}` + - (force ? `&force=true` : '') + - `&t=${Date.now()}` + `&access-token=${encodeURIComponent(token)}` ); } diff --git a/apps/web-antd/src/views/aiot/device/camera/index.vue b/apps/web-antd/src/views/aiot/device/camera/index.vue index d80484e95..21411bed0 100644 --- a/apps/web-antd/src/views/aiot/device/camera/index.vue +++ b/apps/web-antd/src/views/aiot/device/camera/index.vue @@ -32,9 +32,11 @@ import { getCameraList, getMediaServerList, getRoiByCameraId, + getSnapUrl, pushAllConfig, saveCamera, } from '#/api/aiot/device'; +import { wvpRequestClient } from '#/api/aiot/request'; defineOptions({ name: 'AiotDeviceCamera' }); @@ -45,12 +47,14 @@ const router = useRouter(); const loading = ref(false); const cameraList = ref([]); const roiCounts = ref>({}); +const cameraStatus = ref>({}); const page = ref(1); const pageSize = ref(15); const total = ref(0); const searchQuery = ref(''); const columns = [ + { title: '状态', key: 'status', width: 60, align: 'center' as const }, { title: '应用名', dataIndex: 'app', width: 120 }, { title: '流ID', dataIndex: 'stream', width: 150 }, { title: '拉流地址', dataIndex: 'srcUrl', ellipsis: true }, @@ -113,6 +117,7 @@ async function loadData() { cameraList.value = res.list || []; total.value = res.total || 0; loadRoiCounts(); + loadCameraStatus(); } catch { message.error('加载摄像头列表失败'); } finally { @@ -133,6 +138,24 @@ function loadRoiCounts() { } } +function loadCameraStatus() { + for (const cam of cameraList.value) { + const cameraCode = cam.cameraCode; + if (!cameraCode) continue; + wvpRequestClient + .get('/aiot/device/roi/snap/image', { + params: { cameraCode }, + responseType: 'blob', + }) + .then(() => { + cameraStatus.value = { ...cameraStatus.value, [cameraCode]: true }; + }) + .catch(() => { + cameraStatus.value = { ...cameraStatus.value, [cameraCode]: false }; + }); + } +} + async function loadMediaServers() { try { const data = await getMediaServerList(); @@ -252,7 +275,19 @@ async function handleSave() { await saveCamera({ ...editForm }); message.success(editForm.id ? '编辑成功' : '添加成功'); editModalOpen.value = false; - loadData(); + // 新增摄像头成功后自动触发首次截图预热(非阻塞) + if (!editForm.id) { + loadData().then(() => { + const saved = cameraList.value.find( + (c) => c.app === editForm.app && c.stream === editForm.stream, + ); + if (saved?.cameraCode) { + getSnapUrl(saved.cameraCode, true).catch(() => {}); + } + }); + } else { + loadData(); + } } catch (err: any) { message.error(err?.message || '保存失败'); } finally { @@ -382,7 +417,19 @@ onMounted(() => { size="middle" >