Kaynağa Gözat

Merge branch 'dev-bxy' into 'dev'

feat: 保卫保密总览-保密要害部位+治安重点部位

See merge request product-group-fe/sfy-safety-group/sfy-safety!228
毕欣怡 6 ay önce
ebeveyn
işleme
2ebb06b139

+ 24 - 0
src/api/security-confidentiality-position/index.ts

@@ -181,3 +181,27 @@ export const exportAccessRecord = (data: QueryAccessRecordParams) => {
     },
   );
 };
+
+/**
+ * @description: 保密要害部位--总览--查询今日出入抓拍记录数
+ */
+export const getTodayCaptureRecordCount = () => {
+  return http.request({
+    url: '/detectRecord/queryTodayIntrusionCaptureCount',
+    method: 'get',
+  });
+};
+
+/**
+ * @description: 保密要害部位--总览--查询近几日出入抓拍统计
+ */
+export interface GetRecentDaysCaptureRecordCountRes {
+  date: string; // 日期yyyy-MM-dd
+  count: number; // 入侵抓拍数量
+}
+export const getRecentDaysCaptureRecordCount = (day: number) => {
+  return http.request<GetRecentDaysCaptureRecordCountRes[]>({
+    url: `/detectRecord/queryRecentIntrusionCaptureStatistic?day=${day}`,
+    method: 'get',
+  });
+};

BIN
src/assets/images/warning.png


+ 542 - 2
src/views/security-confidentiality/overview/components/ConfidentialityPosition.vue

@@ -3,11 +3,269 @@
     <div class="container-title">
       <span class="line"></span>
       <span class="title">保密要害部位监控</span>
+      <el-tooltip effect="dark" placement="right">
+        <span class="position-count">{{ positionCameraCount }}</span>
+        <template #content>
+          <span>共{{ positionCameraCount }}个监控位置</span>
+        </template>
+      </el-tooltip>
+    </div>
+    <div class="monitor-chart">
+      <div class="monitor-area">
+        <div class="has-main-camera" v-if="curPlayPositionCamera && getCameraUrl(curPlayPositionCamera)">
+          <LiveVideo
+            :url="getCameraUrl(curPlayPositionCamera)"
+            :poster="getCameraImg(curPlayPositionCamera)"
+            :id="`monitor-livevideo`"
+            class="main-video"
+          />
+          <el-icon class="switch-icon" @click="switchCameraPage = true"><Switch /></el-icon>
+          <div class="monitor-info">
+            <img src="@/assets/images/disaster-overview/camera.png" alt="" />
+            <span class="camera-name" :title="curPlayPositionCamera?.positionName + '-' + curPlayPositionCamera?.name"
+              >{{ curPlayPositionCamera?.positionName }}-{{ curPlayPositionCamera?.name }}</span
+            >
+            <img
+              src="@/assets/images/disaster-overview/full-screen.png"
+              alt=""
+              class="full-screen"
+              @click="isFullScreen ? exitFullscreen() : fullScreen(`monitor-livevideo`, 'overview-monitor')"
+            />
+          </div>
+        </div>
+        <div class="no-main-camera" v-if="!curPlayPositionCamera || !getCameraUrl(curPlayPositionCamera)">
+          <img class="cameraEmptyImg" src="@/assets/icons/nine-square-grid/cameraEmpty.png" />
+          <span>暂无保密要害部位监控</span>
+        </div>
+        <div class="switch-camera-page" v-if="switchCameraPage">
+          <el-icon class="close-icon" @click="switchCameraPage = false"><CloseBold /></el-icon>
+          <div class="switch-camera-page-content">
+            <div
+              class="switch-camera-page-content-item"
+              :class="{ active: curPlayPositionCamera?.id === item.id }"
+              v-for="item in confidentialityPositionCameraInfo"
+              :key="item.id"
+              @click="switchCamera(item)"
+              >{{ item.positionName }}-{{ item.name }}</div
+            >
+          </div>
+        </div>
+      </div>
+      <div class="chart-area">
+        <ConfidentialityPositionChart />
+      </div>
+    </div>
+    <div class="invasion-snapshot-list">
+      <div class="invasion-snapshot-list-title"> 近30日抓拍记录明细表 </div>
+      <div class="invasion-snapshot-list-table">
+        <div class="invasion-snapshot-list-content" ref="scrollContainer" @scroll="handleScroll">
+          <div class="snapshot-table">
+            <div class="table-header">
+              <div class="table-cell">序号</div>
+              <div class="table-cell">事件</div>
+              <div class="table-cell">地点</div>
+              <div class="table-cell">抓拍照片</div>
+              <div class="table-cell">抓拍时间</div>
+            </div>
+            <div class="table-body">
+              <div v-for="(item, index) in invasionSnapshotList" :key="item.id" class="table-row">
+                <div class="table-cell">{{ index + 1 }}</div>
+                <div class="table-cell">{{ item.event }}</div>
+                <div class="table-cell">{{ item.location }}</div>
+                <div class="table-cell"><ImageViewer :file-list="item.pictures" /></div>
+                <div class="table-cell">{{ item.eventTime }}</div>
+              </div>
+            </div>
+          </div>
+          <!-- 加载状态 -->
+          <div v-if="loading" class="loading-container">
+            <el-icon class="is-loading"><Loading /></el-icon>
+            <span>加载中...</span>
+          </div>
+          <!-- 没有更多数据 -->
+          <div v-if="!hasMore && invasionSnapshotList.length > 0" class="no-more-data">没有更多数据了</div>
+          <!-- 空状态 -->
+          <div v-if="!loading && invasionSnapshotList.length === 0" class="empty-state">
+            <el-icon><Picture /></el-icon>
+            <span>暂无抓拍记录</span>
+          </div>
+        </div>
+      </div>
     </div>
   </div>
 </template>
 
