miaofuhao 3 settimane fa
parent
commit
895b23e1c8

+ 176 - 0
apps/web-ele/src/api/aiAnalysis/aiAnalysis.ts

@@ -0,0 +1,176 @@
1
+import type { DetectStatisticsModel, WarningStatisticsModel, SecurityAlertModel, SecurityCameraModel } from './model';
2
+
3
+import type { BaseResult } from '#/api/base-result';
4
+
5
+import { commonExport } from '#/api/helper';
6
+import { requestClient } from '#/api/request';
7
+
8
+// API路径常量枚举
9
+enum Api {
10
+  // 检测统计
11
+  detectStatistics = '/security/statistics/detect',
12
+  // 预警分类统计
13
+  warningStatistics = '/security/statistics/warning',
14
+  
15
+  // 预警记录相关
16
+  alertRoot = '/securityAlert/alert',
17
+  alertList = '/securityAlert/alert/list',
18
+  alertExport = '/securityAlert/alert/export',
19
+  alertHandle = '/securityAlert/alert/handle',
20
+  
21
+  // 摄像头设备相关
22
+  cameraRoot = '/securityCamera/camera',
23
+  cameraList = '/securityCamera/camera/list',
24
+  cameraExport = '/securityCamera/camera/export',
25
+}
26
+
27
+/**
28
+ * 查询检测统计
29
+ * @returns 检测统计结果
30
+ */
31
+export function getDetectStatistics() {
32
+  return requestClient.get<BaseResult<DetectStatisticsModel>>(Api.detectStatistics);
33
+}
34
+
35
+/**
36
+ * 查询每个分类的预警未处理数量
37
+ * @returns 预警分类统计结果
38
+ */
39
+export function getWarningStatistics() {
40
+  return requestClient.get<BaseResult<WarningStatisticsModel[]>>(Api.warningStatistics);
41
+}
42
+
43
+/**
44
+ * 查询预警记录列表
45
+ * @param params 查询参数
46
+ * @returns 预警记录列表
47
+ */
48
+export function getSecurityAlertList(params: any) {
49
+  return requestClient.get<BaseResult<SecurityAlertModel[]>>(Api.alertList, {
50
+    params,
51
+  });
52
+}
53
+
54
+/**
55
+ * 获取预警记录详细信息
56
+ * @param alertId 预警ID
57
+ * @returns 预警记录详情
58
+ */
59
+export function getSecurityAlertDetail(alertId: number) {
60
+  return requestClient.get<SecurityAlertModel>(`${Api.alertRoot}/${alertId}`);
61
+}
62
+
63
+/**
64
+ * 新增预警记录
65
+ * @param data 预警记录数据
66
+ * @returns 操作结果
67
+ */
68
+export function addSecurityAlert(data: SecurityAlertModel) {
69
+  return requestClient.post(Api.alertRoot, data, {
70
+    successMessageMode: 'message',
71
+  });
72
+}
73
+
74
+/**
75
+ * 修改预警记录
76
+ * @param data 预警记录数据
77
+ * @returns 操作结果
78
+ */
79
+export function updateSecurityAlert(data: SecurityAlertModel) {
80
+  return requestClient.put(Api.alertRoot, data, {
81
+    successMessageMode: 'message',
82
+  });
83
+}
84
+
85
+/**
86
+ * 删除预警记录
87
+ * @param alertIds 预警ID数组
88
+ * @returns 操作结果
89
+ */
90
+export function deleteSecurityAlert(alertIds: number[]) {
91
+  return requestClient.delete(`${Api.alertRoot}/${alertIds.join(',')}`, {
92
+    successMessageMode: 'message',
93
+  });
94
+}
95
+
96
+/**
97
+ * 导出预警记录列表
98
+ * @param data 导出参数
99
+ * @returns 导出结果
100
+ */
101
+export function exportSecurityAlert(data: Partial<SecurityAlertModel>) {
102
+  return commonExport(Api.alertExport, data);
103
+}
104
+
105
+/**
106
+ * 处理预警记录
107
+ * @param data 处理参数
108
+ * @returns 操作结果
109
+ */
110
+export function handleSecurityAlert(data: any) {
111
+  return requestClient.put(Api.alertHandle, data, {
112
+    successMessageMode: 'message',
113
+  });
114
+}
115
+
116
+/**
117
+ * 查询摄像头设备列表
118
+ * @param params 查询参数
119
+ * @returns 摄像头设备列表
120
+ */
121
+export function getSecurityCameraList(params: any) {
122
+  return requestClient.get<BaseResult<SecurityCameraModel[]>>(Api.cameraList, {
123
+    params,
124
+  });
125
+}
126
+
127
+/**
128
+ * 获取摄像头设备详细信息
129
+ * @param cameraId 摄像头ID
130
+ * @returns 摄像头设备详情
131
+ */
132
+export function getSecurityCameraDetail(cameraId: number) {
133
+  return requestClient.get<SecurityCameraModel>(`${Api.cameraRoot}/${cameraId}`);
134
+}
135
+
136
+/**
137
+ * 新增摄像头设备
138
+ * @param data 摄像头设备数据
139
+ * @returns 操作结果
140
+ */
141
+export function addSecurityCamera(data: SecurityCameraModel) {
142
+  return requestClient.post(Api.cameraRoot, data, {
143
+    successMessageMode: 'message',
144
+  });
145
+}
146
+
147
+/**
148
+ * 修改摄像头设备
149
+ * @param data 摄像头设备数据
150
+ * @returns 操作结果
151
+ */
152
+export function updateSecurityCamera(data: SecurityCameraModel) {
153
+  return requestClient.put(Api.cameraRoot, data, {
154
+    successMessageMode: 'message',
155
+  });
156
+}
157
+
158
+/**
159
+ * 删除摄像头设备
160
+ * @param cameraIds 摄像头ID数组
161
+ * @returns 操作结果
162
+ */
163
+export function deleteSecurityCamera(cameraIds: number[]) {
164
+  return requestClient.delete(`${Api.cameraRoot}/${cameraIds.join(',')}`, {
165
+    successMessageMode: 'message',
166
+  });
167
+}
168
+
169
+/**
170
+ * 导出摄像头设备列表
171
+ * @param data 导出参数
172
+ * @returns 导出结果
173
+ */
174
+export function exportSecurityCamera(data: Partial<SecurityCameraModel>) {
175
+  return commonExport(Api.cameraExport, data);
176
+}

+ 123 - 0
apps/web-ele/src/api/aiAnalysis/model.ts

@@ -0,0 +1,123 @@
1
+// 检测统计模型
2
+export interface DetectStatisticsModel {
3
+  /** 摄像头数量 */
4
+  cameraCount?: number;
5
+  /** 预警数量 */
6
+  alertCount?: number;
7
+  /** 场站数量 */
8
+  stationCount?: number;
9
+  /** 预警类型数量 */
10
+  alertTypes?: number;
11
+}
12
+
13
+// 预警分类统计模型
14
+export interface WarningStatisticsModel {
15
+  /** 预警类型名称 */
16
+  alertTypeName?: string;
17
+  /** 预警类型代码 */
18
+  alertType?: string;
19
+  /** 数量 */
20
+  count?: number;
21
+  /** 月度数量 */
22
+  monthCount?: number;
23
+}
24
+
25
+// 预警记录模型
26
+export interface SecurityAlertModel {
27
+  /** 预警ID */
28
+  alertId?: number;
29
+  /** 预警编号 */
30
+  alertCode?: string;
31
+  /** 摄像头ID */
32
+  cameraId?: number;
33
+  /** 关联识别记录ID */
34
+  detectRecordId?: number;
35
+  /** 预警类型(fire火焰 smoke烟雾 fire_danger火灾 intrusion入侵等) */
36
+  alertType?: string;
37
+  /** 预警级别(1一般 2重要 3紧急) */
38
+  alertLevel?: string;
39
+  /** 识别置信度 */
40
+  confidence?: number;
41
+  /** 快照图片URL */
42
+  snapshotUrl?: string;
43
+  /** 快照图片存储路径 */
44
+  snapshotPath?: string;
45
+  /** 预警时间 */
46
+  alertTime?: string;
47
+  /** 处理状态(unhandled未处理 handling处理中 resolved已解决 false_alarm误报) */
48
+  status?: string;
49
+  /** 处理人ID */
50
+  handleUserId?: number;
51
+  /** 处理时间 */
52
+  handleTime?: string;
53
+  /** 处理备注 */
54
+  handleRemark?: string;
55
+  /** 预警位置 */
56
+  location?: string;
57
+  /** 场站ID */
58
+  stationId?: number;
59
+  /** 场站名称 */
60
+  stationName?: string;
61
+  /** 站长ID */
62
+  stationManagerId?: number;
63
+  /** 站长姓名 */
64
+  stationManagerName?: string;
65
+  /** 站长电话 */
66
+  stationManagerPhone?: string;
67
+  /** 片区ID */
68
+  areaId?: number;
69
+  /** 片区经理ID */
70
+  areaManagerId?: number;
71
+  /** 片区经理姓名 */
72
+  areaManagerName?: string;
73
+  /** 片区经理电话 */
74
+  areaManagerPhone?: string;
75
+}
76
+
77
+// 摄像头设备模型
78
+export interface SecurityCameraModel {
79
+  /** 摄像头ID */
80
+  cameraId?: number;
81
+  /** 摄像头编码(唯一) */
82
+  cameraCode?: string;
83
+  /** 摄像头名称 */
84
+  cameraName?: string;
85
+  /** 所属场站id */
86
+  stationId?: number;
87
+  /** 摄像头类型(IPC枪机 PTZ球机 DOME半球) */
88
+  cameraType?: string;
89
+  /** IP地址 */
90
+  ipAddress?: string;
91
+  /** 端口号 */
92
+  port?: number;
93
+  /** 登录用户名 */
94
+  username?: string;
95
+  /** 登录密码(加密存储) */
96
+  password?: string;
97
+  /** RTSP流地址 */
98
+  rtspUrl?: string;
99
+  /** HTTP预览地址 */
100
+  httpUrl?: string;
101
+  /** 安装位置 */
102
+  location?: string;
103
+  /** 经度 */
104
+  longitude?: number;
105
+  /** 纬度 */
106
+  latitude?: number;
107
+  /** 是否启用AI识别(0否 1是) */
108
+  aiEnabled?: string;
109
+  /** 对接平台类型(宇视/海康/大华等) */
110
+  platformType?: string;
111
+  /** 平台设备ID */
112
+  platformId?: string;
113
+  /** 设备状态(0在线 1离线 2故障) */
114
+  status?: string;
115
+  /** 最后在线时间 */
116
+  lastOnlineTime?: string;
117
+  /** 最后维护时间 */
118
+  lastMaintenance?: string;
119
+  /** 维护周期(天) */
120
+  maintenanceCycle?: number;
121
+  /** 删除标志(0代表存在 2代表删除) */
122
+  delFlag?: string;
123
+}

