Parcourir la source

fix🐛: 告警功能实现、告警配置实现

gitboyzcf il y a 1 jour
Parent
commit
bfcbf10fdf

+ 40 - 2
src/api/modules/alarm.ts

@@ -21,6 +21,11 @@ export type apiT = {
   API_DISTANCE_GET: (params?: object) => Promise<unknown>
   API_RM_STATUS_GET: (params?: object) => Promise<unknown>
   API_RM_STATUS_PUT: (params?: object) => Promise<unknown>
+  API_RECORD_GET: (params?: object) => Promise<unknown>
+  API_THRESHOLD_PUT: (data?: object) => Promise<unknown>
+  API_THRESHOLD_GET: (params?: object) => Promise<unknown>
+  API_DISTANCE_PUT: (params?: object) => Promise<unknown>
+
 }
 const apiAll: apiT = {
   // 通过ES查询告警
@@ -188,7 +193,7 @@ const apiAll: apiT = {
     });
   },
 
-  // 获取热力图半径
+  // 获取设备与目标距离
   API_DISTANCE_GET(params = {}) {
     return request({
       url: 'Options/Distance',
@@ -196,6 +201,14 @@ const apiAll: apiT = {
       params
     })
   },
+  // 创建或修改设备与目标距离
+  API_DISTANCE_PUT(data = {}) {
+    return request({
+      url: `/Options/Distance`,
+      method: 'put',
+      data
+    })
+  },
   // 查询人密算法状态
   API_RM_STATUS_GET(params = {}) {
     return request({
@@ -210,7 +223,32 @@ const apiAll: apiT = {
       url: `/api/edge/algorithm/${params.enable}`,
       method: 'put'
     })
-  }
+  },
+  // 查询阈值告警记录
+  API_RECORD_GET(params = {}) {
+    return request({
+      url: `/api/edge/alertThreshold/record`,
+      method: 'get',
+      params
+    })
+  },
+  // 修改设备告警阈值
+  API_THRESHOLD_PUT(data = {}) {
+    return request({
+      url: `/api/edge/alert/threshold`,
+      method: 'put',
+      data
+    })
+  },
+  // 查询设备告警阈值
+  API_THRESHOLD_GET(params = {}) {
+    return request({
+      url: `/api/edge/alert/threshold`,
+      method: 'get',
+      params
+    })
+  },
+
 
 }
 

+ 0 - 1
src/hooks/useTheme.ts

