Files
iot-device-management-frontend/apps/web-antd/src/views/aiot/device/camera/index.vue
2026-03-23 17:02:30 +08:00

561 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
/**
* 摄像头管理页面
*
* 功能:摄像头列表展示、搜索筛选、新增/编辑/删除、拉流控制、ROI 配置跳转、配置导出
* 后端WVP 视频平台 StreamProxy API
*/
import type { AiotDeviceApi } from '#/api/aiot/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/aiot/device';
defineOptions({ name: 'AiotDeviceCamera' });
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
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: '摄像头名称', 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<AiotDeviceApi.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}/aiot/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: AiotDeviceApi.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: AiotDeviceApi.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: AiotDeviceApi.Camera) {
router.push({
path: '/aiot/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>