Переглянути джерело

fix🐛: 添加热力图 修复播放切换问题

gitboyzcf 8 годин тому
батько
коміт
91865b456a

+ 2 - 1
package.json

@@ -45,6 +45,7 @@
     "vuedraggable": "^4.1.0",
     "web-streams-polyfill": "^4.0.0",
     "xgplayer": "^3.0.20",
+    "keli-heatmap.js": "^2.0.18",
     "zx-calendar": "^0.7.3"
   },
   "devDependencies": {
@@ -88,4 +89,4 @@
     "bin-wrapper": "npm:bin-wrapper-china"
   },
   "packageManager": "pnpm@10.1.0+sha1.ab7948c89104fdd3fc88b5b391fa4b73fd800631"
-}
+}

+ 8 - 0
pnpm-lock.yaml

@@ -56,6 +56,9 @@ importers:
       js-cookie:
         specifier: ^3.0.5
         version: 3.0.5
+      keli-heatmap.js:
+        specifier: ^2.0.18
+        version: 2.0.18
       livekit-client:
         specifier: ^2.7.5
         version: 2.15.14(@types/dom-mediacapture-record@1.0.22)
@@ -3905,6 +3908,9 @@ packages:
     resolution: {integrity: sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==}
     engines: {node: '>=8'}
 
+  keli-heatmap.js@2.0.18:
+    resolution: {integrity: sha512-MVhLSVcKK4CEV24ogeWX+cvRfi09xz0TL5dtpDQW9r9lxF2DBTLyyPyUbTx7wDIhY/mK9ryHTxybI1kPyrVpPg==}
+
   keyv@3.0.0:
     resolution: {integrity: sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA==}
 
@@ -9999,6 +10005,8 @@ snapshots:
 
   junk@3.1.0: {}
 
+  keli-heatmap.js@2.0.18: {}
+
   keyv@3.0.0:
     dependencies:
       json-buffer: 3.0.0

+ 0 - 4
src/App.vue

@@ -1,6 +1,5 @@
 <script setup lang="ts">
 import { RouterView } from "vue-router";
-import { useAlarmStore } from "@/stores/modules/alarm";
 import {
   NConfigProvider,
   NModalProvider,
@@ -12,10 +11,7 @@ import {
 } from "naive-ui";
 import { getConfigProviderProps } from "./hooks/useDiscreteApi";
 
-const alarmStore = useAlarmStore();
 const configProviderProps = getConfigProviderProps();
-
-alarmStore.connectAlarmWS();
 </script>
 
 <template>

+ 36 - 0
src/api/modules/alarm.ts

@@ -17,6 +17,10 @@ export type apiT = {
   API_ALERT_DOWN_JSON_GET: (params?: object) => Promise<unknown>
   API_VODE_LIST_GET: (params?: object) => Promise<unknown>
   API_DEVICE_GET: (params?: object) => Promise<unknown>
+  API_VIEW_BC_LIST_GET: (params?: object) => Promise<unknown>
+  API_DISTANCE_GET: (params?: object) => Promise<unknown>
+  API_RM_STATUS_GET: (params?: object) => Promise<unknown>
+  API_RM_STATUS_PUT: (params?: object) => Promise<unknown>
 }
 const apiAll: apiT = {
   // 通过ES查询告警
@@ -175,6 +179,38 @@ const apiAll: apiT = {
       params
     })
   },
+  // 球机列表
+  API_VIEW_BC_LIST_GET(params = {}) {
+    return request({
+      url: `/api/BallCamera/List`,
+      method: "get",
+      params,
+    });
+  },
+
+  // 获取热力图半径
+  API_DISTANCE_GET(params = {}) {
+    return request({
+      url: 'Options/Distance',
+      method: 'get',
+      params
+    })
+  },
+  // 查询人密算法状态
+  API_RM_STATUS_GET(params = {}) {
+    return request({
+      url: '/api/edge/algorithm/status',
+      method: 'get',
+      params
+    })
+  },
+  // 开启/关闭人密算法
+  API_RM_STATUS_PUT(params: { enable?: string } = {}) {
+    return request({
+      url: `/api/edge/algorithm/${params.enable}`,
+      method: 'put'
+    })
+  }
 
 }
 

+ 295 - 0
src/components/HeatmapBox.vue

