重构: 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:
560
apps/web-antd/src/views/video/device/camera/index.vue
Normal file
560
apps/web-antd/src/views/video/device/camera/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user