BIN
apps/web-ele/src/assets/icon/ai-alert.png


BIN
apps/web-ele/src/assets/icon/ai-monitor.png


BIN
apps/web-ele/src/assets/icon/angle-right-circle.png


BIN
apps/web-ele/src/assets/icon/camera.png


BIN
apps/web-ele/src/assets/icon/monitor-type.png


+ 22 - 0
apps/web-ele/src/router/routes/local.ts

@@ -279,6 +279,28 @@ const localRoutes: RouteRecordStringComponent[] = [
279 279
     name: 'AddClass',
280 280
     path: '/schedule/work/class/addandedit',
281 281
   },
282
+  {
283
+    component: '/aiAnalysis/camera/detail',
284
+    meta: {
285
+      activePath: '/aiAnalysis/camera',
286
+      icon: 'carbon:information',
287
+      title: '摄像头详情',
288
+      hideInMenu: true,
289
+    },
290
+    name: 'CameraDetail',
291
+    path: '/aiAnalysis/camera/detail/:cameraId',
292
+  },
293
+  {
294
+    component: '/aiAnalysis/warning/detail',
295
+    meta: {
296
+      activePath: '/aiAnalysis/warning',
297
+      icon: 'carbon:information',
298
+      title: '预警详情',
299
+      hideInMenu: true,
300
+    },
301
+    name: 'WarningDetail',
302
+    path: '/aiAnalysis/warning/detail/:alertId',
303
+  },
282 304
 ];
283 305
 
284 306
 /**

+ 284 - 0
apps/web-ele/src/views/aiAnalysis/camera/camera-data.tsx

@@ -0,0 +1,284 @@
1
+import type { FormSchemaGetter } from '#/adapter/form';
2
+import type { VxeGridProps } from '#/adapter/vxe-table';
3
+
4
+import { selectAllSysStation } from '#/api/system/infoEntry/stationInfo/stationInfo';
5
+import { selectAllSysStationAreaList } from '#/api/system/infoEntry/stationInfo/stationInfo';
6
+
7
+export const queryFormSchema: FormSchemaGetter = () => [
8
+  {
9
+    component: 'Input',
10
+    fieldName: 'location',
11
+    label: '摄像头位置',
12
+    componentProps: {
13
+      placeholder: '请输入摄像头位置',
14
+    },
15
+  },
16
+  {
17
+    component: 'Input',
18
+    fieldName: 'httpUrl',
19
+    label: '通道地址',
20
+    componentProps: {
21
+      placeholder: '请输入通道地址',
22
+    },
23
+  },
24
+  {
25
+    component: 'ApiSelect',
26
+    fieldName: 'stationId',
27
+    label: '所属场站',
28
+    componentProps: {
29
+      placeholder: '请选择所属场站',
30
+      api: async () => {
31
+        const resp = await selectAllSysStation();
32
+        const data = resp || [];
33
+        return Array.isArray(data)
34
+          ? data.map((item: any) => ({
35
+              label: item.stationName,
36
+              value: item.id.toString(),
37
+            }))
38
+          : [];
39
+      },
40
+    },
41
+  },
42
+];
43
+
44
+export const tableColumns: VxeGridProps['columns'] = [
45
+  {
46
+    type: 'checkbox',
47
+    width: 80,
48
+    fixed: 'left',
49
+  },
50
+  {
51
+    field: 'location',
52
+    title: '摄像头位置',
53
+    minWidth: 150,
54
+    fixed: 'left',
55
+  },
56
+  {
57
+    field: 'stationName',
58
+    title: '所属场站',
59
+    minWidth: 120,
60
+    fixed: 'left',
61
+  },
62
+  {
63
+    field: 'httpUrl',
64
+    title: '通道地址',
65
+    minWidth: 200,
66
+  },
67
+  {
68
+    field: 'cameraName',
69
+    title: '摄像头名称',
70
+    minWidth: 120,
71
+  },
72
+  {
73
+    field: 'cameraCode',
74
+    title: '摄像头编码',
75
+    minWidth: 120,
76
+  },
77
+  {
78
+    field: 'cameraType',
79
+    title: '摄像头类型',
80
+    minWidth: 100,
81
+  },
82
+  {
83
+    field: 'ipAddress',
84
+    title: 'IP地址',
85
+    minWidth: 120,
86
+  },
87
+  {
88
+    field: 'aiEnabled',
89
+    title: '是否启用AI',
90
+    minWidth: 100,
91
+    formatter: (params: { cellValue: string }) => {
92
+      return params.cellValue === '1' ? '是' : '否';
93
+    },
94
+  },
95
+  {
96
+    field: 'status',
97
+    title: '设备状态',
98
+    minWidth: 100,
99
+    formatter: (params: { cellValue: string }) => {
100
+      const statusMap = {
101
+        '0': '在线',
102
+        '1': '离线',
103
+        '2': '故障',
104
+      };
105
+      return statusMap[params.cellValue] || params.cellValue;
106
+    },
107
+  },
108
+  {
109
+    field: 'action',
110
+    fixed: 'right',
111
+    slots: { default: 'action' },
112
+    title: '操作',
113
+    width: 220,
114
+  },
115
+];
116
+
117
+export const drawerFormSchema: FormSchemaGetter = () => [
118
+  {
119
+    component: 'Input',
120
+    dependencies: {
121
+      show: () => false,
122
+      triggerFields: [''],
123
+    },
124
+    fieldName: 'cameraId',
125
+  },
126
+  {
127
+    component: 'Input',
128
+    fieldName: 'cameraCode',
129
+    label: '设备编号',
130
+    componentProps: {
131
+      placeholder: '请输入设备编号(唯一)',
132
+      maxlength: 100,
133
+    },
134
+    rules: 'required',
135
+  },
136
+  {
137
+    component: 'Input',
138
+    fieldName: 'cameraName',
139
+    label: '设备名称',
140
+    componentProps: {
141
+      placeholder: '请输入设备名称',
142
+      maxlength: 100,
143
+    },
144
+  },
145
+  {
146
+    component: 'ApiSelect',
147
+    fieldName: 'stationId',
148
+    label: '所属场站',
149
+    componentProps: {
150
+      placeholder: '请选择所属场站',
151
+      api: async () => {
152
+        const resp = await selectAllSysStation();
153
+        const data = resp || [];
154
+        return Array.isArray(data)
155
+          ? data.map((item: any) => ({
156
+              label: item.stationName,
157
+              value: item.id.toString(),
158
+            }))
159
+          : [];
160
+      },
161
+    },
162
+  },
163
+  {
164
+    component: 'Input',
165
+    fieldName: 'cameraType',
166
+    label: '摄像头类型',
167
+    componentProps: {
168
+      placeholder: '请输入摄像头类型(IPC枪机/PTZ球机/DOME半球)',
169
+      maxlength: 50,
170
+    },
171
+  },
172
+  {
173
+    component: 'Input',
174
+    fieldName: 'ipAddress',
175
+    label: 'IP地址',
176
+    componentProps: {
177
+      placeholder: '请输入IP地址',
178
+      maxlength: 50,
179
+    },
180
+  },
181
+  {
182
+    component: 'InputNumber',
183
+    fieldName: 'port',
184
+    label: '端口号',
185
+    componentProps: {
186
+      placeholder: '请输入端口号',
187
+      style: {
188
+        width: '100%',
189
+      },
190
+    },
191
+  },
192
+  {
193
+    component: 'Input',
194
+    fieldName: 'username',
195
+    label: '登录用户名',
196
+    componentProps: {
197
+      placeholder: '请输入登录用户名',
198
+      maxlength: 50,
199
+    },
200
+  },
201
+  {
202
+    component: 'Input',
203
+    fieldName: 'password',
204
+    label: '登录密码',
205
+    componentProps: {
206
+      placeholder: '请输入登录密码',
207
+      maxlength: 100,
208
+    },
209
+  },
210
+  {
211
+    component: 'Input',
212
+    fieldName: 'rtspUrl',
213
+    label: 'RTSP流地址',
214
+    componentProps: {
215
+      placeholder: '请输入RTSP视频流地址',
216
+      maxlength: 200,
217
+    },
218
+  },
219
+  {
220
+    component: 'Input',
221
+    fieldName: 'httpUrl',
222
+    label: 'HTTP流地址',
223
+    componentProps: {
224
+      placeholder: '请输入HTTP视频流地址',
225
+      maxlength: 200,
226
+    },
227
+  },
228
+  {
229
+    component: 'Input',
230
+    fieldName: 'location',
231
+    label: '摄像头位置',
232
+    componentProps: {
233
+      placeholder: '请输入摄像头位置',
234
+      maxlength: 100,
235
+    },
236
+  },
237
+  {
238
+    component: 'InputNumber',
239
+    fieldName: 'longitude',
240
+    label: '经度',
241
+    componentProps: {
242
+      placeholder: '请输入经度',
243
+      style: {
244
+        width: '100%',
245
+      },
246
+    },
247
+  },
248
+  {
249
+    component: 'InputNumber',
250
+    fieldName: 'latitude',
251
+    label: '纬度',
252
+    componentProps: {
253
+      placeholder: '请输入纬度',
254
+      style: {
255
+        width: '100%',
256
+      },
257
+    },
258
+  },
259
+  {
260
+    component: 'Select',
261
+    fieldName: 'aiEnabled',
262
+    label: '是否启用AI识别',
263
+    componentProps: {
264
+      placeholder: '请选择是否启用AI识别',
265
+      options: [
266
+        { label: '是', value: '1' },
267
+        { label: '否', value: '0' },
268
+      ],
269
+    },
270
+  },
271
+  {
272
+    component: 'Select',
273
+    fieldName: 'status',
274
+    label: '设备状态',
275
+    componentProps: {
276
+      placeholder: '请选择设备状态',
277
+      options: [
278
+        { label: '在线', value: '0' },
279
+        { label: '离线', value: '1' },
280
+      ],
281
+    },
282
+  },
283
+];
284
+

+ 117 - 0
apps/web-ele/src/views/aiAnalysis/camera/camera-drawer.vue

@@ -0,0 +1,117 @@
1
+<script setup lang="ts">
2
+import { onMounted, ref } from 'vue';
3
+
4
+import { useVbenDrawer, useVbenForm } from '@vben/common-ui';
5
+
6
+import { addSecurityCamera, getSecurityCameraDetail, updateSecurityCamera } from '#/api/aiAnalysis/aiAnalysis';
7
+import { drawerFormSchema } from './camera-data';
8
+
9
+const emit = defineEmits<{
10
+  reload: [];
11
+}>();
12
+
13
+// 响应式变量
14
+const isUpdateRef = ref<boolean>(false);
15
+
16
+// 初始化表单
17
+const [Form, formApi] = useVbenForm({
18
+  showDefaultActions: false,
19
+  schema: drawerFormSchema(),
20
+});
21
+
22
+// 初始化抽屉
23
+const [Drawer, drawerApi] = useVbenDrawer({
24
+  async onOpenChange(isOpen) {
25
+    if (!isOpen) {
26
+      return;
27
+    }
28
+    await handleDrawerOpen();
29
+  },
30
+
31
+  async onConfirm() {
32
+    await handleFormSubmit();
33
+  },
34
+
35
+  onClosed() {
36
+    formApi.resetForm();
37
+  },
38
+});
39
+
40
+/**
41
+ * 处理抽屉打开逻辑
42
+ */
43
+async function handleDrawerOpen() {
44
+  try {
45
+    drawerApi.drawerLoading(true);
46
+    const { cameraId, isUpdate } = drawerApi.getData();
47
+    isUpdateRef.value = isUpdate;
48
+
49
+    // 处理编辑模式的数据回显
50
+    if (isUpdate && cameraId) {
51
+      await loadCameraDetail(cameraId);
52
+    }
53
+  } catch (error) {
54
+    console.error('打开抽屉失败:', error);
55
+  } finally {
56
+    drawerApi.drawerLoading(false);
57
+  }
58
+}
59
+
60
+/**
61
+ * 加载摄像头详情数据
62
+ * @param cameraId 摄像头ID
63
+ */
64
+async function loadCameraDetail(cameraId: number | string) {
65
+  try {
66
+    const res = await getSecurityCameraDetail(cameraId);
67
+
68
+    // 确保从正确的响应结构中获取数据
69
+    const detailData = res?.data || res;
70
+
71
+    // 设置表单值
72
+    await formApi.setValues(detailData);
73
+  } catch (error) {
74
+    console.error('获取摄像头详情失败:', error);
75
+  }
76
+}
77
+
78
+/**
79
+ * 处理表单提交
80
+ */
81
+async function handleFormSubmit() {
82
+  try {
83
+    // 验证表单
84
+    const { valid } = await formApi.validate();
85
+    if (!valid) {
86
+      return;
87
+    }
88
+
89
+    // 获取表单数据
90
+    const data = await formApi.getValues();
91
+    console.log('表单数据:', data);
92
+
93
+    // 提交请求
94
+    await (isUpdateRef.value
95
+      ? updateSecurityCamera({ ...data, cameraId: drawerApi.getData().cameraId })
96
+      : addSecurityCamera(data));
97
+
98
+    // 触发重载事件并关闭抽屉
99
+    emit('reload');
100
+    drawerApi.close();
101
+  } catch (error) {
102
+    console.error('保存摄像头信息失败:', error);
103
+  }
104
+}
105
+</script>
106
+
107
+<template>
108
+  <Drawer :title="isUpdateRef ? '编辑摄像头' : '新增摄像头'">
109
+    <Form />
110
+  </Drawer>
111
+</template>
112
+
113
+<style scoped>
114
+:deep(.vben-drawer-content) {
115
+  padding: 20px;
116
+}
117
+</style>