-<script setup lang="ts"></script>
+<script setup lang="ts">
+  import { onMounted, onUnmounted, ref, nextTick } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import screenfull from 'screenfull';
+  import urlJoin from 'url-join';
+  import { Switch, CloseBold, Loading, Picture } from '@element-plus/icons-vue';
+  import LiveVideo from '@/components/live/LiveVideo.vue';
+  import ImageViewer from '@/views/traffic/violation/act/components/ImageViewer.vue';
+  import ConfidentialityPositionChart from './ConfidentialityPositionChart.vue';
+  import { userSplitScreenFullScreen } from '@/store/modules/userSplitScreenFullScreen';
+  import { CameraInfo } from '@/api/disaster-overview';
+  import {
+    getConfidentialityPositionList,
+    getInvasionSnapshotList,
+    type QueryInvasionSnapshotRes,
+  } from '@/api/security-confidentiality-position';
+
+  const { isFullScreen, curFullScreenType } = storeToRefs(userSplitScreenFullScreen());
+  const { fullScreen, exitFullscreen } = userSplitScreenFullScreen();
+
+  interface ConfidentialityPositionCameraInfo extends CameraInfo {
+    positionName: string;
+  }
+
+  const positionCameraCount = ref(0);
+  const confidentialityPositionCameraInfo = ref<ConfidentialityPositionCameraInfo[]>([]);
+  const curPlayPositionCamera = ref<ConfidentialityPositionCameraInfo>();
+  const switchCameraPage = ref(false);
+
+  // 入侵抓拍记录相关状态
+  const invasionSnapshotList = ref<QueryInvasionSnapshotRes[]>([]);
+  const loading = ref(false);
+  const hasMore = ref(true);
+  const currentPage = ref(1);
+  const pageSize = ref(20);
+  const scrollContainer = ref<HTMLElement>();
+
+  // 计算近30天的时间范围
+  const getLast30DaysTimeRange = () => {
+    const endTime = new Date();
+    const startTime = new Date();
+    startTime.setDate(endTime.getDate() - 30);
+
+    return {
+      startTime: startTime.toISOString().slice(0, 19).replace('T', ' '),
+      endTime: endTime.toISOString().slice(0, 19).replace('T', ' '),
+    };
+  };
+
+  const isHttps = () => {
+    return window.location.protocol.startsWith('https');
+  };
+
+  const getCameraUrl = (val: ConfidentialityPositionCameraInfo) => {
+    if (val.pushStreamDTO && val.pushStreamDTO.videoUrls) {
+      const videoUrl = val.pushStreamDTO.videoUrls.pushstreamIp;
+      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;
+    }
+    return '';
+  };
+
+  const getCameraImg = (val: ConfidentialityPositionCameraInfo) => {
+    if (val.pushStreamDTO && val.pushStreamDTO.imageUrl) {
+      return val.pushStreamDTO.imageUrl;
+    }
+    return '';
+  };
+
+  const switchCamera = (item: ConfidentialityPositionCameraInfo) => {
+    curPlayPositionCamera.value = item;
+    switchCameraPage.value = false;
+  };
+
+  // 获取入侵抓拍记录数据
+  const loadInvasionSnapshotData = async (isLoadMore = false) => {
+    if (loading.value) return;
+
+    loading.value = true;
+    try {
+      const timeRange = getLast30DaysTimeRange();
+      const response = await getInvasionSnapshotList({
+        pageNumber: isLoadMore ? currentPage.value : 1,
+        pageSize: pageSize.value,
+        queryParam: {
+          startTime: timeRange.startTime,
+          endTime: timeRange.endTime,
+        },
+      });
+
+      if (isLoadMore) {
+        invasionSnapshotList.value = [...invasionSnapshotList.value, ...response.records];
+      } else {
+        invasionSnapshotList.value = response.records;
+        currentPage.value = 1;
+      }
+
+      hasMore.value = response.records.length === pageSize.value;
+      if (hasMore.value) {
+        currentPage.value++;
+      }
+    } catch (error) {
+      console.error('获取入侵抓拍记录失败:', error);
+    } finally {
+      loading.value = false;
+    }
+  };
+
+  // 无限滚动处理
+  const handleScroll = () => {
+    if (!scrollContainer.value || loading.value || !hasMore.value) return;
+
+    const { scrollTop, scrollHeight, clientHeight } = scrollContainer.value;
+    // 当滚动到距离底部50px时触发加载
+    if (scrollTop + clientHeight >= scrollHeight - 50) {
+      loadInvasionSnapshotData(true);
+    }
+  };
+
+  onMounted(() => {
+    getConfidentialityPositionList({
+      groupName: '',
+      cameraName: '',
+    }).then((res) => {
+      confidentialityPositionCameraInfo.value = res.flatMap((item) => {
+        return item.children.map((child) => ({
+          ...child,
+          positionName: item.groupName,
+        }));
+      });
+      positionCameraCount.value = confidentialityPositionCameraInfo.value.length;
+      curPlayPositionCamera.value = confidentialityPositionCameraInfo.value[0] ?? undefined;
+    });
+
+    // 加载入侵抓拍记录数据
+    loadInvasionSnapshotData();
+
+    // 绑定滚动事件监听器
+    nextTick(() => {
+      if (scrollContainer.value) {
+        scrollContainer.value.addEventListener('scroll', handleScroll);
+      }
+    });
+  });
+
+  window.onresize = () => {
+    if (!screenfull.isFullscreen) {
+      isFullScreen.value = false; //判断退出全屏,进行赋值
+      curFullScreenType.value = 'single';
+    }
+  };
+
+  onUnmounted(() => {
+    window.onresize = null;
+    // 清理滚动事件监听
+    if (scrollContainer.value) {
+      scrollContainer.value.removeEventListener('scroll', handleScroll);
+    }
+  });
+</script>
 
 <style scoped lang="scss">
   .container-title {
@@ -30,9 +288,291 @@
     }
   }
 
