Quellcode durchsuchen

Merge branch 'fjc-dev' into 'dev'

Fjc dev

See merge request product-group-fe/sfy-safety-group/sfy-safety!78
楼航飞 vor 11 Monaten
Ursprung
Commit
a3126eb187
54 geänderte Dateien mit 3488 neuen und 1 gelöschten Zeilen
  1. 3 0
      package.json
  2. 75 0
      src/api/camera/camera-preview.ts
  3. 74 0
      src/api/camera/camera.ts
  4. 139 0
      src/api/camera/cameraType.ts
  5. 83 0
      src/api/nine-square-grid/index.ts
  6. 4 0
      src/assets/declare.d.ts
  7. BIN
      src/assets/icons/nine-square-grid/add.png
  8. BIN
      src/assets/icons/nine-square-grid/camera.png
  9. BIN
      src/assets/icons/nine-square-grid/cameraEmpty.png
  10. BIN
      src/assets/icons/nine-square-grid/expand.png
  11. BIN
      src/assets/icons/nine-square-grid/folder-white.png
  12. BIN
      src/assets/icons/nine-square-grid/folder.png
  13. BIN
      src/assets/icons/nine-square-grid/fourGrids.png
  14. BIN
      src/assets/icons/nine-square-grid/fullScreen2.png
  15. BIN
      src/assets/icons/nine-square-grid/history-recall-icon.png
  16. BIN
      src/assets/icons/nine-square-grid/icon-delete.png
  17. BIN
      src/assets/icons/nine-square-grid/lock.png
  18. BIN
      src/assets/icons/nine-square-grid/more.png
  19. BIN
      src/assets/icons/nine-square-grid/nineGrids.png
  20. BIN
      src/assets/icons/nine-square-grid/oneGrid.png
  21. BIN
      src/assets/icons/nine-square-grid/playVideo.png
  22. BIN
      src/assets/icons/nine-square-grid/playing.png
  23. BIN
      src/assets/icons/nine-square-grid/playingCamera.png
  24. BIN
      src/assets/icons/nine-square-grid/sixteenGrids.png
  25. BIN
      src/assets/icons/nine-square-grid/unlock.png
  26. BIN
      src/assets/images/nine-square-grid/empty.png
  27. BIN
      src/assets/images/nine-square-grid/loading.gif
  28. BIN
      src/assets/images/nine-square-grid/logo.png
  29. 26 0
      src/components/live/LiveVideo.vue
  30. 223 0
      src/components/live/LiveVideoFlv.vue
  31. 146 0
      src/components/live/LiveVideoHLSApple.vue
  32. 143 0
      src/components/thumbnail/Thumbnail.vue
  33. 2 1
      src/router/full-routes.ts
  34. 425 0
      src/store/modules/useCameraGroupList.ts
  35. 33 0
      src/store/modules/useTargetTenantIdStore.ts
  36. 14 0
      src/store/modules/userGridType.ts
  37. 21 0
      src/store/modules/userSplitScreenFullScreen.ts
  38. 139 0
      src/types/scene-types/scene-types.ts
  39. 62 0
      src/utils/flv/captureFirstPicFromFLV.ts
  40. 376 0
      src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupList/CameraGroup.vue
  41. 200 0
      src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupList/CameraGroupList.vue
  42. 199 0
      src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupList/CameraListOfGroup.vue
  43. 227 0
      src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupList/CameraTreeOfGroupList.vue
  44. 59 0
      src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupListAndTree.vue
  45. 48 0
      src/views/disaster/monitor/splitScreenRetrieval/SplitScreenRetrieval.vue
  46. 261 0
      src/views/disaster/monitor/splitScreenRetrieval/VideosGridBase/CamerasGrid.vue
  47. 329 0
      src/views/disaster/monitor/splitScreenRetrieval/VideosGridBase/ScreenToolbar.vue
  48. 14 0
      src/views/disaster/monitor/splitScreenRetrieval/VideosGridBase/VideosGridBase.vue
  49. 26 0
      src/views/disaster/monitor/splitScreenRetrieval/hooks/parseData.ts
  50. 43 0
      src/views/disaster/monitor/splitScreenRetrieval/hooks/useCameraStatus.ts
  51. 65 0
      src/views/disaster/monitor/splitScreenRetrieval/type.ts
  52. 26 0
      src/views/disaster/monitor/splitScreenRetrieval/utils.ts
  53. 1 0
      utils/devProxy/staff/proxy.ts
  54. 2 0
      utils/devProxy/types.ts

+ 3 - 0
package.json

@@ -44,6 +44,7 @@
     "echarts": "5.4.2",
     "element-plus": "2.9.7",
     "element-resize-detector": "1.2.4",
+    "flv.js": "^1.6.2",
     "form-data": "^4.0.0",
     "html2canvas": "1.0.0",
     "lodash-es": "4.17.21",
@@ -54,10 +55,12 @@
     "pinia": "2.0.16",
     "qrcode": "^1.5.4",
     "qs": "6.11.0",
+    "screenfull": "^6.0.2",
     "uid": "2.0.2",
     "url-join": "5.0.0",
     "vue": "3.5.13",
     "vue-demi": "0.14.6",
+    "vue-draggable-plus": "^0.6.0",
     "vue-echarts": "^6.7.3",
     "vue-hooks-plus": "1.8.6",
     "vue-router": "4.1.2",

+ 75 - 0
src/api/camera/camera-preview.ts

@@ -0,0 +1,75 @@
+import { http } from '@/utils/http/axios';
+
+/** 相机树的结点类型 */
+export enum CameraTreeNodeType {
+  /** 公司 */
+  company = 'company',
+  /** 车间 */
+  workshop = 'workshop',
+  /** 工位 */
+  workspace = 'workspace',
+  /** 相机 */
+  camera = 'camera',
+}
+
+export interface UpdateCameraAlgoParam {
+  id: number;
+  algoId: number;
+  cameraId: number;
+  status: 0 | 1;
+}
+
+export interface CameraTree {
+  id: number;
+  name: string;
+  code: string;
+  children: CameraTree[];
+  nodeType: CameraTreeNodeType;
+  pushstreamIp: string;
+  integrationState: number;
+}
+
+export interface UpdateAlgoStatusBatchParam {
+  cameraId: number;
+  algoIds: number[];
+  status: 0 | 1;
+}
+
+/** 获取摄像头所在的树状结构 */
+export const getCameraTree = () => {
+  return http.request<CameraTree[]>(
+    {
+      url: '/camera/queryAllOwnedCameraTree',
+      method: 'get',
+    },
+    { ignoreTargetTenantId: true },
+  );
+};
+
+/** 更新相机的某个算法是否开启 */
+export const updateCameraAlgoApi = (data: UpdateCameraAlgoParam) => {
+  return http.request(
+    {
+      url: '/algo/updateCameraAlgoRel',
+      method: 'post',
+      data,
+    },
+    {
+      isShowErrorMessage: true,
+    },
+  );
+};
+
+/** 更新相机的算法列表是否开启 */
+export const updateAlgoStatusBatchApi = (data: UpdateAlgoStatusBatchParam) => {
+  return http.request(
+    {
+      url: '/algo/updateAlgoStatusBatch',
+      method: 'post',
+      data,
+    },
+    {
+      isShowErrorMessage: true,
+    },
+  );
+};

+ 74 - 0
src/api/camera/camera.ts

@@ -0,0 +1,74 @@
+import { CameraDetailServer } from './cameraType';
+import { IS_DISABLED } from '@/types/common/constants';
+import { http } from '@/utils/http/axios';
+
+export interface CameraStatusDetail {
+  id: number;
+  code: string;
+  integrationState: IS_DISABLED;
+  networkingState: IS_DISABLED;
+}
+
+/** 获取相机状态 */
+export const getCameraState = (data: { cameraCodeList: string[] }) => {
+  return http.request<CameraStatusDetail[]>(
+    {
+      url: '/camera/queryCameraStatusListByCameraCodes',
+      method: 'post',
+      data,
+    },
+    { ignoreTargetTenantId: true },
+  );
+};
+
+export const getCameraInfoDetail = (param: string) => {
+  return http.request<CameraDetailServer>({
+    url: `/camera/queryCameraWithDetail?cameraCode=${param}`,
+    method: 'get',
+  });
+};
+
+interface FenceType {
+  algoCode: string;
+  algoId: number;
+  cameraCode: string;
+  cameraId: number;
+  electronicFencePolygon: string;
+  presetToken: string;
+}
+
+// 获取相机电子围栏
+export const getCameraFence = (cameraId: number) => {
+  return http.request<FenceType[]>({
+    url: `/algo/queryCameraPreset?cameraId=${cameraId}`,
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    method: 'get',
+  });
+};
+
+// 根据相机查询对应NVR
+export const getNVRByCamera = (cameraId: number) => {
+  return http.request(
+    {
+      url: `/nvr/findNvrByCamera?cameraId=${cameraId}`,
+      method: 'get',
+    },
+    { isShowMessage: false, isShowErrorMessage: false },
+  );
+};
+
+/** 根据相机id查询相机详情 */
+export function queryCameraDetailById(cameraId: number): Promise<CameraDetailServer> {
+  return http.request({
+    url: `/admin/cameraPreview/queryCameraDetailById?id=${cameraId}`,
+    method: 'post',
+  });
+}
+
+/** 根据相机code查询相机详情 */
+export function queryCameraDetailByCode(cameraCode: number): Promise<CameraDetailServer> {
+  return http.request({
+    url: `/admin/cameraPreview/queryCameraDetailById?id=${cameraCode}`,
+    method: 'post',
+  });
+}

+ 139 - 0
src/api/camera/cameraType.ts

@@ -0,0 +1,139 @@
+/** 相机详情类型 */
+export interface CameraDetailServer {
+  /*自增主键 */
+  id: number;
+
+  /*车间id */
+  workshopId: number;
+
+  /*工位id */
+  workspaceId: number;
+
+  /*相机名称 */
+  name: string;
+
+  /*相机编号 */
+  code: string;
+
+  /*相机IP */
+  cameraIp: string;
+
+  /*相机端口 */
+  cameraPort: string;
+
+  /*登录账号(用户名) */
+  username: string;
+
+  /*密码 */
+  password: string;
+
+  /*相机类型: haikang/dahua/anxus/huawei */
+  cameraType: string;
+
+  /*描述 */
+  remark: string;
+
+  /*相机是否支持移动缩放:0-不支持;1-支持 */
+  isPtz: number;
+
+  /*相机的ONVIF端口号 */
+  onvifPort: string;
+
+  /*添加方式: IP,NVR,RTSP */
+  sourceType: string;
+
+  /*rtsp地址 */
+  rtspUrl: string;
+
+  /*NVR id */
+  nvrId: number;
+
+  /*NVR通道号 */
+  nvrChannel: string;
+
+  /*视频编码标准,H264, H265 */
+  videoStandard: string;
+
+  /*视频服务类型,TCP, UDP, AUTO */
+  videoServiceType: string;
+
+  /*状态: 0-启用, 1-禁用 */
+  isDisabled: number;
+
+  /*创建人 */
+  createdBy: number;
+
+  /*更新人 */
+  updatedBy: number;
+
+  /*创建时间 */
+  createdAt: Record<string, unknown>;
+
+  /*更新时间 */
+  updatedAt: Record<string, unknown>;
+
+  /*0-未删除,大于0(时间戳)-已删除 */
+  isDeleted: number;
+
+  /*租户ID */
+  tenantId: number;
+
+  /*联网状态: 0-启用, 1-禁用 */
+  networkingState: number;
+
+  /*接入状态: 0-启用, 1-禁用 */
+  integrationState: number;
+
+  /*渲染选择,无渲染/某个算法 */
+  render: string;
+
+  /*版本 */
+  version: number;
+
+  /*扩展数据 */
+  extra: string;
+
+  /*业务场景 */
+  sceneTemplateList: {
+    /*场景id */
+    sceneId: number;
+
+    /*模板id */
+    templateId: number;
+
+    templateCode: string;
+  }[];
+
+  /* */
+  pushStreamDTO: {
+    /* */
+    videoUrls: {
+      /*推流地址(前端播放的地址) */
+      pushstreamIp: string;
+
+      /*渲染推流地址(前端播放的渲染地址) */
+      pushstreamRenderUrl: string;
+
+      /*ios推流地址(前端播放的地址) */
+      m3u8PushstreamIp: string;
+
+      /*ios推流地址(前端播放的地址) */
+      m3u8PushstreamRenderIp: string;
+
+      /*推流地址(前端播放的地址) */
+      pushstreamIpAbs: string;
+
+      /*渲染推流地址(前端播放的渲染地址) */
+      pushstreamRenderUrlAbs: string;
+
+      /*ios推流地址(前端播放的地址) */
+      m3u8PushstreamIpAbs: string;
+
+      /*ios推流地址(前端播放的地址) */
+      m3u8PushstreamRenderIpAbs: string;
+    };
+
+    /*摄像头实时记录的画面 */
+    imageUrl: string;
+  };
+}

+ 83 - 0
src/api/nine-square-grid/index.ts

