重构: aiot 模块重命名为 video,WVP 凭据移至环境变量

路径重命名:
- api/aiot/{alarm,device,edge,request} → api/video/{alarm,device,edge,request}
- views/aiot/{alarm,device,edge} → views/video/{alarm,device,edge}
- vite.config.mts 代理路径 /admin-api/aiot/* → /admin-api/video/*

video/request.ts 改造:
- WVP 用户名/密码 MD5 改读 import.meta.env,不再写死在源码里
- force 截图失败时补一条 console.debug,便于回溯 COS 图片加载异常

video/alarm/index.ts 顺带清理:
- 移除无调用方的重复 API getRecentAlerts(与 getAlertPage 重叠)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-22 23:57:44 +08:00
parent 4111c3a34f
commit 5cd86e6cf1
18 changed files with 179 additions and 174 deletions

View File

@@ -0,0 +1,560 @@
<script setup lang="ts">
/**
* 摄像头管理页面
*
* 功能:摄像头列表展示、搜索筛选、新增/编辑/删除、拉流控制、ROI 配置跳转、配置导出
* 后端WVP 视频平台 StreamProxy API
*/
import type { VideoDeviceApi } from '#/api/video/device';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAppConfig } from '@vben/hooks';
import { Page } from '@vben/common-ui';
import {
AutoComplete,
Badge,
Button,
Checkbox,
Form,
Input,
message,
Modal,
Select,
Space,
Table,
Tag,
} from 'ant-design-vue';
import {
deleteCamera,
getCameraList,
getMediaServerList,
getRoiByCameraId,
getSnapUrl,
pushAllConfig,
saveCamera,
} from '#/api/video/device';
defineOptions({ name: 'VideoDeviceCamera' });
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const router = useRouter();
// ==================== 列表状态 ====================
const loading = ref(false);
const cameraList = ref<VideoDeviceApi.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: '摄像头名称', dataIndex: 'cameraName', width: 150 },
{ title: '拉流地址', dataIndex: 'srcUrl', ellipsis: true },
{ title: '边缘设备', dataIndex: 'edgeDeviceId', width: 100 },
{ title: '状态', key: 'status', width: 60, align: 'center' as const },
{ title: 'ROI', key: 'roiCount', width: 80, align: 'center' as const },
{ title: '操作', key: 'actions', width: 240, fixed: 'right' as const },
];
// ==================== 编辑弹窗状态 ====================
const editModalOpen = ref(false);
const editModalTitle = ref('添加摄像头');
const saving = ref(false);
const mediaServerOptions = ref<{ label: string; value: string }[]>([]);
const editForm = reactive<Partial<VideoDeviceApi.Camera>>({
id: undefined,
type: 'default',
cameraName: '',
app: '',
stream: '',
edgeDeviceId: 'edge',
srcUrl: '',
timeout: 15,
rtspType: '0',
enable: true,
enableAudio: false,
enableMp4: false,
enableDisableNoneReader: true,
relatesMediaServerId: '',
ffmpegCmdKey: '',
});
// 从已有摄像头中提取应用名选项
const appOptions = computed(() => {
// 统计每个应用名下的摄像头数量
const appCounts = new Map<string, number>();
cameraList.value.forEach((cam) => {
if (cam.app) {
appCounts.set(cam.app, (appCounts.get(cam.app) || 0) + 1);
}
});
// 转换为选项数组,按摄像头数量降序排序
return Array.from(appCounts.entries())
.map(([app, count]) => ({
label: `${app} (${count}个)`,
value: app,
count,
}))
.sort((a, b) => b.count - a.count);
});
// ==================== 数据加载 ====================
async function loadData() {
loading.value = true;
try {
const res = await getCameraList({
page: page.value,
count: pageSize.value,
query: searchQuery.value || undefined,
});
cameraList.value = res.list || [];
total.value = res.total || 0;
loadRoiCounts();
loadCameraStatus();
} catch {
message.error('加载摄像头列表失败');
} finally {
loading.value = false;
}
}
function loadRoiCounts() {
for (const cam of cameraList.value) {
const cameraCode = cam.cameraCode;
if (!cameraCode) continue;
getRoiByCameraId(cameraCode)
.then((data: any) => {
const items = Array.isArray(data) ? data : [];
roiCounts.value = { ...roiCounts.value, [cameraCode]: items.length };
})
.catch(() => {});
}
}
async function loadCameraStatus() {
for (const cam of cameraList.value) {
const cameraCode = cam.cameraCode;
if (!cameraCode) continue;
// 使用 fetch HEAD 请求检测截图是否可用(/snap/image 已免认证)
try {
const url = `${apiURL}/video/device/roi/snap/image?cameraCode=${encodeURIComponent(cameraCode)}`;
const res = await fetch(url, { method: 'HEAD' });
cameraStatus.value = { ...cameraStatus.value, [cameraCode]: res.ok };
} catch {
cameraStatus.value = { ...cameraStatus.value, [cameraCode]: false };
}
}
}
async function loadMediaServers() {
try {
const data = await getMediaServerList();
const list = Array.isArray(data) ? data : [];
mediaServerOptions.value = [
{ value: '', label: '自动分配' },
...list.map((s: any) => ({
value: s.id,
label: `${s.id} (${s.ip})`,
})),
];
} catch {
mediaServerOptions.value = [{ value: '', label: '自动分配' }];
}
}
// ==================== 新增 / 编辑 ====================
function resetForm() {
Object.assign(editForm, {
id: undefined,
type: 'default',
cameraName: '',
app: '',
stream: '',
edgeDeviceId: 'edge',
srcUrl: '',
timeout: 15,
rtspType: '0',
enable: true,
enableAudio: false,
enableMp4: false,
enableDisableNoneReader: true,
relatesMediaServerId: '',
ffmpegCmdKey: '',
});
}
/**
* 自动填充流ID编号
* 根据当前应用名,自动计算下一个可用的编号
*/
function autoFillStreamId() {
const app = editForm.app;
if (!app) return;
// 过滤出同一应用下的摄像头(排除当前编辑的摄像头)
const sameAppCameras = cameraList.value.filter(
(c) => c.app === app && c.id !== editForm.id,
);
// 获取已用的纯数字编号
const usedNumbers = sameAppCameras
.map((c) => c.stream)
.filter((s) => /^\d+$/.test(s))
.map((s) => parseInt(s, 10))
.sort((a, b) => a - b);
// 找到第一个未使用的编号
let nextNum = 1;
for (const num of usedNumbers) {
if (num === nextNum) {
nextNum++;
} else {
break;
}
}
// 自动填充3位数字前导零
editForm.stream = String(nextNum).padStart(3, '0');
}
function handleAdd() {
resetForm();
editModalTitle.value = '添加摄像头';
editModalOpen.value = true;
loadMediaServers();
// 自动填充流ID
autoFillStreamId();
}
function handleEdit(row: VideoDeviceApi.Camera) {
Object.assign(editForm, {
id: row.id,
type: row.type || 'default',
cameraName: row.cameraName || '',
app: row.app || '',
stream: row.stream || '',
edgeDeviceId: row.edgeDeviceId || 'edge',
cameraCode: row.cameraCode || '',
srcUrl: row.srcUrl || '',
timeout: row.timeout ?? 15,
rtspType: row.rtspType || '0',
enable: row.enable ?? true,
enableAudio: row.enableAudio ?? false,
enableMp4: row.enableMp4 ?? false,
enableDisableNoneReader: row.enableDisableNoneReader ?? true,
relatesMediaServerId: row.relatesMediaServerId || '',
ffmpegCmdKey: row.ffmpegCmdKey || '',
});
editModalTitle.value = '编辑摄像头';
editModalOpen.value = true;
loadMediaServers();
}
async function handleSave() {
if (!editForm.cameraName?.trim()) {
message.warning('请输入摄像头名称');
return;
}
if (!editForm.srcUrl?.trim()) {
message.warning('请输入拉流地址');
return;
}
// app/stream 为 ZLM 内部字段,自动填充默认值即可
if (!editForm.app) editForm.app = 'default';
if (!editForm.stream) editForm.stream = '001';
saving.value = true;
try {
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 {
saving.value = false;
}
}
// ==================== 删除 ====================
function handleDelete(row: VideoDeviceApi.Camera) {
Modal.confirm({
title: '删除确认',
content: `确定删除摄像头 ${row.cameraName || row.cameraCode || row.stream} `,
okType: 'danger',
async onOk() {
try {
await deleteCamera(row.id!);
message.success('删除成功');
loadData();
} catch {
message.error('删除失败');
}
},
});
}
// ==================== ROI 配置跳转 ====================
function handleRoiConfig(row: VideoDeviceApi.Camera) {
router.push({
path: '/video/device/roi',
query: {
cameraCode: row.cameraCode,
srcUrl: row.srcUrl,
},
});
}
// ==================== 同步全局配置 ====================
const syncing = ref(false);
async function handleSyncAll() {
syncing.value = true;
try {
const res = await pushAllConfig();
message.success(
`同步完成ROI ${res.rois ?? 0} 条,算法绑定 ${res.binds ?? 0}`,
);
} catch {
message.error('同步全局配置失败');
} finally {
syncing.value = false;
}
}
// ==================== 分页 ====================
function handlePageChange(p: number, size: number) {
page.value = p;
pageSize.value = size;
loadData();
}
// ==================== 初始化 ====================
// 监听应用名变化自动填充流ID
watch(
() => editForm.app,
() => {
autoFillStreamId();
},
);
onMounted(() => {
loadData();
});
</script>
<template>
<Page auto-content-height>
<div style="padding: 16px">
<!-- 搜索栏 -->
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
"
>
<div style="display: flex; align-items: center; gap: 8px">
<Input
v-model:value="searchQuery"
placeholder="搜索摄像头名称/地址"
style="width: 250px"
allow-clear
@press-enter="loadData"
/>
<Button type="primary" @click="loadData">查询</Button>
</div>
<Space>
<Button type="primary" :loading="syncing" @click="handleSyncAll">
同步全局配置
</Button>
<Button type="primary" @click="handleAdd">添加摄像头</Button>
</Space>
</div>
<!-- 表格 -->
<Table
:columns="columns"
:data-source="cameraList"
:loading="loading"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['15', '25', '50'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: handlePageChange,
}"
:scroll="{ x: 1000 }"
row-key="id"
bordered
size="middle"
>
<template #bodyCell="{ column, record }">
<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' }"
show-zero
/>
</template>
<template v-else-if="column.key === 'actions'">
<Space>
<Button
type="primary"
size="small"
@click="handleRoiConfig(record)"
>
ROI配置
</Button>
<Button size="small" @click="handleEdit(record)">编辑</Button>
<Button size="small" danger @click="handleDelete(record)">
删除
</Button>
</Space>
</template>
</template>
</Table>
</div>
<!-- 新增/编辑弹窗 -->
<Modal
v-model:open="editModalOpen"
:title="editModalTitle"
:width="560"
:confirm-loading="saving"
@ok="handleSave"
>
<Form
layout="horizontal"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
style="margin-top: 16px"
>
<Form.Item label="代理类型">
<Select
v-model:value="editForm.type"
:options="[
{ value: 'default', label: '默认' },
{ value: 'ffmpeg', label: 'FFmpeg' },
]"
/>
</Form.Item>
<Form.Item v-if="editForm.id" label="摄像头编码">
<Input :value="editForm.cameraCode" disabled />
</Form.Item>
<Form.Item label="摄像头名称" required>
<Input
v-model:value="editForm.cameraName"
placeholder="请输入摄像头名称,如:大堂吧台、室外停车场"
/>
</Form.Item>
<Form.Item label="边缘设备">
<Select
v-model:value="editForm.edgeDeviceId"
:options="[
{ value: 'edge', label: 'edge主站' },
{ value: 'edge_002', label: 'edge_002梦中心' },
]"
placeholder="选择绑定的边缘设备"
/>
</Form.Item>
<Form.Item label="拉流地址" required>
<Input
v-model:value="editForm.srcUrl"
placeholder="rtsp://... 或 rtmp://..."
/>
</Form.Item>
<Form.Item label="超时(秒)">
<Input
v-model:value="editForm.timeout"
type="number"
placeholder="15"
/>
</Form.Item>
<Form.Item label="RTSP拉流方式">
<Select
v-model:value="editForm.rtspType"
:options="[
{ value: '0', label: 'TCP' },
{ value: '1', label: 'UDP' },
{ value: '2', label: '组播' },
]"
/>
</Form.Item>
<Form.Item label="媒体服务器">
<Select
v-model:value="editForm.relatesMediaServerId"
:options="mediaServerOptions"
placeholder="自动分配"
/>
</Form.Item>
<Form.Item
v-if="editForm.type === 'ffmpeg'"
label="FFmpeg命令模板"
>
<Input
v-model:value="editForm.ffmpegCmdKey"
placeholder="默认留空"
/>
</Form.Item>
<Form.Item label="选项" :wrapper-col="{ offset: 6, span: 16 }">
<div style="display: flex; flex-wrap: wrap; gap: 16px">
<Checkbox v-model:checked="editForm.enable">启用</Checkbox>
<Checkbox v-model:checked="editForm.enableAudio">
开启音频
</Checkbox>
<Checkbox v-model:checked="editForm.enableMp4">
MP4录制
</Checkbox>
<Checkbox v-model:checked="editForm.enableDisableNoneReader">
无人观看时停止
</Checkbox>
</div>
</Form.Item>
</Form>
</Modal>
</Page>
</template>