+  .position-count {
+    font-size: 18px;
+    color: #1777ff;
+    text-shadow: 0 0 10px #1777ff;
+  }
+
   .outer-person-container {
     width: 100%;
     height: 100%;
-    padding-top: 14px;
+    padding: 14px;
+    overflow: auto;
+  }
+
+  .monitor-chart {
+    height: 274px;
+    margin: 0 15px 11px 15px;
+    padding: 16px 0;
+    border-bottom: 1px dashed #c0c4cc;
+    display: flex;
+
+    .monitor-area {
+      width: 50%;
+      height: 100%;
+      margin-right: 10px;
+      position: relative;
+
+      .has-main-camera {
+        width: 100%;
+        height: 100%;
+        background-color: #000000;
+        border-radius: 8px;
+
+        .main-video {
+          height: 100%;
+        }
+
+        .switch-icon {
+          position: absolute;
+          top: 10px;
+          right: 10px;
+          cursor: pointer;
+          color: #ffffff;
+        }
+
+        .switch-icon:hover {
+          color: #1777ff;
+        }
+
+        .monitor-info {
+          width: 100%;
+          position: absolute;
+          bottom: 0px;
+          left: 0px;
+          display: flex;
+          align-items: center;
+          padding: 5px 10px;
+          background: rgba(255, 255, 255, 0.2);
+          border-radius: 8px;
+          color: #fff;
+          gap: 10px;
+        }
+
+        .camera-name {
+          flex: 1;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+          max-width: 100%;
+        }
+
+        .full-screen {
+          margin-left: auto;
+          cursor: pointer;
+        }
+      }
+
+      .no-main-camera {
+        height: 100%;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        gap: 20px;
+        color: #999;
+
+        img {
+          height: 80%;
+        }
+      }
+
+      .switch-camera-page {
+        width: 100%;
+        height: 100%;
+        background-color: rgba(0, 0, 0, 0.2);
+        backdrop-filter: blur(10px);
+        z-index: 100;
+        position: absolute;
+        top: 0;
+        left: 0;
+        display: flex;
+        flex-direction: column;
+        padding-bottom: 10px;
+        color: #ffffff;
+        border-radius: 8px;
+
+        .close-icon {
+          margin: 10px 10px 10px auto;
+          font-size: 20px;
+          cursor: pointer;
+        }
+
+        .close-icon:hover {
+          color: #1777ff;
+        }
+
+        .switch-camera-page-content {
+          flex: 1;
+          padding: 0 20px;
+          overflow: auto;
+          display: flex;
+          flex-direction: column;
+          gap: 10px;
+        }
+
+        .switch-camera-page-content-item {
+          word-wrap: break-word;
+          cursor: pointer;
+          padding: 5px 10px;
+          border-radius: 5px;
+          background-color: rgba(255, 255, 255, 0.2);
+          transition: all 0.3s;
+        }
+
+        .switch-camera-page-content-item:hover,
+        .switch-camera-page-content-item.active {
+          background-color: rgba(255, 255, 255, 0.4);
+          box-shadow: 0 0 10px rgba(255, 255, 255, 0.4);
+          transform: scale(1.03);
+        }
+
+        .switch-camera-page-content-item:active {
+          background-color: rgba(255, 255, 255, 0.4);
+          box-shadow: 0 0 10px rgba(255, 255, 255, 0.4);
+          transform: scale(0.98);
+        }
+      }
+    }
+
+    .chart-area {
+      width: calc(100% - 50% - 10px);
+      height: 100%;
+    }
+  }
+
+  .invasion-snapshot-list {
+    width: 100%;
+    height: calc(100% - 274px - 24px - 11px);
+    padding: 0 15px;
+
+    .invasion-snapshot-list-title {
+      font-size: 16px;
+      font-weight: 500;
+      color: #000000;
+      margin-bottom: 12px;
+    }
+
+    .invasion-snapshot-list-table {
+      width: 100%;
+      height: calc(100% - 24px);
+      min-height: 220px;
+    }
+
+    .invasion-snapshot-list-content {
+      width: 100%;
+      height: calc(100% - 20px);
+      min-height: 200px;
+      background-color: #f5f7fa;
+      border-radius: 8px;
+      overflow-y: auto;
+      margin-bottom: 12px;
+
+      .snapshot-table {
+        width: 100%;
+        background-color: #ffffff;
+        border-radius: 8px;
+        overflow: hidden;
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+
+        .table-header {
+          display: grid;
+          grid-template-columns: 60px 1fr 1fr 150px 1fr;
+          background-color: #f8f9fa;
+          border-bottom: 1px solid #e9ecef;
+
+          .table-cell {
+            padding: 12px 8px;
+            font-weight: 500;
+            font-size: 14px;
+            color: #333;
+            text-align: center;
+            border-right: 1px solid #e9ecef;
+
+            &:last-child {
+              border-right: none;
+            }
+          }
+        }
+
+        .table-body {
+          .table-row {
+            display: grid;
+            grid-template-columns: 60px 1fr 1fr 150px 1fr;
+            border-bottom: 1px solid #f0f0f0;
+            transition: background-color 0.2s;
+
+            &:hover {
+              background-color: #f8f9fa;
+            }
+
+            &:last-child {
+              border-bottom: none;
+            }
+
+            .table-cell {
+              padding: 12px 8px;
+              font-size: 13px;
+              color: #666;
+              text-align: center;
+              border-right: 1px solid #f0f0f0;
+              display: flex;
+              align-items: center;
+              justify-content: center;
+              word-break: break-all;
+
+              &:last-child {
+                border-right: none;
+              }
+            }
+          }
+        }
+      }
+
+      .loading-container {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        padding: 20px;
+        color: #666;
+        gap: 8px;
+
+        .is-loading {
+          animation: rotate 1s linear infinite;
+        }
+      }
+
+      .no-more-data {
+        text-align: center;
+        padding: 10px;
+        color: #999;
+        font-size: 14px;
+      }
+
+      .empty-state {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        padding: 40px 20px;
+        color: #999;
+        gap: 12px;
+
+        .el-icon {
+          font-size: 48px;
+          color: #ddd;
+        }
+      }
+    }
+  }
+
+  @keyframes rotate {
+    from {
+      transform: rotate(0deg);
+    }
+    to {
+      transform: rotate(360deg);
+    }
   }
 </style>

