feat(aiot-device): 摄像头管理增加增删改功能

- Camera 接口扩展完整字段(type、timeout、rtspType、enable 等)
- 新增 saveCamera、deleteCamera、getMediaServerList API
- 摄像头管理页面增加添加/编辑弹窗(表单含代理类型、拉流地址、RTSP方式等)
- 增加删除确认对话框
- Vite 代理增加媒体服务器 API 转发规则

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 09:51:37 +08:00
parent e59eb5fe65
commit f7bfde0135
3 changed files with 286 additions and 23 deletions

View File

@@ -49,9 +49,18 @@ export namespace AiotDeviceApi {
/** 摄像头(拉流代理) */
export interface Camera {
id?: number;
type?: string; // default | ffmpeg
app?: string;
stream?: string;
srcUrl?: string;
timeout?: number;
rtspType?: string; // 0=TCP, 1=UDP, 2=Multicast
enable?: boolean;
enableAudio?: boolean;
enableMp4?: boolean;
enableDisableNoneReader?: boolean;
relatesMediaServerId?: string;
ffmpegCmdKey?: string;
pulling?: boolean;
mediaServerId?: string;
}
@@ -79,6 +88,23 @@ export function stopCamera(id: number) {
return wvpRequestClient.get('/aiot/device/proxy/stop', { params: { id } });
}
/** 保存摄像头(新增/编辑) */
export function saveCamera(data: Partial<AiotDeviceApi.Camera>) {
return wvpRequestClient.post('/aiot/device/proxy/save', data);
}
/** 删除摄像头 */
export function deleteCamera(id: number) {
return wvpRequestClient.delete('/aiot/device/proxy/delete', {
params: { id },
});
}
/** 获取在线媒体服务器列表 */
export function getMediaServerList() {
return wvpRequestClient.get<any>('/aiot/device/server/online/list');
}
// ==================== ROI 区域管理 API ====================
/** 获取 ROI 列表(分页) */

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AiotDeviceApi } from '#/api/aiot/device';
import { onMounted, ref } from 'vue';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
@@ -9,8 +9,11 @@ import { Page } from '@vben/common-ui';
import {
Badge,
Button,
Checkbox,
Form,
Input,
message,
Modal,
Select,
Space,
Table,
@@ -18,9 +21,12 @@ import {
} from 'ant-design-vue';
import {
deleteCamera,
exportConfig,
getCameraList,
getMediaServerList,
getRoiByCameraId,
saveCamera,
startCamera,
stopCamera,
} from '#/api/aiot/device';
@@ -38,13 +44,34 @@ const total = ref(0);
const searchQuery = ref('');
const searchPulling = ref<string | undefined>(undefined);
// 编辑弹窗
const editModalOpen = ref(false);
const editModalTitle = ref('添加摄像头');
const saving = ref(false);
const mediaServerOptions = ref<{ value: string; label: string }[]>([]);
const editForm = reactive<Partial<AiotDeviceApi.Camera>>({
id: undefined,
type: 'default',
app: 'live',
stream: '',
srcUrl: '',
timeout: 15,
rtspType: '0',
enable: true,
enableAudio: false,
enableMp4: false,
enableDisableNoneReader: true,
relatesMediaServerId: '',
ffmpegCmdKey: '',
});
const columns = [
{ title: '应用名', dataIndex: 'app', width: 100 },
{ title: '流ID', dataIndex: 'stream', width: 120 },
{ title: '拉流地址', dataIndex: 'srcUrl', ellipsis: true },
{ title: '状态', key: 'pulling', width: 100 },
{ title: 'ROI数量', key: 'roiCount', width: 100 },
{ title: '操作', key: 'actions', width: 260, fixed: 'right' as const },
{ title: '操作', key: 'actions', width: 340, fixed: 'right' as const },
];
async function loadData() {
@@ -81,6 +108,107 @@ function loadRoiCounts() {
});
}
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() {
editForm.id = undefined;
editForm.type = 'default';
editForm.app = 'live';
editForm.stream = '';
editForm.srcUrl = '';
editForm.timeout = 15;
editForm.rtspType = '0';
editForm.enable = true;
editForm.enableAudio = false;
editForm.enableMp4 = false;
editForm.enableDisableNoneReader = true;
editForm.relatesMediaServerId = '';
editForm.ffmpegCmdKey = '';
}
function handleAdd() {
resetForm();
editModalTitle.value = '添加摄像头';
editModalOpen.value = true;
loadMediaServers();
}
function handleEdit(row: AiotDeviceApi.Camera) {
editForm.id = row.id;
editForm.type = row.type || 'default';
editForm.app = row.app || '';
editForm.stream = row.stream || '';
editForm.srcUrl = row.srcUrl || '';
editForm.timeout = row.timeout ?? 15;
editForm.rtspType = row.rtspType || '0';
editForm.enable = row.enable ?? true;
editForm.enableAudio = row.enableAudio ?? false;
editForm.enableMp4 = row.enableMp4 ?? false;
editForm.enableDisableNoneReader = row.enableDisableNoneReader ?? true;
editForm.relatesMediaServerId = row.relatesMediaServerId || '';
editForm.ffmpegCmdKey = row.ffmpegCmdKey || '';
editModalTitle.value = '编辑摄像头';
editModalOpen.value = true;
loadMediaServers();
}
async function handleSave() {
if (!editForm.app?.trim()) {
message.warning('请输入应用名');
return;
}
if (!editForm.stream?.trim()) {
message.warning('请输入流ID');
return;
}
if (!editForm.srcUrl?.trim()) {
message.warning('请输入拉流地址');
return;
}
saving.value = true;
try {
await saveCamera({ ...editForm });
message.success(editForm.id ? '编辑成功' : '添加成功');
editModalOpen.value = false;
loadData();
} catch (err: any) {
message.error(err?.message || '保存失败');
} finally {
saving.value = false;
}
}
function handleDelete(row: AiotDeviceApi.Camera) {
Modal.confirm({
title: '删除确认',
content: `确定删除摄像头 ${row.app}/${row.stream} `,
okType: 'danger',
async onOk() {
try {
await deleteCamera(row.id!);
message.success('删除成功');
loadData();
} catch {
message.error('删除失败');
}
},
});
}
function handleRoiConfig(row: AiotDeviceApi.Camera) {
router.push({
path: '/aiot/device/roi',
@@ -139,35 +267,40 @@ onMounted(() => {
<template>
<Page auto-content-height>
<div style="padding: 16px">
<!-- 搜索栏 -->
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 8px;
"
>
<Input
v-model:value="searchQuery"
placeholder="搜索摄像头名称/地址"
style="width: 250px"
allow-clear
@press-enter="loadData"
/>
<Select
v-model:value="searchPulling"
placeholder="拉流状态"
style="width: 130px"
allow-clear
:options="[
{ value: 'true', label: '拉流中' },
{ value: 'false', label: '拉流' },
]"
@change="loadData"
/>
<Button type="primary" @click="loadData">查询</Button>
<div style="display: flex; align-items: center; gap: 8px">
<Input
v-model:value="searchQuery"
placeholder="搜索摄像头名称/地址"
style="width: 250px"
allow-clear
@press-enter="loadData"
/>
<Select
v-model:value="searchPulling"
placeholder="拉流状态"
style="width: 130px"
allow-clear
:options="[
{ value: 'true', label: '拉流' },
{ value: 'false', label: '未拉流' },
]"
@change="loadData"
/>
<Button type="primary" @click="loadData">查询</Button>
</div>
<Button type="primary" @click="handleAdd">添加摄像头</Button>
</div>
<!-- 表格 -->
<Table
:columns="columns"
:data-source="cameraList"
@@ -181,7 +314,7 @@ onMounted(() => {
showTotal: (t: number) => `共 ${t} 条`,
onChange: handlePageChange,
}"
:scroll="{ x: 900 }"
:scroll="{ x: 1000 }"
row-key="id"
bordered
size="middle"
@@ -208,6 +341,7 @@ onMounted(() => {
>
ROI配置
</Button>
<Button size="small" @click="handleEdit(record)">编辑</Button>
<Button
:type="record.pulling ? 'default' : 'primary'"
:danger="record.pulling"
@@ -217,10 +351,106 @@ onMounted(() => {
{{ record.pulling ? '停止' : '拉流' }}
</Button>
<Button size="small" @click="handleExport(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 label="应用名" required>
<Input v-model:value="editForm.app" placeholder="如: live" />
</Form.Item>
<Form.Item label="流ID" required>
<Input
v-model:value="editForm.stream"
placeholder="唯一标识,如: camera01"
:disabled="!!editForm.id"
/>
</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>