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:
2026-02-06 16:40:43 +08:00
parent 159a82aaa9
commit c601395a03
9 changed files with 1161 additions and 0 deletions

View 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' },
},
];
}

View 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>

View 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>

View 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>

View 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' },
},
];
}

View 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>

View 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',
},
];
}

View 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>

View 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>