@@ -0,0 +1,295 @@
+<template>
+  <div
+    ref="heatmapCanvas"
+    class="w-full h-full absolute! top-0 left-0 pointer-events-none transition-opacity duration-300"
+    v-if="isVisible"
+  ></div>
+
+  <div
+    class="absolute pointer-events-none top-2 right-15px px-5 py-1 bg-black/20 backdrop-blur-sm rounded-2xl border border-white/10 flex items-center gap-3"
+    v-if="isVisible"
+  >
+    <div class="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
+    <div class="flex items-center gap-1">
+      <span class="text-xs text-slate-400 font-bold uppercase tracking-wider"
+        >总人数</span
+      >
+      <span class="text-2xl text-white font-mono leading-none">{{
+        totalCount
+      }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useAlarmStore } from "@/stores/modules/alarm";
+import { $mitt } from "@/utils";
+import { useDebounceFn } from "@vueuse/core";
+import h337 from "keli-heatmap.js";
+
+const { API_DISTANCE_GET } = useRequest();
+const deviceId = inject<Ref<string>>("deviceId");
+const useAlarm = useAlarmStore();
+const isVisible = ref(false);
+const heatmapCanvas = ref(null);
+const heatmapInstance = ref(null);
+
+// 自动生成9×1布局
+const generate9x1Regions = () => {
+  const regions = [];
+  const cameraCount = 4;
+  const width = 1 / cameraCount;
+
+  for (let i = 0; i < cameraCount; i++) {
+    regions.push({
+      id: String(i + 1),
+      x: i * width,
+      y: 0,
+      width: width,
+      height: 1,
+      // 可选的旋转角度(如果有摄像头旋转)
+      rotation: 0,
+    });
+  }
+  return regions;
+};
+
+// 数据状态
+const totalCount = ref(0);
+const heatmapPoints = ref([]);
+const cameraDataMap = ref(new Map());
+let gRadius = 0;
+
+// 初始化热力图
+const initHeatmap = ({ radius }) => {
+  if (!heatmapCanvas.value) return;
+
+  // 创建热力图实例
+  heatmapInstance.value = h337.create({
+    container: heatmapCanvas.value,
+    radius, // 点半径
+    maxOpacity: 0.5,
+    minOpacity: 0.2,
+    blur: 0.75,
+  });
+  handleResize();
+  // // 初始渲染
+  // updateHeatmap()
+};
+// 坐标转换:从单个摄像头坐标转换到全景坐标
+const convertToPanoramaCoord = (cameraId, point, pointLength) => {
+  // 找到摄像头对应的区域配置
+  const region = generate9x1Regions().find((r) => r.id === cameraId);
+  if (!region) return null;
+
+  const container = heatmapCanvas.value;
+  if (!container) return null;
+
+  // 假设原始摄像头分辨率为1920x1080(根据实际调整)
+  const cameraWidth = point.W;
+  const cameraHeight = point.H;
+
+  // 转换坐标:摄像头坐标 -> 相对比例 -> 全景坐标
+  const xRatio = point.X / cameraWidth;
+  const yRatio = point.Y / cameraHeight;
+
+  // 在全景中的相对位置
+  const panoramaX = (region.x + xRatio * region.width) * container.clientWidth;
+  const panoramaY =
+    (region.y + yRatio * region.height) * container.clientHeight;
+
+  return {
+    x: Math.round(panoramaX),
+    y: Math.round(panoramaY),
+    value: Math.ceil((pointLength - 1) / 2), // 热力值,可根据需要调整
+  };
+};
+
+let peoClose = null;
+let originCameraPoint = {};
+
+const pointsCount = (points, cameraId) => {
+  // 每两个点一个人,计算中心点
+  for (let i = 0; i < points.length; i += 2) {
+    if (i + 1 < points.length) {
+      const point1 = points[i];
+      const point2 = points[i + 1];
+
+      // 计算人的中心点
+      const centerX = (point1.X + point2.X) / 2 - gRadius / 2;
+      const centerY = (point1.Y + point2.Y) / 2 - gRadius / 2;
+
+      // 转换到全景坐标
+      const panoramaPoint = convertToPanoramaCoord(
+        cameraId,
+        {
+          X: centerX,
+          Y: centerY,
+          W: point1.W,
+          H: point1.H,
+        },
+        points.length,
+      );
+
+      if (panoramaPoint) {
+        heatmapPoints.value.push(panoramaPoint);
+      }
+    }
+  }
+};
+const peopleIntensiveConnect = () => {
+  // const parsedData = {
+  //   Ais: [
+  //     {
+  //       CameraID: '9',
+  //       EventTime: 1767861135000,
+  //       RawImageUrl:
+  //         'http://192.168.211.68:80//pic_link/9_alg_crowd_density-crowd_density_people_count_2026-01-08_16_32_15_9913.jpeg',
+  //       SceneWidth: 1080,
+  //       SceneHeight: 1920,
+  //       Data: [
+  //         {
+  //           Points: [
+  //             {
+  //               X: 876,
+  //               Y: 736
+  //             },
+  //             {
+  //               X: 881,
+  //               Y: 741
+  //             },
+  //             {
+  //               X: 886,
+  //               Y: 736
+  //             },
+  //             {
+  //               X: 891,
+  //               Y: 741
+  //             }
+  //           ]
+  //         }
+  //       ]
+  //     }
+  //   ],
+  //   PersonCount: 1
+  // }
+  peoClose = useAlarm.connectRltWS((parsedData) => {
+    // 更新总人数
+    totalCount.value = parsedData.PersonCount || 0;
+    // 清空旧的点位数据
+    heatmapPoints.value = [];
+    originCameraPoint = {};
+    if (
+      parsedData.Ais &&
+      Array.isArray(parsedData.Ais) &&
+      parsedData.Ais.length
+    ) {
+      // 处理每个摄像头的检测数据
+      parsedData.Ais.forEach((aiData) => {
+        const cameraId = aiData.CameraID;
+
+        // 处理Data中的Points
+        if (aiData.Data && Array.isArray(aiData.Data)) {
+          aiData.Data.forEach((dataItem) => {
+            if (dataItem.Points && dataItem.Points.length >= 2) {
+              const points = dataItem.Points.map((point) => ({
+                ...point,
+                W: aiData.SceneWidth,
+                H: aiData.SceneHeight,
+              }));
+              originCameraPoint[cameraId] = points;
+              pointsCount(points, cameraId);
+            }
+          });
+        }
+
+        // 缓存每个摄像头的数据
+        cameraDataMap.value.set(cameraId, {
+          timestamp: aiData.EventTime,
+          count: aiData.Data?.length || 0,
+          rawData: aiData,
+        });
+      });
+    }
+    // 更新热力图
+    updateHeatmap();
+  }, deviceId.value);
+};
+
+// 更新热力图
+const updateHeatmap = () => {
+  if (!heatmapInstance.value) return;
+
+  const data = {
+    max: totalCount.value < 20 ? 20 : totalCount.value, // 最大热力值
+    min: 1,
+    data: heatmapPoints.value,
+  };
+  heatmapInstance.value.setData(data);
+};
+
+const peopleIntensiveConnectClose = () => {
+  peoClose && peoClose();
+  heatmapInstance.value = null;
+};
+
+// 响应式调整
+const handleResize = useDebounceFn(() => {
+  if (heatmapCanvas.value) {
+    const hCanvas: HTMLCanvasElement =
+      document.querySelector(".heatmap-canvas");
+
+    hCanvas.width = heatmapCanvas.value.offsetWidth;
+    hCanvas.height = heatmapCanvas.value.offsetHeight;
+    heatmapPoints.value = [];
+    for (let key in originCameraPoint) {
+      pointsCount(originCameraPoint[key], key);
+    }
+    // 重新渲染热力图
+    if (heatmapInstance.value) {
+      updateHeatmap();
+    }
+  }
+}, 200);
+
+const resetRender = () => {
+  const temp = [...heatmapPoints.value];
+  heatmapPoints.value = [];
+  updateHeatmap();
+  heatmapPoints.value = temp;
+  initHeatmap({ radius: gRadius });
+};
+
+const heatmapChangeFn = async (v) => {
+  if (v) {
+    isVisible.value = true;
+    const radius: any = await API_DISTANCE_GET({ DeviceId: deviceId.value });
+    gRadius = radius.Distance;
+    await nextTick();
+    initHeatmap({ radius: radius.Distance });
+    peopleIntensiveConnect();
+  } else {
+    isVisible.value = false;
+    peopleIntensiveConnectClose();
+  }
+  // 监听窗口大小变化
+  window.addEventListener("resize", handleResize);
+};
+
+onMounted(() => {
+  heatmapChangeFn(true)
+});
+
+onUnmounted(() => {
+  if (heatmapInstance.value) {
+    heatmapInstance.value.repaint();
+    heatmapInstance.value = null;
+  }
+  peopleIntensiveConnectClose();
+  window.removeEventListener("resize", handleResize);
+});
+
+defineExpose({
+  resetRender,
+});
+</script>

