Compare commits

...

4 Commits

Author SHA1 Message Date
936dff610c feat(aiot): 更新 Vite 代理规则,移除旧 ai-alert 路由
- 添加 4 条 aiot 代理规则:
  /admin-api/aiot/alarm → 告警服务 :8000(直通)
  /admin-api/aiot/edge → 告警服务 :8000(直通)
  /admin-api/aiot/device → WVP :18080(rewrite 为 /api/ai)
  /admin-api/aiot/video → WVP :18080(rewrite 为 /api)
- 移除旧的 /admin-api/ai-alert 和 /admin-api/video 代理规则
- 删除旧的 ai-alert 路由、视图、API 文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 16:41:00 +08:00
c601395a03 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>
2026-02-06 16:40:43 +08:00
159a82aaa9 feat(aiot): 搭建 aiot 前端模块路由和 API 层
- 新增 router/routes/modules/aiot.ts:6 个页面路由
  告警列表、摄像头汇总、摄像头管理、ROI配置、实时视频、边缘节点
- 新增 api/aiot/alarm/:告警 API(分页、详情、处理、删除、统计、汇总)
- 新增 api/aiot/edge/:边缘设备 API(分页、详情、统计)
- 新增 api/aiot/device/:摄像头和 ROI API(调用 WVP 后端)
- 新增 api/aiot/video/:视频播放 API(playStart/playStop)
- 新增 api/aiot/request.ts:WVP 专用请求客户端(跳过芋道响应拦截器)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 16:40:26 +08:00
bc2f1e89c9 fix: 恢复默认主题色系,关闭租户和 API 加密
- 移除自定义暖黄色主题,恢复芋道默认蓝色色系
- 关闭租户功能(VITE_APP_TENANT_ENABLE=false)
- 关闭 API 加密(VITE_APP_API_ENCRYPT_ENABLE=false)
- 以上配置适配当前 FastAPI 后端(不支持租户和加密)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 16:40:08 +08:00
17 changed files with 1637 additions and 3 deletions

View File

@@ -11,7 +11,7 @@ VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key
VITE_NITRO_MOCK=false
# 租户开关
VITE_APP_TENANT_ENABLE=true
VITE_APP_TENANT_ENABLE=false
# 验证码的开关
VITE_APP_CAPTCHA_ENABLE=false
@@ -26,7 +26,7 @@ VITE_APP_BAIDU_CODE = e98f2eab6ceb8688bc6d8fc5332ff093
VITE_GOVIEW_URL='http://127.0.0.1:3000'
# API 加解密
VITE_APP_API_ENCRYPT_ENABLE = true
VITE_APP_API_ENCRYPT_ENABLE = false
VITE_APP_API_ENCRYPT_HEADER = X-Api-Encrypt
VITE_APP_API_ENCRYPT_ALGORITHM = AES
VITE_APP_API_ENCRYPT_REQUEST_KEY = 52549111389893486934626385991395

View File

@@ -0,0 +1,118 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiotAlarmApi {
/** AI 告警 VO */
export interface Alert {
id?: number;
alertNo?: string;
cameraId?: string;
cameraName?: string;
roiId?: string;
bindId?: string;
deviceId?: string;
alertType?: string;
alertTypeName?: string;
algorithm?: string;
confidence?: number;
durationMinutes?: number;
triggerTime?: string;
message?: string;
bbox?: string;
snapshotUrl?: string;
ossUrl?: string;
status?: string;
statusName?: string;
level?: string;
handleRemark?: string;
handledBy?: string;
handledAt?: string;
workOrderId?: number;
aiAnalysis?: Record<string, any>;
createdAt?: string;
updatedAt?: string;
logInfo?: AlertLogInfo;
}
/** 告警日志信息 */
export interface AlertLogInfo {
receiveTime?: string;
handleTime?: string;
handledBy?: string;
handleRemark?: string;
confidence?: number;
durationMinutes?: number;
bbox?: string;
aiAnalysis?: Record<string, any>;
}
/** 摄像头告警汇总 */
export interface CameraAlertSummary {
cameraId?: string;
cameraName?: string;
totalCount?: number;
pendingCount?: number;
lastAlertTime?: string;
lastAlertType?: string;
lastAlertTypeName?: string;
}
/** 告警统计 */
export interface AlertStatistics {
total?: number;
todayCount?: number;
pendingCount?: number;
handledCount?: number;
byType?: Record<string, number>;
byStatus?: Record<string, number>;
byLevel?: Record<string, number>;
}
}
// ==================== 告警管理 API ====================
/** 分页查询告警列表 */
export function getAlertPage(params: PageParam) {
return requestClient.get<PageResult<AiotAlarmApi.Alert>>(
'/aiot/alarm/alert/page',
{ params },
);
}
/** 获取告警详情 */
export function getAlert(id: number) {
return requestClient.get<AiotAlarmApi.Alert>(
`/aiot/alarm/alert/get?id=${id}`,
);
}
/** 处理告警 */
export function handleAlert(id: number, status: string, remark?: string) {
return requestClient.put('/aiot/alarm/alert/handle', null, {
params: { id, status, remark },
});
}
/** 删除告警 */
export function deleteAlert(id: number) {
return requestClient.delete(`/aiot/alarm/alert/delete?id=${id}`);
}
/** 获取告警统计 */
export function getAlertStatistics(startTime?: string, endTime?: string) {
return requestClient.get<AiotAlarmApi.AlertStatistics>(
'/aiot/alarm/alert/statistics',
{ params: { startTime, endTime } },
);
}
// ==================== 摄像头告警汇总 API ====================
/** 以摄像头维度获取告警汇总 */
export function getCameraAlertSummary(params: PageParam) {
return requestClient.get<PageResult<AiotAlarmApi.CameraAlertSummary>>(
'/aiot/alarm/camera-summary/page',
{ params },
);
}