@@ -0,0 +1,83 @@
+import { http } from '@/utils/http/axios';
+
+export function editTenant(data) {
+  return http.request({
+    url: '/tenant/update',
+    method: 'post',
+    data,
+  });
+}
+
+export function queryCameraGroupList(isHomeDisplay: 0 | 1) {
+  //是否主页展示:0-否 1-是
+  return http.request({
+    url: `/cameraGroup/queryCameraGroupList?isHomeDisplay=${isHomeDisplay}`,
+    method: 'get',
+  });
+}
+
+export function addCameraGroupApi(data: { groupName: string }) {
+  return http.request({
+    url: '/cameraGroup/addCameraGroup',
+    method: 'post',
+    data,
+  });
+}
+
+export function deleteCameraGroupApi(cameraGroupId: number) {
+  return http.request({
+    url: `/cameraGroup/deleteCameraGroup?cameraGroupId=${cameraGroupId}`,
+    method: 'delete',
+  });
+}
+
+export function addCameraIntoGroupApi(data: { cameraId: number; groupId: number; orderNum: number }) {
+  return http.request({
+    url: '/cameraGroup/saveCamera2Group',
+    method: 'post',
+    data,
+  });
+}
+
+export function deleteCameraFromGroupApi(data: {
+  // cameraGroupDetailId: number;
+  groupId: number;
+  cameraId: number;
+}) {
+  return http.request({
+    // url: "/cameraGroup/deleteCameraFromGroup",
+    url: `/cameraGroup/deleteCameraFromGroup?groupId=${data.groupId}&&cameraId=${data.cameraId}`,
+    method: 'delete',
+  });
+}
+
+export function modifyCameraGroupApi(data: {
+  groupId: number;
+  groupName?: string;
+  isDefault?: number;
+  playIntervalSec?: number;
+  isPaused?: number;
+}) {
+  return http.request({
+    url: '/cameraGroup/updateCameraGroup',
+    method: 'post',
+    data,
+  });
+}
+
+interface UpdateCameraOrderParam {
+  groupId: number;
+  cameraId: number;
+  orderNum: number;
+}
+
+/**
+ * 修改相机的顺序
+ */
+export function updateCameraOrderApi(data: UpdateCameraOrderParam[]) {
+  return http.request({
+    url: '/cameraGroup/updateCameraOrder',
+    method: 'post',
+    data,
+  });
+}

+ 4 - 0
src/assets/declare.d.ts

@@ -6,3 +6,7 @@ declare module '*.png' {
   const src: string;
   export default src;
 }
+declare module '*.gif' {
+  const src: string;
+  export default src;
+}

BIN
src/assets/icons/nine-square-grid/add.png


BIN
src/assets/icons/nine-square-grid/camera.png


BIN
src/assets/icons/nine-square-grid/cameraEmpty.png


BIN
src/assets/icons/nine-square-grid/expand.png


BIN
src/assets/icons/nine-square-grid/folder-white.png


BIN
src/assets/icons/nine-square-grid/folder.png


BIN
src/assets/icons/nine-square-grid/fourGrids.png


BIN
src/assets/icons/nine-square-grid/fullScreen2.png


BIN
src/assets/icons/nine-square-grid/history-recall-icon.png


BIN
src/assets/icons/nine-square-grid/icon-delete.png


BIN
src/assets/icons/nine-square-grid/lock.png


BIN
src/assets/icons/nine-square-grid/more.png


BIN
src/assets/icons/nine-square-grid/nineGrids.png


BIN
src/assets/icons/nine-square-grid/oneGrid.png


BIN
src/assets/icons/nine-square-grid/playVideo.png


BIN
src/assets/icons/nine-square-grid/playing.png


BIN
src/assets/icons/nine-square-grid/playingCamera.png


BIN
src/assets/icons/nine-square-grid/sixteenGrids.png


BIN
src/assets/icons/nine-square-grid/unlock.png


BIN
src/assets/images/nine-square-grid/empty.png


BIN
src/assets/images/nine-square-grid/loading.gif


BIN
src/assets/images/nine-square-grid/logo.png


+ 26 - 0
src/components/live/LiveVideo.vue

@@ -0,0 +1,26 @@
+<template>
+  <div @click="emitClickVideo">
+    <component
+      :is="!isAppleTerminal() ? LiveVideo : LiveVideoOS"
+      :url="props.url"
+      v-if="props.url"
+      :poster="props.poster"
+    />
+  </div>
+</template>
+<script lang="ts" setup>
+import { isAppleTerminal } from "@/utils/is";
+import { defineAsyncComponent } from "vue";
+
+const props = defineProps<{ url: string; poster?: string }>();
+const emit = defineEmits(["clickVideo"]);
+
+const LiveVideo = defineAsyncComponent(() => import("@/components/live/LiveVideoFlv.vue"));
+const LiveVideoOS = defineAsyncComponent(() => import("@/components/live/LiveVideoHLSApple.vue"));
+
+// 点击视频时隐藏header和导航按钮
+function emitClickVideo() {
+  emit("clickVideo");
+}
+</script>
+<style scoped></style>

+ 223 - 0
src/components/live/LiveVideoFlv.vue

@@ -0,0 +1,223 @@
+<template>
+  <div class="videoWrapper">
+    <video ref="videoRef" autoplay muted class="video-js video-content" :poster="poster" disablePictureInPicture>
+      <source :src="urlWithToken" />
+    </video>
+    <img class="loading" :src="loadingImg" v-show="loading" />
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, onBeforeUnmount, watch, ref, computed } from 'vue';
+  import mpegts from 'mpegts.js';
+  // import useGlobalStore from '@/store/modules/user/use-global-store';
+  import { storeToRefs } from 'pinia';
+  import { useUserStore } from '@/store/modules/user';
+  import useAuthStore from '@/store/modules/useAuth';
+  import loadingImg from '@/assets/images/nine-square-grid/loading.gif';
+  // import useCameraPlaybackStore from '@/stores/useCameraPlaybackStore';
+
+  const props = defineProps<{
+    url: string;
+    poster?: string;
+  }>();
+
+  const userStore = useUserStore();
+  const { token } = storeToRefs(userStore);
+
+  const authStore = useAuthStore();
+  const { checkAuthValid } = authStore;
+
+  // const stateStore = useGlobalStore();
+  // const { liveLoaded } = storeToRefs(stateStore);
+
+  // const cameraPlaybackStore = useCameraPlaybackStore();
+  // const { isPlaybacking, isTouchDragging } = storeToRefs(cameraPlaybackStore);
+  // const { handlePlaybackVideoUpdateTime } = cameraPlaybackStore;
+
+  let player: mpegts.Player | null;
+  let lastDecodedFrames = 0; // 10s前的解码帧数
+  let currentDecodedFrames = 0; // 当前解码帧数
+  let loadingTimeout = 0;
+
+  const videoRef = ref<HTMLVideoElement | null>(null);
+
+  const loading = ref(true);
+
+  const urlWithToken = computed(() => {
+    // if (!token.value || !props.url) return '';
+    // return props.url + '?token=' + token.value;
+    if (!props.url) return '';
+
+    console.log('props.url', props.url);
+
+    return props.url;
+  });
+
+  // const handleTimeUpdate = (e) => {
+  //   if (!isPlaybacking.value || !liveLoaded.value || isTouchDragging.value) return;
+
+  //   handlePlaybackVideoUpdateTime(e.target.currentTime);
+
+  // };
+
+  const handleLoadeddata = () => {
+    // liveLoaded.value = true;
+    loading.value = false;
+    // if (isTouchDragging.value) {
+    //   isTouchDragging.value = false;
+    // }
+  };
+
+  const initPlay = () => {
+    if (!props.url || !videoRef.value || !token.value) {
+      return;
+    }
+
+    const videoElement = videoRef.value;
+    lastDecodedFrames = 0;
+    currentDecodedFrames = 0;
+    player = mpegts.createPlayer(
+      {
+        type: 'flv',
+        isLive: true,
+        hasAudio: false,
+        url: urlWithToken.value,
+      },
+      {
+        liveBufferLatencyChasing: true,
+        /**
+         * 控制直播视频流在缓冲区中允许的最大延迟时间。
+         * 较小的值使播放更接近实时,但可能会因为网络波动或数据传输不及时导致播放卡顿。
+         * 较大的值则会允许更多的缓冲,这样可以更好地应对网络波动,减少卡顿的可能性,但会增加播放延迟。
+         */
+        liveBufferLatencyMaxLatency: 4,
+      },
+    );
+
+    videoElement.removeEventListener('loadeddata', handleLoadeddata);
+    videoElement.addEventListener('loadeddata', handleLoadeddata);
+
+    player.attachMediaElement(videoElement);
+    player.load();
+    player.on(mpegts.Events.ERROR, (e, detail, data) => {
+      loading.value = true;
+      console.log('视频加载错误类型', e);
+      console.log('视频加载错误详情类型', detail);
+      console.log('视频加载错误信息', data);
+      checkAuthValid();
+      // 当发生error时,这里会发生死循环,所以要注销掉。 interval方式中已经包含了此种错误的处理
+      // reloadPlayer();
+    });
+    player.on(mpegts.Events.RECOVERED_EARLY_EOF, () => {
+      console.log('视频播放结束');
+      loading.value = true;
+    });
+    player.on(mpegts.Events.STATISTICS_INFO, (e) => {
+      // console.log("视频播放信息", e.decodedFrames);
+      const frame = e.decodedFrames || 0;
+      handleLoading(frame);
+      currentDecodedFrames = frame;
+    });
+    // player.play();
+    setTimeout(() => {
+      player?.play() as Promise<void>;
+      console.log('视频play()触发');
+    }, 50);
+  };
+
+  /** 处理loading信息 */
+  const handleLoading = (nextFrame: number) => {
+    if (currentDecodedFrames === nextFrame) {
+      if (loadingTimeout) return;
+      loadingTimeout = window.setTimeout(() => {
+        loading.value = true;
+        loadingTimeout = 0;
+      }, 6000);
+    } else {
+      clearTimeout(loadingTimeout);
+      loadingTimeout = 0;
+      loading.value = false;
+    }
+  };
+
+  const interval = setInterval(() => {
+    if (currentDecodedFrames === lastDecodedFrames) {
+      console.log('视频播放卡顿,10s前解码帧数为 ' + lastDecodedFrames + ' ,当前解码帧数为 ' + currentDecodedFrames);
+
+      reloadPlayer();
+    }
+    lastDecodedFrames = currentDecodedFrames;
+  }, 15000);
+
+  const destroyPlayer = () => {
+    // liveLoaded.value = false;
+    if (player) {
+      console.log('视频判断需要销毁');
+      player!.pause();
+      player!.unload();
+      player!.detachMediaElement();
+      console.log('视频播放器销毁');
+      player!.destroy();
+      player = null;
+    }
+  };
+
+  onMounted(() => {
+    initPlay();
+  });
+
+  const reloadPlayer = () => {
+    // liveLoaded.value = false;
+    loading.value = true;
+    destroyPlayer();
+    setTimeout(() => {
+      initPlay();
+    }, 100);
+  };
+
+  //切换播放url
+  watch(
+    () => props.url,
+    () => {
+      if (props.url) {
+        reloadPlayer();
+      }
+    },
+    {
+      deep: true,
+    },
+  );
+
+  onBeforeUnmount(() => {
+    destroyPlayer();
+    clearInterval(interval);
+    clearTimeout(loadingTimeout);
+  });
+</script>
+
+<style scoped lang="less">
+  .video-content {
+    width: 100%;
+    height: 100%;
+    background-color: transparent !important;
+    object-fit: fill;
+  }
+
+  .videoWrapper {
+    width: 100%;
+    height: 100%;
+    display: inline-block;
+  }
+
+  .loading {
+    position: absolute;
+    z-index: 1;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    margin: auto;
+    width: 60px;
+  }
+</style>

+ 146 - 0
src/components/live/LiveVideoHLSApple.vue

@@ -0,0 +1,146 @@
+<template>
+  <video ref="videoRef" autoplay muted loop playsinline webkit-playsinline class="video-content"></video>
+</template>
+
+<script setup lang="ts">
+import { onMounted, onBeforeUnmount, watch, ref } from "vue";
+import useGlobalStore from "@/stores/use-global-store";
+import { storeToRefs } from "pinia";
+import Hls from "hls.js";
+import { showToast } from "@/utils/common";
+import { useUserStore } from "@/stores/use-user";
+import useAuthStore from "@/stores/useAuth";
+import useCameraPlaybackStore from "@/stores/useCameraPlaybackStore";
+
+const props = defineProps<{
+  url: string;
+}>();
+
+const userStore = useUserStore();
+const { token } = storeToRefs(userStore);
+
+const authStore = useAuthStore();
+const { checkAuthValid } = authStore;
+
+const stateStore = useGlobalStore();
+const { liveLoaded } = storeToRefs(stateStore);
+
+const cameraPlaybackStore = useCameraPlaybackStore();
+const { isPlaybacking, isTouchDragging, isEnd } = storeToRefs(cameraPlaybackStore);
+const { handlePlaybackVideoUpdateTime, getIsNearEnd } = cameraPlaybackStore;
+
+const videoRef = ref<HTMLMediaElement | null>(null);
+
+const handleTimeUpdate = (e) => {
+  if (!isPlaybacking.value || !liveLoaded.value || isTouchDragging.value) return;
+
+  handlePlaybackVideoUpdateTime(e.target.currentTime);
+};
+
+const handleLoadeddata = () => {
+  liveLoaded.value = true;
+  if (isTouchDragging.value) {
+    isTouchDragging.value = false;
+  }
+};
+
+const handleEnd = () => {
+  isEnd.value = true;
+  showToast({ message: "回看已结束", type: "info" });
+};
+
+let hls: Hls;
+
+const initPlay = () => {
+  console.log("现在是iphone的视频流播放组件");
+  console.log(props.url);
+
+  videoRef.value!.removeEventListener("loadeddata", handleLoadeddata);
+  videoRef.value!.addEventListener("loadeddata", handleLoadeddata);
+  videoRef.value!.removeEventListener("timeupdate", handleTimeUpdate);
+  videoRef.value!.addEventListener("timeupdate", handleTimeUpdate);
+  videoRef.value!.removeEventListener("ended", handleEnd);
+  videoRef.value!.addEventListener("ended", handleEnd);
+
+  if (!props.url) {
+    return;
+  }
+
+  if (!Hls.isSupported()) {
+    showToast({
+      message: "您的设备不支持视频流播放,请升级系统",
+      type: "error",
+      offset: 100,
+    });
+    return;
+  }
+  // const videoElement = document.getElementById("video") as HTMLMediaElement;
+  hls = new Hls();
+  hls.loadSource(props.url + "?token=" + token.value);
+  hls.attachMedia(videoRef.value!);
+  hls.on(Hls.Events.MANIFEST_PARSED, () => {
+    const timer = setTimeout(() => {
+      liveLoaded.value = true;
+      videoRef.value!.play();
+      clearTimeout(timer);
+    }, 1000);
+  });
+  hls.on(Hls.Events.ERROR, (err, data) => {
+    console.error("iphone视频播放error");
+    console.error(err);
+    console.error(data);
+    checkAuthValid();
+    if (getIsNearEnd() && !isEnd.value) {
+      handleEnd();
+    }
+  });
+  hls.on(Hls.Events.BUFFER_EOS, () => {
+    console.log("流已经结束了");
+    handleEnd();
+  });
+};
+
+const destroyPlayer = () => {
+  liveLoaded.value = false;
+  isEnd.value = false;
+  if (hls) {
+    hls.destroy();
+  }
+};
+
+onMounted(() => {
+  initPlay();
+});
+
+//切换播放url
+watch(
+  () => props.url,
+  () => {
+    destroyPlayer();
+    if (props.url) {
+      initPlay();
+    }
+  },
+  {
+    deep: true,
+  },
+);
+
+onBeforeUnmount(() => {
+  destroyPlayer();
+});
+</script>
+
+<style scoped lang="less">
+.video-content {
+  width: 100%;
+  height: 100%;
+  background-color: transparent !important;
+}
+
+.video-content::--webkit-media-controls-play-button {
+  display: none !important;
+  -webkit-appearance: none !important;
+}
+</style>
+@/stores/use-global-store

