Files
iot-device-management-frontend/apps/web-antd/src/views/aiot/device/roi/index.vue
16337 7e13025e3b 优化:ROI 编辑页删除边缘设备选择器
device_id 现在由后端从摄像头配置自动继承,前端不再需要手动选择。
2026-03-30 14:14:28 +08:00

702 lines
17 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 区域配置页面
*
* 功能摄像头截图展示、ROI 区域绘制(全图/自定义多边形、ROI 属性编辑、
* 算法绑定管理、配置推送到边缘端
* 后端WVP 视频平台 AiRoi / AiConfig API
*/
import type { AiotDeviceApi } from '#/api/aiot/device';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import {
Button,
Divider,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Switch,
Tag,
} from 'ant-design-vue';
import {
deleteRoi,
getCameraList,
getRoiByCameraId,
getRoiDetail,
getSnapUrl,
pushConfig,
saveRoi,
} from '#/api/aiot/device';
import { wvpRequestClient } from '#/api/aiot/request';
import RoiAlgorithmBind from './components/RoiAlgorithmBind.vue';
import RoiCanvas from './components/RoiCanvas.vue';
defineOptions({ name: 'AiotDeviceRoi' });
const route = useRoute();
const router = useRouter();
// ==================== 摄像头选择 ====================
const cameraCode = ref('');
const currentCamera = ref<AiotDeviceApi.Camera | null>(null);
const showCameraSelector = ref(false);
const cameraOptions = ref<
{ camera: AiotDeviceApi.Camera; label: string; value: string }[]
>([]);
const selectedCamera = ref<string | undefined>(undefined);
const cameraLoading = ref(false);
// ==================== ROI 状态 ====================
const drawMode = ref<null | string>(null);
const roiList = ref<AiotDeviceApi.Roi[]>([]);
const selectedRoiId = ref<null | string>(null);
const selectedRoiBindings = ref<AiotDeviceApi.RoiAlgoBinding[]>([]);
const snapUrl = ref('');
const snapOk = ref(false);
const panelVisible = ref(false);
const roiCanvasRef = ref<InstanceType<typeof RoiCanvas> | null>(null);
const selectedRoi = computed(() => {
if (!selectedRoiId.value) return null;
return roiList.value.find((r) => r.roiId === selectedRoiId.value) || null;
});
const isDrawing = computed(() => drawMode.value === 'polygon');
const polygonPointCount = computed(() => {
return roiCanvasRef.value?.polygonPoints?.length ?? 0;
});
// ==================== 初始化 ====================
onMounted(async () => {
const q = route.query;
if (q.cameraCode) {
cameraCode.value = String(q.cameraCode);
await loadCurrentCamera();
await buildSnapUrl();
loadRois();
} else {
showCameraSelector.value = true;
loadCameraOptions();
}
});
// ==================== 摄像头加载与选择 ====================
async function loadCurrentCamera() {
try {
const res = await getCameraList({ page: 1, count: 200 });
const list = res.list || [];
const camera = list.find((c: AiotDeviceApi.Camera) => c.cameraCode === cameraCode.value);
if (camera) {
currentCamera.value = camera;
}
} catch {
console.error('加载摄像头信息失败');
}
}
async function loadCameraOptions() {
cameraLoading.value = true;
try {
const res = await getCameraList({ page: 1, count: 200 });
const list = res.list || [];
cameraOptions.value = list.map((cam: AiotDeviceApi.Camera) => ({
value: cam.cameraCode || '',
label: `${cam.cameraName || cam.app || cam.stream}${cam.srcUrl ? ` (${cam.srcUrl})` : ''}`,
camera: cam,
}));
} catch {
message.error('加载摄像头列表失败');
} finally {
cameraLoading.value = false;
}
}
async function onCameraSelected(val: string) {
const opt = cameraOptions.value.find((o) => o.value === val);
if (opt) {
cameraCode.value = val;
currentCamera.value = opt.camera;
showCameraSelector.value = false;
await buildSnapUrl();
loadRois();
}
}
function goBack() {
router.push('/aiot/device/camera');
}
// ==================== ROI 类型标签 ====================
function getRoiTagColor(roi: AiotDeviceApi.Roi) {
if (roi.roiType === 'fullscreen') return 'orange';
if (roi.roiType === 'rectangle') return 'blue';
return 'green';
}
function getRoiTagLabel(roi: AiotDeviceApi.Roi) {
if (roi.roiType === 'fullscreen') return '全图';
if (roi.roiType === 'rectangle') return '矩形';
return '自定义';
}
// ==================== 截图 ====================
async function buildSnapUrl(force = false) {
if (cameraCode.value) {
snapUrl.value = await getSnapUrl(cameraCode.value, force);
}
}
async function refreshSnap() {
await buildSnapUrl(true);
}
function onSnapStatus(ok: boolean) {
snapOk.value = ok;
}
// ==================== ROI 数据加载 ====================
async function loadRois() {
try {
const data = await getRoiByCameraId(cameraCode.value);
roiList.value = Array.isArray(data) ? data : [];
if (selectedRoiId.value) {
loadRoiDetail();
}
} catch {
message.error('加载ROI失败');
}
}
async function loadRoiDetail() {
if (!selectedRoi.value?.id) return;
try {
const data = await getRoiDetail(selectedRoi.value.id);
if (data) {
selectedRoiBindings.value = data.algorithms || [];
}
} catch {
/* empty */
}
}
// ==================== 全图 ====================
function addFullscreen() {
const hasFullscreen = roiList.value.some((r) => r.roiType === 'fullscreen');
if (hasFullscreen) {
message.warning('已存在全图选区');
return;
}
Modal.confirm({
title: '新建全图选区',
content: '将创建覆盖整张图片的选区',
async onOk() {
const newRoi: Partial<AiotDeviceApi.Roi> = {
cameraId: cameraCode.value,
name: `全图-${roiList.value.length + 1}`,
roiType: 'fullscreen',
coordinates: JSON.stringify({ x: 0, y: 0, w: 1, h: 1 }),
color: '#FF0000',
priority: 0,
enabled: 1,
description: '',
deviceId: '',
};
try {
await saveRoi(newRoi);
message.success('全图选区已创建');
loadRois();
} catch {
message.error('保存失败');
}
},
});
}
// ==================== 自定义选区(多边形绘制) ====================
function startDraw(mode: string) {
drawMode.value = mode;
}
function finishDraw() {
roiCanvasRef.value?.finishPolygon();
}
function undoPoint() {
if (roiCanvasRef.value && roiCanvasRef.value.polygonPoints.length > 0) {
roiCanvasRef.value.polygonPoints.pop();
roiCanvasRef.value.redraw();
if (roiCanvasRef.value.polygonPoints.length > 0) {
roiCanvasRef.value.drawPolygonInProgress();
}
}
}
function cancelDraw() {
drawMode.value = null;
}
function onDrawCancelled() {
drawMode.value = null;
}
async function onRoiDrawn(data: { coordinates: string; roi_type: string }) {
drawMode.value = null;
const roiName = `ROI-${roiList.value.length + 1}`;
const newRoi: Partial<AiotDeviceApi.Roi> = {
cameraId: cameraCode.value,
name: roiName,
roiType: data.roi_type,
coordinates: data.coordinates,
color: '#FF0000',
priority: 0,
enabled: 1,
description: '',
deviceId: '',
};
try {
await saveRoi(newRoi);
message.success('选区已保存');
loadRois();
} catch {
message.error('保存失败');
}
}
// ==================== ROI 选择与面板 ====================
function onRoiSelected(roiId: null | string) {
selectedRoiId.value = roiId;
if (roiId) {
panelVisible.value = true;
loadRoiDetail();
}
}
function selectRoi(roi: AiotDeviceApi.Roi) {
selectedRoiId.value = roi.roiId || null;
loadRoiDetail();
}
function closePanel() {
panelVisible.value = false;
selectedRoiId.value = null;
selectedRoiBindings.value = [];
}
// ==================== ROI 编辑 ====================
async function updateRoiData(roi: AiotDeviceApi.Roi) {
try {
await saveRoi(roi);
loadRois();
} catch {
message.error('更新失败');
}
}
// ==================== ROI 删除 ====================
function onRoiDeleted(roiId: string) {
Modal.confirm({
title: '提示',
content: '确定删除该ROI关联的算法绑定也将删除。',
async onOk() {
doDeleteRoi(roiId);
},
});
}
function handleDeleteRoi(roi: AiotDeviceApi.Roi) {
Modal.confirm({
title: '提示',
content: '确定删除该ROI关联的算法绑定也将删除。',
async onOk() {
doDeleteRoi(roi.roiId!);
},
});
}
async function doDeleteRoi(roiId: string) {
try {
await deleteRoi(roiId);
message.success('已删除');
if (selectedRoiId.value === roiId) {
selectedRoiId.value = null;
selectedRoiBindings.value = [];
}
await loadRois();
if (roiList.value.length === 0) {
panelVisible.value = false;
}
} catch {
message.error('删除失败');
}
}
// ==================== 配置推送 ====================
function handlePush() {
Modal.confirm({
title: '推送配置',
content: '确定将此摄像头的所有ROI配置推送到边缘端',
async onOk() {
try {
await pushConfig(cameraCode.value);
message.success('推送成功');
} catch (err: any) {
message.error(err?.message || '推送失败请检查AI服务是否启用');
}
},
});
}
</script>
<template>
<Page auto-content-height>
<!-- 摄像头选择器 cameraId 参数时显示 -->
<div v-if="showCameraSelector" style="padding: 60px; text-align: center">
<h3 style="margin-bottom: 20px">请选择要配置ROI的摄像头</h3>
<Select
v-model:value="selectedCamera"
placeholder="选择摄像头"
style="width: 500px; max-width: 100%"
show-search
:loading="cameraLoading"
:options="cameraOptions"
@change="(val: any) => onCameraSelected(String(val))"
/>
<div style="margin-top: 16px; color: #999; font-size: 13px">
或从摄像头管理页面点击ROI配置进入
</div>
</div>
<!-- ROI 配置主界面 -->
<div v-else class="roi-config-page">
<!-- 顶部操作栏 -->
<div class="page-header">
<div class="header-left">
<Button size="small" @click="goBack">返回</Button>
<h3>{{ currentCamera?.cameraName || currentCamera?.app || cameraCode }} - ROI配置</h3>
</div>
<div class="header-right">
<!-- 默认工具栏 -->
<template v-if="!isDrawing">
<Button size="small" type="primary" @click="addFullscreen">
全图
</Button>
<Button size="small" type="primary" @click="startDraw('polygon')">
自定义选区
</Button>
<Button size="small" @click="refreshSnap">刷新截图</Button>
<Button size="small" type="default" @click="handlePush">
推送到边缘端
</Button>
</template>
<!-- 绘制中工具栏 -->
<template v-else>
<Button size="small" type="primary" :disabled="polygonPointCount < 3" @click="finishDraw">
完成选区
</Button>
<Button size="small" :disabled="polygonPointCount === 0" @click="undoPoint">
撤销上一点
</Button>
<Button size="small" danger @click="cancelDraw">
取消绘制
</Button>
</template>
</div>
</div>
<!-- 主内容区左侧画布 + 右侧面板 -->
<div class="main-content">
<!-- 画布区域 -->
<div :class="['canvas-panel', { 'panel-open': panelVisible }]">
<RoiCanvas
ref="roiCanvasRef"
:rois="roiList"
:draw-mode="drawMode"
:selected-roi-id="selectedRoiId"
:snap-url="snapUrl"
@roi-drawn="onRoiDrawn"
@roi-selected="onRoiSelected"
@roi-deleted="onRoiDeleted"
@draw-cancelled="onDrawCancelled"
@snap-status="onSnapStatus"
/>
</div>
<!-- 右侧面板 -->
<transition name="slide-panel">
<div v-if="panelVisible" class="side-panel">
<div class="panel-close">
<Button size="small" shape="circle" @click="closePanel">
</Button>
</div>
<!-- ROI 列表 -->
<div class="section-header">
<span>ROI列表 ({{ roiList.length }})</span>
</div>
<div v-if="roiList.length === 0" class="empty-tip">
暂无ROI请使用上方按钮添加
</div>
<div
v-for="roi in roiList"
:key="roi.roiId"
:class="['roi-item', { active: selectedRoiId === roi.roiId }]"
@click="selectRoi(roi)"
>
<div class="roi-item-header">
<span
class="roi-color"
:style="{ background: roi.color || '#FF0000' }"
/>
<span class="roi-name">{{ roi.name || '未命名' }}</span>
<Tag
:color="getRoiTagColor(roi)"
style="margin-right: 0"
>
{{ getRoiTagLabel(roi) }}
</Tag>
<Switch
:checked="roi.enabled === 1"
size="small"
style="margin-left: auto"
@change="
(val: string | number | boolean) => {
roi.enabled = val ? 1 : 0;
updateRoiData(roi);
}
"
@click.stop
/>
<Button
size="small"
type="text"
danger
style="margin-left: 4px"
@click.stop="handleDeleteRoi(roi)"
>
删除
</Button>
</div>
</div>
<Divider />
<!-- ROI 属性编辑 -->
<div v-if="selectedRoi" class="roi-detail-section">
<h4>ROI属性</h4>
<Form
layout="horizontal"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
size="small"
>
<Form.Item label="名称">
<Input
v-model:value="selectedRoi.name"
@blur="updateRoiData(selectedRoi!)"
/>
</Form.Item>
<Form.Item label="颜色">
<Input
v-model:value="selectedRoi.color"
type="color"
style="width: 60px; padding: 2px"
@change="updateRoiData(selectedRoi!)"
/>
</Form.Item>
<Form.Item label="优先级">
<InputNumber
v-model:value="selectedRoi.priority"
:min="0"
:max="100"
@change="updateRoiData(selectedRoi!)"
/>
</Form.Item>
<Form.Item label="描述">
<Input.TextArea
v-model:value="selectedRoi.description"
:rows="2"
@blur="updateRoiData(selectedRoi!)"
/>
</Form.Item>
</Form>
<Divider />
<!-- 算法绑定管理 -->
<RoiAlgorithmBind
:roi-id="selectedRoi.roiId || ''"
:bindings="selectedRoiBindings"
:snap-ok="snapOk"
@changed="loadRoiDetail"
/>
</div>
<div v-else class="empty-tip" style="margin-top: 20px">
点击左侧ROI区域或列表项查看详情
</div>
</div>
</transition>
</div>
</div>
</Page>
</template>
<style scoped>
.roi-config-page {
padding: 12px;
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-left h3 {
margin: 0;
font-size: 15px;
}
.header-right {
display: flex;
gap: 8px;
}
.main-content {
display: flex;
flex: 1;
gap: 0;
overflow: hidden;
position: relative;
}
.canvas-panel {
flex: 1;
background: #000;
border-radius: 4px;
overflow: hidden;
transition: flex 0.3s ease;
}
.canvas-panel.panel-open {
flex: 6;
}
.side-panel {
flex: 4;
overflow-y: auto;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 0 4px 4px 0;
padding: 12px;
margin-left: 1px;
}
.panel-close {
text-align: right;
margin-bottom: 8px;
}
/* 面板滑入动画 */
.slide-panel-enter-active,
.slide-panel-leave-active {
transition: all 0.3s ease;
}
.slide-panel-enter-from,
.slide-panel-leave-to {
flex: 0 !important;
padding: 0 !important;
overflow: hidden;
opacity: 0;
}
.section-header {
font-weight: bold;
font-size: 14px;
margin-bottom: 10px;
}
.empty-tip {
color: #999;
text-align: center;
padding: 20px 0;
font-size: 13px;
}
.roi-item {
padding: 8px 10px;
border: 1px solid #f0f0f0;
border-radius: 4px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.2s;
}
.roi-item:hover {
border-color: #1677ff;
}
.roi-item.active {
border-color: #1677ff;
background: #e6f4ff;
}
.roi-item-header {
display: flex;
align-items: center;
gap: 6px;
}
.roi-color {
width: 12px;
height: 12px;
border-radius: 2px;
display: inline-block;
}
.roi-name {
font-size: 13px;
font-weight: 500;
}
.roi-detail-section h4 {
margin: 0 0 10px;
font-size: 14px;
}
</style>