Преглед на файлове

增加云台控制功能

liujintao преди 1 седмица
родител
ревизия
d9c2d2591b
променени са 3 файла, в които са добавени 449 реда и са изтрити 84 реда
  1. 52 0
      src/api/ptz.ts
  2. 12 1
      src/components/myVideo.vue
  3. 385 83
      src/views/preview/index.vue

+ 52 - 0
src/api/ptz.ts

@@ -0,0 +1,52 @@
+import { request } from '@/utils/request'
+
+/**
+ * PTZ control direction enum
+ */
+export enum PTZDirection {
+  Stop = 0,
+  Up = 1,
+  Down = 2,
+  Left = 3,
+  Right = 4,
+  Center = 5
+}
+
+/**
+ * PTZ control request params
+ */
+export interface PTZControlParams {
+  PtzCmd: number
+  Speed?: number
+}
+
+/**
+ * Send PTZ control command
+ */
+export function ptzControl(data: PTZControlParams) {
+  return request<any>({
+    url: `/API/V1.0/Device/PTZ`,
+    method: 'put',
+    data
+  })
+}
+
+/**
+ * Get PTZ status
+ */
+export function getPtzStatus() {
+  return request<any>({
+    url: `/API/V1.0/Device/PTZ`,
+    method: 'get'
+  })
+}
+
+/**
+ * Get device channel info (LensNumber)
+ */
+export function getDeviceNum() {
+  return request<any>({
+    url: `/API/V1.0/Video/DeviceNum`,
+    method: 'get'
+  })
+}

+ 12 - 1
src/components/myVideo.vue

@@ -40,6 +40,10 @@ const props = defineProps({
   dragFlag: {
     type: Boolean,
     default: false
+  },
+  channel: {
+    type: String,
+    default: 'ch1'
   }
 })
 
@@ -159,6 +163,11 @@ const resetVideoElement = () => {
   // 暂停播放
   video.pause()
 
+  // 重置缩放和位移状态
+  scale.value = 1
+  position.value = { x: 0, y: 0 }
+  video.style.transform = ''
+
   // 断开 MediaSource 绑定
   video.removeAttribute('src')
   video.srcObject = null
@@ -185,7 +194,9 @@ const createWebSocket = () => {
       wsMaxPacketInterval: 8000
     })
 