+ 20 - 26
src/components/PubVideoNew.vue

@@ -4,7 +4,6 @@
       name="videoElement"
       style="width: 100%"
       :class="newClass"
-      :id="newClass"
       muted
       autoplay
       playsinline
@@ -12,6 +11,7 @@
     >
       Your browser is too old which doesn't support HTML5 video.
     </video>
+    <HeatmapBox v-if="isRlt" ref="heatmapBoxRef"></HeatmapBox>
     <n-empty
       v-if="error"
       class="absolute top-1/2 left-1/2 -translate-1/2"
@@ -31,32 +31,26 @@
     </n-flex>
   </div>
 </template>
-<script setup>
+<script setup lang="ts">
 import { NSpin, NFlex, NEmpty } from "naive-ui";
-const props = defineProps({
-  width: {
-    type: String,
-    default: "100%",
-  },
-  height: {
-    type: String,
-    default: "100%",
-  },
-  src: {
-    type: String,
-  },
-  loading: {
-    type: Boolean,
-    default: false,
-  },
-  error: {
-    type: Boolean,
-    default: true,
-  },
+
+interface Props {
+  width?: string;
+  height?: string;
+  loading: boolean;
+  error: boolean;
+  isRlt?: boolean;
+  newClass?: string | object;
+}
+const props = withDefaults(defineProps<Props>(), {
+  width: "100%",
+  height: "100%",
+  loading: false,
+  error: true,
+  isRlt: true,
+  isActive: false,
   // 画布类名 区分画布
-  newClass: {
-    type: [String, Object],
-    default: "pub-video",
-  },
+  newClass: "pub-video",
 });
+// const heatmapBoxRef = ref(null);
 </script>

+ 2 - 2
src/directives/index.ts

@@ -22,9 +22,9 @@ function validatorModules(obj) {
 
 export default {
   install: (app) => {
-    const directiveModules: any = import.meta.glob('./modules/*.js', { eager: true })
+    const directiveModules: any = import.meta.glob('./modules/*.ts', { eager: true })
     Object.keys(directiveModules).forEach((key) => {
-      const directiveName = key.replace(/^\.\/modules\/(.*)\.js$/, '$1')
+      const directiveName = key.replace(/^\.\/modules\/(.*)\.ts$/, '$1')
       const modules = directiveModules[key].default
       if (typeof modules === 'function' || typeof modules === 'object') {
         if (typeof modules === 'object' && !validatorModules(modules)) {

+ 12 - 0
src/layout/components/Setting.vue

@@ -0,0 +1,12 @@
+<template>
+  <div class="setting-box flex items-center">
+    <n-button text size="large">
+      <template #icon>
+        <div class="i-lets-icons-setting-alt-line"></div>
+      </template>
+    </n-button>
+  </div>
+</template>
+<script setup lang="ts">
+import { NButton } from "naive-ui";
+</script>

+ 3 - 1
src/layout/index.vue

@@ -18,6 +18,7 @@
           <n-flex align="center" :size="20">
             <div class="time-display">{{ currentTime }}</div>
             <AlertCom />
+            <Setting />
           </n-flex>
         </div>
       </n-flex>
@@ -41,10 +42,11 @@
 <script setup lang="ts">
 import { NLayout, NLayoutHeader, NLayoutContent, NFlex } from "naive-ui";
 import { ref, onMounted, onUnmounted } from "vue";
+import { useOutsideSystemStore } from "@/stores/modules/system";
 import Menu from "./components/Menu/menu.vue";
 import AlertCom from "./components/AlertCom.vue";
+import Setting from "./components/Setting.vue";
 import packageJson from "../../package.json";
-import { useOutsideSystemStore } from "@/stores/modules/system";
 
 const title = import.meta.env.VITE_APP_TITLE;
 const useSystemStore = useOutsideSystemStore();

+ 2 - 0
src/main.ts

@@ -17,6 +17,8 @@ import '@/assets/scss/theme.scss'
 async function bootstrap() {
   const app = createApp(App)
 
+  app.use(directives)
+
   // app.use(naiveUi)
   app.use(directives)
   app.use(i18n)

+ 105 - 88
src/stores/modules/alarm.ts

@@ -81,107 +81,124 @@ export const useAlarmStore = defineStore('alarm', {
     getFileUrl(fileName: string) {
       return `${this.ip}/data/show/${fileName}`
     },
-    // 连接报警信息
-    async connectAlarmWS() {
-      // let ii = 0
+    connectRltWS(callback: (data: any) => void, deviceId: string) {
       // 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,
-      //   }
-      //   this.aiList.unshift({
-      //     ...data,
-      //     EventID: ii++,
-      //     ImgUrl: this.getFileUrl(data.Image),
-      //     EventTimeFormat: formatDate(data.EventTime),
+      //   callback({
+      //     Ais: [
+      //       {
+      //         EventID: '',
+      //         EventType: 'alg_crowd_density-crowd_density_people_count',
+      //         Status: 0,
+      //         Level: 1,
+      //         Confidence: 0,
+      //         CameraID: '6',
+      //         CameraName: '6',
+      //         EventTime: 1765957184000,
+      //         RawImageUrl:
+      //           'http://192.168.211.68:80//pic_link/6_alg_crowd_density-crowd_density_people_count_2025-12-17_15_39_44_141.jpeg',
+      //         MarkedImageUrl: '',
+      //         SceneWidth: 0,
+      //         SceneHeight: 0,
+      //         Data: [
+      //           {
+      //             Type: 4,
+      //             Points: [
+      //               {
+      //                 X: 413,
+      //                 Y: 795,
+      //                 W: 0,
+      //                 H: 0
+      //               },
+      //               {
+      //                 X: 418,
+      //                 Y: 800,
+      //                 W: 0,
+      //                 H: 0
+      //               }
+      //             ]
+      //           }
+      //         ]
+      //       }
+      //     ],
+      //     Count: 1,
+      //     PersonCount: 1
       //   })
-      //   const alarms = this.aiList.slice(0, 4).map((item: any) => ({
-      //     id: item.EventID,
-      //     location: '',
-      //     timestamp: formatDate(new Date()),
-      //     img: item.ImgUrl,
-      //     type: 'large',
-      //     ...item,
-      //   }))
-
-      //   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)
-      //   }
-      // }, 2000)
+      // }, 1000)
+      let websocket = new WebSocket(`wss://${this.ipF}/api/edge/alert/${deviceId}`)
+      websocket.onopen = function () {
+        console.log('Ai/人员密集 连接已经建立')
+      }
+      websocket.onmessage = function (e) {
+        const data = JSON.parse(e.data)
+        callback(data)
+        // this.aiList.unshift({
+        //   ...data,
+        //   state: 0,
+        //   AlgorithmName: data.Type,
+        //   timeFormat: dayjs(parseInt(`${data.AlarmTime}`.padEnd(13, '0'))).format(
+        //     'YYYY-MM-DD HH:mm:ss'
+        //   )
+        // })
+      }
+      websocket.onerror = function () {
+        console.log('Ai/人员密集  发生错误')
+      }
+      websocket.onclose = function () {
+        console.log('Ai/人员密集  连接断开')
+      }
+      return () => {
+        websocket.close()
+      }
+    },
+    // 连接报警信息
+    async connectAlarmWS() {
       let flag = true
       const that = this
         ; (() => {
           // websocket = new WebSocket(`wss://${this.ipF}/api/Ai-Message`)
-          that.websocket = new WebSocket(`wss://${this.ipF}/api/alert/ws`)
+          that.websocket = new WebSocket(`wss://${this.ipF}/api/edge/alertThreshold`)
           that.websocket.onopen = function () {
-            console.log('Ai/Ai-Message 连接已经建立')
+            console.log('告警阈值-Message 连接已经建立')
           }
           that.websocket.onmessage = function (e) {
             const data = JSON.parse(e.data).Ais[0]
+            console.log(data);
 
-            that.alarmCount++
-            that.aiList.unshift({
-              ...data,
-              ImgUrl: data.RawImageUrl,
-              EventTimeFormat: formatDate(data.EventTime),
-            })
+            // that.alarmCount++
+            // that.aiList.unshift({
+            //   ...data,
+            //   ImgUrl: data.RawImageUrl,
+            //   EventTimeFormat: formatDate(data.EventTime),
+            // })
           }
 
-          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)
-              if (i === -1) {
-                that.alarms.unshift(item)
-                if (that.alarms.length == 5) that.alarms.splice(that.alarms.length - 1, 1)
-              }
-            })
-            if (that.alarms.length > 50) {
-              that.alarms.splice(50, that.alarms.length - 50)
-            }
-          }
-          if (flag) {
-            pushAlarm()
-            flag = false
-          }
-          setInterval(() => {
-            pushAlarm()
-          }, 3000)
+          // 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)
+          //     if (i === -1) {
+          //       that.alarms.unshift(item)
+          //       if (that.alarms.length == 5) that.alarms.splice(that.alarms.length - 1, 1)
+          //     }
+          //   })
+          //   if (that.alarms.length > 50) {
+          //     that.alarms.splice(50, that.alarms.length - 50)
+          //   }
+          // }
+          // if (flag) {
+          //   pushAlarm()
+          //   flag = false
+          // }
+          // setInterval(() => {
+          //   pushAlarm()
+          // }, 3000)
           that.websocket.onerror = function () {
             console.log('Ai/Alarm 发生错误')
           }

+ 26 - 0
src/types/components.d.ts

@@ -0,0 +1,26 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+declare module 'vue' {
+  export interface GlobalComponents {
+    AlarmShow: typeof import('./../components/AlarmDrawer/components/AlarmShow.vue')['default']
+    Drawer: typeof import('./../components/AlarmDrawer/Drawer.vue')['default']
+    ECharts: typeof import('./../components/ECharts/ECharts.vue')['default']
+    HeatmapBox: typeof import('./../components/HeatmapBox.vue')['default']
+    Intercom: typeof import('./../components/Intercom.vue')['default']
+    Language: typeof import('./../components/Language.vue')['default']
+    Loading: typeof import('./../components/Loading.vue')['default']
+    PubPlayer: typeof import('./../components/PubPlayer.vue')['default']
+    PubPopover: typeof import('./../components/PubPopover.vue')['default']
+    PubVideo: typeof import('./../components/PubVideo.vue')['default']
+    PubVideoNew: typeof import('./../components/PubVideoNew.vue')['default']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    Speech: typeof import('./../components/Speech.vue')['default']
+    ToggleTheme: typeof import('./../components/ToggleTheme.vue')['default']
+  }
+}

+ 306 - 44
src/views/Home/Home.vue

@@ -19,12 +19,15 @@ import {
   NEmpty,
   NFlex,
 } from "naive-ui";
-import { uuid } from "@/utils";
+import { useFlvPlayer } from "omnimatrix-video-player";
+import { uuid, $mitt } from "@/utils";
+import { useOutsideSystemStore } from "@/stores/modules/system";
 
 interface IscreenInfo {
   id: string;
   loading: boolean;
   error: boolean;
+  isRlt: boolean;
   playerObj: null | any;
 }
 
@@ -34,62 +37,198 @@ interface IscreenModeOptions {
   screenInfo: IscreenInfo[];
 }
 
-const { API_DEVICE_GET } = useRequest();
+const { API_DEVICE_GET, API_VIEW_BC_LIST_GET } = useRequest();
+const useSystem = useOutsideSystemStore();
 
 const screenMode = ref(1); // 默认为单屏模式
 const screenModeOptions: IscreenModeOptions[] = [
   {
     label: "单画面",
     key: 1,
-    screenInfo: [{ id: uuid(), loading: true, error: false, playerObj: null }],
+    screenInfo: [
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+    ],
   },
   {
     label: "4分屏",
     key: 4,
     screenInfo: [
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
     ],
   },
   {
     label: "6分屏",
     key: 6,
     screenInfo: [
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
     ],
   },
   {
     label: "9分屏",
     key: 9,
     screenInfo: [
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
-      { id: uuid(), loading: true, error: false, playerObj: null },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
+      {
+        id: uuid(),
+        loading: false,
+        error: true,
+        playerObj: null,
+        isRlt: false,
+      },
     ],
   },
 ];
 const screenInfoActive = ref(screenModeOptions[0].screenInfo[0].id);
-const screenInfo = ref<IscreenInfo[]>(screenModeOptions[0].screenInfo);
+const screenInfo = shallowRef<IscreenInfo[]>(screenModeOptions[0].screenInfo);
 
+const handleBtns = [
+  {
+    text: "关闭单个",
+    handle: () => destroyAllVideo(screenInfoActive.value),
+  },
+  {
+    text: "关闭全部",
+    handle: () => destroyAllVideo(),
+  },
+];
 const screenModeChange = (v) => {
-  screenMode.value = v;
-  screenInfo.value = screenModeOptions.find(
-    (item) => item.key === v,
-  ).screenInfo;
-  screenInfoActive.value = screenInfo.value[0].id;
+  destroyAllVideo();
+  nextTick(() => {
+    screenMode.value = v;
+    screenInfo.value = screenModeOptions.find(
+      (item) => item.key === v,
+    ).screenInfo;
+    screenInfoActive.value = screenInfo.value[0].id;
+  });
 };
 
 // 设备列表数据
@@ -113,17 +252,107 @@ const alertInfo = [
 const currentActive = ref("");
 
 API_DEVICE_GET().then((res) => {
-  console.log(res);
   deviceList.value = (res as any[]).map((item) => ({
     name: item.Alias,
     key: item.Id,
   }));
 });
 
+provide("deviceId", currentActive);
 const deviceAceiveClick = (item) => {
-  console.log(item);
   currentActive.value = item.key;
+  nextTick(() => {
+    playVideo(item.key);
+    ballCamera(item.key);
+  });
+};
+
+const videoList = computed(() => useSystem.videoList);
+const playVideo = (deviceId: string) => {
+  const item = screenInfo.value.find(
+    (item) => item.id === screenInfoActive.value,
+  );
+  if (item?.playerObj) {
+    item.playerObj.destroyed();
+  }
+  try {
+    item.loading = true;
+    item.error = false;
+    const url = videoList.value?.pano_view
+      .replace("{4}", item.id)
+      .replace("{5}", deviceId);
+    item.playerObj = useFlvPlayer(url, ".pub-video" + item.id, () => {
+      item.isRlt = true;
+      item.loading = false;
+      // 强制响应式
+      triggerRef(screenInfo);
+    });
+  } catch (error) {
+    console.error(error);
+    error = true;
+    item.isRlt = false;
+    item.loading = false;
+  }
+};
+
+let ballPlayerObj = null;
+const ballLoading = ref(false);
+const ballError = ref(true);
+const ballCamera = async (deviceId: string) => {
+  ballPlayerObj?.destroyed();
+  ballLoading.value = true;
+  const res = await API_VIEW_BC_LIST_GET({ DeviceId: deviceId });
+  const url = videoList.value?.ball_camera
+    .replace("{4}", uuid())
+    .replace("{12}", Object.keys(res)[0]);
+  try {
+    ballPlayerObj = useFlvPlayer(url, ".ball-video", () => {
+      ballLoading.value = false;
+      ballError.value = false;
+    });
+  } catch (error) {
+    console.error(error);
+    ballLoading.value = false;
+    ballError.value = true;
+  }
+};
+
+// 重置并更新
+const destroyAllVideo = (current?: string) => {
+  if (current) {
+    screenInfo.value.forEach((item) => {
+      if (item.id === current) {
+        if (!item.playerObj) return;
+        item.isRlt = false;
+        item.playerObj.destroyed();
+        item.loading = false;
+        item.error = true;
+        item.id = uuid();
+        item.playerObj = null;
+        screenInfoActive.value = item.id;
+      }
+    });
+  } else {
+    ballPlayerObj?.destroyed();
+    ballLoading.value = false;
+    ballError.value = true;
+    ballPlayerObj = null;
+    screenInfo.value.forEach((item) => {
+      if (!item.playerObj) return;
+      item.isRlt = false;
+      item.playerObj?.destroyed();
+      item.loading = false;
+      item.error = true;
+      item.id = uuid();
+      item.playerObj = null;
+    });
+    screenInfoActive.value = screenInfo.value[0].id;
+    currentActive.value = "";
+  }
+  triggerRef(screenInfo);
 };
+
+onMounted(() => {});
 </script>
 
 <template>
@@ -148,7 +377,7 @@ const deviceAceiveClick = (item) => {
       </n-flex>
     </n-card>
     <div class="p-4 flex-1 flex flex-col gap-4">
-      <div class="flex justify-end">
+      <div class="flex justify-between">
         <n-tabs
           class="w-30%"
           type="segment"
@@ -164,12 +393,42 @@ const deviceAceiveClick = (item) => {
           >
           </n-tab-pane>
         </n-tabs>
+        <div class="flex gap-2">
+          <!-- <n-button
+            class="ml-2"
+            @click="screenModeChange('full')"
+          >
+            全屏
+          </n-button> -->
+          <n-button
+            v-for="(btn, i) in handleBtns"
+            :key="i"
+            @click="btn.handle"
+            >{{ btn.text }}</n-button
+          >
+        </div>
       </div>
 
       <!-- 视频监控画面显示区域 -->
-      <div class="h-full flex flex-col gap-4">
+      <div class="flex-1 min-h-0 flex flex-col gap-4">
+        <div
+          v-if="screenMode === 1"
+          :class="[
+            'w-full h-full bg-[#000] relative rounded cursor-pointer overflow-hidden',
+            'outline-2 outline-offset-2 outline-solid',
+          ]"
+        >
+          <pub-video-new
+            :loading="screenInfo[0]?.loading"
+            :error="screenInfo[0]?.error"
+            :is-rlt="screenInfo[0]?.isRlt"
+            class="w-full h-full"
+            :newClass="['object-fill', 'pub-video' + screenInfo[0]?.id]"
+          />
+        </div>
         <n-grid
-          class="flex-1"
+          v-else
+          :class="['flex-1']"
           x-gap="4"
           y-gap="4"
           :cols="
@@ -183,6 +442,7 @@ const deviceAceiveClick = (item) => {
           "
         >
           <n-grid-item
+            class="max-h-full"
             v-for="(_, i) in screenMode === 1
               ? 1
               : screenMode === 4
@@ -190,7 +450,7 @@ const deviceAceiveClick = (item) => {
                 : screenMode === 6
                   ? 6
                   : 9"
-            :key="i"
+            :key="screenInfo[i]?.id"
           >
             <div
               :class="[
@@ -202,22 +462,24 @@ const deviceAceiveClick = (item) => {
               @click="screenInfoActive = screenInfo[i].id"
             >
               <pub-video-new
+                :loading="screenInfo[i].loading"
+                :error="screenInfo[i].error"
+                :is-rlt="screenInfo[i].isRlt"
+                :newClass="['object-fill', 'pub-video' + screenInfo[i].id]"
                 class="w-full h-full"
-                newClass="pub-video  object-fill"
               />
             </div>
           </n-grid-item>
         </n-grid>
         <div class="flex gap-4 h-40%" v-if="screenMode == 1">
-          <div class="aspect-video bg-[#000] relative">
-            <n-empty
-              class="absolute top-1/2 left-1/2 -translate-1/2"
-              description="无信号"
-            >
-              <template #icon>
-                <div class="size-20 i-heroicons-signal-slash-16-solid"></div>
-              </template>
-            </n-empty>
+          <div class="aspect-video bg-[#000] relative rounded">
+            <pub-video-new
+              :loading="ballLoading"
+              :error="ballError"
+              :is-rlt="false"
+              class="w-full h-full"
+              newClass="ball-video  object-fill"
+            />
           </div>
           <div class="flex-1">
             <n-card

+ 1 - 1
vite.config.ts

@@ -66,7 +66,7 @@ export default ({ mode }) => {
       }),
       Components({
         dirs: ["src/components"],
-        dts: false,
+        dts: "src/types/components.d.ts",
         resolvers: [],
         include: [/\.vue$/, /\.vue\?vue/, /\.jsx$/],
       }),