feat(aiot-device): 重写摄像头管理页面,迁移 WVP 功能

从 WVP 的 cameraConfig/index.vue 迁移至 Vue3 + Ant Design:
- 搜索过滤:支持名称/地址搜索和拉流状态筛选
- 摄像头列表:显示应用名、流ID、拉流地址、状态
- ROI 数量徽标:异步加载每个摄像头的 ROI 数量
- 操作按钮:ROI 配置(跳转)、拉流/停止、导出配置
- 配置导出:JSON 文件下载

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 23:24:27 +08:00
parent fc56ea0f75
commit b72839622e

View File

@@ -1,110 +1,226 @@
<script setup lang="ts">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiotDeviceApi } from '#/api/aiot/device';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Tag } from 'ant-design-vue';
import {
Badge,
Button,
Input,
message,
Select,
Space,
Table,
Tag,
} from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getChannelPage } from '#/api/aiot/device';
import {
exportConfig,
getCameraList,
getRoiByCameraId,
startCamera,
stopCamera,
} from '#/api/aiot/device';
defineOptions({ name: 'AiotDeviceCamera' });
const router = useRouter();
/** 配置 ROI */
function handleConfigRoi(row: AiotDeviceApi.Channel) {
router.push({
path: '/aiot/device/roi',
query: { channelId: row.channelId },
const loading = ref(false);
const cameraList = ref<AiotDeviceApi.Camera[]>([]);
const roiCounts = ref<Record<string, number>>({});
const page = ref(1);
const pageSize = ref(15);
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: 100 },
{ title: '操作', key: 'actions', width: 260, fixed: 'right' as const },
];
async function loadData() {
loading.value = true;
try {
const res = await getCameraList({
page: page.value,
count: pageSize.value,
query: searchQuery.value || undefined,
pulling:
searchPulling.value === undefined
? undefined
: searchPulling.value === 'true',
});
cameraList.value = res.list || [];
total.value = res.total || 0;
loadRoiCounts();
} catch {
message.error('加载失败');
} finally {
loading.value = false;
}
}
function loadRoiCounts() {
cameraList.value.forEach((cam) => {
const cameraId = `${cam.app}/${cam.stream}`;
getRoiByCameraId(cameraId)
.then((data: any) => {
const items = Array.isArray(data) ? data : [];
roiCounts.value = { ...roiCounts.value, [cameraId]: items.length };
})
.catch(() => {});
});
}
const [Grid] = useVbenVxeGrid({
gridOptions: {
columns: [
{
field: 'channelId',
title: '通道编号',
minWidth: 180,
},
{
field: 'name',
title: '通道名称',
minWidth: 150,
},
{
field: 'manufacturer',
title: '厂商',
minWidth: 100,
},
{
field: 'status',
title: '状态',
minWidth: 80,
slots: { default: 'status' },
},
{
field: 'ptztypeText',
title: 'PTZ 类型',
minWidth: 100,
},
{
title: '操作',
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
],
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }) => {
return await getChannelPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
});
},
},
function handleRoiConfig(row: AiotDeviceApi.Camera) {
router.push({
path: '/aiot/device/roi',
query: {
cameraId: `${row.app}/${row.stream}`,
app: row.app,
stream: row.stream,
srcUrl: row.srcUrl,
},
rowConfig: {
keyField: 'channelId',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
} as VxeTableGridOptions<AiotDeviceApi.Channel>,
});
}
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}`;
try {
const data = await exportConfig(cameraId);
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `config_${row.app}_${row.stream}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
message.error('导出失败');
}
}
function handlePageChange(p: number, size: number) {
page.value = p;
pageSize.value = size;
loadData();
}
onMounted(() => {
loadData();
});
</script>
<template>
<Page auto-content-height>
<Grid table-title="摄像头管理">
<!-- 状态列 -->
<template #status="{ row }">
<Tag :color="row.status === 'ON' ? 'success' : 'default'">
{{ row.status === 'ON' ? '在线' : '离线' }}
</Tag>
</template>
<!-- 操作列 -->
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '配置ROI',
type: 'link',
icon: ACTION_ICON.EDIT,
onClick: handleConfigRoi.bind(null, row),
},
]"
<div style="padding: 16px">
<div
style="
display: flex;
align-items: center;
margin-bottom: 16px;
gap: 8px;
"
>
<Input
v-model:value="searchQuery"
placeholder="搜索摄像头名称/地址"
style="width: 250px"
allow-clear
@press-enter="loadData"
/>
</template>
</Grid>
<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>
<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: 900 }"
row-key="id"
bordered
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'pulling'">
<Tag :color="record.pulling ? 'success' : 'default'">
{{ record.pulling ? '拉流中' : '未拉流' }}
</Tag>
</template>
<template v-else-if="column.key === 'roiCount'">
<Badge
:count="roiCounts[`${record.app}/${record.stream}`] ?? 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
:type="record.pulling ? 'default' : 'primary'"
:danger="record.pulling"
size="small"
@click="toggleStream(record)"
>
{{ record.pulling ? '停止' : '拉流' }}
</Button>
<Button size="small" @click="handleExport(record)">导出</Button>
</Space>
</template>
</template>
</Table>
</div>
</Page>
</template>