sunqijun 3 месяцев назад
Родитель
Сommit
917fa8b63c
13 измененных файлов с 1891 добавлено и 1 удалено
  1. 388 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/CameraGroupListAndTree/CameraGroupList/CameraGroup.vue
  2. 200 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/CameraGroupListAndTree/CameraGroupList/CameraGroupList.vue
  3. 198 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/CameraGroupListAndTree/CameraGroupList/CameraListOfGroup.vue
  4. 232 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/CameraGroupListAndTree/CameraGroupList/CameraTreeOfGroupList.vue
  5. 59 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/CameraGroupListAndTree/CameraGroupListAndTree.vue
  6. 258 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/VideosGridBase/CamerasGrid.vue
  7. 334 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/VideosGridBase/ScreenToolbar.vue
  8. 14 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/VideosGridBase/VideosGridBase.vue
  9. 26 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/hooks/parseData.ts
  10. 43 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/hooks/useCameraStatus.ts
  11. 48 1
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/index.vue
  12. 65 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/type.ts
  13. 26 0
      src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/utils.ts

+ 388 - 0
src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/CameraGroupListAndTree/CameraGroupList/CameraGroup.vue

@@ -0,0 +1,388 @@
+<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-white.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;
+    }
+
+    const camera = cameraGroupList.value.find((x) => x.groupName === inputNewGroupName.value);
+
+    try {
+      if (camera && camera.id === props.cameraGroup.id) return;
+      else if (camera && camera.id !== props.cameraGroup.id)
+        return ElMessage({ message: '已存在同名区域', type: 'error' });
+    } finally {
+      showRenameGroupInput.value = false;
+    }
+
+    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: 38px;
+      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-left: 12px;
+          margin-right: 12px;
+        }
+        .GroupNameInput {
+          height: 24px;
+        }
+      }
+      .IconAndGroupName {
+        display: flex;
+        align-items: center;
+        .folderImg {
+          height: 24px;
+          width: 24px;
+          margin-right: 12px;
+        }
+        .groupName {
+          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: -63px;
+        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 {
+            color: #fff;
+            background-color: #1777ff;
+            cursor: pointer;
+          }
+        }
+      }
+    }
+
+    .playingCameraGroup {
+      height: 38px;
+      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;
+          top: -7px;
+          font-size: 20px;
+          color: #1777ff;
+          text-align: center;
+          z-index: 1;
+        }
+      }
+    }
+
+    :deep(.el-collapse-item__header) {
+      height: 38px;
+    }
+  }
+
+  :deep(.el-collapse-item__header:has(.playingCameraGroup)) {
+    height: 38px;
+    background-color: #1777ff;
+    border-radius: 4px;
+
+    .groupName,
+    svg {
+      color: #fff;
+    }
+    .IconAndGroupName 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: #cdd8ff;
+    border-radius: 4px;
+  }
+
+  :deep(.el-collapse-item__header:not(.el-collapse-item__header:has(.playingCameraGroup)):hover .cameraGroupTitle) {
+    background-color: #cdd8ff;
+    border-radius: 4px;
+  }
+
+  :deep(.el-dialog__header) {
+    text-align: left;
+    font-weight: bold;
+  }
+
+  :deep(.el-dialog__body) {
+  }
+</style>

+ 200 - 0
src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/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-white.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: 120px;
+      display: flex;
+      margin-right: 12px;
+      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: -1px;
+          left: 0;
+          right: 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>

+ 198 - 0
src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/CameraGroupListAndTree/CameraGroupList/CameraListOfGroup.vue