+ 172 - 0
apps/web-ele/src/views/aiAnalysis/camera/detail.vue

@@ -0,0 +1,172 @@
1
+<script setup lang="ts">
2
+import { onMounted, ref } from 'vue';
3
+import { useRoute } from 'vue-router';
4
+
5
+import { Page } from '@vben/common-ui';
6
+import { ElCard, ElDescriptions, ElDescriptionsItem } from 'element-plus';
7
+
8
+import { getSecurityCameraDetail } from '#/api/aiAnalysis/aiAnalysis';
9
+
10
+// 摄像头详情数据
11
+const cameraInfo = ref({});
12
+
13
+const route = useRoute();
14
+const cameraId = ref(Number(route.params.cameraId));
15
+
16
+
17
+// 初始化数据
18
+const init = async () => {
19
+  try {
20
+    const res = await getSecurityCameraDetail(cameraId.value);
21
+    cameraInfo.value = res || {};
22
+  } catch (error) {
23
+    console.error('获取摄像头详情失败:', error);
24
+  }
25
+};
26
+
27
+onMounted(async () => {
28
+  await init();
29
+});
30
+</script>
31
+
32
+<template>
33
+  <Page title="摄像头详情" :auto-content-height="true">
34
+    <div class="boxdev">
35
+      <div class="min-w-0 flex-1">
36
+        <!-- 基础信息卡片 -->
37
+        <ElCard>
38
+          <template #header>
39
+            <div class="flex items-center gap-4">
40
+              <div
41
+                style="width: 4px; height: 12px; background-color: #215acd"
42
+              ></div>
43
+              <span
44
+                class="text-lg font-bold text-gray-800"
45
+                style="font-size: 14px; font-weight: 600"
46
+              >
47
+                基本信息
48
+              </span>
49
+            </div>
50
+          </template>
51
+          <ElDescriptions class="camera-info" :column="4">
52
+            <ElDescriptionsItem label="摄像头位置:">
53
+              {{ cameraInfo.location || '-' }}
54
+            </ElDescriptionsItem>
55
+            <ElDescriptionsItem label="所属场站:">
56
+              {{ cameraInfo.stationName || '-' }}
57
+            </ElDescriptionsItem>
58
+            <ElDescriptionsItem label="通道地址:">
59
+              {{ cameraInfo.httpUrl || '-' }}
60
+            </ElDescriptionsItem>
61
+            <ElDescriptionsItem label="摄像头名称:">
62
+              {{ cameraInfo.cameraName || '-' }}
63
+            </ElDescriptionsItem>
64
+
65
+            <ElDescriptionsItem label="摄像头编码:">
66
+              {{ cameraInfo.cameraCode || '-' }}
67
+            </ElDescriptionsItem>
68
+            <ElDescriptionsItem label="摄像头类型:">
69
+              {{ cameraInfo.cameraType || '-' }}
70
+            </ElDescriptionsItem>
71
+            <ElDescriptionsItem label="IP地址:">
72
+              {{ cameraInfo.ipAddress || '-' }}
73
+            </ElDescriptionsItem>
74
+            <ElDescriptionsItem label="端口号:">
75
+              {{ cameraInfo.port || '-' }}
76
+            </ElDescriptionsItem>
77
+
78
+            <ElDescriptionsItem label="登录用户名:">
79
+              {{ cameraInfo.username || '-' }}
80
+            </ElDescriptionsItem>
81
+            <ElDescriptionsItem label="RTSP流地址:">
82
+              {{ cameraInfo.rtspUrl || '-' }}
83
+            </ElDescriptionsItem>
84
+            <ElDescriptionsItem label="是否启用AI:">
85
+              {{ cameraInfo.aiEnabled === '1' ? '是' : '否' }}
86
+            </ElDescriptionsItem>
87
+            <ElDescriptionsItem label="设备状态:">
88
+              {{ 
89
+                cameraInfo.status === '0' ? '在线' : 
90
+                cameraInfo.status === '1' ? '离线' : 
91
+                cameraInfo.status === '2' ? '故障' : '-' 
92
+              }}
93
+            </ElDescriptionsItem>
94
+
95
+            <ElDescriptionsItem label="对接平台类型:">
96
+              {{ cameraInfo.platformType || '-' }}
97
+            </ElDescriptionsItem>
98
+            <ElDescriptionsItem label="平台设备ID:">
99
+              {{ cameraInfo.platformId || '-' }}
100
+            </ElDescriptionsItem>
101
+            <ElDescriptionsItem label="最后在线时间:">
102
+              {{ cameraInfo.lastOnlineTime || '-' }}
103
+            </ElDescriptionsItem>
104
+            <ElDescriptionsItem label="最后维护时间:">
105
+              {{ cameraInfo.lastMaintenance || '-' }}
106
+            </ElDescriptionsItem>
107
+
108
+            <ElDescriptionsItem label="维护周期(天):">
109
+              {{ cameraInfo.maintenanceCycle || '-' }}
110
+            </ElDescriptionsItem>
111
+            <ElDescriptionsItem label="经度:">
112
+              {{ cameraInfo.longitude || '-' }}
113
+            </ElDescriptionsItem>
114
+            <ElDescriptionsItem label="纬度:">
115
+              {{ cameraInfo.latitude || '-' }}
116
+            </ElDescriptionsItem>
117
+          </ElDescriptions>
118
+        </ElCard>
119
+      </div>
120
+    </div>
121
+  </Page>
122
+</template>
123
+
124
+<style scoped lang="scss">
125
+.boxdev {
126
+  display: flex;
127
+  gap: 20px;
128
+}
129
+
130
+.boxdev :deep(.el-card) {
131
+  border: none !important;
132
+  box-shadow: none !important;
133
+}
134
+
135
+.boxdev :deep(.el-card__header) {
136
+  padding-top: 18px !important;
137
+  padding-bottom: 4px !important;
138
+  padding-left: 0px !important;
139
+  border-bottom: none !important;
140
+}
141
+
142
+.boxdev :deep(.el-card__body) {
143
+  padding-top: 16px !important;
144
+}
145
+
146
+::v-deep .el-descriptions__body {
147
+  background: #ffffff00 !important;
148
+}
149
+
150
+.boxdev > div:nth-child(1) {
151
+  flex: 1;
152
+  min-width: 0;
153
+}
154
+
155
+.camera-info {
156
+  :deep(.el-descriptions__label) {
157
+    font-size: 14px;
158
+    font-weight: 400;
159
+    color: var(--text-color-secondary);
160
+  }
161
+
162
+  :deep(.el-descriptions__content) {
163
+    font-size: 14px;
164
+    font-weight: 500;
165
+    color: var(--text-color-primary);
166
+  }
167
+
168
+  :deep(.el-descriptions__table) {
169
+    background: transparent !important;
170
+  }
171
+}
172
+</style>

