Просмотр исходного кода

✨ feat(海康监控): 支持拖拽监控点到指定视口播放

Zimo 6 часов назад
Родитель
Сommit
3ef60cf19b
1 измененных файлов с 149 добавлено и 4 удалено
  1. 149 4
      src/views/pms/video_center/haikang/index.vue

+ 149 - 4
src/views/pms/video_center/haikang/index.vue

@@ -55,6 +55,9 @@ const activeWindowIndex = ref(0)
 const hoveredWindowIndex = ref<number>()
 // 鼠标进入页面叠加按钮后 SDK 会触发 windowEventOut,单独记录按钮悬浮以保持栏可见。
 const hoveredControlWindowIndex = ref<number>()
+// 拖拽期间才展示投放层,避免常驻透明层拦截 H5Player 的选窗、双击和电子放大事件。
+const draggingCamera = ref<HikvisionCameraNode>()
+const dragOverWindowIndex = ref<number>()
 const splitPopoverVisible = ref(false)
 const splitControlLoading = ref(false)
 // 云台 Popover 的全局展示状态和所属窗口,用于确保弹层打开时对应视口工具栏保持可见。
@@ -756,14 +759,16 @@ function initPlayer(): Promise<HikvisionPlayerInstance> {
   return playerInitPromise
 }
 
