yunfeng wu 1 год назад
Родитель
Сommit
5d6c7a650f

+ 53 - 0
src/api/datamanagement/playback.ts

@@ -0,0 +1,53 @@
+import { http } from '@/utils/http/axios';
+
+export interface GetReplayNvrBody {
+  cameraId: number;
+  endTime: string;
+  startTime: string;
+}
+/** 回放nvr */
+export const getReplayNvr = (data: GetReplayNvrBody) => {
+  return http.request(
+    {
+      url: '/nvrOption/replayNvr',
+      method: 'post',
+      data,
+    },
+    {
+      isTransformResponse: false,
+    },
+  );
+};
+
+export interface GetRecordByTimeBody {
+  algoList: number[];
+  cameraId: number;
+  endTime: string;
+  startTime: string;
+}
+
+/** 根据时间段获取违规记录点 */
+export const getRecordByTime = (data: GetRecordByTimeBody): Promise<ViolationRecordItem[]> => {
+  return http.request({
+    url: '/minio/getRecordByTime',
+    method: 'post',
+    data,
+  });
+};
+
+export interface ViolationRecordItem {
+  algoId: number;
+  algoName: string;
+  cameraId: number;
+  id: number;
+  time: string;
+}
+
+/** 获取nvr下载链接 */
+export const getNvrDownloadUrl = (data: GetReplayNvrBody) => {
+  return http.request({
+    url: '/nvrOption/download',
+    method: 'post',
+    data,
+  });
+};

+ 23 - 1
src/components/LiveVideo/LiveVideo.vue

@@ -5,7 +5,7 @@
 
     <div class="loadingError" v-if="isVideoLoadingFailed">视频加载失败</div>
 
-    <video v-loading="true" id="video" autoplay muted loop class="video-js video-content">
+    <video v-loading="true" id="video" autoplay muted class="video-js video-content">
       <source :src="props.url" />
     </video>
   </div>
@@ -24,6 +24,10 @@
     url: string;
   }>();
 
+  const emit = defineEmits(['timeUpdate']);
+
+  // /live/video/aa.flv?starttime=202410101001}
+
   let player: mpegts.Player | null;
 
   const loadingText = computed(() => {
@@ -44,6 +48,24 @@
         liveBufferLatencyChasing: true,
       },
     );
