gitboyzcf преди 2 седмици
родител
ревизия
f0b7402870
променени са 11 файла, в които са добавени 552 реда и са изтрити 334 реда
  1. 1 0
      package.json
  2. 8 0
      pnpm-lock.yaml
  3. 3 2
      src/App.vue
  4. 10 2
      src/api/modules/api.ts
  5. 119 0
      src/components/alarm-drawer/Drawer.vue
  6. 1 1
      src/main.ts
  7. 140 86
      src/stores/alarm.ts
  8. 37 0
      src/utils/index.ts
  9. 17 4
      src/views/alarm-management/index.vue
  10. 209 238
      src/views/dashboard/index.vue
  11. 7 1
      src/views/monitoring-platform/index.vue

+ 1 - 0
package.json

@@ -22,6 +22,7 @@
   "dependencies": {
     "@vitejs/plugin-basic-ssl": "^2.1.0",
     "@vueuse/core": "^13.6.0",
+    "animate.css": "^4.1.1",
     "axios": "^1.12.2",
     "chroma-js": "^3.1.2",
     "dayjs": "^1.11.18",

+ 8 - 0
pnpm-lock.yaml

@@ -14,6 +14,9 @@ importers:
       '@vueuse/core':
         specifier: ^13.6.0
         version: 13.6.0(vue@3.5.18(typescript@5.8.3))
+      animate.css:
+        specifier: ^4.1.1
+        version: 4.1.1
       axios:
         specifier: ^1.12.2
         version: 1.12.2
@@ -1233,6 +1236,9 @@ packages:
   alien-signals@2.0.5:
     resolution: {integrity: sha512-PdJB6+06nUNAClInE3Dweq7/2xVAYM64vvvS1IHVHSJmgeOtEdrAGyp7Z2oJtYm0B342/Exd2NT0uMJaThcjLQ==}
 
+  animate.css@4.1.1:
+    resolution: {integrity: sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==, tarball: https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz}
+
   ansi-regex@5.0.1:
     resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, tarball: https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz}
     engines: {node: '>=8'}
@@ -3983,6 +3989,8 @@ snapshots:
 
   alien-signals@2.0.5: {}
 
+  animate.css@4.1.1: {}
+
   ansi-regex@5.0.1: {}
 
   ansi-regex@6.1.0: {}

+ 3 - 2
src/App.vue

@@ -16,12 +16,12 @@ import { RouterView } from 'vue-router'
 
 import Noise from '@/components/Noise.vue'
 import { getConfigProviderProps } from '@/composables'
-import { usePreferencesStore } from '@/stores'
+import { usePreferencesStore, useAlarmStore } from '@/stores'
 
 import { layoutInjectionKey, mediaQueryInjectionKey } from './injection'
 
 import type { LayoutSlideDirection } from './injection'
-
+const { connectAlarmWS } = useAlarmStore()
 const { showWatermark, showNoise, watermarkOptions } = storeToRefs(usePreferencesStore())
 const configProviderProps = getConfigProviderProps()
 
@@ -53,6 +53,7 @@ provide(layoutInjectionKey, {
   mobileLeftAsideWidth: ref(0),
   mobileRightAsideWidth: ref(0),
 })
+connectAlarmWS()
 </script>
 
 <template>

+ 10 - 2
src/api/modules/api.ts

@@ -1,6 +1,7 @@
 import { request } from '../request'
 export type apiT = {
-  API_ALERT_ESEARCH_GET: (params?: object) => Promise<unknown>;
+  API_ALERT_ESEARCH_GET: (params?: object) => Promise<unknown>
+  API_ALERT_STATISTICS_GET: (params?: object) => Promise<unknown>
 }
 const apiAll: apiT = {
   API_ALERT_ESEARCH_GET(params = {}) {
@@ -9,7 +10,14 @@ const apiAll: apiT = {
       method: 'get',
       params,
     })
-  }
+  },
+  API_ALERT_STATISTICS_GET(params = {}) {
+    return request({
+      url: '/api/alert/statistics',
+      method: 'get',
+      params,
+    })
+  },
 }
 
 export default apiAll

+ 119 - 0
src/components/alarm-drawer/Drawer.vue