View File

@@ -0,0 +1,99 @@
import type { PageParam, PageResult } from '@vben/request';
import { wvpRequestClient } from '#/api/aiot/request';
export namespace AiotDeviceApi {
/** ROI 区域 */
export interface Roi {
id?: number;
channelId?: string;
channelName?: string;
name?: string;
type?: string;
points?: string;
enabled?: boolean;
createdAt?: string;
updatedAt?: string;
}
/** ROI 算法绑定 */
export interface RoiAlgoBind {
id?: number;
roiId?: number;
algorithmId?: number;
algorithmName?: string;
enabled?: boolean;
config?: Record<string, any>;
}
/** 算法 */
export interface Algorithm {
id?: number;
name?: string;
code?: string;
description?: string;
enabled?: boolean;
}
/** 摄像头通道 */
export interface Channel {
channelId?: string;
name?: string;
manufacturer?: string;
status?: string;
ptztypeText?: string;
longitudeWgs84?: number;
latitudeWgs84?: number;
}
}
// ==================== ROI 区域管理 API ====================
/** 获取 ROI 列表 */
export function getRoiPage(params: PageParam) {
return wvpRequestClient.get<PageResult<AiotDeviceApi.Roi>>(
'/aiot/device/roi/list',
{ params },
);
}
/** 保存 ROI */
export function saveRoi(data: AiotDeviceApi.Roi) {
return wvpRequestClient.post('/aiot/device/roi/save', data);
}
/** 删除 ROI */
export function deleteRoi(id: number) {
return wvpRequestClient.delete(`/aiot/device/roi/delete?id=${id}`);
}
// ==================== 算法绑定 API ====================
/** 绑定算法到 ROI */
export function bindAlgo(data: { roiId: number; algorithmId: number }) {
return wvpRequestClient.post('/aiot/device/roi/bindAlgo', data);
}
/** 解绑算法 */
export function unbindAlgo(bindId: number) {
return wvpRequestClient.delete(
`/aiot/device/roi/unbindAlgo?bindId=${bindId}`,
);
}
/** 获取算法列表 */
export function getAlgorithmList() {
return wvpRequestClient.get<AiotDeviceApi.Algorithm[]>(
'/aiot/device/algorithm/list',
);
}
// ==================== 摄像头通道 API ====================
/** 获取摄像头通道列表 */
export function getChannelPage(params: PageParam) {
return wvpRequestClient.get<PageResult<AiotDeviceApi.Channel>>(
'/aiot/device/channel/list',
{ params },
);
}

View File

@@ -0,0 +1,55 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiotEdgeApi {
/** 边缘设备 VO */
export interface Device {
id?: number;
deviceId?: string;
deviceName?: string;
status?: string;
statusName?: string;
lastHeartbeat?: string;
uptimeSeconds?: number;
framesProcessed?: number;
alertsGenerated?: number;
ipAddress?: string;
streamCount?: number;
configVersion?: string;
extraInfo?: Record<string, any>;
updatedAt?: string;
}
/** 设备统计 */
export interface DeviceStatistics {
total?: number;
online?: number;
offline?: number;
error?: number;
}
}
// ==================== 边缘设备 API ====================
/** 分页查询边缘设备列表 */
export function getDevicePage(params: PageParam) {
return requestClient.get<PageResult<AiotEdgeApi.Device>>(
'/aiot/edge/device/page',
{ params },
);
}
/** 获取设备详情 */
export function getDevice(id: string) {
return requestClient.get<AiotEdgeApi.Device>(
`/aiot/edge/device/get?id=${id}`,
);
}
/** 获取设备统计 */
export function getDeviceStatistics() {
return requestClient.get<AiotEdgeApi.DeviceStatistics>(
'/aiot/edge/device/statistics',
);
}

View File

@@ -0,0 +1,35 @@
/**
* WVP 视频平台专用请求客户端
*
* WVP 返回原始 JSON非芋道的 {code:0, data:...} 格式),
* 需要跳过芋道默认的响应拦截器。
*/
import { useAppConfig } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import { RequestClient } from '@vben/request';
import { useAccessStore } from '@vben/stores';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
function createWvpRequestClient(baseURL: string) {
const client = new RequestClient({
baseURL,
});
// 请求头:携带 token 用于 Vite 代理鉴权透传
client.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
const token = accessStore.accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers['Accept-Language'] = preferences.app.locale;
return config;
},
});
return client;
}
export const wvpRequestClient = createWvpRequestClient(apiURL);

