feat(aiot): 添加 aiot 全部业务视图页面
- alarm/list:告警列表(搜索、详情弹窗、处理/忽略操作) - alarm/summary:摄像头告警汇总(跳转到对应摄像头告警列表) - device/camera:摄像头通道管理(跳转配置 ROI) - device/roi:ROI 区域配置列表(删除操作) - video/live:实时视频播放(输入设备/通道 ID 播放) - edge/node:边缘节点管理(状态徽标、运行时长、帧数统计) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
180
apps/web-antd/src/views/aiot/alarm/list/data.ts
Normal file
180
apps/web-antd/src/views/aiot/alarm/list/data.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 告警类型选项 */
|
||||
export const ALERT_TYPE_OPTIONS = [
|
||||
{ label: '离岗检测', value: 'leave_post' },
|
||||
{ label: '周界入侵', value: 'intrusion' },
|
||||
];
|
||||
|
||||
/** 告警状态选项 */
|
||||
export const ALERT_STATUS_OPTIONS = [
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '处理中', value: 'processing' },
|
||||
{ label: '已处理', value: 'handled' },
|
||||
{ label: '已忽略', value: 'ignored' },
|
||||
];
|
||||
|
||||
/** 告警级别选项 */
|
||||
export const ALERT_LEVEL_OPTIONS = [
|
||||
{ label: '低', value: 'low' },
|
||||
{ label: '中', value: 'medium' },
|
||||
{ label: '高', value: 'high' },
|
||||
];
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'cameraId',
|
||||
label: '摄像头',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入摄像头ID',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'alertType',
|
||||
label: '告警类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: ALERT_TYPE_OPTIONS,
|
||||
placeholder: '请选择告警类型',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '处理状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: ALERT_STATUS_OPTIONS,
|
||||
placeholder: '请选择处理状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'alertNo',
|
||||
title: '告警编号',
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
field: 'cameraId',
|
||||
title: '摄像头',
|
||||
minWidth: 120,
|
||||
slots: { default: 'camera' },
|
||||
},
|
||||
{
|
||||
field: 'alertType',
|
||||
title: '告警类型',
|
||||
minWidth: 100,
|
||||
slots: { default: 'alertType' },
|
||||
},
|
||||
{
|
||||
field: 'confidence',
|
||||
title: '置信度',
|
||||
minWidth: 80,
|
||||
slots: { default: 'confidence' },
|
||||
},
|
||||
{
|
||||
field: 'durationMinutes',
|
||||
title: '持续时长',
|
||||
minWidth: 100,
|
||||
slots: { default: 'duration' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
minWidth: 90,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{
|
||||
field: 'level',
|
||||
title: '级别',
|
||||
minWidth: 80,
|
||||
slots: { default: 'level' },
|
||||
},
|
||||
{
|
||||
field: 'triggerTime',
|
||||
title: '触发时间',
|
||||
minWidth: 170,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
title: '创建时间',
|
||||
minWidth: 170,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 摄像头告警汇总列表字段 */
|
||||
export function useCameraSummaryColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'cameraId',
|
||||
title: '摄像头ID',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'cameraName',
|
||||
title: '摄像头名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'totalCount',
|
||||
title: '告警总数',
|
||||
minWidth: 100,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'pendingCount',
|
||||
title: '待处理',
|
||||
minWidth: 100,
|
||||
slots: { default: 'pendingCount' },
|
||||
},
|
||||
{
|
||||
field: 'lastAlertTypeName',
|
||||
title: '最近告警类型',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'lastAlertTime',
|
||||
title: '最近告警时间',
|
||||
minWidth: 170,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
368
apps/web-antd/src/views/aiot/alarm/list/index.vue
Normal file
368
apps/web-antd/src/views/aiot/alarm/list/index.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AiotAlarmApi } from '#/api/aiot/alarm';
|
||||
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message, Modal, Tag } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAlert, getAlertPage, handleAlert } from '#/api/aiot/alarm';
|
||||
|
||||
import {
|
||||
ALERT_LEVEL_OPTIONS,
|
||||
ALERT_STATUS_OPTIONS,
|
||||
ALERT_TYPE_OPTIONS,
|
||||
useGridColumns,
|
||||
useGridFormSchema,
|
||||
} from './data';
|
||||
|
||||
defineOptions({ name: 'AiotAlarmList' });
|
||||
|
||||
const currentAlert = ref<AiotAlarmApi.Alert | null>(null);
|
||||
const detailVisible = ref(false);
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 获取告警类型文本 */
|
||||
function getAlertTypeText(type?: string) {
|
||||
const option = ALERT_TYPE_OPTIONS.find((o) => o.value === type);
|
||||
return option?.label || type || '-';
|
||||
}
|
||||
|
||||
/** 获取告警类型颜色 */
|
||||
function getAlertTypeColor(type?: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
leave_post: 'orange',
|
||||
intrusion: 'red',
|
||||
crowd_detection: 'purple',
|
||||
fire_detection: 'volcano',
|
||||
smoke_detection: 'magenta',
|
||||
fall_detection: 'cyan',
|
||||
};
|
||||
return type ? colorMap[type] || 'default' : 'default';
|
||||
}
|
||||
|
||||
/** 获取状态文本 */
|
||||
function getStatusText(status?: string) {
|
||||
const option = ALERT_STATUS_OPTIONS.find((o) => o.value === status);
|
||||
return option?.label || status || '-';
|
||||
}
|
||||
|
||||
/** 获取状态颜色 */
|
||||
function getStatusColor(status?: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
pending: 'warning',
|
||||
processing: 'processing',
|
||||
handled: 'success',
|
||||
ignored: 'default',
|
||||
};
|
||||
return status ? colorMap[status] || 'default' : 'default';
|
||||
}
|
||||
|
||||
/** 获取级别文本 */
|
||||
function getLevelText(level?: string) {
|
||||
const option = ALERT_LEVEL_OPTIONS.find((o) => o.value === level);
|
||||
return option?.label || level || '-';
|
||||
}
|
||||
|
||||
/** 获取级别颜色 */
|
||||
function getLevelColor(level?: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
low: 'green',
|
||||
medium: 'orange',
|
||||
high: 'red',
|
||||
};
|
||||
return level ? colorMap[level] || 'default' : 'default';
|
||||
}
|
||||
|
||||
/** 查看告警详情 */
|
||||
async function handleView(row: AiotAlarmApi.Alert) {
|
||||
try {
|
||||
const alert = await getAlert(row.id as number);
|
||||
currentAlert.value = alert;
|
||||
detailVisible.value = true;
|
||||
} catch (error) {
|
||||
console.error('获取告警详情失败:', error);
|
||||
message.error('获取告警详情失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理告警 */
|
||||
async function handleProcess(row: AiotAlarmApi.Alert, status: string) {
|
||||
const statusText = status === 'handled' ? '处理' : '忽略';
|
||||
Modal.confirm({
|
||||
title: `${statusText}告警`,
|
||||
content: h('div', [
|
||||
h('p', `确定要${statusText}该告警吗?`),
|
||||
h('p', { class: 'text-gray-500 text-sm' }, `告警编号:${row.alertNo}`),
|
||||
h('textarea', {
|
||||
id: 'processRemark',
|
||||
class: 'ant-input mt-2',
|
||||
rows: 3,
|
||||
placeholder: '请输入处理备注(可选)',
|
||||
}),
|
||||
]),
|
||||
async onOk() {
|
||||
const textarea = document.querySelector(
|
||||
'#processRemark',
|
||||
) as HTMLTextAreaElement;
|
||||
const remark = textarea?.value || '';
|
||||
|
||||
const hideLoading = message.loading({
|
||||
content: '正在处理...',
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await handleAlert(row.id as number, status, remark);
|
||||
message.success(`${statusText}成功`);
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
console.error(`${statusText}失败:`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
let startTime: string | undefined;
|
||||
let endTime: string | undefined;
|
||||
if (formValues.createTime && formValues.createTime.length === 2) {
|
||||
startTime = formValues.createTime[0];
|
||||
endTime = formValues.createTime[1];
|
||||
}
|
||||
|
||||
return await getAlertPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
cameraId: formValues.cameraId,
|
||||
alertType: formValues.alertType,
|
||||
status: formValues.status,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AiotAlarmApi.Alert>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid table-title="AI告警列表">
|
||||
<!-- 摄像头列 -->
|
||||
<template #camera="{ row }">
|
||||
<span class="font-medium">{{ row.cameraId || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 告警类型列 -->
|
||||
<template #alertType="{ row }">
|
||||
<Tag :color="getAlertTypeColor(row.alertType)">
|
||||
{{ getAlertTypeText(row.alertType) }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 置信度列 -->
|
||||
<template #confidence="{ row }">
|
||||
<span v-if="row.confidence != null">{{ row.confidence }}%</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 持续时长列 -->
|
||||
<template #duration="{ row }">
|
||||
<span v-if="row.durationMinutes != null"
|
||||
>{{ row.durationMinutes }} 分钟</span
|
||||
>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template #status="{ row }">
|
||||
<Tag :color="getStatusColor(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 级别列 -->
|
||||
<template #level="{ row }">
|
||||
<Tag :color="getLevelColor(row.level)">
|
||||
{{ getLevelText(row.level) }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '查看',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.VIEW,
|
||||
onClick: handleView.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '处理',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleProcess.bind(null, row, 'handled'),
|
||||
ifShow: row.status === 'pending',
|
||||
},
|
||||
{
|
||||
label: '忽略',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
onClick: handleProcess.bind(null, row, 'ignored'),
|
||||
ifShow: row.status === 'pending',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 告警详情弹窗 -->
|
||||
<Modal
|
||||
v-model:open="detailVisible"
|
||||
title="告警详情"
|
||||
width="700px"
|
||||
:footer="null"
|
||||
>
|
||||
<div v-if="currentAlert" class="space-y-4">
|
||||
<!-- 基本信息 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-gray-500">告警编号:</span>
|
||||
<span class="font-medium">{{ currentAlert.alertNo }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">摄像头:</span>
|
||||
<span class="font-medium">{{ currentAlert.cameraId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">告警类型:</span>
|
||||
<Tag :color="getAlertTypeColor(currentAlert.alertType)">
|
||||
{{ getAlertTypeText(currentAlert.alertType) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">告警级别:</span>
|
||||
<Tag :color="getLevelColor(currentAlert.level)">
|
||||
{{ getLevelText(currentAlert.level) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处理状态:</span>
|
||||
<Tag :color="getStatusColor(currentAlert.status)">
|
||||
{{ getStatusText(currentAlert.status) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">置信度:</span>
|
||||
<span>{{
|
||||
currentAlert.confidence != null
|
||||
? `${currentAlert.confidence}%`
|
||||
: '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">持续时长:</span>
|
||||
<span>{{
|
||||
currentAlert.durationMinutes != null
|
||||
? `${currentAlert.durationMinutes} 分钟`
|
||||
: '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">触发时间:</span>
|
||||
<span>{{ currentAlert.triggerTime || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 告警消息 -->
|
||||
<div v-if="currentAlert.message">
|
||||
<span class="text-gray-500">告警消息:</span>
|
||||
<div class="mt-1 bg-gray-50 p-3 rounded text-sm">
|
||||
{{ currentAlert.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 告警截图 -->
|
||||
<div v-if="currentAlert.ossUrl || currentAlert.snapshotUrl">
|
||||
<span class="text-gray-500">告警截图:</span>
|
||||
<div class="mt-2">
|
||||
<Image
|
||||
:src="currentAlert.ossUrl || currentAlert.snapshotUrl"
|
||||
:width="300"
|
||||
:preview="{ src: currentAlert.ossUrl || currentAlert.snapshotUrl }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 检测区域 -->
|
||||
<div v-if="currentAlert.bbox">
|
||||
<span class="text-gray-500">检测区域 (bbox):</span>
|
||||
<code class="ml-2 text-xs bg-gray-100 px-2 py-1 rounded">{{
|
||||
currentAlert.bbox
|
||||
}}</code>
|
||||
</div>
|
||||
|
||||
<!-- 日志信息 -->
|
||||
<div v-if="currentAlert.logInfo" class="border-t pt-4">
|
||||
<div class="text-gray-500 font-medium mb-2">处理日志</div>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">接收时间:</span>
|
||||
<span>{{ currentAlert.logInfo.receiveTime || '-' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处理时间:</span>
|
||||
<span>{{ currentAlert.logInfo.handleTime || '-' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处理人:</span>
|
||||
<span>{{ currentAlert.logInfo.handledBy || '-' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处理备注:</span>
|
||||
<span>{{ currentAlert.logInfo.handleRemark || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 分析结果 -->
|
||||
<div v-if="currentAlert.aiAnalysis" class="border-t pt-4">
|
||||
<div class="text-gray-500 font-medium mb-2">AI 分析结果</div>
|
||||
<pre class="bg-gray-50 p-3 rounded text-xs overflow-auto">{{
|
||||
JSON.stringify(currentAlert.aiAnalysis, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
83
apps/web-antd/src/views/aiot/alarm/summary/index.vue
Normal file
83
apps/web-antd/src/views/aiot/alarm/summary/index.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AiotAlarmApi } from '#/api/aiot/alarm';
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Badge, Tag } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getCameraAlertSummary } from '#/api/aiot/alarm';
|
||||
|
||||
import { useCameraSummaryColumns } from '../list/data';
|
||||
|
||||
defineOptions({ name: 'AiotAlarmSummary' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
/** 跳转到该摄像头的告警列表 */
|
||||
function handleViewAlerts(row: AiotAlarmApi.CameraAlertSummary) {
|
||||
router.push({
|
||||
path: '/aiot/alarm/list',
|
||||
query: { cameraId: row.cameraId },
|
||||
});
|
||||
}
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useCameraSummaryColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
return await getCameraAlertSummary({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'cameraId',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AiotAlarmApi.CameraAlertSummary>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid table-title="摄像头告警汇总">
|
||||
<!-- 待处理数量列 -->
|
||||
<template #pendingCount="{ row }">
|
||||
<Badge
|
||||
v-if="row.pendingCount > 0"
|
||||
:count="row.pendingCount"
|
||||
:overflow-count="99"
|
||||
:number-style="{ backgroundColor: '#faad14' }"
|
||||
/>
|
||||
<span v-else class="text-gray-400">0</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '查看告警',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.VIEW,
|
||||
onClick: handleViewAlerts.bind(null, row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
110
apps/web-antd/src/views/aiot/device/camera/index.vue
Normal file
110
apps/web-antd/src/views/aiot/device/camera/index.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AiotDeviceApi } from '#/api/aiot/device';
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getChannelPage } 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 [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,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'channelId',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AiotDeviceApi.Channel>,
|
||||
});
|
||||
</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),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
40
apps/web-antd/src/views/aiot/device/roi/data.ts
Normal file
40
apps/web-antd/src/views/aiot/device/roi/data.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
/** ROI 列表字段 */
|
||||
export function useRoiGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'name',
|
||||
title: 'ROI 名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'channelName',
|
||||
title: '摄像头',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '类型',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '启用状态',
|
||||
minWidth: 90,
|
||||
slots: { default: 'enabled' },
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
title: '创建时间',
|
||||
minWidth: 170,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
99
apps/web-antd/src/views/aiot/device/roi/index.vue
Normal file
99
apps/web-antd/src/views/aiot/device/roi/index.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AiotDeviceApi } from '#/api/aiot/device';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { message, Modal, Tag } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteRoi, getRoiPage } from '#/api/aiot/device';
|
||||
|
||||
import { useRoiGridColumns } from './data';
|
||||
|
||||
defineOptions({ name: 'AiotDeviceRoi' });
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 删除 ROI */
|
||||
async function handleDelete(row: AiotDeviceApi.Roi) {
|
||||
Modal.confirm({
|
||||
title: '删除确认',
|
||||
content: `确定要删除 ROI "${row.name}" 吗?`,
|
||||
async onOk() {
|
||||
const hideLoading = message.loading({
|
||||
content: '正在删除...',
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteRoi(row.id as number);
|
||||
message.success('删除成功');
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useRoiGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
return await getRoiPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AiotDeviceApi.Roi>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid table-title="ROI 区域配置">
|
||||
<!-- 启用状态列 -->
|
||||
<template #enabled="{ row }">
|
||||
<Tag :color="row.enabled ? 'success' : 'default'">
|
||||
{{ row.enabled ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
onClick: handleDelete.bind(null, row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
77
apps/web-antd/src/views/aiot/edge/node/data.ts
Normal file
77
apps/web-antd/src/views/aiot/edge/node/data.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
/** 设备状态选项 */
|
||||
export const DEVICE_STATUS_OPTIONS = [
|
||||
{ label: '在线', value: 'online' },
|
||||
{ label: '离线', value: 'offline' },
|
||||
{ label: '异常', value: 'error' },
|
||||
];
|
||||
|
||||
/** 边缘设备搜索表单 */
|
||||
export function useEdgeGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '设备状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: DEVICE_STATUS_OPTIONS,
|
||||
placeholder: '请选择设备状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 边缘设备列表字段 */
|
||||
export function useEdgeGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'deviceId',
|
||||
title: '设备ID',
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
field: 'deviceName',
|
||||
title: '设备名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
minWidth: 90,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{
|
||||
field: 'lastHeartbeat',
|
||||
title: '最后心跳',
|
||||
minWidth: 170,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'uptimeSeconds',
|
||||
title: '运行时长',
|
||||
minWidth: 100,
|
||||
slots: { default: 'uptime' },
|
||||
},
|
||||
{
|
||||
field: 'framesProcessed',
|
||||
title: '处理帧数',
|
||||
minWidth: 100,
|
||||
slots: { default: 'frames' },
|
||||
},
|
||||
{
|
||||
field: 'alertsGenerated',
|
||||
title: '告警数',
|
||||
minWidth: 90,
|
||||
slots: { default: 'alerts' },
|
||||
},
|
||||
{
|
||||
field: 'updatedAt',
|
||||
title: '更新时间',
|
||||
minWidth: 170,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
];
|
||||
}
|
||||
92
apps/web-antd/src/views/aiot/edge/node/index.vue
Normal file
92
apps/web-antd/src/views/aiot/edge/node/index.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AiotEdgeApi } from '#/api/aiot/edge';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getDevicePage } from '#/api/aiot/edge';
|
||||
|
||||
import { useEdgeGridColumns, useEdgeGridFormSchema } from './data';
|
||||
|
||||
defineOptions({ name: 'AiotEdgeNode' });
|
||||
|
||||
/** 获取状态颜色 */
|
||||
function getStatusColor(status?: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'success',
|
||||
offline: 'default',
|
||||
error: 'error',
|
||||
};
|
||||
return status ? colorMap[status] || 'default' : 'default';
|
||||
}
|
||||
|
||||
/** 格式化运行时长 */
|
||||
function formatUptime(seconds?: number) {
|
||||
if (seconds == null) return '-';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useEdgeGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useEdgeGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getDevicePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
status: formValues?.status,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AiotEdgeApi.Device>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid table-title="边缘节点管理">
|
||||
<!-- 状态列 -->
|
||||
<template #status="{ row }">
|
||||
<Tag :color="getStatusColor(row.status)">
|
||||
{{ row.statusName || row.status || '-' }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 运行时长列 -->
|
||||
<template #uptime="{ row }">
|
||||
<span>{{ formatUptime(row.uptimeSeconds) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 处理帧数列 -->
|
||||
<template #frames="{ row }">
|
||||
<span>{{ row.framesProcessed?.toLocaleString() ?? '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 告警数列 -->
|
||||
<template #alerts="{ row }">
|
||||
<span>{{ row.alertsGenerated?.toLocaleString() ?? '-' }}</span>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
112
apps/web-antd/src/views/aiot/video/live/index.vue
Normal file
112
apps/web-antd/src/views/aiot/video/live/index.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { AiotVideoApi } from '#/api/aiot/video';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Input, message, Select, Space } from 'ant-design-vue';
|
||||
|
||||
import { playStart, playStop } from '#/api/aiot/video';
|
||||
|
||||
defineOptions({ name: 'AiotVideoLive' });
|
||||
|
||||
const deviceId = ref('');
|
||||
const channelId = ref('');
|
||||
const streamInfo = ref<AiotVideoApi.StreamInfo | null>(null);
|
||||
const loading = ref(false);
|
||||
const playing = ref(false);
|
||||
|
||||
/** 开始播放 */
|
||||
async function handlePlay() {
|
||||
if (!deviceId.value || !channelId.value) {
|
||||
message.warning('请输入设备ID和通道ID');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const info = await playStart(deviceId.value, channelId.value);
|
||||
streamInfo.value = info;
|
||||
playing.value = true;
|
||||
message.success('播放请求已发送');
|
||||
} catch (error) {
|
||||
console.error('播放失败:', error);
|
||||
message.error('播放失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止播放 */
|
||||
async function handleStop() {
|
||||
if (!deviceId.value || !channelId.value) return;
|
||||
|
||||
try {
|
||||
await playStop(deviceId.value, channelId.value);
|
||||
playing.value = false;
|
||||
streamInfo.value = null;
|
||||
message.success('已停止播放');
|
||||
} catch (error) {
|
||||
console.error('停止播放失败:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Card title="实时视频播放">
|
||||
<div class="mb-4">
|
||||
<Space>
|
||||
<Input
|
||||
v-model:value="deviceId"
|
||||
placeholder="设备ID"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="channelId"
|
||||
placeholder="通道ID"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="playing"
|
||||
@click="handlePlay"
|
||||
>
|
||||
开始播放
|
||||
</Button>
|
||||
<Button :disabled="!playing" danger @click="handleStop">
|
||||
停止播放
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<!-- 播放器区域 -->
|
||||
<div v-if="streamInfo" class="mt-4">
|
||||
<div class="mb-2 text-gray-500">
|
||||
流地址:{{ streamInfo.flv || streamInfo.ws_flv || '-' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="streamInfo.ws_flv || streamInfo.flv"
|
||||
class="bg-black rounded"
|
||||
style="width: 100%; max-width: 800px; aspect-ratio: 16/9"
|
||||
>
|
||||
<video
|
||||
:src="streamInfo.flv"
|
||||
autoplay
|
||||
controls
|
||||
style="width: 100%; height: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center bg-gray-100 rounded"
|
||||
style="width: 100%; max-width: 800px; aspect-ratio: 16/9"
|
||||
>
|
||||
<span class="text-gray-400">请选择通道开始播放</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Page>
|
||||
</template>
|
||||
Reference in New Issue
Block a user