561 lines
15 KiB
Vue
561 lines
15 KiB
Vue
<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>
|