refactor(aiot-device): 规范整理摄像头管理和 ROI 配置页面代码
- 摄像头管理:代码按功能分区(列表状态/编辑弹窗/数据加载/增删改/拉流控制/配置导出) - ROI 配置:代码按功能分区(摄像头选择/截图/数据加载/绘制/选择/编辑/删除/推送) - getSnapUrl 适配 async 调用,截图 URL 携带 access-token 认证参数 - 统一中文注释风格 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 摄像头管理页面
|
||||
*
|
||||
* 功能:摄像头列表展示、搜索筛选、新增/编辑/删除、拉流控制、ROI 配置跳转、配置导出
|
||||
* 后端:WVP 视频平台 StreamProxy API
|
||||
*/
|
||||
import type { AiotDeviceApi } from '#/api/aiot/device';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
@@ -35,6 +41,8 @@ defineOptions({ name: 'AiotDeviceCamera' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// ==================== 列表状态 ====================
|
||||
|
||||
const loading = ref(false);
|
||||
const cameraList = ref<AiotDeviceApi.Camera[]>([]);
|
||||
const roiCounts = ref<Record<string, number>>({});
|
||||
@@ -44,11 +52,21 @@ const total = ref(0);
|
||||
const searchQuery = ref('');
|
||||
const searchPulling = ref<string | undefined>(undefined);
|
||||
|
||||
// 编辑弹窗
|
||||
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: 80, align: 'center' as const },
|
||||
{ title: '操作', key: 'actions', width: 340, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
// ==================== 编辑弹窗状态 ====================
|
||||
|
||||
const editModalOpen = ref(false);
|
||||
const editModalTitle = ref('添加摄像头');
|
||||
const saving = ref(false);
|
||||
const mediaServerOptions = ref<{ value: string; label: string }[]>([]);
|
||||
const mediaServerOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const editForm = reactive<Partial<AiotDeviceApi.Camera>>({
|
||||
id: undefined,
|
||||
type: 'default',
|
||||
@@ -65,14 +83,7 @@ const editForm = reactive<Partial<AiotDeviceApi.Camera>>({
|
||||
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: 340, fixed: 'right' as const },
|
||||
];
|
||||
// ==================== 数据加载 ====================
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
@@ -90,14 +101,14 @@ async function loadData() {
|
||||
total.value = res.total || 0;
|
||||
loadRoiCounts();
|
||||
} catch {
|
||||
message.error('加载失败');
|
||||
message.error('加载摄像头列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadRoiCounts() {
|
||||
cameraList.value.forEach((cam) => {
|
||||
for (const cam of cameraList.value) {
|
||||
const cameraId = `${cam.app}/${cam.stream}`;
|
||||
getRoiByCameraId(cameraId)
|
||||
.then((data: any) => {
|
||||
@@ -105,7 +116,7 @@ function loadRoiCounts() {
|
||||
roiCounts.value = { ...roiCounts.value, [cameraId]: items.length };
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMediaServers() {
|
||||
@@ -124,20 +135,24 @@ async function loadMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 新增 / 编辑 ====================
|
||||
|
||||
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 = '';
|
||||
Object.assign(editForm, {
|
||||
id: undefined,
|
||||
type: 'default',
|
||||
app: 'live',
|
||||
stream: '',
|
||||
srcUrl: '',
|
||||
timeout: 15,
|
||||
rtspType: '0',
|
||||
enable: true,
|
||||
enableAudio: false,
|
||||
enableMp4: false,
|
||||
enableDisableNoneReader: true,
|
||||
relatesMediaServerId: '',
|
||||
ffmpegCmdKey: '',
|
||||
});
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
@@ -148,19 +163,21 @@ function handleAdd() {
|
||||
}
|
||||
|
||||
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 || '';
|
||||
Object.assign(editForm, {
|
||||
id: row.id,
|
||||
type: row.type || 'default',
|
||||
app: row.app || '',
|
||||
stream: row.stream || '',
|
||||
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();
|
||||
@@ -192,6 +209,8 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 删除 ====================
|
||||
|
||||
function handleDelete(row: AiotDeviceApi.Camera) {
|
||||
Modal.confirm({
|
||||
title: '删除确认',
|
||||
@@ -209,6 +228,25 @@ function handleDelete(row: AiotDeviceApi.Camera) {
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 拉流控制 ====================
|
||||
|
||||
async function toggleStream(row: AiotDeviceApi.Camera) {
|
||||
try {
|
||||
if (row.pulling) {
|
||||
await stopCamera(row.id!);
|
||||
message.success('已停止拉流');
|
||||
} else {
|
||||
await startCamera(row.id!);
|
||||
message.success('开始拉流');
|
||||
}
|
||||
loadData();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ROI 配置跳转 ====================
|
||||
|
||||
function handleRoiConfig(row: AiotDeviceApi.Camera) {
|
||||
router.push({
|
||||
path: '/aiot/device/roi',
|
||||
@@ -221,20 +259,7 @@ function handleRoiConfig(row: AiotDeviceApi.Camera) {
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleStream(row: AiotDeviceApi.Camera) {
|
||||
try {
|
||||
if (row.pulling) {
|
||||
await stopCamera(row.id!);
|
||||
message.success('已停止');
|
||||
} else {
|
||||
await startCamera(row.id!);
|
||||
message.success('开始拉流');
|
||||
}
|
||||
loadData();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
}
|
||||
// ==================== 配置导出 ====================
|
||||
|
||||
async function handleExport(row: AiotDeviceApi.Camera) {
|
||||
const cameraId = `${row.app}/${row.stream}`;
|
||||
@@ -253,12 +278,16 @@ async function handleExport(row: AiotDeviceApi.Camera) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 分页 ====================
|
||||
|
||||
function handlePageChange(p: number, size: number) {
|
||||
page.value = p;
|
||||
pageSize.value = size;
|
||||
loadData();
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
@@ -351,11 +380,7 @@ onMounted(() => {
|
||||
{{ record.pulling ? '停止' : '拉流' }}
|
||||
</Button>
|
||||
<Button size="small" @click="handleExport(record)">导出</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
@click="handleDelete(record)"
|
||||
>
|
||||
<Button size="small" danger @click="handleDelete(record)">
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -364,7 +389,7 @@ onMounted(() => {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑弹窗 -->
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<Modal
|
||||
v-model:open="editModalOpen"
|
||||
:title="editModalTitle"
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ROI 区域配置页面
|
||||
*
|
||||
* 功能:摄像头截图展示、ROI 区域绘制(矩形/多边形)、ROI 属性编辑、
|
||||
* 算法绑定管理、配置推送到边缘端
|
||||
* 后端:WVP 视频平台 AiRoi / AiConfig API
|
||||
*/
|
||||
import type { AiotDeviceApi } from '#/api/aiot/device';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
@@ -37,23 +44,24 @@ defineOptions({ name: 'AiotDeviceRoi' });
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Route params
|
||||
// ==================== 摄像头选择 ====================
|
||||
|
||||
const cameraId = ref('');
|
||||
const app = ref('');
|
||||
const stream = ref('');
|
||||
|
||||
// Camera selector (when no cameraId in route)
|
||||
const showCameraSelector = ref(false);
|
||||
const cameraOptions = ref<
|
||||
{ value: string; label: string; camera: AiotDeviceApi.Camera }[]
|
||||
{ camera: AiotDeviceApi.Camera; label: string; value: string }[]
|
||||
>([]);
|
||||
const selectedCamera = ref<string | undefined>(undefined);
|
||||
const cameraLoading = ref(false);
|
||||
|
||||
// ROI state
|
||||
const drawMode = ref<string | null>(null);
|
||||
// ==================== ROI 状态 ====================
|
||||
|
||||
const drawMode = ref<null | string>(null);
|
||||
const roiList = ref<AiotDeviceApi.Roi[]>([]);
|
||||
const selectedRoiId = ref<string | null>(null);
|
||||
const selectedRoiId = ref<null | string>(null);
|
||||
const selectedRoiBindings = ref<AiotDeviceApi.RoiAlgoBinding[]>([]);
|
||||
const snapUrl = ref('');
|
||||
|
||||
@@ -62,13 +70,15 @@ const selectedRoi = computed(() => {
|
||||
return roiList.value.find((r) => r.roiId === selectedRoiId.value) || null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(async () => {
|
||||
const q = route.query;
|
||||
if (q.cameraId) {
|
||||
cameraId.value = String(q.cameraId);
|
||||
app.value = String(q.app || '');
|
||||
stream.value = String(q.stream || '');
|
||||
buildSnapUrl();
|
||||
await buildSnapUrl();
|
||||
loadRois();
|
||||
} else {
|
||||
showCameraSelector.value = true;
|
||||
@@ -76,6 +86,8 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 摄像头加载与选择 ====================
|
||||
|
||||
async function loadCameraOptions() {
|
||||
cameraLoading.value = true;
|
||||
try {
|
||||
@@ -93,14 +105,14 @@ async function loadCameraOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
function onCameraSelected(val: string) {
|
||||
async function onCameraSelected(val: string) {
|
||||
const opt = cameraOptions.value.find((o) => o.value === val);
|
||||
if (opt) {
|
||||
cameraId.value = val;
|
||||
app.value = opt.camera.app || '';
|
||||
stream.value = opt.camera.stream || '';
|
||||
showCameraSelector.value = false;
|
||||
buildSnapUrl();
|
||||
await buildSnapUrl();
|
||||
loadRois();
|
||||
}
|
||||
}
|
||||
@@ -109,16 +121,20 @@ function goBack() {
|
||||
router.push('/aiot/device/camera');
|
||||
}
|
||||
|
||||
function buildSnapUrl() {
|
||||
// ==================== 截图 ====================
|
||||
|
||||
async function buildSnapUrl() {
|
||||
if (app.value && stream.value) {
|
||||
snapUrl.value = getSnapUrl(app.value, stream.value);
|
||||
snapUrl.value = await getSnapUrl(app.value, stream.value);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSnap() {
|
||||
buildSnapUrl();
|
||||
async function refreshSnap() {
|
||||
await buildSnapUrl();
|
||||
}
|
||||
|
||||
// ==================== ROI 数据加载 ====================
|
||||
|
||||
async function loadRois() {
|
||||
try {
|
||||
const data = await getRoiByCameraId(cameraId.value);
|
||||
@@ -143,11 +159,13 @@ async function loadRoiDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ROI 绘制 ====================
|
||||
|
||||
function startDraw(mode: string) {
|
||||
drawMode.value = mode;
|
||||
}
|
||||
|
||||
async function onRoiDrawn(data: { roi_type: string; coordinates: string }) {
|
||||
async function onRoiDrawn(data: { coordinates: string; roi_type: string }) {
|
||||
drawMode.value = null;
|
||||
const newRoi: Partial<AiotDeviceApi.Roi> = {
|
||||
cameraId: cameraId.value,
|
||||
@@ -168,7 +186,9 @@ async function onRoiDrawn(data: { roi_type: string; coordinates: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function onRoiSelected(roiId: string | null) {
|
||||
// ==================== ROI 选择 ====================
|
||||
|
||||
function onRoiSelected(roiId: null | string) {
|
||||
selectedRoiId.value = roiId;
|
||||
if (roiId) {
|
||||
loadRoiDetail();
|
||||
@@ -182,6 +202,19 @@ function selectRoi(roi: AiotDeviceApi.Roi) {
|
||||
loadRoiDetail();
|
||||
}
|
||||
|
||||
// ==================== ROI 编辑 ====================
|
||||
|
||||
async function updateRoiData(roi: AiotDeviceApi.Roi) {
|
||||
try {
|
||||
await saveRoi(roi);
|
||||
loadRois();
|
||||
} catch {
|
||||
message.error('更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ROI 删除 ====================
|
||||
|
||||
function onRoiDeleted(roiId: string) {
|
||||
doDeleteRoi(roiId);
|
||||
}
|
||||
@@ -210,14 +243,7 @@ async function doDeleteRoi(roiId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRoiData(roi: AiotDeviceApi.Roi) {
|
||||
try {
|
||||
await saveRoi(roi);
|
||||
loadRois();
|
||||
} catch {
|
||||
message.error('更新失败');
|
||||
}
|
||||
}
|
||||
// ==================== 配置推送 ====================
|
||||
|
||||
function handlePush() {
|
||||
Modal.confirm({
|
||||
@@ -237,7 +263,7 @@ function handlePush() {
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<!-- Camera selector when no cameraId -->
|
||||
<!-- 摄像头选择器(无 cameraId 参数时显示) -->
|
||||
<div v-if="showCameraSelector" style="padding: 60px; text-align: center">
|
||||
<h3 style="margin-bottom: 20px">请选择要配置ROI的摄像头</h3>
|
||||
<Select
|
||||
@@ -254,9 +280,9 @@ function handlePush() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ROI Config Page -->
|
||||
<!-- ROI 配置主界面 -->
|
||||
<div v-else class="roi-config-page">
|
||||
<!-- Header -->
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<Button size="small" @click="goBack">返回</Button>
|
||||
@@ -279,7 +305,7 @@ function handlePush() {
|
||||
>
|
||||
画多边形
|
||||
</Button>
|
||||
<Button size="small" @click="drawMode = null" :disabled="!drawMode">
|
||||
<Button size="small" :disabled="!drawMode" @click="drawMode = null">
|
||||
取消绘制
|
||||
</Button>
|
||||
<Button size="small" @click="refreshSnap">刷新截图</Button>
|
||||
@@ -289,9 +315,9 @@ function handlePush() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<!-- 主内容区:左侧画布 + 右侧面板 -->
|
||||
<div class="main-content">
|
||||
<!-- Canvas Panel -->
|
||||
<!-- 画布区域 -->
|
||||
<div class="canvas-panel">
|
||||
<RoiCanvas
|
||||
:rois="roiList"
|
||||
@@ -304,9 +330,9 @@ function handlePush() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Side Panel -->
|
||||
<!-- 右侧面板 -->
|
||||
<div class="side-panel">
|
||||
<!-- ROI List -->
|
||||
<!-- ROI 列表 -->
|
||||
<div class="section-header">
|
||||
<span>ROI列表 ({{ roiList.length }})</span>
|
||||
</div>
|
||||
@@ -357,7 +383,7 @@ function handlePush() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<!-- ROI Detail -->
|
||||
<!-- ROI 属性编辑 -->
|
||||
<div v-if="selectedRoi" class="roi-detail-section">
|
||||
<h4>ROI属性</h4>
|
||||
<Form
|
||||
@@ -398,6 +424,7 @@ function handlePush() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<!-- 算法绑定管理 -->
|
||||
<RoiAlgorithmBind
|
||||
:roi-id="selectedRoi.roiId || ''"
|
||||
:bindings="selectedRoiBindings"
|
||||
|
||||
Reference in New Issue
Block a user