-async function playCameraPreview(camera: HikvisionCameraNode) {
+async function playCameraPreview(
+  camera: HikvisionCameraNode,
+  windowIndex = activeWindowIndex.value
+) {
   if (stopAllLoading.value || splitControlLoading.value) {
     showPreviewError('播放器正在切换状态,请稍后再选择监控点')
     return
   }
 
-  // 点击监控点时固定目标窗口,之后用户切换选中窗口不会改变本次请求的播放位置。
-  const windowIndex = activeWindowIndex.value
+  // 开始请求时固定目标窗口,之后用户切换选中窗口不会改变本次请求的播放位置。
   const requestId = beginWindowPreviewRequest(windowIndex)
   clearPreviewError()
 
@@ -776,6 +781,13 @@ async function playCameraPreview(camera: HikvisionCameraNode) {
     if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
     if (!previewUrl) throw new Error('预览地址为空')
 
+    // 首次操作就是拖拽时播放器可能刚完成初始化,需要补一次 SDK 选窗;请求期间若用户已经
+    // 选择其他视口,则保持用户的新选择,不把当前窗口强行切回本次请求的目标视口。
+    if (activeWindowIndex.value === windowIndex) {
+      await currentPlayer.JS_SelectWnd(windowIndex).catch(() => undefined)
+      if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    }
+
     // keepDecoder 为 0 时切换监控点前先停止旧流,确保旧解码资源得到回收。
     // 替换摄像机前停止旧监控点的云台动作,停止请求必须使用旧 indexCode。
     await stopActivePtzControl(windowIndex)
@@ -1261,6 +1273,46 @@ function handleNodeClick(data: HikvisionTreeNode) {
   void playCameraPreview(data)
 }
 
+function isCameraDraggable(data: HikvisionTreeNode): data is HikvisionCameraNode {
+  return data.nodeType === 'camera' && data.available !== false && data.onlineStatus !== false
+}
+
+function clearCameraDragState() {
+  draggingCamera.value = undefined
+  dragOverWindowIndex.value = undefined
+}
+
+function handleCameraDragStart(event: DragEvent, data: HikvisionTreeNode) {
+  if (!isCameraDraggable(data)) {
+    event.preventDefault()
+    return
+  }
+
+  draggingCamera.value = data
+  dragOverWindowIndex.value = undefined
+  if (!event.dataTransfer) return
+
+  event.dataTransfer.effectAllowed = 'copy'
+  // Firefox 要求设置 dataTransfer 数据后才会真正启动原生拖拽。
+  event.dataTransfer.setData('text/plain', data.indexCode)
+}
+
+function handleWindowDragOver(event: DragEvent, windowIndex: number) {
+  if (!draggingCamera.value) return
+  dragOverWindowIndex.value = windowIndex
+  if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
+}
+
+function handleCameraDrop(windowIndex: number) {
+  const camera = draggingCamera.value
+  clearCameraDragState()
+  if (!camera) return
+
+  // 投放后同步页面和 SDK 的当前窗口;JS_Play 仍使用显式下标,不依赖回调更新时序。
+  activeWindowIndex.value = windowIndex
+  void playCameraPreview(camera, windowIndex)
+}
+
 async function resizePlayer() {
   playerResizeFrame = undefined
   const currentPlayer = player
@@ -1485,6 +1537,7 @@ onMounted(() => {
 onBeforeUnmount(() => {
   componentUnmounted = true
   treeRequestId += 1
+  clearCameraDragState()
   document.removeEventListener('fullscreenchange', handleFullscreenChange)
   // 页面离开时补发停止;即使开始请求仍在途中,停止流程也会等待并保持正确顺序。
   void stopActivePtzControl()
@@ -1530,8 +1583,13 @@ onBeforeUnmount(() => {
               class="tree-node"
               :class="{
                 'is-camera': data.nodeType === 'camera',
+                'is-draggable': isCameraDraggable(data),
                 'is-unavailable': data.available === false
-              }">
+              }"
+              :draggable="isCameraDraggable(data)"
+              :title="isCameraDraggable(data) ? `${data.name}:可拖到右侧视口播放` : undefined"
+              @dragstart.stop="handleCameraDragStart($event, data)"
+              @dragend.stop="clearCameraDragState">
               <el-icon class="node-icon">
                 <Location v-if="data.nodeType === 'region'" />
                 <VideoCamera v-else />
@@ -1590,6 +1648,28 @@ onBeforeUnmount(() => {
           ref="playerContainerRef"
           class="player-container"
           @dblclick.capture="handlePlayerDoubleClick"></div>
+        <div
+          v-if="draggingCamera"
+          class="window-drop-layer"
+          :style="{
+            gridTemplateColumns: `repeat(${currentSplitColumns}, minmax(0, 1fr))`,
+            gridTemplateRows: `repeat(${currentSplitRows}, minmax(0, 1fr))`
+          }">
+          <div
+            v-for="windowIndex in currentSplitWindowIndexes"
+            :key="windowIndex"
+            class="window-drop-cell"
+            :class="{ 'is-drag-over': dragOverWindowIndex === windowIndex }"
+            @dragenter.prevent="dragOverWindowIndex = windowIndex"
+            @dragover.prevent="handleWindowDragOver($event, windowIndex)"
+            @drop.prevent.stop="handleCameraDrop(windowIndex)">
+            <div class="window-drop-hint">
+              <i class="i-lucide:monitor-down size-6"></i>
+              <span>投放到视口 {{ windowIndex + 1 }}</span>
+              <small>{{ draggingCamera.name }}</small>
+            </div>
+          </div>
+        </div>
         <div
           class="window-loading-layer"
           :style="{
@@ -2054,6 +2134,14 @@ onBeforeUnmount(() => {
   color: #334155;
 }
 
+.tree-node.is-draggable {
+  cursor: grab;
+}
+
+.tree-node.is-draggable:active {
+  cursor: grabbing;
+}
+
 .tree-node.is-camera .node-icon {
   color: #2563eb;
 }
@@ -2227,6 +2315,63 @@ onBeforeUnmount(() => {
   box-sizing: border-box !important;
 }
 
+.window-drop-layer {
+  position: absolute;
+  z-index: 10;
+  display: grid;
+  background: rgb(2 6 23 / 24%);
+  inset: 0 0 var(--video-toolbar-height);
+}
+
+.window-drop-cell {
+  position: relative;
+  display: flex;
+  min-width: 0;
+  min-height: 0;
+  color: #bfdbfe;
+  background: rgb(15 23 42 / 46%);
+  border: 2px dashed rgb(147 197 253 / 62%);
+  align-items: center;
+  justify-content: center;
+  transition:
+    color 0.15s ease,
+    background-color 0.15s ease,
+    border-color 0.15s ease;
+}
+
+.window-drop-cell.is-drag-over {
+  color: #fff;
+  background: rgb(37 99 235 / 48%);
+  border-color: #60a5fa;
+  box-shadow: inset 0 0 24px rgb(59 130 246 / 28%);
+}
+
+.window-drop-hint {
+  display: flex;
+  max-width: calc(100% - 24px);
+  padding: 12px 18px;
+  font-size: 14px;
+  font-weight: 600;
+  text-align: center;
+  pointer-events: none;
+  background: rgb(2 6 23 / 68%);
+  border: 1px solid rgb(147 197 253 / 38%);
+  border-radius: 8px;
+  box-shadow: 0 8px 24px rgb(2 6 23 / 28%);
+  align-items: center;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.window-drop-hint small {
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 400;
+  color: #cbd5e1;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .window-loading-layer {
   position: absolute;
   z-index: 2;