@@ -0,0 +1,119 @@
+<template>
+  <NDrawer
+    v-model:show="model"
+    :mask-closable="false"
+    :placement="placement"
+    :width="300"
+  >
+    <NDrawerContent
+      closable
+    >
+      <template #header> 告警查看 </template>
+      <!-- <template #footer> Footer </template> -->
+      <slot></slot>
+    </NDrawerContent>
+
+    <div
+      class="absolute top-0 right-[300px] flex h-full w-[calc(100vw-300px)] flex-col items-center justify-around"
+    >
+      <div>
+        <img
+          width="500"
+          src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
+          alt=""
+          :style="styleObj"
+        />
+      </div>
+      <div class="rounded-2xl bg-[rgba(225,225,225,0.8)] px-6 py-4">
+        <NButton
+          v-for="(item, i) in baseHandles"
+          :key="i"
+          quaternary
+          round
+          type="info"
+          @click="item.onClick"
+        >
+          <template #icon>
+            <span :class="[item.icon]"></span>
+          </template>
+        </NButton>
+      </div>
+    </div>
+  </NDrawer>
+</template>
+
+<script setup lang="ts">
+import { NDrawer, NDrawerContent } from 'naive-ui'
+import { NButton } from 'naive-ui'
+import { reactive, ref, watch } from 'vue'
+
+import type { DrawerPlacement } from 'naive-ui'
+const model = defineModel({ default: false })
+
+const props = defineProps<{
+  currentItem: any
+}>()
+
+let rotate = 0
+let scale = 1
+const styleObj = reactive<Record<string, string>>({
+  transition: 'all 0.5s ease',
+  transform: 'rotate(0) scale(1)',
+})
+const placement = ref<DrawerPlacement>('right')
+
+const baseHandles = [
+  {
+    icon: 'iconify-[mdi--close]',
+    onClick: () => {
+      model.value = false
+    },
+  },
+  {
+    icon: 'iconify-[mdi--rotate-left]',
+    onClick: () => {
+      rotate -= 90
+      styleObj.transform = `rotate(${rotate}deg) scale(${scale})`
+    },
+  },
+  {
+    icon: 'iconify-[mdi--rotate-right]',
+    onClick: () => {
+      rotate += 90
+      styleObj.transform = `rotate(${rotate}deg) scale(${scale})`
+    },
+  },
+  {
+    icon: 'iconify-[mdi--zoom-in-outline]',
+    onClick: () => {
+      scale += 0.1
+      styleObj.transform = `rotate(${rotate}deg) scale(${scale})`
+    },
+  },
+  {
+    icon: 'iconify-[mdi--zoom-out-outline]',
+    onClick: () => {
+      scale -= 0.1
+      styleObj.transform = `rotate(${rotate}deg) scale(${scale})`
+    },
+  },
+]
+
+const handleList = ref([{}])
+
+watch(model, (v) => {
+  if (v) {
+    console.log(props.currentItem)
+    // nextTick(() => {
+    //   const imageDom = document.getElementById('image')
+    //   if (!imageDom) return
+    //   const viewer = new Viewer(imageDom, {
+    //     inline: true,
+    //     viewed() {
+    //       viewer.zoomTo(1)
+    //     },
+    //   })
+    // })
+  }
+})
+</script>

+ 1 - 1
src/main.ts

@@ -1,5 +1,5 @@
 import './assets/main.css'
-
+import 'animate.css'
 import { createApp } from 'vue'
 
 import { setupRouterGuard } from '@/router/guard'

+ 140 - 86
src/stores/alarm.ts

@@ -1,6 +1,8 @@
 import dayjs from 'dayjs'
 import { acceptHMRUpdate, defineStore, storeToRefs } from 'pinia'
 
