功能:摄像头状态指示器 + 截图缓存优化 + 截图预热

1. 摄像头列表状态指示器(绿/红点)
   - 新增状态列显示摄像头截图可用性
   - 绿色=截图正常,红色=截图失败,灰色=加载中
   - 使用 /snap/image 代理端点检测状态

2. 截图缓存优化(getSnapUrl 重写)
   - 非强制模式:返回稳定URL(无时间戳),浏览器缓存5分钟
   - 强制模式:先触发边缘截图,再返回带时间戳URL破缓存
   - 使用 /snap/image 代理端点,避免COS预签名URL过期问题

3. 截图预热(新增摄像头时)
   - 添加摄像头成功后自动触发首次截图
   - 预热失败不影响主流程(非阻塞)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 16:02:41 +08:00
parent eab4337a77
commit fae585f5e9
2 changed files with 71 additions and 10 deletions

View File

@@ -189,20 +189,34 @@ 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<string> {
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` +
`${apiURL}/aiot/device/roi/snap/image` +
`?cameraCode=${encodeURIComponent(cameraCode)}` +
`&access-token=${encodeURIComponent(token)}` +
(force ? `&force=true` : '') +
`&t=${Date.now()}`
);
}
// 非 force使用代理端点不加时间戳浏览器自动缓存
return (
`${apiURL}/aiot/device/roi/snap/image` +
`?cameraCode=${encodeURIComponent(cameraCode)}` +
`&access-token=${encodeURIComponent(token)}`
);
}
// ==================== 算法管理 ====================

View File

@@ -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<AiotDeviceApi.Camera[]>([]);
const roiCounts = ref<Record<string, number>>({});
const cameraStatus = ref<Record<string, boolean | null>>({});
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;
// 新增摄像头成功后自动触发首次截图预热(非阻塞)
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"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'roiCount'">
<template v-if="column.key === 'status'">
<Badge
:status="
cameraStatus[record.cameraCode] === null ||
cameraStatus[record.cameraCode] === undefined
? 'default'
: cameraStatus[record.cameraCode]
? 'success'
: 'error'
"
/>
</template>
<template v-else-if="column.key === 'roiCount'">
<Badge
:count="roiCounts[record.cameraCode] ?? 0"
:number-style="{ backgroundColor: '#1677ff' }"