@@ -0,0 +1,198 @@
+<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: '确定',
+      customClass: 'customMessageBox--warning',
+      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: #cdd8ff;
+    }
+    &: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: #999999;
+      }
+    }
+
+    .cameraOperation {
+      visibility: hidden;
+      margin-right: 4px;
+      .cameraOperationIcon {
+        height: 20px;
+        width: 20px;
+        margin-left: 10px;
+        padding: 2px;
+        z-index: 999;
+        &:hover {
+          background-color: rgb(197, 197, 197);
+          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>

+ 232 - 0
src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/CameraGroupListAndTree/CameraGroupList/CameraTreeOfGroupList.vue

@@ -0,0 +1,232 @@
+<template>
+  <div class="cameraTreeWrapper">
+    <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(node, 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 { storeToRefs } from 'pinia';
+  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 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 { cameraInPlay, playingGroup, totalRound } = storeToRefs(useCameraGroupList());
+  const { addCameraIntoGroup, deleteCameraFromGroup, deleteCameraInPlaylist, getValidateCameraNum, restartPlay } =
+    useCameraGroupList();
+
+  const isSelected = (nodeData, id: number) => {
+    return props.cameraGroup.children.find((x) => x.id === id && nodeData.data.nodeType === CameraTreeNodeType.camera);
+  };
+
+  const handleClickCamera = (nodeData) => {
+    // 如果点击的不是摄像头
+    if (nodeData.nodeType !== CameraTreeNodeType.camera) return;
+
+    const cameraInGroup = props.cameraGroup.children.find((x) => x.id === nodeData.id);
+
+    // 如果该摄像头在分组里,则从分组移除该摄像头,否则添加该摄像头进分组
+    if (cameraInGroup) {
+      cameraInPlay.value.includes(cameraInGroup)
+        ? deleteCameraInPlaylist(props.cameraGroup, cameraInGroup)
+        : deleteCameraFromGroup(props.cameraGroup, cameraInGroup);
+    } else {
+      const camera = {
+        code: nodeData.code,
+        name: nodeData.name,
+        url: nodeData.pushStreamDTO.videoUrls?.pushstreamIp || '',
+        id: nodeData.id,
+        cameraGroupDetailId: -1,
+        imageUrl: '',
+      };
+
+      addCameraIntoGroup(props.cameraGroup.id, camera);
+
+      // 如果往正在轮播的分组添加相机,且该分组的有效相机数未能填满宫格数,则刷新当前播放的画面
+      if (props.cameraGroup.id === playingGroup.value?.id && getValidateCameraNum() >= 0 && totalRound.value <= 1) {
+        restartPlay();
+      }
+    }
+  };
+
+  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 !important;
+      }
+      .treeNode {
+        display: flex;
+        align-items: center;
+        color: black;
+        // 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/production-safety/risk-identification-and-control/key-site-monitor-manage/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>

+ 258 - 0
src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/VideosGridBase/CamerasGrid.vue

@@ -0,0 +1,258 @@
+<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: '确定',
+      customClass: 'customMessageBox--warning',
+    })
+      .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,
+    );
+
+    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>

+ 334 - 0
src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/VideosGridBase/ScreenToolbar.vue

@@ -0,0 +1,334 @@
+<template>
+  <div class="control-panel">
+    <div class="changeGridBar">
+      <div class="changeGrid">
+        <el-tooltip class="box-item" effect="dark" content="一屏" placement="bottom">
+          <img
+            :src="currentGrid === GridType.oneGrid ? oneGrid_selected : oneGrid"
+            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="currentGrid === GridType.fourGrids ? fourGrids_selected : fourGrids"
+            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="currentGrid === GridType.nineGrids ? nineGrids_selected : nineGrids"
+            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="currentGrid === GridType.sixteenGrids ? sixteenGrids_selected : sixteenGrids"
+            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';
+  import oneGrid from '@/assets/icons/nine-square-grid/oneGrid.png';
+  import oneGrid_selected from '@/assets/icons/nine-square-grid/oneGrid-selected.png';
+  import fourGrids from '@/assets/icons/nine-square-grid/fourGrids.png';
+  import fourGrids_selected from '@/assets/icons/nine-square-grid/fourGrids-selected.png';
+  import nineGrids from '@/assets/icons/nine-square-grid/nineGrids.png';
+  import nineGrids_selected from '@/assets/icons/nine-square-grid/nineGrids-selected.png';
+  import sixteenGrids from '@/assets/icons/nine-square-grid/sixteenGrids.png';
+  import sixteenGrids_selected from '@/assets/icons/nine-square-grid/sixteenGrids-selected.png';
+
+  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 lang="scss" 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;
+        }
+      }
+    }
+
+    .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/production-safety/risk-identification-and-control/key-site-monitor-manage/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 lang="scss" scoped></style>

+ 26 - 0
src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/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/production-safety/risk-identification-and-control/key-site-monitor-manage/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);
+  });
+};

+ 48 - 1
src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/index.vue

@@ -1 +1,48 @@
-<template>list minitor</template>
+<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>

+ 65 - 0
src/views/production-safety/risk-identification-and-control/key-site-monitor-manage/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/production-safety/risk-identification-and-control/key-site-monitor-manage/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");
+// };