+import { drawBoundingBox } from '@/utils'
+
 import { pinia } from '.'
 
 export interface Alarm {
@@ -11,50 +13,58 @@ export interface Alarm {
   type: string
 }
 
+interface AlarmState {
+  alarms: Alarm[]
+  aiList: Alarm[]
+  alarmCount: number
+  ip: string
+}
+
 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', {
-  state: () => ({
+  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',
-      },
+      // {
+      //   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[],
+    alarmCount: 0,
     ip:
       import.meta.env.VITE_APP_API_BASEURL === '/'
         ? window.location.origin
         : import.meta.env.VITE_APP_API_BASEURL,
   }),
   getters: {
-    ipF(state) {
+    ipF: (state) => {
       return state.ip.split('//')[1]
     },
   },
@@ -65,68 +75,112 @@ export const useAlarmStore = defineStore('alarm', {
     removeAlarm(alarm: Alarm) {
       this.alarms = this.alarms.filter((a) => a.id !== alarm.id)
     },
+    getFileUrl(fileName: string) {
+      return `${this.ip}/data/show/${fileName}`
+    },
     // 连接报警信息
     async connectAlarmWS() {
-      // setInterval(() => {
-      //   const data = {
-      //     AlarmTime: 1709274691,
-      //     CfgId: 95,
-      //     Type: 'human',
-      //     SerialNumber: '5cc8e284-55c9-4916-b493-20a222d3386f',
-      //     AlarmId: 1516,
-      //     Res: {
-      //       '16535ccf-ab52-4553-a494-f168f5144bd2%7C9%7C1546372969796': [
-      //         { x: 525, y: 1775, w: 1615, h: 2067 }
-      //       ],
-      //       'd29e7346-8d8d-44ff-918b-ca0928687f75|0|1709628059861': [
-      //         { x: 525, y: 1775, w: 1615, h: 2067 }
-      //       ]
-      //     }
-      //   }
-      //   this.aiList.unshift({
-      //     ...data,
-      //     state: 0,
-      //     AlgorithmName: data.Type,
-      //     timeFormat: dayjs(parseInt(`${data.AlarmTime}`.padEnd(13, '0'))).format(
-      //       'YYYY-MM-DD HH:mm:ss'
-      //     )
-      //   })
-      //   if (this.aiList.length >= 30) {
-      //     this.aiList.splice(20, 10)
-      //   }
-      // }, 1000)
-      let websocket
-      // eslint-disable-next-line @typescript-eslint/no-this-alias
-      const that = this
-      ;(() => {
-        // websocket = new WebSocket(`wss://${this.ipF}/api/Ai-Message`)
-        websocket = new WebSocket(`wss://${this.ipF}/api/alert/ws`)
-        websocket.onopen = function () {
-          console.log('Ai/Ai-Message 连接已经建立')
+      let ii = 0
+      setInterval(() => {
+        const data: any = {
+          ID: 0,
+          UsbCameraIndex: 3,
+          Image: 'event-1757034431210-ZDK5McUW-0.jpg',
+          ImageData: '',
+          Video: '',
+          Degree: 190,
+          EventTime: 1757034431210,
+          X: 19,
+          Y: 1,
+          W: 150,
+          H: 197,
+          Score: 0.90657794,
+          Type: 0,
+          Pan: -48.894867,
+          Tilt: 76.85202,
+          EventID: 'event-1757034431210-ZDK5McUW-0',
+          Status: 0,
         }
-        websocket.onmessage = function (e) {
-          const data = JSON.parse(e.data)
-          // console.log(data)
-          that.aiList.unshift({
-            ...data,
-            state: 0,
-            AlgorithmName: data.Type,
-            timeFormat: dayjs(parseInt(`${data.AlarmTime}`.padEnd(13, '0'))).format(
-              'YYYY-MM-DD HH:mm:ss',
-            ),
-          })
+        this.aiList.unshift({
+          ...data,
+          EventID: ii++,
+          ImgUrl: this.getFileUrl(data.Image),
+          EventTimeFormat: formatDate(data.EventTime),
+        })
+        const alarms = this.aiList.slice(0, 4).map((item: any) => ({
+          id: item.EventID,
+          location: '',
+          timestamp: formatDate(new Date()),
+          img: item.ImgUrl,
+          type: 'large',
+          ...item,
+        }))
 
-          if (that.aiList.length >= 30) {
-            that.aiList.splice(20, 10)
+        alarms.forEach((item: any) => {
+          const i = this.alarms.findIndex((alarm) => alarm.id === item.id)
+          // eslint-disable-next-line @typescript-eslint/no-this-alias
+          const that = this
+          if (i === -1) {
+            drawBoundingBox(item).then((res: any) => {
+              item.img = res
+              that.alarms.unshift(item)
+            })
+            if (this.alarms.length == 5) this.alarms.splice(this.alarms.length - 1, 1)
           }
+        })
+        this.alarmCount++
+        if (this.aiList.length >= 30) {
+          this.aiList.splice(20, 10)
         }
-        websocket.onerror = function () {
-          console.log('Ai/Alarm 发生错误')
-        }
-        websocket.onclose = function () {
-          console.log('Ai/Alarm 连接断开')
-        }
-      })()
+      }, 2000)
+      // let websocket
+      // // eslint-disable-next-line @typescript-eslint/no-this-alias
+      // const that = this
+      // ;(() => {
+      //   // websocket = new WebSocket(`wss://${this.ipF}/api/Ai-Message`)
+      //   websocket = new WebSocket(`wss://${this.ipF}/api/alert/ws`)
+      //   websocket.onopen = function () {
+      //     console.log('Ai/Ai-Message 连接已经建立')
+      //   }
+      //   websocket.onmessage = function (e) {
+      //     const data = JSON.parse(e.data)
+      //     that.alarmCount++
+      //     that.aiList.unshift({
+      //       ...data,
+      //       ImgUrl: that.getFileUrl(data.Image),
+      //       EventTimeFormat: formatDate(data.EventTime),
+      //     })
+      //     const alarms = that.aiList.slice(0, 4).map((item: any) => ({
+      //       id: item.EventID,
+      //       location: '',
+      //       timestamp: formatDate(new Date()),
+      //       img: item.ImgUrl,
+      //       type: 'large',
+      //       ...item
+      //     }))
+      //     console.log(alarms)
+
+      //     alarms.forEach((item: any) => {
+      //       const i = that.alarms.findIndex((alarm) => alarm.id === item.id)
+      //       console.log(i)
+      //       if (i === -1) {
+      //         ;(function () {
+      //           drawBoundingBox(item).then((res: any) => {
+      //             item.img = res
+      //             that.alarms.unshift(item)
+      //           })
+      //         })()
+      //         if (that.alarms.length == 5) that.alarms.splice(that.alarms.length - 1, 1)
+      //       }
+      //     })
+      //   }
+      //   websocket.onerror = function () {
+      //     console.log('Ai/Alarm 发生错误')
+      //   }
+      //   websocket.onclose = function () {
+      //     console.log('Ai/Alarm 连接断开')
+      //   }
+      // })()
     },
   },
 })

+ 37 - 0
src/utils/index.ts

@@ -0,0 +1,37 @@
+// 绘制边界框的函数
+export function drawBoundingBox(item: any) {
+  return new Promise((resolve, reject) => {
+    const img = new Image()
+    img.crossOrigin = 'Anonymous' // 处理跨域图片
+
+    img.onload = function () {
+      const canvas = document.createElement('canvas')
+      canvas.width = img.width
+      canvas.height = img.height
+
+      const ctx = canvas.getContext('2d')
+      if (!ctx) return
+      ctx.drawImage(img, 0, 0)
+
+      // 绘制红色边界框
+      ctx.strokeStyle = '#ff0000'
+      ctx.lineWidth = 3
+      ctx.strokeRect(item.X, item.Y, item.W, item.H)
+
+      // 添加标签
+      ctx.fillStyle = '#ff0000'
+      ctx.font = '16px Arial'
+      ctx.fillText('告警区域', item.X, item.Y - 5)
+
+      // 将Canvas转换为Data URL
+      const dataURL = canvas.toDataURL('image/jpeg')
+      resolve(dataURL)
+    }
+
+    img.onerror = function () {
+      reject(new Error('图片加载失败: ' + item.Image))
+    }
+
+    img.src = item.img
+  })
+}

+ 17 - 4
src/views/alarm-management/index.vue

@@ -131,9 +131,9 @@
       trigger="manual"
       v-bind="dropdownOptions"
       :show="enableContextmenu && showDropdown"
-      />
-    </ScrollContainer>
-  </template>
+    />
+  </ScrollContainer>
+</template>
 
 <script setup lang="tsx">
 import dayjs from 'dayjs'
@@ -153,6 +153,7 @@ import {
   NTag,
   NNumberAnimation,
   NDatePicker,
+  NImage
 } from 'naive-ui'
 import { reactive, ref, useTemplateRef, nextTick } from 'vue'
 
@@ -160,6 +161,7 @@ import { useRequest } from '@/api'
 import { ScrollContainer } from '@/components'
 import { useInjection, useComponentModifier, useResettableReactive } from '@/composables'
 import { mediaQueryInjectionKey } from '@/injection'
+import { useAlarmStore } from '@/stores'
 
 import ActionModal from './components/ActionModal.vue'
 
@@ -209,6 +211,8 @@ defineOptions({ name: 'AlarmManagement' })
 
 const { API_ALERT_ESEARCH_GET } = useRequest()
 
+const { getFileUrl } = useAlarmStore()
+
 const { isMaxMd, isMaxLg } = useInjection(mediaQueryInjectionKey)
 
 const formRef = useTemplateRef<InstanceType<typeof NForm>>('formRef')
@@ -355,6 +359,14 @@ const columns: DataTableColumns<AlarmInfo> = [
     key: 'image_file_name',
     title: '告警图片',
     align: 'center',
+    render: (row) => (
+      <NImage
+        lazy
+        width='100'
+        alt='告警图片'
+        src={row.image_file_name}
+      />
+    ),
   },
   {
     width: 140,
@@ -445,7 +457,6 @@ const dropdownOptions = reactive<DropdownProps>({
   },
 })
 
-
 function createOrEditData(data?: AlarmInfo) {
   const title = data ? '编辑数据' : '新增数据'
 
@@ -496,9 +507,11 @@ async function getDataList() {
   const res = (await API_ALERT_ESEARCH_GET(form).finally(() => {
     isRequestLoading.value = false
   })) as { items: AlarmInfo[]; page: number; page_size: number; total: number }
+  
   dataList.value = res.items.map((item, index) => ({
     ...item,
     event_timestamp_f: dayjs(item.event_timestamp).format('YYYY-MM-DD HH:mm:ss'),
+    image_file_name: getFileUrl(item.image_file_name),
     number:
       pagination.page &&
       pagination.pageSize &&

+ 209 - 238
src/views/dashboard/index.vue

@@ -1,11 +1,14 @@
 <script setup lang="ts">
 import { watchDebounced } from '@vueuse/core'
 import chroma from 'chroma-js'
+import dayjs from 'dayjs'
 import * as echarts from 'echarts'
-import { NNumberAnimation, NTag, NCard, NButton } from 'naive-ui'
+import { NTag, NCard, NButton, NEmpty } from 'naive-ui'
 import { onMounted, watch, ref, onUnmounted } from 'vue'
 
+import { useRequest } from '@/api'
 import { ScrollContainer } from '@/components'
+import Drawer from '@/components/alarm-drawer/Drawer.vue'
 import { toRefsPreferencesStore, toRefsAlarmStore } from '@/stores'
 import twc from '@/utils/tailwindColor'
 
@@ -15,13 +18,15 @@ defineOptions({
   name: 'Dashboard',
 })
 
+const tF = () => dayjs().subtract(2, 'minute').format('HH:mm:ss')
 const { sidebarMenu, navigationMode, themeColor, isDark } = toRefsPreferencesStore()
-const { alarms } = toRefsAlarmStore()
+const { alarms, alarmCount } = toRefsAlarmStore()
+const { API_ALERT_STATISTICS_GET } = useRequest()
 
 const cardList = ref([
   {
     title: '今日告警',
-    value: 23,
+    value: alarmCount,
     suffix: '个',
     iconClass: 'iconify ph--warning-bold text-indigo-50 dark:text-indigo-150',
     iconBgClass:
@@ -31,7 +36,7 @@ const cardList = ref([
     title: '数据更新频率',
     value: 2,
     suffix: '分钟',
-    description: '上次更新: 10:42:15',
+    description: `上次更新: ${tF()}`,
     iconClass: 'iconify ph--timer-bold text-blue-50 dark:text-blue-150',
     iconBgClass:
       'text-blue-500/5 bg-blue-400 ring-4 ring-blue-200 dark:bg-blue-650 dark:ring-blue-500/30 transition-all',
@@ -67,143 +72,52 @@ const createTooltipConfig = (formatter?: any) => ({
   ...(formatter && { formatter }),
 })
 
+const [loading1, loading2, loading3] = [true, true, true]
 const chartDataManager = {
   get24HourLine: () => {
     return new Promise((resolve) => {
-      // 24小时活动分布
-      resolve([
-        {
-          label: '00:00',
-          value: 100,
-        },
-        {
-          label: '01:00',
-          value: 120,
-        },
-        {
-          label: '02:00',
-          value: 80,
-        },
-        {
-          label: '03:00',
-          value: 90,
-        },
-        {
-          label: '04:00',
-          value: 110,
-        },
-        {
-          label: '05:00',
-          value: 130,
-        },
-        {
-          label: '06:00',
-          value: 150,
-        },
-        {
-          label: '07:00',
-          value: 170,
-        },
-        {
-          label: '08:00',
-          value: 190,
-        },
-        {
-          label: '09:00',
-          value: 210,
-        },
-        {
-          label: '10:00',
-          value: 230,
-        },
-        {
-          label: '11:00',
-          value: 250,
-        },
-        {
-          label: '12:00',
-          value: 270,
-        },
-        {
-          label: '13:00',
-          value: 290,
-        },
-        {
-          label: '14:00',
-          value: 310,
-        },
-        {
-          label: '15:00',
-          value: 330,
-        },
-        {
-          label: '16:00',
-          value: 350,
-        },
-        {
-          label: '17:00',
-          value: 370,
-        },
-        {
-          label: '18:00',
-          value: 390,
-        },
-        {
-          label: '19:00',
-          value: 410,
-        },
-        {
-          label: '20:00',
-          value: 430,
-        },
-        {
-          label: '21:00',
-          value: 450,
-        },
-        {
-          label: '22:00',
-          value: 470,
-        },
-        {
-          label: '23:00',
-          value: 490,
-        },
-      ])
+      getStatistics('hour').then((res: any) => {
+        // 24小时活动分布
+        resolve(res.Periods.map((item: any) => ({ label: item.Period, value: item.Count })))
+      })
     })
   },
 
   getLowestWeekLine: () => {
     return new Promise((resolve) => {
-      resolve([
-        {
-          label: '周一',
-          value: [1000, 5000, 8000, 1000],
-        },
-        {
-          label: '周二',
-          value: [4000, 6000, 9000, 1000],
-        },
-        {
-          label: '周三',
-          value: [3000, 7000, 10000, 5000],
-        },
-        {
-          label: '周四',
-          value: [5000, 8000, 12000, 6000],
-        },
-        {
-          label: '周五',
-          value: [4000, 6000, 9000, 1000],
-        },
-        {
-          label: '周六',
-          value: [6000, 8000, 10000, 2000],
-        },
-        {
-          label: '周日',
-          value: [7000, 9000, 600, 3000],
-        },
-      ])
+      getStatistics('day').then((res: any) => {
+        resolve(res.Periods.map((item: any) => ({ label: item.Period, value: [item.Count] })))
+      })
+      // resolve([
+      //   {
+      //     label: '周一',
+      //     value: [1000, 5000, 8000, 1000],
+      //   },
+      //   {
+      //     label: '周二',
+      //     value: [4000, 6000, 9000, 1000],
+      //   },
+      //   {
+      //     label: '周三',
+      //     value: [3000, 7000, 10000, 5000],
+      //   },
+      //   {
+      //     label: '周四',
+      //     value: [5000, 8000, 12000, 6000],
+      //   },
+      //   {
+      //     label: '周五',
+      //     value: [4000, 6000, 9000, 1000],
+      //   },
+      //   {
+      //     label: '周六',
+      //     value: [6000, 8000, 10000, 2000],
+      //   },
+      //   {
+      //     label: '周日',
+      //     value: [7000, 9000, 600, 3000],
+      //   },
+      // ])
     })
   },
 
@@ -219,41 +133,40 @@ const chartDataManager = {
   },
 }
 
-async function initRevenueChart() {
-  if (!revenueChart.value) return
+const initRevenueChartOption = async (chart: ECharts) => {
   const lineData = (await chartDataManager.getLowestWeekLine()) as Array<{
     label: string
     value: number
   }>
+  chart.hideLoading()
   const lineX = lineData.map((item) => item.label)
   const lineY = lineData.map((item) => item.value)
 
   const legend = [
     {
       name: '大型鸟类',
-      color: '#EF4444', // 红色
+      color: themeColor.value, // 红色
       data: lineY.map((data: any) => data[0]),
     },
-    {
-      name: '中型鸟类',
-      color: '#F59E0B', // 黄色
-      data: lineY.map((data: any) => data[1]),
-    },
-    {
-      name: '小型鸟类',
-      color: '#3B82F6', // 蓝色
-      data: lineY.map((data: any) => data[2]),
-    },
-    {
-      name: '未知',
-      color: '#10B981', // 绿色
-      data: lineY.map((data: any) => data[3]),
-    },
+    // {
+    //   name: '中型鸟类',
+    //   color: '#F59E0B', // 黄色
+    //   data: lineY.map((data: any) => data[1]),
+    // },
+    // {
+    //   name: '小型鸟类',
+    //   color: '#3B82F6', // 蓝色
+    //   data: lineY.map((data: any) => data[2]),
+    // },
+    // {
+    //   name: '未知',
+    //   color: '#10B981', // 绿色
+    //   data: lineY.map((data: any) => data[3]),
+    // },
   ]
 
-  const chart = echarts.init(revenueChart.value)
-
   const option = {
+    loading3,
     title: [
       {
         text: '周活动趋势',
@@ -296,7 +209,7 @@ async function initRevenueChart() {
     },
     grid: {
       left: 20,
-      right: 20,
+      right: 38,
       top: 72,
       bottom: 0,
       containLabel: true,
@@ -313,6 +226,7 @@ async function initRevenueChart() {
       },
       splitLine: {
         show: true,
+        rotate: 'auto',
         lineStyle: {
           type: 'dashed',
           color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
@@ -328,10 +242,6 @@ async function initRevenueChart() {
       axisLabel: {
         color: isDark.value ? twc.neutral[400] : twc.neutral[600],
         fontSize: 11,
-        formatter(value: number) {
-          if (value === 0) return '0'
-          return `${(value / 1000).toFixed(0)},000`
-        },
       },
       splitLine: {
         show: true,
@@ -347,7 +257,7 @@ async function initRevenueChart() {
       type: 'line',
       data: line.data,
       symbol: 'circle',
-      showSymbol: false,
+      symbolSize: 6,
       smooth: true,
       lineStyle: {
         color: line.color,
@@ -389,23 +299,24 @@ async function initRevenueChart() {
   }
 
   chart.setOption(option)
+}
 
+async function initRevenueChart() {
+  if (!revenueChart.value) return
+  const chart = echarts.init(revenueChart.value)
+  chart?.showLoading()
+  initRevenueChartOption(chart)
   revenueChartInstance = chart
   revenueChartResizeHandler = () => chart.resize()
   window.addEventListener('resize', revenueChartResizeHandler, { passive: true })
 }
 
-async function initMonthlyRadarChart() {
-  if (!monthlyRadarChart.value) return
-
-  // const now = new Date()
-  // const currentDay = now.getDay()
-
+const initMonthlyRadarChartOption = async (chart: ECharts) => {
   const currentDayData = await chartDataManager.getCurrentDayData()
-
-  const chart = echarts.init(monthlyRadarChart.value)
+  chart.hideLoading()
 
   const option = {
+    loading1,
     title: {
       text: '鸟类分布图',
       textStyle: {
@@ -446,24 +357,31 @@ async function initMonthlyRadarChart() {
   }
 
   chart.setOption(option)
+}
+
+async function initMonthlyRadarChart() {
+  if (!monthlyRadarChart.value) return
+
+  // const now = new Date()
+  // const currentDay = now.getDay()
+
+  const chart = echarts.init(monthlyRadarChart.value)
+  chart?.showLoading()
+  initMonthlyRadarChartOption(chart)
   monthlyRadarChartInstance = chart
   monthlyRadarChartResizeHandler = () => chart.resize()
   window.addEventListener('resize', monthlyRadarChartResizeHandler, { passive: true })
 }
-
-async function initHighestRevenueChart() {
-  if (!highestRevenueChart.value) return
-
+const initHighestRevenueChartOption = async (chart: ECharts) => {
   const twentyFourHourLine = (await chartDataManager.get24HourLine()) as Array<{
     label: string
     value: number
   }>
+  chart.hideLoading()
   const xAxisData = twentyFourHourLine.map((item) => item.label)
   const seriesData = twentyFourHourLine.map((item) => item.value)
-
-  const chart = echarts.init(highestRevenueChart.value)
-
   const option = {
+    loading2,
     title: {
       text: '24小时活动分布',
       textStyle: {
@@ -512,6 +430,9 @@ async function initHighestRevenueChart() {
         axisTick: {
           alignWithLabel: true,
         },
+        axisLabel: {
+          rotate: 45,
+        },
         splitLine: {
           show: true,
           lineStyle: {
@@ -547,15 +468,42 @@ async function initHighestRevenueChart() {
   }
 
   chart.setOption(option)
+}
+async function initHighestRevenueChart() {
+  if (!highestRevenueChart.value) return
+
+  const chart = echarts.init(highestRevenueChart.value)
+  chart?.showLoading()
+  initHighestRevenueChartOption(chart)
   highestRevenueChartInstance = chart
   highestRevenueChartResizeHandler = () => chart.resize()
   window.addEventListener('resize', highestRevenueChartResizeHandler, { passive: true })
 }
+// hour (24小时) day (7天) month (30天)
+const getStatistics = (periodType: 'hour' | 'day' | 'month') => {
+  return new Promise((resolve) => {
+    API_ALERT_STATISTICS_GET({ periodType }).then((res) => {
+      resolve(res)
+    })
+  })
+}
 
-onMounted(() => {
+const initEcharts = () => {
   initRevenueChart()
   initMonthlyRadarChart()
   initHighestRevenueChart()
+}
+
+let timer: any = null
+onMounted(() => {
+  initEcharts()
+  // 两分钟更新一次
+  timer = setInterval(() => {
+    if (revenueChartInstance) initRevenueChartOption(revenueChartInstance)
+    if (monthlyRadarChartInstance) initMonthlyRadarChartOption(monthlyRadarChartInstance)
+    if (highestRevenueChartInstance) initHighestRevenueChartOption(highestRevenueChartInstance)
+    cardList.value[1].description = `上次更新: ${tF()}`
+  }, 120000)
 })
 
 onUnmounted(() => {
@@ -590,20 +538,10 @@ onUnmounted(() => {
     clearTimeout(collapseResizeTimeout)
     collapseResizeTimeout = null
   }
+  if (timer) clearInterval(timer)
 })
 
-function resizeAllCharts() {
-  if (revenueChartInstance) revenueChartInstance.resize()
-  if (monthlyRadarChartInstance) monthlyRadarChartInstance.resize()
-  if (highestRevenueChartInstance) highestRevenueChartInstance.resize()
-}
-
-watchDebounced([() => sidebarMenu.value, () => navigationMode.value], resizeAllCharts, {
-  debounce: 300,
-  deep: true,
-})
-
-watch([isDark, themeColor], () => {
+function clearCharts() {
   if (revenueChartInstance) {
     if (revenueChartResizeHandler) {
       window.removeEventListener('resize', revenueChartResizeHandler)
@@ -630,11 +568,30 @@ watch([isDark, themeColor], () => {
     highestRevenueChartInstance.dispose()
     highestRevenueChartInstance = null
   }
+}
 
-  initRevenueChart()
-  initMonthlyRadarChart()
-  initHighestRevenueChart()
+function resizeAllCharts() {
+  if (revenueChartInstance) revenueChartInstance.resize()
+  if (monthlyRadarChartInstance) monthlyRadarChartInstance.resize()
+  if (highestRevenueChartInstance) highestRevenueChartInstance.resize()
+}
+
+watchDebounced([() => sidebarMenu.value, () => navigationMode.value], resizeAllCharts, {
+  debounce: 300,
+  deep: true,
+})
+
+watch([isDark, themeColor], () => {
+  clearCharts()
+  initEcharts()
 })
+
+const drawerShow = ref(false)
+const currentItem = ref(null)
+const alarmShow = (item: any) => {
+  currentItem.value = item
+  drawerShow.value = true
+}
 </script>
 <template>
   <ScrollContainer wrapper-class="flex flex-col gap-y-4 max-sm:gap-y-2">
@@ -649,10 +606,11 @@ watch([isDark, themeColor], () => {
           <div
             class="mt-1 mb-1.5 flex items-center gap-x-4 text-2xl text-neutral-700 dark:text-neutral-400"
           >
-            <NNumberAnimation
+            <!-- <NNumberAnimation
               :to="value"
               show-separator
-            />
+            /> -->
+            <span>{{ value }}</span>
             <span class="text-lg text-neutral-500 dark:text-neutral-400">{{ suffix }}</span>
           </div>
           <div class="flex items-center">
@@ -702,60 +660,72 @@ watch([isDark, themeColor], () => {
         </div>
       </div>
     </div>
-    <div class="overflow-hidden rounded bg-naive-card p-6 shadow-xs transition-[background-color]">
+    <div
+      class="overflow-hidden rounded bg-naive-card p-6 shadow-xs transition-[background-color] lg:h-[193px]"
+    >
       <div class="mb-4 flex items-center gap-x-2">
         <span class="iconify text-xl ph--warning-bold"></span>
         <span class="text-xl font-medium text-neutral-750 dark:text-neutral-400">最近告警</span>
       </div>
-      <div class="grid grid-cols-1 gap-4 max-sm:gap-2 md:grid-cols-2 lg:grid-cols-4">
-        <NCard
-          v-for="item in alarms"
-          :key="item.id"
+      <NEmpty
+        v-if="!alarms.length"
+        description="暂无告警"
+      ></NEmpty>
+      <div class="grid grid-cols-1 gap-4 max-sm:gap-2 md:grid-cols-1 lg:grid-cols-4">
+        <TransitionGroup
+          type="animation"
+          enter-active-class="animate__animated animate__fadeInLeft"
+          leave-active-class="animate__animated animate__fadeOutRight"
         >
-          <div class="flex items-center justify-between">
-            <div class="flex flex-1 gap-x-3">
-              <img
-                class="size-15 object-cover"
-                :src="item.img"
-                alt="鸟图"
-              />
-              <div class="flex flex-1 flex-col justify-between">
-                <div>
-                  <span
-                    v-if="item.type === 'large'"
-                    class="rounded-xl bg-red-500 px-2 py-1 text-white"
-                    >大型鸟类</span
-                  >
-                  <span
-                    v-else-if="item.type === 'medium'"
-                    class="rounded-xl bg-yellow-500 px-2 py-1 text-white"
-                    >中型鸟类</span
-                  >
-                  <span
-                    v-else
-                    class="rounded-xl bg-green-500 px-2 py-1 text-white"
-                    >小型鸟类</span
-                  >
-                  <span class="ml-2">{{ item.location }}</span>
-                </div>
-                <div class="text-xs text-neutral-500 dark:text-neutral-400">
-                  <span>{{ item.timestamp }}</span>
+          <NCard
+            v-for="item in alarms"
+            :key="item.id"
+          >
+            <div class="flex items-center justify-between">
+              <div class="flex flex-1 gap-x-3">
+                <img
+                  class="size-15 object-cover"
+                  :src="item.img"
+                  alt="鸟图"
+                />
+                <div class="flex flex-1 flex-col justify-between">
+                  <div class="whitespace-nowrap">
+                    <span
+                      v-if="item.type === 'large'"
+                      class="rounded-xl bg-red-500 px-2 py-1 text-white"
+                      >大型鸟类</span
+                    >
+                    <span
+                      v-else-if="item.type === 'medium'"
+                      class="rounded-xl bg-yellow-500 px-2 py-1 text-white"
+                      >中型鸟类</span
+                    >
+                    <span
+                      v-else
+                      class="rounded-xl bg-green-500 px-2 py-1 text-white"
+                      >小型鸟类</span
+                    >
+                    <span class="ml-2">{{ item.location }}</span>
+                  </div>
+                  <div class="text-xs text-neutral-500 dark:text-neutral-400">
+                    <span>{{ item.timestamp }}</span>
+                  </div>
                 </div>
               </div>
+              <div>
+                <NButton
+                  size="medium"
+                  strong
+                  secondary
+                  circle
+                  @click="alarmShow(item)"
+                >
+                  <span class="iconify ph--eye"></span>
+                </NButton>
+              </div>
             </div>
-            <div>
-              <NButton
-                size="medium"
-                strong
-                secondary
-                circle
-                @click="() => {}"
-              >
-                <span class="iconify ph--eye"></span>
-              </NButton>
-            </div>
-          </div>
-        </NCard>
+          </NCard>
+        </TransitionGroup>
       </div>
     </div>
 
@@ -796,5 +766,6 @@ watch([isDark, themeColor], () => {
         </div>
       </div>
     </div>
+    <Drawer v-model="drawerShow" :currentItem="currentItem"> 123123 </Drawer>
   </ScrollContainer>
 </template>

+ 7 - 1
src/views/monitoring-platform/index.vue

@@ -1,7 +1,7 @@
 <template>
   <ScrollContainer
     wrapper-class=""
-    :scrollable="false"
+    :scrollable="isMaxLg"
   >
     监控平台
   </ScrollContainer>
@@ -9,6 +9,12 @@
 
 <script setup lang="ts">
 import { ScrollContainer } from '@/components'
+import { useInjection } from '@/composables'
+import { mediaQueryInjectionKey } from '@/injection'
+const { isMaxLg } = useInjection(mediaQueryInjectionKey)
 
 defineOptions({ name: 'MonitoringPlatform' })
+
+
+
 </script>