+
+    let min = 0;
+    const handleTimeUpdate = (event) => {
+      const currentTime = Math.floor(event.target.currentTime);
+      if (currentTime > min && currentTime % 60 === 0) {
+        min = currentTime;
+        emit('timeUpdate');
+      }
+      // const currentMin = Math.floor(event.target.currentTime / 60);
+      // if (currentMin > min) {
+      //   min = currentMin;
+      //   emit('timeUpdate', currentMin);
+      // }
+      // console.log(event.target.currentTime);
+    };
+    videoElement.removeEventListener('timeupdate', handleTimeUpdate);
+    videoElement.addEventListener('timeupdate', handleTimeUpdate);
+
     player.attachMediaElement(videoElement);
     player.load();
     player.on(mpegts.Events.MEDIA_INFO, () => {

+ 177 - 0
src/views/datamanager/playback/Playback.vue

@@ -0,0 +1,177 @@
+<template>
+  <div>
+    <div class="camera-main">
+      <div class="camera-tree">
+        <CameraTreeCom
+          :loading="presetListStore.loading"
+          :camera-tree="cameraTree || []"
+          :total="codeShowList.length"
+          :no-integration-num="noIntegrationNum"
+          :no-networking-num="noNetworkingNum"
+        />
+      </div>
+      <div class="camera-placeholder" v-if="!cameraDetailStore.cameraId">请选择左侧相机</div>
+      <div v-else class="camera-setting-wrapper">
+        <NvrCameraView :camera-id="cameraDetailStore.cameraId" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import NvrCameraView from './components/NvrCameraView.vue';
+  import CameraTreeCom from '@/views/cameras/preview/components/CameraTree/CameraTree.vue';
+  import { onUnmounted, ref, watch } from 'vue';
+  import useCameraDetailStore from '@/views/cameras/preview/store/useCameraDetailStore';
+  import usePresetListStore from '@/views/cameras/preview/store/usePresetListStore';
+  import useFenceStore from '@/views/cameras/preview/store/useFenceStore';
+  // import useCameraAlgoStore from '@/views/cameras/preview/store/useCameraAlgoStore';
+  import { onMounted } from 'vue';
+  import { CameraDetailServer, IsPtz } from '@/api/camera/camera-overview';
+  import { CameraTree, getCameraTree } from '@/api/camera/camera-preview';
+  import useCameraStatus from '@/views/cameras/preview/store/useCameraStatus';
+
+  const cameraStatus = useCameraStatus();
+  const { noNetworkingNum, openInterval, closeInterval } = cameraStatus;
+
+  const cameraDetailStore = useCameraDetailStore();
+  const fenceStore = useFenceStore();
+  const presetListStore = usePresetListStore();
+  // const cameraAlgoStore = useCameraAlgoStore();
+  const cameraTree = ref<CameraTree[]>([]);
+  const codeShowList = ref<string[]>([]);
+  const noIntegrationNum = ref<number>(0);
+
+  // const { allAlgoList } = storeToRefs(cameraAlgoStore);
+
+  function updateNetworkingState(data, targetData) {
+    let integrationCount = 0;
+    for (let i = 0; i < data.length; i++) {
+      const node = data[i];
+      const matchedNode = targetData.find((item) => item.cameraCode === node.code);
+      if (matchedNode) {
+        node.networkingState = matchedNode.status;
+        node.integrationState = matchedNode.integrationState;
+      }
+      if (node.integrationState === 1) {
+        integrationCount++;
+      }
+      if (node.children && node.children.length > 0) {
+        const childIntegrationCount = updateNetworkingState(node.children, targetData);
+        integrationCount += childIntegrationCount;
+      }
+    }
+    noIntegrationNum.value = integrationCount;
+    return integrationCount;
+  }
+
+  function getCameraList(data) {
+    let cameraList = [] as string[];
+    for (let i = 0; i < data.length; i++) {
+      const node = data[i];
+      if (node.nodeType === 'camera') {
+        cameraList.push(node.code);
+      }
+      if (node.children && node.children.length > 0) {
+        const childCameraList = getCameraList(node.children);
+        cameraList.push(...childCameraList);
+      }
+    }
+    return cameraList;
+  }
+
+  watch(
+    () => cameraDetailStore.cameraId,
+    async (cameraId) => {
+      fenceStore.clear();
+      if (cameraId) {
+        const presetList = await presetListStore.getPresetList(cameraId);
+
+        if (cameraTree.value.length === 0) {
+          /** 如果当前树为空,那么切换相机的时候,要重新请求树结构 */
+          const tree = await getCameraTree();
+          cameraTree.value = tree as unknown as CameraTree[];
+        }
+
+        const detail: CameraDetailServer = getCameraDetail(
+          cameraTree.value,
+          cameraDetailStore.cameraId,
+        ) as unknown as CameraDetailServer;
+        if (detail) {
+          cameraDetailStore.setDetail(detail);
+          if (detail?.isPtz === IsPtz.disabled) {
+            presetListStore.currentPresetToken = presetList?.[0].token;
+          }
+        }
+        // cameraAlgoStore.getCameraAlgoList(cameraId);
+        // cameraAlgoStore.selectedAlgoId = null;
+      } else {
+        /** 没有相机的时候也要请求相机树 */
+        const tree = await getCameraTree();
+        cameraTree.value = tree as unknown as CameraTree[];
+      }
+    },
+    {
+      immediate: true,
+    },
+  );
+
+  onMounted(() => {
+    getCameraTree().then((res) => {
+      cameraTree.value = res;
+      codeShowList.value = getCameraList(res);
+      openInterval(codeShowList.value, (targetData) => {
+        updateNetworkingState(cameraTree.value, targetData);
+      });
+    });
+  });
+
+  onUnmounted(() => {
+    /** 离开页面要清理掉所有的store */
+    cameraDetailStore.clear();
+    // cameraAlgoStore.clear();
+    fenceStore.clear();
+    presetListStore.clear();
+    closeInterval();
+  });
+
+  function getCameraDetail(tree: CameraTree[], cameraId: number): CameraTree | null {
+    let detail: CameraTree | null = null;
+
+    function getDetail(t: CameraTree[]) {
+      t.forEach((x) => {
+        if (x.nodeType === 'camera' && x.id === cameraId) {
+          detail = x;
+        } else if (x.children && x.children.length > 0) {
+          getDetail(x.children);
+        }
+      });
+    }
+
+    getDetail(tree);
+
+    return detail;
+  }
+</script>
+<style lang="scss" scoped>
+  .camera-main {
+    display: flex;
+    background: #fff;
+
+    .camera-tree {
+      width: 250px;
+      flex-shrink: 0;
+      border: 1px solid #f0f2f5;
+      margin: 5px;
+    }
+    .camera-placeholder {
+      color: #333;
+      text-align: center;
+      margin-top: 100px;
+      margin-left: 100px;
+    }
+    .camera-setting-wrapper {
+      width: 960px;
+    }
+  }
+</style>

+ 260 - 0
src/views/datamanager/playback/components/NvrCameraView.vue

@@ -0,0 +1,260 @@
+<template>
+  <div class="nvr-camera-view">
+    <div class="nvr-tips">
+      <el-icon size="18" color="#409eff" style="margin: 11px 8px 11px 16px"><InfoFilled /></el-icon>
+      可以回看和下载三个月内的视频回放数据;默认显示直播画面</div
+    >
+    <div class="nvr-date-picker">
+      <el-date-picker
+        v-model="dateRange"
+        type="datetimerange"
+        start-placeholder="开始日期"
+        end-placeholder="结束日期"
+        range-separator="至"
+        format="YYYY-MM-DD HH:mm:ss"
+        :prefix-icon="Calendar"
+        :disabled-date="disableDate"
+        :clearable="false"
+        @change="judgeDate"
+      />
+      <el-button style="margin-left: 10px" type="primary" @click="handleNvrUrl(null)"
+        >查看回放</el-button
+      >
+    </div>
+    <div>
+      <LiveVideo v-if="videoUrl" :url="videoUrl" @time-update="handleTimeUpdate" />
+    </div>
+    <div class="nvr-slider">
+      <NvrSlider
+        ref="nvrSliderRef"
+        v-if="confirmDate"
+        :start-time="dateRange[0]"
+        :end-time="dateRange[1]"
+        :start-position="100"
+        :violations="violationsList"
+        @update-nvr="handleNvrUrl"
+      />
+    </div>
+    <div class="nvr-setting-bar">
+      <NvrVioCheckbox :available="confirmDate" :camera-id="cameraId" @check-tags="handleVioTags" />
+      <NvrTimeSelect
+        ref="nvrTimeSelectRef"
+        @set-Time="handleSetTime"
+        @download-nvr="handleDownloadNvr"
+      />
+    </div>
+    <a ref="downloadRef" style="display: none" :href="downloadUrl" download />
+  </div>
+</template>
+
+<script setup lang="ts">
+  import NvrVioCheckbox from './NvrCheckbox.vue';
+  import NvrTimeSelect from './NvrTimeSelect.vue';
+  import NvrSlider from './NvrSlider.vue';
+  // import CameraLiveVideo from '@/views/cameras/preview/components/CameraLiveVideo/CameraLiveVideo.vue';
+  import { ref } from 'vue';
+  import LiveVideo from '@/components/LiveVideo/LiveVideo.vue';
+  import useCameraDetailStore from '@/views/cameras/preview/store/useCameraDetailStore';
+  import { dayjs, ElMessage } from 'element-plus';
+  import { computed } from 'vue';
+  import { InfoFilled, Calendar } from '@element-plus/icons-vue';
+  import {
+    getReplayNvr,
+    GetReplayNvrBody,
+    getRecordByTime,
+    GetRecordByTimeBody,
+    ViolationRecordItem,
+    getNvrDownloadUrl,
+  } from '@/api/datamanagement/playback';
+
+  defineProps<{ cameraId: number }>();
+  const cameraDetailStore = useCameraDetailStore();
+
+  // 日期范围
+  const dateRange = ref();
+  // 确认查看回放
+  const confirmDate = ref(false);
+  // 回放nvr地址
+  const nvrUrl = ref();
+  // 违例点列表
+  const violationsList = ref<ViolationRecordItem[]>([]);
+  // nvr滑动组件引用
+  const nvrSliderRef = ref();
+  // nvr下载组件引用
+  const nvrTimeSelectRef = ref();
+  // 下载
+  const downloadRef = ref();
+  const downloadUrl = ref();
+
+  const judgeDate = (date: Date[]) => {
+    // 判断日期范围大于半小时小于三个月
+    if (date && date.length === 2) {
+      const startTime = new Date(date[0]);
+      const endTime = new Date(date[1]);
+      if ((endTime.getTime() - startTime.getTime()) / (1000 * 60) < 1) {
+        ElMessage({
+          message: `选择回放时间范围不小于1分钟`,
+          type: 'error',
+        });
+        dateRange.value = undefined;
+        return;
+      }
+      // if (startTime.diff(endTime, 'month') > 3) {
+      //   ElMessage({
+      //     message: `选择回放时间范围不大于三个月`,
+      //     type: 'error',
+      //   });
+      //   dateRange.value = undefined;
+      //   return;
+      // }
+
+      confirmDate.value = false;
+      nvrUrl.value = undefined;
+    }
+  };
+  const handleNvrUrl = (nowTime?: Date | null) => {
+    if (!dateRange.value) {
+      return;
+    }
+    if (cameraDetailStore.detail?.pushstreamIp) {
+      cameraDetailStore.clear();
+    }
+    const nvrParams: GetReplayNvrBody = {
+      cameraId: cameraDetailStore.cameraId,
+      startTime: dayjs(dateRange.value[0]).format('YYYY-MM-DD HH:mm:ss'),
+      endTime: dayjs(dateRange.value[1]).format('YYYY-MM-DD HH:mm:ss'),
+    };
+    confirmDate.value = true;
+    if (nowTime) {
+      nvrParams.startTime = dayjs(nowTime).format('YYYY-MM-DD HH:mm:ss');
+    }
+    getReplayNvr(nvrParams).then((res) => {
+      nvrUrl.value = res.data;
+    });
+  };
+
+  const handleVioTags = (tags: string[]) => {
+    if (tags.length === 0) {
+      violationsList.value = [];
+      return;
+    }
+    const violationsParams: GetRecordByTimeBody = {
+      algoList: tags.map((tag) => Number(tag)),
+      cameraId: cameraDetailStore.cameraId,
+      startTime: dayjs(dateRange.value[0]).format('YYYY-MM-DD HH:mm:ss'),
+      endTime: dayjs(dateRange.value[1]).format('YYYY-MM-DD HH:mm:ss'),
+    };
+    getRecordByTime(violationsParams).then((res) => {
+      violationsList.value = res;
+    });
+  };
+
+  const handleSetTime = (isStart: boolean) => {
+    if (!confirmDate.value) {
+      return;
+    }
+    if (isStart) {
+      const end = nvrTimeSelectRef.value.endTime;
+      if (end.length > 0 && new Date(end) <= new Date(nvrSliderRef.value.onTime)) {
+        ElMessage({
+          message: `结束时间不早于开始时间`,
+          type: 'error',
+        });
+        return;
+      }
+      nvrTimeSelectRef.value.startTime = dayjs(nvrSliderRef.value.onTime).format(
+        'YYYY-MM-DD HH:mm:ss',
+      );
+    } else {
+      const start = nvrTimeSelectRef.value.startTime;
+      if (start.length > 0 && new Date(start) >= new Date(nvrSliderRef.value.onTime)) {
+        ElMessage({
+          message: `开始时间不晚于结束时间`,
+          type: 'error',
+        });
+        return;
+      }
+      nvrTimeSelectRef.value.endTime = dayjs(nvrSliderRef.value.onTime).format(
+        'YYYY-MM-DD HH:mm:ss',
+      );
+    }
+  };
+
+  const handleTimeUpdate = () => {
+    nvrSliderRef.value.pushTime();
+  };
+
+  const handleDownloadNvr = () => {
+    if (
+      !confirmDate.value ||
+      nvrTimeSelectRef.value.startTime.length === 0 ||
+      nvrTimeSelectRef.value.endTime.length === 0
+    ) {
+      return;
+    }
+    const nvrParams: GetReplayNvrBody = {
+      cameraId: cameraDetailStore.cameraId,
+      startTime: dayjs(nvrTimeSelectRef.value.startTime).format('YYYY-MM-DD HH:mm:ss'),
+      endTime: dayjs(nvrTimeSelectRef.value.endTime).format('YYYY-MM-DD HH:mm:ss'),
+    };
+    getNvrDownloadUrl(nvrParams).then((res) => {
+      downloadUrl.value = res;
+      downloadRef.value.click();
+
+      // const link = document.createElement('a');
+      // link.href = res;
+      // link.setAttribute('download', 'nvr.mp4');
+      // link.style.display = 'none';
+      // document.body.appendChild(link);
+      // link.click();
+      // document.body.removeChild(link);
+    });
+  };
+
+  const disableDate = (time: Date) => {
+    const now = new Date();
+    return time > now || time < new Date(now.setDate(now.getDate() - 90));
+  };
+
+  const videoUrl = computed(() => {
+    return nvrUrl.value
+      ? nvrUrl.value
+      : cameraDetailStore.detail?.pushstreamIp
+      ? cameraDetailStore.detail?.pushstreamIp
+      : '';
+  });
+</script>
+
+<style scoped lang="scss">
+  .nvr-tips {
+    margin: 5px 0;
+    width: 100%;
+    height: 40px;
+    background: #ecf5ff;
+    border-radius: 2px;
+    font-family: 'PingFangSC', 'PingFang SC';
+    font-weight: 400;
+    font-size: 13px;
+    color: #606266;
+    line-height: 40px;
+    text-align: left;
+    font-style: normal;
+    text-transform: none;
+    display: flex;
+    align-items: center;
+  }
+
+  .nvr-date-picker {
+    margin: 10px 0 10px 20px;
+  }
+
+  .nvr-slider {
+    height: 80px;
+    position: relative;
+  }
+
+  .nvr-setting-bar {
+    display: flex;
+    justify-content: center;
+  }
+</style>

+ 77 - 0
src/views/datamanager/playback/components/NvrCheckbox.vue

@@ -0,0 +1,77 @@
+<template>
+  <div class="nvr-checkbox">
+    <div class="head">
+      <div class="line"></div>
+      <div class="title">违规事件标记</div>
+    </div>
+
+    <div class="checkbox">
+      <el-checkbox-group v-if="tags?.length" v-model="checkedtags">
+        <el-checkbox
+          v-for="tag in tags"
+          :disabled="!available"
+          :key="tag.algoInfo.id"
+          :label="tag.algoInfo.name"
+          :value="tag.algoInfo.id"
+          @change="sendCheckTag"
+        >
+        </el-checkbox>
+      </el-checkbox-group>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ElCheckboxGroup, ElCheckbox } from 'element-plus';
+  import { onMounted, ref } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import useCameraAlgoStore from '@/views/cameras/preview/store/useCameraAlgoStore';
+
+  const cameraAlgoStore = useCameraAlgoStore();
+  const { cameraAlgoList } = storeToRefs(cameraAlgoStore);
+  const checkedtags = ref<any[] | undefined>([]);
+  const tags = ref<any>([]);
+
+  const props = defineProps<{ available: boolean; cameraId: number }>();
+
+  const emit = defineEmits(['checkTags']);
+  const sendCheckTag = () => {
+    emit('checkTags', checkedtags.value);
+  };
+
+  onMounted(() => {
+    cameraAlgoStore.getCameraAlgoList(props.cameraId).then(() => {
+      // checkedtags.value = cameraAlgoList.value?.map((item) => item.algoInfo.id); // 默认选中
+      tags.value = cameraAlgoList.value;
+    });
+  });
+</script>
+
+<style scoped lang="scss">
+  .nvr-checkbox {
+    width: 50%;
+    height: 100vh;
+    background-color: #ffffff;
+    .head {
+      display: flex;
+      align-items: center;
+      margin-left: 8px;
+      margin-top: 14px;
+      .line {
+        width: 4px;
+        height: 16px;
+        background: #1890ff;
+      }
+      .title {
+        font-size: 14px;
+        font-weight: 500;
+        color: rgba(0, 0, 0, 0.88);
+        line-height: 20px;
+        margin-left: 8px;
+      }
+    }
+    .checkbox {
+      padding: 12px 22px 0 22px;
+    }
+  }
+</style>

+ 251 - 0
src/views/datamanager/playback/components/NvrSlider.vue

@@ -0,0 +1,251 @@
+<template>
+  <div class="nvr-track" @mousedown="onDragStart">
+    <div class="run-line" :style="{ left: `${startPosition}px` }"></div>
+    <div class="run-time" :style="{ left: `${startPosition + 3}px` }">{{
+      dayjs(onTime).format('YYYY-MM-DD HH:mm')
+    }}</div>
+    <div id="timeline" class="nvr-slider" :style="{ transform: `translateX(${sliderPosition}px)` }">
+      <div id="violations"></div>
+      <div id="violationsLine" class="nvr-violations-line"> </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, onMounted, watch, computed } from 'vue';
+  import { dayjs } from 'element-plus';
+  import { ViolationRecordItem } from '@/api/datamanagement/playback';
+
+  const emit = defineEmits(['updateNvr']);
+
+  // 定义长条的位置
+  const sliderPosition = ref(0);
+
+  // 记录鼠标按下时的信息
+  const isDragging = ref(false);
+  const initialMousePosition = ref(0);
+  const initialSliderPosition = ref(0);
+
+  // 鼠标按下时添加移动和抬起监听
+  const onDragStart = (event) => {
+    event.preventDefault();
+    isDragging.value = true;
+    initialMousePosition.value = event.clientX;
+    initialSliderPosition.value = sliderPosition.value;
+    document.addEventListener('mousemove', onDragging);
+    document.addEventListener('mouseup', onDragEnd);
+  };
+
+  // 鼠标移动时触发
+  const onDragging = (event) => {
+    if (!isDragging.value) return;
+    const delta = initialSliderPosition.value + (event.clientX - initialMousePosition.value);
+    sliderPosition.value =
+      delta > props.startPosition
+        ? props.startPosition
+        : delta < props.startPosition - durationMins.value
+        ? props.startPosition - durationMins.value
+        : delta;
+  };
+
+  // 鼠标抬起时去除监听器
+  const onDragEnd = () => {
+    isDragging.value = false;
+    document.removeEventListener('mousemove', onDragging);
+    document.removeEventListener('mouseup', onDragEnd);
+    emit('updateNvr', onTime.value);
+  };
+
+  onMounted(() => {
+    createTimeline(props.startTime, durationMins.value);
+
+    sliderPosition.value = props.startPosition;
+
+    watch([() => props.startTime, () => props.endTime], () => {
+      createTimeline(props.startTime, durationMins.value);
+      sliderPosition.value = props.startPosition;
+    });
+
+    watch(
+      () => props.violations,
+      () => {
+        createViolationsLine(props.violations!, props.startTime, props.endTime);
+      },
+    );
+  });
+
+  // 传入起止时间 传入违规点数组 违规点数组只需要时间点这个属性 画在拉动条上
+  const props = defineProps({
+    startTime: {
+      type: Date,
+      required: true,
+    },
+    endTime: {
+      type: Date,
+      required: true,
+    },
+    violations: {
+      type: Array<ViolationRecordItem>,
+      required: false,
+    },
+    startPosition: {
+      type: Number,
+      required: true,
+    },
+  });
+
+  // 时间条长度计算
+  const durationMins = computed(() => {
+    return Math.floor((props.endTime.valueOf() - props.startTime.valueOf()) / (1000 * 60));
+  });
+
+  // 计算停驻时间
+  const onTime = computed(() => {
+    const res =
+      Number(props.startTime.valueOf()) + (props.startPosition - sliderPosition.value) * 1000 * 60;
+    return new Date(res);
+  });
+
+  // 推动时间前进
+  const pushTime = () => {
+    const delta = sliderPosition.value - 1;
+    sliderPosition.value =
+      delta > props.startPosition
+        ? props.startPosition
+        : delta < props.startPosition - durationMins.value
+        ? props.startPosition - durationMins.value
+        : delta;
+  };
+
+  const createTimeline = (startTime: Date, durationMins: number) => {
+    const container = document.getElementById('timeline');
+    container!.innerHTML = '';
+
+    const startHours = startTime.getHours();
+    const marginMins = startTime.getMinutes();
+    // 拉动条的长度设置为分钟数
+    container!.style.width = `${durationMins}px`;
+    // 计算持续小时数 向下取整
+    const duration = Math.floor(durationMins / 60);
+
+    for (let i = 1; i <= duration; i++) {
+      // 刻度元素
+      const mark = document.createElement('div');
+      mark.className = 'time-slider-mark';
+      mark.style.left = `${i * 60 - marginMins}px`;
+      container?.appendChild(mark);
+
+      // 刻度文本
+      const text = document.createElement('span');
+      text.className = 'time-slider-text';
+      text.textContent = `${(startHours + i) % 24}:00`;
+      text.style.left = `${i * 60 - marginMins}px`;
+      container?.appendChild(text);
+    }
+  };
+
+  const createViolationsLine = (
+    violations: ViolationRecordItem[],
+    startTime: Date,
+    endTime: Date,
+  ) => {
+    const container = document.getElementById('timeline');
+    const remove = container!.querySelector('#violationsLine');
+
+    if (remove) {
+      container!.removeChild(remove);
+    }
+
+    // 创建违例时间条
+    const vLine = document.createElement('div');
+    vLine.id = 'violationsLine';
+
+    vLine.className = 'violations-line';
+    container!.appendChild(vLine);
+
+    violations.forEach((item) => {
+      const vTime = new Date(item.time);
+      if (vTime.valueOf() < startTime.valueOf() || vTime.valueOf() > endTime.valueOf()) {
+        return;
+      }
+      const vPoint = document.createElement('div');
+      vPoint.className = 'violations-point';
+      vPoint.style.left = `${(vTime.valueOf() - startTime.valueOf()) / (1000 * 60) - 5}px`;
+      vLine.appendChild(vPoint);
+    });
+  };
+
+  defineExpose({
+    onTime,
+    pushTime,
+  });
+</script>
+
+<style scoped lang="scss">
+  .nvr-track {
+    width: 100%;
+    height: 80px;
+    position: absolute;
+    overflow: hidden;
+    background-color: #d8d8d8;
+
+    .run-line {
+      position: absolute;
+      width: 2px;
+      height: 80px;
+      background-color: #1890ff;
+      z-index: 3;
+    }
+    .run-time {
+      position: absolute;
+      background-color: #1890ff;
+      z-index: 3;
+      top: 50px;
+      font-size: 12px;
+      color: #ffffff;
+      line-height: 17px;
+      text-align: left;
+      background: #1890ff;
+      border-radius: 10px;
+      padding: 3px 6px;
+    }
+  }
+
+  #timeline {
+    cursor: pointer;
+    position: relative;
+    background-color: #f6f7f8;
+    height: 80px;
+    z-index: 1;
+  }
+</style>
+
+<style>
+  .time-slider-mark {
+    position: absolute;
+    width: 1px;
+    height: 15px;
+    background-color: #000;
+  }
+  .time-slider-text {
+    position: absolute;
+    top: 15px;
+    color: #000;
+  }
+  .violations-line {
+    overflow: hidden;
+    position: absolute;
+    width: 100%;
+    height: 8px;
+    top: 36px;
+    background-color: #d8d8d8;
+    z-index: 2;
+  }
+  .violations-point {
+    position: absolute;
+    width: 10px;
+    height: 8px;
+    background-color: #1890ff;
+    z-index: 3;
+  }
+</style>

+ 105 - 0
src/views/datamanager/playback/components/NvrTimeSelect.vue

@@ -0,0 +1,105 @@
+<template>
+  <div class="nvr-timeselect">
+    <div class="head">
+      <div class="line"></div>
+      <div class="title">视频下载</div>
+    </div>
+
+    <div class="timeselect">
+      <div class="time-picker">
+        开始时间:
+        <el-input
+          v-model="startTime"
+          style="width: 160px"
+          disabled
+          placeholder="拖动进度条选择时间"
+        />
+        <img
+          @click="callSetTime(true)"
+          style="cursor: pointer"
+          src="@\assets\icons\edit.png"
+          alt=""
+        />
+      </div>
+      <div class="time-picker">
+        结束时间:
+        <el-input
+          v-model="endTime"
+          style="width: 160px"
+          disabled
+          placeholder="拖动进度条选择时间"
+        />
+        <img
+          @click="callSetTime(false)"
+          style="cursor: pointer"
+          src="@\assets\icons\edit.png"
+          alt=""
+        />
+      </div>
+
+      <el-button type="primary" @click="nvrDownload">下 载</el-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { ElInput } from 'element-plus';
+
+  const startTime = ref('');
+  const endTime = ref('');
+
+  // const setStartTime = (date: Date) => {
+  //   startTime.value = dayjs(date).format('YYYY-MM-DD HH:mm:ss');
+  // };
+  // const setEndTime = (date: Date) => {
+  //   startTime.value = dayjs(date).format('YYYY-MM-DD HH:mm:ss');
+  // };
+  const emit = defineEmits(['setTime', 'downloadNvr']);
+
+  const callSetTime = (isStart: boolean) => {
+    emit('setTime', isStart);
+  };
+
+  const nvrDownload = () => {
+    emit('downloadNvr');
+  };
+
+  defineExpose({ startTime, endTime });
+</script>
+
+<style scoped lang="scss">
+  .nvr-timeselect {
+    width: 50%;
+    height: 100vh;
+    background-color: #ffffff;
+    .head {
+      display: flex;
+      align-items: center;
+      margin-left: 8px;
+      margin-top: 14px;
+      .line {
+        width: 4px;
+        height: 16px;
+        background: #1890ff;
+      }
+      .title {
+        font-size: 14px;
+        font-weight: 500;
+        color: rgba(0, 0, 0, 0.88);
+        line-height: 20px;
+        margin-left: 8px;
+      }
+    }
+    .timeselect {
+      padding: 12px 22px 0 22px;
+      .time-picker {
+        margin-bottom: 12px;
+      }
+      img {
+        display: inline;
+        margin-left: 20px;
+      }
+    }
+  }
+</style>