@@ -28,7 +28,6 @@ export function useTheme() {
   const theme = computed(() => {
     return isDark.value ? darkTheme : lightTheme
   })
-  console.log(theme);
 
   return {
     theme,

+ 75 - 50
src/layout/components/AlertCom.vue

@@ -1,5 +1,6 @@
+template
 <template>
-  <n-badge :value="unreadAlertCount" :max="99" :show-zero="false">
+  <n-badge v-if="isIcon" :value="alarmCount" :max="99" :show-zero="false">
     <n-popover trigger="click" style="width: 400px">
       <template #trigger>
         <n-button text size="large">
@@ -10,69 +11,93 @@
       </template>
       <template #footer>
         <!-- <n-text strong depth="1"> 下面就是分割线 </n-text> -->
-        <n-button block @click="markAllAsRead">全部标记为已读</n-button>
+        <n-flex>
+          <n-button block @click="markAllAsRead">全部标记为已读</n-button>
+          <n-button
+            v-show="$route.name === 'Home'"
+            block
+            @click="$router.push({ name: 'Alert' })"
+            >进入人密告警</n-button
+          >
+        </n-flex>
       </template>
-      <n-flex vertical class="h-100 overflow-y-auto">
+      <n-flex vertical class="max-h-100 overflow-y-auto">
         <n-alert
-          v-for="alert in alerts"
-          :key="alert.id"
+          v-for="alert in alarms"
+          :key="alert.ID"
           title="警告"
           :type="alert.type"
+          :bordered="false"
           closable
+          @close="() => useAlarm.removeAlarm(alert)"
         >
-          {{ alert.title }}
+          <template #icon
+            ><span class="i-mingcute-warning-line"></span
+          ></template>
+          <span class="text-amber font-black mr-1">{{
+            formatTime(new Date(alert.EventTime))
+          }}</span
+          ><span class="mr-1">
+            {{ timeFormat(alert.EventTime) }}
+          </span>
+          {{ alert.Msg }}
         </n-alert>
+        <n-empty v-if="!alarms.length" description="无告警" />
       </n-flex>
     </n-popover>
   </n-badge>
+  <template v-else>
+    <n-flex vertical>
+      <n-alert
+        v-for="alert in alarms"
+        :key="alert.ID"
+        title="警告"
+        :type="alert.type"
+        :bordered="false"
+        closable
+        @close="() => useAlarm.removeAlarm(alert)"
+      >
+        <template #icon><span class="i-mingcute-warning-line"></span></template>
+        <span class="text-amber font-black mr-1">{{
+          formatTime(new Date(alert.EventTime))
+        }}</span
+        ><span class="mr-1">
+          {{ timeFormat(alert.EventTime) }}
+        </span>
+        {{ alert.Msg }}
+      </n-alert>
+      <n-empty
+        v-if="!alarms.length"
+        class="absolute top-1/2 left-1/2 -translate-1/2"
+        description="暂无告警"
+      >
+      </n-empty>
+    </n-flex>
+  </template>
 </template>
 <script setup lang="ts">
-import { NButton, NBadge, NAlert, NPopover, NFlex } from "naive-ui";
+import { NButton, NBadge, NAlert, NPopover, NFlex, NEmpty } from "naive-ui";
+import { useAlarmStore, toRefsAlarmStore } from "@/stores/modules/alarm";
+import { timeFormat } from "@/utils";
+
+withDefaults(defineProps<{ isIcon?: boolean }>(), { isIcon: true });
 // 告警消息相关
 const showAlertPanel = ref(false);
-const unreadAlertCount = ref(3);
 
-interface AlertItem {
-  id: number;
-  title: string;
-  type: "error" | "warning" | "info" | "success";
-  timestamp: Date;
-  actionUrl?: string;
-  isRead: boolean;
-}
+const useAlarm = useAlarmStore();
+const { alarms, alarmCount } = toRefsAlarmStore();
+useAlarm.connectAlarmWS();
 
-const alerts = ref<AlertItem[]>([
-  {
-    id: 1,
-    title: "人流量超过预设阈值",
-    type: "error",
-    timestamp: new Date(Date.now() - 300000), // 5分钟前
-    actionUrl: "/dashboard/analytics",
-    isRead: false,
-  },
-  {
-    id: 2,
-    title: "设备离线警告",
-    type: "warning",
-    timestamp: new Date(Date.now() - 1800000), // 30分钟前
-    actionUrl: "/dashboard/devices",
-    isRead: false,
-  },
-  {
-    id: 3,
-    title: "系统维护通知",
-    type: "info",
-    timestamp: new Date(Date.now() - 86400000), // 1天前
-    isRead: true,
-  },
-  {
-    id: 4,
-    title: "数据同步完成",
-    type: "success",
-    timestamp: new Date(Date.now() - 3600000), // 1小时前
-    isRead: true,
-  },
-]);
+// const alerts = ref<TAlertItem[]>([
+//   {
+//     id: 1,
+//     title: "人流量超过预设阈值",
+//     type: "error",
+//     timestamp: new Date(Date.now() - 300000), // 5分钟前
+//     actionUrl: "/dashboard/analytics",
+//     isRead: false,
+//   }
+// ]);
 
 // 格式化时间
 function formatTime(date: Date): string {
@@ -142,9 +167,9 @@ function getAlertTagType(type: string) {
 
 // 全部标记为已读
 function markAllAsRead() {
-  alerts.value.forEach((alert) => {
+  alarms.value.forEach((alert) => {
     alert.isRead = true;
   });
-  unreadAlertCount.value = 0;
+  alarmCount.value = 0;
 }
 </script>

+ 146 - 2
src/layout/components/Setting.vue

@@ -1,12 +1,156 @@
 <template>
   <div class="setting-box flex items-center">
-    <n-button text size="large">
+    <n-button text size="large" @click="settingOpen">
       <template #icon>
         <div class="i-lets-icons-setting-alt-line"></div>
       </template>
     </n-button>
   </div>
+  <n-modal
+    v-model:show="showModel"
+    :mask-closable="false"
+    :showIcon="false"
+    :autoFocus="false"
+    draggable
+    class="w-60%!"
+    preset="dialog"
+    title="设置"
+    negative-text="关闭"
+    @negative-click="showModel = false"
+  >
+    <n-flex justify="end"><n-tag type="warning" class="mb-4">  保存后请按 F5 刷新页面 </n-tag></n-flex>
+    <n-tabs :default-value="0" type="line" animated placement="left">
+      <n-tab-pane
+        v-for="(item, i) in deviceThreshold"
+        :name="i"
+        :tab="item.name"
+      >
+        <n-flex vertical>
+          <n-flex align="center">
+            <div class="w-100px">告警阀值</div>
+            <n-input-number
+              class="flex-1"
+              size="small"
+              v-model:value="item.value"
+              clearable
+            />
+            <n-button
+              type="primary"
+              size="small"
+              :disabled="item.loading"
+              @click="() => item.handleSave(item)"
+              >保存</n-button
+            >
+          </n-flex>
+          <n-flex align="center">
+            <div class="w-100px">目标距离</div>
+            <n-input-number
+              class="flex-1"
+              size="small"
+              v-model:value="item.distance"
+              clearable
+            />
+            <n-button
+              type="primary"
+              size="small"
+              :disabled="item.distanceLoading"
+              @click="() => item.distanceHandleSave(item)"
+              >保存</n-button
+            >
+          </n-flex>
+        </n-flex>
+      </n-tab-pane>
+      <!-- <n-tab-pane name="the beatles" tab="the Beatles"> Hey Jude </n-tab-pane> -->
+    </n-tabs>
+  </n-modal>
 </template>
 <script setup lang="ts">
-import { NButton } from "naive-ui";
+import {
+  NButton,
+  NFlex,
+  NModal,
+  NTabPane,
+  NTabs,
+  NDivider,
+  NInputNumber,
+  useMessage,
+  NTag,
+} from "naive-ui";
+
+interface ThresholdInfo {
+  name: string;
+  deviceId: number;
+  value: number;
+  loading: boolean;
+  handleSave: (item: ThresholdInfo) => void;
+  distance: number;
+  distanceLoading: boolean;
+  distanceHandleSave: (item: ThresholdInfo) => void;
+}
+
+const {
+  API_DEVICE_GET,
+  API_THRESHOLD_PUT,
+  API_THRESHOLD_GET,
+  API_DISTANCE_GET,
+  API_DISTANCE_PUT,
+} = useRequest();
+
+const message = useMessage();
+const showModel = ref(false);
+
+const deviceThreshold = ref<ThresholdInfo[]>([]);
+
+const getDevicesThreshold = async () => {
+  const devices: any = await API_DEVICE_GET();
+  const threshold: any = await API_THRESHOLD_GET();
+  const distance: any = await API_DISTANCE_GET();
+
+  deviceThreshold.value = devices.map((item) => {
+    const thresholdItem = threshold.find((thresholdItem) => {
+      return thresholdItem.Id === item.device_id;
+    });
+    return {
+      name: item.Alias,
+      deviceId: item.Id,
+      value: thresholdItem.person_threshold,
+      loading: false,
+      handleSave: async (item: ThresholdInfo) => {
+        item.loading = true;
+        await API_THRESHOLD_PUT({
+          device_id: item.deviceId,
+          person_threshold: item.value,
+        });
+        item.loading = false;
+        message.success("修改成功");
+      },
+      distance: distance.Distance,
+      distanceLoading: false,
+      distanceHandleSave: async (item: ThresholdInfo) => {
+        item.distanceLoading = true;
+        await API_DISTANCE_PUT({
+          DeviceId: item.deviceId,
+          Distance: item.value,
+        });
+        item.distanceLoading = false;
+        message.success("修改成功");
+      },
+    };
+  });
+  console.log(deviceThreshold);
+};
+
+const distance = ref(0);
+
+watch(showModel, (v) => {
+  if (v) {
+    getDevicesThreshold();
+  }
+});
+
+onMounted(() => {});
+
+const settingOpen = () => {
+  showModel.value = true;
+};
 </script>

+ 4 - 3
src/layout/index.vue

@@ -8,7 +8,8 @@
         style="height: 100%"
       >
         <div class="logo-container">
-          <img src="@/assets/logo.svg" alt="logo" class="logo" />
+          <!-- <img src="@/assets/logo.svg" alt="logo" class="logo" /> -->
+           <div class="i-mingcute-heartbeat-line text-10"></div>
           <div class="text">{{ title }}</div>
         </div>
         <div class="flex-1 w-0 menu-container">
@@ -28,8 +29,8 @@
         <transition
           name="fade"
           mode="out-in"
-          enter-active-class="animate__animated animate__fast animate__fadeIn"
-          leave-active-class="animate__animated animate__fast animate__fadeOut"
+          enter-active-class="animate__animated animate__faster animate__fadeIn"
+          leave-active-class="animate__animated animate__faster animate__fadeOut"
         >
           <component :is="Component" />
         </transition>

+ 28 - 63
src/stores/modules/alarm.ts

@@ -1,21 +1,16 @@
 import dayjs from 'dayjs'
 import { acceptHMRUpdate, defineStore, storeToRefs } from 'pinia'
-import { useThrottleFn } from '@vueuse/core'
-
 import { piniaStore } from '@/stores'
+import type { AlertItem } from "@/views/Alert/Alert.vue";
 
-export interface Alarm {
-  ImgUrl: string | undefined
-  id: number
-  location: string
-  timestamp: string
-  img: string | null
-  type: string
-}
+export type TAlarm = AlertItem & {
+  isRead: boolean;
+  type: "default" | "info" | "warning" | "error" | "success";
+};
 
 interface AlarmState {
-  alarms: Alarm[]
-  aiList: Alarm[]
+  alarms: TAlarm[]
+  aiList: TAlarm[]
   alarmCount: number,
   websocket: WebSocket | null
   ip: string
@@ -25,39 +20,10 @@ const formatDate = (date: Date, day: number = 0) => {
   return dayjs(date).subtract(day, 'day').format('YYYY-MM-DD HH:mm:ss')
 }
 
-export const useAlarmStore = defineStore('alarm', {
+export const useAlarmStore = defineStore('TAlarm', {
   state: (): AlarmState => ({
-    alarms: [
-      // {
-      //   id: 1,
-      //   location: 'A3跑道',
-      //   timestamp: formatDate(new Date()),
-      //   img: 'https://picsum.photos/id/237/200/200',
-      //   type: 'large',
-      // },
-      // {
-      //   id: 2,
-      //   location: 'A1跑道',
-      //   timestamp: formatDate(new Date(), 1),
-      //   img: 'https://picsum.photos/id/237/200/200',
-      //   type: 'medium',
-      // },
-      // {
-      //   id: 3,
-      //   location: 'A2跑道',
-      //   timestamp: formatDate(new Date(), 2),
-      //   img: 'https://picsum.photos/id/237/200/200',
-      //   type: 'small',
-      // },
-      // {
-      //   id: 4,
-      //   location: 'A4跑道',
-      //   timestamp: formatDate(new Date(), 3),
-      //   img: 'https://picsum.photos/id/238/200/200',
-      //   type: 'large',
-      // },
-    ] as Alarm[],
-    aiList: [] as Alarm[],
+    alarms: [] as TAlarm[],
+    aiList: [] as TAlarm[],
     alarmCount: 0,
     ip:
       import.meta.env.VITE_APP_API_BASEURL === '/'
@@ -72,11 +38,12 @@ export const useAlarmStore = defineStore('alarm', {
     },
   },
   actions: {
-    addAlarm(alarm: Alarm) {
-      this.alarms.push(alarm)
+    addAlarm(TAlarm: TAlarm) {
+      this.alarms.push(TAlarm)
     },
-    removeAlarm(alarm: Alarm) {
-      this.alarms = this.alarms.filter((a) => a.id !== alarm.id)
+    removeAlarm(TAlarm: TAlarm) {
+      this.alarms = this.alarms.filter((a: TAlarm) => a.ID !== TAlarm.ID)
+      this.alarmCount--
     },
     getFileUrl(fileName: string) {
       return `${this.ip}/data/show/${fileName}`
@@ -161,28 +128,26 @@ export const useAlarmStore = defineStore('alarm', {
             console.log('告警阈值-Message 连接已经建立')
           }
           that.websocket.onmessage = function (e) {
-            const data = JSON.parse(e.data).Ais[0]
+            const data = JSON.parse(e.data)
             console.log(data);
 
-            // that.alarmCount++
-            // that.aiList.unshift({
-            //   ...data,
-            //   ImgUrl: data.RawImageUrl,
-            //   EventTimeFormat: formatDate(data.EventTime),
-            // })
+            that.alarmCount++
+            that.alarms.unshift({
+              ...data,
+              isRead: false,
+              type: 'warning',
+            })
+            if (that.alarms.length > 50) {
+              that.alarms.splice(50, that.alarms.length - 50)
+            }
           }
 
           // const pushAlarm = () => {
           //   const alarms = that.aiList.slice(0, 4).map((item: any) => ({
           //     ...item,
-          //     id: item.EventID,
-          //     location: '',
-          //     timestamp: formatDate(item.EventTime),
-          //     img: null,
-          //     type: 'large',
           //   }))
           //   alarms.forEach((item: any) => {
-          //     const i = that.alarms.findIndex((alarm) => alarm.id === item.id)
+          //     const i = that.alarms.findIndex((TAlarm) => TAlarm.id === item.id)
           //     if (i === -1) {
           //       that.alarms.unshift(item)
           //       if (that.alarms.length == 5) that.alarms.splice(that.alarms.length - 1, 1)
@@ -200,10 +165,10 @@ export const useAlarmStore = defineStore('alarm', {
           //   pushAlarm()
           // }, 3000)
           that.websocket.onerror = function () {
-            console.log('Ai/Alarm 发生错误')
+            console.log('告警阈值-Message 发生错误')
           }
           that.websocket.onclose = function () {
-            console.log('Ai/Alarm 连接断开')
+            console.log('告警阈值-Message 连接断开')
           }
         })()
     },

+ 5 - 0
src/utils/index.ts

@@ -1,6 +1,7 @@
 import mitt from "mitt";
 import FingerprintJS from "@fingerprintjs/fingerprintjs";
 import forge from "node-forge";
+import dayjs from "dayjs";
 import { v4 as uuidv4 } from "uuid";
 
 
@@ -290,6 +291,10 @@ export function getAlarmStatusName(status: number): string {
   return AlarmStatus[status] || '未知'
 }
 
+export const timeFormat = (time: string | number | Date) => {
+  return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
+}
+
 export {
   getStaticResource,
   $mitt,

+ 266 - 2
src/views/Alert/Alert.vue

@@ -1,5 +1,269 @@
+<script setup lang="ts">
+import {
+  NButton,
+  NGridItem,
+  NInput,
+  NSpace,
+  NStatistic,
+  NNumberAnimation,
+  NGrid,
+  NSelect,
+  NDatePicker,
+  NBadge,
+  NRadioGroup,
+  NRadioButton,
+  PaginationProps,
+  NPagination,
+  NScrollbar,
+  NForm,
+  NFormItemGi,
+} from "naive-ui";
+import { useAlarmStore, toRefsAlarmStore } from "@/stores/modules/alarm";
+import GridData from "./components/gridData.vue";
+import TableData from "./components/tableData.vue";
+
+const { API_DEVICE_GET, API_RECORD_GET } = useRequest();
+
+// 视图状态
+const viewMode = ref("grid"); // 'grid' | 'table'
+
+const { getFileUrl } = useAlarmStore();
+const { alarms, alarmCount } = toRefsAlarmStore();
+
+const viewModeOptions = [
+  {
+    label: "网格视图",
+    value: "grid",
+    icon: " i-lucide-layout-grid",
+  },
+  { label: "表格视图", value: "table", icon: " i-lucide-table" },
+];
+
+export interface AlertItem {
+  ID: number;
+  DeviceId: number;
+  Alias: string;
+  EdgeIp: string;
+  PersonCount: number;
+  PersonThreshold: number;
+  EventTime: number;
+  Msg: string;
+}
+
+export interface formInfo {
+  start_time: number | null; // 事件开始时间戳:秒级,筛选事件时间≥该值,默认0(不限制开始时间)
+  end_time: number | null; // 事件结束时间戳:秒级,筛选事件时间≤该值,默认0(不限制结束时间)
+  min_confidence?: number | null; // 最小置信度:筛选置信度≥该值,默认0.0(不限制置信度)
+  page: number; // 页码:从1开始,默认1(第一页)
+  page_size: number; // 每页条数:默认20条/页
+  device_id: number; // 设备id
+}
+
+const alertData = ref<AlertItem[]>([]);
+const prevUserListTotal = ref(0);
+
+const isRequestLoading = ref(true);
+const deviceOptions = ref([]);
+async function getDeviceList() {
+  const res = await API_DEVICE_GET();
+  deviceOptions.value = (res as any[]).map((item) => ({
+    label: item.Alias,
+    value: item.Id,
+  }));
+}
+async function getDataList(form = {}) {
+  const res: any = await API_RECORD_GET(searchForm);
+  alertData.value = res.data as AlertItem[];
+  pagination.itemCount = res.total;
+  isRequestLoading.value = false;
+}
+
+const nDatePickerV = ref<[number, number] | null>(null);
+const searchForm = reactive<formInfo>({
+  device_id: null,
+  start_time: null,
+  end_time: null,
+  page: 1,
+  page_size: 20,
+});
+const pagination = reactive<PaginationProps>({
+  page: 1,
+  pageSize: 20,
+  showSizePicker: true,
+  pageSizes: [10, 20, 50, 100],
+  itemCount: 0,
+  showQuickJumper: true,
+  showQuickJumpDropdown: true,
+  onUpdatePage: (page: number) => {
+    pagination.page = page;
+    searchForm.page = page;
+    getDataList();
+  },
+  onUpdatePageSize: (pageSize: number) => {
+    pagination.pageSize = pageSize;
+    pagination.page = 1;
+    searchForm.page_size = pageSize;
+    getDataList();
+  },
+});
+
+const paginationPrefix: PaginationProps["prefix"] = (info) => {
+  const { itemCount } = info;
+  if (!itemCount) return null;
+  return h("div", null, [
+    h("span", "总\u00A0"),
+    h(NNumberAnimation, {
+      from: prevUserListTotal.value,
+      to: itemCount,
+      onFinish: () => {
+        prevUserListTotal.value = itemCount;
+      },
+    }),
+    h("span", "\u00A0条"),
+  ]);
+};
+
+watchThrottled(
+  alarms,
+  () => {
+    // console.log(v) // TODO
+    getDataList();
+  },
+  { throttle: 3000, deep: true },
+);
+
+const gridDataRef = useTemplateRef<typeof GridData>("gridDataRef");
+
+onMounted(() => {
+  getDeviceList();
+  getDataList();
+});
+
+provide("getDataList", getDataList);
+</script>
+
 <template>
-  <div>12</div>
+  <div class="h-100% flex flex-col bg-bl p-4 text-slate-200">
+    <!-- 过滤与搜索工具栏 -->
+    <section
+      class="flex flex-wrap items-center justify-between gap-4 mb-2 bg-[#1d293d] p-4 rounded border border-white/5 backdrop-blur-md"
+    >
+      <NForm
+        ref="formRef"
+        class="w-full"
+        :model="searchForm"
+        label-placement="left"
+        label-width="auto"
+      >
+        <n-grid
+          :x-gap="12"
+          :y-gap="8"
+          cols="3"
+          item-responsive
+          responsive="screen"
+          class="items-center"
+        >
+          <n-form-item-gi
+            span="1"
+            label="设备列表"
+            path="status"
+            :show-feedback="false"
+          >
+            <NSelect
+              v-model:value="searchForm.device_id"
+              :options="deviceOptions"
+              style="min-width: 88px"
+              clearable
+            />
+          </n-form-item-gi>
+          <n-form-item-gi
+            span="1"
+            label="时间范围"
+            path="status"
+            :show-feedback="false"
+          >
+            <NDatePicker
+              ref="nDatePickerRef"
+              type="datetimerange"
+              clearable
+              v-model:value="nDatePickerV"
+              @update:value="
+                (v: number[]) => {
+                  searchForm.start_time = v ? v[0] : null;
+                  searchForm.end_time = v ? v[1] : null;
+                }
+              "
+            />
+          </n-form-item-gi>
+          <n-form-item-gi
+            span="1"
+            class="flex items-center justify-end"
+            :show-feedback="false"
+          >
+            <n-button type="primary" size="medium" @click="getDataList">
+              <template #icon>
+                <span class="i-lucide-search text-white" />
+              </template>
+              搜索
+            </n-button>
+            <div class="h-6 w-px bg-blue/10 mx-2" />
+            <!-- 布局切换按钮 -->
+            <n-radio-group
+              v-model:value="viewMode"
+              name="radiobuttongroup2"
+              size="medium"
+            >
+              <n-radio-button
+                v-for="item in viewModeOptions"
+                :key="item.value"
+                :value="item.value"
+              >
+                <div
+                  class="h-full w-full flex items-center justify-center absolute left-0"
+                >
+                  <span :class="item.icon"></span>
+                </div>
+              </n-radio-button>
+            </n-radio-group>
+          </n-form-item-gi>
+        </n-grid>
+      </NForm>
+    </section>
+
+    <!-- 主体列表区域 -->
+    <main
+      :class="['flex-1 mb-2', viewMode === 'grid' ? ' overflow-y-auto' : '']"
+      id="image-scroll-container"
+    >
+      <!-- 网格视图 -->
+      <GridData
+        v-if="viewMode === 'grid'"
+        ref="gridDataRef"
+        :alertData="alertData"
+        :loading="isRequestLoading"
+      />
+
+      <!-- 表格视图 -->
+      <template v-else>
+        <TableData :alertData="alertData" :loading="isRequestLoading" />
+      </template>
+      <!-- <div v-if="alertData.length === 0" class="py-24 text-center">
+        <n-empty description="未检索到相关告警信息" />
+      </div> -->
+    </main>
+    <!-- 页脚 -->
+    <div class="flex justify-end">
+      <n-pagination
+        v-bind="pagination"
+        :prefix="paginationPrefix"
+        size="medium"
+        show-quick-jumper
+        show-size-picker
+      />
+    </div>
+  </div>
 </template>
 
-<script setup></script>
+<style scoped>
+/* Naive UI 深色主题微调 */
+</style>

+ 152 - 0
src/views/Alert/components/gridData.vue

@@ -0,0 +1,152 @@
+<script setup lang="ts">
+import {
+  NCard,
+  NTag,
+  NButton,
+  NIcon,
+  NGrid,
+  NGi,
+  NBadge,
+  NImage,
+  NSkeleton,
+  NSpin,
+  NFlex,
+  NModal,
+} from "naive-ui";
+import { timeFormat } from "@/utils";
+import type { AlertItem } from "../Alert.vue";
+const props = defineProps<{ alertData: any[]; loading?: boolean }>();
+
+const drawerShow = ref(false);
+const currentItem = ref<AlertItem>(null);
+
+function getLabel(item: any) {
+  if (Array.isArray(item.FieldList) && typeof item.Type === "number") {
+    return item.FieldList[item.Type] || `类型-${item.Type}`;
+  }
+  return item.Type || "未知";
+}
+
+function formatScore(s: number | undefined) {
+  if (s == null) return "-";
+  return `${Math.round((s as number) * 100)}%`;
+}
+
+const drawerOpen = (item: any) => {
+  currentItem.value = item;
+  drawerShow.value = true;
+};
+
+defineExpose({
+  drawerOpen,
+});
+</script>
+
+<template>
+  <n-flex
+    v-if="loading"
+    class="absolute w-full h-full"
+    justify="center"
+    align="center"
+  >
+    <n-spin size="medium" />
+  </n-flex>
+  <n-grid v-else x-gap="20" y-gap="20" cols="1 s:2 m:4 l:4" responsive="screen">
+    <n-gi v-for="item in props.alertData" :key="item.EventID || item.ID">
+      <n-card
+        hoverable
+        content-style="padding: 0"
+        class="border-white/5! rounded! overflow-hidden group hover:border-primary/50! transition-all duration-300"
+      >
+        <!-- 告警详情 -->
+        <div class="p-4 space-y-2">
+          <div class="flex justify-between items-center">
+            <div>
+              <h3 class="text-sm font-bold text-slate-100 line-clamp-1">
+                {{ item.Alias }}
+              </h3>
+            </div>
+            <span class="text-[12px] text-slate-400">
+              {{ timeFormat(item.EventTime) || "-" }}
+            </span>
+          </div>
+
+          <div
+            class="grid grid-cols-2 gap-y-2 text-[11px] text-slate-500 font-mono"
+          >
+            <div class="flex items-center gap-1.5">
+              <div class="i-token-zkid w-4 h-4 text-slate-600" />
+              {{ item.ID ?? "-" }}
+            </div>
+            <div class="flex items-center justify-end gap-1.5">
+              <div class="i-carbon-ip w-6 h-6 text-slate-600" />
+              {{ item.EdgeIp ?? "-" }}
+            </div>
+            <div class="flex items-center gap-1.5 col-span-2">
+              <div
+                class="i-material-symbols-description-outline w-4 h-4 text-slate-600"
+              />
+              {{ item.Msg ?? "-" }}
+            </div>
+          </div>
+
+          <div class="pt-2 flex gap-2">
+            <n-button
+              size="small"
+              class="flex-1 rounded-md!"
+              @click="drawerOpen(item)"
+            >
+              <template #icon><div class="i-lucide-file-text" /></template>
+              查看详情
+            </n-button>
+            <!-- <n-button
+              size="small"
+              type="error"
+              class="flex-1 rounded-md!"
+              ghost
+              @click="console.log('详情', item)"
+            >
+              <template #icon><div class="i-lucide-delete" /></template>
+              删除
+            </n-button> -->
+          </div>
+        </div>
+      </n-card>
+    </n-gi>
+  </n-grid>
+  <!-- <Drawer v-model="drawerShow" v-model:currentItem="currentItem">
+    <AlarmShow />
+  </Drawer> -->
+  <n-modal
+    v-model:show="drawerShow"
+    :mask-closable="false"
+    preset="dialog"
+    title="查看详情"
+    :showIcon="false"
+    negative-text="关闭"
+    @negative-click="drawerShow = false"
+  >
+    <n-flex vertical>
+      <div>
+        <span class="inline-block w-50px">ID :</span>
+        <span>{{ currentItem.ID }}</span>
+      </div>
+      <div>
+        <span class="inline-block w-50px">设备 :</span>
+        <span>{{ currentItem.Alias }}</span>
+      </div>
+      <div>
+        <span class="inline-block w-50px">时间 :</span>
+        <span>{{ timeFormat(currentItem.EventTime) }}</span>
+      </div>
+      <div>
+        <span class="inline-block w-50px">IP :</span>
+        <span>{{ currentItem.EdgeIp }}</span>
+      </div>
+      <div>
+        <span class="inline-block w-50px">描述 :</span>
+        <span>{{ currentItem.Msg }}</span>
+      </div>
+    </n-flex>
+  </n-modal>
+</template>

+ 141 - 0
src/views/Alert/components/tableData.vue

@@ -0,0 +1,141 @@
+<script setup lang="ts">
+import {
+  NIcon,
+  NTag,
+  NButton,
+  NInput,
+  NSpin,
+  NStatistic,
+  NGrid,
+  NGi,
+  NDataTable,
+  NFlex,
+  NModal,
+  DataTableColumns,
+} from "naive-ui";
+import type { AlertItem, formInfo } from "../Alert.vue";
+import { timeFormat } from "@/utils";
+
+defineProps<{ alertData: AlertItem[]; loading: boolean }>();
+
+const getDataList = inject<(form: Partial<formInfo>) => void>("getDataList");
+
+function getLabel(item: any) {
+  if (Array.isArray(item.FieldList) && typeof item.Type === "number") {
+    return item.FieldList[item.Type] || `类型-${item.Type}`;
+  }
+  return item.Type || "未知";
+}
+
+const columns: DataTableColumns<AlertItem> = [
+  {
+    key: "ID",
+    width: 100,
+    align: "center",
+    title: "ID",
+  },
+  {
+    key: "Alias",
+    align: "center",
+    title: "设备",
+  },
+  {
+    key: "EdgeIp",
+    title: "IP",
+    align: "center",
+    width: 200,
+  },
+  {
+    key: "EventTime",
+    title: "时间",
+    align: "center",
+    resizable: true,
+    render: (row: AlertItem) => {
+      return timeFormat(row.EventTime);
+    },
+  },
+  {
+    key: "Msg",
+    title: "描述",
+    width: 200,
+    align: "center",
+    ellipsis: {
+      tooltip: true,
+    },
+  },
+  {
+    width: 100,
+    key: "actions",
+    align: "center",
+    title: "操作",
+    fixed: "right",
+    render(row) {
+      return h(
+        NButton,
+        {
+          size: "small",
+          onClick: () => alarmShow(row),
+        },
+        { default: () => "查看详情" },
+      );
+    },
+  },
+];
+
+const drawerShow = ref(false);
+const currentItem = ref<any>(null);
+const alarmShow = (item: any) => {
+  currentItem.value = item;
+  drawerShow.value = true;
+};
+</script>
+
+<template>
+  <div
+    class="h-full rounded-xl border border-white/5 shadow-2xl overflow-hidden"
+  >
+    <n-data-table
+      :columns="columns"
+      :data="alertData"
+      :flex-height="true"
+      :row-key="(row) => row.id"
+      :loading="loading"
+      class="analysis-table h-full"
+    />
+  </div>
+  <!-- <Drawer v-model="drawerShow" v-model:currentItem="currentItem">
+    <AlarmShow />
+  </Drawer> -->
+  <n-modal
+    v-model:show="drawerShow"
+    :mask-closable="false"
+    preset="dialog"
+    title="查看详情"
+    :showIcon="false"
+    negative-text="关闭"
+    @negative-click="drawerShow = false"
+  >
+    <n-flex vertical>
+      <div>
+        <span class="inline-block w-50px">ID :</span>
+        <span>{{ currentItem.ID }}</span>
+      </div>
+      <div>
+        <span class="inline-block w-50px">设备 :</span>
+        <span>{{ currentItem.Alias }}</span>
+      </div>
+      <div>
+        <span class="inline-block w-50px">时间 :</span>
+        <span>{{ timeFormat(currentItem.EventTime) }}</span>
+      </div>
+      <div>
+        <span class="inline-block w-50px">IP :</span>
+        <span>{{ currentItem.EdgeIp }}</span>
+      </div>
+      <div>
+        <span class="inline-block w-50px">描述 :</span>
+        <span>{{ currentItem.Msg }}</span>
+      </div>
+    </n-flex>
+  </n-modal>
+</template>

+ 6 - 15
src/views/Home/Home.vue

@@ -20,8 +20,9 @@ import {
   NFlex,
 } from "naive-ui";
 import { useFlvPlayer } from "omnimatrix-video-player";
-import { uuid, $mitt } from "@/utils";
+import { uuid } from "@/utils";
 import { useOutsideSystemStore } from "@/stores/modules/system";
+import AlertCom from "@/layout/components/AlertCom.vue";
 
 interface IscreenInfo {
   id: string;
@@ -241,14 +242,6 @@ const deviceList = ref([
   // { name: "北侧广场", key: "device6" },
 ]);
 
-// 告警信息数据
-const alertInfo = [
-  { time: "14:32:15", message: "区域A人数超限 (156/100)", type: "error" },
-  { time: "14:28:43", message: "区域B人数接近上限 (89/100)", type: "warning" },
-  { time: "14:15:22", message: "区域C人数正常 (45/100)", type: "success" },
-  { time: "14:10:08", message: "区域D停留时间过长", type: "warning" },
-];
-
 const currentActive = ref("");
 
 API_DEVICE_GET().then((res) => {
@@ -374,6 +367,7 @@ onMounted(() => {});
           </div>
           <!-- <div v-if="currentActive == item.key" class="i-ep-select"></div> -->
         </div>
+        <n-empty v-if="!deviceList.length" description="暂无设备" />
       </n-flex>
     </n-card>
     <div class="p-4 flex-1 flex flex-col gap-4">
@@ -484,15 +478,12 @@ onMounted(() => {});
           <div class="flex-1">
             <n-card
               class="h-full w-full"
-              :bordered="false"
+              content-class="overflow-y-auto!"
               title="告警信息"
               size="small"
+              :bordered="false"
             >
-              <n-empty
-                class="absolute top-1/2 left-1/2 -translate-1/2"
-                description="暂无告警"
-              >
-              </n-empty>
+              <AlertCom :is-icon="false" />
             </n-card>
           </div>
         </div>

+ 0 - 178
src/views/Home/components/gridData.vue

@@ -1,178 +0,0 @@
-<script setup lang="ts">
-import {
-  NCard,
-  NTag,
-  NButton,
-  NIcon,
-  NGrid,
-  NGi,
-  NBadge,
-  NImage,
-  NSkeleton,
-  NSpin,
-} from "naive-ui";
-
-const props = defineProps<{ alertData: any[]; loading?: boolean }>();
-
-const drawerShow = ref(false);
-const currentItem = ref<any>(null);
-
-function getLabel(item: any) {
-  if (Array.isArray(item.FieldList) && typeof item.Type === "number") {
-    return item.FieldList[item.Type] || `类型-${item.Type}`;
-  }
-  return item.Type || "未知";
-}
-
-function formatScore(s: number | undefined) {
-  if (s == null) return "-";
-  return `${Math.round((s as number) * 100)}%`;
-}
-
-
-const drawerOpen = (item: any) => {
-  currentItem.value = item;
-  drawerShow.value = true;
-};
-
-defineExpose({
-  drawerOpen,
-});
-</script>
-
-<template>
-  <n-grid x-gap="20" y-gap="20" cols="1 s:2 m:4 l:5" responsive="screen">
-    <n-gi v-if="props.loading" v-for="i in 8" :key="'skeleton-' + i">
-      <n-card
-        hoverable
-        content-style="padding: 0"
-        class="bg-[#283F7E]! border-white/5! rounded-xl! overflow-hidden"
-      >
-        <n-skeleton loading show-avatar show-title :paragraph="{ rows: 3 }" />
-      </n-card>
-    </n-gi>
-
-    <n-gi v-else v-for="item in props.alertData" :key="item.EventID || item.ID">
-      <n-card
-        hoverable
-        content-style="padding: 0"
-        class="bg-[#283F7E]! border-white/5! rounded-xl! overflow-hidden group hover:border-primary/50! transition-all duration-300"
-      >
-        <!-- 缩略图 -->
-        <div class="relative aspect-video bg-black/40 overflow-hidden">
-          <n-image
-            :preview-disabled="true"
-            :src="item.ImgUrl"
-            :img-props="{ class: 'w-full' }"
-            fit="fill"
-            class="w-full h-full"
-            lazy
-            :intersection-observer-options="{
-              root: '#image-scroll-container',
-            }"
-          >
-            <template #error>
-              <n-icon :size="100" color="lightGrey">
-                <span class="i-lucide-image-off"></span>
-              </n-icon>
-            </template>
-            <template #placeholder>
-              <div class="flex items-center justify-center w-full h-full">
-                <n-spin size="medium" />
-              </div>
-            </template>
-          </n-image>
-
-          <!-- 左上:类型 + 置信度 -->
-          <div class="absolute top-3 left-3 z-20 flex items-center gap-2">
-            <n-tag
-              size="small"
-              :bordered="false"
-              class="font-bold! bg-black/60 text-white"
-            >
-              {{ getLabel(item) }}
-            </n-tag>
-            <n-tag
-              size="small"
-              :bordered="false"
-              class="font-bold! bg-black/40 text-cyan-300"
-            >
-              {{ formatScore(item.Score) }}
-            </n-tag>
-          </div>
-
-          <!-- 右上:目标数量 -->
-          <div class="absolute top-3 right-3 z-20">
-            <n-badge type="info" :value="(item.Targets || []).length">
-              <div class="i-lucide-target text-slate-200" />
-            </n-badge>
-          </div>
-        </div>
-
-        <!-- 告警详情 -->
-        <div class="p-4 space-y-2">
-          <div class="flex justify-between items-start">
-            <div>
-              <h3 class="text-sm font-bold text-slate-100 line-clamp-1">
-                {{ getLabel(item) }}
-              </h3>
-              <div class="text-[12px] text-slate-400">
-                {{ item.EventTimeFormat || item.EventTime || "-" }}
-              </div>
-            </div>
-            <div class="text-[11px] text-slate-400 text-right">
-              <div>ID: {{ item.EventID || item.ID || "-" }}</div>
-              <div>Degree: {{ item.Degree ?? "-" }}</div>
-            </div>
-          </div>
-
-          <div
-            class="grid grid-cols-2 gap-y-2 text-[11px] text-slate-500 font-mono"
-          >
-            <div class="flex items-center gap-1.5">
-              <div class="i-lucide-compass w-3 h-3 text-slate-600" />
-              Pan: {{ item.Pan ?? "-" }}°
-            </div>
-            <div class="flex items-center gap-1.5">
-              <div class="i-lucide-arrow-up-down w-3 h-3 text-slate-600" />
-              Tilt: {{ item.Tilt ?? "-" }}°
-            </div>
-            <div class="flex items-center gap-1.5">
-              <div class="i-lucide-map-pin w-3 h-3 text-slate-600" />
-              X: {{ item.X ?? "-" }} Y: {{ item.Y ?? "-" }}
-            </div>
-            <div class="flex items-center gap-1.5">
-              <div class="i-lucide-box w-3 h-3 text-slate-600" />
-              W: {{ item.W ?? "-" }} H: {{ item.H ?? "-" }}
-            </div>
-          </div>
-
-          <div class="pt-2 flex gap-2">
-            <n-button
-              size="small"
-              class="flex-1 rounded-md!"
-              type="primary"
-              @click="drawerOpen(item)"
-            >
-              <template #icon><div class="i-lucide-file-text" /></template>
-              处置
-            </n-button>
-            <!-- <n-button
-              size="small"
-              type="error"
-              class="flex-1 rounded-md!"
-              ghost
-              @click="console.log('详情', item)"
-            >
-              <template #icon><div class="i-lucide-delete" /></template>
-              删除
-            </n-button> -->
-          </div>
-        </div>
-      </n-card>
-    </n-gi>
-  </n-grid>
-  <Drawer v-model="drawerShow" v-model:currentItem="currentItem">
-    <AlarmShow />
-  </Drawer>
-</template>

+ 0 - 193
src/views/Home/components/tableData.vue

@@ -1,193 +0,0 @@
-<script setup lang="ts">
-import {
-  NIcon,
-  NTag,
-  NButton,
-  NInput,
-  NSpin,
-  NStatistic,
-  NGrid,
-  NGi,
-  NDataTable,
-  NBadge,
-  NBreadcrumb,
-  NBreadcrumbItem,
-  NEmpty,
-  NImage,
-  DataTableColumns,
-} from "naive-ui";
-import type { AlertItem, formInfo } from "@/views/Home/Home.vue";
-import dayjs from "dayjs";
-import { getAlarmStatusName } from "@/utils";
-
-defineProps<{ alertData: AlertItem[]; loading: boolean }>();
-
-export interface AlarmInfo {
-  alert_id: string;
-  usb_camera_index: number;
-  image_file_name: string;
-  degree: number;
-  event_timestamp: number;
-  target_x: number;
-  target_y: number;
-  target_width: number;
-  target_height: number;
-  confidence_score: number;
-  alarm_type: number;
-  pan_angle: number;
-  tilt_angle: number;
-  event_id: string;
-  status: number;
-  create_time: number;
-}
-
-const getDataList = inject<(form: Partial<formInfo>) => void>("getDataList");
-
-function getLabel(item: any) {
-  if (Array.isArray(item.FieldList) && typeof item.Type === "number") {
-    return item.FieldList[item.Type] || `类型-${item.Type}`;
-  }
-  return item.Type || "未知";
-}
-
-const columns: DataTableColumns<AlarmInfo> = [
-  {
-    key: "number",
-    title: "序号",
-    width: 100,
-    align: "center",
-  },
-  {
-    key: "EventID",
-    width: 160,
-    align: "center",
-    title: "事件ID",
-  },
-  {
-    key: "Type",
-    align: "center",
-    title: "告警类型",
-    render: (row) => {
-      return h("span", {}, getLabel(row));
-    },
-  },
-  {
-    key: "Status",
-    title: "告警状态",
-    align: "center",
-    width: 120,
-    render: (row) => {
-      let type:
-        | "default"
-        | "success"
-        | "error"
-        | "warning"
-        | "info"
-        | "primary"
-        | undefined = "default";
-      switch (getAlarmStatusName(row.Status)) {
-        case getAlarmStatusName(row.Status):
-          type = "error";
-          break;
-        case getAlarmStatusName(row.Status):
-          type = "warning";
-          break;
-        case getAlarmStatusName(row.Status):
-          type = "info";
-          break;
-        case getAlarmStatusName(row.Status):
-          type = "success";
-          break;
-        default:
-          type = "default";
-      }
-
-      return h(NTag, { type }, () => getAlarmStatusName(row.Status));
-    },
-  },
-  {
-    key: "EventTimeFormat",
-    title: "告警时间",
-    align: "center",
-    resizable: true,
-    defaultSortOrder: "descend",
-    sorter: (row1, row2) => row1.EventTimeFormat - row2.EventTimeFormat,
-    customNextSortOrder: (order) => {
-      if (order === "ascend") {
-        getDataList({ sort_asc: false });
-        return "descend";
-      }
-      getDataList({ sort_asc: true });
-      return "ascend";
-    },
-  },
-  {
-    key: "image_file_name",
-    title: "告警图片",
-    width: 200,
-    align: "center",
-    render: (row) =>
-      h(
-        NImage,
-        {
-          src: row.ImgUrl,
-          alt: "告警图片",
-          width: 60,
-          height: 60,
-          previewDisabled: true,
-          class: "m-auto cursor-pointer object-cover",
-          onClick: () => alarmShow(row),
-        },
-        {
-          error: () =>
-            h(
-              NIcon,
-              { size: 100, color: "lightGrey" },
-              {
-                default: () => h("span", { class: "i-lucide-image-off" }),
-              },
-            ),
-          placeholder: () =>
-            h(
-              "div",
-              { class: "flex items-center justify-center w-full h-full" },
-              [h(NSpin, { size: "medium" })],
-            ),
-        },
-      ),
-  },
-  // {
-  //   width: 140,
-  //   key: 'actions',
-  //   align: 'center',
-  //   title: '操作',
-  //   fixed: 'right',
-  //   render: (row) => <CellActions {...row} />,
-  // },
-];
-
-const drawerShow = ref(false);
-const currentItem = ref<any>(null);
-const alarmShow = (item: any) => {
-  currentItem.value = item;
-  drawerShow.value = true;
-};
-</script>
-
-<template>
-  <div
-    class="bg-[#141414] h-full rounded-xl border border-white/5 shadow-2xl overflow-hidden"
-  >
-    <n-data-table
-      :columns="columns"
-      :data="alertData"
-      :flex-height="true"
-      :row-key="(row) => row.id"
-      :loading="loading"
-      class="analysis-table h-full"
-    />
-  </div>
-  <Drawer v-model="drawerShow" v-model:currentItem="currentItem">
-    <AlarmShow />
-  </Drawer>
-</template>