+ 296 - 0
src/views/security-confidentiality/overview/components/ConfidentialityPositionChart.vue

@@ -0,0 +1,296 @@
+<template>
+  <div class="confidentiality-position-chart">
+    <!-- 标题区域 -->
+    <div class="chart-header">
+      <div class="main-title">
+        <img src="@/assets/images/warning.png" alt="warning" class="warning-icon" />
+        <span class="title-text">今日抓拍记录</span>
+        <span class="record-count">{{ todayCount }}</span>
+        <span class="title-text">条</span>
+      </div>
+      <div class="subtitle">近7日抓拍记录变化折线图</div>
+    </div>
+
+    <!-- 图表区域 -->
+    <div ref="chartRef" class="chart-container"></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, onMounted, onUnmounted, nextTick } from 'vue';
+  import * as echarts from 'echarts';
+  import { getTodayCaptureRecordCount, getRecentDaysCaptureRecordCount } from '@/api/security-confidentiality-position';
+
+  const chartRef = ref<HTMLDivElement | null>(null);
+  let chartInstance: echarts.ECharts | null = null;
+  const todayCount = ref<number>(0);
+
+  // 初始化图表
+  const initChart = async () => {
+    if (!chartRef.value) return;
+
+    // 获取数据
+    try {
+      const [todayRes, recentRes] = await Promise.all([
+        getTodayCaptureRecordCount(),
+        getRecentDaysCaptureRecordCount(7),
+      ]);
+
+      todayCount.value = todayRes || 0;
+
+      // 销毁旧实例
+      if (chartInstance) {
+        chartInstance.dispose();
+      }
+
+      // 初始化图表实例
+      chartInstance = echarts.init(chartRef.value);
+
+      // 处理数据
+      const dates = recentRes.map((item) => {
+        const date = new Date(item.date);
+        const month = String(date.getMonth() + 1).padStart(2, '0');
+        const day = String(date.getDate()).padStart(2, '0');
+        return `${month}.${day}`;
+      });
+
+      const counts = recentRes.map((item) => item.count);
+
+      // 找到最大值用于高亮显示
+      const maxCount = Math.max(...counts, 0);
+      const maxIndex = counts.indexOf(maxCount);
+
+      const option: echarts.EChartsOption = {
+        grid: {
+          left: '5%',
+          right: '5%',
+          top: '15%',
+          bottom: '0%',
+          containLabel: true,
+        },
+        xAxis: {
+          type: 'category',
+          data: dates,
+          axisLine: {
+            show: false,
+          },
+          axisTick: {
+            show: false,
+          },
+          axisLabel: {
+            color: '#666',
+            fontSize: 12,
+            margin: 10,
+          },
+        },
+        yAxis: {
+          type: 'value',
+          axisLine: {
+            show: false,
+          },
+          axisTick: {
+            show: false,
+          },
+          axisLabel: {
+            color: '#666',
+            fontSize: 12,
+          },
+          splitLine: {
+            lineStyle: {
+              color: '#f0f0f0',
+              type: 'dashed',
+            },
+          },
+        },
+        series: [
+          {
+            name: '抓拍记录',
+            type: 'line',
+            data: counts,
+            smooth: true,
+            symbol: 'circle',
+            symbolSize: 6,
+            lineStyle: {
+              color: '#ff8c00',
+              width: 2,
+            },
+            itemStyle: {
+              color: '#ff8c00',
+            },
+            markPoint:
+              maxCount > 0
+                ? {
+                    data: [
+                      {
+                        type: 'max',
+                        name: '最大值',
+                        coord: [maxIndex, maxCount],
+                        symbolSize: 0,
+                        label: {
+                          show: true,
+                          position: 'top',
+                          color: '#ff8c00',
+                          fontSize: 14,
+                          fontWeight: 'bold',
+                          formatter: maxCount.toString(),
+                        },
+                      },
+                    ],
+                  }
+                : undefined,
+          },
+        ],
+        tooltip: {
+          trigger: 'axis',
+          backgroundColor: 'rgba(0, 0, 0, 0.6)',
+          borderColor: 'transparent',
+          textStyle: {
+            color: '#fff',
+          },
+          formatter: (params: any) => {
+            const data = params[0];
+            return `${data.axisValue}<br/>抓拍记录: ${data.value}条`;
+          },
+        },
+      };
+
+      chartInstance.setOption(option);
+
+      // 响应式处理
+      window.addEventListener('resize', handleResize);
+    } catch (error) {
+      console.error('Failed to load chart data:', error);
+      todayCount.value = 0;
+    }
+  };
+
+  // 处理窗口大小变化
+  const handleResize = () => {
+    if (chartInstance) {
+      chartInstance.resize();
+    }
+  };
+
+  onMounted(async () => {
+    await nextTick();
+    initChart();
+  });
+
+  onUnmounted(() => {
+    window.removeEventListener('resize', handleResize);
+    if (chartInstance) {
+      chartInstance.dispose();
+    }
+  });
+</script>
+
+<style scoped lang="scss">
+  .confidentiality-position-chart {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 5px 20px;
+
+    .chart-header {
+      width: 100%;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      margin-bottom: 8px;
+
+      .main-title {
+        display: flex;
+        align-items: baseline;
+        justify-content: center;
+        margin-bottom: 20px;
+        gap: 8px;
+
+        .warning-icon {
+          width: 14px;
+          height: 14px;
+        }
+
+        .title-text {
+          font-size: 16px;
+          font-weight: 600;
+          color: #303133;
+        }
+
+        .record-count {
+          font-size: 24px;
+          font-weight: 600;
+          color: #ff5f53;
+        }
+      }
+
+      .subtitle {
+        font-size: 12px;
+        color: #303133;
+        text-align: center;
+      }
+    }
+
+    .chart-container {
+      width: 100%;
+      height: 153px;
+      flex: 1;
+    }
+  }
+
+  // 响应式设计
+  // @media (max-width: 768px) {
+  //   .confidentiality-position-chart {
+  //     padding: 15px;
+
+  //     .chart-header {
+  //       margin-bottom: 15px;
+
+  //       .main-title {
+  //         .title-text {
+  //           font-size: 14px;
+  //         }
+
+  //         .record-count {
+  //           font-size: 14px;
+  //         }
+  //       }
+
+  //       .subtitle {
+  //         font-size: 12px;
+  //       }
+  //     }
+
+  //     .chart-container {
+  //       height: 150px;
+  //     }
+  //   }
+  // }
+
+  // @media (max-width: 480px) {
+  //   .confidentiality-position-chart {
+  //     padding: 10px;
+
+  //     .chart-header {
+  //       .main-title {
+  //         flex-direction: column;
+  //         gap: 4px;
+
+  //         .title-text {
+  //           font-size: 13px;
+  //         }
+
+  //         .record-count {
+  //           font-size: 13px;
+  //         }
+  //       }
+  //     }
+
+  //     .chart-container {
+  //       height: 120px;
+  //     }
+  //   }
+  // }
+</style>

