瀏覽代碼

feat: implement PTZ camera control feature with UI integration

- Add PTZController component for controlling camera movements
- Create PTZ API for sending commands to the camera
- Integrate PTZ controls into the WebRTC streaming page for seamless operation
- Update Vite configuration to include camera proxy settings for development
- Enhance styling for PTZ controls and improve layout in the streaming interface
yb 2 周之前
父節點
當前提交
905f23341e
共有 6 個文件被更改,包括 578 次插入34 次删除
  1. 95 0
      src/api/ptz.ts
  2. 2 3
      src/assets/styles/index.scss
  3. 2 0
      src/components.d.ts
  4. 190 0
      src/components/PTZController.vue
  5. 281 30
      src/views/demo/webrtc-stream.vue
  6. 8 1
      vite.config.ts

+ 95 - 0
src/api/ptz.ts

@@ -0,0 +1,95 @@
+/**
+ * PTZ 云台控制 API
+ * 支持海康威视 ISAPI 协议
+ */
+
+export interface PTZConfig {
+  host: string // 摄像头 IP 地址
+  username: string // 用户名
+  password: string // 密码
+  channel?: number // 通道号,默认 1
+}
+
+export interface PTZDirection {
+  pan: number // -100 ~ 100, 负左正右
+  tilt: number // -100 ~ 100, 负下正上
+}
+
+// 方向预设值
+export const PTZ_DIRECTIONS = {
+  UP: { pan: 0, tilt: 50 },
+  DOWN: { pan: 0, tilt: -50 },
+  LEFT: { pan: -50, tilt: 0 },
+  RIGHT: { pan: 50, tilt: 0 },
+  UP_LEFT: { pan: -50, tilt: 50 },
+  UP_RIGHT: { pan: 50, tilt: 50 },
+  DOWN_LEFT: { pan: -50, tilt: -50 },
+  DOWN_RIGHT: { pan: 50, tilt: -50 },
+  STOP: { pan: 0, tilt: 0 }
+} as const
+
+/**
+ * 生成 Basic Auth Header
+ */
+function createBasicAuth(username: string, password: string): string {
+  const credentials = btoa(`${username}:${password}`)
+  return `Basic ${credentials}`
+}
+
+/**
+ * 生成 PTZ 控制 XML
+ */
+function createPTZXml(direction: PTZDirection): string {
+  return `<?xml version="1.0" encoding="UTF-8"?>
+<PTZData>
+  <pan>${direction.pan}</pan>
+  <tilt>${direction.tilt}</tilt>
+</PTZData>`
+}
+
+/**
+ * 发送 PTZ 控制命令
+ * 通过 Vite 代理转发请求,避免 CORS 问题
+ */
+export async function sendPTZCommand(
+  config: PTZConfig,
+  direction: PTZDirection
+): Promise<{ success: boolean; error?: string }> {
+  const channel = config.channel || 1
+  // 开发环境: 使用 /camera-proxy 代理 (vite.config.ts 配置固定 IP)
+  // 生产环境: 需要通过后端 API 代理
+  const url = `/camera-proxy/ISAPI/PTZCtrl/channels/${channel}/continuous`
+
+  try {
+    const response = await fetch(url, {
+      method: 'PUT',
+      headers: {
+        'Content-Type': 'application/xml',
+        Authorization: createBasicAuth(config.username, config.password)
+      },
+      body: createPTZXml(direction)
+    })
+
+    if (response.ok) {
+      return { success: true }
+    } else {
+      return { success: false, error: `HTTP ${response.status}` }
+    }
+  } catch (error) {
+    return { success: false, error: String(error) }
+  }
+}
+
+/**
+ * 开始 PTZ 移动
+ */
+export function startPTZ(config: PTZConfig, direction: keyof typeof PTZ_DIRECTIONS) {
+  return sendPTZCommand(config, PTZ_DIRECTIONS[direction])
+}
+
+/**
+ * 停止 PTZ 移动
+ */
+export function stopPTZ(config: PTZConfig) {
+  return sendPTZCommand(config, PTZ_DIRECTIONS.STOP)
+}

+ 2 - 3
src/assets/styles/index.scss