-    wfsObj.attachMedia(video, 'ch1', 'H264Raw', socketURL)
+    // console.log("props.channel",props.channel)
+
+    wfsObj.attachMedia(video, props.channel, 'H264Raw', socketURL)
 
     wfsObj.on(window.Wfs.Events.ERROR, handleWfsError)
     wfsObj.on(window.Wfs.Events.MANIFEST_PARSED, () => {

+ 385 - 83
src/views/preview/index.vue

@@ -4,16 +4,117 @@
     class="video-container"
     :class="{ 'is-fullscreen': isFullscreen }"
   >
+    <!-- PTZ 球机控制面板(左侧) -->
+    <div class="ptz-wrapper">
+      <div class="ptz-panel" :class="{ 'is-collapsed': ptzCollapsed }">
+        <div class="ptz-title">PTZ Control</div>
+        <div class="ptz-grid">
+        <div class="ptz-placeholder" />
+        <button
+          class="ptz-btn"
+          title="Up"
+          @mousedown="handlePtzStart(PTZDirection.Up)"
+          @mouseup="handlePtzStop"
+        >
+          <svg viewBox="0 0 24 24" width="22" height="22">
+            <path fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/>
+          </svg>
+        </button>
+        <div class="ptz-placeholder" />
+
+        <button
+          class="ptz-btn"
+          title="Left"
+          @mousedown="handlePtzStart(PTZDirection.Left)"
+          @mouseup="handlePtzStop"
+        >
+          <svg viewBox="0 0 24 24" width="22" height="22">
+            <path fill="currentColor" d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
+          </svg>
+        </button>
+        <button
+          class="ptz-btn ptz-btn-center"
+          title="Reset"
+          @click="handlePtzCenter"
+        >
+          <svg viewBox="0 0 24 24" width="22" height="22">
+            <path fill="currentColor" d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8A5.87 5.87 0 0 1 6 12c0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z"/>
+          </svg>
+        </button>
+        <button
+          class="ptz-btn"
+          title="Right"
+          @mousedown="handlePtzStart(PTZDirection.Right)"
+          @mouseup="handlePtzStop"
+        >
+          <svg viewBox="0 0 24 24" width="22" height="22">
+            <path fill="currentColor" d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
+          </svg>
+        </button>
+
+        <div class="ptz-placeholder" />
+        <button
+          class="ptz-btn"
+          title="Down"
+          @mousedown="handlePtzStart(PTZDirection.Down)"
+          @mouseup="handlePtzStop"
+        >
+          <svg viewBox="0 0 24 24" width="22" height="22">
+            <path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/>
+          </svg>
+        </button>
+        <div class="ptz-placeholder" />
+      </div>
+
+      <!-- 速度控制 -->
+<!--      <div class="ptz-speed">-->
+<!--        <span class="ptz-speed-label">Speed</span>-->
+<!--        <el-slider-->
+<!--          v-model="ptzSpeed"-->
+<!--          :min="1"-->
+<!--          :max="8"-->
+<!--          :step="1"-->
+<!--          :show-tooltip="false"-->
+<!--          size="small"-->
+<!--        />-->
+<!--        <span class="ptz-speed-value">{{ ptzSpeed }}</span>-->
+<!--      </div>-->
+      </div>
+      <!-- 折叠/展开按钮 -->
+      <button class="ptz-toggle" :title="ptzCollapsed ? 'Expand PTZ' : 'Collapse PTZ'" @click="ptzCollapsed = !ptzCollapsed">
+        <svg viewBox="0 0 24 24" width="16" height="16">
+          <path fill="currentColor" :d="ptzCollapsed ? 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z' : 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z'"/>
+        </svg>
+      </button>
+    </div>
+
+    <!-- 视频区域(右侧) -->
     <div class="video-wrapper">
       <MyVideo
         ref="videoRef"
         :drag-flag="true"
+        :channel="currentChannel"
         @video-error="handleVideoError"
       />
-      <!-- 全屏按钮 -->
+      <!-- Channel selector -->
+      <div v-if="channelOptions.length > 1" class="channel-selector">
+        <el-select
+          v-model="currentChannel"
+          size="small"
+          placeholder="Channel"
+          @change="handleChannelChange"
+        >
+          <el-option
+            v-for="ch in channelOptions"
+            :key="ch.value"
+            :label="ch.label"
+            :value="ch.value"
+          />
+        </el-select>
+      </div>
       <button
         class="fullscreen-btn"
-        :title="isFullscreen ? '退出全屏 (F)' : '全屏 (F)'"
+        :title="isFullscreen ? 'Exit Fullscreen (F)' : 'Fullscreen (F)'"
         @click="toggleFullscreen"
       >
         <svg
@@ -38,36 +139,64 @@
 </template>
 
 <script setup lang="ts">
-import {ref, onMounted, onUnmounted} from 'vue'
+import { ref, onMounted, onUnmounted } from 'vue'
 import MyVideo from '@/components/myVideo.vue'
+import { ptzControl, PTZDirection, getDeviceNum } from '@/api/ptz'
 
 const videoRef = ref()
 const isFullscreen = ref(false)
 const containerRef = ref<HTMLElement>()
 
-// 全屏切换
+const ptzSpeed = ref(4)
+const isPtzMoving = ref(false)
+const ptzCollapsed = ref(false)
+
+// Channel
+const currentChannel = ref('ch1')
+const channelOptions = ref<{ label: string; value: string }[]>([
+  { label: 'Channel 1', value: 'ch1' }
+])
+
+// Fetch lens count and build channel options
+const fetchChannelCount = async () => {
+  try {
+    // const res = await getDeviceNum()
+    // const lensNumber = res?.data?.LensNumber ?? 1
+    const lensNumber = 2
+    const options = []
+    for (let i = 1; i <= lensNumber; i++) {
+      options.push({ label: `Channel ${i}`, value: `ch${i}` })
+    }
+    channelOptions.value = options
+  } catch (error) {
+    console.error('Failed to get device channel count:', error)
+  }
+}
+
+// Switch channel: refresh video stream
+const handleChannelChange = () => {
+  if (videoRef.value?.refreshWebSocket) {
+    videoRef.value.refreshWebSocket()
+  }
+}
+
 const toggleFullscreen = () => {
   if (!containerRef.value) return
-
   if (!isFullscreen.value) {
-    // 进入全屏
     if (containerRef.value.requestFullscreen) {
       containerRef.value.requestFullscreen()
     }
   } else {
-    // 退出全屏
     if (document.exitFullscreen) {
       document.exitFullscreen()
     }
   }
 }
 
-// 监听全屏状态变化
 const handleFullscreenChange = () => {
   isFullscreen.value = !!document.fullscreenElement
 }
 
-// 键盘快捷键
 const handleKeydown = (event: KeyboardEvent) => {
   switch (event.code) {
     case 'KeyF':
@@ -83,15 +212,42 @@ const handleKeydown = (event: KeyboardEvent) => {
   }
 }
 
-// 处理视频错误
+const handlePtzStart = async (direction: number) => {
+  if (isPtzMoving.value) return
+  isPtzMoving.value = true
+  try {
+    await ptzControl({ PtzCmd: direction })
+  } catch (error) {
+    console.error('PTZ control failed:', error)
+  }
+}
+
+const handlePtzStop = async () => {
+  if (!isPtzMoving.value) return
+  isPtzMoving.value = false
+  try {
+    await ptzControl({ PtzCmd: PTZDirection.Stop })
+  } catch (error) {
+    console.error('PTZ stop failed:', error)
+  }
+}
+
+const handlePtzCenter = async () => {
+  try {
+    await ptzControl({ PtzCmd: PTZDirection.Center })
+  } catch (error) {
+    console.error('PTZ Center failed:', error)
+  }
+}
+
 const handleVideoError = (error: any) => {
-  console.error('视频播放错误:', error)
-  // 这里可以添加用户友好的错误提示
+  console.error('Video playback error:', error)
 }
 
 onMounted(() => {
   document.addEventListener('fullscreenchange', handleFullscreenChange)
   document.addEventListener('keydown', handleKeydown)
+  fetchChannelCount()
 })
 
 onUnmounted(() => {
@@ -108,10 +264,11 @@ onUnmounted(() => {
   margin-top: 20px;
   justify-content: center;
   align-items: center;
+  gap: 16px;
+  padding: 0 16px;
   position: relative;
   overflow: hidden;
 
-  // 全屏状态
   &.is-fullscreen {
     position: fixed;
     top: 0;
@@ -119,18 +276,38 @@ onUnmounted(() => {
     z-index: 9999;
     width: 100vw;
     height: 100vh;
+    margin-top: 0;
+    padding: 0;
   }
 }
 
 .video-wrapper {
   position: relative;
-  width: 100%;
-  height: 100%;
-  max-width: min(100vw, calc((100vh - 90px) * 16 / 9));
-  max-height: min(100vh - 90px, calc(100vw / 16 * 9));
-  aspect-ratio: 16/9;
+  /*
+   * 用固定的 calc 尺寸而非 flex:1,
+   * 这样 video.load() 清空内容时容器不会收缩。
+   * 宽度 = 总宽 - PTZ面板(180px) - gap(16px) - 两侧padding(32px) - 留白(20px)
+   * 高度按 16:9 从宽度推算,同时不超过容器高度
+   */
+  width: calc(100vw - 208px);
+  height: calc((100vw - 208px) / 16 * 9);
+  max-height: calc(100vh - 110px);
+  max-width: calc((100vh - 110px) / 9 * 16);
+  flex-shrink: 0;
+
+  :deep(.preview-container) {
+    width: 100%;
+    height: 100%;
+  }
+
+  // 全屏时视频撑满整个屏幕
+  .video-container.is-fullscreen & {
+    width: 100vw;
+    height: 100vh;
+    max-width: none;
+    max-height: none;
+  }
 
-  // 确保视频组件填满容器
   :deep(.preview-container) {
     width: 100%;
     height: 100%;
@@ -142,38 +319,68 @@ onUnmounted(() => {
     background: #000;
     border-radius: 5px;
     overflow: hidden;
-    //box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
 
-    // 全屏时移除圆角和阴影
     .video-container.is-fullscreen & {
       border-radius: 0;
-      box-shadow: none;
     }
   }
 
   :deep(#player) {
     width: 100%;
     height: 100%;
-    //object-fit: cover;
     object-fit: contain;
   }
 
-  // 优化控制条样式
   :deep(.video-control) {
     opacity: 0;
     transition: opacity 0.3s ease;
-
-    &:hover {
-      opacity: 1;
-    }
+    &:hover { opacity: 1; }
   }
 
-  // 鼠标悬停时显示控制条
   &:hover :deep(.video-control) {
     opacity: 1;
   }
 }
 
+/* Channel selector */
+.channel-selector {
+  position: absolute;
+  bottom: 48px;
+  right: 16px;
+  z-index: 10;
+  opacity: 0;
+  transition: opacity 0.3s ease;
+
+  .video-wrapper:hover & {
+    opacity: 1;
+  }
+
+  :deep(.el-select) {
+    width: 120px;
+  }
+
+  :deep(.el-input__wrapper) {
+    background: rgba(0, 0, 0, 0.6);
+    border: none;
+    box-shadow: none;
+    backdrop-filter: blur(8px);
+    border-radius: 6px;
+  }
+
+  :deep(.el-input__inner) {
+    color: #fff;
+    font-size: 12px;
+
+    &::placeholder {
+      color: rgba(255, 255, 255, 0.6);
+    }
+  }
+
+  :deep(.el-select__caret) {
+    color: #fff;
+  }
+}
+
 .fullscreen-btn {
   position: absolute;
   top: 16px;
@@ -197,17 +404,9 @@ onUnmounted(() => {
     background: rgba(0, 0, 0, 0.8);
     transform: scale(1.05);
   }
+  &:active { transform: scale(0.95); }
+  .video-wrapper:hover & { opacity: 1; }
 
-  &:active {
-    transform: scale(0.95);
-  }
-
-  // 鼠标悬停在容器上时显示
-  .video-wrapper:hover & {
-    opacity: 1;
-  }
-
-  // 全屏状态下的样式调整
   .video-container.is-fullscreen & {
     top: 20px;
     right: 20px;
@@ -216,66 +415,169 @@ onUnmounted(() => {
   }
 }
 
-// 响应式设计
-@media (max-width: 768px) {
-  .video-container {
-    height: 100vh;
+/* PTZ 云台控制面板 */
+.ptz-wrapper {
+  position: relative;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+}
+
+.ptz-panel {
+  width: 140px;
+  background: #f5f7fa;
+  border-radius: 10px;
+  padding: 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 10px;
+  overflow: hidden;
+  transition: width 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
+
+  &.is-collapsed {
+    width: 0;
     padding: 0;
+    opacity: 0;
+    pointer-events: none;
   }
 
-  .video-wrapper {
-    max-width: 100vw;
-    max-height: 100vh;
-
-    :deep(.video_container) {
-      border-radius: 0;
+  .video-container.is-fullscreen & {
+    background: rgba(0, 0, 0, 0.7);
+    backdrop-filter: blur(12px);
+
+    .ptz-title { color: #fff; }
+    .ptz-btn {
+      background: rgba(255, 255, 255, 0.1);
+      color: #fff;
+      border-color: rgba(255, 255, 255, 0.15);
+      &:hover { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.3); }
+      &:active { background: rgba(255, 255, 255, 0.3); }
+    }
+    .ptz-btn-center {
+      background: rgba(64, 158, 255, 0.3) !important;
+      border-color: rgba(64, 158, 255, 0.5) !important;
+      &:hover { background: rgba(64, 158, 255, 0.5) !important; }
     }
+    .ptz-speed-label, .ptz-speed-value { color: #fff; }
   }
+}
 
-  .fullscreen-btn {
-    width: 36px;
-    height: 36px;
-    top: 12px;
-    right: 12px;
+.ptz-toggle {
+  width: 24px;
+  height: 48px;
+  border: 1px solid #e4e7ed;
+  border-radius: 0 8px 8px 0;
+  background: #f5f7fa;
+  color: #909399;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  transition: all 0.2s ease;
+  padding: 0;
+  margin-left: -1px;
 
-    svg {
-      width: 14px;
-      height: 14px;
-    }
+  &:hover {
+    background: #ecf5ff;
+    color: #409eff;
+    border-color: #b3d8ff;
   }
-}
 
-@media (max-width: 480px) {
-  .video-wrapper {
-    :deep(.video-control) {
-      height: 48px;
+  .video-container.is-fullscreen & {
+    background: rgba(0, 0, 0, 0.7);
+    border-color: rgba(255, 255, 255, 0.15);
+    color: #fff;
+    backdrop-filter: blur(12px);
 
-      .control-btn {
-        width: 40px;
-        height: 40px;
-        font-size: 20px;
-      }
+    &:hover {
+      background: rgba(0, 0, 0, 0.85);
+      border-color: rgba(255, 255, 255, 0.3);
     }
   }
 }
 
-// 减少动画以提升性能
-@media (prefers-reduced-motion: reduce) {
-  * {
-    transition: none !important;
-    animation: none !important;
-  }
+.video-container.is-fullscreen .ptz-wrapper {
+  position: absolute;
+  left: 20px;
+  top: 50%;
+  transform: translateY(-50%);
+  z-index: 10;
 }
 
-// 高对比度模式支持
-@media (prefers-contrast: custom) {
-  .video-wrapper :deep(.video_container) {
-    border: 2px solid white;
-  }
+.ptz-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  white-space: nowrap;
+}
 
-  .fullscreen-btn {
-    border: 1px solid white;
-    background: black;
+.ptz-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 4px;
+  width: 100%;
+}
+
+.ptz-placeholder { aspect-ratio: 1; }
+
+.ptz-btn {
+  width: 100%;
+  aspect-ratio: 1;
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  background: #fff;
+  color: #606266;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.2s ease;
+  padding: 0;
+
+  &:hover { background: #ecf5ff; border-color: #b3d8ff; color: #409eff; }
+  &:active { background: #d9ecff; transform: scale(0.95); }
+}
+
+.ptz-btn-center {
+  background: #ecf5ff !important;
+  border-color: #b3d8ff !important;
+  color: #409eff !important;
+  &:hover { background: #d9ecff !important; }
+}
+
+.ptz-speed {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  .ptz-speed-label { font-size: 12px; color: #909399; white-space: nowrap; }
+  .ptz-speed-value { font-size: 12px; color: #606266; font-weight: 600; min-width: 14px; text-align: center; }
+  :deep(.el-slider) { flex: 1; }
+}
+
+@media (max-width: 768px) {
+  .video-container {
+    flex-direction: column;
+    height: auto;
+    padding: 0;
+    gap: 12px;
+  }
+  .ptz-panel { width: 100%; max-width: 200px; order: 2; }
+  .video-wrapper {
+    order: 1;
+    width: 100vw;
+    height: calc(100vw / 16 * 9);
+    max-height: none;
+    max-width: none;
+    :deep(.video_container) { border-radius: 0; }
   }
+  .fullscreen-btn { width: 36px; height: 36px; top: 12px; right: 12px; svg { width: 14px; height: 14px; } }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  * { transition: none !important; animation: none !important; }
 }
-</style>
+</style>