+ 184 - 1
src/views/security-confidentiality/overview/components/SecurityPosition.vue

@@ -3,11 +3,122 @@
     <div class="container-title">
       <span class="line"></span>
       <span class="title">治安重点部位监控</span>
+      <el-tooltip effect="dark" placement="left">
+        <span class="position-count">{{ positionCameraCount }}</span>
+        <template #content>
+          <span>共{{ positionCameraCount }}个监控位置</span>
+        </template>
+      </el-tooltip>
+    </div>
+    <div class="monitor-area">
+      <div v-if="securityPositionCameraInfo.length === 0" class="empty-style">
+        <img class="empty-img" src="@/assets/images/empty-2.png" alt="" />
+        <span>暂无治安重点部位监控</span>
+      </div>
+      <div v-for="(item, index) in securityPositionCameraInfo" :key="index" class="monitor-item">
+        <img
+          v-if="!getCameraUrl(item)"
+          src="@/assets/images/disaster-overview/no-camera-url.png"
+          alt=""
+          class="monitor-no-url"
+        />
+        <div class="monitor-video">
+          <LiveVideo :url="getCameraUrl(item)" :poster="getCameraImg(item)" :id="`monitor-livevideo-${index}`" />
+          <img
+            src="@/assets/images/disaster-overview/full-screen.png"
+            alt=""
+            class="full-screen"
+            @click="isFullScreen ? exitFullscreen() : fullScreen(`monitor-livevideo-${index}`, 'overview-monitor')"
+          />
+        </div>
+        <div class="monitor-info">
+          <img src="@/assets/images/disaster-overview/camera.png" alt="" />
+          <span class="camera-name" :title="item.positionName + '-' + item.name"
+            >{{ item.positionName }}-{{ item.name }}</span
+          >
+        </div>
+      </div>
     </div>
   </div>
 </template>
 
-<script setup lang="ts"></script>
+<script setup lang="ts">
+  import { onMounted, onUnmounted, ref } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import screenfull from 'screenfull';
+  import urlJoin from 'url-join';
+  import LiveVideo from '@/components/live/LiveVideo.vue';
+  import { userSplitScreenFullScreen } from '@/store/modules/userSplitScreenFullScreen';
+  import { CameraInfo } from '@/api/disaster-overview';
+  import { getSecurityPositionList } from '@/api/security-confidentiality-position';
+
+  const { isFullScreen, curFullScreenType } = storeToRefs(userSplitScreenFullScreen());
+  const { fullScreen, exitFullscreen } = userSplitScreenFullScreen();
+
+  interface SecurityPositionCameraInfo extends CameraInfo {
+    positionName: string;
+  }
+
+  const positionCameraCount = ref(0);
+  const securityPositionCameraInfo = ref<SecurityPositionCameraInfo[]>([]);
+
+  const isHttps = () => {
+    return window.location.protocol.startsWith('https');
+  };
+
+  const getCameraUrl = (val: CameraInfo) => {
+    if (val.pushStreamDTO && val.pushStreamDTO.videoUrls) {
+      const videoUrl = val.pushStreamDTO.videoUrls.pushstreamIp;
+      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;
+    }
+    return '';
+  };
+
+  const getCameraImg = (val: CameraInfo) => {
+    if (val.pushStreamDTO && val.pushStreamDTO.imageUrl) {
+      return val.pushStreamDTO.imageUrl;
+    }
+    return '';
+  };
+
+  onMounted(() => {
+    getSecurityPositionList({
+      groupName: '',
+      cameraName: '',
+    }).then((res) => {
+      securityPositionCameraInfo.value = res.flatMap((item) => {
+        return item.children.map((child) => ({
+          ...child,
+          positionName: item.groupName,
+        }));
+      });
+      positionCameraCount.value = securityPositionCameraInfo.value.length;
+    });
+  });
+
+  window.onresize = () => {
+    if (!screenfull.isFullscreen) {
+      isFullScreen.value = false; //判断退出全屏,进行赋值
+      curFullScreenType.value = 'single';
+    }
+  };
+
+  onUnmounted(() => {
+    window.onresize = null;
+  });
+</script>
 
 <style scoped lang="scss">
   .container-title {
@@ -30,9 +141,81 @@
     }
   }
 
+  .position-count {
+    font-size: 18px;
+    color: #1777ff;
+    text-shadow: 0 0 10px #1777ff;
+  }
+
   .outer-person-container {
     width: 100%;
     height: 100%;
     padding-top: 14px;
   }
+
+  .monitor-area {
+    width: 100%;
+    height: calc(100% - 48px);
+    overflow: auto;
+    padding: 0 16px;
+    margin-top: 10px;
+
+    .monitor-item:last-child {
+      margin-bottom: 0;
+    }
+
+    .monitor-item {
+      margin-bottom: 10px;
+
+      .monitor-info {
+        display: flex;
+        align-items: center;
+
+        .camera-name {
+          margin-left: 12px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+          max-width: 100%;
+          font-size: 14px;
+          color: #333333;
+        }
+      }
+
+      .monitor-no-url {
+        width: 100%;
+        height: 100%;
+        object-fit: contain;
+      }
+
+      .monitor-video {
+        position: relative;
+        width: 100%;
+        height: 100%;
+      }
+
+      .full-screen {
+        position: absolute;
+        bottom: 10px;
+        right: 5px;
+        cursor: pointer;
+      }
+    }
+
+    .empty-style {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      align-items: center;
+      font-size: 16px;
+      color: rgba(0, 0, 0, 0.5);
+
+      .empty-img {
+        width: 100%;
+        object-fit: contain;
+      }
+    }
+  }
 </style>