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:
@@ -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 列表(分页) */
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user