+ 143 - 0
src/components/thumbnail/Thumbnail.vue

@@ -0,0 +1,143 @@
+<template>
+  <div class="thumb-nail">
+    <ElPopover
+      popper-class="thumb-popover"
+      ref="popoverRef"
+      :placement="position || 'bottom'"
+      :show-after="100"
+      :hide-after="0"
+      @show="handleMouseEnter"
+    >
+      <template #reference>
+        <div>
+          <slot></slot>
+        </div>
+      </template>
+      <template #default>
+        <ElImage
+          v-loading="isLoading"
+          :src="imgUrl || EmptyImg"
+          fit="contain"
+          class="thumb-img"
+          :preview-src-list="srcList"
+          :zoom-rate="1.2"
+          :max-scale="3"
+          :min-scale="0.1"
+          @close="handleClose"
+        />
+      </template>
+    </ElPopover>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ElPopover, ElImage } from 'element-plus';
+  import EmptyImg from '@/assets/images/nine-square-grid/empty.png';
+  import { captureFirstPicFromFLV } from '@/utils/flv/captureFirstPicFromFLV';
+  import { getCameraInfoDetail } from '@/api/camera/camera';
+  import { CameraDetailServer } from '@/types/scene-types/scene-types';
+  import { ref } from 'vue';
+  import { useUserStore } from '@/store/modules/user';
+  import { storeToRefs } from 'pinia';
+  const isLoading = ref(false);
+  const imgUrl = ref();
+  const srcList = ref<string[]>([]);
+  const popoverRef = ref();
+  const props = defineProps<{
+    // 图片预览的url
+    imageUrl?: string;
+    code?: string;
+    position?:
+      | 'top'
+      | 'top-start'
+      | 'top-end'
+      | 'bottom'
+      | 'bottom-start'
+      | 'bottom-end'
+      | 'left'
+      | 'left-start'
+      | 'left-end'
+      | 'right'
+      | 'right-start'
+      | 'right-end';
+  }>();
+  const getCameraDetail = async (code: string) => {
+    return getCameraInfoDetail(code);
+  };
+
+  const userStore = useUserStore();
+
+  const { token } = storeToRefs(userStore);
+  const getPushstreamImg = async (detail: CameraDetailServer) => {
+    return detail.pushStreamDTO?.imageUrl;
+  };
+  const getPushstreamIp = async (detail: CameraDetailServer) => {
+    if (/macintosh|mac os x/i.test(navigator.userAgent)) {
+      return detail.pushStreamDTO?.videoUrls?.m3u8PushstreamIp;
+    } else if (detail.render) {
+      return detail.pushStreamDTO?.videoUrls?.pushstreamRenderUrl;
+    }
+    return detail.pushStreamDTO?.videoUrls?.pushstreamIp;
+  };
+
+  const handleMouseEnter = async () => {
+    // if (imgUrl.value) return;
+    srcList.value = [];
+    isLoading.value = true;
+    const imageUrl = props.imageUrl;
+    if (imageUrl) {
+      imgUrl.value = imageUrl;
+      srcList.value.push(imageUrl);
+      isLoading.value = false;
+      return;
+    }
+
+    if (!props.code) return;
+    const detail = await getCameraDetail(props.code);
+    let url: string | null;
+    url = await getPushstreamImg(detail);
+    if (url) {
+      imgUrl.value = url;
+      srcList.value.push(url);
+      isLoading.value = false;
+      return;
+    }
+    const videoSrc = await getPushstreamIp(detail);
+
+    const urlWithToken = videoSrc + '?token=' + token.value;
+    url = await captureFirstPicFromFLV(urlWithToken);
+    imgUrl.value = url;
+    srcList.value.push(url);
+    isLoading.value = false;
+  };
+  const handleClose = () => {
+    popoverRef.value.hide();
+  };
+  defineExpose({
+    handleClose,
+  });
+</script>
+
+<style lang="scss" scoped>
+  .thumb-nail {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    width: 100%;
+    height: 100%;
+  }
+</style>
+<style lang="scss">
+  .thumb-popover {
+    display: flex;
+    align-items: center;
+    padding: 0 !important;
+    border-radius: 5px !important;
+    width: 400px !important;
+  }
+
+  .thumb-img {
+    width: 100%;
+    border-radius: 5px;
+  }
+</style>

+ 2 - 1
src/router/full-routes.ts

@@ -56,7 +56,8 @@ export const disasterPreventionRoute = {
       redirect: '',
     },
     {
-      component: '/disaster/monitor/PageMonitor',
+      // component: '/disaster/monitor/PageMonitor',
+      component: '/disaster/monitor/splitScreenRetrieval/SplitScreenRetrieval',
       id: 1026,
       meta: {
         activeMenu: null,

+ 425 - 0
src/store/modules/useCameraGroupList.ts

@@ -0,0 +1,425 @@
+import { defineStore } from 'pinia';
+import { computed, ref, watch } from 'vue';
+import { ElMessage } from 'element-plus';
+import type {
+  CameraGroupListType,
+  CameraGroupType,
+  Camera,
+  CameraInPlay,
+} from '@/views/disaster/monitor/splitScreenRetrieval/type';
+import {
+  queryCameraGroupList,
+  addCameraGroupApi,
+  deleteCameraGroupApi,
+  addCameraIntoGroupApi,
+  deleteCameraFromGroupApi,
+  modifyCameraGroupApi,
+} from '@/api/nine-square-grid';
+import { parseCameraGroupChildren } from '@/views/disaster/monitor/splitScreenRetrieval/hooks/parseData';
+import { userGridType } from './userGridType';
+import { storeToRefs } from 'pinia';
+import { CameraStatusDetail } from '@/api/camera/camera';
+
+const { currentGrid } = storeToRefs(userGridType());
+
+export const useCameraGroupList = defineStore('useCameraGroupList', () => {
+  const cameraGroupList = ref<CameraGroupListType>([]); // 所有分组列表
+  const cameraStatusList = ref<CameraStatusDetail[]>([]); // 相机状态列表
+  const playingGroup = ref<CameraGroupType>(); // 正在轮播的分组
+  const cameraInPlay = ref<CameraInPlay[]>([]); // 正在轮播中的摄像头列表
+  const playIntervalTime = ref<number>(60); // 播放间隔
+  const isPlaying = ref<boolean>(false); // 是否正在播放
+  const isPaused = ref<boolean>(false); // 播放是否暂停
+  const startIndex = ref<number>(0);
+  const endIndex = ref<number>(0);
+  let timer; // 轮播定时器
+
+  // 轮播分组里的所有摄像头列表
+  const carouselList = computed(() => {
+    const theGroupAllCameras = (cameraGroupList.value.find((x) => x.id === playingGroup.value?.id)?.children ||
+      []) as CameraInPlay[];
+
+    const goodCameras = theGroupAllCameras.filter((x) => {
+      // 如果相机cameraStatusList长度为0,说明还未请求数据,这时候先返回所有的相机进行展示。
+      if (cameraStatusList.value.length === 0) return true;
+      // 如果有坏的相机,这时候要进行过滤掉
+      return cameraStatusList.value.find((item) => item.code === x.code)?.networkingState === 0;
+    });
+    return goodCameras;
+  });
+
+  // 总轮数
+  const totalRound = computed(() => Math.ceil(carouselList.value.length / currentGrid.value));
+
+  // 当前轮数
+  const currentRound = computed(() => {
+    // 没有接入相机时
+    if (cameraInPlay.value.every((x) => x.url === '')) return 0;
+
+    // 在第一轮时
+    if (startIndex.value === 0) return 1;
+
+    return Math.floor((startIndex.value / carouselList.value.length) * totalRound.value + 1);
+  });
+
+  // 默认为9宫格
+  initialGrid(currentGrid.value);
+
+  function initialGrid(gridNum: number) {
+    const tempList: CameraInPlay[] = [];
+    for (let i = 1; i < gridNum + 1; i++) {
+      tempList.push({
+        id: -i, // 负id为用于填充宫格的假摄像头
+        cameraGroupDetailId: -i,
+        url: '',
+        name: '',
+        code: '',
+        imageUrl: '',
+      });
+    }
+    cameraInPlay.value = tempList;
+  }
+
+  function getCameraGroupList() {
+    queryCameraGroupList(0).then((res) => {
+      console.log('res', res);
+
+      cameraGroupList.value = parseCameraGroupChildren(res);
+      const defaultPlayGroup = cameraGroupList.value.find((x) => x.isDefault === 1);
+      if (defaultPlayGroup) {
+        isPlaying.value = true;
+        playingGroup.value = defaultPlayGroup;
+
+        // 如果分组是默认播放但没设置播放间隔则设置播放间隔为60秒
+        if (!defaultPlayGroup.playIntervalSec) defaultPlayGroup.playIntervalSec = 60;
+
+        playIntervalTime.value = defaultPlayGroup?.playIntervalSec;
+        isPaused.value = Boolean(defaultPlayGroup?.isPaused);
+        groupStartPlay(defaultPlayGroup.playIntervalSec);
+      }
+    });
+  }
+
+  function createCameraGroup(groupName: string) {
+    // 由于请求接口有延迟,所以先更新本地数据,然后再通过接口更新数据
+    if (cameraGroupList.value.find((x) => x.groupName === groupName))
+      return ElMessage({ message: '分组名已存在', type: 'error' });
+
+    const newGroup = {
+      id: Date.now(),
+      groupName: groupName,
+      children: [],
+      isDefault: 0,
+      playIntervalSec: 0,
+      isPaused: false,
+    };
+
+    cameraGroupList.value.unshift(newGroup);
+
+    addCameraGroupApi({ groupName })
+      .then((res) => {
+        // getCameraGroupList();
+        newGroup.id = res;
+      })
+      .catch(() => {
+        ElMessage({ message: '新建分组失败', type: 'error' });
+      });
+  }
+
+  function deleteCameraGroup(id: number) {
+    // 由于请求接口有延迟,所以先更新本地数据,然后再通过接口更新数据
+    cameraGroupList.value = cameraGroupList.value.filter((x) => x.id !== id);
+
+    deleteCameraGroupApi(id).then(() => {
+      // getCameraGroupList();
+    });
+  }
+
+  function addCameraIntoGroup(id: number, camera: Camera) {
+    const group = cameraGroupList.value.find((x) => x.id === id);
+    // 由于请求接口有延迟,所以先更新本地数据,然后再通过接口更新数据
+    const newCamera = {
+      name: camera.name,
+      code: camera.code,
+      id: camera.id,
+      url: camera.url,
+      cameraGroupDetailId: 0,
+      imageUrl: camera.imageUrl,
+    };
+    group?.children.push(newCamera);
+    const newCameraToBackEnd = {
+      cameraId: camera.id,
+      groupId: group!.id,
+      orderNum: group!.children.length,
+    };
+    addCameraIntoGroupApi(newCameraToBackEnd).then((res) => {
+      // getCameraGroupList();
+
+      newCamera.cameraGroupDetailId = res.cameraGroupDetailId;
+    });
+
+    // group?.children.push(camera);
+  }
+
+  function deleteCameraFromGroup(cameraGroup: CameraGroupType, camera: Camera) {
+    if (camera.cameraGroupDetailId === 0) {
+      return;
+    }
+
+    // 场景树中的相机没有cameraGroupDetailId这个属性,所以只能从cameraGroup里过滤
+    // let cameraGroupDetailId = cameraGroup.children.find(
+    //   (x) => x.id === camera.id
+    // )?.cameraGroupDetailId;
+
+    // 由于请求接口有延迟,所以先更新本地数据,然后再通过接口更新数据
+    cameraGroup.children = cameraGroup.children.filter((x) => x.id !== camera.id);
+
+    deleteCameraFromGroupApi({
+      // cameraGroupDetailId: cameraGroupDetailId!,
+      groupId: cameraGroup.id,
+      cameraId: camera.id,
+    }).then(() => {
+      // getCameraGroupList();
+    });
+  }
+
+  // 删除正在播放的相机
+  function deleteCameraInPlaylist(cameraGroup: CameraGroupType, camera: Camera) {
+    const newCameraInPlay = cameraInPlay.value.filter((x) => x.id !== camera.id);
+    // 用于替代被删除相机的相机index
+    let replaceCameraIndex: null | number = null;
+
+    // 如果当前是最后一轮
+    if (currentRound.value === totalRound.value) {
+      // 正在播放的相机列表中最后一个非填充补位的相机的index
+      const lastCameraIndex = cameraInPlay.value.findIndex((x) => x.id === carouselList.value.slice(-1)[0].id);
+
+      // 用于填充空屏的相机列表
+      const cameraOfFillList = cameraInPlay.value.slice(lastCameraIndex + 1, cameraInPlay.value.length);
+
+      // 如果被删除的相机不在用于填充的相机中,且用于填充的相机等于当前宫格数-1,则表示删除的是最后一个剩余未播放的相机,那么删除该相机后返回第一轮
+      if (!cameraOfFillList.includes(camera) && cameraOfFillList.length === currentGrid.value - 1) {
+        restartPlay();
+        deleteCameraFromGroup(cameraGroup, camera);
+        return;
+      }
+      // 如果删除的是用于填充的相机,则用它在所有播放列表里所在位置的下一个相机代替
+      else {
+        replaceCameraIndex = carouselList.value.indexOf(cameraOfFillList.slice(-1)[0]) + 1;
+      }
+    } else {
+      // 用当前播放列表里的最后一个相机在所有播放列表里所在位置的下一个相机代替
+      replaceCameraIndex = carouselList.value.indexOf(cameraInPlay.value.slice(-1)[0]) + 1;
+
+      // 防止掉线相机重连后让endIndex被推后
+      endIndex.value = replaceCameraIndex;
+
+      // replaceCameraIndex =
+      //   endIndex.value === carouselList.value.length ? 0 : endIndex.value;
+    }
+    // 用未播放的相机填充被删除相机
+    newCameraInPlay.push(carouselList.value[replaceCameraIndex!]);
+
+    deleteCameraFromGroup(cameraGroup, camera);
+    cameraInPlay.value = newCameraInPlay;
+
+    // 更新startIndex避免currentRound出错
+    startIndex.value = carouselList.value.indexOf(cameraInPlay.value[0]);
+  }
+
+  const updateCameraStatus = (list: CameraStatusDetail[]) => {
+    // 比较
+    cameraStatusList.value = list;
+
+    // 如果在播的相机掉线,则返回第一轮
+    const isAllGood = cameraInPlay.value.every((x) => list.find((item) => item.code === x.code)?.networkingState === 0);
+    if (!isAllGood) {
+      restartPlay();
+    }
+  };
+
+  function setPlayGroup(cameraGroup: CameraGroupType) {
+    playingGroup.value = cameraGroup;
+  }
+
+  function fillEmptyGrid(emptyGrid: number) {
+    // // 如果当前轮播列表的摄像头数量不够填满宫格,则往无摄像头的宫格插入id为负的空摄像头
+    const tempList: CameraInPlay[] = [];
+    for (let i = 1; i < emptyGrid + 1; i++) {
+      tempList.push({
+        id: -i, // 负id为用于填充宫格的假摄像头
+        cameraGroupDetailId: -i,
+        url: '',
+        name: '',
+        code: '',
+        imageUrl: '',
+      });
+    }
+    cameraInPlay.value = cameraInPlay.value.concat(tempList);
+  }
+
+  // 切换到上一轮的逻辑是把索引设置为上上轮,再立即播放下一轮
+  function playPreviousRound() {
+    startIndex.value -= 2 * currentGrid.value;
+    endIndex.value = startIndex.value + currentGrid.value;
+    if (startIndex.value < 0) {
+      endIndex.value = 0;
+      startIndex.value = 0;
+    }
+    console.log('previousRound startIndex.value', startIndex.value);
+    console.log('previousRound endIndex.value', endIndex.value);
+    playNextRound();
+  }
+
+  function playNextRound() {
+    // 轮播列表摄像头数小于宫格数时停止轮播
+    if (carouselList.value.length < currentGrid.value) {
+      cameraInPlay.value = carouselList.value;
+      const emptyGridNum = currentGrid.value - cameraInPlay.value.length;
+      fillEmptyGrid(emptyGridNum);
+      return;
+    }
+
+    // 如果正好播完分组里的全部摄像头,则直接返回第一轮
+    if (endIndex.value === carouselList.value.length) {
+      endIndex.value = 0;
+      startIndex.value = 0;
+    }
+
+    // 即将播放完分组里的全部摄像头时,如果剩余的摄像头数量不能充满宫格,则用分组里index为0到(缺少的数量)的摄像头填充
+    if (endIndex.value + currentGrid.value > carouselList.value.length) {
+      // 剩余未播放的摄像头
+      const restCameras = carouselList.value.slice(endIndex.value, carouselList.value.length + 1);
+
+      endIndex.value = currentGrid.value - restCameras.length;
+
+      // 用于填充的摄像头
+      const fillGridCameras = carouselList.value.slice(0, endIndex.value);
+
+      cameraInPlay.value = restCameras.concat(fillGridCameras);
+
+      startIndex.value += currentGrid.value;
+      console.log('startIndex.value', startIndex.value);
+      console.log('endIndex.value', endIndex.value);
+
+      // 注释掉可改变第二轮开始以及后续轮次的摄像头顺序
+      endIndex.value = 0;
+
+      return;
+    }
+    endIndex.value = endIndex.value + currentGrid.value;
+    startIndex.value = endIndex.value - currentGrid.value;
+
+    console.log('startIndex.value', startIndex.value);
+    console.log('endIndex.value', endIndex.value);
+
+    cameraInPlay.value = carouselList.value.slice(startIndex.value, endIndex.value);
+  }
+
+  function groupStartPlay(intervalTime: number) {
+    // 如果正在轮播就清空之前的计时器,避免重复创建多个计时器
+    if (isPlaying.value) {
+      cleanTimer();
+    }
+
+    // 如果先前未在轮播,则在计时器开始前先开始第一次轮播
+    if (!cameraInPlay.value.find((x) => x.id > 0)) {
+      playNextRound();
+    }
+
+    if (isPaused.value === false) {
+      timer = setInterval(() => {
+        playNextRound();
+      }, intervalTime * 1000);
+    }
+  }
+
+  function cleanTimer() {
+    clearInterval(timer);
+    timer = null;
+  }
+
+  function pausePlay() {
+    cleanTimer();
+  }
+
+  function stopPlay(id: number, setIsDefault?: boolean) {
+    playingGroup.value = undefined;
+    cameraInPlay.value = [];
+    isPlaying.value = false;
+    startIndex.value = 0;
+    endIndex.value = 0;
+    initialGrid(currentGrid.value);
+    cleanTimer();
+    if (setIsDefault) {
+      modifyCameraGroupApi({
+        groupId: id,
+        isDefault: 0,
+      });
+    }
+  }
+
+  function continuePlay() {
+    playNextRound();
+    timer = setInterval(() => {
+      playNextRound();
+    }, playIntervalTime.value * 1000);
+  }
+
+  function restartPlay() {
+    endIndex.value = 0;
+    startIndex.value = 0;
+    // 重置轮次为第一轮后立即开始第一轮轮播
+    playNextRound();
+    if (isPaused.value === false) {
+      groupStartPlay(playIntervalTime.value);
+    }
+  }
+
+  // 切换宫格时调整正在轮播中的摄像头列表
+  watch(
+    () => currentGrid.value,
+    (newGridNum) => {
+      cleanTimer();
+
+      // 如果当前在轮播
+      if (cameraInPlay.value.find((x) => x.id > 0)) {
+        restartPlay();
+      }
+      // 如果当前不在轮播
+      else {
+        initialGrid(newGridNum);
+      }
+    },
+  );
+
+  return {
+    carouselList,
+    playingGroup,
+    cameraGroupList,
+    cameraStatusList,
+    cameraInPlay,
+    isPlaying,
+    isPaused,
+    playIntervalTime,
+    totalRound,
+    currentRound,
+    getCameraGroupList,
+    createCameraGroup,
+    deleteCameraGroup,
+    addCameraIntoGroup,
+    deleteCameraFromGroup,
+    deleteCameraInPlaylist,
+    setPlayGroup,
+    groupStartPlay,
+    playPreviousRound,
+    playNextRound,
+    continuePlay,
+    stopPlay,
+    pausePlay,
+    restartPlay,
+    updateCameraStatus,
+  };
+});
+
+export default useCameraGroupList;

+ 33 - 0
src/store/modules/useTargetTenantIdStore.ts

@@ -0,0 +1,33 @@
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+
+/** 设置targetTenantId */
+export const useTargetTenantIdStore = defineStore(
+  'targetTenantId',
+  () => {
+    const targetTenantId = ref<number | null>(null);
+
+    function clear() {
+      targetTenantId.value = null;
+    }
+
+    function setValue(newVal: number) {
+      targetTenantId.value = newVal;
+    }
+
+    function getValue() {
+      return targetTenantId.value;
+    }
+
+    function getStringValue(): string {
+      return targetTenantId.value === null ? '' : String(targetTenantId.value);
+    }
+
+    return { setValue, getValue, clear, targetTenantId, getStringValue };
+  },
+  {
+    persist: {
+      storage: sessionStorage,
+    },
+  },
+);

+ 14 - 0
src/store/modules/userGridType.ts

@@ -0,0 +1,14 @@
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+import { GridType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
+
+export const userGridType = defineStore('userGridType', () => {
+  const currentGrid = ref<GridType>(GridType.nineGrids); // 默认九宫格
+
+  const changeGridType = (newGrid: GridType) => {
+    currentGrid.value = newGrid;
+  };
+  return { currentGrid, changeGridType };
+});
+
+export default userGridType;

+ 21 - 0
src/store/modules/userSplitScreenFullScreen.ts

@@ -0,0 +1,21 @@
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+
+export const userSplitScreenFullScreen = defineStore('userSplitScreenFullScreen', () => {
+  const isFullScreen = ref(false);
+  const curFullScreenType = ref('single');
+
+  function fullScreen(elementId, fullScreenType) {
+    curFullScreenType.value = fullScreenType;
+    const element = document.getElementById(elementId);
+    if (element?.requestFullscreen) element.requestFullscreen();
+    isFullScreen.value = true;
+  }
+  function exitFullscreen() {
+    if (document.exitFullscreen) document.exitFullscreen();
+    isFullScreen.value = false;
+  }
+  return { isFullScreen, curFullScreenType, fullScreen, exitFullscreen };
+});
+
+export default userSplitScreenFullScreen;

+ 139 - 0
src/types/scene-types/scene-types.ts

@@ -0,0 +1,139 @@
+/** 相机详情类型 */
+export interface CameraDetailServer {
+  /*自增主键 */
+  id: number;
+
+  /*车间id */
+  workshopId: number;
+
+  /*工位id */
+  workspaceId: number;
+
+  /*相机名称 */
+  name: string;
+
+  /*相机编号 */
+  code: string;
+
+  /*相机IP */
+  cameraIp: string;
+
+  /*相机端口 */
+  cameraPort: string;
+
+  /*登录账号(用户名) */
+  username: string;
+
+  /*密码 */
+  password: string;
+
+  /*相机类型: haikang/dahua/anxus/huawei */
+  cameraType: string;
+
+  /*描述 */
+  remark: string;
+
+  /*相机是否支持移动缩放:0-不支持;1-支持 */
+  isPtz: number;
+
+  /*相机的ONVIF端口号 */
+  onvifPort: string;
+
+  /*添加方式: IP,NVR,RTSP */
+  sourceType: string;
+
+  /*rtsp地址 */
+  rtspUrl: string;
+
+  /*NVR id */
+  nvrId: number;
+
+  /*NVR通道号 */
+  nvrChannel: string;
+
+  /*视频编码标准,H264, H265 */
+  videoStandard: string;
+
+  /*视频服务类型,TCP, UDP, AUTO */
+  videoServiceType: string;
+
+  /*状态: 0-启用, 1-禁用 */
+  isDisabled: number;
+
+  /*创建人 */
+  createdBy: number;
+
+  /*更新人 */
+  updatedBy: number;
+
+  /*创建时间 */
+  createdAt: Record<string, unknown>;
+
+  /*更新时间 */
+  updatedAt: Record<string, unknown>;
+
+  /*0-未删除,大于0(时间戳)-已删除 */
+  isDeleted: number;
+
+  /*租户ID */
+  tenantId: number;
+
+  /*联网状态: 0-启用, 1-禁用 */
+  networkingState: number;
+
+  /*接入状态: 0-启用, 1-禁用 */
+  integrationState: number;
+
+  /*渲染选择,无渲染/某个算法 */
+  render: string;
+
+  /*版本 */
+  version: number;
+
+  /*扩展数据 */
+  extra: string;
+
+  /*业务场景 */
+  sceneTemplateList: {
+    /*场景id */
+    sceneId: number;
+
+    /*模板id */
+    templateId: number;
+
+    templateCode: string;
+  }[];
+
+  /* */
+  pushStreamDTO: {
+    /* */
+    videoUrls: {
+      /*推流地址(前端播放的地址) */
+      pushstreamIp: string;
+
+      /*渲染推流地址(前端播放的渲染地址) */
+      pushstreamRenderUrl: string;
+
+      /*ios推流地址(前端播放的地址) */
+      m3u8PushstreamIp: string;
+
+      /*ios推流地址(前端播放的地址) */
+      m3u8PushstreamRenderIp: string;
+
+      /*推流地址(前端播放的地址) */
+      pushstreamIpAbs: string;
+
+      /*渲染推流地址(前端播放的渲染地址) */
+      pushstreamRenderUrlAbs: string;
+
+      /*ios推流地址(前端播放的地址) */
+      m3u8PushstreamIpAbs: string;
+
+      /*ios推流地址(前端播放的地址) */
+      m3u8PushstreamRenderIpAbs: string;
+    };
+
+    /*摄像头实时记录的画面 */
+    imageUrl: string;
+  };
+}

+ 62 - 0
src/utils/flv/captureFirstPicFromFLV.ts

@@ -0,0 +1,62 @@
+import flvjs from "flv.js";
+/**
+ * 截取flv视频流第一帧并返回为Data URL
+ * @param {string} videoSrc - flv视频流的URL地址
+ * @returns {Promise<string>} - 第一帧图片的Base64 数据
+ */
+export async function captureFirstPicFromFLV(
+  videoSrc: string
+): Promise<string> {
+  return new Promise((resolve, reject) => {
+    //创建video元素
+    const video = document.createElement("video");
+    video.muted = true;
+    video.autoplay = false;
+    //创建canvas画布
+    const canvas = document.createElement("canvas");
+    const ctx = canvas.getContext("2d");
+    //创建FLV播放器
+    if (flvjs.isSupported()) {
+      const player = flvjs.createPlayer({
+        type: "flv",
+        url: videoSrc,
+      });
+      player.attachMediaElement(video);
+      player.load();
+      player.play();
+      player.on(flvjs.Events.ERROR, (errorType, errorDetail) => {
+        reject(`FLV.js error: ${errorType},${errorDetail}`);
+      });
+      //监听数据加载
+      video.addEventListener("loadedmetadata", () => {
+        console.log("视频元数据已加载,准备捕获第一帧。。。");
+        canvas.width = video.videoWidth;
+        canvas.height = video.videoHeight;
+        video.addEventListener("canplay", () => {
+          console.log("视频已准备播放,开始捕获第一帧。。。");
+          //捕获第一帧
+          video.currentTime = 0;
+        });
+        video.addEventListener("seeked", () => {
+          console.log("视频已经定位到第一帧,开始绘制图像。。。");
+          if (ctx) {
+            ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+            const imageData = canvas.toDataURL("image/png");
+            resolve(imageData);
+            //销毁播放器并清理DOM
+            player.destroy();
+            video.remove();
+            canvas.remove();
+          } else {
+            reject("无法捕捉关键帧");
+          }
+        });
+        video.addEventListener("error", (err) => {
+          reject(`视频加载失败:${err}`);
+        });
+      });
+    } else {
+      reject("不支持flv播放");
+    }
+  });
+}

+ 376 - 0
src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupList/CameraGroup.vue

@@ -0,0 +1,376 @@
+<template>
+  <div class="cameraGroup">
+    <el-collapse-item :name="cameraGroup.groupName" @keydown.space.stop.prevent @keydown.enter.stop.prevent>
+      <template #title>
+        <div
+          class="cameraGroupTitle"
+          :class="playingGroup?.id === props.cameraGroup.id ? 'playingCameraGroup' : ''"
+          @click.stop=""
+          @dblclick="handleRenameGroup"
+        >
+          <div class="renameGroupInput" v-show="showRenameGroupInput">
+            <img class="folderIcon" src="@/assets/icons/nine-square-grid/folder.png" />
+            <el-input
+              v-model="inputNewGroupName"
+              class="GroupNameInput"
+              placeholder="为此区域命名"
+              maxlength="15"
+              show-word-limit
+              ref="groupNameInputRef"
+              @blur="enterNewName"
+              @keyup.enter="$event.target.blur()"
+            />
+          </div>
+
+          <div class="IconAndGroupName" v-show="!showRenameGroupInput">
+            <img class="folderImg" src="@/assets/icons/nine-square-grid/folder.png" alt="" />
+            <div class="groupName" :title="cameraGroup.groupName">
+              {{ cameraGroup.groupName }}
+            </div>
+          </div>
+
+          <div class="groupOperationBar">
+            <img
+              src="@/assets/icons/nine-square-grid/more.png"
+              class="groupOperationIcon"
+              @click="showGroupOperation = !showGroupOperation"
+              @dblclick.stop=""
+            />
+          </div>
+
+          <div class="groupOperation" v-show="showGroupOperation">
+            <div @click="handelStartPlay()" class="groupOperationItem">
+              {{ cameraGroup.id !== playingGroup?.id ? '开始播放' : '停止播放' }}
+            </div>
+            <div @click="handleDelete(cameraGroup)" class="groupOperationItem"> 删除区域 </div>
+          </div>
+        </div>
+      </template>
+
+      <div class="addCamera" @click.stop="addCamera">
+        <div class="addCameraIcon">
+          <img src="@/assets/icons/nine-square-grid/add.png" />
+        </div>
+        <div>添加监控点位</div>
+      </div>
+
+      <CameraListOfGroup :cameraGroup="cameraGroup" />
+    </el-collapse-item>
+  </div>
+
+  <div>
+    <el-dialog v-model="showCameraTreeDialog" width="500" :title="`添加相机至“${cameraGroup.groupName}”区域`">
+      <CameraTreeOfGroupList :cameraGroup="cameraGroup" />
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { storeToRefs } from 'pinia';
+  import { ref, watch, nextTick } from 'vue';
+  import CameraTreeOfGroupList from './CameraTreeOfGroupList.vue';
+  import { ElCollapseItem, ElDialog, ElMessageBox, ElInput, ElMessage } from 'element-plus';
+  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import { userGridType } from '@/store/modules/userGridType';
+  import { GridType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
+  import type { CameraGroupType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
+  import { modifyCameraGroupApi } from '@/api/nine-square-grid';
+  import CameraListOfGroup from './CameraListOfGroup.vue';
+
+  const showGroupOperation = ref(false);
+  const showCameraTreeDialog = ref(false);
+  const props = defineProps<{
+    cameraGroup: CameraGroupType;
+    activeGroup: string[];
+  }>();
+
+  const showRenameGroupInput = ref(false);
+  let groupOriginName = props.cameraGroup.groupName;
+  const inputNewGroupName = ref('');
+  const groupNameInputRef = ref();
+
+  const { cameraGroupList, playingGroup, isPlaying, isPaused, playIntervalTime } = storeToRefs(useCameraGroupList());
+  const { deleteCameraGroup, groupStartPlay, setPlayGroup, stopPlay } = useCameraGroupList();
+  const { changeGridType } = userGridType();
+
+  // 控制播放/删除菜单的隐藏
+  const closeGroupOperation = (event) => {
+    if (event.target.className !== 'groupOperationItem') showGroupOperation.value = false;
+    if (event.target.innerText === '开始播放') {
+      handelStartPlay();
+      showGroupOperation.value = false;
+    }
+  };
+
+  function handleRenameGroup() {
+    showRenameGroupInput.value = true;
+    nextTick(() => {
+      groupNameInputRef.value.focus();
+      inputNewGroupName.value = groupOriginName;
+    });
+  }
+
+  function enterNewName() {
+    if (inputNewGroupName.value === '') {
+      showRenameGroupInput.value = false;
+      return;
+    }
+
+    if (cameraGroupList.value.find((x) => x.groupName === inputNewGroupName.value && x.id !== props.cameraGroup.id)) {
+      showRenameGroupInput.value = false;
+      return ElMessage({ message: '区域名已存在', type: 'error' });
+    }
+
+    props.cameraGroup.groupName = inputNewGroupName.value;
+    groupOriginName = inputNewGroupName.value;
+    showRenameGroupInput.value = false;
+    modifyCameraGroupApi({
+      groupId: props.cameraGroup.id,
+      groupName: inputNewGroupName.value,
+    });
+  }
+
+  function handelStartPlay() {
+    // 如果点击的是其他区域的轮播按钮
+    const changeGroup = props.cameraGroup.id !== playingGroup.value?.id;
+    if (isPlaying.value) {
+      ElMessageBox.confirm(changeGroup ? '是否切换播放区域' : '是否取消当前区域相机播放', '提示', {
+        cancelButtonText: '取消',
+        confirmButtonText: '确定',
+        customClass: 'customMessageBox--warning',
+      })
+        .then(() => {
+          if (changeGroup) {
+            isPlaying.value = false;
+            stopPlay(playingGroup.value?.id!);
+            playClickedGroup();
+          } else {
+            stopPlay(props.cameraGroup.id, true);
+          }
+        })
+        .catch(() => {
+          return;
+        });
+    } else {
+      playClickedGroup();
+    }
+  }
+
+  function playClickedGroup() {
+    isPlaying.value = true;
+    setPlayGroup(props.cameraGroup);
+    playIntervalTime.value = props.cameraGroup.playIntervalSec ? props.cameraGroup.playIntervalSec : 60;
+    isPaused.value = Boolean(props.cameraGroup.isPaused);
+    changeGridType(GridType.nineGrids);
+    groupStartPlay(playIntervalTime.value);
+    modifyCameraGroupApi({
+      groupId: props.cameraGroup.id,
+      isDefault: 1,
+    });
+  }
+
+  function addCamera() {
+    showCameraTreeDialog.value = true;
+  }
+
+  function handleDelete(cameraGroup: CameraGroupType) {
+    const text = '删除后,区域数据不可恢复,需要重新添加,是否确认删除该区域?';
+    ElMessageBox.confirm(text, '提示', {
+      cancelButtonText: '取消',
+      confirmButtonText: '确定',
+      customClass: 'customMessageBox--warning',
+    })
+      .then(() => {
+        deleteCameraGroup(cameraGroup.id);
+      })
+      .catch(() => {
+        return;
+      });
+  }
+
+  watch(
+    () => showGroupOperation.value,
+    (newValue) => {
+      newValue
+        ? document.addEventListener('mousedown', closeGroupOperation)
+        : document.removeEventListener('mousedown', closeGroupOperation);
+    },
+  );
+</script>
+
+<style lang="scss" scoped>
+  .cameraGroup {
+    margin-bottom: 1px;
+    .cameraGroupTitle {
+      height: 100%;
+      width: 100%;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      color: #333333;
+      border-radius: 4px;
+      position: relative;
+      cursor: default;
+      .arrowIcon {
+        height: 16px;
+        width: 16px;
+        margin-right: 12px;
+        display: flex;
+        align-items: center;
+      }
+      .renameGroupInput {
+        height: 32px;
+        width: 100%;
+        display: flex;
+        align-items: center;
+        background-color: #1777ff;
+        border-radius: 4px;
+        font-size: 14px;
+        line-height: 24px;
+        padding-right: 6px;
+
+        .folderIcon {
+          height: 24px;
+          margin-right: 12px;
+        }
+        .GroupNameInput {
+          height: 24px;
+        }
+      }
+      .IconAndGroupName {
+        display: flex;
+        align-items: center;
+        .folderImg {
+          height: 24px;
+          width: 24px;
+          margin-right: 12px;
+        }
+        .groupName {
+          height: 48px;
+          white-space: nowrap;
+        }
+      }
+      .groupOperationBar {
+        visibility: hidden;
+        margin-left: 10px;
+        margin-right: 10px;
+        .groupOperationIcon {
+          height: 20px;
+          width: 20px;
+          &:hover {
+            background-color: rgba(255, 255, 255, 0.2);
+            border-radius: 4px;
+            cursor: pointer;
+          }
+        }
+      }
+
+      &:hover .groupOperationBar {
+        visibility: visible;
+        display: flex;
+        align-items: center;
+      }
+
+      .groupOperation {
+        display: block;
+        height: 63px;
+        width: 130px;
+        position: absolute;
+        bottom: -60px;
+        right: 5px;
+        box-shadow: 0px 0px 10px 2px rgb(0, 0, 0, 0.3);
+        border-radius: 4px;
+        background: #f4f7ff;
+        outline: 1px solid rgba(245, 248, 255, 0.25);
+        z-index: 999;
+        .groupOperationItem {
+          height: 31.5px;
+          line-height: 31.5px;
+          border-radius: 4px;
+          &:hover {
+            background-color: #c0c4d2;
+            cursor: pointer;
+          }
+        }
+      }
+    }
+
+    .playingCameraGroup {
+      background-color: #1777ff;
+    }
+
+    .cameraGroupTitle:not(.playingCameraGroup):hover {
+      border-radius: 4px;
+      background-color: #c0c4d2;
+    }
+
+    .addCamera {
+      height: 32px;
+      width: fit-content;
+      display: flex;
+      align-items: center;
+      margin-left: 74px;
+      font-size: 14px;
+      line-height: 32px;
+      color: #333333;
+      text-align: left;
+      white-space: nowrap;
+      cursor: pointer;
+
+      .addCameraIcon {
+        margin-right: 14px;
+        position: relative;
+        display: flex;
+        align-items: center;
+
+        img {
+          height: 20px;
+          width: 20px;
+        }
+
+        &::after {
+          content: '+';
+          position: absolute;
+          left: 0;
+          right: 0;
+          font-size: 20px;
+          font-weight: 500;
+          color: #1777ff;
+          text-align: center;
+          z-index: 1;
+        }
+      }
+    }
+  }
+
+  :deep(.el-collapse-item__header:has(.playingCameraGroup)) {
+    background-color: #1777ff;
+    border-radius: 4px;
+
+    .groupName,
+    svg {
+      color: #fff;
+    }
+    img {
+      content: url('@/assets/icons/nine-square-grid/folder-white.png');
+    }
+  }
+
+  :deep(.el-collapse-item__header:not(.el-collapse-item__header:has(.playingCameraGroup)):hover) {
+    background-color: #c0c4d2;
+    border-radius: 4px;
+  }
+
+  :deep(.el-collapse-item__header:not(.el-collapse-item__header:has(.playingCameraGroup)):hover .cameraGroupTitle) {
+    background-color: #c0c4d2;
+    border-radius: 4px;
+  }
+
+  :deep(.el-dialog__header) {
+    text-align: left;
+  }
+
+  :deep(.el-dialog__body) {
+    padding: 0px 20px 20px 20px;
+  }
+</style>

+ 200 - 0
src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupList/CameraGroupList.vue

@@ -0,0 +1,200 @@
+<template>
+  <div class="cameraGroups__decoration"></div>
+
+  <div class="cameraGroups">
+    <div class="createGroup" @click="handleCreateGroup">
+      <div class="createGroupIcon">
+        <img src="@/assets/icons/nine-square-grid/add.png" />
+      </div>
+      <div>添加重点监控区域</div>
+    </div>
+
+    <div class="createGroupInput" v-show="showCreateGroupInput">
+      <img class="folderIcon" src="@/assets/icons/nine-square-grid/folder.png" />
+      <el-input
+        v-model="inputNewGroupName"
+        class="GroupNameInput"
+        placeholder="为此区域命名"
+        maxlength="15"
+        show-word-limit
+        ref="groupNameInputRef"
+        @blur="handleEnterGroupName()"
+        @keyup.enter="$event.target.blur()"
+      />
+    </div>
+
+    <div class="groupList">
+      <el-scrollbar>
+        <el-collapse v-model="activeGroup">
+          <div v-for="cameraGroup in cameraGroupList" :key="cameraGroup.id">
+            <CameraGroup :cameraGroup="cameraGroup" :activeGroup="activeGroup" />
+          </div>
+        </el-collapse>
+      </el-scrollbar>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, onMounted, nextTick } from 'vue';
+  import { ElInput, ElScrollbar, ElCollapse } from 'element-plus';
+  import CameraGroup from './CameraGroup.vue';
+  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import { storeToRefs } from 'pinia';
+
+  const activeGroup = ref(['']);
+  const showCreateGroupInput = ref(false);
+  const inputNewGroupName = ref('');
+  const groupNameInputRef = ref();
+  const { cameraGroupList } = storeToRefs(useCameraGroupList());
+  const { getCameraGroupList, createCameraGroup } = useCameraGroupList();
+
+  function handleCreateGroup() {
+    showCreateGroupInput.value = !showCreateGroupInput.value;
+    if (showCreateGroupInput.value) {
+      checkGroupName();
+
+      nextTick(() => {
+        groupNameInputRef.value.focus();
+      });
+    }
+  }
+
+  function handleEnterGroupName() {
+    checkGroupName();
+    createCameraGroup(inputNewGroupName.value);
+    activeGroup.value = [inputNewGroupName.value]; // 添加区域后展开区域
+    inputNewGroupName.value = '';
+    showCreateGroupInput.value = false;
+  }
+
+  function checkGroupName() {
+    if (inputNewGroupName.value === '') {
+      let maxSuffix = 0;
+      cameraGroupList.value.map((x) => {
+        if (x.groupName.startsWith('新建区域')) {
+          const suffix = Number(x.groupName.replace('新建区域', ''));
+          // 过滤区域名后缀不是数字的区域
+          if (!isNaN(suffix)) {
+            if (suffix > maxSuffix) {
+              maxSuffix = suffix;
+            }
+          }
+        }
+      });
+
+      if (maxSuffix !== 0) {
+        const newDefaultGroupNameIndex = maxSuffix + 1;
+
+        inputNewGroupName.value = '新建区域' + newDefaultGroupNameIndex;
+      } else {
+        inputNewGroupName.value = '新建区域1';
+      }
+    }
+  }
+
+  onMounted(() => {
+    getCameraGroupList();
+  });
+</script>
+
+<style lang="scss" scoped>
+  .cameraGroups__decoration {
+    height: 5px;
+    margin-bottom: 20px;
+    border-radius: 8px 8px 0 0;
+    background-color: #1777ff;
+  }
+
+  .cameraGroups {
+    height: calc(100% - 25px);
+    color: #333333;
+    padding: 0px 16px;
+    padding-right: 4px;
+    transition: all 0.3s ease;
+
+    .createGroup {
+      height: 24px;
+      width: 150px;
+      display: flex;
+      margin-bottom: 12px;
+      color: #333333;
+      font-size: 14px;
+      line-height: 24px;
+      cursor: pointer;
+
+      .createGroupIcon {
+        margin-right: 12px;
+        position: relative;
+
+        img {
+          height: 24px;
+          width: 24px;
+        }
+
+        &::after {
+          content: '+';
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          font-size: 20px;
+          font-weight: 500;
+          color: #1777ff;
+          text-align: center;
+        }
+      }
+    }
+
+    .createGroupInput {
+      height: 32px;
+      display: flex;
+      align-items: center;
+      background-color: #1677ff;
+      border-radius: 4px;
+      margin-bottom: 12px;
+      padding-right: 6px;
+      color: #333333;
+      font-size: 14px;
+      line-height: 24px;
+      .folderIcon {
+        height: 24px;
+        margin-right: 12px;
+      }
+      .GroupNameInput {
+        height: 24px;
+      }
+    }
+    .groupList {
+      height: 80%;
+
+      padding: 0 0px;
+      overflow: auto;
+    }
+  }
+
+  :deep(.el-collapse) {
+    border: 0px;
+  }
+  :deep(.el-collapse-item__arrow) {
+    margin-right: 12px;
+    color: #333333;
+  }
+
+  :deep(.el-collapse-item__wrap) {
+    background: transparent;
+    border-bottom: transparent;
+  }
+  :deep(.el-collapse-item__content) {
+    padding: 0px;
+    margin-right: 10px;
+  }
+  :deep(.el-collapse-item__header) {
+    background: #f4f7ff;
+    border: 0px;
+    flex-direction: row-reverse;
+    padding-left: 12px;
+    margin-right: 10px;
+  }
+</style>

+ 199 - 0
src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupList/CameraListOfGroup.vue

@@ -0,0 +1,199 @@
+<template>
+  <div ref="dragRef">
+    <div class="camera" v-for="camera in cameraGroup.children" :key="camera.cameraGroupDetailId">
+      <div class="IconAndCameraName">
+        <div class="cameraIcon">
+          <WarningFilled
+            class="invalidCameraIcon"
+            style="color: red"
+            v-if="!carouselList.includes(camera) && playingGroup?.id === cameraGroup.id"
+          />
+          <img
+            class="cameraImg"
+            src="@/assets/icons//nine-square-grid/camera.png"
+            v-if="!cameraInPlay.includes(camera)"
+          />
+          <img class="cameraImg" src="@/assets/icons/nine-square-grid/playingCamera.png" v-else />
+        </div>
+
+        <div
+          class="cameraName"
+          :class="{
+            playingCamera: cameraInPlay.includes(camera),
+            invalidCameraName: !carouselList.includes(camera) && playingGroup?.id === cameraGroup.id,
+          }"
+        >
+          {{ camera.name }}
+        </div>
+      </div>
+
+      <div class="cameraOperation">
+        <img
+          class="cameraOperationIcon"
+          src="@/assets/icons/nine-square-grid/icon-delete.png"
+          @click.stop="handleDelete(cameraGroup, camera)"
+        />
+      </div>
+
+      <Thumbnail :imageUrl="camera.imageUrl" :code="camera.code" position="right">
+        <div class="mask"></div>
+      </Thumbnail>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, watch } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import type { Camera, CameraGroupType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
+  import { updateCameraOrderApi } from '@/api/nine-square-grid';
+  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import { WarningFilled } from '@element-plus/icons-vue';
+  import { ElMessageBox } from 'element-plus';
+  import { useDraggable } from 'vue-draggable-plus';
+  import Thumbnail from '@/components/thumbnail/Thumbnail.vue';
+
+  const props = defineProps<{
+    cameraGroup: CameraGroupType;
+  }>();
+  const dragRef = ref<HTMLElement | null>(null);
+  const list = ref<Camera[]>([]);
+
+  const { carouselList, playingGroup, cameraInPlay } = storeToRefs(useCameraGroupList());
+
+  const { deleteCameraFromGroup, deleteCameraInPlaylist, restartPlay } = useCameraGroupList();
+
+  function handleDelete(cameraGroup: CameraGroupType, camera: Camera) {
+    const text = '删除后,相机数据不可恢复,是否确认删除?';
+    ElMessageBox.confirm(text, '提示', {
+      cancelButtonText: '取消',
+      confirmButtonText: '确定',
+      type: 'warning',
+      center: false,
+      lockScroll: false,
+    })
+      .then(() => {
+        if (cameraInPlay.value.includes(camera)) {
+          deleteCameraInPlaylist(cameraGroup, camera);
+        } else {
+          deleteCameraFromGroup(cameraGroup, camera);
+          restartPlay();
+        }
+      })
+      .catch(() => {
+        return;
+      });
+  }
+
+  watch(
+    () => props.cameraGroup.children,
+    (children) => {
+      list.value = children;
+    },
+    { immediate: true, deep: true },
+  );
+
+  useDraggable(dragRef, list, {
+    animation: 150,
+    ghostClass: 'ghost',
+    onUpdate() {
+      const updateIds = list.value.map((x, index) => {
+        return {
+          groupId: props.cameraGroup.id,
+          cameraId: x.id,
+          orderNum: index + 1,
+        };
+      });
+      props.cameraGroup.children = list.value;
+      updateCameraOrderApi(updateIds);
+      restartPlay();
+    },
+  });
+</script>
+
+<style lang="scss" scoped>
+  .camera {
+    height: 32px;
+    width: 100%;
+    white-space: nowrap;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding-left: 72px;
+    font-size: 14px;
+    line-height: 32px;
+    color: #333333;
+    text-align: left;
+    background: #f4f7ff;
+    position: relative;
+    &:hover {
+      background-color: #c0c4d2;
+    }
+    &:hover .cameraOperation {
+      visibility: visible;
+      display: flex;
+      align-items: center;
+    }
+
+    .IconAndCameraName {
+      display: flex;
+      align-items: center;
+      .cameraIcon {
+        display: flex;
+        align-items: center;
+        position: relative;
+        .cameraImg {
+          height: 24px;
+          width: 24px;
+          margin-right: 12px;
+        }
+        .invalidCameraIcon {
+          height: 14px;
+          color: #dd5869;
+          position: absolute;
+          right: 6px;
+          top: 0px;
+        }
+      }
+      .cameraName {
+        width: fit-content;
+        position: relative;
+      }
+      .invalidCameraName {
+        color: rgba(235, 237, 241, 0.25);
+      }
+    }
+
+    .cameraOperation {
+      visibility: hidden;
+      margin-right: 4px;
+      .cameraOperationIcon {
+        height: 20px;
+        width: 20px;
+        margin-left: 10px;
+        padding: 2px;
+        z-index: 999;
+        &:hover {
+          background-color: rgba(255, 255, 255, 0.2);
+          border-radius: 4px;
+          cursor: pointer;
+        }
+      }
+    }
+
+    .mask {
+      height: 32px;
+      width: 100%;
+    }
+  }
+
+  :deep(.thumb-nail) {
+    display: block;
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 9;
+  }
+</style>

+ 227 - 0
src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupList/CameraTreeOfGroupList.vue

@@ -0,0 +1,227 @@
+<template>
+  <div class="cameraTreeWrapper">
+    <!-- <div class="cameraTreeTitle">
+      <span style="color: black">场景树</span>
+    </div> -->
+    <div class="cameraTreeInputWrapper">
+      <el-input
+        v-model="filterText"
+        placeholder="请输入相机的名称进行搜索"
+        :suffix-icon="Search"
+        class="filterTextInput"
+      />
+    </div>
+    <el-scrollbar class="tree-scroll">
+      <el-tree
+        :data="cameraTree"
+        :props="defaultProps"
+        @node-click="handleClickCamera"
+        node-key="code"
+        :default-expand-all="false"
+        :default-expanded-keys="cameraTree"
+        :filter-node-method="filterNode"
+        ref="treeRef"
+        v-loading="treeLoading"
+        :empty-text="treeEmptyText"
+        element-loading-background="rgba(0, 0, 0, 0)"
+      >
+        <template #default="{ node, data }">
+          <div
+            class="treeNode"
+            :class="{
+              selectedCamera: isSelected(data.id),
+            }"
+          >
+            <div v-if="data.nodeType === CameraTreeNodeType.camera" class="icons">
+              <VideoCamera class="cameraIcon" />
+              <WarningFilled v-if="isInvalid(data)" class="invalidCamera" style="color: red" />
+            </div>
+
+            <div class="cameraName">
+              {{ node.label }}
+            </div>
+
+            <Thumbnail
+              v-if="data.nodeType === CameraTreeNodeType.camera"
+              :imageUrl="data.imageUrl"
+              :code="data.code"
+              position="right"
+            >
+              <div class="mask"></div>
+            </Thumbnail>
+          </div>
+        </template>
+      </el-tree>
+    </el-scrollbar>
+  </div>
+</template>
+<script setup lang="ts">
+  import { ElInput, ElTree, ElScrollbar } from 'element-plus';
+  import { onMounted, ref, watch } from 'vue';
+  import { VideoCamera, WarningFilled, Search } from '@element-plus/icons-vue';
+  import { CameraTreeNodeType, getCameraTree } from '@/api/camera/camera-preview';
+  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import type { CameraGroupType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
+  import { getVideoRenderUrlKey } from '../../utils';
+  import Thumbnail from '@/components/thumbnail/Thumbnail.vue';
+
+  interface Tree {
+    [key: string]: any;
+  }
+
+  const defaultProps = {
+    children: 'children',
+    label: 'name',
+  };
+
+  const cameraTree = ref();
+  const filterText = ref('');
+  const treeRef = ref<InstanceType<typeof ElTree>>();
+  const childrenNodeList = ref<string[]>([]);
+  const treeLoading = ref(false);
+  const treeEmptyText = ref('');
+  const props = defineProps<{ cameraGroup: CameraGroupType }>();
+
+  const { addCameraIntoGroup, deleteCameraFromGroup } = useCameraGroupList();
+
+  const isSelected = (id: number) => {
+    return props.cameraGroup.children.find((x) => x.id === id);
+  };
+
+  const handleClickCamera = (nodeData) => {
+    // // 如果点击的不是摄像头或是已失效相机
+    // if (nodeData.nodeType !== CameraTreeNodeType.camera || nodeData.disable)
+
+    // 如果点击的不是摄像头
+    if (nodeData.nodeType !== CameraTreeNodeType.camera) return;
+
+    const cameraInGroup = props.cameraGroup.children.find((x) => x.id === nodeData.id);
+
+    // 如果该摄像头在分组里,则从分组移除该摄像头,否则添加该摄像头进分组
+    if (cameraInGroup) {
+      deleteCameraFromGroup(props.cameraGroup, cameraInGroup);
+    } else {
+      const camera = {
+        code: nodeData.code,
+        name: nodeData.name,
+        url: nodeData[getVideoRenderUrlKey()],
+        id: nodeData.id,
+        cameraGroupDetailId: -1,
+        imageUrl: '',
+      };
+      addCameraIntoGroup(props.cameraGroup.id, camera);
+    }
+  };
+
+  watch(filterText, (val) => {
+    childrenNodeList.value = [];
+    treeRef.value!.filter(val);
+  });
+
+  function extractCodes(data: any[], codes: string[] = []) {
+    data.forEach((item) => {
+      codes.push(item.data.code);
+      if (item.childNodes) {
+        extractCodes(item.childNodes, codes);
+      }
+    });
+    return codes;
+  }
+
+  const filterNode = (value: string, data: Tree, node) => {
+    if (!value) return true;
+    // 检查当前节点的 label 是否包含关键词
+    const labelMatch = data.name.includes(value);
+
+    if (labelMatch) {
+      if (node.childNodes && node.childNodes.length > 0) {
+        childrenNodeList.value = extractCodes(node.childNodes, []);
+      }
+    }
+
+    if (childrenNodeList.value.includes(data.code)) {
+      return true;
+    } else {
+      return labelMatch;
+    }
+  };
+
+  const isInvalid = (data) => {
+    if (data.networkingState !== 0) data.disable = true;
+    return data.networkingState !== 0;
+  };
+
+  onMounted(() => {
+    treeLoading.value = true;
+    getCameraTree()
+      .then((res) => {
+        cameraTree.value = res;
+        if (cameraTree.value.length === 0) treeEmptyText.value = '暂无数据';
+      })
+      .catch(() => {
+        treeEmptyText.value = '暂无数据';
+      })
+      .finally(() => {
+        treeLoading.value = false;
+      });
+  });
+</script>
+<style lang="scss" scoped>
+  .cameraTreeWrapper {
+    .cameraTreeInputWrapper {
+      padding: 8px;
+      .filterTextInput {
+        margin: 8px 0;
+      }
+    }
+
+    .tree-scroll {
+      height: 400px;
+      .selectedCamera {
+        color: #1777ff;
+      }
+      .treeNode {
+        display: flex;
+        align-items: center;
+        // position: relative;
+        .icons {
+          display: flex;
+          align-items: center;
+          position: relative;
+          .cameraIcon {
+            height: 18px;
+            margin-right: 8px;
+            color: black;
+          }
+          .invalidCamera {
+            height: 14px;
+            color: #dd5869;
+            position: absolute;
+            right: 0px;
+            top: -5px;
+          }
+        }
+        .cameraName {
+        }
+      }
+      .mask {
+        height: 26px;
+        width: 100%;
+      }
+    }
+  }
+
+  :deep(.thumb-nail) {
+    display: block;
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 99;
+  }
+
+  :deep(.el-tree-node__content) {
+    position: relative;
+  }
+</style>

+ 59 - 0
src/views/disaster/monitor/splitScreenRetrieval/CameraGroupListAndTree/CameraGroupListAndTree.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="cameraGroupContainer">
+    <div class="CameraGroupList" :class="{ collapsed: isCollapse }">
+      <CameraGroupList />
+    </div>
+
+    <div class="toggle-button-wrapper" @click="isCollapse = !isCollapse">
+      <div class="collapse-button" :class="{ collapsed: isCollapse === false }"></div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import CameraGroupList from './CameraGroupList/CameraGroupList.vue';
+
+  const isCollapse = ref(false);
+</script>
+
+<style lang="scss" scoped>
+  .cameraGroupContainer {
+    height: 100%;
+    width: 100%;
+    display: flex;
+    align-items: center;
+    .CameraGroupList {
+      height: 100%;
+      width: 100%;
+      background: #f4f7ff;
+      overflow: hidden;
+      transition: all 0.3s ease;
+    }
+
+    .toggle-button-wrapper {
+      @include flex-center;
+      width: 15cpx;
+      height: 75cpx;
+      margin-right: 5px;
+      z-index: 10;
+      clip-path: polygon(0 0, 100% 10cpx, 100% 65cpx, 0 75cpx);
+      background-color: $primary-color;
+      cursor: pointer;
+
+      .collapse-button {
+        border-style: solid;
+        border-width: 5cpx 0 5cpx 8cpx;
+        border-color: transparent transparent transparent #fff;
+        transform: rotate(180deg);
+
+        &.collapsed {
+          transform: rotate(0deg);
+        }
+      }
+    }
+    .collapsed {
+      width: 0;
+    }
+  }
+</style>

+ 48 - 0
src/views/disaster/monitor/splitScreenRetrieval/SplitScreenRetrieval.vue

@@ -0,0 +1,48 @@
+<template>
+  <div class="nine-square-grid">
+    <div class="leftSideBar">
+      <CameraGroupListAndTree />
+    </div>
+
+    <div class="toolbarAndCamerasGrid">
+      <VideosGridBase :cameraInPlay="cameraInPlay" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted } from 'vue';
+  import CameraGroupListAndTree from './CameraGroupListAndTree/CameraGroupListAndTree.vue';
+  import VideosGridBase from './VideosGridBase/VideosGridBase.vue';
+  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import { storeToRefs } from 'pinia';
+  import { useTargetTenantIdStore } from '@/store/modules/useTargetTenantIdStore';
+  import { useCameraStatus } from './hooks/useCameraStatus';
+
+  const { cameraInPlay } = storeToRefs(useCameraGroupList());
+
+  const { clear } = useTargetTenantIdStore();
+  useCameraStatus();
+
+  onMounted(() => {
+    clear();
+  });
+</script>
+
+<style lang="scss" scoped>
+  .nine-square-grid {
+    height: 100%;
+    padding: 14px 10px;
+    display: flex;
+    border-radius: 4px;
+    background: #fff;
+
+    .leftSideBar {
+      height: 100%;
+    }
+
+    .toolbarAndCamerasGrid {
+      width: 100%;
+    }
+  }
+</style>

+ 261 - 0
src/views/disaster/monitor/splitScreenRetrieval/VideosGridBase/CamerasGrid.vue

@@ -0,0 +1,261 @@
+<template>
+  <div class="main-grid" id="main-grid">
+    <div v-if="cameraInPlay.some((x) => x.url !== '')" class="video-grid" :class="setGridSize()" id="video-grid">
+      <div :id="`video-${index}`" v-for="(camera, index) in props.cameraInPlay" :key="camera.id" class="video-box">
+        <div class="LiveVideo" v-if="camera.url !== ''">
+          <LiveVideo
+            :url="getWsUrl(camera.url)"
+            :poster="camera.imageUrl"
+            @dblclick="isFullScreen ? exitFullscreen() : fullScreen(`video-${index}`, 'single')"
+          />
+        </div>
+
+        <div class="emptyCameraGrid" v-else>
+          <div class="emptyCameraImgAndText">
+            <img class="emptyCameraGridImg" src="@/assets/icons/nine-square-grid/cameraEmpty.png" />
+            <div>暂未接入相机</div>
+          </div>
+        </div>
+
+        <div class="video-controlBar" v-if="camera.url && !isFullScreen">
+          <div class="cameraName">{{ camera.name }}</div>
+          <div class="controlIconContainer">
+            <Delete class="controlIcon fullScreen" @click="handleDeleteCamera(camera)" />
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="allCameraEmpty" v-else>
+      <img class="cameraEmptyImg" src="@/assets/icons/nine-square-grid/cameraEmpty.png" />
+      <div>暂未接入相机</div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onUnmounted } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import LiveVideo from '@/components/live/LiveVideoFlv.vue';
+  import screenfull from 'screenfull';
+  import { Delete } from '@element-plus/icons-vue';
+  import urlJoin from 'url-join';
+  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import { userGridType } from '@/store/modules/userGridType';
+  import { GridType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
+  import { type Camera, CameraInPlay } from '../type';
+  import { userSplitScreenFullScreen } from '@/store/modules/userSplitScreenFullScreen';
+  import { ElMessageBox } from 'element-plus';
+
+  const props = defineProps<{ cameraInPlay: CameraInPlay[] }>();
+  const { currentGrid } = storeToRefs(userGridType());
+  const { isFullScreen, curFullScreenType } = storeToRefs(userSplitScreenFullScreen());
+
+  const { playingGroup } = storeToRefs(useCameraGroupList());
+  const { deleteCameraInPlaylist } = useCameraGroupList();
+  const { fullScreen, exitFullscreen } = userSplitScreenFullScreen();
+
+  function handleDeleteCamera(camera: Camera) {
+    ElMessageBox.confirm('删除后,相机数据不可恢复,是否确认删除?', '提示', {
+      cancelButtonText: '取消',
+      confirmButtonText: '确定',
+      type: 'warning',
+      center: false,
+    })
+      .then(() => {
+        deleteCameraInPlaylist(playingGroup.value!, camera);
+      })
+      .catch(() => {
+        return;
+      });
+  }
+
+  const isHttps = () => {
+    return window.location.protocol.startsWith('https');
+  };
+
+  const getWsUrl = (videoUrl: string) => {
+    if (!videoUrl) return '';
+    const protocol = isHttps() ? 'wss' : 'ws';
+    // 如果是绝对地址
+    if (videoUrl.startsWith('http')) {
+      // 如果是https的话,websocket要用wss
+      return videoUrl.replace('http', protocol);
+    }
+    const u = urlJoin(
+      `${protocol}://`,
+      window.location.host,
+      window.location.pathname === '/' ? '' : window.location.pathname,
+      videoUrl,
+    );
+
+    console.log('u', u);
+
+    return u;
+  };
+
+  // 根据当前宫格数设置每个宫格宽高
+  const setGridSize = () => {
+    if (currentGrid.value === GridType.oneGrid) return 'oneGrid';
+    if (currentGrid.value === GridType.fourGrids) return 'fourGrid';
+    if (currentGrid.value === GridType.nineGrids) return 'nineGrids';
+    if (currentGrid.value === GridType.sixteenGrids) return 'sixteenGrids';
+  };
+
+  // 全屏之后,无法监听到键盘按键的点击事件,所以只能监听窗口的变化进行判断
+  window.onresize = () => {
+    if (!screenfull.isFullscreen) {
+      isFullScreen.value = false; //判断退出全屏,进行赋值
+      curFullScreenType.value = 'single';
+    }
+  };
+
+  onUnmounted(() => {
+    window.onresize = null;
+  });
+</script>
+
+<style lang="scss" scoped>
+  .main-grid {
+    height: calc(100% - 54px);
+    background: #f4f7ff;
+    padding: 0 10px 10px 10px;
+    border-radius: 0 0 4px 4px;
+    .video-grid {
+      height: 100%;
+      display: grid;
+      gap: 4px;
+
+      .video-box {
+        position: relative;
+        background: #fff;
+        .LiveVideo {
+          width: 100%;
+          height: 100%;
+          video {
+            object-fit: fill;
+          }
+        }
+        &:hover {
+          .video-controlBar {
+            display: flex;
+          }
+        }
+        .emptyCameraGrid {
+          height: 100%;
+          font-size: 12px;
+          color: #ebedf1;
+          margin: auto;
+          text-align: center;
+          .emptyCameraImgAndText {
+            margin: auto;
+            color: #999999;
+            .emptyCameraGridImg {
+              height: 50%;
+              width: 40%;
+            }
+          }
+        }
+        .video-controlBar {
+          position: absolute;
+          display: none;
+          bottom: 10px;
+          left: 0;
+          width: 100%;
+          justify-content: space-between;
+          padding: 0 10px;
+
+          .video-name {
+            background-color: rgba(255, 255, 255, 0.7);
+            padding: 0px 5px;
+            border-radius: 5px;
+            font-size: 12px;
+            color: black;
+          }
+          .controlIconContainer {
+            display: flex;
+            justify-content: space-around;
+            align-items: center;
+
+            border-radius: 16px;
+            padding: 0 16px;
+            background: rgba(0, 0, 0, 0.4);
+            .controlIcon {
+              height: 20px;
+              color: #fff;
+            }
+            .fullScreen {
+              height: 18px;
+              width: 18px;
+            }
+            .controlIcon:hover {
+              color: #1777ff;
+              cursor: pointer;
+            }
+          }
+        }
+        .videoDisconnected {
+          height: 20%;
+          width: 30%;
+          position: absolute;
+          color: white;
+          font-size: 24px;
+          top: 0;
+          bottom: 0;
+          left: 0;
+          right: 0;
+          margin: auto;
+        }
+      }
+      .selectedVideo {
+        outline: 4px;
+        outline-style: solid;
+        outline-color: #a1a2a2;
+      }
+    }
+
+    .oneGrid {
+      grid-template-columns: repeat(1, 100%);
+      grid-template-rows: repeat(1, 100%);
+    }
+
+    .fourGrid {
+      grid-template-columns: repeat(2, calc(50% - 2px));
+      grid-template-rows: repeat(2, calc(50% - 2px));
+    }
+
+    .nineGrids {
+      grid-template-columns: repeat(3, calc(33.3% - 2px));
+      grid-template-rows: repeat(3, calc(33.3% - 2px));
+    }
+
+    .sixteenGrids {
+      gap: 2px;
+      grid-template-columns: repeat(4, calc(25% - 1px));
+      grid-template-rows: repeat(4, calc(25% - 1px));
+    }
+
+    .allCameraEmpty {
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      align-items: center;
+      margin: auto;
+      color: #999999;
+      .cameraEmptyImg {
+        height: 350px;
+        width: 350px;
+      }
+    }
+  }
+
+  .cameraName {
+    border-radius: 16px;
+    height: 32px;
+    line-height: 32px;
+    padding: 0 16px;
+    background: rgba(0, 0, 0, 0.4);
+    color: #fff;
+  }
+</style>

+ 329 - 0
src/views/disaster/monitor/splitScreenRetrieval/VideosGridBase/ScreenToolbar.vue

@@ -0,0 +1,329 @@
+<template>
+  <div class="control-panel">
+    <div class="changeGridBar">
+      <div class="changeGrid">
+        <el-tooltip class="box-item" effect="dark" content="一屏" placement="bottom">
+          <img
+            src="@/assets/icons/nine-square-grid/oneGrid.png"
+            class="changeGridIcon"
+            :class="currentGrid === GridType.oneGrid ? 'selectedGridIcon' : ''"
+            @click="changeGridType(GridType.oneGrid)"
+          />
+        </el-tooltip>
+      </div>
+
+      <div class="changeGrid">
+        <el-tooltip effect="dark" content="四屏" placement="bottom">
+          <img
+            src="@/assets/icons/nine-square-grid/fourGrids.png"
+            class="changeGridIcon"
+            :class="currentGrid === GridType.fourGrids ? 'selectedGridIcon' : ''"
+            @click="changeGridType(GridType.fourGrids)"
+          />
+        </el-tooltip>
+      </div>
+
+      <div class="changeGrid">
+        <el-tooltip class="box-item" effect="dark" content="九屏" placement="bottom">
+          <img
+            src="@/assets/icons/nine-square-grid/nineGrids.png"
+            class="changeGridIcon"
+            :class="currentGrid === GridType.nineGrids ? 'selectedGridIcon' : ''"
+            @click="changeGridType(GridType.nineGrids)"
+          />
+        </el-tooltip>
+      </div>
+
+      <div class="changeGrid">
+        <el-tooltip class="box-item" effect="dark" content="十六屏" placement="bottom">
+          <img
+            src="@/assets/icons/nine-square-grid/sixteenGrids.png"
+            class="changeGridIcon"
+            :class="currentGrid === GridType.sixteenGrids ? 'selectedGridIcon' : ''"
+            @click="changeGridType(GridType.sixteenGrids)"
+          />
+        </el-tooltip>
+      </div>
+
+      <div>|</div>
+    </div>
+
+    <div class="controlBtns">
+      <div class="lockAndSetTime">
+        <div class="pausePlay" @click="handleClickLock">
+          <el-tooltip class="box-item" effect="dark" :content="isPaused ? '恢复轮播' : '取消轮播'" placement="bottom">
+            <div v-if="isPaused">
+              <img src="@/assets/icons/nine-square-grid/lock.png" />
+            </div>
+            <div v-else>
+              <img src="@/assets/icons/nine-square-grid/unlock.png" />
+            </div>
+          </el-tooltip>
+        </div>
+
+        <div class="setIntervalTime" v-show="isPlaying && !isPaused">
+          <div>相机轮播时间间隔:</div>
+          <el-input
+            v-model="playIntervalTime"
+            ref="inputRef"
+            placeholder="默认60"
+            class="intervalTimeInputBar"
+            @blur="handelStartPlay"
+            @keyup.enter="handelStartPlay"
+          >
+            <template #append>
+              <div>秒</div>
+            </template>
+          </el-input>
+        </div>
+      </div>
+
+      <div class="RoundAndFullScreen">
+        <div class="controlRound">
+          <div
+            class="previousRound"
+            :class="currentRound === 1 || currentRound === 0 ? 'disableChangeRound' : ''"
+            @click="playPreviousRound"
+          >
+            <el-tooltip class="box-item" effect="dark" content="上一轮" placement="bottom">
+              <ArrowLeft />
+            </el-tooltip>
+          </div>
+
+          <div class="rounds">
+            <div class="currentRound">{{ currentRound }}</div>
+            <div class="totalRound">/{{ totalRound }}</div>
+          </div>
+
+          <div
+            class="nextRound"
+            @click="playNextRound"
+            :class="currentRound === totalRound ? 'disableChangeRound' : ''"
+          >
+            <el-tooltip class="box-item" effect="dark" content="下一轮" placement="bottom">
+              <ArrowRight />
+            </el-tooltip>
+          </div>
+        </div>
+
+        <el-tooltip class="box-item" effect="dark" content="全屏" placement="bottom">
+          <img
+            src="@/assets/icons/nine-square-grid/fullScreen2.png"
+            class="fullScreenIcon"
+            @click="isFullScreen ? exitFullscreen() : fullScreen('video-grid', 'all')"
+          />
+        </el-tooltip>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, watch } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { ElInput, ElTooltip } from 'element-plus';
+  import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
+  import { userGridType } from '@/store/modules/userGridType';
+  import { GridType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
+  import { userSplitScreenFullScreen } from '@/store/modules/userSplitScreenFullScreen';
+  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import { modifyCameraGroupApi } from '@/api/nine-square-grid';
+  const inputRef = ref();
+
+  const { currentGrid } = storeToRefs(userGridType());
+  const { changeGridType } = userGridType();
+
+  const { isFullScreen } = storeToRefs(userSplitScreenFullScreen());
+  const { fullScreen, exitFullscreen } = userSplitScreenFullScreen();
+
+  const { playingGroup, isPlaying, isPaused, playIntervalTime, totalRound, currentRound } = storeToRefs(
+    useCameraGroupList(),
+  );
+  const { groupStartPlay, continuePlay, pausePlay, playPreviousRound, playNextRound } = useCameraGroupList();
+
+  function handelStartPlay() {
+    // 如果输入为0或非数字则返回
+    if (playIntervalTime.value === null || playIntervalTime.value === 0 || !Number(playIntervalTime.value)) {
+      inputRef.value.blur();
+      playIntervalTime.value = 60;
+      return;
+    }
+
+    inputRef.value.blur();
+    playingGroup.value!.playIntervalSec = Number(playIntervalTime.value);
+    groupStartPlay(Number(playIntervalTime.value));
+    // inputRef.value.focus();
+    modifyCameraGroupApi({
+      groupId: playingGroup.value?.id!,
+      playIntervalSec: playIntervalTime.value,
+    });
+  }
+
+  function handleClickLock() {
+    isPaused.value = !isPaused.value;
+    if (isPaused.value) {
+      pausePlay();
+    } else {
+      continuePlay();
+    }
+
+    if (isPlaying.value) {
+      playingGroup.value!.isPaused = isPaused.value;
+      modifyCameraGroupApi({
+        groupId: playingGroup.value?.id!,
+        isPaused: Number(isPaused.value),
+      });
+    }
+  }
+
+  watch(
+    () => isPlaying.value,
+    (newValue) => {
+      if (newValue) {
+        inputRef.value.focus();
+        inputRef.value.select();
+      }
+    },
+  );
+</script>
+
+<style scoped>
+  .control-panel {
+    height: 54px;
+    width: 100%;
+    background: #f4f7ff;
+    display: flex;
+    justify-content: space-between;
+    font-size: 12px;
+    border-radius: 4px 4px 0 0;
+
+    .changeGridBar {
+      display: flex;
+      align-items: center;
+      padding-left: 10px;
+      margin-right: 19px;
+      .changeGrid {
+        margin-right: 32px;
+        .changeGridIcon {
+          width: 25px;
+          color: white;
+        }
+        .changeGridIcon:hover {
+          outline: 1px solid #1777ff;
+          color: #1777ff;
+        }
+        .selectedGridIcon {
+          width: 25px;
+          background-color: rgba(97, 151, 226, 0.4);
+        }
+      }
+    }
+
+    .controlBtns {
+      width: 100%;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      .lockAndSetTime {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        .pausePlay {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          margin-right: 19px;
+          img {
+            height: 24px;
+            width: 24px;
+          }
+        }
+        .setIntervalTime {
+          width: 230px;
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          .intervalTimeInputBar {
+            margin-left: 10px;
+            width: 110px;
+          }
+        }
+      }
+
+      .RoundAndFullScreen {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        .controlRound {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          margin-right: 69px;
+          .previousRound,
+          .nextRound {
+            width: 15px;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            color: #000;
+            border-radius: 4px;
+            margin-right: 14px;
+            cursor: pointer;
+            &:hover {
+              background-color: rgba(255, 255, 255, 0.2);
+            }
+          }
+          .disableChangeRound {
+            color: #909399;
+            pointer-events: none;
+          }
+          .rounds {
+            display: flex;
+            line-height: 59px;
+            color: #ffffff;
+            font-weight: 400;
+            font-size: 20px;
+            margin-right: 14px;
+            .currentRound {
+              color: #1777ff;
+            }
+            .totalRound {
+              color: rgb(0, 0, 0, 0.3);
+            }
+          }
+        }
+
+        .fullScreenIcon {
+          width: 25px;
+          color: white;
+          margin-right: 20px;
+        }
+        .fullScreenIcon:hover {
+          color: #1777ff;
+        }
+      }
+    }
+  }
+
+  :deep(.el-input) {
+    background-color: #f4f7ff;
+  }
+  :deep(.el-input__inner) {
+    color: #333333;
+    border: 0px;
+  }
+  :deep(.el-input__wrapper) {
+    background-color: #f4f7ff;
+  }
+  :deep(.el-input-group__append) {
+    width: 0px;
+    background-color: #f4f7ff;
+  }
+  :deep(.el-input.is-disabled .el-input__wrapper) {
+    background-color: #f4f7ff;
+  }
+  :deep(.el-tooltip__trigger) {
+    outline: none;
+    border: none;
+  }
+</style>

+ 14 - 0
src/views/disaster/monitor/splitScreenRetrieval/VideosGridBase/VideosGridBase.vue

@@ -0,0 +1,14 @@
+<template>
+  <ScreenToolbar />
+  <CamerasGrid :cameraInPlay="props.cameraInPlay" />
+</template>
+
+<script setup lang="ts">
+  import ScreenToolbar from './ScreenToolbar.vue';
+  import CamerasGrid from './CamerasGrid.vue';
+  import { type CameraInPlay } from '../type';
+
+  const props = defineProps<{ cameraInPlay: CameraInPlay[] }>();
+</script>
+
+<style scoped></style>

+ 26 - 0
src/views/disaster/monitor/splitScreenRetrieval/hooks/parseData.ts

@@ -0,0 +1,26 @@
+import { CameraGroupListTypeBackEnd, Camera } from '../type';
+import { getVideoRenderUrlKey } from '../utils';
+
+export function parseCameraGroupChildren(groupListBackEnd: CameraGroupListTypeBackEnd) {
+  const groupList = groupListBackEnd as any;
+  for (const group of groupList) {
+    const newChildren: Camera[] = [];
+    group.children.map((x) => {
+      const cameraInfo = {} as Camera;
+      cameraInfo['id'] = x['id'];
+      cameraInfo['code'] = x['code'];
+      cameraInfo['name'] = x['name'];
+      cameraInfo.imageUrl = x.pushStreamDTO['imageUrl'];
+      // cameraInfo.tenantId = x.tenantId;
+      try {
+        cameraInfo['url'] = x.pushStreamDTO['videoUrls'][getVideoRenderUrlKey()];
+      } catch (error) {
+        cameraInfo['url'] = '';
+      }
+      newChildren.push(cameraInfo as Camera);
+    });
+    group.children = newChildren;
+  }
+
+  return groupList;
+}

+ 43 - 0
src/views/disaster/monitor/splitScreenRetrieval/hooks/useCameraStatus.ts

@@ -0,0 +1,43 @@
+// 查询相机的在线或者离线状态
+
+import { getCameraState } from '@/api/camera/camera';
+import useCameraGroupList from '@/store/modules/useCameraGroupList';
+import { onMounted, onUnmounted, watch } from 'vue';
+
+export const useCameraStatus = () => {
+  const cameraGroupStore = useCameraGroupList();
+  let timer = 0;
+
+  const queryCameraState = () => {
+    const cameraList = cameraGroupStore.playingGroup?.children || [];
+    if (cameraList.length === 0) return;
+    const cameraCodes = cameraList.map((x) => x.code);
+    getCameraState({ cameraCodeList: cameraCodes }).then((res) => {
+      if (res?.length !== cameraCodes.length) return;
+      cameraGroupStore.updateCameraStatus(res);
+    });
+  };
+
+  watch(
+    () => cameraGroupStore.playingGroup?.children || [],
+    (cameraList) => {
+      if (cameraList.length > 0) {
+        queryCameraState();
+      }
+    },
+    {
+      immediate: true,
+      deep: true,
+    },
+  );
+
+  onMounted(() => {
+    timer = window.setInterval(() => {
+      queryCameraState();
+    }, 1000 * 10);
+  });
+
+  onUnmounted(() => {
+    window.clearInterval(timer);
+  });
+};

+ 65 - 0
src/views/disaster/monitor/splitScreenRetrieval/type.ts

@@ -0,0 +1,65 @@
+export enum GridType {
+  oneGrid = 1,
+  fourGrids = 4,
+  nineGrids = 9,
+  sixteenGrids = 16,
+}
+// 轮播中的摄像头
+export type CameraInPlay = {
+  id: number;
+  cameraGroupDetailId: number; // 相机所属分组id
+  url: string;
+  name: string;
+  code: string;
+  // 相机的缩略图
+  imageUrl: string;
+};
+
+export type Camera = {
+  code: string;
+  name: string;
+  url: string;
+  id: number; // 相机id
+  cameraGroupDetailId: number; // 相机所属分组id
+  /** 相机的缩略图 */
+  imageUrl: string;
+  // /** 租户id */
+  // tenantId: number;
+};
+
+export type CameraGroupType = {
+  id: number;
+  groupName: string;
+  children: Camera[];
+  isDefault: number;
+  playIntervalSec: number;
+  isPaused: boolean;
+};
+
+// 后端返回的数据
+export type cameraWithDetailBackEnd = {
+  code: string;
+  name: string;
+  pushstreamIp: string;
+  pushstreamIpAbs: string;
+  id: number;
+};
+
+// 后端返回的数据
+export type CameraBackEnd = {
+  cameraGroupDetailId: number;
+  cameraWithDetail: cameraWithDetailBackEnd;
+};
+
+// 后端返回的数据
+export type CameraGroupTypeBackEnd = {
+  id: number;
+  groupName: string;
+  children: CameraBackEnd[];
+  isDefault: number;
+  playIntervalSec: number;
+  isPaused: number;
+};
+
+export type CameraGroupListType = CameraGroupType[];
+export type CameraGroupListTypeBackEnd = CameraGroupTypeBackEnd[];

+ 26 - 0
src/views/disaster/monitor/splitScreenRetrieval/utils.ts

@@ -0,0 +1,26 @@
+export const SKYEYE_VIDEO_PATH = 'skyeyeVideoPath';
+export const SKYEYE_VIDEO_PATH_MAP = {
+  // 看视频用绝对地址
+  abs: 'abs',
+  // 看视频用相对地址
+  relative: '',
+};
+
+const videoPath = window.localStorage.getItem(SKYEYE_VIDEO_PATH);
+
+export const getVideoRenderUrlKey = () => {
+  // // 总部内网地址的话,用绝对地址去访问
+  // if (isPrivateIp(location.host)) return "pushstreamIpAbs";
+  // // 其他用相对地址
+  // return "pushstreamIp";
+
+  if (videoPath === SKYEYE_VIDEO_PATH_MAP.abs) {
+    return 'pushstreamIpAbs';
+  } else {
+    return 'pushstreamIp';
+  }
+};
+
+// export const isPrivateIp = (host: string) => {
+//   return host.startsWith("10.11");
+// };

+ 1 - 0
utils/devProxy/staff/proxy.ts

@@ -6,6 +6,7 @@ const proxyStaff: PROXY_TYPE = {
   serverHost: 'http://192.168.13.68:8802/',
   loginHost: 'http://192.168.13.68:7200/login/#/',
   fileUploadHost: 'http://192.168.13.102:9000/',
+  push_stream_host: 'http://192.168.13.69:8080',
 };
 
 // 对外导出的代理

+ 2 - 0
utils/devProxy/types.ts

@@ -6,4 +6,6 @@ export interface PROXY_TYPE {
   loginHost: string;
   /** 文件存储服务minio */
   fileUploadHost: string;
+  /** 流媒体服务 */
+  push_stream_host: string;
 }