+ 195 - 0
apps/web-ele/src/views/aiAnalysis/camera/index.vue

@@ -0,0 +1,195 @@
1
+<script setup lang="ts">
2
+import type { VbenFormProps } from '@vben/common-ui';
3
+import type { VxeGridProps } from '#/adapter/vxe-table';
4
+
5
+import { onMounted, ref } from 'vue';
6
+import { useRouter } from 'vue-router';
7
+import { Page, useVbenDrawer } from '@vben/common-ui';
8
+import { ElMessageBox } from 'element-plus';
9
+import { useAccess } from '@vben/access';
10
+
11
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
12
+import { deleteSecurityCamera, exportSecurityCamera, getSecurityCameraList } from '#/api/aiAnalysis/aiAnalysis';
13
+import { commonDownloadExcel } from '#/utils/file/download';
14
+
15
+import { queryFormSchema, tableColumns } from './camera-data';
16
+import CameraDrawerComp from './camera-drawer.vue';
17
+
18
+const router = useRouter();
19
+
20
+const formOptions: VbenFormProps = {
21
+  commonConfig: {
22
+    labelWidth: 80,
23
+    componentProps: {
24
+      allowClear: true,
25
+    },
26
+  },
27
+  schema: queryFormSchema(),
28
+  wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
29
+};
30
+
31
+const gridOptions: VxeGridProps = {
32
+  checkboxConfig: {
33
+    highlight: true,
34
+    reserve: true,
35
+    trigger: 'default',
36
+  },
37
+  columns: tableColumns,
38
+  size: 'medium',
39
+  height: 'auto',
40
+  proxyConfig: {
41
+    ajax: {
42
+      query: async ({ page }, formValues = {}) => {
43
+        const resp = await getSecurityCameraList({
44
+          ...formValues,
45
+          pageNum: page.currentPage,
46
+          pageSize: page.pageSize,
47
+        });
48
+        return { items: resp.rows, total: resp.total };
49
+      },
50
+    },
51
+  },
52
+  rowConfig: {
53
+    keyField: 'cameraId',
54
+  },
55
+  toolbarConfig: {
56
+    custom: true,
57
+    refresh: true,
58
+    zoom: true,
59
+  },
60
+  id: 'aiAnalysis-camera-index',
61
+};
62
+
63
+const [BasicTable, basicTableApi] = useVbenVxeGrid({
64
+  formOptions,
65
+  gridOptions,
66
+});
67
+
68
+const [CameraDrawer, drawerApi] = useVbenDrawer({
69
+  connectedComponent: CameraDrawerComp,
70
+});
71
+
72
+const { hasAccessByCodes } = useAccess();
73
+
74
+// 新增摄像头
75
+function openDrawer() {
76
+  drawerApi.setData({ isUpdate: false }).open();
77
+}
78
+
79
+// 编辑摄像头
80
+function handleEdit(row: any) {
81
+  drawerApi.setData({ ...row, isUpdate: true }).open();
82
+}
83
+
84
+// 查看摄像头详情
85
+function handleView(row: any) {
86
+  // 跳转到详情页面
87
+  router.push({
88
+    name: 'CameraDetail',
89
+    params: { cameraId: row.cameraId },
90
+  });
91
+}
92
+
93
+// 单个删除摄像头
94
+async function confirmEvent(row: any) {
95
+  await deleteSecurityCamera([row.cameraId]);
96
+  await basicTableApi.reload();
97
+}
98
+
99
+// 批量删除摄像头
100
+function deleteHandle() {
101
+  const checkRecords = basicTableApi.grid.getCheckboxRecords();
102
+  const ids = checkRecords.map((item: any) => item.cameraId);
103
+  if (ids.length <= 0) {
104
+    return;
105
+  }
106
+
107
+  ElMessageBox.confirm(`确认删除选中的${ids.length}条数据吗?`, '提示', {
108
+    confirmButtonText: '确定',
109
+    cancelButtonText: '取消',
110
+    type: 'warning',
111
+  }).then(async () => {
112
+    await deleteSecurityCamera(ids);
113
+    await basicTableApi.reload();
114
+  });
115
+}
116
+
117
+// 导出摄像头数据
118
+async function exportHandle() {
119
+  await commonDownloadExcel(
120
+    exportSecurityCamera,
121
+    '摄像头设备数据',
122
+    basicTableApi.formApi.form.values,
123
+    {
124
+      fieldMappingTime: formOptions.fieldMappingTime,
125
+    },
126
+  );
127
+}
128
+</script>
129
+
130
+<template>
131
+  <Page :auto-content-height="true">
132
+    <BasicTable table-title="摄像头设备列表">
133
+      <template #toolbar-tools>
134
+        <ElSpace>
135
+          <ElButton
136
+            type="danger"
137
+            :disabled="!(basicTableApi?.grid?.getCheckboxRecords?.()?.length > 0)"
138
+            @click="deleteHandle"
139
+            v-access:code="['aiAnalysis:camera:remove']"
140
+          >
141
+            批量删除
142
+          </ElButton>
143
+          <ElButton
144
+            type="primary"
145
+            @click="openDrawer"
146
+            v-access:code="['aiAnalysis:camera:add']"
147
+          >
148
+            新增摄像头
149
+          </ElButton>
150
+        </ElSpace>
151
+      </template>
152
+      <template #action="{ row }">
153
+        <ElSpace>
154
+          <ElButton
155
+            size="small"
156
+            type="info"
157
+            plain
158
+            @click="handleView(row)"
159
+            v-access:code="['aiAnalysis:camera:view']"
160
+          >
161
+            查看
162
+          </ElButton>
163
+          <ElButton
164
+            size="small"
165
+            type="primary"
166
+            plain
167
+            @click="handleEdit(row)"
168
+            v-access:code="['aiAnalysis:camera:edit']"
169
+          >
170
+            编辑
171
+          </ElButton>
172
+          <ElPopconfirm title="确认删除" @confirm="confirmEvent(row)">
173
+            <template #reference>
174
+              <ElButton
175
+                size="small"
176
+                type="danger"
177
+                plain
178
+                v-access:code="['aiAnalysis:camera:remove']"
179
+              >
180
+                删除
181
+              </ElButton>
182
+            </template>
183
+          </ElPopconfirm>
184
+        </ElSpace>
185
+      </template>
186
+    </BasicTable>
187
+    <CameraDrawer @reload="basicTableApi.reload()" />
188
+  </Page>
189
+</template>
190
+
191
+<style scoped lang="scss">
192
+:deep(.el-tooltip__trigger:focus) {
193
+  outline: none;
194
+}
195
+</style>

+ 628 - 0
apps/web-ele/src/views/aiAnalysis/home/index.vue