@@ -168,7 +168,7 @@ ol {
 
 // 页面容器
 .page-container {
-  padding: var(--content-padding);
+  // padding: var(--content-padding);
   background-color: var(--bg-container);
   border-radius: var(--radius-lg);
   box-shadow: var(--shadow-sm);
@@ -208,7 +208,6 @@ ol {
 // 主题切换过渡
 .theme-transition {
   transition: background-color var(--transition-base) var(--transition-timing),
-    border-color var(--transition-base) var(--transition-timing),
-    color var(--transition-base) var(--transition-timing),
+    border-color var(--transition-base) var(--transition-timing), color var(--transition-base) var(--transition-timing),
     box-shadow var(--transition-base) var(--transition-timing);
 }

+ 2 - 0
src/components.d.ts

@@ -49,6 +49,7 @@ declare module 'vue' {
     ElResult: typeof import('element-plus/es')['ElResult']
     ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
+    ElSlider: typeof import('element-plus/es')['ElSlider']
     ElSpace: typeof import('element-plus/es')['ElSpace']
     ElStatistic: typeof import('element-plus/es')['ElStatistic']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
@@ -63,6 +64,7 @@ declare module 'vue' {
     ElUpload: typeof import('element-plus/es')['ElUpload']
     HelloWorld: typeof import('./components/HelloWorld.vue')['default']
     LangDropdown: typeof import('./components/LangDropdown.vue')['default']
+    PTZController: typeof import('./components/PTZController.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     ThemeSettings: typeof import('./components/ThemeSettings.vue')['default']

+ 190 - 0
src/components/PTZController.vue

@@ -0,0 +1,190 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { startPTZ, stopPTZ, type PTZConfig } from '@/api/ptz'
+
+const props = withDefaults(
+  defineProps<{
+    /** 摄像头配置 */
+    config?: PTZConfig
+  }>(),
+  {
+    config: () => ({
+      host: '192.168.0.64',
+      username: 'admin',
+      password: 'admin123',
+      channel: 1
+    })
+  }
+)
+
+const isMoving = ref(false)
+const currentDirection = ref<string | null>(null)
+
+// 开始移动
+async function handleStart(direction: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' | 'UP_LEFT' | 'UP_RIGHT' | 'DOWN_LEFT' | 'DOWN_RIGHT') {
+  if (isMoving.value) return
+  isMoving.value = true
+  currentDirection.value = direction
+
+  const result = await startPTZ(props.config, direction)
+  if (!result.success) {
+    console.error('PTZ 控制失败:', result.error)
+  }
+}
+
+// 停止移动
+async function handleStop() {
+  if (!isMoving.value) return
+
+  const result = await stopPTZ(props.config)
+  if (!result.success) {
+    console.error('PTZ 停止失败:', result.error)
+  }
+
+  isMoving.value = false
+  currentDirection.value = null
+}
+</script>
+
+<template>
+  <div class="ptz-controller">
+    <div class="ptz-grid">
+      <!-- 第一行 -->
+      <button
+        class="ptz-btn corner"
+        @mousedown="handleStart('UP_LEFT')"
+        @mouseup="handleStop"
+        @mouseleave="handleStop"
+      >
+        <el-icon><i-ep-top-left /></el-icon>
+      </button>
+      <button
+        class="ptz-btn"
+        @mousedown="handleStart('UP')"
+        @mouseup="handleStop"
+        @mouseleave="handleStop"
+      >
+        <el-icon><i-ep-arrow-up /></el-icon>
+      </button>
+      <button
+        class="ptz-btn corner"
+        @mousedown="handleStart('UP_RIGHT')"
+        @mouseup="handleStop"
+        @mouseleave="handleStop"
+      >
+        <el-icon><i-ep-top-right /></el-icon>
+      </button>
+
+      <!-- 第二行 -->
+      <button
+        class="ptz-btn"
+        @mousedown="handleStart('LEFT')"
+        @mouseup="handleStop"
+        @mouseleave="handleStop"
+      >
+        <el-icon><i-ep-arrow-left /></el-icon>
+      </button>
+      <div class="ptz-center">
+        <el-icon v-if="isMoving" class="spinning"><i-ep-loading /></el-icon>
+        <span v-else>PTZ</span>
+      </div>
+      <button
+        class="ptz-btn"
+        @mousedown="handleStart('RIGHT')"
+        @mouseup="handleStop"
+        @mouseleave="handleStop"
+      >
+        <el-icon><i-ep-arrow-right /></el-icon>
+      </button>
+
+      <!-- 第三行 -->
+      <button
+        class="ptz-btn corner"
+        @mousedown="handleStart('DOWN_LEFT')"
+        @mouseup="handleStop"
+        @mouseleave="handleStop"
+      >
+        <el-icon><i-ep-bottom-left /></el-icon>
+      </button>
+      <button
+        class="ptz-btn"
+        @mousedown="handleStart('DOWN')"
+        @mouseup="handleStop"
+        @mouseleave="handleStop"
+      >
+        <el-icon><i-ep-arrow-down /></el-icon>
+      </button>
+      <button
+        class="ptz-btn corner"
+        @mousedown="handleStart('DOWN_RIGHT')"
+        @mouseup="handleStop"
+        @mouseleave="handleStop"
+      >
+        <el-icon><i-ep-bottom-right /></el-icon>
+      </button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.ptz-controller {
+  display: inline-block;
+}
+
+.ptz-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 48px);
+  gap: 4px;
+}
+
+.ptz-btn {
+  width: 48px;
+  height: 48px;
+  border: 1px solid var(--el-border-color);
+  border-radius: 8px;
+  background: var(--el-bg-color);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 20px;
+  color: var(--el-text-color-primary);
+  transition: all 0.2s;
+
+  &:hover {
+    background: var(--el-color-primary-light-9);
+    border-color: var(--el-color-primary);
+  }
+
+  &:active {
+    background: var(--el-color-primary-light-7);
+  }
+
+  &.corner {
+    font-size: 16px;
+  }
+}
+
+.ptz-center {
+  width: 48px;
+  height: 48px;
+  border-radius: 50%;
+  background: var(--el-color-primary-light-9);
+  border: 2px solid var(--el-color-primary);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  font-weight: bold;
+  color: var(--el-color-primary);
+}
+
+.spinning {
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+</style>

+ 281 - 30
src/views/demo/webrtc-stream.vue

@@ -9,11 +9,7 @@
     <div class="config-section">
       <el-form label-width="120px">
         <el-form-item label="go2rtc 地址">
-          <el-input
-            v-model="config.go2rtcUrl"
-            placeholder="go2rtc 服务地址"
-            style="width: 400px"
-          >
+          <el-input v-model="config.go2rtcUrl" placeholder="go2rtc 服务地址" style="width: 400px">
             <template #prepend>http://</template>
           </el-input>
           <el-text type="info" style="margin-left: 10px">默认端口 1984</el-text>
@@ -36,25 +32,127 @@
       </el-form>
     </div>
 
-    <!-- 播放器区域 -->
-    <div class="player-section">
-      <div v-if="!isPlaying" class="player-placeholder">
-        <el-icon :size="60" color="#ddd"><VideoPlay /></el-icon>
-        <p>请配置 go2rtc 地址和流名称后点击播放</p>
+    <!-- 播放器和PTZ控制区域 -->
+    <div class="player-ptz-container">
+      <!-- 播放器区域 -->
+      <div class="player-section">
+        <div v-if="!isPlaying" class="player-placeholder">
+          <el-icon :size="60" color="#ddd"><VideoPlay /></el-icon>
+          <p>请配置 go2rtc 地址和流名称后点击播放</p>
+        </div>
+        <VideoPlayer
+          v-else
+          ref="playerRef"
+          player-type="webrtc"
+          :go2rtc-url="fullGo2rtcUrl"
+          :stream-name="config.streamName"
+          :autoplay="playConfig.autoplay"
+          :muted="playConfig.muted"
+          :controls="true"
+          @play="onPlay"
+          @pause="onPause"
+          @error="onError"
+        />
+      </div>
+
+      <!-- PTZ 云台控制 -->
+      <div class="ptz-panel">
+        <div class="ptz-header">
+          <span>PTZ 云台控制</span>
+        </div>
+
+        <!-- PTZ 配置 -->
+        <div class="ptz-config">
+          <el-form label-width="70px" size="small">
+            <el-form-item label="摄像头IP">
+              <el-input v-model="ptzConfig.host" placeholder="192.168.0.64" />
+            </el-form-item>
+            <el-form-item label="用户名">
+              <el-input v-model="ptzConfig.username" placeholder="admin" />
+            </el-form-item>
+            <el-form-item label="密码">
+              <el-input v-model="ptzConfig.password" type="password" show-password placeholder="密码" />
+            </el-form-item>
+          </el-form>
+        </div>
+
+        <!-- PTZ 方向控制 九宫格 -->
+        <div class="ptz-controls">
+          <div
+            class="ptz-btn"
+            @mousedown="handlePTZ('UP_LEFT')"
+            @mouseup="handlePTZStop"
+            @mouseleave="handlePTZStop"
+          >
+            <el-icon><TopLeft /></el-icon>
+          </div>
+          <div
+            class="ptz-btn"
+            @mousedown="handlePTZ('UP')"
+            @mouseup="handlePTZStop"
+            @mouseleave="handlePTZStop"
+          >
+            <el-icon><Top /></el-icon>
+          </div>
+          <div
+            class="ptz-btn"
+            @mousedown="handlePTZ('UP_RIGHT')"
+            @mouseup="handlePTZStop"
+            @mouseleave="handlePTZStop"
+          >
+            <el-icon><TopRight /></el-icon>
+          </div>
+          <div
+            class="ptz-btn"
+            @mousedown="handlePTZ('LEFT')"
+            @mouseup="handlePTZStop"
+            @mouseleave="handlePTZStop"
+          >
+            <el-icon><Back /></el-icon>
+          </div>
+          <div class="ptz-btn ptz-center" @click="handlePTZStop">
+            <el-icon><Refresh /></el-icon>
+          </div>
+          <div
+            class="ptz-btn"
+            @mousedown="handlePTZ('RIGHT')"
+            @mouseup="handlePTZStop"
+            @mouseleave="handlePTZStop"
+          >
+            <el-icon><Right /></el-icon>
+          </div>
+          <div
+            class="ptz-btn"
+            @mousedown="handlePTZ('DOWN_LEFT')"
+            @mouseup="handlePTZStop"
+            @mouseleave="handlePTZStop"
+          >
+            <el-icon><BottomLeft /></el-icon>
+          </div>
+          <div
+            class="ptz-btn"
+            @mousedown="handlePTZ('DOWN')"
+            @mouseup="handlePTZStop"
+            @mouseleave="handlePTZStop"
+          >
+            <el-icon><Bottom /></el-icon>
+          </div>
+          <div
+            class="ptz-btn"
+            @mousedown="handlePTZ('DOWN_RIGHT')"
+            @mouseup="handlePTZStop"
+            @mouseleave="handlePTZStop"
+          >
+            <el-icon><BottomRight /></el-icon>
+          </div>
+        </div>
+
+        <!-- 速度控制 -->
+        <div class="ptz-speed">
+          <span>速度</span>
+          <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" />
+        </div>
       </div>
-      <VideoPlayer
-        v-else
-        ref="playerRef"
-        player-type="webrtc"
-        :go2rtc-url="fullGo2rtcUrl"
-        :stream-name="config.streamName"
-        :autoplay="playConfig.autoplay"
-        :muted="playConfig.muted"
-        :controls="true"
-        @play="onPlay"
-        @pause="onPause"
-        @error="onError"
-      />
     </div>
 
     <!-- 播放控制 -->
@@ -89,21 +187,33 @@
         <el-collapse-item title="go2rtc 配置说明" name="1">
           <div class="info-content">
             <h4>1. 下载 go2rtc</h4>
-            <p>访问 <el-link type="primary" href="https://github.com/AlexxIT/go2rtc/releases" target="_blank">GitHub Releases</el-link> 下载对应系统版本</p>
+            <p>
+              访问
+              <el-link type="primary" href="https://github.com/AlexxIT/go2rtc/releases" target="_blank">
+                GitHub Releases
+              </el-link>
+              下载对应系统版本
+            </p>
 
             <h4>2. 创建配置文件 go2rtc.yaml</h4>
-            <pre class="code-block">streams:
+            <pre class="code-block">
+streams:
   camera1: rtsp://admin:password@192.168.0.64:554/Streaming/Channels/101
 
 webrtc:
   candidates:
-    - stun:stun.l.google.com:19302</pre>
+    - stun:stun.l.google.com:19302</pre
+            >
 
             <h4>3. 启动服务</h4>
             <pre class="code-block">./go2rtc -config go2rtc.yaml</pre>
 
             <h4>4. 验证</h4>
-            <p>访问 <el-link type="primary" href="http://localhost:1984" target="_blank">http://localhost:1984</el-link> 查看管理界面</p>
+            <p>
+              访问
+              <el-link type="primary" href="http://localhost:1984" target="_blank">http://localhost:1984</el-link>
+              查看管理界面
+            </p>
           </div>
         </el-collapse-item>
       </el-collapse>
@@ -129,8 +239,20 @@ webrtc:
 <script setup lang="ts">
 import { ref, reactive, computed } from 'vue'
 import { ElMessage } from 'element-plus'
-import { VideoPlay } from '@element-plus/icons-vue'
+import {
+  VideoPlay,
+  Top,
+  Bottom,
+  Back,
+  Right,
+  TopLeft,
+  TopRight,
+  BottomLeft,
+  BottomRight,
+  Refresh
+} from '@element-plus/icons-vue'
 import VideoPlayer from '@/components/VideoPlayer.vue'
+import { startPTZ, stopPTZ, PTZ_DIRECTIONS } from '@/api/ptz'
 
 const playerRef = ref<InstanceType<typeof VideoPlayer>>()
 
@@ -146,6 +268,14 @@ const playConfig = reactive({
   muted: true
 })
 
+// PTZ 配置
+const ptzConfig = reactive({
+  host: '192.168.0.64',
+  username: 'admin',
+  password: 'Wxc767718929'
+})
+const ptzSpeed = ref(50)
+
 // 播放状态
 const isPlaying = ref(false)
 
@@ -270,6 +400,43 @@ function onPause() {
 function onError(error: any) {
   addLog(`播放错误: ${error?.message || JSON.stringify(error)}`, 'error')
 }
+
+// PTZ 控制
+async function handlePTZ(direction: keyof typeof PTZ_DIRECTIONS) {
+  if (!ptzConfig.host || !ptzConfig.username || !ptzConfig.password) {
+    ElMessage.warning('请先配置摄像头信息')
+    return
+  }
+
+  const result = await startPTZ(
+    {
+      host: ptzConfig.host,
+      username: ptzConfig.username,
+      password: ptzConfig.password
+    },
+    direction
+  )
+
+  if (result.success) {
+    addLog(`PTZ 移动: ${direction}`, 'info')
+  } else {
+    addLog(`PTZ 控制失败: ${result.error}`, 'error')
+  }
+}
+
+async function handlePTZStop() {
+  if (!ptzConfig.host) return
+
+  const result = await stopPTZ({
+    host: ptzConfig.host,
+    username: ptzConfig.username,
+    password: ptzConfig.password
+  })
+
+  if (!result.success) {
+    addLog(`PTZ 停止失败: ${result.error}`, 'error')
+  }
+}
 </script>
 
 <style lang="scss" scoped>
@@ -295,14 +462,19 @@ function onError(error: any) {
 
 .config-section {
   margin-bottom: 20px;
-  padding: 20px;
   background-color: var(--bg-container);
   border-radius: var(--radius-base);
 }
 
+.player-ptz-container {
+  display: flex;
+  gap: 20px;
+  margin-bottom: 20px;
+}
+
 .player-section {
+  flex: 1;
   height: 500px;
-  margin-bottom: 20px;
   border-radius: var(--radius-base);
   overflow: hidden;
   background-color: #000;
@@ -322,6 +494,85 @@ function onError(error: any) {
   }
 }
 
+.ptz-panel {
+  width: 280px;
+  background-color: var(--bg-container);
+  border-radius: var(--radius-base);
+  padding: 15px;
+
+  .ptz-header {
+    font-size: 14px;
+    font-weight: 600;
+    color: var(--text-primary);
+    margin-bottom: 15px;
+    padding-bottom: 10px;
+    border-bottom: 1px solid var(--border-color);
+  }
+
+  .ptz-config {
+    margin-bottom: 15px;
+  }
+
+  .ptz-controls {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 8px;
+    margin-bottom: 15px;
+  }
+
+  .ptz-btn {
+    aspect-ratio: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background-color: var(--bg-hover);
+    border: 1px solid var(--border-color);
+    border-radius: var(--radius-sm);
+    cursor: pointer;
+    transition: all 0.2s;
+    color: var(--text-regular);
+
+    &:hover {
+      background-color: var(--color-primary-light-9);
+      border-color: var(--color-primary);
+      color: var(--color-primary);
+    }
+
+    &:active {
+      background-color: var(--color-primary);
+      color: #fff;
+    }
+
+    .el-icon {
+      font-size: 20px;
+    }
+  }
+
+  .ptz-center {
+    background-color: var(--bg-page);
+
+    &:hover {
+      background-color: var(--color-primary-light-9);
+    }
+  }
+
+  .ptz-speed {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+
+    span {
+      font-size: 12px;
+      color: var(--text-secondary);
+      white-space: nowrap;
+    }
+
+    .el-slider {
+      flex: 1;
+    }
+  }
+}
+
 .control-section {
   padding: 15px 20px;
   background-color: var(--bg-container);

+ 8 - 1
vite.config.ts

@@ -65,7 +65,14 @@ export default defineConfig({
       '/api': {
         target: 'https://tg-live-game.pwtk.cc',
         changeOrigin: true,
-        secure: false  // 禁用 SSL 证书验证(开发环境)
+        secure: false // 禁用 SSL 证书验证(开发环境)
+      },
+      // 摄像头 PTZ 代理 - 固定 IP(开发环境)
+      '/camera-proxy': {
+        target: 'http://192.168.0.64',
+        changeOrigin: true,
+        secure: false,
+        rewrite: (path) => path.replace(/^\/camera-proxy/, '')
       }
     }
   },