View File

@@ -0,0 +1,43 @@
import { wvpRequestClient } from '#/api/aiot/request';
export namespace AiotVideoApi {
/** 流信息 */
export interface StreamInfo {
app?: string;
stream?: string;
ip?: string;
flv?: string;
ws_flv?: string;
rtmp?: string;
hls?: string;
rtsp?: string;
mediaServerId?: string;
tracks?: StreamTrack[];
}
export interface StreamTrack {
codec_id?: number;
codec_id_name?: string;
ready?: boolean;
type?: number;
width?: number;
height?: number;
fps?: number;
}
}
// ==================== 视频播放 API ====================
/** 开始播放 */
export function playStart(deviceId: string, channelId: string) {
return wvpRequestClient.get<AiotVideoApi.StreamInfo>(
`/aiot/video/play/start/${deviceId}/${channelId}`,
);
}
/** 停止播放 */
export function playStop(deviceId: string, channelId: string) {
return wvpRequestClient.get(
`/aiot/video/play/stop/${deviceId}/${channelId}`,
);
}

View File

@@ -0,0 +1,71 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/aiot',
name: 'AIoT',
meta: {
title: 'AIoT 智能平台',
icon: 'ant-design:robot-outlined',
keepAlive: true,
},
children: [
{
path: 'alarm/list',
name: 'AiotAlarmList',
component: () => import('#/views/aiot/alarm/list/index.vue'),
meta: {
title: '告警列表',
icon: 'ant-design:alert-outlined',
},
},
{
path: 'alarm/summary',
name: 'AiotAlarmSummary',
component: () => import('#/views/aiot/alarm/summary/index.vue'),
meta: {
title: '摄像头告警汇总',
icon: 'ant-design:video-camera-outlined',
},
},
{
path: 'device/camera',
name: 'AiotDeviceCamera',
component: () => import('#/views/aiot/device/camera/index.vue'),
meta: {
title: '摄像头管理',
icon: 'ant-design:camera-outlined',
},
},
{
path: 'device/roi',
name: 'AiotDeviceRoi',
component: () => import('#/views/aiot/device/roi/index.vue'),
meta: {
title: 'ROI 区域配置',
icon: 'ant-design:aim-outlined',
},
},
{
path: 'video/live',
name: 'AiotVideoLive',
component: () => import('#/views/aiot/video/live/index.vue'),
meta: {
title: '实时视频',
icon: 'ant-design:play-circle-outlined',
},
},
{
path: 'edge/node',
name: 'AiotEdgeNode',
component: () => import('#/views/aiot/edge/node/index.vue'),
meta: {
title: '边缘节点管理',
icon: 'ant-design:cluster-outlined',
},
},
],
},
];
export default routes;

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>

View File

@@ -7,10 +7,62 @@ export default defineConfig(async () => {
server: {
allowedHosts: true,
proxy: {
// ==================== AIoT 统一路由 ====================
// aiot/alarm, aiot/edge -> 告警服务 :8000直通
'/admin-api/aiot/alarm': {
changeOrigin: true,
target: 'http://127.0.0.1:8000',
},
'/admin-api/aiot/edge': {
changeOrigin: true,
target: 'http://127.0.0.1:8000',
},
// aiot/device -> WVP :18080rewrite: /admin-api/aiot/device -> /api/ai
'/admin-api/aiot/device': {
changeOrigin: true,
target: 'http://127.0.0.1:18080',
rewrite: (path: string) =>
path.replace('/admin-api/aiot/device', '/api/ai'),
},
// aiot/video -> WVP :18080rewrite: /admin-api/aiot/video -> /api
'/admin-api/aiot/video': {
changeOrigin: true,
target: 'http://127.0.0.1:18080',
rewrite: (path: string) =>
path.replace('/admin-api/aiot/video', '/api'),
},
// ==================== 系统基础路由 ====================
// 认证相关接口 -> 告警平台(测试阶段提供模拟认证)
'/admin-api/system/auth': {
changeOrigin: true,
target: 'http://127.0.0.1:8000',
ws: true,
},
// 租户相关接口 -> 告警平台(测试阶段返回默认值)
'/admin-api/system/tenant': {
changeOrigin: true,
target: 'http://127.0.0.1:8000',
},
// 验证码接口 -> 告警平台(测试阶段返回禁用状态)
'/admin-api/system/captcha': {
changeOrigin: true,
target: 'http://127.0.0.1:8000',
},
// 字典数据接口 -> 告警平台(测试阶段返回空)
'/admin-api/system/dict-data': {
changeOrigin: true,
target: 'http://127.0.0.1:8000',
},
// 消息通知接口 -> 告警平台(测试阶段返回空)
'/admin-api/system/notify-message': {
changeOrigin: true,
target: 'http://127.0.0.1:8000',
},
// 其他接口 -> 芋道主平台(未来对接)
// 测试阶段:这些接口会 404但不影响核心功能
'/admin-api': {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/admin-api/, ''),
// mock代理目标地址
target: 'http://localhost:48080/admin-api',
ws: true,
},