sxy il y a 1 mois
Parent
commit
5c4e0a8d32

Fichier diff supprimé car celui-ci est trop grand
+ 19513 - 1
package-lock.json


+ 1 - 0
package.json

@@ -47,6 +47,7 @@
     "highlight.js": "9.18.5",
     "hls.js": "^1.5.11",
     "html-webpack-plugin": "^5.6.0",
+    "hz-player": "^0.0.6",
     "js-beautify": "1.13.0",
     "js-cookie": "3.0.1",
     "jsencrypt": "3.0.0-rc.1",

+ 4 - 3
public/config.js

@@ -1,6 +1,7 @@
 const configPage={
-  "httpServe":'http://127.0.0.1:8081'
-  // "httpServe":'/prod-api'
-  // "httpServe":'http://407rm551yp60.vicp.fun'
+  // "httpServe":'http://127.0.0.1:8081'
+  // bi打包修改为这个
+  "httpServe":'/prod-api'
+  // "httpServe":'http://127.0.0.1:8080'
 }
 module.exports={configPage}

+ 1 - 0
public/index.html

@@ -16,6 +16,7 @@
         body,
         #app {
             height: 100%;
+            background: #1a1a2e;
             margin: 0px;
             padding: 0px;
         }

+ 56 - 47
src/components/Jessibuca/index.vue

@@ -36,7 +36,7 @@ import { formatDate, formatTime } from "../../utils";
 import { addIcons } from "oh-vue-icons";
 import { mapGetters } from "vuex";
 import { OiMute, OiUnmute } from "oh-vue-icons/icons";
-import { configPage } from '/public/config'
+import HZPlayer from 'hz-player';
 addIcons(OiMute, OiUnmute);
 
 let jessibucaPlayer = {}