@@ -0,0 +1,628 @@
1
+<template>
2
+  <div class="ai-analysis-home">
3
+    <!-- 顶部搜索块 -->
4
+    <div class="search-section">
5
+      <el-form :inline="true" :model="searchForm" class="demo-form-inline">
6
+        <el-form-item label="场站">
7
+          <el-select v-model="searchForm.stationId" placeholder="全部" clearable style="width: 220px;">
8
+            <el-option label="全部" value=""></el-option>
9
+            <!-- 这里需要动态加载场站数据 -->
10
+            <el-option 
11
+              v-for="station in stationList" 
12
+              :key="station.stationId" 
13
+              :label="station.stationName" 
14
+              :value="station.stationId"
15
+            ></el-option>
16
+          </el-select>
17
+        </el-form-item>
18
+        <el-form-item>
19
+          <el-button type="primary" @click="handleQuery">查询</el-button>
20
+        </el-form-item>
21
+        <el-form-item>
22
+          <el-button @click="handleReset">重置</el-button>
23
+        </el-form-item>
24
+      </el-form>
25
+    </div>
26
+
27
+    <!-- 四个卡片 -->
28
+    <div class="cards-section">
29
+      <div class="card-item">
30
+        <div class="card-icon ai-monitor">
31
+          <img :src="aiMonitorIcon" alt="AI监控图标" />
32
+        </div>
33
+        <div class="card-title">AI监控场站</div>
34
+        <div class="card-value">{{ detectStatistics.stationCount || 0 }}个</div>
35
+        <div class="card-percentage">占所有场站<span>{{ detectStatistics.stationRatio || 0 }}%</span></div>
36
+      </div>
37
+      <div class="card-item">
38
+        <div class="card-icon camera">
39
+          <img :src="cameraIcon" alt="摄像头图标" />
40
+        </div>
41
+        <div class="card-title">接入摄像头数量</div>
42
+        <div class="card-value">{{ detectStatistics.cameraCount || 0 }}个</div>
43
+        <div class="card-percentage">占所有设备</div>
44
+      </div>
45
+      <div class="card-item">
46
+        <div class="card-icon monitor-type">
47
+          <img :src="monitorTypeIcon" alt="监测类型图标" />
48
+        </div>
49
+        <div class="card-title">监测类型数量</div>
50
+        <div class="card-value">{{ detectStatistics.alertTypes || 0 }}个</div>
51
+        <div class="card-percentage">覆盖所有类型</div>
52
+      </div>
53
+      <div class="card-item">
54
+        <div class="card-icon ai-alert">
55
+          <img :src="aiAlertIcon" alt="AI预警图标" />
56
+        </div>
57
+        <div class="card-title">AI预警总数</div>
58
+        <div class="card-value">{{ warningStatistics.alertCount || 0 }}个</div>
59
+        <div class="card-percentage">涉及预警类型<span>{{ warningStatistics.alertTypeCount || 0 }}个</span></div>
60
+      </div>
61
+    </div>
62
+
63
+    <!-- 左右模块 -->
64
+    <div class="main-section">
65
+      <!-- 预警类型模块 -->
66
+      <div class="left-module">
67
+        <div class="module-title">预警类型</div>
68
+        <div class="warning-types-list">
69
+          <div 
70
+            v-for="warning in warningStatistics" 
71
+            :key="warning.alertType"
72
+            class="warning-type-item"
73
+          >
74
+            <div class="warning-type-name">{{ warning.alertTypeName }}</div>
75
+            <div class="warning-type-stats">
76
+              <div class="warning-type-count">当前有 {{ warning.count || 0 }} 个预警未读</div>
77
+              <div class="warning-type-month-count">本月共发生 {{ warning.monthCount || 0 }} 次</div>
78
+            </div>
79
+          </div>
80
+        </div>
81
+      </div>
82
+
83
+      <!-- 未处理预警记录模块 -->
84
+      <div class="right-module">
85
+        <div class="module-header">
86
+          <div class="module-title" style="margin-bottom: 0;">未处理预警记录</div>
87
+          <div class="view-all" @click="viewAllAlerts">查看全部</div>
88
+        </div>
89
+        <div class="alert-records-list">
90
+          <div 
91
+            v-for="alert in alertRecords" 
92
+            :key="alert.alertId"
93
+            class="alert-record-item">
94
+            <div class="alert-image">
95
+              <img :src="alert.snapshotPathUrl[0] || 'https://ts1.tc.mm.bing.net/th/id/R-C.8bbf769b39bb26eefb9b6de51c23851d?rik=crTnc5i8A%2b8p7A&riu=http%3a%2f%2fpicview.iituku.com%2fcontentm%2fzhuanji%2fimg%2f202207%2f09%2fe7196ac159f7cf2b.jpg%2fnu&ehk=DYPLVpoNAXLj5qzwgR5vHf9DladFh%2b34s4UcuP3Kn6E%3d&risl=&pid=ImgRaw&r=0'" alt="预警图片" />
96
+            </div>
97
+            <div class="alert-info">
98
+              <div class="alert-title-row">
99
+                <span class="alert-title">{{ alert.alertTypeName || '预警' }}</span>
100
+                <span class="alert-time">{{ formatDate(alert.alertTime) }}</span>
101
+              </div>
102
+              <div class="alert-station-row">
103
+                <span class="alert-station">{{ alert.stationName || '未知场站' }}</span>
104
+              </div>
105
+            </div>
106
+            <div class="alert-arrow"  @click="viewAlertDetail(alert.alertId)">
107
+              <img :src="angleRightIcon" alt="箭头图标" />
108
+            </div>
109
+          </div>
110
+        </div>
111
+      </div>
112
+    </div>
113
+  </div>
114
+</template>
115
+
116
+<script setup lang="ts">
117
+import { ref, onMounted, computed } from 'vue';
118
+import { useRouter } from 'vue-router';
119
+import { getDetectStatistics, getWarningStatistics, getSecurityAlertList } from '#/api/aiAnalysis/aiAnalysis';
120
+import { selectAllSysStation } from '#/api/system/infoEntry/stationInfo/stationInfo';
121
+import type { DetectStatisticsModel, WarningStatisticsModel, SecurityAlertModel } from '#/api/aiAnalysis/model';
122
+import aiMonitorIcon from '#/assets/icon/ai-monitor.png';
123
+import cameraIcon from '#/assets/icon/camera.png';
124
+import monitorTypeIcon from '#/assets/icon/monitor-type.png';
125
+import aiAlertIcon from '#/assets/icon/ai-alert.png'; 
126
+import angleRightIcon from '#/assets/icon/angle-right-circle.png';
127
+// 路由
128
+const router = useRouter();
129
+
130
+// 搜索表单
131
+const searchForm = ref({
132
+  stationId: ''
133
+});
134
+
135
+// 场站列表
136
+const stationList = ref<any[]>([]);
137
+
138
+// 检测统计数据
139
+const detectStatistics = ref<DetectStatisticsModel>({});
140
+
141
+// 预警分类统计数据
142
+const warningStatistics = ref<WarningStatisticsModel[]>([]);
143
+
144
+// 预警记录数据
145
+const alertRecords = ref<(SecurityAlertModel & { alertTypeName?: string })[]>([]);
146
+
147
+// 处理查询
148
+const handleQuery = async () => {
149
+  await fetchData();
150
+};
151
+
152
+// 处理重置
153
+const handleReset = async () => {
154
+  searchForm.value.stationId = '';
155
+  await fetchData();
156
+};
157
+
158
+// 格式化日期
159
+const formatDate = (dateStr?: string) => {
160
+  if (!dateStr) return '';
161
+  const date = new Date(dateStr);
162
+  return date.toLocaleString('zh-CN');
163
+};
164
+
165
+// 查看全部预警
166
+const viewAllAlerts = () => {
167
+  // 跳转到预警记录列表页面
168
+  router.push('/aiAnalysis/warning');
169
+
170
+};
171
+
172
+// 查看预警详情
173
+const viewAlertDetail = (alertId?: number) => {
174
+  if (alertId) {
175
+    router.push({
176
+      name: 'WarningDetail',
177
+      params: { alertId: alertId },
178
+    }); 
179
+  }
180
+};
181
+
182
+// 获取预警类型名称
183
+const getAlertTypeName = (alertType?: string) => {
184
+  const typeMap: Record<string, string> = {
185
+    'fire': '火焰',
186
+    'smoke': '烟雾',
187
+    'fire_danger': '火灾',
188
+    'intrusion': '入侵'
189
+  };
190
+  return typeMap[alertType || ''] || alertType;
191
+};
192
+
193
+// 获取场站列表
194
+const fetchStationList = async () => {
195
+  try {
196
+    const resp = await selectAllSysStation();
197
+    const data = resp || [];
198
+    if (Array.isArray(data)) {
199
+      stationList.value = data.map((item: any) => ({
200
+        stationId: item.id,
201
+        stationName: item.stationName
202
+      }));
203
+    }
204
+  } catch (error) {
205
+    console.error('获取场站列表失败:', error);
206
+  }
207
+};
208
+
209
+// 获取数据
210
+const fetchData = async () => {
211
+  try {
212
+    // 获取检测统计数据
213
+    const detectRes = await getDetectStatistics();
214
+    console.log('detectRes', detectRes);
215
+    if (detectRes) {
216
+      detectStatistics.value = detectRes || {};
217
+    }
218
+
219
+    // 获取预警分类统计数据
220
+    const warningRes = await getWarningStatistics();
221
+    console.log('warningRes', warningRes);
222
+    if (warningRes) {
223
+      warningStatistics.value = warningRes || [];
224
+    }
225
+
226
+    // 获取未处理预警记录
227
+    const alertRes = await getSecurityAlertList({
228
+      status: 'unhandled',
229
+      stationId: searchForm.value.stationId,
230
+      pageSize: 5
231
+    });
232
+    console.log('alertRes', alertRes);
233
+    if (alertRes) {
234
+      const alerts = alertRes || [];
235
+
236
+      // 添加预警类型名称
237
+      alertRecords.value = alerts.rows || [];
238
+      alertRecords.value = alertRecords.value.map((alert: SecurityAlertModel) => ({
239
+        ...alert,
240
+        alertTypeName: getAlertTypeName(alert.alertType)
241
+      }));
242
+    }
243
+  } catch (error) {
244
+    console.error('获取数据失败:', error);
245
+  }
246
+};
247
+
248
+// 初始化
249
+onMounted(async () => {
250
+  // 获取场站列表
251
+  await fetchStationList();
252
+  await fetchData();
253
+});
254
+</script>
255
+
256
+<style scoped>
257
+.ai-analysis-home {
258
+  padding: 20px;
259
+  background-color: #f5f7fa;
260
+  min-height: calc(100vh - 120px);
261
+}
262
+
263
+/* 搜索块 */
264
+.search-section {
265
+  background-color: #fff;
266
+  padding: 20px;
267
+  border-radius: 8px;
268
+  margin-bottom: 12px;
269
+  box-shadow: 0 2px 4px rgba(63, 50, 50, 0.1);
270
+  
271
+  :deep(.el-form-item) {
272
+    margin-bottom: 0;
273
+  }
274
+}
275
+
276
+/* 四个卡片 */
277
+.cards-section {
278
+  display: grid;
279
+  grid-template-columns: repeat(4, 1fr);
280
+  gap: 12px;
281
+  margin-bottom: 12px;
282
+}
283
+
284
+.card-item {
285
+  background-color: #fff;
286
+  padding: 25px;
287
+  border-radius: 8px;
288
+  box-shadow: 0 2px 4px rgba(63, 50, 50, 0.1);
289
+  text-align: left;
290
+  transition: transform 0.3s ease;
291
+}
292
+
293
+.card-item:hover {
294
+  transform: translateY(-5px);
295
+}
296
+
297
+.card-icon {
298
+  width: 48px;
299
+  height: 48px;
300
+  border-radius: 50%;
301
+  background-color: #f0f9ff;
302
+  display: flex;
303
+  align-items: center;
304
+  justify-content: center;
305
+  font-size: 24px;
306
+  color: #3b82f6;
307
+  margin-bottom: 24px;
308
+}
309
+.card-icon img {
310
+  width: 24px;
311
+  height: 24px;
312
+}
313
+.card-icon.ai-monitor {
314
+  background-color: #B5E4CA;
315
+  color: #10b981;
316
+}
317
+
318
+.card-icon.camera {
319
+  background-color: #BEDAFF;
320
+  color: #f59e0b;
321
+}
322
+
323
+.card-icon.monitor-type {
324
+  background-color: #FFE4BA;
325
+  color: #ef4444;
326
+}
327
+
328
+.card-icon.ai-alert {
329
+  background-color: #FDCDC5;
330
+  color: #8b5cf6;
331
+}
332
+
333
+.card-title {
334
+  color: var(--arco-e-668--light-text-color-text-2, #4E5969);
335
+  font-family: "Alibaba PuHuiTi 2.0";
336
+  font-size: 18px;
337
+  font-style: normal;
338
+  font-weight: 600;
339
+  line-height: 16px; /* 88.889% */
340
+  letter-spacing: -0.18px;
341
+}
342
+
343
+.card-value {
344
+  color: var(--, #31373D);
345
+  font-family: "Alibaba PuHuiTi 2.0";
346
+  font-size: 48px;
347
+  font-style: normal;
348
+  font-weight: 600;
349
+  line-height: 48px; /* 100% */
350
+  letter-spacing: -1.44px;
351
+  padding: 8px;
352
+}
353
+
354
+.card-percentage {
355
+  color: #6F767E;
356
+  font-family: "Alibaba PuHuiTi 2.0";
357
+  font-size: 14px;
358
+  font-style: normal;
359
+  font-weight: 400;
360
+  line-height: 16px; /* 114.286% */
361
+  letter-spacing: -0.14px;
362
+}
363
+
364
+.card-percentage span {
365
+  color: var(--, #38B865);
366
+  font-family: "Alibaba PuHuiTi 2.0";
367
+  font-size: 14px;
368
+  font-style: normal;
369
+  font-weight: 400;
370
+  line-height: 16px;
371
+  letter-spacing: -0.14px;
372
+}
373
+/* 主模块 */
374
+.main-section {
375
+  display: grid;
376
+  grid-template-columns: 1fr 1fr;
377
+  gap: 20px;
378
+}
379
+
380
+.module-title {
381
+  font-size: 16px;
382
+  font-weight: bold;
383
+  color: #333;
384
+  margin: 24px 0px;
385
+  border-left: 4px solid #215ACD;
386
+  padding-left: 20px;
387
+}
388
+/* 预警类型模块 */
389
+.left-module {
390
+  background-color: #fff;
391
+  border-radius: 8px;
392
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
393
+  height: 500px;
394
+  display: flex;
395
+  flex-direction: column;
396
+}
397
+
398
+.warning-types-list {
399
+  flex: 1;
400
+  overflow-y: auto;
401
+}
402
+
403
+.warning-type-item {
404
+  display: flex;
405
+  background-color: #F7F9FA;
406
+  justify-content: space-between;
407
+  align-items: center;
408
+  padding: 25px;
409
+  margin: 0 24px 12px 24px;
410
+}
411
+
412
+.warning-type-item:last-child {
413
+  border-bottom: none;
414
+}
415
+
416
+.warning-type-name {
417
+  font-size: 14px;
418
+  color: #333;
419
+  font-weight: 500;
420
+}
421
+
422
+.warning-type-stats {
423
+  text-align: right;
424
+  font-size: 12px;
425
+  color: #666;
426
+}
427
+
428
+.warning-type-count {
429
+  color: var(---B, #215ACD);
430
+  font-family: "PingFang SC";
431
+  font-size: 14px;
432
+  font-style: normal;
433
+  font-weight: 500;
434
+  line-height: 14px; /* 100% */
435
+  letter-spacing: 0.56px;
436
+}
437
+
438
+.warning-type-month-count {
439
+  color: var(--, #31373D);
440
+  text-align: right;
441
+  font-family: "PingFang SC";
442
+  font-size: 14px;
443
+  font-style: normal;
444
+  font-weight: 500;
445
+  line-height: 14px; /* 100% */
446
+  letter-spacing: 0.56px;
447
+  margin-top: 8px;
448
+}
449
+
450
+.right-module {
451
+  background-color: #fff;
452
+  border-radius: 8px;
453
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
454
+  height: 500px;
455
+  display: flex;
456
+  flex-direction: column;
457
+}
458
+
459
+.module-header {
460
+  display: flex;
461
+  justify-content: space-between;
462
+  align-items: center;
463
+  margin-bottom: 24px;
464
+}
465
+
466
+.view-all {
467
+  font-size: 14px;
468
+  color: #215ACD;
469
+  cursor: pointer;
470
+  margin-top: 24px;
471
+  margin-right: 35px;
472
+}
473
+
474
+.view-all:hover {
475
+  text-decoration: underline;
476
+}
477
+
478
+.alert-records-list {
479
+  display: flex;
480
+  flex-direction: column;
481
+  gap: 12px;
482
+  flex: 1;
483
+  overflow-y: auto;
484
+  margin: 0 24px;
485
+}
486
+
487
+.alert-record-item {
488
+  display: flex;
489
+  align-items: center;
490
+  padding: 5px;
491
+  border: 1px solid #f0f0f0;
492
+  border-radius: 8px;
493
+  cursor: pointer;
494
+  transition: all 0.3s ease;
495
+}
496
+
497
+.alert-record-item:hover {
498
+  /* border-color: #3b82f6;
499
+  box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1); */
500
+}
501
+
502
+.alert-image {
503
+  width: 48px;
504
+  height: 48px;
505
+  margin-right: 12px;
506
+  border-radius: 4px;
507
+  overflow: hidden;
508
+  margin: 20px 8px 20px 24px;
509
+}
510
+
511
+.alert-image img {
512
+  width: 100%;
513
+  height: 100%;
514
+  object-fit: cover;
515
+}
516
+
517
+.alert-info {
518
+  flex: 1;
519
+}
520
+
521
+.alert-title-row {
522
+  display: flex;
523
+  align-items: center;
524
+  margin-bottom: 8px;
525
+}
526
+
527
+.alert-title {
528
+  color: var(--, #EB5E12);
529
+  font-family: "PingFang SC";
530
+  font-size: 14px;
531
+  font-style: normal;
532
+  font-weight: 500;
533
+  line-height: 14px; /* 100% */
534
+  letter-spacing: 0.56px;
535
+}
536
+
537
+.alert-time {
538
+  color: var(--, #31373D);
539
+  font-family: "PingFang SC";
540
+  font-size: 14px;
541
+  font-style: normal;
542
+  font-weight: 400;
543
+  line-height: 14px; /* 100% */
544
+  letter-spacing: 0.56px;
545
+  margin-left: 10px;
546
+}
547
+
548
+.alert-station-row {
549
+  display: flex;
550
+  justify-content: space-between;
551
+  align-items: center;
552
+}
553
+
554
+.alert-station {
555
+  color: var(--, #31373D);
556
+  font-family: "PingFang SC";
557
+  font-size: 14px;
558
+  font-style: normal;
559
+  font-weight: 400;
560
+  line-height: 14px; /* 100% */
561
+  letter-spacing: 0.56px;
562
+
563
+}
564
+
565
+.alert-status {
566
+  font-size: 12px;
567
+  color: #ef4444;
568
+  font-weight: 500;
569
+}
570
+
571
+.alert-arrow {
572
+  width: 24px;
573
+  height: 24px;
574
+  margin-right: 24px;
575
+}
576
+
577
+/* 滚动条样式 */
578
+.warning-types-list::-webkit-scrollbar,
579
+.alert-records-list::-webkit-scrollbar {
580
+  width: 6px;
581
+}
582
+
583
+.warning-types-list::-webkit-scrollbar-track,
584
+.alert-records-list::-webkit-scrollbar-track {
585
+  background: #f1f1f1;
586
+  border-radius: 3px;
587
+}
588
+
589
+.warning-types-list::-webkit-scrollbar-thumb,
590
+.alert-records-list::-webkit-scrollbar-thumb {
591
+  background: #c1c1c1;
592
+  border-radius: 3px;
593
+}
594
+
595
+.warning-types-list::-webkit-scrollbar-thumb:hover,
596
+.alert-records-list::-webkit-scrollbar-thumb:hover {
597
+  background: #a1a1a1;
598
+}
599
+
600
+/* 响应式设计 */
601
+@media (max-width: 1200px) {
602
+  .cards-section {
603
+    grid-template-columns: repeat(2, 1fr);
604
+  }
605
+  
606
+  .main-section {
607
+    grid-template-columns: 1fr;
608
+  }
609
+}
610
+
611
+@media (max-width: 768px) {
612
+  .cards-section {
613
+    grid-template-columns: 1fr;
614
+  }
615
+  
616
+  .search-section {
617
+    padding: 16px;
618
+  }
619
+  
620
+  .card-item {
621
+    padding: 16px;
622
+  }
623
+  
624
+  .right-module {
625
+    padding: 16px;
626
+  }
627
+}
628
+</style>

+ 219 - 0
apps/web-ele/src/views/aiAnalysis/warning/detail.vue

@@ -0,0 +1,219 @@
1
+<script setup lang="ts">
2
+import { onMounted, ref } from 'vue';
3
+import { useRoute } from 'vue-router';
4
+
5
+import { Page } from '@vben/common-ui';
6
+import { ElCard, ElDescriptions, ElDescriptionsItem } from 'element-plus';
7
+
8
+import { getSecurityAlertDetail } from '#/api/aiAnalysis/aiAnalysis';
9
+
10
+// 预警详情数据
11
+const warningInfo = ref({});
12
+
13
+const route = useRoute();
14
+const alertId = ref(Number(route.params.alertId));
15
+
16
+
17
+// 初始化数据
18
+const init = async () => {
19
+  try {
20
+    const res = await getSecurityAlertDetail(alertId.value);
21
+    warningInfo.value = res || {};
22
+  } catch (error) {
23
+    console.error('获取预警详情失败:', error);
24
+  }
25
+};
26
+
27
+onMounted(async () => {
28
+  await init();
29
+});
30
+</script>
31
+
32
+<template>
33
+  <Page title="预警详情" :auto-content-height="true">
34
+    <div class="boxdev">
35
+      <div class="min-w-0 flex-1">
36
+        <!-- 基础信息卡片 -->
37
+        <ElCard>
38
+          <template #header>
39
+            <div class="flex items-center gap-4">
40
+              <div
41
+                style="width: 4px; height: 12px; background-color: #215acd"
42
+              ></div>
43
+              <span
44
+                class="text-lg font-bold text-gray-800"
45
+                style="font-size: 14px; font-weight: 600"
46
+              >
47
+                基本信息
48
+              </span>
49
+            </div>
50
+          </template>
51
+          <ElDescriptions class="warning-info" :column="4">
52
+            <ElDescriptionsItem label="预警时间:">
53
+              {{ warningInfo.alertTime || '-' }}
54
+            </ElDescriptionsItem>
55
+            <ElDescriptionsItem label="预警场站:">
56
+              {{ warningInfo.stationName || '-' }}
57
+            </ElDescriptionsItem>
58
+            <ElDescriptionsItem label="预警场景:">
59
+              {{ 
60
+                warningInfo.alertType === 'fire' ? '火焰' : 
61
+                warningInfo.alertType === 'smoke' ? '烟雾' : 
62
+                warningInfo.alertType === 'fire_danger' ? '火灾' : 
63
+                warningInfo.alertType === 'intrusion' ? '入侵' : 
64
+                warningInfo.alertType || '-' 
65
+              }}
66
+            </ElDescriptionsItem>
67
+            <ElDescriptionsItem label="预警级别:">
68
+              {{ 
69
+                warningInfo.alertLevel === '1' ? '一般' : 
70
+                warningInfo.alertLevel === '2' ? '重要' : 
71
+                warningInfo.alertLevel === '3' ? '紧急' : 
72
+                warningInfo.alertLevel || '-' 
73
+              }}
74
+            </ElDescriptionsItem>
75
+
76
+            <ElDescriptionsItem label="处理状态:">
77
+              {{ 
78
+                warningInfo.status === 'unhandled' ? '未处理' : 
79
+                warningInfo.status === 'handling' ? '处理中' : 
80
+                warningInfo.status === 'resolved' ? '已解决' : 
81
+                warningInfo.status === 'false_alarm' ? '误报' : 
82
+                warningInfo.status || '-' 
83
+              }}
84
+            </ElDescriptionsItem>
85
+            <ElDescriptionsItem label="摄像头位置:">
86
+              {{ warningInfo.location || '-' }}
87
+            </ElDescriptionsItem>
88
+            <ElDescriptionsItem label="识别置信度:">
89
+              {{ warningInfo.confidence || '-' }}
90
+            </ElDescriptionsItem>
91
+            <ElDescriptionsItem label="预警编号:">
92
+              {{ warningInfo.alertCode || '-' }}
93
+            </ElDescriptionsItem>
94
+
95
+            <ElDescriptionsItem label="场站站长:">
96
+              {{ warningInfo.stationManagerName || '-' }}
97
+            </ElDescriptionsItem>
98
+            <ElDescriptionsItem label="站长电话:">
99
+              {{ warningInfo.stationManagerPhone || '-' }}
100
+            </ElDescriptionsItem>
101
+            <ElDescriptionsItem label="片区经理:">
102
+              {{ warningInfo.areaManagerName || '-' }}
103
+            </ElDescriptionsItem>
104
+            <ElDescriptionsItem label="片区经理电话:">
105
+              {{ warningInfo.areaManagerPhone || '-' }}
106
+            </ElDescriptionsItem>
107
+
108
+            <ElDescriptionsItem label="处理人ID:">
109
+              {{ warningInfo.handleUserId || '-' }}
110
+            </ElDescriptionsItem>
111
+            <ElDescriptionsItem label="处理时间:">
112
+              {{ warningInfo.handleTime || '-' }}
113
+            </ElDescriptionsItem>
114
+            <ElDescriptionsItem label="处理备注:">
115
+              {{ warningInfo.handleRemark || '-' }}
116
+            </ElDescriptionsItem>
117
+            <ElDescriptionsItem label="预警ID:">
118
+              {{ warningInfo.alertId || '-' }}
119
+            </ElDescriptionsItem>
120
+          </ElDescriptions>
121
+        </ElCard>
122
+        <!-- 异常图片卡片 -->
123
+        <ElCard class="mt-4">
124
+          <template #header>
125
+            <div class="flex items-center gap-4">
126
+              <div
127
+                style="width: 4px; height: 12px; background-color: #215acd"
128
+              ></div>
129
+              <span
130
+                class="text-lg font-bold text-gray-800"
131
+                style="font-size: 14px; font-weight: 600"
132
+              >
133
+                异常图片
134
+              </span>
135
+            </div>
136
+          </template>
137
+          <div v-if="warningInfo.snapshotPathUrl" class="snapshot-container">
138
+            <!-- snapshotPathUrl  是一个数组 -->
139
+            <el-image
140
+              v-for="url in warningInfo.snapshotPathUrl"
141
+              :key="url"
142
+              :src="url"
143
+              style="width: 300px; height: 200px"
144
+              fit="cover"
145
+              :preview-src-list="[url]"
146
+            />
147
+          </div>
148
+          <div v-else class="snapshot-container">
149
+            <span>无异常图片</span>
150
+          </div>
151
+        </ElCard>
152
+      </div>
153
+    </div>
154
+  </Page>
155
+</template>
156
+
157
+<style scoped lang="scss">
158
+.boxdev {
159
+  display: flex;
160
+  gap: 20px;
161
+}
162
+
163
+.boxdev :deep(.el-card) {
164
+  border: none !important;
165
+  box-shadow: none !important;
166
+}
167
+
168
+.boxdev :deep(.el-card__header) {
169
+  padding-top: 18px !important;
170
+  padding-bottom: 4px !important;
171
+  padding-left: 0px !important;
172
+  border-bottom: none !important;
173
+}
174
+
175
+.boxdev :deep(.el-card__body) {
176
+  padding-top: 16px !important;
177
+}
178
+
179
+::v-deep .el-descriptions__body {
180
+  background: #ffffff00 !important;
181
+}
182
+
183
+.boxdev > div:nth-child(1) {
184
+  flex: 1;
185
+  min-width: 0;
186
+}
187
+
188
+.warning-info {
189
+  :deep(.el-descriptions__label) {
190
+    font-size: 14px;
191
+    font-weight: 400;
192
+    color: var(--text-color-secondary);
193
+  }
194
+
195
+  :deep(.el-descriptions__content) {
196
+    font-size: 14px;
197
+    font-weight: 500;
198
+    color: var(--text-color-primary);
199
+  }
200
+
201
+  :deep(.el-descriptions__table) {
202
+    background: transparent !important;
203
+  }
204
+}
205
+
206
+.mt-4 {
207
+  margin-top: 16px;
208
+}
209
+
210
+.snapshot-container {
211
+  display: flex;
212
+  align-items: center;
213
+  justify-content: center;
214
+  min-height: 200px;
215
+  border: 1px solid #f0f0f0;
216
+  border-radius: 4px;
217
+  padding: 20px;
218
+}
219
+</style>

+ 127 - 0
apps/web-ele/src/views/aiAnalysis/warning/index.vue

@@ -0,0 +1,127 @@
1
+<script setup lang="ts">
2
+import type { VbenFormProps } from '@vben/common-ui';
3
+import type { VxeGridProps } from '#/adapter/vxe-table';
4
+import { useRouter } from 'vue-router';
5
+import { Page, useVbenDrawer } from '@vben/common-ui';
6
+import { useAccess } from '@vben/access';
7
+
8
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
9
+import { getSecurityAlertList } from '#/api/aiAnalysis/aiAnalysis';
10
+
11
+import { queryFormSchema, tableColumns } from './warning-data';
12
+import WarningDrawerComp from './warning-drawer.vue';
13
+
14
+const router = useRouter();
15
+
16
+const formOptions: VbenFormProps = {
17
+  commonConfig: {
18
+    labelWidth: 80,
19
+    componentProps: {
20
+      allowClear: true,
21
+    },
22
+  },
23
+  schema: queryFormSchema(),
24
+  wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
25
+  fieldMappingTime: [
26
+    [
27
+      'alertTime',
28
+      ['alertStartTime', 'alertEndTime'],
29
+      ['YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss'],
30
+    ],
31
+  ],
32
+};
33
+
34
+const gridOptions: VxeGridProps = {
35
+  checkboxConfig: {
36
+    highlight: true,
37
+    reserve: true,
38
+    trigger: 'default',
39
+  },
40
+  columns: tableColumns,
41
+  size: 'medium',
42
+  height: 'auto',
43
+  proxyConfig: {
44
+    ajax: {
45
+      query: async ({ page }, formValues = {}) => {
46
+        const resp = await getSecurityAlertList({
47
+          ...formValues,
48
+          pageNum: page.currentPage,
49
+          pageSize: page.pageSize,
50
+        });
51
+        return { items: resp.rows, total: resp.total };
52
+      },
53
+    },
54
+  },
55
+  rowConfig: {
56
+    keyField: 'alertId',
57
+  },
58
+  toolbarConfig: {
59
+    custom: true,
60
+    refresh: true,
61
+    zoom: true,
62
+  },
63
+  id: 'aiAnalysis-warning-index',
64
+};
65
+
66
+const [BasicTable, basicTableApi] = useVbenVxeGrid({
67
+  formOptions,
68
+  gridOptions,
69
+});
70
+
71
+const [WarningDrawer, drawerApi] = useVbenDrawer({
72
+  connectedComponent: WarningDrawerComp,
73
+});
74
+
75
+const { hasAccessByCodes } = useAccess();
76
+
77
+// 处理预警
78
+function handleProcess(row: any) {
79
+  drawerApi.setData({ ...row, isProcess: true }).open();
80
+}
81
+
82
+// 查看预警详情
83
+function handleView(row: any) {
84
+  // 跳转到详情页面
85
+  router.push({
86
+    name: 'WarningDetail',
87
+    params: { alertId: row.alertId },
88
+  });
89
+}
90
+</script>
91
+
92
+<template>
93
+  <Page :auto-content-height="true">
94
+    <BasicTable table-title="预警记录列表">
95
+      <template #action="{ row }">
96
+        <ElSpace>
97
+          <ElButton
98
+            size="small"
99
+            type="info"
100
+            plain
101
+            @click="handleView(row)"
102
+            v-access:code="['aiAnalysis:warning:view']"
103
+          >
104
+            查看
105
+          </ElButton>
106
+          <ElButton
107
+            v-if="row.status === 'unhandled'"
108
+            size="small"
109
+            type="primary"
110
+            plain
111
+            @click="handleProcess(row)"
112
+            v-access:code="['aiAnalysis:warning:process']"
113
+          >
114
+            处理
115
+          </ElButton>
116
+        </ElSpace>
117
+      </template>
118
+    </BasicTable>
119
+    <WarningDrawer @reload="basicTableApi.reload()" />
120
+  </Page>
121
+</template>
122
+
123
+<style scoped lang="scss">
124
+:deep(.el-tooltip__trigger:focus) {
125
+  outline: none;
126
+}
127
+</style>

+ 184 - 0
apps/web-ele/src/views/aiAnalysis/warning/warning-data.tsx

@@ -0,0 +1,184 @@
1
+import type { FormSchemaGetter } from '#/adapter/form';
2
+import type { VxeGridProps } from '#/adapter/vxe-table';
3
+
4
+import { selectAllSysStation } from '#/api/system/infoEntry/stationInfo/stationInfo';
5
+import { getDictOptions } from '#/utils/dict';
6
+
7
+// 字典标识常量
8
+const DICT_KEYS = {
9
+  // 预警类型字典标识
10
+  WARNING_TYPE: 'warning_type',
11
+  // 处理状态字典标识
12
+  HANDLE_STATUS: 'handle_status',
13
+};
14
+
15
+// 获取预警类型字典选项
16
+const getWarningTypes = () => getDictOptions(DICT_KEYS.WARNING_TYPE);
17
+
18
+// 获取处理状态字典选项
19
+const getHandleStatuses = () => getDictOptions(DICT_KEYS.HANDLE_STATUS);
20
+
21
+export const queryFormSchema: FormSchemaGetter = () => [
22
+  {
23
+    component: 'ApiSelect',
24
+    fieldName: 'stationId',
25
+    label: '预警场站',
26
+    componentProps: {
27
+      placeholder: '请选择预警场站',
28
+      api: async () => {
29
+        const resp = await selectAllSysStation();
30
+        const data = resp || [];
31
+        return Array.isArray(data)
32
+          ? data.map((item: any) => ({
33
+              label: item.stationName,
34
+              value: item.id.toString(),
35
+            }))
36
+          : [];
37
+      },
38
+    },
39
+  },
40
+  {
41
+    component: 'DatePicker',
42
+    componentProps: {
43
+      type: 'daterange',
44
+      format: 'YYYY-MM-DD HH:mm:ss',
45
+      valueFormat: 'YYYY-MM-DD HH:mm:ss',
46
+      startPlaceholder: '开始时间',
47
+      endPlaceholder: '结束时间',
48
+      style: {
49
+        width: '100%',
50
+      },
51
+    },
52
+    fieldName: 'alertTime',
53
+    label: '检查时间',
54
+  },
55
+  {
56
+    component: 'Select',
57
+    fieldName: 'alertType',
58
+    label: '预警场景',
59
+    componentProps: {
60
+      placeholder: '请选择预警场景',
61
+      options: getWarningTypes(),
62
+    },
63
+  },
64
+];
65
+
66
+export const tableColumns: VxeGridProps['columns'] = [
67
+  {
68
+    field: 'status',
69
+    title: '状态',
70
+    minWidth: 100,
71
+    fixed: 'left',
72
+    formatter: (params: { cellValue: string }) => {
73
+      const statusMap = {
74
+        'unhandled': '未处理',
75
+        'handling': '处理中',
76
+        'resolved': '已解决',
77
+        'false_alarm': '误报',
78
+      };
79
+      return statusMap[params.cellValue] || params.cellValue;
80
+    },
81
+  },
82
+  {
83
+    field: 'alertTime',
84
+    title: '预警时间',
85
+    minWidth: 180,
86
+    fixed: 'left',
87
+  },
88
+  {
89
+    field: 'stationName',
90
+    title: '预警场站',
91
+    minWidth: 120,
92
+  },
93
+  {
94
+    field: 'stationManagerName',
95
+    title: '场站站长',
96
+    minWidth: 100,
97
+  },
98
+  {
99
+    field: 'areaManagerName',
100
+    title: '片区经理',
101
+    minWidth: 100,
102
+  },
103
+  {
104
+    field: 'location',
105
+    title: '摄像头位置',
106
+    minWidth: 120,
107
+  },
108
+  {
109
+    field: 'alertType',
110
+    title: '预警场景',
111
+    minWidth: 100,
112
+    formatter: (params: { cellValue: string }) => {
113
+      const alertTypeMap = {
114
+        'fire': '火焰',
115
+        'smoke': '烟雾',
116
+        'fire_danger': '火灾',
117
+        'intrusion': '入侵',
118
+      };
119
+      return alertTypeMap[params.cellValue] || params.cellValue;
120
+    },
121
+  },
122
+  {
123
+    field: 'snapshotUrl',
124
+    title: '异常图片',
125
+    minWidth: 100,
126
+    slots: {
127
+      default: ({ row }: { row: any }) => {
128
+        return row.snapshotUrl ? (
129
+          <el-image
130
+            src={row.snapshotUrl}
131
+            style={{ width: '60px', height: '60px' }}
132
+            fit="cover"
133
+            preview-src-list={[row.snapshotUrl]}
134
+          />
135
+        ) : '-';
136
+      },
137
+    },
138
+  },
139
+  {
140
+    field: 'action',
141
+    fixed: 'right',
142
+    slots: { default: 'action' },
143
+    title: '操作',
144
+    width: 150,
145
+  },
146
+];
147
+
148
+export const drawerFormSchema: FormSchemaGetter = () => [
149
+  {
150
+    component: 'Input',
151
+    dependencies: {
152
+      show: () => false,
153
+      triggerFields: [''],
154
+    },
155
+    fieldName: 'alertId',
156
+  },
157
+  {
158
+    component: 'Select',
159
+    fieldName: 'handleAction',
160
+    label: '处理动作',
161
+    componentProps: {
162
+      placeholder: '请选择处理动作',
163
+      options: [
164
+        { label: '解决', value: 'resolve' },
165
+        { label: '忽略', value: 'ignore' },
166
+        { label: '误报', value: 'false_alarm' },
167
+      ],
168
+    },
169
+    rules: 'required',
170
+  },
171
+  {
172
+    component: 'Input',
173
+    fieldName: 'handleContent',
174
+    label: '处理内容',
175
+    componentProps: {
176
+      placeholder: '请输入处理内容',
177
+      maxlength: 500,
178
+      type: 'textarea',
179
+      showWordLimit: true,
180
+      rows: 4,
181
+    },
182
+    rules: 'required',
183
+  }
184
+];

+ 129 - 0
apps/web-ele/src/views/aiAnalysis/warning/warning-drawer.vue

@@ -0,0 +1,129 @@
1
+<script setup lang="ts">
2
+import { onMounted, ref } from 'vue';
3
+
4
+import { useVbenDrawer, useVbenForm } from '@vben/common-ui';
5
+
6
+import { addSecurityAlert, getSecurityAlertDetail, handleSecurityAlert, updateSecurityAlert } from '#/api/aiAnalysis/aiAnalysis';
7
+import { drawerFormSchema } from './warning-data';
8
+
9
+const emit = defineEmits<{
10
+  reload: [];
11
+}>();
12
+
13
+// 响应式变量
14
+const isUpdateRef = ref<boolean>(false);
15
+const isProcessRef = ref<boolean>(false);
16
+
17
+// 初始化表单
18
+const [Form, formApi] = useVbenForm({
19
+  showDefaultActions: false,
20
+  schema: drawerFormSchema(),
21
+});
22
+
23
+// 初始化抽屉
24
+const [Drawer, drawerApi] = useVbenDrawer({
25
+  async onOpenChange(isOpen) {
26
+    if (!isOpen) {
27
+      return;
28
+    }
29
+    await handleDrawerOpen();
30
+  },
31
+
32
+  async onConfirm() {
33
+    await handleFormSubmit();
34
+  },
35
+
36
+  onClosed() {
37
+    formApi.resetForm();
38
+  },
39
+});
40
+
41
+/**
42
+ * 处理抽屉打开逻辑
43
+ */
44
+async function handleDrawerOpen() {
45
+  try {
46
+    drawerApi.drawerLoading(true);
47
+    const { alertId, isUpdate, isProcess } = drawerApi.getData();
48
+    isUpdateRef.value = isUpdate;
49
+    isProcessRef.value = isProcess;
50
+
51
+    // 处理编辑或处理模式的数据回显
52
+    if ((isUpdate || isProcess) && alertId) {
53
+      await loadWarningDetail(alertId);
54
+    }
55
+  } catch (error) {
56
+    console.error('打开抽屉失败:', error);
57
+  } finally {
58
+    drawerApi.drawerLoading(false);
59
+  }
60
+}
61
+
62
+/**
63
+ * 加载预警详情数据
64
+ * @param alertId 预警ID
65
+ */
66
+async function loadWarningDetail(alertId: number | string) {
67
+  try {
68
+    const res = await getSecurityAlertDetail(alertId);
69
+
70
+    // 确保从正确的响应结构中获取数据
71
+    const detailData = res?.data || res;
72
+
73
+    // 设置表单值
74
+    await formApi.setValues(detailData);
75
+  } catch (error) {
76
+    console.error('获取预警详情失败:', error);
77
+  }
78
+}
79
+
80
+/**
81
+ * 处理表单提交
82
+ */
83
+async function handleFormSubmit() {
84
+  try {
85
+    // 验证表单
86
+    const { valid } = await formApi.validate();
87
+    if (!valid) {
88
+      return;
89
+    }
90
+
91
+    // 获取表单数据
92
+    const data = await formApi.getValues();
93
+    console.log('表单数据:', data);
94
+
95
+    // 提交请求
96
+    if (isProcessRef.value) {
97
+      // 处理预警
98
+      await handleSecurityAlert({
99
+        alertId: drawerApi.getData().alertId,
100
+        handleAction: data.handleAction,
101
+        handleContent: data.handleContent,
102
+      });
103
+    } else {
104
+      // 新增或编辑预警
105
+      await (isUpdateRef.value
106
+        ? updateSecurityAlert({ ...data, alertId: drawerApi.getData().alertId })
107
+        : addSecurityAlert(data));
108
+    }
109
+
110
+    // 触发重载事件并关闭抽屉
111
+    emit('reload');
112
+    drawerApi.close();
113
+  } catch (error) {
114
+    console.error('保存预警信息失败:', error);
115
+  }
116
+}
117
+</script>
118
+
119
+<template>
120
+  <Drawer :title="isProcessRef ? '处理预警' : (isUpdateRef ? '编辑预警' : '新增预警')">
121
+    <Form />
122
+  </Drawer>
123
+</template>
124
+
125
+<style scoped>
126
+:deep(.vben-drawer-content) {
127
+  padding: 20px;
128
+}
129
+</style>