@@ -68,6 +68,10 @@ export default {
     }
   },
   props: {
+    videoId: {
+      type: Number,
+      default: 0
+    },
     videoUrl: {
       type: String,
       default: ''
@@ -149,8 +153,6 @@ export default {
       //    height = document.querySelector('.video-zoom').clientHeight
       // }
 
-
-
       const clientHeight = Math.min(document.body.clientHeight, document.documentElement.clientHeight)
       if (height > clientHeight) {
         height = clientHeight
@@ -162,14 +164,14 @@ export default {
     },
     create() {
       let options = {}
-      jessibucaPlayer[this._uid] = new Jessibuca(Object.assign(
+      jessibucaPlayer[this._uid] = new HZPlayer(Object.assign(
         {
           container: this.$refs.container,
           autoWasm: true,
           background: '',
           controlAutoHide: false,
           debug: false,
-          decoder: 'jessibuca/decoder.js',
+          decoder: '/hz-player/src/hzPlayer/decoder-pro.js',
           forceNoOffscreen: true,
           hasAudio: typeof (this.hasAudio) == 'undefined' ? true : this.hasAudio,
           hasVideo: true,
@@ -200,10 +202,10 @@ export default {
           showBandwidth: false,
           supportDblclickFullscreen: false,
           timeout: 10,
-          useMSE: false,
-          useOffscreen: false,
-          useWCS: location.hostname === 'localhost' || location.protocol === 'https',
-          useWebFullScreen: true,
+          // useMSE: false,
+          // useOffscreen: false,
+          // useWCS: location.hostname === 'localhost' || location.protocol === 'https',
+          useWebFullScreen: false,
           videoBuffer: 0,
           wasmDecodeAudioSyncVideo: true,
           wasmDecodeErrorReplay: true,
@@ -213,53 +215,53 @@ export default {
       ))
       let jessibuca = jessibucaPlayer[this._uid]
       let _this = this
-      jessibuca.on('load', function () {
+      jessibuca.on('load', function() {
         console.log('on load init')
       })
-      jessibuca.on('log', function (msg) {
+      jessibuca.on('log', function(msg) {
         console.log('on log', msg)
       })
-      jessibuca.on('record', function (msg) {
+      jessibuca.on('record', function(msg) {
         console.log('on record:', msg)
       })
-      jessibuca.on('pause', function () {
+      jessibuca.on('pause', function() {
         _this.playing = false
       })
-      jessibuca.on('play', function () {
+      jessibuca.on('play', function() {
         _this.playing = true
       })
-      jessibuca.on('fullscreen', function (msg) {
+      jessibuca.on('fullscreen', function(msg) {
         console.log('on fullscreen', msg)
         _this.fullscreen = msg
       })
 
-      jessibuca.on('mute', function (msg) {
+      jessibuca.on('mute', function(msg) {
         console.log('on mute', msg)
         _this.isNotMute = !msg
       })
-      jessibuca.on('audioInfo', function (msg) {
+      jessibuca.on('audioInfo', function(msg) {
         console.log('audioInfo', msg)
       })
 
-      jessibuca.on('bps', function (bps) {
+      jessibuca.on('bps', function(bps) {
         // console.log('bps', bps);
 
       })
       let _ts = 0
-      jessibuca.on('timeUpdate', function (ts) {
+      jessibuca.on('timeUpdate', function(ts) {
         // console.log('timeUpdate,old,new,timestamp', _ts, ts, ts - _ts);
         _ts = ts
       })
 
-      jessibuca.on('videoInfo', function (info) {
+      jessibuca.on('videoInfo', function(info) {
         console.log('videoInfo', info)
       })
 
-      jessibuca.on('error', function (error) {
+      jessibuca.on('error', function(error) {
         console.log('error', error)
       })
 
-      jessibuca.on('timeout', function () {
+      jessibuca.on('timeout', function() {
         console.log('timeout')
       })
 
@@ -268,43 +270,43 @@ export default {
         this.$emit('autoplay', true)
       })
 
-      jessibuca.on('performance', function (performance) {
+      jessibuca.on('performance', function(performance) {
         let show = '卡顿'
         if (performance === 2) {
           show = '非常流畅'
         } else if (performance === 1) {
           show = '流畅'
         }
-        console.log(show)
+        // console.log(show)
         _this.performance = show
       })
-      jessibuca.on('buffer', function (buffer) {
+      jessibuca.on('buffer', function(buffer) {
         // console.log('buffer', buffer);
       })
 
-      jessibuca.on('stats', function (stats) {
+      jessibuca.on('stats', function(stats) {
         // console.log('stats', stats);
       })
 
-      jessibuca.on('kBps', function (kBps) {
+      jessibuca.on('kBps', function(kBps) {
         _this.kBps = Math.round(kBps)
       })
 
       // 显示时间戳 PTS
-      jessibuca.on('videoFrame', function () {
+      jessibuca.on('videoFrame', function() {
       })
 
       //
-      jessibuca.on('metadata', function () {
+      jessibuca.on('metadata', function() {
       })
       this.jessibuca = jessibuca
       // 添加对播放容器数量的监听
       // this.addSubscribe()
     },
-    playBtnClick: function (event) {
+    playBtnClick: function(event) {
       this.play(this.videoUrl)
     },
-    play: function (url) {
+    play: function(url) {
       console.log(url)
       if (jessibucaPlayer[this._uid]) {
         this.destroy()
@@ -324,7 +326,7 @@ export default {
         })
       }
     },
-    pause: function () {
+    pause: function() {
       if (jessibucaPlayer[this._uid]) {
         jessibucaPlayer[this._uid].pause()
       }
@@ -344,7 +346,7 @@ export default {
 
       return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`;
     },
-    screenshot: function () {
+    screenshot: function() {
       const currentDateTime = this.getCurrentDateTime();
       console.log(this.boxList);
       const item = this.boxList.find(box => box.url === this.videoUrl);
@@ -352,7 +354,7 @@ export default {
 
       const fileName = `${currentDateTime}-${this.cameraName}-${this.$store.getters.name}.jpg`;
       const saveDir = "images";
-      const url = configPage.httpServe+`/downloadSnapshot?streamUrl=${item.rtsp}&fileName=${fileName}`;
+      const url = process.env.VUE_APP_BASE_API + `/downloadSnapshot?streamUrl=${item.rtsp}&fileName=${fileName}`;
 
       fetch(url, {
         method: 'GET',
@@ -381,40 +383,44 @@ export default {
         })
         .catch(error => console.error('Error downloading file:', error));
     },
-    startRecord: function () {
+    startRecord: function() {
       if (jessibucaPlayer[this._uid]) {
         this.recording = true
+        this.boxList[this.videoId - 1].recording = true
         // 录像开始时间
         this.startRecordTime = new Date().getTime()
         jessibucaPlayer[this._uid].startRecord(`${formatDate(new Date().getTime())}-${this.cameraName}-${this.$store.getters.name}`, 'mp4')
       }
     },
-    stopRecordAndSave: function () {
+    stopRecordAndSave: function() {
       if (jessibucaPlayer[this._uid]) {
-        this.recording = false
+
         if (parseInt((Number(new Date().getTime()) - Number(this.startRecordTime)) / 1000) <= 10) {
           this.$message({ message: '无效录像,录像时间太短,大于10s录像开始保存', type: "warning" })
         } else {
+          this.recording = false
+          this.boxList[this.videoId - 1].recording = false
           jessibucaPlayer[this._uid].stopRecordAndSave()
+          // 重置开始时间
+          this.startRecordTime = ''
         }
-        // 重置开始时间
-        this.startRecordTime = ''
+
       }
     },
-    isRecording: function () {
+    isRecording: function() {
       return jessibucaPlayer[this._uid].isRecording()
     },
-    mute: function () {
+    mute: function() {
       if (jessibucaPlayer[this._uid]) {
         jessibucaPlayer[this._uid].mute()
       }
     },
-    cancelMute: function () {
+    cancelMute: function() {
       if (jessibucaPlayer[this._uid]) {
         jessibucaPlayer[this._uid].cancelMute()
       }
     },
-    destroy: function () {
+    destroy: function() {
       if (jessibucaPlayer[this._uid]) {
         jessibucaPlayer[this._uid].destroy()
       }
@@ -428,17 +434,17 @@ export default {
       this.timeInterval = null
       this.$emit('destroy')
     },
-    eventcallbacK: function (type, message) {
+    eventcallbacK: function(type, message) {
       // console.log("player 事件回调")
       // console.log(type)
       // console.log(message)
     },
-    fullscreenSwich: function () {
+    fullscreenSwich: function() {
       let isFull = this.isFullscreen()
       jessibucaPlayer[this._uid].setFullscreen(!isFull)
       this.fullscreen = !isFull
     },
-    isFullscreen: function () {
+    isFullscreen: function() {
       return document.fullscreenElement ||
         document.msFullscreenElement ||
         document.mozFullScreenElement ||
@@ -504,14 +510,17 @@ export default {
   font-size: 0.8rem !important;
   transition: transform 0.1s, color 0.1s; /* 添加过渡效果 */
 }
+
 .jessibuca-btn:hover {
   transform: scale(1.3); /* 放大 */
   color: #409EFF; /* 高亮 */
 }
-::v-deep .el-icon-video-camera-solid{
+
+::v-deep .el-icon-video-camera-solid {
   color: #ff0000;
   animation: breathe 2s infinite;
 }
+
 @keyframes breathe {
   0% {
     transform: scale(1);

+ 28 - 1
src/components/TimelineCanvas/index.vue

@@ -115,6 +115,9 @@ export default {
       distance: 0,
       //是否在播放中
       isPlay: false,
+
+      isMouseOver: false, // 鼠标是否在画布上
+      lastMousePos: null, // 记录最后一次鼠标位置
     };
   },
   mounted() {
@@ -140,8 +143,15 @@ export default {
     if (this.isAutoPlay) {
       this.play();
     }
+
+    if (!this.isMobile) {
+      // 新增:监听鼠标进入/离开
+      this.canvas.addEventListener("mouseenter", this.mouseenter);
+      this.canvas.addEventListener("mouseleave", this.mouseleave);
+    }
   },
   methods: {
+
     init() {
       this.canvas = this.$refs.canvas;
 
@@ -283,8 +293,16 @@ export default {
       }
       this.mousedown(e);
     },
+    mouseenter(e) {
+      this.isMouseOver = true; // 标记鼠标在画布上
+      this.lastMousePos = { offsetX: e.offsetX, offsetY: e.offsetY }; // 记录初始位置
+      this.drow();
+      this.drowMoveLine(e); // 首次进入立即绘制
+    },
     //鼠标离开
     mouseleave(e) {
+      this.isMouseOver = false; // 标记鼠标离开
+      this.lastMousePos = null;
       this.drow();
       //鼠标离开无法在触发mouseup,所以当拖动时将离开视释放
       if (this.mouseDown) {
@@ -295,6 +313,9 @@ export default {
     },
     // 鼠标移动
     mousemove(e) {
+      // 先记录鼠标位置,再执行drow()
+      this.lastMousePos = { offsetX: e.offsetX, offsetY: e.offsetY };
+      this.isMouseOver = true;
       this.drow();
       //PC滑动显示时间
       if (!this.isMobile) {
@@ -375,6 +396,11 @@ export default {
       this.drowMark();
       this.drowScaleLine();
       this.drowMeddleLine(this.meddleTime);
+
+      // 👇 新增:如果鼠标在画布上,自动绘制悬浮线
+      if (!this.isMobile && this.isMouseOver && this.lastMousePos) {
+        this.drowMoveLine(this.lastMousePos);
+      }
     },
     // 画鼠标移上去的线
     drowMoveLine(e) {
@@ -613,7 +639,8 @@ export default {
       let second = (this.whole_hour * 60 * 60) / this.canvasWidth;
       // 保证不管缩小到什么宽度,刻度线都不会挤压在一起
       if (second > this.minPxSecond) second = this.minPxSecond;
-      return second;
+      // 优化:保留2位小数,减少浮点数误差
+      return Number(second.toFixed(2));
     },
     // 计算timeRange 兼容string |Array转换
     realTimeRange() {

+ 52 - 5
src/components/WebrtcPlayer/index.vue

@@ -107,7 +107,10 @@ export default {
         rectHeight: 0,
         rectCenterOffsetX: 0,
         rectCenterOffsetY: 0
-      }
+      },
+      reconnectTimer: null, // 用于存放重连的定时器
+      reconnectCount: 0,  // 当前重连次数
+      maxReconnectAttempts: 5 // 设置最大重连次数,防止无限重连
     }
   },
   computed: {
@@ -118,6 +121,9 @@ export default {
       if (newData !== '' && newData !== oldData) {
         this.initVideo(this.videoSrc, this.videoId);
         this.rectZoomInit()
+
+
+
       } else {
         this.stop()
       }
@@ -216,11 +222,20 @@ export default {
       // })
       this.player.on(ZLMRTCClient.Events.WEBRTC_ON_CONNECTION_STATE_CHANGE, (state) =>{
         console.log('当前状态==>', state)
-        if(state === 'timeout'){
-          this.$message({ message: '视频流连接超时!请检查流接入转发配置信息', type: "error" })
+        // 当连接失败、断开、关闭或超时时,启动重连机制
+        if (state === 'failed' || state === 'disconnected' || state === 'closed' || state === 'timeout') {
+          this.$message({ message: '视频流连接已断开,正在尝试重连...', type: "warning" });
+          this.handleReconnect();
+        } else if (state === 'connected') {
+          // 连接成功后,清除重连定时器并重置计数器
+          console.log("视频流连接成功!");
+          this.reconnectCount = 0;
+          if (this.reconnectTimer) {
+            clearTimeout(this.reconnectTimer);
+            this.reconnectTimer = null;
+          }
         }
-        // console.log(new Date().getTime())
-      })
+      });
       this.player.on(ZLMRTCClient.Events.WEBRTC_ON_DATA_CHANNEL_OPEN, function (e) {
         // console.log('rtc datachannel 打开:', e)
       })
@@ -234,7 +249,35 @@ export default {
         // console.log('rtc datachannel 关闭:', e)
       })
     },
+    handleReconnect() {
+      if (this.reconnectCount < this.maxReconnectAttempts) {
+        this.reconnectCount++;
+        console.log(`正在进行第 ${this.reconnectCount} 次重连...`);
+
+        // 清除之前的定时器
+        if (this.reconnectTimer) {
+          clearTimeout(this.reconnectTimer);
+        }
+
+        // 设置一个延时后重连,避免过于频繁
+        this.reconnectTimer = setTimeout(() => {
+          this.initVideo(this.videoSrc, this.videoId);
+        }, 3000); // 3秒后尝试重连
+      } else {
+        this.$message({ message: `已达到最大重连次数 (${this.maxReconnectAttempts}次),停止重连。请手动刷新或检查网络。`, type: "error" });
+        // 停止重连后,清除定时器
+        if (this.reconnectTimer) {
+          clearTimeout(this.reconnectTimer);
+          this.reconnectTimer = null;
+        }
+      }
+    },
     stop() {
+      // 手动停止播放时,也应清除重连定时器
+      if (this.reconnectTimer) {
+        clearTimeout(this.reconnectTimer);
+        this.reconnectTimer = null;
+      }
       let videoDom = document.getElementById(this.videoId)
       videoDom.pause()
       if (this.player) {
@@ -571,6 +614,10 @@ export default {
     },
   },
   beforeDestroy() {
+    // 组件销毁前,清除重连定时器
+    if (this.reconnectTimer) {
+      clearTimeout(this.reconnectTimer);
+    }
     if (this.player) {
       this.player.pc.close()
       this.player = null

Fichier diff supprimé car celui-ci est trop grand
+ 150 - 604
src/layout/index.vue


+ 2 - 2
src/permission.js

@@ -10,7 +10,7 @@ import Cookies from 'js-cookie'
 
 NProgress.configure({ showSpinner: false })
 
-const whiteList = ['/login', '/register']
+const whiteList = ['/login', '/register','/*']
 
 router.beforeEach((to, from, next) => {
   // 如何存在标识
@@ -22,7 +22,7 @@ router.beforeEach((to, from, next) => {
   let toLivePreview=new URLSearchParams(window.location.search).get('toLivePreview')
   if ((to.path === '/index' && to.query.toLivePreview === 'true')|| (to.path === '/index' &&to.query.toVideoPlayback === 'true')) {
     // 已登录则直接进入
-    if (getToken()) {
+    if (!getToken()) {
       next()
       return
     }

+ 47 - 18
src/views/livePreview/bigLivePreview.vue

@@ -230,20 +230,20 @@
                 <div class="move-btn">
                   <div class="btn" style="align-self: flex-end">
                     <i class="btn el-icon-arrow-up" style="transform: rotate(-45deg); cursor: pointer"
-                       @mousedown="handleMove('leftUp')" @mouseup="handleMove('stop')"
+                       @mousedown="ptzStart('leftUp')"
                     />
                   </div>
                   <div class="btn" style="margin-bottom: 13px; cursor: pointer">
-                    <i class="btn el-icon-arrow-up" @mousedown="handleMove('up')" @mouseup="handleMove('stop')"/>
+                    <i class="btn el-icon-arrow-up" @mousedown="ptzStart('up')" />
                   </div>
                   <div class="btn" style="align-self: flex-end">
                     <i class="btn el-icon-arrow-up" style="transform: rotate(45deg); cursor: pointer"
-                       @mousedown="handleMove('rightUp')" @mouseup="handleMove('stop')"
+                       @mousedown="ptzStart('rightUp')"
                     />
                   </div>
                   <div class="btn" style="margin-right: 13px">
                     <i class="btn el-icon-arrow-up" style="transform: rotate(-90deg); cursor: pointer"
-                       @mousedown="handleMove('left')" @mouseup="handleMove('stop')"
+                       @mousedown="ptzStart('left')"
                     />
                   </div>
                   <div class="btn" style="">
@@ -251,22 +251,22 @@
                   </div>
                   <div class="btn" style="margin-left: 13px">
                     <i class="btn el-icon-arrow-up" style="transform: rotate(90deg); cursor: pointer"
-                       @mousedown="handleMove('right')" @mouseup="handleMove('stop')"
+                       @mousedown="ptzStart('right')"
                     />
                   </div>
                   <div class="btn" style="align-self: flex-start">
                     <i class="btn el-icon-arrow-up" style="transform: rotate(225deg); cursor: pointer"
-                       @mousedown="handleMove('leftDown')" @mouseup="handleMove('stop')"
+                       @mousedown="ptzStart('leftDown')"
                     />
                   </div>
                   <div class="btn" style="margin-top: 13px">
                     <i class="btn el-icon-arrow-up" style="transform: rotate(180deg); cursor: pointer"
-                       @mousedown="handleMove('down')" @mouseup="handleMove('stop')"
+                       @mousedown="ptzStart('down')"
                     />
                   </div>
                   <div class="btn" style="align-self: flex-start">
                     <i class="btn el-icon-arrow-up" style="transform: rotate(135deg); cursor: pointer"
-                       @mousedown="handleMove('rightDown')" @mouseup="handleMove('stop')"
+                       @mousedown="ptzStart('rightDown')"
                     />
                   </div>
                 </div>
@@ -277,21 +277,17 @@
                     <i class="el-icon-zoom-out" style="cursor: pointer" @click="delSpeed()"></i>
                   </div>
                   <div class="btn2">
-                    <i class="el-icon-zoom-in" style="cursor: pointer" @mousedown="handleFocusing('up')"
-                       @mouseup="handleFocusing('stop')"
+                    <i class="el-icon-zoom-in" style="cursor: pointer" @mousedown="ptzFocusStart('up')"
                     ></i>
                     <span>变倍</span>
-                    <i class="el-icon-zoom-out" style="cursor: pointer" @mousedown="handleFocusing('down')"
-                       @mouseup="handleFocusing('stop')"
+                    <i class="el-icon-zoom-out" style="cursor: pointer" @mousedown="ptzFocusStart('down')"
                     ></i>
                   </div>
                   <div class="btn2">
-                    <i class="el-icon-zoom-in" style="cursor: pointer" @mousedown="handleFocusing('focusOut')"
-                       @mouseup="handleFocusing('stop')"
+                    <i class="el-icon-zoom-in" style="cursor: pointer" @mousedown="ptzFocusStart('focusOut')"
                     ></i>
                     <span>变焦</span>
-                    <i class="el-icon-zoom-out" style="cursor: pointer" @mousedown="handleFocusing('focusIn')"
-                       @mouseup="handleFocusing('stop')"
+                    <i class="el-icon-zoom-out" style="cursor: pointer" @mousedown="ptzFocusStart('focusIn')"
                     ></i>
                   </div>
                 </div>
@@ -637,7 +633,7 @@ export default {
           setTimeout(() => {
             if (node) {
               this.$nextTick(() => {
-                node.scrollIntoView({ block: 'center' }) // 通过scrollIntoView方法将对应的dom元素定位到可见区域 【block: 'center'】这个属性是在垂直方向居中显示
+                node.scrollIntoView({ block: 'nearest' }) // 通过scrollIntoView方法将对应的dom元素定位到可见区域 【block: 'center'】这个属性是在垂直方向居中显示
               })
             }
           }, 100)
@@ -713,6 +709,7 @@ export default {
             let isplaying = false
             this.boxList.forEach((item) => {
               if (item.cameraId === node.id) {
+                console.log(item)
                 this.$store.commit('updateSelectedMonitor', item)
                 isplaying = true
               }
@@ -826,11 +823,22 @@ export default {
     },
     handleViewNodedbClick(node) {
       console.log(node)
+
+      if (this.boxList.some(item => item.recording === true)) {
+        this.$message.warning('正在录像中,请先结束录像再切换视图!')
+        return; // 阻止切换
+      }
+
       this.equipmentInfo = {}
       this.viewId = node.id
       let data = JSON.parse(node.viewPreviewJson)
-      this.$store.commit('updateActive', data.active)
+
+      // --- (修改) 还原为最简单的状态 ---
+      this.$store.commit('updateBoxListIndex', 0)
+      this.$store.commit('updateSelectedMonitor', {})
       this.$store.commit('updateBoxList', data.boxList)
+      this.$store.commit('updateActive', data.active)
+      // --- (修改结束) ---
     },
     updateView() {
       updatePreview({ id: this.viewId, viewName: this.viewName }).then(
@@ -842,6 +850,27 @@ export default {
         }
       )
     },
+    // 新增:云台移动停止 (由 mouseup 监听器调用)
+    ptzStop() {
+      // 3. 发送停止命令
+      this.handleMove('stop');
+      // { once: true } 选项会自动移除监听器,无需手动移除
+    },
+
+    // 新增:变焦/变倍开始
+    ptzFocusStart(command) {
+      // 1. 发送变焦/变倍命令
+      this.handleFocusing(command);
+
+      // 2. 向 document 添加一个一次性的 mouseup 监听器
+      document.addEventListener('mouseup', this.ptzFocusStop, { once: true });
+    },
+
+    // 新增:变焦/变倍停止
+    ptzFocusStop() {
+      // 3. 发送停止命令
+      this.handleFocusing('stop');
+    },
     deleteView(data) {
       this.$modal
         .confirm('是否确认删除名称为"' + data.label + '"的视图?')

+ 3 - 3
src/views/livePreview/components/jessibucaBox.vue

@@ -1,13 +1,13 @@
 <template>
-    <div id="app1" ref="app1">
-        <player v-show="videoShow"  :video-url='videoSrc' :camera-name="cameraName" ref='player' fluent
+    <div id="app1" ref="app1" style="width: 100%;height: 100%">
+        <player v-show="videoShow"  :video-url='videoSrc' :video-id="videoId" :camera-name="cameraName" ref='player' fluent
             autoplay live :has-audio='true' @destroy="close"/>
 
         <!--拖拽选择框-->
         <div ref="rectArea" class="rect"></div>
         <!--放大后视频区域-->
         <div ref="videoZoom" class="video-zoom">
-            <player v-if="videoZoomShow" :id="'zoom'+videoId" :video-url='videoSrc' :camera-name="cameraName" :showButtonBox="false"
+            <player v-if="videoZoomShow" :id="'zoom'+videoId" :video-url='videoSrc' :video-id="videoId" :camera-name="cameraName" :showButtonBox="false"
                 ref='playerZoom' fluent autoplay live :has-audio='true' />
         </div>
     </div>

+ 9 - 1
src/views/livePreview/components/toolBar.vue

@@ -65,10 +65,18 @@
         ></v-icon>
       </el-col>
       <el-col :span="12" style="margin-left: 20px">
-        <v-icon
+        <v-icon v-if="!isPolling"
           title="清空预览" @click.native="handleCloseAll"
           name="md-cleaningservices-outlined" scale="2" fill="#ffffff"
         ></v-icon>
+        <el-statistic
+          v-if="isPolling"
+          :value="Date.now() + duration * 1000 * 60"
+          time-indices
+          format="HH:mm:ss"
+          :value-style="{color: '#ffffff'}"
+        >
+        </el-statistic>
       </el-col>
     </el-row>
     <el-dialog title="轮询参数" :visible.sync="openPolling" width="450px" append-to-body class="dialog-class">

+ 118 - 115
src/views/livePreview/components/toolBar1.vue

@@ -1,104 +1,103 @@
 <template>
   <div class='toolBar'>
     <el-row style='display: flex;width: 100%;align-items: center;justify-content: flex-start'>
-<!--      <span style="margin-left: 10px;">分屏:</span>-->
-      <el-row v-show="!isPolling" :gutter="10" type="flex" align="middle" >
+      <!--      <span style="margin-left: 10px;">分屏:</span>-->
+      <el-row v-show="!isPolling" :gutter="10" type="flex" align="middle">
         <el-col :span="1.5">
-          <v-icon name="ri-checkbox-blank-fill" scale="2" :fill="active===1 ? '#3f9dfd' : '#ffffff'" @click.native="one" style="width: 30px;height: 30px"></v-icon>
-<!--          <el-button-->
-<!--              type="primary"-->
-<!--              plain-->
-<!--              size="mini"-->
-<!--              :disabled="isPolling"-->
-<!--              @click="one"-->
-<!--          >-->
-<!--          </el-button>-->
+          <v-icon name="ri-checkbox-blank-fill" scale="2" :fill="active===1 ? '#3f9dfd' : '#ffffff'"
+                  @click.native="one"></v-icon>
+          <!--          <el-button-->
+          <!--              type="primary"-->
+          <!--              plain-->
+          <!--              size="mini"-->
+          <!--              :disabled="isPolling"-->
+          <!--              @click="one"-->
+          <!--          >-->
+          <!--          </el-button>-->
         </el-col>
         <el-col :span="1.5">
-          <v-icon name="ri-function-fill" scale="2" :fill="active===4 ? '#3f9dfd' : '#ffffff'" @click.native="two" style="width: 30px;height: 30px"></v-icon>
-<!--          <el-button-->
-<!--              type="primary"-->
-<!--              plain-->
-<!--              size="mini"-->
-<!--              :disabled="isPolling"-->
-<!--              @click="two"-->
-<!--          >四分屏-->
-<!--          </el-button>-->
+          <v-icon name="ri-function-fill" scale="2" :fill="active===4 ? '#3f9dfd' : '#ffffff'"
+                  @click.native="two"></v-icon>
+          <!--          <el-button-->
+          <!--              type="primary"-->
+          <!--              plain-->
+          <!--              size="mini"-->
+          <!--              :disabled="isPolling"-->
+          <!--              @click="two"-->
+          <!--          >四分屏-->
+          <!--          </el-button>-->
         </el-col>
         <el-col :span="1.5">
-          <v-icon name="ri-grid-fill" scale="2" :fill="active===9 ? '#3f9dfd' : '#ffffff'" @click.native="three" style="width: 30px;height: 30px"></v-icon>
-<!--          <el-button-->
-<!--              type="primary"-->
-<!--              plain-->
-<!--              size="mini"-->
-<!--              :disabled="isPolling"-->
-<!--              @click="three"-->
-<!--          >九分屏-->
-<!--          </el-button>-->
+          <v-icon name="ri-grid-fill" scale="2" :fill="active===9 ? '#3f9dfd' : '#ffffff'"
+                  @click.native="three"></v-icon>
+          <!--          <el-button-->
+          <!--              type="primary"-->
+          <!--              plain-->
+          <!--              size="mini"-->
+          <!--              :disabled="isPolling"-->
+          <!--              @click="three"-->
+          <!--          >九分屏-->
+          <!--          </el-button>-->
         </el-col>
         <el-col :span="1.5">
-          <v-icon name="md-dashboardcustomize" scale="2" :fill="active===16 ? '#3f9dfd' : '#ffffff'" @click.native="four" style="width: 30px;height: 30px"></v-icon>
-<!--          <el-button-->
-<!--              type="primary"-->
-<!--              plain-->
-<!--              size="mini"-->
-<!--              :disabled="isPolling"-->
-<!--              @click="four"-->
-<!--          >十六分屏-->
-<!--          </el-button>-->
+          <v-icon name="md-dashboardcustomize" scale="2" :fill="active===16 ? '#3f9dfd' : '#ffffff'"
+                  @click.native="four"></v-icon>
+          <!--          <el-button-->
+          <!--              type="primary"-->
+          <!--              plain-->
+          <!--              size="mini"-->
+          <!--              :disabled="isPolling"-->
+          <!--              @click="four"-->
+          <!--          >十六分屏-->
+          <!--          </el-button>-->
         </el-col>
       </el-row>
     </el-row>
     <el-row style="display: flex;align-items:center; margin-right: 10px">
-      <el-col :span="5">
+      <el-col :span="12">
         <v-icon
-        v-if="!isPolling" title="开始轮询" @click.native="openPolling = true"
-        name="bi-bootstrap-reboot" scale="2" fill="#ffffff"
-        style="width: 30px;height: 30px"
-       ></v-icon>
-       <v-icon
-        v-if="isPolling" title="停止轮询" @click.native="endPolling"
-        name="md-cached-outlined" animation="spin" speed="slow" scale="2" fill="#3f9dfd"
-        style="width: 30px;height: 30px"
-       ></v-icon>
-        <!-- <el-button
-            v-if="!isPolling"
-            type="primary"
-            plain
-            size="mini"
-            @click="openPolling = true"
-        >开始轮询
-        </el-button> -->
-        <!-- <el-button
-            v-if="isPolling"
-            type="warning"
-            icon="el-icon-loading"
-            plain
-            size="mini"
-            @click="endPolling"
-        >停止轮询
-        </el-button> -->
+          v-if="!isPolling" title="开始轮询" @click.native="openPolling = true"
+          name="bi-bootstrap-reboot" scale="2" fill="#ffffff"
+        ></v-icon>
+        <v-icon
+          v-if="isPolling" title="停止轮询" @click.native="endPolling"
+          name="md-cached-outlined" animation="spin" speed="slow" scale="2" fill="#3f9dfd"
+        ></v-icon>
+      </el-col>
+      <el-col :span="12" style="margin-left: 20px">
+        <v-icon v-if="!isPolling"
+                title="清空预览" @click.native="handleCloseAll"
+                name="md-cleaningservices-outlined" scale="2" fill="#ffffff"
+        ></v-icon>
+        <el-statistic
+          v-if="isPolling"
+          :value="Date.now() + duration * 1000 * 60"
+          time-indices
+          format="HH:mm:ss"
+          :value-style="{color: '#ffffff'}"
+        >
+        </el-statistic>
       </el-col>
     </el-row>
     <el-dialog title="轮询参数" :visible.sync="openPolling" width="450px" append-to-body class="dialog-class">
-      <el-row style="display: flex;align-items: center;margin-bottom: 15px;font-size: 16px">
+      <el-row style="display: flex;align-items: center;margin-bottom: 15px">
         画面数量:
-        <el-radio-group v-model="count" style="width: 70%;margin:0 10px" size="small">
-          <el-radio-button label="one" >1</el-radio-button>
-          <el-radio-button label="two" >4</el-radio-button>
-          <el-radio-button label="three" >9</el-radio-button>
-          <el-radio-button label="four" >16</el-radio-button>
+        <el-radio-group v-model="count" style="width: 70%" size="small">
+          <el-radio-button label="one">1</el-radio-button>
+          <el-radio-button label="two">4</el-radio-button>
+          <el-radio-button label="three">9</el-radio-button>
+          <el-radio-button label="four">16</el-radio-button>
         </el-radio-group>
       </el-row>
-      <el-row style="display: flex;align-items: center;margin-bottom: 15px;font-size: 16px">
+      <el-row style="display: flex;align-items: center;margin-bottom: 15px">
         轮询时长:
-        <el-input-number v-model="duration" placeholder="请输入轮询时长(m)" style="width: 70%;margin:0 10px" type="mini"
+        <el-input-number v-model="duration" placeholder="请输入轮询时长(m)" style="width: 70%" type="mini"
                          :controls="false" :precision="0"></el-input-number>
         分钟
       </el-row>
-      <el-row style="display: flex;align-items: center;font-size: 16px">
+      <el-row style="display: flex;align-items: center;">
         时间间隔:
-        <el-input-number v-model="interval" placeholder="请输入时间间隔(s)" style="width: 70%;margin:0 10px" type="mini"
+        <el-input-number v-model="interval" placeholder="请输入时间间隔(s)" style="width: 70%" type="mini"
                          :controls="false" :min="5" :precision="0"></el-input-number>
         s
       </el-row>
@@ -114,12 +113,22 @@
 
 <script>
 import {mapGetters} from 'vuex'
-import { addIcons } from "oh-vue-icons";
-import { RiNumber1,RiCheckboxBlankFill,RiFunctionFill,RiGridFill,MdDashboardcustomize,BiBootstrapReboot,MdCachedOutlined} from "oh-vue-icons/icons";
-addIcons(RiNumber1,RiCheckboxBlankFill,RiFunctionFill,RiGridFill,MdDashboardcustomize,BiBootstrapReboot,MdCachedOutlined);
+import {addIcons} from "oh-vue-icons";
+import {
+  RiNumber1,
+  RiCheckboxBlankFill,
+  RiFunctionFill,
+  RiGridFill,
+  MdDashboardcustomize,
+  BiBootstrapReboot,
+  MdCachedOutlined,
+  MdCleaningservicesOutlined
+} from "oh-vue-icons/icons";
+
+addIcons(RiNumber1, RiCheckboxBlankFill, RiFunctionFill, RiGridFill, MdDashboardcustomize, BiBootstrapReboot, MdCachedOutlined,MdCleaningservicesOutlined);
 
 export default {
-  name: 'toolBar1',
+  name: 'toolBar',
   data() {
     return {
       color: 'red',
@@ -131,7 +140,7 @@ export default {
     }
   },
   computed: {
-    ...mapGetters(['boxList', 'active','boxListIndex'])
+    ...mapGetters(['boxList', 'active', 'boxListIndex'])
   },
   watch: {
     // count(newData, oldData) {
@@ -169,7 +178,7 @@ export default {
           break
       }
       this.openPolling = false;
-      this.count='one'
+      this.count = 'one'
       this.$emit('startPolling', this.duration, this.interval)
     },
     endPolling() {
@@ -179,13 +188,16 @@ export default {
     changePollingStatus() {
       this.isPolling = false;
     },
-
+    // 点击按钮时,通过自定义事件通知父组件
+    handleCloseAll() {
+      this.$emit('closeAllVideos'); // 触发自定义事件
+    },
     async one() {
       this.$store.commit('updateBoxListIndex', 0)
-      if(!this.boxList.some(item=>item.recording===true)){
+      if (!this.boxList.some(item => item.recording === true)) {
         this.$store.commit('updateActive', 1)
         // this.$store.commit('updateBoxList', this.boxList.filter((item, index) => index < 1))
-      }else {
+      } else {
         this.$message.warning('正在录像中,请先结束录像!')
       }
 
@@ -193,31 +205,31 @@ export default {
     },
     two() {
       this.$store.commit('updateBoxListIndex', 0)
-      if(!this.boxList.some(item=>item.recording===true)){
-      //   // 处理右侧视频容器 少则新增,多则筛选
-      //   let boxList = []
-      //   switch (this.boxList.length) {
-      //     case 1:
-      //       boxList = this.boxList.filter((item, index) => index < 1)
-      //       boxList.push({boxId: 2}, {boxId: 3}, {boxId: 4})
-      //       break
-      //     case 9:
-      //       boxList = this.boxList.filter((item, index) => index < 4)
-      //       break
-      //     case 16:
-      //       boxList = this.boxList.filter((item, index) => index < 4)
-      //       break
+      if (!this.boxList.some(item => item.recording === true)) {
+        //   // 处理右侧视频容器 少则新增,多则筛选
+        //   let boxList = []
+        //   switch (this.boxList.length) {
+        //     case 1:
+        //       boxList = this.boxList.filter((item, index) => index < 1)
+        //       boxList.push({boxId: 2}, {boxId: 3}, {boxId: 4})
+        //       break
+        //     case 9:
+        //       boxList = this.boxList.filter((item, index) => index < 4)
+        //       break
+        //     case 16:
+        //       boxList = this.boxList.filter((item, index) => index < 4)
+        //       break
         // }
         this.$store.commit('updateActive', 4)
         // this.$store.commit('updateBoxList', boxList)
-      }else {
+      } else {
         this.$message.warning('正在录像中,请先结束录像!')
       }
 
     },
     three() {
       this.$store.commit('updateBoxListIndex', 0)
-      if(!this.boxList.some(item=>item.recording===true)){
+      if (!this.boxList.some(item => item.recording === true)) {
         // 处理右侧视频容器 少则新增,多则筛选
         // let boxList = []
         // switch (this.boxList.length) {
@@ -235,13 +247,13 @@ export default {
         // }
         this.$store.commit('updateActive', 9)
         // this.$store.commit('updateBoxList', boxList)
-      }else {
+      } else {
         this.$message.warning('正在录像中,请先结束录像!')
       }
     },
     four() {
       this.$store.commit('updateBoxListIndex', 0)
-      if(!this.boxList.some(item=>item.recording===true)){
+      if (!this.boxList.some(item => item.recording === true)) {
         // 处理右侧视频容器 少则新增,多则筛选
         // let boxList = []
         // switch (this.boxList.length) {
@@ -260,7 +272,7 @@ export default {
         // }
         this.$store.commit('updateActive', 16)
         // this.$store.commit('updateBoxList', boxList)
-      }else {
+      } else {
         this.$message.warning('正在录像中,请先结束录像!')
       }
     }
@@ -278,23 +290,14 @@ export default {
 </script>
 
 <style scoped>
-.toolBar{
+.toolBar {
   margin: 10px 0 0 5px;
   width: 99.6%;
   z-index: 1;
   padding: 0px 10px;
-  background: transparent url("../../../assets/images/kuang.png") no-repeat ;
-  background-size: 100% 100%;
+  /*background: transparent url("../../../assets/images/kuang.png") no-repeat ;*/
+  /*background-size: 100% 100%;*/
   display: flex;
   justify-content: center;
 }
-::v-deep .el-dialog .el-dialog__header{
-  background-color: #032046 !important;
-}
-::v-deep .el-dialog .el-dialog__body{
-  background-color: #032046 !important;
-}
-::v-deep .el-dialog .el-dialog__footer{
-  background-color: #032046 !important;
-}
 </style>

+ 63 - 14
src/views/livePreview/components/videoBox.vue

@@ -1,18 +1,18 @@
 <template>
-  <el-card style="border: none;border-radius: 0;" :body-style="{ padding: '0px', background: 'transparent' }">
-    <el-row class='mainBox'>
+  <el-card style="border: none;border-radius: 0;height: 100%;background-color: transparent" :body-style="{ height:'100%', padding: '0px', background: 'transparent',textAlign:'-webkit-center' }">
+    <el-row class='mainBox' :style="mainBoxStyle" >
       <el-col :span='number' v-for='(item, index) in boxList.slice(boxListIndex)' :key='item.boxId'
-        :id="'childBox_' + item.boxId" class='childBox' :style='{ height: heightActive }'
-        @click.native='setActiveBox(item)' @dblclick.native="changeToOne(item)"
-        :class='{ redBorder: ifShowRedBorder(item) }'>
+              :id="'childBox_' + item.boxId" class='childBox' :style='{ height: heightActive }'
+              @click.native='setActiveBox(item)' @dblclick.native="changeToOne(item)"
+              :class='{ redBorder: ifShowRedBorder(item) }'>
         <div>
           <span class="title">{{ item.name }}</span>
         </div>
         <!-- v-if和v-show可以考虑一下 -->
         <webrtc-player v-if="(item.code === 'H264' || item.code !== 'H265') && item.mainUrl" :video-src='active===1 ? item.mainUrl : item.auxiliaryUrl' :video-id="item.boxId"
-          :camera-name="item.name" ref='player' @destroy="close" :key="index" style='width: 100%' />
+                       :camera-name="item.name" ref='player' @destroy="close" :key="index" style='width: 100%' />
         <jessibucaBox v-if="item.code === 'H265' && item.mainUrl" :video-src='active===1 ? item.mainUrl : item.auxiliaryUrl' :video-id="item.boxId"
-          :camera-name="item.name" ref='player' @destroy="close"></jessibucaBox>
+                      :camera-name="item.name" ref='player' @destroy="close"></jessibucaBox>
         <span v-if="!item.mainUrl" style="color: white">无信号</span>
       </el-col>
     </el-row>
@@ -26,12 +26,14 @@ import { mapGetters } from 'vuex'
 import jessibucaBox from './jessibucaBox.vue'
 
 export default {
-  name: 'videoBox',
+  name: 'videoBox1',
   components: { player, webrtcPlayer, jessibucaBox },
   data() {
     return {
       clickBoxId: '',
       showRedBorder: true,
+      mainBoxStyle: {}, // 动态样式
+      resizeObserver: null,
     }
   },
   computed: {
@@ -89,6 +91,17 @@ export default {
   },
   mounted() {
     // this.rectZoomInit()
+    this.updateMainBoxSize();
+    // 使用 ResizeObserver 监听容器变化
+    this.resizeObserver = new ResizeObserver(() => this.updateMainBoxSize());
+    this.resizeObserver.observe(this.$el);
+
+    // 监听窗口变化
+    window.addEventListener('resize', this.updateMainBoxSize);
+  },
+  beforeDestroy() {
+    this.resizeObserver?.disconnect();
+    window.removeEventListener('resize', this.updateMainBoxSize);
   },
   methods: {
     // 新增:关闭所有视频的方法
@@ -119,6 +132,30 @@ export default {
 
       this.$message.success('所有预览已关闭');
     },
+    updateMainBoxSize() {
+      const container = this.$el;
+      const containerWidth = container.clientWidth;
+      const containerHeight = container.clientHeight;
+
+      // 计算基于宽和高的两种情况
+      const heightBasedOnWidth = containerWidth * 9 / 16;
+      const widthBasedOnHeight = containerHeight * 16 / 9;
+
+      // 选择合适的基准,确保容器不会超出父元素
+      if (heightBasedOnWidth <= containerHeight) {
+        // 以宽为基准,高度会有剩余空间
+        this.mainBoxStyle = {
+          width: `${containerWidth}px`,
+          height: '100%',
+        };
+      } else {
+        // 以高为基准,宽度会有剩余空间
+        this.mainBoxStyle = {
+          width: '100%',
+          height: `${containerHeight}px`,
+        };
+      }
+    },
     // 双击窗口转换成单画面播放
     changeToOne(item) {
       console.log(this.boxList)
@@ -168,13 +205,18 @@ export default {
     },
     setActiveBox(item) {
       this.$nextTick(() => {
+        // 1. 无论窗口是否为空,始终更新 Vuex,
+        //    这样父组件(index.vue)就能知道当前哪个窗口被选中了。
+        this.$store.commit('updateSelectedMonitor', item)
+        this.showRedBorder = true // 只要点击了,就应该显示红框
+
+        // 2. 根据窗口是否有内容,决定是高亮树节点还是清空树节点
         if (item.cameraId) {
-          // 树节点回显
+          // 窗口内有摄像头,正常回显高亮树节点
           this.$emit('setCurrentKeyTree', item.cameraId)
-          this.$store.commit('updateSelectedMonitor', item)
-          this.showRedBorder = true
-        }else{
-          this.showRedBorder = false
+        } else {
+          // 窗口是空的,通知父组件清除树的选中状态
+          this.$emit('clearTreeSelected')
         }
       })
     },
@@ -193,8 +235,15 @@ export default {
 </script>
 
 <style lang="less" scoped>
+
+::v-deep video::-webkit-media-controls-fullscreen-button {
+  display: none !important;
+}
+::v-deep video::-webkit-media-controls-enclosure {
+  overflow: hidden !important;
+}
 .mainBox {
-  //padding-bottom: 95px;
+  //padding-bottom: 90px;
   width: 100%;
   aspect-ratio: 16/9;
   margin: 0px;

+ 28 - 0
src/views/livePreview/components/videoBox1.vue

@@ -104,6 +104,34 @@ export default {
     window.removeEventListener('resize', this.updateMainBoxSize);
   },
   methods: {
+    // 新增:关闭所有视频的方法
+    closeAllVideos() {
+      // 遍历所有视频窗口
+      this.boxList.forEach(item => {
+        if (item.mainUrl || item.auxiliaryUrl) {
+          // 1. 先设置当前选中窗口(用于触发播放器销毁逻辑)
+          this.setActiveBox(item);
+
+          // 2. 清空视频流地址和相关信息
+          item.mainUrl = '';
+          item.auxiliaryUrl = '';
+          item.code = '';
+          item.cameraId = '';
+          item.name = '';
+          item.rtc = '';
+          item.rtsp = '';
+        }
+      });
+
+      // 3. 清除选中状态和红色边框
+      this.$store.commit('updateSelectedMonitor', {});
+      this.showRedBorder = false;
+
+      // 4. 通知父组件清除树节点选中状态
+      this.$emit('clearTreeSelected');
+
+      this.$message.success('所有预览已关闭');
+    },
     updateMainBoxSize() {
       const container = this.$el;
       const containerWidth = container.clientWidth;

+ 207 - 109
src/views/livePreview/index.vue

@@ -3,13 +3,13 @@
     <el-row style="display: flex; width: 100%">
       <!--设备树-->
       <div class="tree-card" style="width: 220px">
-        <div class="head-container">
+        <div class="head-container" >
           <el-input v-model="searchWord" placeholder="请输入设备名称" clearable size="small"
                     prefix-icon="el-icon-search"
                     style="margin-bottom: 5px"
           />
         </div>
-        <div class="head-container">
+        <div class="head-container" >
           <el-tabs v-model="activeName" @tab-click="handleTabClick" :stretch="true">
             <el-tab-pane label="设备" name="first">
               <el-tree :data="equipmentOptions" :props="defaultProps" node-key="id" :expand-on-click-node="false"
@@ -143,6 +143,78 @@
         </div>
         <div class="tool-tab">
           <el-tabs v-model="activeTool" :stretch="true">
+            <el-tab-pane v-if="
+              equipmentInfo.isGimbalControl === '1' &&
+              equipmentInfo.isHolder === '1'
+            " label="云台" name="ptz"
+            >
+              <div class="operate-panel" @dragstart.prevent>
+                <div class="move-btn">
+                  <div class="btn" style="align-self: flex-end">
+                    <i class="btn el-icon-arrow-up" style="transform: rotate(-45deg); cursor: pointer"
+                       @mousedown="ptzStart('leftUp')"
+                    />
+                  </div>
+                  <div class="btn" style="margin-bottom: 13px; cursor: pointer">
+                    <i class="btn el-icon-arrow-up" @mousedown="ptzStart('up')" />
+                  </div>
+                  <div class="btn" style="align-self: flex-end">
+                    <i class="btn el-icon-arrow-up" style="transform: rotate(45deg); cursor: pointer"
+                       @mousedown="ptzStart('rightUp')"
+                    />
+                  </div>
+                  <div class="btn" style="margin-right: 13px">
+                    <i class="btn el-icon-arrow-up" style="transform: rotate(-90deg); cursor: pointer"
+                       @mousedown="ptzStart('left')"
+                    />
+                  </div>
+                  <div class="btn" style="">
+                    <i class="btn el-icon-refresh" @click="handleMove('refresh')"/>
+                  </div>
+                  <div class="btn" style="margin-left: 13px">
+                    <i class="btn el-icon-arrow-up" style="transform: rotate(90deg); cursor: pointer"
+                       @mousedown="ptzStart('right')"
+                    />
+                  </div>
+                  <div class="btn" style="align-self: flex-start">
+                    <i class="btn el-icon-arrow-up" style="transform: rotate(225deg); cursor: pointer"
+                       @mousedown="ptzStart('leftDown')"
+                    />
+                  </div>
+                  <div class="btn" style="margin-top: 13px">
+                    <i class="btn el-icon-arrow-up" style="transform: rotate(180deg); cursor: pointer"
+                       @mousedown="ptzStart('down')"
+                    />
+                  </div>
+                  <div class="btn" style="align-self: flex-start">
+                    <i class="btn el-icon-arrow-up" style="transform: rotate(135deg); cursor: pointer"
+                       @mousedown="ptzStart('rightDown')"
+                    />
+                  </div>
+                </div>
+                <div class="compose-btn">
+                  <div class="btn2">
+                    <i class="el-icon-zoom-in" style="cursor: pointer" @click="addSpeed()"></i>
+                    <div style="display: flex">速度:{{ getSpeed }}</div>
+                    <i class="el-icon-zoom-out" style="cursor: pointer" @click="delSpeed()"></i>
+                  </div>
+                  <div class="btn2">
+                    <i class="el-icon-zoom-in" style="cursor: pointer" @mousedown="ptzFocusStart('up')"
+                    ></i>
+                    <span>变倍</span>
+                    <i class="el-icon-zoom-out" style="cursor: pointer" @mousedown="ptzFocusStart('down')"
+                    ></i>
+                  </div>
+                  <div class="btn2">
+                    <i class="el-icon-zoom-in" style="cursor: pointer" @mousedown="ptzFocusStart('focusOut')"
+                    ></i>
+                    <span>变焦</span>
+                    <i class="el-icon-zoom-out" style="cursor: pointer" @mousedown="ptzFocusStart('focusIn')"
+                    ></i>
+                  </div>
+                </div>
+              </div>
+            </el-tab-pane>
             <el-tab-pane label="工具" name="first">
               <div style="
                   display: flex;
@@ -173,8 +245,8 @@
                 "
               >
                 <el-switch
-                           style="color: #ffffff; zoom: 1.15; margin-top: 5px" v-model="equipmentInfo.lamplightStatus"
-                           inactive-text="开启灯光" active-value="1" inactive-value="0" @change="handleOpenLight"
+                  style="color: #ffffff; zoom: 1.15; margin-top: 5px" v-model="equipmentInfo.lamplightStatus"
+                  inactive-text="开启灯光" active-value="1" inactive-value="0" @change="handleOpenLight"
                 />
                 <div style="
                     display: flex;
@@ -221,82 +293,7 @@
                 </div>
               </div>
             </el-tab-pane>
-            <el-tab-pane v-if="
-              equipmentInfo.isGimbalControl === '1' &&
-              equipmentInfo.isHolder === '1'
-            " label="云台"
-            >
-              <div class="operate-panel" @dragstart.prevent>
-                <div class="move-btn">
-                  <div class="btn" style="align-self: flex-end">
-                    <i class="btn el-icon-arrow-up" style="transform: rotate(-45deg); cursor: pointer"
-                       @mousedown="handleMove('leftUp')" @mouseup="handleMove('stop')"
-                    />
-                  </div>
-                  <div class="btn" style="margin-bottom: 13px; cursor: pointer">
-                    <i class="btn el-icon-arrow-up" @mousedown="handleMove('up')" @mouseup="handleMove('stop')"/>
-                  </div>
-                  <div class="btn" style="align-self: flex-end">
-                    <i class="btn el-icon-arrow-up" style="transform: rotate(45deg); cursor: pointer"
-                       @mousedown="handleMove('rightUp')" @mouseup="handleMove('stop')"
-                    />
-                  </div>
-                  <div class="btn" style="margin-right: 13px">
-                    <i class="btn el-icon-arrow-up" style="transform: rotate(-90deg); cursor: pointer"
-                       @mousedown="handleMove('left')" @mouseup="handleMove('stop')"
-                    />
-                  </div>
-                  <div class="btn" style="">
-                    <i class="btn el-icon-refresh" @click="handleMove('refresh')"/>
-                  </div>
-                  <div class="btn" style="margin-left: 13px">
-                    <i class="btn el-icon-arrow-up" style="transform: rotate(90deg); cursor: pointer"
-                       @mousedown="handleMove('right')" @mouseup="handleMove('stop')"
-                    />
-                  </div>
-                  <div class="btn" style="align-self: flex-start">
-                    <i class="btn el-icon-arrow-up" style="transform: rotate(225deg); cursor: pointer"
-                       @mousedown="handleMove('leftDown')" @mouseup="handleMove('stop')"
-                    />
-                  </div>
-                  <div class="btn" style="margin-top: 13px">
-                    <i class="btn el-icon-arrow-up" style="transform: rotate(180deg); cursor: pointer"
-                       @mousedown="handleMove('down')" @mouseup="handleMove('stop')"
-                    />
-                  </div>
-                  <div class="btn" style="align-self: flex-start">
-                    <i class="btn el-icon-arrow-up" style="transform: rotate(135deg); cursor: pointer"
-                       @mousedown="handleMove('rightDown')" @mouseup="handleMove('stop')"
-                    />
-                  </div>
-                </div>
-                <div class="compose-btn">
-                  <div class="btn2">
-                    <i class="el-icon-zoom-in" style="cursor: pointer" @click="addSpeed()"></i>
-                    <div style="display: flex">速度:{{ getSpeed }}</div>
-                    <i class="el-icon-zoom-out" style="cursor: pointer" @click="delSpeed()"></i>
-                  </div>
-                  <div class="btn2">
-                    <i class="el-icon-zoom-in" style="cursor: pointer" @mousedown="handleFocusing('up')"
-                       @mouseup="handleFocusing('stop')"
-                    ></i>
-                    <span>变倍</span>
-                    <i class="el-icon-zoom-out" style="cursor: pointer" @mousedown="handleFocusing('down')"
-                       @mouseup="handleFocusing('stop')"
-                    ></i>
-                  </div>
-                  <div class="btn2">
-                    <i class="el-icon-zoom-in" style="cursor: pointer" @mousedown="handleFocusing('focusOut')"
-                       @mouseup="handleFocusing('stop')"
-                    ></i>
-                    <span>变焦</span>
-                    <i class="el-icon-zoom-out" style="cursor: pointer" @mousedown="handleFocusing('focusIn')"
-                       @mouseup="handleFocusing('stop')"
-                    ></i>
-                  </div>
-                </div>
-              </div>
-            </el-tab-pane>
+
           </el-tabs>
         </div>
       </div>
@@ -313,8 +310,8 @@
             overflow: hidden;
           "
         >
-          <div style="height: 100%; aspect-ratio: 16/9">
-            <videoBox ref="video" @clearTreeSelected="clearTreeSelected" @setCurrentKeyTree="setCurrentKeyTree"
+          <div style="height: 100%; aspect-ratio: 16/9" >
+            <videoBox ref="video"  @clearTreeSelected="clearTreeSelected" @setCurrentKeyTree="setCurrentKeyTree"
                       @clickToolBar="clickToolBar"
             ></videoBox>
           </div>
@@ -455,6 +452,7 @@ export default {
         app: '',
         isGeneralControl: true
       },
+
       viewId: undefined,
       viewName: '',
       openSave: false,
@@ -482,7 +480,8 @@ export default {
       onlinePresenceInterval: null, //定时器id
       messages: [],
       eventSource: null,
-      fullscreenLoading: false
+      fullscreenLoading: false,
+      totalDurationTimer: null // 存储总时长定时器ID
     }
   },
   watch: {
@@ -526,9 +525,23 @@ export default {
   methods: {
     // 新增:处理关闭所有视频的逻辑
     handleCloseAllVideos() {
-      // 通过 ref 调用 videoBox 组件的 closeAllVideos 方法
-      if (this.$refs.video) {
-        this.$refs.video.closeAllVideos();
+      // 检查是否有正在录像的窗口
+      const isRecording = this.boxList.some(item => item.recording === true);
+
+      if (isRecording) {
+        this.$confirm('当前有窗口正在录像,不允许全部关闭', '提示', {
+          confirmButtonText: '确定',
+          type: 'warning'
+        }).then(() => {
+
+        }).catch(() => {
+          // 取消操作,不关闭
+        });
+      } else {
+        // 没有录像,直接关闭
+        if (this.$refs.video) {
+          this.$refs.video.closeAllVideos();
+        }
       }
     },
     getEquipmentOptions() {
@@ -672,6 +685,9 @@ export default {
         // 判断是否是设备
         if (node.isLeaf) {
           this.equipmentInfo = node
+          if (this.equipmentInfo.isGimbalControl === '1' && this.equipmentInfo.isHolder === '1') {
+            this.activeTool = 'ptz'
+          }
           // 判断灯光状态
           if (this.equipmentInfo.isOutboard === '1') {
             // 判断灯光状态
@@ -700,6 +716,7 @@ export default {
             let isplaying = false
             this.boxList.forEach((item) => {
               if (item.cameraId === node.id) {
+                console.log(item)
                 this.$store.commit('updateSelectedMonitor', item)
                 isplaying = true
               }
@@ -729,29 +746,44 @@ export default {
           // 设置当前选中设备
           this.$store.commit('updateSelectedTreeData', node)
           this.$refs.video.setShowBorder(true)
-          // 遍历寻找空屏
-          let findBox = false
-          this.boxList.slice(0, this.active).forEach((item) => {
-            if (!findBox && !item.cameraId) {
-              findBox = true
-              this.$store.commit('updateSelectedMonitor', item)
+
+          let targetBox = null;
+          // 1. 检查 vuex 中是否已有一个被用户点击选中的窗口
+          if (this.selectedMonitor && this.selectedMonitor.boxId) {
+            console.log('selectedMonitor', this.selectedMonitor)
+            if(this.selectedMonitor.recording){
+              this.$message.warning('当前窗口正在录像,不可替换源!')
+              return
             }
-          })
-          // 遍历完了还没找到
-          if (!findBox) {
-            this.$store.commit(
-              'updateSelectedMonitor',
-              this.boxList[this.active - 1]
-            )
+            targetBox = this.selectedMonitor;
+          }
+
+          // 2. 如果没有选中的窗口,则使用旧逻辑:遍历寻找空屏
+          if (!targetBox) {
+            let findBox = false
+            this.boxList.slice(0, this.active).forEach((item) => {
+              if (!findBox && !item.cameraId) {
+                findBox = true
+                targetBox = item; // 找到空闲窗口作为目标
+              }
+            })
+            // 3. 遍历完了还没找到空屏,使用最后一个窗口
+            if (!findBox) {
+              targetBox = this.boxList[this.active - 1];
+            }
+            // 确保将新目标同步到 vuex
+            this.$store.commit('updateSelectedMonitor', targetBox)
           }
+
+
           // 判断设备是否停用
           if (
             this.onlinePresenceList.find(
               (item) => item.id === this.selectedTreeData.id
             ).onlinePresence === '1'
           ) {
-            // 获取流
-            this.getPlayUrl(this.selectedMonitor)
+            // 获取流 (注意:这里使用我们新确定的 targetBox)
+            this.getPlayUrl(targetBox) // <--- 修改点:确保传递的是 targetBox
           } else {
             this.$message.warning('当前设备已离线!')
             this.clearTreeSelected()
@@ -813,11 +845,22 @@ export default {
     },
     handleViewNodedbClick(node) {
       console.log(node)
+
+      if (this.boxList.some(item => item.recording === true)) {
+        this.$message.warning('正在录像中,请先结束录像再切换视图!')
+        return; // 阻止切换
+      }
+
       this.equipmentInfo = {}
       this.viewId = node.id
       let data = JSON.parse(node.viewPreviewJson)
-      this.$store.commit('updateActive', data.active)
+
+      // --- (修改) 还原为最简单的状态 ---
+      this.$store.commit('updateBoxListIndex', 0)
+      this.$store.commit('updateSelectedMonitor', {})
       this.$store.commit('updateBoxList', data.boxList)
+      this.$store.commit('updateActive', data.active)
+      // --- (修改结束) ---
     },
     updateView() {
       updatePreview({ id: this.viewId, viewName: this.viewName }).then(
@@ -951,7 +994,8 @@ export default {
             item.mainUrl = item.auxiliaryUrl
           }
         }
-      } else {
+      }
+      else {
         item.cameraId = this.selectedTreeData.id
         item.name = this.selectedTreeData.cameraName
         let object = JSON.parse(info[0].urlObject)
@@ -1026,9 +1070,19 @@ export default {
       console.log('item', item)
     },
     clearTreeSelected() {
+      // 1. 清除 vuex 中的树数据
       this.$store.commit('updateSelectedTreeData', {})
-      this.$store.commit('updateSelectedMonitor', {})
-      // this.selectedTree = []
+
+      // 2. (可选但推荐)清除树组件的实际高亮
+      if (this.activeName === 'first' && this.$refs.tree1) {
+        this.$refs.tree1.setCurrentKey(null)
+      } else if (this.activeName === 'second' && this.$refs.tree2) {
+        this.$refs.tree2.setCurrentKey(null)
+      }
+
+      // 3. (重要) 我们不能在此处清除 selectedMonitor,
+      //    因为 selectedMonitor 现在由 videoBox.vue 的 setActiveBox 统一管理。
+      //    (确保 this.$store.commit('updateSelectedMonitor', {}) 这一行被删除或注释掉)
     },
     clickToolBar(value) {
       if (value === 1) {
@@ -1037,14 +1091,19 @@ export default {
     },
     // 开始轮询
     startPolling(duration, interval) {
-      // 时间到了停止轮询
-      setTimeout(() => {
+      // 清除上一次未执行的总时长定时器(关键修复)
+      if (this.totalDurationTimer) {
+        clearTimeout(this.totalDurationTimer)
+      }
+      // 重新创建总时长定时器,基于当前时间计算
+      this.totalDurationTimer = setTimeout(() => {
         this.endPolling()
       }, duration * 1000 * 60)
+
       // 清空boxList 只留boxId
       this.$store.commit(
         'updateBoxList',
-        this.boxList.map((item) => ({ boxId: item.boxId }))
+        this.boxList.map((item) => ({ boxId: item.boxId, recording: false }))
       )
 
       let viewCount = this.active
@@ -1123,8 +1182,14 @@ export default {
       }
     },
     endPolling() {
+      // 清除轮询间隔定时器
       clearInterval(this.pollingInterval)
       this.pollingInterval = null
+      // 清除总时长定时器(关键修复)
+      if (this.totalDurationTimer) {
+        clearTimeout(this.totalDurationTimer)
+        this.totalDurationTimer = null
+      }
       this.$refs.toolbar.changePollingStatus()
     },
     save() {
@@ -1486,6 +1551,39 @@ export default {
       })
 
     },
+
+    // 新增:云台移动开始
+    ptzStart(command) {
+      // 1. 发送移动命令
+      this.handleMove(command);
+
+      // 2. 向 document 添加一个一次性的 mouseup 监听器
+      //    (使用 .bind(this) 确保 ptzStop 内部的 this 指向 Vue 实例)
+      document.addEventListener('mouseup', this.ptzStop, { once: true });
+    },
+
+    // 新增:云台移动停止 (由 mouseup 监听器调用)
+    ptzStop() {
+      // 3. 发送停止命令
+      this.handleMove('stop');
+      // { once: true } 选项会自动移除监听器,无需手动移除
+    },
+
+    // 新增:变焦/变倍开始
+    ptzFocusStart(command) {
+      // 1. 发送变焦/变倍命令
+      this.handleFocusing(command);
+
+      // 2. 向 document 添加一个一次性的 mouseup 监听器
+      document.addEventListener('mouseup', this.ptzFocusStop, { once: true });
+    },
+
+    // 新增:变焦/变倍停止
+    ptzFocusStop() {
+      // 3. 发送停止命令
+      this.handleFocusing('stop');
+    },
+
     handleMove(val) {
       switch (val) {
         case 'up':
@@ -1768,7 +1866,7 @@ export default {
   background-color: #304156;
 }
 ::v-deep .head-container .el-tabs__content {
-  height: 35vh;
+  height: 48vh;
   flex-grow: 1;
   //background-color: #030c18;
   overflow-y: auto;

+ 8 - 6
src/views/service/recordPlan/index.vue

@@ -42,6 +42,7 @@
       v-if="refreshTable"
       v-loading="loading"
       :data="streamList"
+      row-key="stream"
       width="120"
     >
       <el-table-column label="序号" align="center" prop="id" width="50px"/>
@@ -336,15 +337,16 @@ export default {
     },
     handleStatusChange(row) {
       let text = row.taskStatus === "1" ? "确认要启用吗?" : "确认要停用吗?停用后无法进行录制!";
-      this.$modal.confirm(text).then(function () {
-        if(row.taskStatus==='1'){
-          return updateStatusByTrue({app: row.app, stream: row.stream});
-        }else{
-          return updateStatusByFalse({app: row.app, stream: row.stream});
+      this.$modal.confirm(text).then(() => { // 使用箭头函式
+        if (row.taskStatus === '1') {
+          return updateStatusByTrue({ app: row.app, stream: row.stream });
+        } else {
+          return updateStatusByFalse({ app: row.app, stream: row.stream });
         }
       }).then(() => {
         this.$modal.msgSuccess("成功");
-      }).catch(function () {
+      }).catch(() => { // 使用箭头函式
+        // 这个逻辑可以在使用者取消操作或 API 呼叫失败时,将开关状态恢复原状
         row.taskStatus = row.taskStatus === "0" ? "1" : "0";
       });
     },

+ 169 - 170
src/views/videoPlayback/bigVideoPlayback.vue

@@ -1,7 +1,6 @@
 <template>
   <div style="height: 100%">
     <el-row style="display: flex; width: 100%;height: 100%">
-      <!--设备树-->
       <div class="tree-card" style="
           display: flex;
           width: 240px;
@@ -16,20 +15,6 @@
             <el-tab-pane label="设备-流" name="first">
               <span slot="label">
                 <span>摄像机-视频流</span>
-<!--                <el-dropdown size="mini" trigger="click" style="margin-left: 10px;">-->
-<!--                  <i class="el-icon-refresh" style="color: #ffffff; font-size: 14px">{{ getSelectType }}</i>-->
-<!--                  <el-dropdown-menu slot="dropdown">-->
-<!--                    <el-dropdown-item icon="el-icon-refresh" @click.native="clickDownItem(2)">-->
-<!--                      默认-->
-<!--                    </el-dropdown-item>-->
-<!--                    <el-dropdown-item icon="el-icon-refresh" @click.native="clickDownItem(1)">-->
-<!--                      硬存储-->
-<!--                    </el-dropdown-item>-->
-<!--                    <el-dropdown-item icon="el-icon-refresh" @click.native="clickDownItem(0)">-->
-<!--                      流媒体-->
-<!--                    </el-dropdown-item>-->
-<!--                  </el-dropdown-menu>-->
-<!--                </el-dropdown>-->
               </span>
 
               <el-tree :data="streamOptions" :props="defaultProps" :expand-on-click-node="true" ref="tree1"
@@ -44,7 +29,6 @@
                   " slot-scope="{ node, data }">
                   <div style="display: flex; align-items: center">
                     <v-icon name="oi-git-merge" scale="1.2" fill="#ffffff"></v-icon>
-<!--                    <el-tooltip :content="data.label" placement="top">-->
                       <span style="font-size: 14px; margin-left: 5px;
                               width: 160px;
                               display: inline-block;
@@ -53,7 +37,6 @@
                               text-overflow: ellipsis;">{{
                           data.label
                         }}</span>
-<!--                    </el-tooltip>-->
                   </div>
                 </span>
               </el-tree>
@@ -63,28 +46,17 @@
         <div class="head-container bottom-head-class" >
           <el-tabs v-model="activeName" :stretch="true">
             <el-tab-pane label="日历" name="first">
-              <Calendar :sundayStart="false" v-on:choseDay="clickDay" :markDate="markDay"
+              <Calendar :key="calendarKey" :sundayStart="false" v-on:choseDay="clickDay" :markDate="markDay"
                         v-on:changeMonth="changeMonth"></Calendar>
-              <!-- <vue-quick-calendar showPrepNext startYearMonth='2021-01' :markDate="markDate" :checkedDate='checkedDate'
-                @clickDate="clickDate" @changeMonth="changeMonth" /> -->
-              <!-- <el-calendar v-model="month" style="flex: 1;flex-shrink: 1;">
-                <template slot="dateCell" slot-scope="{date,data}">
-                  <p v-if="isCurrentMonth(data)" :class="getDateClass(data)" @click="clickDay(data)">
-                    {{ data.day.split('-').slice(2).join('-') }}
-                  </p>
-                </template>
-</el-calendar> -->
             </el-tab-pane>
           </el-tabs>
         </div>
       </div>
-      <!--监控预览-->
       <el-card class="tree-card" style="flex: 1; margin-left: 10px" :body-style="{
         display: 'flex',
         flexDirection: 'column',
         height: '100%',
       }">
-        <!--播放器盒子-->
         <div style="
             height: calc(88vh);
             aspect-ratio: 16/9;
@@ -113,7 +85,6 @@
               <span slot="description" style="color: white">当前无可播放的录像资源</span>
             </el-empty>
             <div style="position: absolute;bottom: 0;right: 0;">
-              <!-- 点击弹出播放速度调整栏 -->
               <el-dropdown @command="handleChangeSpeed">
                 <span class="el-dropdown-link">
                   <v-icon name="ri-speed-fill" scale="1.5"/>
@@ -137,7 +108,6 @@
 
         </div>
 
-        <!--时间线-->
         <div style="display: flex; margin-bottom: 5px">
           <TimelineCanvas style="flex-grow: 1" ref="time_line" @change="changeTimeline" @click="clickTimeline"
                           :time-range="time" :mark-time="markTime" :isAutoPlay="isAutoPlay" :startMeddleTime="startMeddleTime">
@@ -149,7 +119,6 @@
           <el-button class="button-class"
                      @click="downloadVideo">下载</el-button>
         </div>
-        <!-- 下载对话框 -->
         <el-dialog :visible.sync="openDownload" width="300px" append-to-body>
           <div style="text-align: center">
             是否确认下载通道{{ equipmentInfo.app }}从{{ downloadTime[0] }}至{{
@@ -182,8 +151,9 @@ import TimelineCanvas from "@/components/TimelineCanvas";
 import { formatDate, formatDate2, } from "../../utils";
 import { getPlayUrl, selectStreamProxyItem } from "../../api/service/streamProxy";
 import { addIcons } from "oh-vue-icons";
-import { OiGitMerge,RiSpeedFill } from "oh-vue-icons/icons";
-addIcons(OiGitMerge,RiSpeedFill);
+import { OiGitMerge, RiSpeedFill } from "oh-vue-icons/icons";
+
+addIcons(OiGitMerge, RiSpeedFill);
 
 export default {
   components: {
@@ -191,7 +161,7 @@ export default {
     player,
     TimelineCanvas,
   },
-  props:{
+  props: {
     streamOptions: {
       type: Object,
       required: true
@@ -199,7 +169,7 @@ export default {
   },
   data() {
     return {
-      emptyImg:emptyIcon,
+      emptyImg: emptyIcon,
       // 遮罩层
       loading: true,
       // 分组数据
@@ -248,8 +218,16 @@ export default {
       endMonth: "",
       // 日历
       markDay: [],
+      // 日历刷新Key,用于强制重置日历状态
+      calendarKey: 0,
+
+      jessibucaSpeed: '1.0',
 
-      jessibucaSpeed:'1.0',
+      // 【新增】双击控制变量
+      lastTreeClickTime: 0,
+      lastTreeData: null,
+      lastClickTime: 0,
+      lastClickDate: null,
     };
   },
   watch: {
@@ -293,33 +271,31 @@ export default {
     // this.getStreamOptions();
   },
   methods: {
-    handleChangeSpeed(command){
-      if(command==='4'){
-        setRecordVideoSpeed({id: this.equipmentInfo.id,startTime: this.startMeddleTime,type: 4,}).then(
+    handleChangeSpeed(command) {
+      if (command === '4') {
+        setRecordVideoSpeed({ id: this.equipmentInfo.id, startTime: this.startMeddleTime, type: 4, }).then(
           (res) => {
             this.jessibucaSpeed = '2.0'
             // 调整时间轴
-            this.playStatus("play",2)
-            // 调整视频
-
+            this.playStatus("play", 2)
             this.$message.success('倍速成功')
           }
         )
       }
-      if(command==='5'){
-        setRecordVideoSpeed({id: this.equipmentInfo.id,startTime: this.startMeddleTime,type: 5,}).then(
+      if (command === '5') {
+        setRecordVideoSpeed({ id: this.equipmentInfo.id, startTime: this.startMeddleTime, type: 5, }).then(
           (res) => {
             this.jessibucaSpeed = '0.5'
-            this.playStatus("play",0.5)
+            this.playStatus("play", 0.5)
             this.$message.success('倍速成功')
           }
         )
       }
-      if(command==='6'){
-        setRecordVideoSpeed({id: this.equipmentInfo.id,startTime: this.startMeddleTime,type: 6,}).then(
+      if (command === '6') {
+        setRecordVideoSpeed({ id: this.equipmentInfo.id, startTime: this.startMeddleTime, type: 6, }).then(
           (res) => {
             this.jessibucaSpeed = '1.0'
-            this.playStatus("play",1)
+            this.playStatus("play", 1)
             this.$message.success('倍速成功')
           }
         )
@@ -328,8 +304,22 @@ export default {
     clickDownItem(val) {
       this.selectType = Number(val);
     },
+    // 【修改】日历双击逻辑
     clickDay(data) {
-      console.log(formatDate(data));
+      const now = Date.now();
+      // 判断是否为双击(间隔 < 300ms 且是同一日期)
+      // 注意:data 可能是一个对象或字符串,使用 JSON.stringify 比较内容
+      const isDblClick =
+        now - this.lastClickTime < 300 &&
+        JSON.stringify(data) === JSON.stringify(this.lastClickDate);
+
+      this.lastClickTime = now;
+      this.lastClickDate = data;
+
+      // 只有双击时才执行后续逻辑
+      if (!isDblClick) return;
+
+      console.log("双击日历日期:", formatDate(data));
       if (this.equipmentInfo.id === "") {
         return;
       }
@@ -348,7 +338,6 @@ export default {
             item.cameraName = this.equipmentInfo.cameraName;
             item.groupName = this.equipmentInfo.groupName;
           });
-          // this.videoList = res.data;
           // 时间轴标记
           this.startMeddleTime = res.data[0].Start;
           this.markTime = res.data.map((item) => ({
@@ -361,24 +350,35 @@ export default {
           this.getPlayUrl(this.equipmentInfo.id, res.data[0].Start)
         } else {
           this.videoList = [];
+          this.markTime = []; // 清空标记
+          this.$message.info("该日期无录像数据");
         }
       });
     },
+    // 【优化】播放地址获取,增大时间窗口至120分钟
     getPlayUrl(id, startTime) {
-      // 将字符串转换为Date对象
       const dateObj = new Date(startTime);
       let endTime
-      // 检查并处理可能的解析错误
       if (isNaN(dateObj)) {
         console.error('Invalid date');
+        return;
+      }
+
+      // 优化:改为请求 120 分钟,减少播放中断
+      dateObj.setMinutes(dateObj.getMinutes() + 120);
+
+      // 边界处理:不跨天(可选,视后端支持情况,通常建议不跨天)
+      const endOfDay = new Date(startTime);
+      endOfDay.setHours(23, 59, 59, 999);
+      if (dateObj > endOfDay) {
+        endTime = formatDate(endOfDay);
       } else {
-        // 加上半个小时
-        dateObj.setMinutes(dateObj.getMinutes() + 30);
-        // 将新的Date对象转换回字符串
         endTime = formatDate(dateObj);
-        this.endTimeMark = endTime;
-        console.log(endTime);
       }
+
+      this.endTimeMark = endTime;
+      console.log(`请求播放区间: ${startTime} ~ ${endTime}`);
+
       selectRecordVideo({
         id: id,
         startTime: startTime,
@@ -420,7 +420,6 @@ export default {
           });
         }
         this.markDay = s1;
-        console.log(this.markDay);
       });
     },
     getStreamOptions() {
@@ -452,7 +451,6 @@ export default {
       });
     },
     getGroupOptions() {
-      // 获取分组
       cameraGroupSelect().then((res) => {
         this.groupOptions = res.map((item) => ({
           ...item,
@@ -462,10 +460,9 @@ export default {
         }));
       });
     },
-    // 获取设备分组下的设备
     handleTabClick(tab) {
       if (tab.name === "second") {
-        this.groupOptions.forEach(function (item) {
+        this.groupOptions.forEach(function(item) {
           listCamera({ groupId: item.id }).then((res) => {
             const children = res.rows.map((item) => ({
               ...item,
@@ -477,21 +474,36 @@ export default {
         });
       }
     },
-    // 筛选节点
     filterNode(value, data) {
       if (!value) return true;
       return data.label.indexOf(value) !== -1;
     },
+    // 【修改】设备树双击逻辑 + 状态重置
     handleNodeClick(data) {
-      // 初始化时间轴
+      // 1. 双击检测
+      const now = Date.now();
+      const isDblClick =
+        (now - this.lastTreeClickTime < 300) &&
+        (this.lastTreeData && this.lastTreeData.id === data.id);
+
+      this.lastTreeClickTime = now;
+      this.lastTreeData = data;
+
+      // 非双击则只更新选中状态,不加载数据
+      if (!isDblClick) return;
+
+      // 2. 执行加载逻辑
       this.isAutoPlay = false;
       this.startMeddleTime = "";
       this.startTime = "";
       this.endTime = "";
       this.markTime = [];
 
+      // 【关键】切换摄像头时,重置日历选中状态
+      this.time = "";
+      this.calendarKey++; // 强制重置日历组件
+
       if (data.isLeaf) {
-        this.time = "";
         this.videoUrl = "";
         this.equipmentInfo = data;
         if (this.startMonth === "") {
@@ -504,6 +516,7 @@ export default {
             new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1)
           );
         }
+        // 查询该月哪些天有录像,用于日历打点
         selectRecordList({
           id: data.id,
           startTime: this.startMonth,
@@ -517,18 +530,15 @@ export default {
               let endDate = new Date(item.End.split(' ')[0]);
 
               while (startDate <= endDate) {
-                // 使用 formatDate2 来格式化日期
                 let currentDateStr = formatDate2(startDate);
                 if (!s1.includes(currentDateStr)) {
                   s1.push(currentDateStr);
                 }
-                // 日期加一天
                 startDate.setDate(startDate.getDate() + 1);
               }
             });
           }
           this.markDay = s1;
-          console.log(this.markDay);
         });
       } else {
         this.equipmentInfo = {
@@ -555,26 +565,16 @@ export default {
         this.$modal.msgWarning("请选择设备和日期!");
         return;
       }
-      console.log(new Date(this.time + " " + this.downloadTime[0]));
-      if (
-        parseInt(
-          (new Date(this.time + " " + this.downloadTime[1]).getTime() -
-            new Date(this.time + " " + this.downloadTime[0]).getTime()) /
-          1000 /
-          60
-        ) > 120
-      ) {
+      // 简单计算时间差
+      let start = new Date(this.time + " " + this.downloadTime[0]).getTime();
+      let end = new Date(this.time + " " + this.downloadTime[1]).getTime();
+      let diffMins = (end - start) / 1000 / 60;
+
+      if (diffMins > 120) {
         this.$modal.msgWarning("时长大于2小时,请重新选择!");
         return;
       }
-      if (
-        parseInt(
-          (new Date(this.time + " " + this.downloadTime[1]).getTime() -
-            new Date(this.time + " " + this.downloadTime[0]).getTime()) /
-          1000 /
-          60
-        ) < 1
-      ) {
+      if (diffMins < 1) {
         this.$modal.msgWarning("时长低于1分钟,请重新选择!");
         return;
       }
@@ -603,50 +603,52 @@ export default {
     clearTreeSelected() {
       this.$store.commit("updateSelectedTreeData", {});
       this.$store.commit("updateSelectedMonitor", {});
-      // this.selectedTree = []
     },
     changeTimeline(date, status) {
-      // console.log("选择时间:"+date+"播放状态:"+status)
       this.startMeddleTime = date;
     },
-    // 鼠标拖动时间轴的抬起事件
+    // 【优化】时间轴点击事件:本地计算,去除网络请求
+    // 修复问题:播放状态下鼠标悬停跳动问题(因为移除了会导致重绘的网络请求)
     clickTimeline(date) {
       if (this.equipmentInfo.id === "") {
         return;
       }
+
+      let finalStartTime = date;
+
+      // 智能吸附逻辑 (本地计算)
       if (this.equipmentInfo.isYuhang === "0") {
-        // 查list获取第一个录像段的开始时间
-        selectRecordList({
-          id: this.equipmentInfo.id,
-          startTime: date,
-          endTime: this.endTime,
-          selectType: this.selectType
-        }).then((res) => {
-          if (res.data.length > 0) {
-            console.log(res.data);
-            this.startMeddleTime = res.data[0].Start;
+        const clickTime = new Date(date).getTime();
+
+        // 1. 检查是否点击在已有录像段内
+        const insideSegment = this.markTime.find(item => {
+          const s = new Date(item.beginTime).getTime();
+          const e = new Date(item.endTime).getTime();
+          return clickTime >= s && clickTime <= e;
+        });
+
+        if (insideSegment) {
+          finalStartTime = date;
+        } else {
+          // 2. 点击空白处,寻找下一段录像开始
+          const nextSegment = this.markTime.find(item => {
+            return new Date(item.beginTime).getTime() > clickTime;
+          });
+
+          if (nextSegment) {
+            finalStartTime = nextSegment.beginTime;
           } else {
-            this.startMeddleTime = date;
+            // 后面没录像了,保持原位置或不做处理
+            finalStartTime = date;
           }
-          this.getPlayUrl(this.equipmentInfo.id, this.startMeddleTime)
-        });
-      } else {
-        this.startMeddleTime = date;
-        this.getPlayUrl(this.equipmentInfo.id, this.startMeddleTime)
+        }
       }
-      // 获取播放链接
-
-      // selectRecordVideo({
-      //   id: this.equipmentInfo.id,
-      //   startTime: date,
-      //   endTime: this.endTime,
-      //   selectType: this.selectType
-      // }).then((res) => {
-      //   this.videoUrl = res.data;
-      // });
+
+      // 直接更新播放时间,无延迟
+      this.startMeddleTime = finalStartTime;
+      this.getPlayUrl(this.equipmentInfo.id, this.startMeddleTime);
     },
     playStatus(str, speed) {
-      console.log(str);
       switch (str) {
         case "play":
           this.$refs.time_line.play(this.startMeddleTime, speed);
@@ -658,7 +660,6 @@ export default {
     },
     setIsAutoPlay(b) {
       if (b) {
-        console.log(b);
         this.isAutoPlay = true;
         this.$refs.time_line.play();
       } else {
@@ -675,7 +676,6 @@ export default {
 <style scoped>
 .app-container {
   padding: 10px 10px;
-  /*background: #1a1a2e;*/
 }
 
 ::v-deep .top-head-class .el-tree-node__content {
@@ -689,11 +689,11 @@ export default {
   color: #FFFFFF;
 }
 
-::v-deep .el-tree-node:focus>.el-tree-node__content {
+::v-deep .el-tree-node:focus > .el-tree-node__content {
   background-color: transparent;
 }
 
-.el-tree--highlight-current>>>.el-tree-node.is-current>.el-tree-node__content {
+.el-tree--highlight-current >>> .el-tree-node.is-current > .el-tree-node__content {
   background-image: linear-gradient(to right, #acb3bb, #304156 100%);
   font-weight: bold;
 }
@@ -707,35 +707,36 @@ export default {
 
 ::v-deep .el-tabs__header {
   margin: 0 0 0px;
-  /*background-color: #304156;*/
 }
 
-.head-container{
+.head-container {
   margin-bottom: 5px;
-  .el-tabs{
+
+  .el-tabs {
     background: none;
   }
+
   background-image: url("../../assets/images/kuang-2.png") !important;
   background-repeat: no-repeat !important;
   background-size: 240px 100% !important;
 }
+
 ::v-deep .top-head-class .el-tabs__content {
   height: 54.7vh !important;
   flex-grow: 1;
-  /*background-color: #1a2c43;*/
   overflow-y: auto;
 
-  /* 滚动槽 */
   &::-webkit-scrollbar-track {
     background-color: transparent;
   }
 }
-::v-deep .bottom-head-class .el-tabs__content{
+
+::v-deep .bottom-head-class .el-tabs__content {
   height: 100% !important;
   flex-grow: 1;
-  /*background-color: #1a2c43;*/
   overflow-y: auto;
 }
+
 ::v-deep .el-tabs__item {
   font-size: 16px;
   color: #ffffff;
@@ -763,7 +764,6 @@ export default {
 
 ::v-deep .el-input__inner {
   border: none;
-  /*background-color: #6e7a89;*/
   background-color: #0d1a2b;
   color: #ffffff;
 }
@@ -773,31 +773,20 @@ export default {
   color: #aaaaaa;
   background-color: #0d1a2b;
 }
-::v-deep .el-range-separator{
-  color: #9b9b9b;
-}
-.cell-player {
-  flex: 1;
-  display: flex;
-  width: 100%;
-  flex-wrap: wrap;
-  justify-content: space-between;
-}
 
-.cell-player-1 {
-  width: 100%;
-  min-height: 150px;
-  aspect-ratio: 16/9;
-  border: 1px solid #fff;
-  box-sizing: border-box;
+::v-deep .el-range-separator {
+  color: #9b9b9b;
 }
 
+/* 日历样式 */
 ::v-deep .wh_top_change li {
   color: #ffffff;
 }
-::v-deep .wh_content{
+
+::v-deep .wh_content {
   margin-top: 3px
 }
+
 ::v-deep .wh_content_all {
   background-color: #03111e !important;
   border: none;
@@ -810,19 +799,21 @@ export default {
 }
 
 ::v-deep .wh_content_item .wh_isMark {
-  background-color: #6e7a89 !important; /* 这是您原来的背景色,可以保留或修改 */
-  color: #26e847 !important; /* 将字体颜色修改为绿色 */
-  font-weight: bold; /* 可以加粗以突出显示 */
+  background-color: #6e7a89 !important;
+  color: #26e847 !important;
+  font-weight: bold;
 }
 
 ::v-deep .wh_item_date:hover {
   background: #409eff !important;
   color: #fff;
 }
-::v-deep .wh_item_date{
+
+::v-deep .wh_item_date {
   height: 30px !important;
   width: 30px !important;
 }
+
 ::v-deep .wh_content_item .wh_other_dayhide {
   color: transparent;
   pointer-events: none;
@@ -847,7 +838,8 @@ export default {
   border-top: 2px solid #ffffff;
   border-right: 2px solid #ffffff;
 }
-::v-deep .button-class{
+
+::v-deep .button-class {
   margin-left: 5px;
   height: 36px;
   color: #ffffff;
@@ -861,54 +853,61 @@ export default {
 
 </style>
 <style lang="scss">
-.time-class{
+.time-class {
   color: white;
   background: #051c38 !important;
   border: 1px solid #387cf8;
 }
-.el-time-spinner__item.active:not(.disabled){
+
+.el-time-spinner__item.active:not(.disabled) {
   color: white;
 }
-.el-time-spinner__item{
+
+.el-time-spinner__item {
   font-size: 16px;
 }
-.el-time-range-picker__header{
+
+.el-time-range-picker__header {
   font-size: 18px;
 }
-.el-time-panel__btn{
+
+.el-time-panel__btn {
   font-size: 16px;
 }
-.cancel{
+
+.cancel {
   color: #a1a1a1;
 }
-.el-time-range-picker__body{
+
+.el-time-range-picker__body {
   border: 1px solid #474747;
 }
-.el-time-panel__footer{
-  border-top:1px solid #474747;
+
+.el-time-panel__footer {
+  border-top: 1px solid #474747;
 }
-::v-deep .time-class> .popper__arrow::after{
-  border-top-color:#032046 !important;
+
+::v-deep .time-class > .popper__arrow::after {
+  border-top-color: #032046 !important;
 }
-.el-time-spinner__item:hover:not(.disabled):not(.active){
+
+.el-time-spinner__item:hover:not(.disabled):not(.active) {
   background: #042555;
 }
-.el-time-spinner__wrapper{
+
+.el-time-spinner__wrapper {
   border-bottom: 1px solid #474747;
 }
+
 .time-class *, *:before, *:after {
   box-sizing: content-box !important;
 }
-.el-time-range-picker__cell{
+
+.el-time-range-picker__cell {
   width: 45%;
 }
-.el-time-panel__content::after, .el-time-panel__content::before{
+
+.el-time-panel__content::after, .el-time-panel__content::before {
   height: 23px;
 }
-//::v-deep .el-tree{
-//  white-space: nowrap !important;
-//  overflow: hidden !important;
-//  text-overflow: ellipsis !important;
-//  max-width: 80% !important;
-//}
 </style>

+ 16 - 3
src/views/videoPlayback/components/videoBox.vue

@@ -29,6 +29,18 @@
           controlsList="noplaybackrate"
           style="width: 100%; height: 100%; object-fit: fill"
         ></video>
+<!--    <jessibucaBox v-show="videoUrl" :video-src='videoUrl1' :video-id="videoElement"-->
+<!--                   ref='player' @destroy="close"></jessibucaBox>-->
+<!--    <webrtc-player v-show="videoUrl" :video-src='videoUrl1' :video-id="videoElement"-->
+<!--                    ref='player' @destroy="close" :key="index" style='width: 100%' />-->
+<!--    <webrtc-player-->
+<!--      v-show="videoUrl"-->
+<!--      video-src="http://192.168.1.150:80/index/api/webrtc?app=63293011&stream=24384184&type=play"-->
+<!--      :video-id="videoElement"-->
+<!--      ref="player"-->
+<!--      @destroy="close"-->
+<!--      :key="index"-->
+<!--      style="width: 100%" />-->
     <el-empty v-show="!videoUrl" :image="emptyImg" :style="{height:'100%',background: '#000000',display:'flex',justifyItems:'center',activeItem:'center'}">
       <span slot='description' style="color: white">当前无可播放的录像资源</span>
     </el-empty>
@@ -42,9 +54,10 @@ import {Empty} from "element-ui";
 import webrtcPlayer from "@/components/WebrtcPlayer";
 import player from '@/components/Jessibuca'
 import emptyIcon from '@/assets/images/play.png'
+import jessibucaBox from '@/views/livePreview/components/jessibucaBox.vue'
 export default {
   name: 'videoBox',
-  components: {player, webrtcPlayer},
+  components: { jessibucaBox, player, webrtcPlayer},
   data() {
     return {
       emptyImg:emptyIcon,
@@ -153,8 +166,8 @@ export default {
     }
   },
   mounted() {
-    window.addEventListener('resize',this.handleResize);
-    this.handleResize()
+    // window.addEventListener('resize',this.handleResize);
+    // this.handleResize()
     // this.initVideo()
   },
   created() {

+ 139 - 52
src/views/videoPlayback/index.vue

@@ -58,17 +58,11 @@
         <div class="head-container" style="display: flex;flex:1">
           <el-tabs v-model="activeName" :stretch="true">
             <el-tab-pane label="日历" name="first">
-              <Calendar :sundayStart="false" v-on:choseDay="clickDay" :markDate="markDay"
-                v-on:changeMonth="changeMonth"></Calendar>
-              <!-- <vue-quick-calendar showPrepNext startYearMonth='2021-01' :markDate="markDate" :checkedDate='checkedDate'
-                @clickDate="clickDate" @changeMonth="changeMonth" /> -->
-              <!-- <el-calendar v-model="month" style="flex: 1;flex-shrink: 1;">
-                <template slot="dateCell" slot-scope="{date,data}">
-                  <p v-if="isCurrentMonth(data)" :class="getDateClass(data)" @click="clickDay(data)">
-                    {{ data.day.split('-').slice(2).join('-') }}
-                  </p>
-                </template>
-</el-calendar> -->
+              <Calendar :key="calendarKey"
+                        :value="time" :sundayStart="false"
+                        v-on:choseDay="clickDay"
+                        :markDate="markDay"
+                        v-on:changeMonth="changeMonth"></Calendar>
             </el-tab-pane>
           </el-tabs>
         </div>
@@ -187,8 +181,11 @@ export default {
   },
   data() {
     return {
+      lastTreeClickTime: 0,
+      lastTreeData: null,
       // 遮罩层
       loading: true,
+      calendarKey: 0,
       // 分组数据
       videoList: [],
       // 总条数
@@ -237,6 +234,10 @@ export default {
       markDay: [],
 
       jessibucaSpeed:'1.0',
+
+    //   双击控制
+      lastClickTime: 0,
+      lastClickDate: null,
     };
   },
   watch: {
@@ -313,6 +314,20 @@ export default {
       this.selectType = Number(val);
     },
     clickDay(data) {
+      const now = Date.now();
+      // 判断是否为双击(间隔 < 300ms 且是同一日期)
+      const isDblClick =
+        now - this.lastClickTime < 300 &&
+        JSON.stringify(data) === JSON.stringify(this.lastClickDate);
+
+      // 更新上次点击记录
+      this.lastClickTime = now;
+      this.lastClickDate = data;
+
+      // 只有双击时才执行后续逻辑
+      if (!isDblClick) return;
+
+      // 以下是原单击逻辑(现在仅双击触发)
       console.log(formatDate(data));
       if (this.equipmentInfo.id === "") {
         return;
@@ -332,8 +347,6 @@ export default {
             item.cameraName = this.equipmentInfo.cameraName;
             item.groupName = this.equipmentInfo.groupName;
           });
-          // this.videoList = res.data;
-          // 时间轴标记
           this.startMeddleTime = res.data[0].Start;
           this.markTime = res.data.map((item) => ({
             beginTime: item.Start,
@@ -341,7 +354,6 @@ export default {
             bgColor: "#ffc05e",
             text: "",
           }));
-
           this.getPlayUrl(this.equipmentInfo.id, res.data[0].Start)
         } else {
           this.videoList = [];
@@ -351,25 +363,46 @@ export default {
     getPlayUrl(id, startTime) {
       // 将字符串转换为Date对象
       const dateObj = new Date(startTime);
-      let endTime
-      // 检查并处理可能的解析错误
+      let endTime;
+
       if (isNaN(dateObj)) {
         console.error('Invalid date');
+        return;
+      }
+
+      // 【优化点】:
+      // 既然后端已经优化,不再害怕跨小时查询。
+      // 建议将请求窗口从 30分钟 增加到 60分钟 或 120分钟
+      // 这样用户拖动一次可以看很久,减少卡顿。
+      const REQUEST_DURATION_MINUTES = 60; // 建议改为 60 或 120
+
+      dateObj.setMinutes(dateObj.getMinutes() + REQUEST_DURATION_MINUTES);
+
+      // 边界检查:不要超过当天 23:59:59 (如果您的业务逻辑严格限制按天)
+      // 如果后端支持跨天查询,这一步甚至可以省略
+      const endOfDay = new Date(startTime);
+      endOfDay.setHours(23, 59, 59, 999);
+      if (dateObj > endOfDay) {
+        // 如果计算出的结束时间超过了今天,就截断到今天结束
+        // 或者保留原样,看后端 findContinuous 是否支持自动跨天
+        // 根据您之前的后端代码,findContinuous 是支持跨天(daily)或详情的,
+        // 但为了播放器的稳定性,通常建议截断到当天
+        endTime = formatDate(endOfDay);
       } else {
-        // 加上半个小时
-        dateObj.setMinutes(dateObj.getMinutes() + 30);
-        // 将新的Date对象转换回字符串
         endTime = formatDate(dateObj);
-        this.endTimeMark = endTime;
-        console.log(endTime);
       }
+
+      this.endTimeMark = endTime;
+      console.log(`请求播放流: ${startTime} -> ${endTime}`);
+
       selectRecordVideo({
         id: id,
-        startTime: startTime,
+        startTime: startTime, // 这里保持原来的 HH:mm:ss 格式吗?需确认 formatDate 返回格式
         endTime: endTime,
         selectType: this.selectType
       }).then((res) => {
         this.videoUrl = res.data;
+        // 如果是自动播放逻辑,可能需要在这重置播放器状态
       });
     },
     changeMonth(data) {
@@ -397,15 +430,29 @@ export default {
         endTime: this.endMonth,
         selectType: this.selectType
       }).then((res) => {
-        let s1 = [];
-        if (res.data !== "") {
+        const s1 = new Set(); // 用 Set 自动去重
+        if (res.data && res.data.length > 0) { // 更严谨的判断
           res.data.forEach((item) => {
-            s1.push(formatDate2(item.Start));
+            const start = new Date(item.Start);
+            const end = new Date(item.End);
+
+            // 计算记录覆盖的所有日期(包括开始日、结束日和中间跨天的日期)
+            const current = new Date(start); // 从开始日期遍历
+            while (current <= end) {
+              // 格式化当前日期为 "YYYY-MM-DD"(不含时间)
+              const dateStr = formatDate2(new Date(current));
+              s1.add(dateStr); // 添加到 Set 中(自动去重)
+
+              // 跳到下一天的 00:00:00
+              current.setDate(current.getDate() + 1);
+              current.setHours(0, 0, 0, 0);
+            }
           });
         }
-        this.markDay = s1;
+        this.markDay = Array.from(s1); // 转换为数组
         console.log(this.markDay);
       });
+      this.time = "";
     },
     getStreamOptions() {
       selectStreamProxyItem({ recordShow: '1' }).then((response) => {
@@ -467,6 +514,26 @@ export default {
       return data.label.indexOf(value) !== -1;
     },
     handleNodeClick(data) {
+      // --- 1. 双击模拟逻辑开始 ---
+      const now = Date.now();
+      const isDblClick =
+        // 时间间隔小于 300ms
+        (now - this.lastTreeClickTime < 300) &&
+        // 且点击的是同一个节点 (通过 id 判断,确保唯一性)
+        (this.lastTreeData && this.lastTreeData.id === data.id);
+
+      // 更新记录
+      this.lastTreeClickTime = now;
+      this.lastTreeData = data;
+
+      // 如果不是双击,直接结束,不执行后续加载逻辑
+      // 注意:ElementUI 默认单击会展开/折叠节点,这个行为通常是保留的,
+      // 这里只拦截业务数据的加载。
+      if (!isDblClick) return;
+      // --- 双击模拟逻辑结束 ---
+
+
+      // --- 以下为原有业务逻辑 (保持不变) ---
       // 初始化时间轴
       this.isAutoPlay = false;
       this.startMeddleTime = "";
@@ -511,6 +578,8 @@ export default {
           id: "",
         };
       }
+      this.time = ""
+      this.calendarKey++;
     },
     play(record) {
       selectRecordVideo({
@@ -587,36 +656,54 @@ export default {
       if (this.equipmentInfo.id === "") {
         return;
       }
+
+      // 1. 确定点击的时间点
+      let finalStartTime = date;
+
+      // 2. 本地“吸附”逻辑:替代原本的 selectRecordList 请求
+      // 如果是流媒体模式 (isYuhang === '0'),需要找到最近的录像片段
       if (this.equipmentInfo.isYuhang === "0") {
-        // 查list获取第一个录像段的开始时间
-        selectRecordList({
-          id: this.equipmentInfo.id,
-          startTime: date,
-          endTime: this.endTime,
-          selectType: this.selectType
-        }).then((res) => {
-          if (res.data.length > 0) {
-            console.log(res.data);
-            this.startMeddleTime = res.data[0].Start;
+        // 将点击时间转换为时间戳进行比较
+        const clickTime = new Date(date).getTime();
+
+        // 在本地已加载的 markTime (黄色条数据) 中查找
+        // markTime 结构: { beginTime: "yyyy-MM-dd HH:mm:ss", endTime: "...", ... }
+        // 我们需要找到一个片段,使得 clickTime 在片段内,或者找到 clickTime 之后的第一个片段
+
+        // 先排序,确保按时间顺序 (通常后端返回已排序,这里保险起见)
+        // 注意:如果 markTime 数据量极大,可以考虑二分查找,但一般几千条内 filter+find 足够快
+
+        let targetSegment = null;
+
+        // 情况A: 点击的位置正好在某段录像中间 -> 直接播放点击位置
+        const insideSegment = this.markTime.find(item => {
+          const s = new Date(item.beginTime).getTime();
+          const e = new Date(item.endTime).getTime();
+          return clickTime >= s && clickTime <= e;
+        });
+
+        if (insideSegment) {
+          finalStartTime = date; // 就在当前位置播
+        } else {
+          // 情况B: 点击的位置是空白 -> 跳到下一个录像段的开头
+          // 找到第一个开始时间 > 点击时间的片段
+          const nextSegment = this.markTime.find(item => {
+            return new Date(item.beginTime).getTime() > clickTime;
+          });
+
+          if (nextSegment) {
+            finalStartTime = nextSegment.beginTime;
+            console.log("自动吸附到下一段录像:", finalStartTime);
           } else {
-            this.startMeddleTime = date;
+            // 后面没有录像了
+            finalStartTime = date;
           }
-          this.getPlayUrl(this.equipmentInfo.id, this.startMeddleTime)
-        });
-      } else {
-        this.startMeddleTime = date;
-        this.getPlayUrl(this.equipmentInfo.id, this.startMeddleTime)
+        }
       }
-      // 获取播放链接
-
-      // selectRecordVideo({
-      //   id: this.equipmentInfo.id,
-      //   startTime: date,
-      //   endTime: this.endTime,
-      //   selectType: this.selectType
-      // }).then((res) => {
-      //   this.videoUrl = res.data;
-      // });
+
+      // 3. 更新时间并播放
+      this.startMeddleTime = finalStartTime;
+      this.getPlayUrl(this.equipmentInfo.id, this.startMeddleTime);
     },
     playStatus(str, speed) {
       console.log(str);

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff