Bläddra i källkod

refactor(ptz): remove deprecated PTZ API and update camera control integration

- Deleted the obsolete PTZ API file and removed references to it in the configuration.
- Updated PTZ control components to utilize the new camera API for PTZ actions.
- Enhanced PTZ control logic in various components to ensure compatibility with the new camera structure.
- Improved error handling and logging for PTZ operations across the application.
yb 2 dagar sedan
förälder
incheckning
9a37df5a21

+ 5 - 0
src/api/camera.ts

@@ -60,6 +60,11 @@ export function presetList(cameraId: string): Promise<BaseResponse> {
   return get(`/camera/control/${cameraId}/preset/list`)
 }
 
+// 获取PTZ能力 (PTZ后端)
+export function getPTZCapabilities(cameraId: string): Promise<BaseResponse> {
+  return get(`/camera/control/${cameraId}/ptz/capabilities`)
+}
+
 // 跳转到预置位 (PTZ后端)
 export function presetGoto(cameraId: string, presetId: number): Promise<BaseResponse> {
   return post(`/camera/control/${cameraId}/preset/goto`, { presetId })

+ 0 - 393
src/api/ptz.ts

@@ -1,393 +0,0 @@
-/**
- * PTZ 云台控制 API
- * 通过独立的 PTZ 后端服务调用 ONVIF 标准协议
- * 支持厂家: HIKVISION (海康威视), ANPVIZ
- *
- * 注意: 用户名和密码存储在后端服务器,前端不传递凭据
- */
-
-// ==================== 类型定义 ====================
-
-// 支持的摄像头厂家
-export type CameraVendor = 'HIKVISION' | 'ANPVIZ' | 'DAHUA' | 'CT-IP500' | 'SVBC'
-
-export interface PTZConfig {
-  host: string
-  channel?: number
-  cameraId?: number
-  vendor?: CameraVendor
-}
-
-export interface PTZCommand {
-  pan: number
-  tilt: number
-  zoom: number
-}
-
-export interface PTZResult {
-  success: boolean
-  error?: string
-  data?: unknown
-}
-
-// ==================== 常量 ====================
-
-const PTZ_API_BASE = '/camera/control'
-const DEFAULT_SPEED = 50
-
-// 方向预设值 (单位向量)
-export const PTZ_DIRECTIONS = {
-  UP: { pan: 0, tilt: 1 },
-  DOWN: { pan: 0, tilt: -1 },
-  LEFT: { pan: -1, tilt: 0 },
-  RIGHT: { pan: 1, tilt: 0 },
-  UP_LEFT: { pan: -1, tilt: 1 },
-  UP_RIGHT: { pan: 1, tilt: 1 },
-  DOWN_LEFT: { pan: -1, tilt: -1 },
-  DOWN_RIGHT: { pan: 1, tilt: -1 },
-  STOP: { pan: 0, tilt: 0 }
-} as const
-
-// 缩放预设值 (单位向量)
-export const PTZ_ZOOM_DIRECTIONS = {
-  IN: { zoom: 1 },
-  OUT: { zoom: -1 },
-  STOP: { zoom: 0 }
-} as const
-
-export type PTZDirectionKey = keyof typeof PTZ_DIRECTIONS
-export type PTZZoomKey = keyof typeof PTZ_ZOOM_DIRECTIONS
-
-// PTZ 方向命令
-export type PTZCommandType =
-  | 'up'
-  | 'down'
-  | 'left'
-  | 'right'
-  | 'up_left'
-  | 'up_right'
-  | 'down_left'
-  | 'down_right'
-  | 'stop'
-
-// ==================== 核心 API ====================
-
-/**
- * 发送 PTZ 控制命令 (ONVIF 统一接口)
- * 支持: HIKVISION, ANPVIZ
- */
-async function sendPTZCommand(
-  config: PTZConfig,
-  command: PTZCommandType,
-  speed: number = DEFAULT_SPEED
-): Promise<PTZResult> {
-  try {
-    const response = await fetch(`${PTZ_API_BASE}/ptz/control`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        host: config.host,
-        channel: config.channel || 1,
-        cameraId: config.cameraId,
-        vendor: config.vendor || 'HIKVISION',
-        command,
-        speed
-      })
-    })
-
-    const data = await response.json()
-    return data.code === 200
-      ? { success: true, data: data.data }
-      : { success: false, error: data.msg || 'Unknown error' }
-  } catch (error) {
-    return { success: false, error: String(error) }
-  }
-}
-
-/**
- * 发送 PTZ 控制命令 (旧接口 - 使用 pan/tilt/zoom)
- * 注意: 缩放功能可能仅部分摄像头支持
- */
-async function sendCommand(config: PTZConfig, command: PTZCommand): Promise<PTZResult> {
-  try {
-    const response = await fetch(`${PTZ_API_BASE}/ptz/control`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        host: config.host,
-        channel: config.channel || 1,
-        cameraId: config.cameraId,
-        vendor: config.vendor || 'HIKVISION',
-        pan: command.pan,
-        tilt: command.tilt,
-        zoom: command.zoom
-      })
-    })
-
-    const data = await response.json()
-    return data.code === 200
-      ? { success: true, data: data.data }
-      : { success: false, error: data.msg || 'Unknown error' }
-  } catch (error) {
-    return { success: false, error: String(error) }
-  }
-}
-
-/**
- * 获取 PTZ 状态
- */
-export async function getPTZStatus(config: PTZConfig): Promise<PTZResult & { data?: string }> {
-  try {
-    const response = await fetch(`${PTZ_API_BASE}/ptz/status`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        host: config.host,
-        channel: config.channel || 1
-      })
-    })
-
-    const data = await response.json()
-    return data.code === 200
-      ? { success: true, data: data.data?.raw }
-      : { success: false, error: data.msg || 'Unknown error' }
-  } catch (error) {
-    return { success: false, error: String(error) }
-  }
-}
-
-// ==================== 方向控制 ====================
-
-// 方向键映射到命令
-const DIRECTION_TO_COMMAND: Record<PTZDirectionKey, PTZCommandType> = {
-  UP: 'up',
-  DOWN: 'down',
-  LEFT: 'left',
-  RIGHT: 'right',
-  UP_LEFT: 'up_left',
-  UP_RIGHT: 'up_right',
-  DOWN_LEFT: 'down_left',
-  DOWN_RIGHT: 'down_right',
-  STOP: 'stop'
-}
-
-/**
- * 开始 PTZ 移动
- * @param direction 方向: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT, STOP
- * @param speed 速度 1-100,默认 50
- */
-export function startPTZ(
-  config: PTZConfig,
-  direction: PTZDirectionKey,
-  speed: number = DEFAULT_SPEED
-): Promise<PTZResult> {
-  // 使用新的 command 接口,支持所有厂家
-  const command = DIRECTION_TO_COMMAND[direction]
-  return sendPTZCommand(config, command, speed)
-}
-
-/**
- * 停止 PTZ 移动
- */
-export function stopPTZ(config: PTZConfig): Promise<PTZResult> {
-  return sendPTZCommand(config, 'stop')
-}
-
-// ==================== 缩放控制 ====================
-
-/**
- * 开始缩放
- * @param direction 缩放方向: IN, OUT, STOP
- * @param speed 速度 1-100,默认 50
- */
-export function startZoom(config: PTZConfig, direction: PTZZoomKey, speed: number = DEFAULT_SPEED): Promise<PTZResult> {
-  const zoom = PTZ_ZOOM_DIRECTIONS[direction]
-  return sendCommand(config, {
-    pan: 0,
-    tilt: 0,
-    zoom: zoom.zoom * speed
-  })
-}
-
-/**
- * 停止缩放
- */
-export function stopZoom(config: PTZConfig): Promise<PTZResult> {
-  return startZoom(config, 'STOP')
-}
-
-// ==================== 便捷方法 ====================
-
-export const zoomIn = (config: PTZConfig, speed?: number) => startZoom(config, 'IN', speed)
-export const zoomOut = (config: PTZConfig, speed?: number) => startZoom(config, 'OUT', speed)
-export const zoomStop = stopZoom
-
-// ==================== 预置位 API ====================
-
-// 预置位信息
-export interface PTZPresetInfo {
-  id: string
-  name: string
-}
-
-// PTZ 能力信息
-export interface PTZCapabilities {
-  absolutePanTilt?: {
-    panMin: number
-    panMax: number
-    tiltMin: number
-    tiltMax: number
-  }
-  absoluteZoom?: {
-    min: number
-    max: number
-  }
-  continuousPanTilt?: {
-    panMin: number
-    panMax: number
-    tiltMin: number
-    tiltMax: number
-  }
-  continuousZoom?: {
-    min: number
-    max: number
-  }
-  maxPresetNum?: number
-  controlProtocol?: {
-    options: string[]
-    current: string
-  }
-  specialPresetIds?: number[]
-  presetName?: {
-    supported: boolean
-    maxLength: number
-  }
-  support3DPosition?: boolean
-  supportPtzLimits?: boolean
-}
-
-// 摄像头连接参数 (与 PTZConfig 兼容)
-export type CameraConnection = PTZConfig
-
-/**
- * 获取预置位列表
- */
-export async function getPTZPresets(config: PTZConfig): Promise<PTZResult & { data?: PTZPresetInfo[] }> {
-  try {
-    const response = await fetch(`${PTZ_API_BASE}/preset/list`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        host: config.host,
-        channel: config.channel || 1
-      })
-    })
-
-    const data = await response.json()
-    return data.code === 200
-      ? { success: true, data: data.data }
-      : { success: false, error: data.msg || 'Unknown error' }
-  } catch (error) {
-    return { success: false, error: String(error) }
-  }
-}
-
-/**
- * 跳转到预置位
- */
-export async function gotoPTZPreset(config: PTZConfig, presetId: number): Promise<PTZResult> {
-  try {
-    const response = await fetch(`${PTZ_API_BASE}/preset/goto`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        host: config.host,
-        channel: config.channel || 1,
-        presetId
-      })
-    })
-
-    const data = await response.json()
-    return data.code === 200
-      ? { success: true, data: data.data }
-      : { success: false, error: data.msg || 'Unknown error' }
-  } catch (error) {
-    return { success: false, error: String(error) }
-  }
-}
-
-/**
- * 设置预置位 (保存当前位置)
- */
-export async function setPTZPreset(
-  config: PTZConfig,
-  presetId: number,
-  presetName?: string
-): Promise<PTZResult & { data?: { presetId: number; presetName: string } }> {
-  try {
-    const response = await fetch(`${PTZ_API_BASE}/preset/set`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        host: config.host,
-        channel: config.channel || 1,
-        presetId,
-        presetName
-      })
-    })
-
-    const data = await response.json()
-    return data.code === 200
-      ? { success: true, data: data.data }
-      : { success: false, error: data.msg || 'Unknown error' }
-  } catch (error) {
-    return { success: false, error: String(error) }
-  }
-}
-
-/**
- * 删除预置位
- */
-export async function removePTZPreset(config: PTZConfig, presetId: number): Promise<PTZResult> {
-  try {
-    const response = await fetch(`${PTZ_API_BASE}/preset/remove`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        host: config.host,
-        channel: config.channel || 1,
-        presetId
-      })
-    })
-
-    const data = await response.json()
-    return data.code === 200
-      ? { success: true, data: data.data }
-      : { success: false, error: data.msg || 'Unknown error' }
-  } catch (error) {
-    return { success: false, error: String(error) }
-  }
-}
-
-/**
- * 获取 PTZ 能力参数
- */
-export async function getPTZCapabilities(config: PTZConfig): Promise<PTZResult & { data?: PTZCapabilities }> {
-  try {
-    const response = await fetch(`${PTZ_API_BASE}/ptz/capabilities`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        host: config.host,
-        channel: config.channel || 1
-      })
-    })
-
-    const data = await response.json()
-    return data.code === 200
-      ? { success: true, data: data.data }
-      : { success: false, error: data.msg || 'Unknown error' }
-  } catch (error) {
-    return { success: false, error: String(error) }
-  }
-}

+ 21 - 15
src/components/PTZController.vue

@@ -1,46 +1,52 @@
 <script setup lang="ts">
 import { ref } from 'vue'
-import { startPTZ, stopPTZ, type PTZConfig } from '@/api/ptz'
+import { ptzStart, ptzStop } from '@/api/camera'
+import type { PTZAction } from '@/types'
 
 const props = withDefaults(
   defineProps<{
-    /** 摄像头配置 */
-    config?: PTZConfig
+    /** 摄像头ID */
+    cameraId?: string
   }>(),
   {
-    config: () => ({
-      host: '192.168.0.64',
-      username: 'admin',
-      password: 'Wxc767718929',
-      channel: 1
-    })
+    cameraId: ''
   }
 )
 
 const isMoving = ref(false)
 const currentDirection = ref<string | null>(null)
 
+// 方向映射
+const directionToAction: Record<string, PTZAction> = {
+  UP: 'up',
+  DOWN: 'down',
+  LEFT: 'left',
+  RIGHT: 'right',
+  STOP: 'stop'
+}
+
 // 开始移动
 async function handleStart(
   direction: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' | 'UP_LEFT' | 'UP_RIGHT' | 'DOWN_LEFT' | 'DOWN_RIGHT'
 ) {
-  if (isMoving.value) return
+  if (isMoving.value || !props.cameraId) return
   isMoving.value = true
   currentDirection.value = direction
 
-  const result = await startPTZ(props.config, direction)
+  const action = directionToAction[direction] || 'stop'
+  const result = await ptzStart(props.cameraId, action)
   if (!result.success) {
-    console.error('PTZ 控制失败:', result.error)
+    console.error('PTZ 控制失败:', result.errMsg)
   }
 }
 
 // 停止移动
 async function handleStop() {
-  if (!isMoving.value) return
+  if (!isMoving.value || !props.cameraId) return
 
-  const result = await stopPTZ(props.config)
+  const result = await ptzStop(props.cameraId)
   if (!result.success) {
-    console.error('PTZ 停止失败:', result.error)
+    console.error('PTZ 停止失败:', result.errMsg)
   }
 
   isMoving.value = false

+ 29 - 40
src/components/monitor/PtzOverlay.vue

@@ -107,29 +107,15 @@ import {
   ZoomIn,
   ZoomOut
 } from '@element-plus/icons-vue'
-import {
-  startPTZ,
-  stopPTZ,
-  startZoom,
-  stopZoom,
-  type PTZConfig,
-  type PTZDirectionKey,
-  type CameraVendor
-} from '@/api/ptz'
+import { ptzStart, ptzStop } from '@/api/camera'
+import type { PTZAction } from '@/types'
 
 interface Props {
   cameraId?: string
-  vendor?: CameraVendor
-  host?: string
-  username?: string
-  password?: string
 }
 
 const props = withDefaults(defineProps<Props>(), {
-  vendor: 'HIKVISION',
-  host: '192.168.0.64',
-  username: 'admin',
-  password: ''
+  cameraId: ''
 })
 
 const emit = defineEmits<{
@@ -138,48 +124,51 @@ const emit = defineEmits<{
 
 const zoomValue = ref(0)
 
-// 根据 props 生成 PTZ 配置
-function getPtzConfig(): PTZConfig {
-  return {
-    host: props.host,
-    username: props.username,
-    password: props.password,
-    cameraId: props.cameraId ? Number(props.cameraId) : undefined,
-    vendor: props.vendor
-  }
-}
-
 const ptzSpeed = 50
 
-async function handleDirection(direction: PTZDirectionKey) {
+// 方向映射
+const directionToAction: Record<string, PTZAction> = {
+  UP: 'up',
+  DOWN: 'down',
+  LEFT: 'left',
+  RIGHT: 'right',
+  UP_LEFT: 'up',
+  UP_RIGHT: 'up',
+  DOWN_LEFT: 'down',
+  DOWN_RIGHT: 'down',
+  STOP: 'stop'
+}
+
+async function handleDirection(direction: string) {
+  if (!props.cameraId) return
   emit('ptz-action', 'direction', { direction })
-  const config = getPtzConfig()
-  await startPTZ(config, direction, ptzSpeed)
+  const action = directionToAction[direction] || 'stop'
+  await ptzStart(props.cameraId, action, ptzSpeed)
 }
 
 async function handleDirectionStop() {
+  if (!props.cameraId) return
   emit('ptz-action', 'stop')
-  const config = getPtzConfig()
-  await stopPTZ(config)
+  await ptzStop(props.cameraId)
 }
 
 async function handleZoomChange(val: number) {
-  const config = getPtzConfig()
+  if (!props.cameraId) return
   if (val === 0) {
-    await stopZoom(config)
+    await ptzStop(props.cameraId)
     return
   }
 
-  const direction = val > 0 ? 'IN' : 'OUT'
+  const action: PTZAction = val > 0 ? 'zoom_in' : 'zoom_out'
   const speed = Math.abs(val)
-  emit('ptz-action', 'zoom', { direction, speed })
-  await startZoom(config, direction, speed)
+  emit('ptz-action', 'zoom', { action, speed })
+  await ptzStart(props.cameraId, action, speed)
 }
 
 async function handleZoomRelease() {
   zoomValue.value = 0
-  const config = getPtzConfig()
-  await stopZoom(config)
+  if (!props.cameraId) return
+  await ptzStop(props.cameraId)
 }
 </script>
 

+ 2 - 2
src/layout/index.vue

@@ -241,9 +241,9 @@ const menuItems: MenuItem[] = [
     icon: 'mdi:video-wireless',
     children: [{ path: '/live-stream-manage/list', title: 'LiveStream 列表', icon: 'pixelarticons:list' }]
   },
-  { path: '/cc', title: 'Cloudflare Stream', icon: 'mdi:cloud' },
+  // { path: '/cc', title: 'Cloudflare Stream', icon: 'mdi:cloud' },
   { path: '/camera-vendor', title: '摄像头配置', icon: 'mdi:cctv' },
-  { path: '/webrtc', title: 'WebRTC 流', icon: 'mdi:wifi' },
+  // { path: '/webrtc', title: 'WebRTC 流', icon: 'mdi:wifi' },
   { path: '/monitor', title: '多视频监控', icon: 'mdi:video' },
   { path: '/machine', title: '机器管理', icon: 'mdi:monitor' },
   { path: '/camera', title: '摄像头管理', icon: 'mdi:video' },

+ 1 - 18
src/router/index.ts

@@ -72,18 +72,7 @@ const routes: RouteRecordRaw[] = [
           }
         ]
       },
-      {
-        path: 'cloud',
-        name: 'cloud',
-        component: () => import('@/views/cc/cloud.vue'),
-        meta: { title: 'cloud', icon: 'VideoCamera' }
-      },
-      {
-        path: 'cloudflare',
-        name: 'cloudflare',
-        component: () => import('@/views/cc/cloudflare.vue'),
-        meta: { title: 'cloudflare', icon: 'VideoCamera' }
-      },
+
       {
         path: 'camera/channel/:deviceId',
         name: 'CameraChannel',
@@ -132,12 +121,6 @@ const routes: RouteRecordRaw[] = [
         component: () => import('@/views/demo/cloudflareStream.vue'),
         meta: { title: 'Cloudflare Stream', icon: 'VideoCamera' }
       },
-      {
-        path: 'webrtc',
-        name: 'WebrtcStream',
-        component: () => import('@/views/demo/webrtc-stream.vue'),
-        meta: { title: 'WebRTC 流', icon: 'Connection' }
-      },
       {
         path: 'stats',
         name: 'Stats',

+ 0 - 641
src/views/cc/cloud.vue

@@ -1,641 +0,0 @@
-<template>
-  <div class="page-container">
-    <div class="page-header">
-      <span class="title">Cloudflare Stream 播放</span>
-    </div>
-
-    <!-- 配置区域 -->
-    <div class="config-section">
-      <el-form label-width="120px">
-        <el-form-item label="Video ID">
-          <el-input v-model="cfConfig.videoId" placeholder="Cloudflare Stream Video ID" style="width: 400px" />
-        </el-form-item>
-        <el-form-item label="自定义域名">
-          <el-input
-            v-model="cfConfig.customerDomain"
-            placeholder="customer-xxx.cloudflarestream.com"
-            style="width: 400px"
-          />
-        </el-form-item>
-        <el-form-item>
-          <el-button type="primary" @click="playCloudflare">播放</el-button>
-          <el-button @click="generateCfUrl">生成地址</el-button>
-        </el-form-item>
-        <el-form-item v-if="cfGeneratedUrl" label="生成的地址">
-          <el-input :value="cfGeneratedUrl" readonly style="width: 600px">
-            <template #append>
-              <el-button @click="copyUrl(cfGeneratedUrl)">复制</el-button>
-            </template>
-          </el-input>
-        </el-form-item>
-      </el-form>
-    </div>
-
-    <!-- 播放器和PTZ控制区域 -->
-    <div class="player-ptz-container">
-      <!-- 播放器区域 -->
-      <div class="player-section">
-        <div v-if="!currentSrc && !currentVideoId" class="player-placeholder">
-          <el-icon :size="60" color="#ddd">
-            <VideoPlay />
-          </el-icon>
-          <p>请输入 Video ID 并点击播放</p>
-        </div>
-        <VideoPlayer
-          v-else
-          ref="playerRef"
-          :player-type="currentPlayerType"
-          :video-id="currentVideoId"
-          :customer-domain="cfConfig.customerDomain"
-          :src="currentSrc"
-          :use-iframe="useIframe"
-          :autoplay="playConfig.autoplay"
-          :muted="playConfig.muted"
-          :controls="true"
-          @play="onPlay"
-          @pause="onPause"
-          @error="onError"
-          @loadedmetadata="onLoaded"
-        />
-      </div>
-
-      <!-- PTZ 云台控制 -->
-      <div class="ptz-panel">
-        <div class="ptz-header">
-          <span>PTZ 云台控制</span>
-        </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="speed-control">
-          <div class="control-label">
-            <span>速度: {{ ptzSpeed }}</span>
-          </div>
-          <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" :show-tooltip="true" />
-        </div>
-
-        <!-- 缩放控制 -->
-        <div class="zoom-controls">
-          <div class="zoom-header">
-            <el-icon>
-              <ZoomOut />
-            </el-icon>
-            <span>缩放</span>
-            <el-icon>
-              <ZoomIn />
-            </el-icon>
-          </div>
-          <el-slider
-            v-model="zoomValue"
-            :min="-100"
-            :max="100"
-            :step="10"
-            :show-tooltip="true"
-            :format-tooltip="formatZoomTooltip"
-            @input="handleZoomChange"
-            @change="handleZoomRelease"
-          />
-        </div>
-      </div>
-    </div>
-
-    <!-- 播放控制 -->
-    <div class="control-section">
-      <el-space wrap>
-        <el-button type="primary" @click="handlePlay">播放</el-button>
-        <el-button @click="handlePause">暂停</el-button>
-        <el-button type="danger" @click="handleStop">停止</el-button>
-        <el-button @click="handleScreenshot">截图</el-button>
-        <el-button @click="handleFullscreen">全屏</el-button>
-
-        <el-divider direction="vertical" />
-
-        <el-switch v-model="playConfig.muted" active-text="静音" inactive-text="有声" />
-        <el-switch v-model="playConfig.autoplay" active-text="自动播放" inactive-text="手动" />
-      </el-space>
-    </div>
-
-    <!-- 当前状态 -->
-    <!-- <div class="status-section">
-      <el-descriptions title="当前状态" :column="3" border>
-        <el-descriptions-item label="播放器类型">{{ currentPlayerType }}</el-descriptions-item>
-        <el-descriptions-item label="iframe 模式">{{ useIframe ? '是' : '否' }}</el-descriptions-item>
-        <el-descriptions-item label="Video ID">{{ currentVideoId || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="视频地址" :span="3">
-          <el-text truncated style="max-width: 800px">{{ currentSrc || '-' }}</el-text>
-        </el-descriptions-item>
-      </el-descriptions>
-    </div> -->
-
-    <!-- 日志区域 -->
-    <div class="log-section">
-      <div class="log-header">
-        <h4>事件日志</h4>
-        <el-button size="small" @click="logs = []">清空</el-button>
-      </div>
-      <div class="log-content">
-        <div v-for="(log, index) in logs" :key="index" class="log-item" :class="log.type">
-          <span class="time">{{ log.time }}</span>
-          <span class="message">{{ log.message }}</span>
-        </div>
-        <div v-if="logs.length === 0" class="log-empty">暂无日志</div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { ref, reactive } from 'vue'
-import { ElMessage } from 'element-plus'
-import {
-  VideoPlay,
-  Top,
-  Bottom,
-  Back,
-  Right,
-  TopLeft,
-  TopRight,
-  BottomLeft,
-  BottomRight,
-  Refresh,
-  ZoomIn,
-  ZoomOut
-} from '@element-plus/icons-vue'
-import VideoPlayer from '@/components/VideoPlayer.vue'
-import { startPTZ, stopPTZ, PTZ_DIRECTIONS, startZoom, stopZoom, PTZ_ZOOM_DIRECTIONS } from '@/api/ptz'
-
-const playerRef = ref<InstanceType<typeof VideoPlayer>>()
-
-// Cloudflare Stream 配置
-const cfConfig = reactive({
-  videoId: '3c1ae1949e76f200feef94b8f7d093ca',
-  customerDomain: 'customer-pj89kn2ke2tcuh19.cloudflarestream.com'
-})
-const cfGeneratedUrl = ref('')
-
-// 播放配置
-const playConfig = reactive({
-  autoplay: false,
-  muted: true
-})
-
-// PTZ 配置
-const ptzConfig = reactive({
-  host: '192.168.0.64',
-  username: 'admin',
-  password: 'Wxc767718929'
-})
-
-// PTZ 速度和缩放
-const ptzSpeed = ref(50)
-const zoomValue = ref(0)
-
-// 当前播放状态
-const currentSrc = ref('')
-const currentVideoId = ref('')
-const currentPlayerType = ref<'hls' | 'native' | 'cloudflare'>('hls')
-const useIframe = ref(false)
-
-// 日志
-interface LogItem {
-  time: string
-  type: 'info' | 'success' | 'error'
-  message: string
-}
-const logs = ref<LogItem[]>([])
-
-function addLog(message: string, type: LogItem['type'] = 'info') {
-  const time = new Date().toLocaleTimeString()
-  logs.value.unshift({ time, type, message })
-  if (logs.value.length > 100) {
-    logs.value.pop()
-  }
-}
-
-// 播放 Cloudflare Stream
-function playCloudflare() {
-  if (!cfConfig.videoId) {
-    ElMessage.warning('请输入 Video ID')
-    return
-  }
-
-  currentVideoId.value = cfConfig.videoId
-  useIframe.value = true
-  currentSrc.value = ''
-  currentPlayerType.value = 'cloudflare'
-
-  addLog(`播放 Cloudflare Stream: ${cfConfig.videoId} (iframe)`, 'success')
-}
-
-// 生成 Cloudflare URL
-function generateCfUrl() {
-  if (!cfConfig.videoId) {
-    ElMessage.warning('请输入 Video ID')
-    return
-  }
-  const domain = cfConfig.customerDomain || 'customer-xxx.cloudflarestream.com'
-  cfGeneratedUrl.value = `https://${domain}/${cfConfig.videoId}/iframe`
-}
-
-// 复制 URL
-function copyUrl(url: string) {
-  navigator.clipboard.writeText(url)
-  ElMessage.success('已复制到剪贴板')
-}
-
-// 播放控制
-function handlePlay() {
-  playerRef.value?.play()
-  addLog('播放', 'info')
-}
-
-function handlePause() {
-  playerRef.value?.pause()
-  addLog('暂停', 'info')
-}
-
-function handleStop() {
-  playerRef.value?.stop()
-  addLog('停止', 'info')
-}
-
-function handleScreenshot() {
-  playerRef.value?.screenshot()
-  addLog('截图', 'info')
-}
-
-function handleFullscreen() {
-  playerRef.value?.fullscreen()
-  addLog('全屏', 'info')
-}
-
-// 事件处理
-function onPlay() {
-  addLog('视频开始播放', 'success')
-}
-
-function onPause() {
-  addLog('视频已暂停', 'info')
-}
-
-function onLoaded() {
-  addLog('视频加载完成', 'success')
-}
-
-function onError(error: any) {
-  addLog(`播放错误: ${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,
-    ptzSpeed.value
-  )
-
-  if (result.success) {
-    addLog(`PTZ 移动: ${direction} (速度: ${ptzSpeed.value})`, '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')
-  }
-}
-
-// 缩放滑块控制
-function formatZoomTooltip(val: number) {
-  if (val === 0) return '停止'
-  return val > 0 ? `放大 ${val}` : `缩小 ${Math.abs(val)}`
-}
-
-async function handleZoomChange(val: number) {
-  if (!ptzConfig.host || !ptzConfig.username || !ptzConfig.password) return
-
-  if (val === 0) {
-    await stopZoom({
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password
-    })
-    return
-  }
-
-  const direction = val > 0 ? 'IN' : 'OUT'
-  const speed = Math.abs(val)
-
-  await startZoom(
-    {
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password
-    },
-    direction,
-    speed
-  )
-}
-
-async function handleZoomRelease() {
-  // 松开滑块时回到中间并停止
-  zoomValue.value = 0
-  if (!ptzConfig.host) return
-
-  await stopZoom({
-    host: ptzConfig.host,
-    username: ptzConfig.username,
-    password: ptzConfig.password
-  })
-  addLog('缩放停止', 'info')
-}
-</script>
-
-<style lang="scss" scoped>
-.page-container {
-}
-
-.page-header {
-  display: flex;
-  align-items: center;
-  margin-bottom: 20px;
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-  color: var(--text-primary);
-
-  .title {
-    font-size: 18px;
-    font-weight: 600;
-  }
-}
-
-.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;
-  border-radius: var(--radius-base);
-  overflow: hidden;
-  background-color: #000;
-
-  .player-placeholder {
-    height: 100%;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-    color: var(--text-secondary);
-
-    p {
-      margin-top: 15px;
-      font-size: 14px;
-    }
-  }
-}
-
-.ptz-panel {
-  width: 200px;
-  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-controls {
-    display: grid;
-    grid-template-columns: repeat(3, 1fr);
-    gap: 8px;
-  }
-
-  .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);
-    }
-  }
-
-  .speed-control {
-    margin-top: 15px;
-    padding-top: 15px;
-    border-top: 1px solid var(--border-color);
-
-    .control-label {
-      font-size: 12px;
-      color: var(--text-secondary);
-      margin-bottom: 8px;
-    }
-  }
-
-  .zoom-controls {
-    margin-top: 15px;
-    padding-top: 15px;
-    border-top: 1px solid var(--border-color);
-
-    .zoom-header {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      font-size: 12px;
-      color: var(--text-secondary);
-      margin-bottom: 8px;
-
-      span {
-        flex: 1;
-        text-align: center;
-      }
-    }
-  }
-}
-
-.control-section {
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-  margin-bottom: 20px;
-}
-
-.status-section {
-  margin-bottom: 20px;
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-}
-
-.log-section {
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-
-  .log-header {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    margin-bottom: 10px;
-
-    h4 {
-      font-size: 14px;
-      margin: 0;
-      color: var(--text-primary);
-    }
-  }
-
-  .log-content {
-    max-height: 200px;
-    overflow-y: auto;
-    background-color: var(--bg-hover);
-    border-radius: var(--radius-sm);
-    padding: 10px;
-  }
-
-  .log-item {
-    font-size: 12px;
-    padding: 4px 0;
-    border-bottom: 1px solid var(--border-color-light);
-    color: var(--text-regular);
-
-    &:last-child {
-      border-bottom: none;
-    }
-
-    .time {
-      color: var(--text-secondary);
-      margin-right: 10px;
-    }
-
-    &.success .message {
-      color: var(--color-success);
-    }
-
-    &.error .message {
-      color: var(--color-danger);
-    }
-  }
-
-  .log-empty {
-    text-align: center;
-    color: var(--text-secondary);
-    font-size: 12px;
-    padding: 20px 0;
-  }
-}
-</style>

+ 0 - 641
src/views/cc/cloudflare.vue

@@ -1,641 +0,0 @@
-<template>
-  <div class="page-container">
-    <div class="page-header">
-      <span class="title">Cloudflare Stream 播放</span>
-    </div>
-
-    <!-- 配置区域 -->
-    <div class="config-section">
-      <el-form label-width="120px">
-        <el-form-item label="Video ID">
-          <el-input v-model="cfConfig.videoId" placeholder="Cloudflare Stream Video ID" style="width: 400px" />
-        </el-form-item>
-        <el-form-item label="自定义域名">
-          <el-input
-            v-model="cfConfig.customerDomain"
-            placeholder="customer-xxx.cloudflarestream.com"
-            style="width: 400px"
-          />
-        </el-form-item>
-        <el-form-item>
-          <el-button type="primary" @click="playCloudflare">播放</el-button>
-          <el-button @click="generateCfUrl">生成地址</el-button>
-        </el-form-item>
-        <el-form-item v-if="cfGeneratedUrl" label="生成的地址">
-          <el-input :value="cfGeneratedUrl" readonly style="width: 600px">
-            <template #append>
-              <el-button @click="copyUrl(cfGeneratedUrl)">复制</el-button>
-            </template>
-          </el-input>
-        </el-form-item>
-      </el-form>
-    </div>
-
-    <!-- 播放器和PTZ控制区域 -->
-    <div class="player-ptz-container">
-      <!-- 播放器区域 -->
-      <div class="player-section">
-        <div v-if="!currentSrc && !currentVideoId" class="player-placeholder">
-          <el-icon :size="60" color="#ddd">
-            <VideoPlay />
-          </el-icon>
-          <p>请输入 Video ID 并点击播放</p>
-        </div>
-        <VideoPlayer
-          v-else
-          ref="playerRef"
-          :player-type="currentPlayerType"
-          :video-id="currentVideoId"
-          :customer-domain="cfConfig.customerDomain"
-          :src="currentSrc"
-          :use-iframe="useIframe"
-          :autoplay="playConfig.autoplay"
-          :muted="playConfig.muted"
-          :controls="true"
-          @play="onPlay"
-          @pause="onPause"
-          @error="onError"
-          @loadedmetadata="onLoaded"
-        />
-      </div>
-
-      <!-- PTZ 云台控制 -->
-      <div class="ptz-panel">
-        <div class="ptz-header">
-          <span>PTZ 云台控制</span>
-        </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="speed-control">
-          <div class="control-label">
-            <span>速度: {{ ptzSpeed }}</span>
-          </div>
-          <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" :show-tooltip="true" />
-        </div>
-
-        <!-- 缩放控制 -->
-        <div class="zoom-controls">
-          <div class="zoom-header">
-            <el-icon>
-              <ZoomOut />
-            </el-icon>
-            <span>缩放</span>
-            <el-icon>
-              <ZoomIn />
-            </el-icon>
-          </div>
-          <el-slider
-            v-model="zoomValue"
-            :min="-100"
-            :max="100"
-            :step="10"
-            :show-tooltip="true"
-            :format-tooltip="formatZoomTooltip"
-            @input="handleZoomChange"
-            @change="handleZoomRelease"
-          />
-        </div>
-      </div>
-    </div>
-
-    <!-- 播放控制 -->
-    <div class="control-section">
-      <el-space wrap>
-        <el-button type="primary" @click="handlePlay">播放</el-button>
-        <el-button @click="handlePause">暂停</el-button>
-        <el-button type="danger" @click="handleStop">停止</el-button>
-        <el-button @click="handleScreenshot">截图</el-button>
-        <el-button @click="handleFullscreen">全屏</el-button>
-
-        <el-divider direction="vertical" />
-
-        <el-switch v-model="playConfig.muted" active-text="静音" inactive-text="有声" />
-        <el-switch v-model="playConfig.autoplay" active-text="自动播放" inactive-text="手动" />
-      </el-space>
-    </div>
-
-    <!-- 当前状态 -->
-    <!-- <div class="status-section">
-      <el-descriptions title="当前状态" :column="3" border>
-        <el-descriptions-item label="播放器类型">{{ currentPlayerType }}</el-descriptions-item>
-        <el-descriptions-item label="iframe 模式">{{ useIframe ? '是' : '否' }}</el-descriptions-item>
-        <el-descriptions-item label="Video ID">{{ currentVideoId || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="视频地址" :span="3">
-          <el-text truncated style="max-width: 800px">{{ currentSrc || '-' }}</el-text>
-        </el-descriptions-item>
-      </el-descriptions>
-    </div> -->
-
-    <!-- 日志区域 -->
-    <div class="log-section">
-      <div class="log-header">
-        <h4>事件日志</h4>
-        <el-button size="small" @click="logs = []">清空</el-button>
-      </div>
-      <div class="log-content">
-        <div v-for="(log, index) in logs" :key="index" class="log-item" :class="log.type">
-          <span class="time">{{ log.time }}</span>
-          <span class="message">{{ log.message }}</span>
-        </div>
-        <div v-if="logs.length === 0" class="log-empty">暂无日志</div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { ref, reactive } from 'vue'
-import { ElMessage } from 'element-plus'
-import {
-  VideoPlay,
-  Top,
-  Bottom,
-  Back,
-  Right,
-  TopLeft,
-  TopRight,
-  BottomLeft,
-  BottomRight,
-  Refresh,
-  ZoomIn,
-  ZoomOut
-} from '@element-plus/icons-vue'
-import VideoPlayer from '@/components/VideoPlayer.vue'
-import { startPTZ, stopPTZ, PTZ_DIRECTIONS, startZoom, stopZoom, PTZ_ZOOM_DIRECTIONS } from '@/api/ptz'
-
-const playerRef = ref<InstanceType<typeof VideoPlayer>>()
-
-// Cloudflare Stream 配置
-const cfConfig = reactive({
-  videoId: '3c1ae1949e76f200feef94b8f7d093ca',
-  customerDomain: 'customer-pj89kn2ke2tcuh19.cloudflarestream.com'
-})
-const cfGeneratedUrl = ref('')
-
-// 播放配置
-const playConfig = reactive({
-  autoplay: false,
-  muted: true
-})
-
-// PTZ 配置
-const ptzConfig = reactive({
-  host: '192.168.0.64',
-  username: 'admin',
-  password: 'Wxc767718929'
-})
-
-// PTZ 速度和缩放
-const ptzSpeed = ref(50)
-const zoomValue = ref(0)
-
-// 当前播放状态
-const currentSrc = ref('')
-const currentVideoId = ref('')
-const currentPlayerType = ref<'hls' | 'native' | 'cloudflare'>('hls')
-const useIframe = ref(false)
-
-// 日志
-interface LogItem {
-  time: string
-  type: 'info' | 'success' | 'error'
-  message: string
-}
-const logs = ref<LogItem[]>([])
-
-function addLog(message: string, type: LogItem['type'] = 'info') {
-  const time = new Date().toLocaleTimeString()
-  logs.value.unshift({ time, type, message })
-  if (logs.value.length > 100) {
-    logs.value.pop()
-  }
-}
-
-// 播放 Cloudflare Stream
-function playCloudflare() {
-  if (!cfConfig.videoId) {
-    ElMessage.warning('请输入 Video ID')
-    return
-  }
-
-  currentVideoId.value = cfConfig.videoId
-  useIframe.value = true
-  currentSrc.value = ''
-  currentPlayerType.value = 'cloudflare'
-
-  addLog(`播放 Cloudflare Stream: ${cfConfig.videoId} (iframe)`, 'success')
-}
-
-// 生成 Cloudflare URL
-function generateCfUrl() {
-  if (!cfConfig.videoId) {
-    ElMessage.warning('请输入 Video ID')
-    return
-  }
-  const domain = cfConfig.customerDomain || 'customer-xxx.cloudflarestream.com'
-  cfGeneratedUrl.value = `https://${domain}/${cfConfig.videoId}/iframe`
-}
-
-// 复制 URL
-function copyUrl(url: string) {
-  navigator.clipboard.writeText(url)
-  ElMessage.success('已复制到剪贴板')
-}
-
-// 播放控制
-function handlePlay() {
-  playerRef.value?.play()
-  addLog('播放', 'info')
-}
-
-function handlePause() {
-  playerRef.value?.pause()
-  addLog('暂停', 'info')
-}
-
-function handleStop() {
-  playerRef.value?.stop()
-  addLog('停止', 'info')
-}
-
-function handleScreenshot() {
-  playerRef.value?.screenshot()
-  addLog('截图', 'info')
-}
-
-function handleFullscreen() {
-  playerRef.value?.fullscreen()
-  addLog('全屏', 'info')
-}
-
-// 事件处理
-function onPlay() {
-  addLog('视频开始播放', 'success')
-}
-
-function onPause() {
-  addLog('视频已暂停', 'info')
-}
-
-function onLoaded() {
-  addLog('视频加载完成', 'success')
-}
-
-function onError(error: any) {
-  addLog(`播放错误: ${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,
-    ptzSpeed.value
-  )
-
-  if (result.success) {
-    addLog(`PTZ 移动: ${direction} (速度: ${ptzSpeed.value})`, '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')
-  }
-}
-
-// 缩放滑块控制
-function formatZoomTooltip(val: number) {
-  if (val === 0) return '停止'
-  return val > 0 ? `放大 ${val}` : `缩小 ${Math.abs(val)}`
-}
-
-async function handleZoomChange(val: number) {
-  if (!ptzConfig.host || !ptzConfig.username || !ptzConfig.password) return
-
-  if (val === 0) {
-    await stopZoom({
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password
-    })
-    return
-  }
-
-  const direction = val > 0 ? 'IN' : 'OUT'
-  const speed = Math.abs(val)
-
-  await startZoom(
-    {
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password
-    },
-    direction,
-    speed
-  )
-}
-
-async function handleZoomRelease() {
-  // 松开滑块时回到中间并停止
-  zoomValue.value = 0
-  if (!ptzConfig.host) return
-
-  await stopZoom({
-    host: ptzConfig.host,
-    username: ptzConfig.username,
-    password: ptzConfig.password
-  })
-  addLog('缩放停止', 'info')
-}
-</script>
-
-<style lang="scss" scoped>
-.page-container {
-}
-
-.page-header {
-  display: flex;
-  align-items: center;
-  margin-bottom: 20px;
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-  color: var(--text-primary);
-
-  .title {
-    font-size: 18px;
-    font-weight: 600;
-  }
-}
-
-.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;
-  border-radius: var(--radius-base);
-  overflow: hidden;
-  background-color: #000;
-
-  .player-placeholder {
-    height: 100%;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-    color: var(--text-secondary);
-
-    p {
-      margin-top: 15px;
-      font-size: 14px;
-    }
-  }
-}
-
-.ptz-panel {
-  width: 200px;
-  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-controls {
-    display: grid;
-    grid-template-columns: repeat(3, 1fr);
-    gap: 8px;
-  }
-
-  .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);
-    }
-  }
-
-  .speed-control {
-    margin-top: 15px;
-    padding-top: 15px;
-    border-top: 1px solid var(--border-color);
-
-    .control-label {
-      font-size: 12px;
-      color: var(--text-secondary);
-      margin-bottom: 8px;
-    }
-  }
-
-  .zoom-controls {
-    margin-top: 15px;
-    padding-top: 15px;
-    border-top: 1px solid var(--border-color);
-
-    .zoom-header {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      font-size: 12px;
-      color: var(--text-secondary);
-      margin-bottom: 8px;
-
-      span {
-        flex: 1;
-        text-align: center;
-      }
-    }
-  }
-}
-
-.control-section {
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-  margin-bottom: 20px;
-}
-
-.status-section {
-  margin-bottom: 20px;
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-}
-
-.log-section {
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-
-  .log-header {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    margin-bottom: 10px;
-
-    h4 {
-      font-size: 14px;
-      margin: 0;
-      color: var(--text-primary);
-    }
-  }
-
-  .log-content {
-    max-height: 200px;
-    overflow-y: auto;
-    background-color: var(--bg-hover);
-    border-radius: var(--radius-sm);
-    padding: 10px;
-  }
-
-  .log-item {
-    font-size: 12px;
-    padding: 4px 0;
-    border-bottom: 1px solid var(--border-color-light);
-    color: var(--text-regular);
-
-    &:last-child {
-      border-bottom: none;
-    }
-
-    .time {
-      color: var(--text-secondary);
-      margin-right: 10px;
-    }
-
-    &.success .message {
-      color: var(--color-success);
-    }
-
-    &.error .message {
-      color: var(--color-danger);
-    }
-  }
-
-  .log-empty {
-    text-align: center;
-    color: var(--text-secondary);
-    font-size: 12px;
-    padding: 20px 0;
-  }
-}
-</style>

+ 31 - 50
src/views/demo/cloudflareStream.vue

@@ -222,7 +222,8 @@ import {
   ZoomOut
 } from '@element-plus/icons-vue'
 import VideoPlayer from '@/components/VideoPlayer.vue'
-import { startPTZ, stopPTZ, PTZ_DIRECTIONS, startZoom, stopZoom, PTZ_ZOOM_DIRECTIONS } from '@/api/ptz'
+import { ptzStart, ptzStop } from '@/api/camera'
+import type { PTZAction } from '@/types'
 
 const playerRef = ref<InstanceType<typeof VideoPlayer>>()
 
@@ -240,12 +241,8 @@ const playConfig = reactive({
   muted: true
 })
 
-// PTZ 配置
-const ptzConfig = reactive({
-  host: '192.168.0.64',
-  username: 'admin',
-  password: 'Wxc767718929'
-})
+// PTZ cameraId
+const ptzCameraId = ref('HIKVISION-3')
 
 // PTZ 速度和缩放
 const ptzSpeed = ref(50)
@@ -365,41 +362,43 @@ function onError(error: any) {
   addLog(`播放错误: ${JSON.stringify(error)}`, 'error')
 }
 
+// PTZ 方向映射
+const directionToAction: Record<string, PTZAction> = {
+  UP: 'up',
+  DOWN: 'down',
+  LEFT: 'left',
+  RIGHT: 'right',
+  UP_LEFT: 'up',
+  UP_RIGHT: 'up',
+  DOWN_LEFT: 'down',
+  DOWN_RIGHT: 'down',
+  STOP: 'stop'
+}
+
 // PTZ 控制
-async function handlePTZ(direction: keyof typeof PTZ_DIRECTIONS) {
-  if (!ptzConfig.host || !ptzConfig.username || !ptzConfig.password) {
+async function handlePTZ(direction: string) {
+  if (!ptzCameraId.value) {
     ElMessage.warning('请先配置摄像头信息')
     return
   }
 
-  const result = await startPTZ(
-    {
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password
-    },
-    direction,
-    ptzSpeed.value
-  )
+  const action = directionToAction[direction] || 'stop'
+  const result = await ptzStart(ptzCameraId.value, action, ptzSpeed.value)
 
   if (result.success) {
     addLog(`PTZ 移动: ${direction} (速度: ${ptzSpeed.value})`, 'info')
   } else {
-    addLog(`PTZ 控制失败: ${result.error}`, 'error')
+    addLog(`PTZ 控制失败: ${result.errMsg}`, 'error')
   }
 }
 
 async function handlePTZStop() {
-  if (!ptzConfig.host) return
+  if (!ptzCameraId.value) return
 
-  const result = await stopPTZ({
-    host: ptzConfig.host,
-    username: ptzConfig.username,
-    password: ptzConfig.password
-  })
+  const result = await ptzStop(ptzCameraId.value)
 
   if (!result.success) {
-    addLog(`PTZ 停止失败: ${result.error}`, 'error')
+    addLog(`PTZ 停止失败: ${result.errMsg}`, 'error')
   }
 }
 
@@ -410,41 +409,23 @@ function formatZoomTooltip(val: number) {
 }
 
 async function handleZoomChange(val: number) {
-  if (!ptzConfig.host || !ptzConfig.username || !ptzConfig.password) return
+  if (!ptzCameraId.value) return
 
   if (val === 0) {
-    await stopZoom({
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password
-    })
+    await ptzStop(ptzCameraId.value)
     return
   }
 
-  const direction = val > 0 ? 'IN' : 'OUT'
+  const action: PTZAction = val > 0 ? 'zoom_in' : 'zoom_out'
   const speed = Math.abs(val)
-
-  await startZoom(
-    {
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password
-    },
-    direction,
-    speed
-  )
+  await ptzStart(ptzCameraId.value, action, speed)
 }
 
 async function handleZoomRelease() {
-  // 松开滑块时回到中间并停止
   zoomValue.value = 0
-  if (!ptzConfig.host) return
+  if (!ptzCameraId.value) return
 
-  await stopZoom({
-    host: ptzConfig.host,
-    username: ptzConfig.username,
-    password: ptzConfig.password
-  })
+  await ptzStop(ptzCameraId.value)
   addLog('缩放停止', 'info')
 }
 </script>

+ 2 - 20
src/views/demo/rtsp-stream.vue

@@ -53,18 +53,7 @@
       <!-- PTZ 云台控制 -->
       <div class="ptz-section">
         <h4>云台控制</h4>
-        <PTZController :config="ptzConfig" />
-        <el-form label-width="80px" size="small" style="margin-top: 15px">
-          <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" />
-          </el-form-item>
-          <el-form-item label="密码">
-            <el-input v-model="ptzConfig.password" type="password" show-password />
-          </el-form-item>
-        </el-form>
+        <PTZController :camera-id="ptzCameraId" />
       </div>
     </div>
 
@@ -119,8 +108,6 @@ import { ElMessage } from 'element-plus'
 import { VideoPlay } from '@element-plus/icons-vue'
 import VideoPlayer from '@/components/VideoPlayer.vue'
 import PTZController from '@/components/PTZController.vue'
-import type { PTZConfig } from '@/api/ptz'
-
 const playerRef = ref<InstanceType<typeof VideoPlayer>>()
 
 // RTSP 配置
@@ -130,12 +117,7 @@ const rtspConfig = reactive({
 })
 
 // PTZ 配置
-const ptzConfig = reactive<PTZConfig>({
-  host: '192.168.0.64',
-  username: 'admin',
-  password: 'Wxc767718929',
-  channel: 1
-})
+const ptzCameraId = ref('HIKVISION-3')
 
 // 播放配置
 const playConfig = reactive({

+ 0 - 794
src/views/demo/webrtc-stream.vue

@@ -1,794 +0,0 @@
-<template>
-  <div class="page-container">
-    <div class="page-header">
-      <span class="title">WebRTC 播放</span>
-      <!-- <el-tag type="success" size="small" style="margin-left: 10px">延迟 &lt; 2s</el-tag> -->
-    </div>
-
-    <!-- 配置区域 -->
-    <div class="config-section">
-      <el-form label-width="120px">
-        <el-form-item label="本地RTC地址">
-          <el-input v-model="config.go2rtcUrl" placeholder="服务地址" style="width: 400px">
-            <template #prepend>http://</template>
-          </el-input>
-          <el-text type="info" style="margin-left: 10px">默认端口 1984</el-text>
-        </el-form-item>
-        <el-form-item label="选择摄像头">
-          <el-select
-            v-model="config.streamName"
-            placeholder="选择摄像头流"
-            style="width: 300px"
-            @change="handleStreamChange"
-          >
-            <el-option-group label="ANPVIZ">
-              <el-option label="ANPVIZ 主码流" value="anpviz" />
-              <el-option label="ANPVIZ 原始流" value="anpviz_raw" />
-              <el-option label="ANPVIZ 子码流" value="anpviz_sub" />
-            </el-option-group>
-            <el-option-group label="CT-IP500">
-              <el-option label="CT-IP500 主码流" value="ct-ip500" />
-              <el-option label="CT-IP500 子码流" value="ct-ip500_sub" />
-            </el-option-group>
-            <el-option-group label="HIKVISION 海康威视">
-              <el-option label="海康威视 主码流" value="hikvision" />
-              <el-option label="海康威视 子码流" value="hikvision_sub" />
-            </el-option-group>
-            <el-option-group label="SVBC">
-              <el-option label="SVBC 主码流" value="svbc" />
-              <el-option label="SVBC 原始流" value="svbc_raw" />
-              <el-option label="SVBC 子码流" value="svbc_sub" />
-            </el-option-group>
-          </el-select>
-        </el-form-item>
-        <!-- http://localhost:1984/api/webrtc?src=camera1 -->
-        <el-form-item label="完整URL">
-          <el-text>{{ fullGo2rtcUrl }}/api/webrtc?src={{ config.streamName }}</el-text>
-        </el-form-item>
-        <!-- <el-form-item label="生成的 URL">
-          <el-input :value="generatedUrl" readonly style="width: 600px">
-            <template #append>
-              <el-button @click="copyUrl">复制</el-button>
-            </template>
-          </el-input>
-        </el-form-item> -->
-        <el-form-item>
-          <el-button type="primary" @click="startPlay">播放</el-button>
-          <el-button @click="handleReconnect">重连</el-button>
-          <el-button type="danger" @click="handleStop">停止</el-button>
-        </el-form-item>
-      </el-form>
-    </div>
-
-    <!-- 播放器和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-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="speed-control">
-          <div class="control-label">
-            <span>速度: {{ ptzSpeed }}</span>
-          </div>
-          <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" :show-tooltip="true" />
-        </div>
-
-        <!-- 缩放控制 -->
-        <div class="zoom-controls">
-          <div class="zoom-header">
-            <el-icon>
-              <ZoomOut />
-            </el-icon>
-            <span>缩放</span>
-            <el-icon>
-              <ZoomIn />
-            </el-icon>
-          </div>
-          <el-slider
-            v-model="zoomValue"
-            :min="-100"
-            :max="100"
-            :step="10"
-            :show-tooltip="true"
-            :format-tooltip="formatZoomTooltip"
-            @input="handleZoomChange"
-            @change="handleZoomRelease"
-          />
-        </div>
-      </div>
-    </div>
-
-    <!-- 播放控制 -->
-    <div class="control-section">
-      <el-space wrap>
-        <el-button type="primary" @click="handlePlay">播放</el-button>
-        <el-button @click="handlePause">暂停</el-button>
-        <el-button @click="handleScreenshot">截图</el-button>
-        <el-button @click="handleFullscreen">全屏</el-button>
-
-        <el-divider direction="vertical" />
-
-        <el-switch v-model="playConfig.muted" active-text="静音" inactive-text="有声" />
-        <el-switch v-model="playConfig.autoplay" active-text="自动播放" inactive-text="手动" />
-      </el-space>
-    </div>
-
-    <!-- 日志区域 -->
-    <div class="log-section">
-      <div class="log-header">
-        <h4>事件日志</h4>
-        <el-button size="small" @click="logs = []">清空</el-button>
-      </div>
-      <div class="log-content">
-        <div v-for="(log, index) in logs" :key="index" class="log-item" :class="log.type">
-          <span class="time">{{ log.time }}</span>
-          <span class="message">{{ log.message }}</span>
-        </div>
-        <div v-if="logs.length === 0" class="log-empty">暂无日志</div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { ref, reactive, computed } from 'vue'
-import { ElMessage } from 'element-plus'
-import {
-  VideoPlay,
-  Top,
-  Bottom,
-  Back,
-  Right,
-  TopLeft,
-  TopRight,
-  BottomLeft,
-  BottomRight,
-  Refresh,
-  ZoomIn,
-  ZoomOut
-} from '@element-plus/icons-vue'
-import VideoPlayer from '@/components/VideoPlayer.vue'
-import {
-  startPTZ,
-  stopPTZ,
-  PTZ_DIRECTIONS,
-  startZoom,
-  stopZoom,
-  PTZ_ZOOM_DIRECTIONS,
-  type CameraVendor
-} from '@/api/ptz'
-
-const playerRef = ref<InstanceType<typeof VideoPlayer>>()
-
-// 配置
-const config = reactive({
-  go2rtcUrl: 'localhost:1984',
-  streamName: 'hikvision'
-})
-
-// 播放配置
-const playConfig = reactive({
-  autoplay: true,
-  muted: true
-})
-
-// 摄像头 PTZ 配置映射 (统一使用 viewer 账号)
-const cameraPtzConfigs: Record<string, { vendor: CameraVendor; host: string; username: string; password: string }> = {
-  // ANPVIZ
-  anpviz: { vendor: 'ANPVIZ', host: '192.168.0.96', username: 'viewer', password: 'Wxc767718929' },
-  anpviz_raw: { vendor: 'ANPVIZ', host: '192.168.0.96', username: 'viewer', password: 'Wxc767718929' },
-  anpviz_sub: { vendor: 'ANPVIZ', host: '192.168.0.96', username: 'viewer', password: 'Wxc767718929' },
-  // CT-IP500 (无 PTZ)
-  'ct-ip500': { vendor: 'CT-IP500', host: '', username: '', password: '' },
-  'ct-ip500_sub': { vendor: 'CT-IP500', host: '', username: '', password: '' },
-  // HIKVISION 海康威视
-  hikvision: { vendor: 'HIKVISION', host: '192.168.0.64', username: 'viewer', password: 'Wxc767718929' },
-  hikvision_sub: { vendor: 'HIKVISION', host: '192.168.0.64', username: 'viewer', password: 'Wxc767718929' },
-  // SVBC (无 PTZ)
-  svbc: { vendor: 'SVBC', host: '', username: '', password: '' },
-  svbc_raw: { vendor: 'SVBC', host: '', username: '', password: '' },
-  svbc_sub: { vendor: 'SVBC', host: '', username: '', password: '' }
-}
-
-// PTZ 配置 (根据选择的摄像头动态更新)
-const ptzConfig = reactive({
-  vendor: 'HIKVISION' as CameraVendor,
-  host: '192.168.0.64',
-  username: 'viewer',
-  password: 'Wxc767718929'
-})
-
-// PTZ 速度和缩放
-const ptzSpeed = ref(50)
-const zoomValue = ref(0)
-
-// 播放状态
-const isPlaying = ref(false)
-
-// 完整的 go2rtc URL
-const fullGo2rtcUrl = computed(() => {
-  if (!config.go2rtcUrl) return ''
-  const url = config.go2rtcUrl.startsWith('http') ? config.go2rtcUrl : `http://${config.go2rtcUrl}`
-  return url
-})
-
-// 生成的 WebRTC API URL
-const generatedUrl = computed(() => {
-  if (!fullGo2rtcUrl.value || !config.streamName) return ''
-  return `${fullGo2rtcUrl.value}/api/webrtc?src=${config.streamName}`
-})
-
-// 连接状态
-const connectionStatus = computed(() => {
-  return playerRef.value?.webrtcStatus?.value || 'idle'
-})
-
-const statusText = computed(() => {
-  const map: Record<string, string> = {
-    idle: '未连接',
-    connecting: '连接中',
-    connected: '已连接',
-    failed: '连接失败'
-  }
-  return map[connectionStatus.value] || '未知'
-})
-
-const statusTagType = computed(() => {
-  const map: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
-    idle: 'info',
-    connecting: 'warning',
-    connected: 'success',
-    failed: 'danger'
-  }
-  return map[connectionStatus.value] || 'info'
-})
-
-// 日志
-interface LogItem {
-  time: string
-  type: 'info' | 'success' | 'error'
-  message: string
-}
-const logs = ref<LogItem[]>([])
-
-function addLog(message: string, type: LogItem['type'] = 'info') {
-  const time = new Date().toLocaleTimeString()
-  logs.value.unshift({ time, type, message })
-  if (logs.value.length > 100) {
-    logs.value.pop()
-  }
-}
-
-// 开始播放
-function startPlay() {
-  if (!config.go2rtcUrl) {
-    ElMessage.warning('请输入 go2rtc 地址')
-    return
-  }
-  if (!config.streamName) {
-    ElMessage.warning('请选择摄像头')
-    return
-  }
-  isPlaying.value = true
-  addLog(`开始 WebRTC 播放: ${config.streamName}`, 'success')
-}
-
-// 切换摄像头流
-function handleStreamChange(streamName: string) {
-  addLog(`切换摄像头: ${streamName}`, 'info')
-
-  // 更新 PTZ 配置
-  const ptzSettings = cameraPtzConfigs[streamName]
-  if (ptzSettings) {
-    ptzConfig.vendor = ptzSettings.vendor
-    ptzConfig.host = ptzSettings.host
-    ptzConfig.username = ptzSettings.username
-    ptzConfig.password = ptzSettings.password
-    addLog(`PTZ 配置已切换到: ${ptzSettings.vendor} (${ptzSettings.host || '无PTZ'})`, 'info')
-  }
-
-  if (isPlaying.value) {
-    // 如果正在播放,重新连接新的流
-    playerRef.value?.reconnect()
-  }
-}
-
-// 复制 URL
-function copyUrl() {
-  if (!generatedUrl.value) {
-    ElMessage.warning('请先配置 go2rtc 地址和流名称')
-    return
-  }
-  navigator.clipboard.writeText(generatedUrl.value)
-  ElMessage.success('已复制到剪贴板')
-}
-
-// 播放控制
-function handlePlay() {
-  playerRef.value?.play()
-  addLog('播放', 'info')
-}
-
-function handlePause() {
-  playerRef.value?.pause()
-  addLog('暂停', 'info')
-}
-
-function handleStop() {
-  isPlaying.value = false
-  addLog('停止播放', 'info')
-}
-
-function handleReconnect() {
-  playerRef.value?.reconnect()
-  addLog('重新连接', 'info')
-}
-
-function handleScreenshot() {
-  playerRef.value?.screenshot()
-  addLog('截图', 'info')
-}
-
-function handleFullscreen() {
-  playerRef.value?.fullscreen()
-  addLog('全屏', 'info')
-}
-
-// 事件处理
-function onPlay() {
-  addLog('视频开始播放', 'success')
-}
-
-function onPause() {
-  addLog('视频已暂停', 'info')
-}
-
-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('当前摄像头不支持 PTZ 控制')
-    return
-  }
-
-  const result = await startPTZ(
-    {
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password,
-      vendor: ptzConfig.vendor
-    },
-    direction,
-    ptzSpeed.value
-  )
-
-  if (result.success) {
-    addLog(`PTZ 移动: ${direction} (${ptzConfig.vendor}, 速度: ${ptzSpeed.value})`, '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,
-    vendor: ptzConfig.vendor
-  })
-
-  if (!result.success) {
-    addLog(`PTZ 停止失败: ${result.error}`, 'error')
-  }
-}
-
-// 缩放滑块控制
-function formatZoomTooltip(val: number) {
-  if (val === 0) return '停止'
-  return val > 0 ? `放大 ${val}` : `缩小 ${Math.abs(val)}`
-}
-
-async function handleZoomChange(val: number) {
-  if (!ptzConfig.host || !ptzConfig.username || !ptzConfig.password) return
-
-  if (val === 0) {
-    await stopZoom({
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password,
-      vendor: ptzConfig.vendor
-    })
-    return
-  }
-
-  const direction = val > 0 ? 'IN' : 'OUT'
-  const speed = Math.abs(val)
-
-  await startZoom(
-    {
-      host: ptzConfig.host,
-      username: ptzConfig.username,
-      password: ptzConfig.password,
-      vendor: ptzConfig.vendor
-    },
-    direction,
-    speed
-  )
-}
-
-async function handleZoomRelease() {
-  // 松开滑块时回到中间并停止
-  zoomValue.value = 0
-  if (!ptzConfig.host) return
-
-  await stopZoom({
-    host: ptzConfig.host,
-    username: ptzConfig.username,
-    password: ptzConfig.password,
-    vendor: ptzConfig.vendor
-  })
-  addLog('缩放停止', 'info')
-}
-</script>
-
-<style lang="scss" scoped>
-.page-container {
-}
-
-.page-header {
-  display: flex;
-  align-items: center;
-  margin-bottom: 20px;
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-  color: var(--text-primary);
-
-  .title {
-    font-size: 18px;
-    font-weight: 600;
-  }
-}
-
-.config-section {
-  padding: 20px;
-  margin-bottom: 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-}
-
-.player-ptz-container {
-  padding: 20px;
-  background-color: var(--bg-container);
-  display: flex;
-  gap: 20px;
-  margin-bottom: 20px;
-}
-
-.player-section {
-  flex: 1;
-  height: 500px;
-  border-radius: var(--radius-base);
-  overflow: hidden;
-  background-color: #000;
-
-  .player-placeholder {
-    height: 100%;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-    color: var(--text-secondary);
-
-    p {
-      margin-top: 15px;
-      font-size: 14px;
-    }
-  }
-}
-
-.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);
-    }
-  }
-
-  .speed-control {
-    margin-top: 15px;
-    padding-top: 15px;
-    border-top: 1px solid var(--border-color);
-
-    .control-label {
-      font-size: 12px;
-      color: var(--text-secondary);
-      margin-bottom: 8px;
-    }
-  }
-
-  .zoom-controls {
-    margin-top: 15px;
-    padding-top: 15px;
-    border-top: 1px solid var(--border-color);
-
-    .zoom-header {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      font-size: 12px;
-      color: var(--text-secondary);
-      margin-bottom: 8px;
-
-      span {
-        flex: 1;
-        text-align: center;
-      }
-    }
-  }
-
-  .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);
-  border-radius: var(--radius-base);
-  margin-bottom: 20px;
-}
-
-.status-section {
-  margin-bottom: 20px;
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-}
-
-.info-section {
-  margin-bottom: 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-
-  .info-content {
-    padding: 10px 0;
-
-    h4 {
-      margin: 15px 0 8px;
-      color: var(--text-primary);
-      font-size: 14px;
-
-      &:first-child {
-        margin-top: 0;
-      }
-    }
-
-    p {
-      margin: 0;
-      color: var(--text-regular);
-      font-size: 13px;
-    }
-
-    .code-block {
-      background-color: var(--bg-hover);
-      padding: 12px;
-      border-radius: var(--radius-sm);
-      font-family: 'Monaco', 'Menlo', monospace;
-      font-size: 12px;
-      line-height: 1.6;
-      overflow-x: auto;
-      color: var(--text-primary);
-    }
-  }
-}
-
-.log-section {
-  padding: 15px 20px;
-  background-color: var(--bg-container);
-  border-radius: var(--radius-base);
-
-  .log-header {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    margin-bottom: 10px;
-
-    h4 {
-      font-size: 14px;
-      margin: 0;
-      color: var(--text-primary);
-    }
-  }
-
-  .log-content {
-    max-height: 200px;
-    overflow-y: auto;
-    background-color: var(--bg-hover);
-    border-radius: var(--radius-sm);
-    padding: 10px;
-  }
-
-  .log-item {
-    font-size: 12px;
-    padding: 4px 0;
-    border-bottom: 1px solid var(--border-color-light);
-    color: var(--text-regular);
-
-    &:last-child {
-      border-bottom: none;
-    }
-
-    .time {
-      color: var(--text-secondary);
-      margin-right: 10px;
-    }
-
-    &.success .message {
-      color: var(--color-success);
-    }
-
-    &.error .message {
-      color: var(--color-danger);
-    }
-  }
-
-  .log-empty {
-    text-align: center;
-    color: var(--text-secondary);
-    font-size: 12px;
-    padding: 20px 0;
-  }
-}
-</style>

+ 89 - 62
src/views/live-stream/index.vue

@@ -276,7 +276,7 @@
                   <el-button size="small" @click="addTimelinePoint()">+ {{ t('添加点') }}</el-button>
                   <el-button
                     size="small"
-                    type="success"
+                    type="primary"
                     :loading="isTimelinePlaying"
                     :disabled="!hasActivePoints"
                     @click="playTimeline"
@@ -639,17 +639,19 @@ import { adminListCameras } from '@/api/camera'
 import { startStreamTask, stopStreamTask, getStreamPlayback } from '@/api/stream-push'
 import VideoPlayer from '@/components/VideoPlayer.vue'
 import CodeEditor from '@/components/CodeEditor.vue'
-import { getPresets, gotoPreset, type PresetInfo, presetList, presetGoto, presetSet, presetRemove } from '@/api/camera'
 import {
-  startPTZ,
-  stopPTZ,
-  startZoom,
-  type PTZPresetInfo,
-  type PTZCapabilities,
-  type PTZDirectionKey,
-  type PTZZoomKey
-} from '@/api/ptz'
-import type { LiveStreamDTO, LiveStreamStatus, LssNodeDTO, CameraInfoDTO, StreamChannelDTO } from '@/types'
+  getPresets,
+  gotoPreset,
+  type PresetInfo,
+  presetList,
+  presetGoto,
+  presetSet,
+  presetRemove,
+  getPTZCapabilities,
+  ptzStart,
+  ptzStop
+} from '@/api/camera'
+import type { LiveStreamDTO, LiveStreamStatus, LssNodeDTO, CameraInfoDTO, StreamChannelDTO, PTZAction } from '@/types'
 
 const { t } = useI18n({ useScope: 'global' })
 const route = useRoute()
@@ -693,21 +695,23 @@ const ptzSpeed = ref(50)
 const zoomValue = ref(0)
 
 // 预置位
-const presetList = ref<PresetInfo[]>([])
+const presetListData = ref<PresetInfo[]>([])
 const presetsLoading = ref(false)
 const activePresetToken = ref<string | null>(null)
 
-// PTZ 直连 API 相关
+// PTZ 预置位 (camera API)
+interface PTZPresetInfo {
+  id: string
+  name: string
+}
+interface PTZCapabilities {
+  maxPresetNum?: number
+  [key: string]: unknown
+}
 const ptzPresetList = ref<PTZPresetInfo[]>([])
 const activePresetId = ref<string | null>(null)
 const cameraCapabilities = ref<PTZCapabilities | null>(null)
 const capabilitiesLoading = ref(false)
-// PTZ 配置 (使用 device ID)
-const ptzChannel = ref(1)
-const ptzConfig = computed(() => ({
-  host: currentMediaStream.value?.cameraId || '',
-  channel: ptzChannel.value
-}))
 
 // 可折叠面板
 const activePanels = ref(['ptz', 'preset', 'camera'])
@@ -1329,16 +1333,27 @@ function handleFullscreen() {
 }
 
 // PTZ 控制 (使用直连 PTZ API)
+// PTZ 方向映射
+const directionToAction: Record<string, PTZAction> = {
+  UP: 'up',
+  DOWN: 'down',
+  LEFT: 'left',
+  RIGHT: 'right',
+  STOP: 'stop'
+}
+
 async function handlePTZ(direction: string) {
-  if (!hasCameraConnection()) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
     ElMessage.warning(t('请先选择直播流'))
     return
   }
 
   try {
-    const res = await startPTZ(ptzConfig.value, direction as PTZDirectionKey, ptzSpeed.value)
+    const action = directionToAction[direction] || 'stop'
+    const res = await ptzStart(cameraId, action, ptzSpeed.value)
     if (!res.success) {
-      console.error('PTZ 控制失败', res.error)
+      console.error('PTZ 控制失败', res.errMsg)
     }
   } catch (error) {
     console.error('PTZ 控制失败', error)
@@ -1346,10 +1361,11 @@ async function handlePTZ(direction: string) {
 }
 
 async function handlePTZStop() {
-  if (!hasCameraConnection()) return
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) return
 
   try {
-    await stopPTZ(ptzConfig.value)
+    await ptzStop(cameraId)
   } catch (error) {
     console.error('PTZ 停止失败', error)
   }
@@ -1362,33 +1378,36 @@ function formatZoomTooltip(val: number) {
 }
 
 async function handleZoomChange(val: number) {
-  if (!hasCameraConnection()) return
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) return
 
   if (val === 0) {
-    await stopPTZ(ptzConfig.value)
+    await ptzStop(cameraId)
     return
   }
 
-  const direction: PTZZoomKey = val > 0 ? 'IN' : 'OUT'
-  await startZoom(ptzConfig.value, direction, Math.abs(val))
+  const action: PTZAction = val > 0 ? 'zoom_in' : 'zoom_out'
+  await ptzStart(cameraId, action, Math.abs(val))
 }
 
 async function handleZoomRelease() {
   zoomValue.value = 0
-  if (!hasCameraConnection()) return
-  await stopPTZ(ptzConfig.value)
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) return
+  await ptzStop(cameraId)
 }
 
-// 缩放按钮控制 (使用直连 PTZ API)
+// 缩放按钮控制
 async function handleZoomIn() {
-  if (!hasCameraConnection()) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
     ElMessage.warning(t('请先选择直播流'))
     return
   }
   try {
-    const res = await startZoom(ptzConfig.value, 'IN' as PTZZoomKey, ptzSpeed.value)
+    const res = await ptzStart(cameraId, 'zoom_in', ptzSpeed.value)
     if (!res.success) {
-      console.error('Zoom in 失败', res.error)
+      console.error('Zoom in 失败', res.errMsg)
     }
   } catch (error) {
     console.error('Zoom in 失败', error)
@@ -1396,14 +1415,15 @@ async function handleZoomIn() {
 }
 
 async function handleZoomOut() {
-  if (!hasCameraConnection()) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
     ElMessage.warning(t('请先选择直播流'))
     return
   }
   try {
-    const res = await startZoom(ptzConfig.value, 'OUT' as PTZZoomKey, ptzSpeed.value)
+    const res = await ptzStart(cameraId, 'zoom_out', ptzSpeed.value)
     if (!res.success) {
-      console.error('Zoom out 失败', res.error)
+      console.error('Zoom out 失败', res.errMsg)
     }
   } catch (error) {
     console.error('Zoom out 失败', error)
@@ -1461,22 +1481,23 @@ function hasCameraConnection(): boolean {
   return !!currentMediaStream.value?.cameraId
 }
 
-// 加载 PTZ 预置位 (直连 PTZ 服务)
+// 加载 PTZ 预置位 (通过 camera API)
 async function loadPTZPresets() {
-  if (!hasCameraConnection()) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
     ElMessage.warning(t('请先选择直播流'))
     return
   }
 
   presetsLoading.value = true
   try {
-    const res = await getPTZPresets(ptzConfig.value)
+    const res = await presetList(cameraId)
     if (res.success && res.data) {
-      ptzPresetList.value = res.data
+      ptzPresetList.value = res.data as PTZPresetInfo[]
     } else {
       ptzPresetList.value = []
       if (!res.success) {
-        ElMessage.error(res.error || t('加载预置位失败'))
+        ElMessage.error(res.errMsg || t('加载预置位失败'))
       }
     }
   } catch (error) {
@@ -1487,20 +1508,21 @@ async function loadPTZPresets() {
   }
 }
 
-// 跳转到 PTZ 预置位 (直连 PTZ 服务)
+// 跳转到 PTZ 预置位 (通过 camera API)
 async function handleGotoPTZPreset(preset: PTZPresetInfo) {
-  if (!hasCameraConnection()) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
     ElMessage.warning(t('请先配置摄像头连接'))
     return
   }
 
   try {
     activePresetId.value = preset.id
-    const res = await gotoPTZPreset(ptzConfig.value, parseInt(preset.id))
+    const res = await presetGoto(cameraId, parseInt(preset.id))
     if (res.success) {
       ElMessage.success(`${t('已跳转到预置位')}: ${preset.name || preset.id}`)
     } else {
-      ElMessage.error(res.error || t('跳转失败'))
+      ElMessage.error(res.errMsg || t('跳转失败'))
     }
   } catch (error) {
     console.error('跳转 PTZ 预置位失败', error)
@@ -1516,7 +1538,8 @@ function handleEditPreset(preset: PTZPresetInfo) {
 
 // 删除预置位
 async function handleDeletePreset(preset: PTZPresetInfo) {
-  if (!hasCameraConnection()) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
     ElMessage.warning(t('请先配置摄像头连接'))
     return
   }
@@ -1526,13 +1549,13 @@ async function handleDeletePreset(preset: PTZPresetInfo) {
       type: 'warning'
     })
 
-    const res = await removePTZPreset(ptzConfig.value, parseInt(preset.id))
+    const res = await presetRemove(cameraId, parseInt(preset.id))
     if (res.success) {
       ElMessage.success(t('删除成功'))
       // 刷新预置位列表
       loadPTZPresets()
     } else {
-      ElMessage.error(res.error || t('删除失败'))
+      ElMessage.error(res.errMsg || t('删除失败'))
     }
   } catch (error) {
     if (error !== 'cancel') {
@@ -1542,17 +1565,18 @@ async function handleDeletePreset(preset: PTZPresetInfo) {
   }
 }
 
-// 加载摄像头能力信息
+// 加载摄像头能力信息 (通过 camera API)
 async function loadCameraCapabilities() {
-  if (!hasCameraConnection()) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
     return
   }
 
   capabilitiesLoading.value = true
   try {
-    const res = await getPTZCapabilities(ptzConfig.value)
+    const res = await getPTZCapabilities(cameraId)
     if (res.success && res.data) {
-      cameraCapabilities.value = res.data
+      cameraCapabilities.value = res.data as PTZCapabilities
     } else {
       cameraCapabilities.value = null
     }
@@ -1668,8 +1692,9 @@ function selectPoint(point: TimelinePoint) {
   selectedPoint.value = point
 
   // 如果已有预置位,跳转到该位置
-  if (point.presetId && hasCameraConnection()) {
-    gotoPTZPreset(ptzConfig.value, point.presetId).then((res) => {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (point.presetId && cameraId) {
+    presetGoto(cameraId, point.presetId).then((res) => {
       if (res.success) {
         ElMessage.success(`${t('已跳转到')}: ${point.presetName || `Point ${point.id}`}`)
       }
@@ -1681,27 +1706,28 @@ function selectPoint(point: TimelinePoint) {
 async function saveCurrentPoint() {
   if (!selectedPoint.value) return
 
-  if (!hasCameraConnection()) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
     ElMessage.warning(t('请先选择直播流'))
     return
   }
 
   const point = selectedPoint.value
   // 使用时间轴专用的预置位ID范围 (100+)
-  const presetId = point.presetId || 100 + point.id
+  const presetIdNum = point.presetId || 100 + point.id
   const presetName = `Timeline_${point.id}`
 
   savingPreset.value = true
   try {
-    const res = await setPTZPreset(ptzConfig.value, presetId, presetName)
+    const res = await presetSet(cameraId, presetIdNum, presetName)
     if (res.success) {
-      point.presetId = presetId
+      point.presetId = presetIdNum
       point.presetName = presetName
       point.active = true
       saveTimelineConfig()
       ElMessage.success(`${t('已保存')} ${presetName}`)
     } else {
-      ElMessage.error(res.error || t('保存失败'))
+      ElMessage.error(res.errMsg || t('保存失败'))
     }
   } catch (error) {
     console.error('保存预置位失败', error)
@@ -1764,7 +1790,8 @@ async function playTimeline() {
     return
   }
 
-  if (!hasCameraConnection()) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
     ElMessage.warning(t('请先选择直播流'))
     return
   }
@@ -1794,7 +1821,7 @@ async function playTimeline() {
 
       // 跳转到该预置位
       if (point.presetId) {
-        await gotoPTZPreset(ptzConfig.value, point.presetId)
+        await presetGoto(cameraId, point.presetId)
         selectedPoint.value = point
       }
 

+ 427 - 0
tests/e2e/live-stream.spec.ts

@@ -856,3 +856,430 @@ test.describe('LiveStream 管理 - Bug修复验证测试', () => {
     await expect(resetButton).toHaveClass(/el-button--info/)
   })
 })
+
+test.describe('LiveStream 管理 - 播放功能与 PTZ 控制测试', () => {
+  /**
+   * 测试播放按钮打开抽屉并加载 PTZ 预置位和能力信息
+   */
+  test('点击播放按钮打开抽屉并加载 PTZ 数据', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream-manage/list')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // 记录 API 调用
+    let presetListCalled = false
+    let capabilitiesCalled = false
+    let presetListCameraId = ''
+    let capabilitiesCameraId = ''
+
+    // 拦截 PTZ 预置位列表请求
+    await page.route('**/camera/control/*/preset/list', async (route) => {
+      presetListCalled = true
+      const url = route.request().url()
+      const match = url.match(/camera\/control\/([^/]+)\/preset\/list/)
+      if (match) {
+        presetListCameraId = match[1]
+      }
+      // Mock 返回预置位数据
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({
+          code: 200,
+          msg: 'success',
+          data: [
+            { id: '1', name: 'Preset 1' },
+            { id: '2', name: 'Preset 2' }
+          ]
+        })
+      })
+    })
+
+    // 拦截 PTZ 能力请求
+    await page.route('**/camera/control/*/ptz/capabilities', async (route) => {
+      capabilitiesCalled = true
+      const url = route.request().url()
+      const match = url.match(/camera\/control\/([^/]+)\/ptz\/capabilities/)
+      if (match) {
+        capabilitiesCameraId = match[1]
+      }
+      // Mock 返回能力数据
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({
+          code: 200,
+          msg: 'success',
+          data: {
+            maxPresetNum: 255,
+            controlProtocol: {
+              options: ['ISAPI'],
+              current: 'ISAPI'
+            },
+            absoluteZoom: {
+              min: 1,
+              max: 30
+            },
+            support3DPosition: true,
+            supportPtzLimits: true
+          }
+        })
+      })
+    })
+
+    // 找到包含 cameraId 的行并点击播放按钮
+    const rows = page.locator('tbody tr')
+    const rowCount = await rows.count()
+
+    // 找到有 cameraId 的行
+    let targetRow = null
+    for (let i = 0; i < rowCount; i++) {
+      const row = rows.nth(i)
+      const cameraIdCell = await row.locator('td').nth(3).textContent()
+      if (cameraIdCell && cameraIdCell.trim() !== '-' && cameraIdCell.trim() !== '') {
+        targetRow = row
+        break
+      }
+    }
+
+    if (targetRow) {
+      // 点击播放按钮 (data-id="live-stream-play-btn")
+      const playButton = targetRow.locator('[data-id="live-stream-play-btn"]')
+      await expect(playButton).toBeVisible()
+      await playButton.click()
+
+      // 等待抽屉打开
+      const drawer = page.locator('.el-drawer')
+      await expect(drawer).toBeVisible({ timeout: 5000 })
+
+      // 验证播放 tab 被选中
+      const playTab = drawer.locator('.el-tabs__item').filter({ hasText: '播放' })
+      await expect(playTab).toHaveClass(/is-active/)
+
+      // 等待 API 调用完成
+      await page.waitForTimeout(2000)
+
+      // 验证 PTZ 预置位和能力 API 被调用
+      expect(presetListCalled).toBe(true)
+      expect(capabilitiesCalled).toBe(true)
+
+      // 验证抽屉中显示 PTZ 控制面板
+      await expect(drawer.locator('text=PTZ')).toBeVisible()
+
+      // 验证抽屉中显示预置位面板
+      await expect(drawer.locator('text=预置位')).toBeVisible()
+
+      // 验证抽屉中显示摄像头信息面板
+      await expect(drawer.locator('text=摄像头信息')).toBeVisible()
+    }
+  })
+
+  /**
+   * 测试 PTZ 方向控制按钮
+   */
+  test('PTZ 方向控制按钮存在且可交互', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream-manage/list')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // Mock PTZ API 响应
+    await page.route('**/camera/control/*/preset/list', async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({ code: 200, msg: 'success', data: [] })
+      })
+    })
+
+    await page.route('**/camera/control/*/ptz/capabilities', async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({ code: 200, msg: 'success', data: { maxPresetNum: 255 } })
+      })
+    })
+
+    // 找到有 cameraId 的行并点击播放按钮
+    const rows = page.locator('tbody tr')
+    const rowCount = await rows.count()
+
+    for (let i = 0; i < rowCount; i++) {
+      const row = rows.nth(i)
+      const cameraIdCell = await row.locator('td').nth(3).textContent()
+      if (cameraIdCell && cameraIdCell.trim() !== '-' && cameraIdCell.trim() !== '') {
+        const playButton = row.locator('[data-id="live-stream-play-btn"]')
+        await playButton.click()
+        break
+      }
+    }
+
+    // 等待抽屉打开
+    const drawer = page.locator('.el-drawer')
+    await expect(drawer).toBeVisible({ timeout: 5000 })
+
+    // 展开 PTZ 控制面板
+    const ptzHeader = drawer.locator('.el-collapse-item__header').filter({ hasText: 'PTZ' })
+    if (await ptzHeader.isVisible()) {
+      // 验证 PTZ 控制面板
+      const ptzGrid = drawer.locator('.ptz-grid')
+      await expect(ptzGrid).toBeVisible()
+
+      // 验证 9 个方向按钮存在
+      const ptzButtons = drawer.locator('.ptz-btn')
+      await expect(ptzButtons).toHaveCount(9)
+
+      // 验证缩放按钮存在
+      const zoomButtons = drawer.locator('.zoom-buttons button')
+      await expect(zoomButtons).toHaveCount(2)
+
+      // 验证速度滑块存在
+      const speedSlider = drawer.locator('.speed-slider')
+      await expect(speedSlider).toBeVisible()
+    }
+  })
+
+  /**
+   * 测试预置位列表显示
+   */
+  test('预置位列表正确显示', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream-manage/list')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // Mock PTZ API 响应 - 返回预置位列表
+    await page.route('**/camera/control/*/preset/list', async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({
+          code: 200,
+          msg: 'success',
+          data: [
+            { id: '1', name: '门口' },
+            { id: '2', name: '窗户' },
+            { id: '3', name: '走廊' }
+          ]
+        })
+      })
+    })
+
+    await page.route('**/camera/control/*/ptz/capabilities', async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({ code: 200, msg: 'success', data: { maxPresetNum: 255 } })
+      })
+    })
+
+    // 找到有 cameraId 的行并点击播放按钮
+    const rows = page.locator('tbody tr')
+    const rowCount = await rows.count()
+
+    for (let i = 0; i < rowCount; i++) {
+      const row = rows.nth(i)
+      const cameraIdCell = await row.locator('td').nth(3).textContent()
+      if (cameraIdCell && cameraIdCell.trim() !== '-' && cameraIdCell.trim() !== '') {
+        const playButton = row.locator('[data-id="live-stream-play-btn"]')
+        await playButton.click()
+        break
+      }
+    }
+
+    // 等待抽屉打开
+    const drawer = page.locator('.el-drawer')
+    await expect(drawer).toBeVisible({ timeout: 5000 })
+
+    // 等待预置位加载
+    await page.waitForTimeout(1500)
+
+    // 展开预置位面板
+    const presetHeader = drawer.locator('.el-collapse-item__header').filter({ hasText: '预置位' })
+    await expect(presetHeader).toBeVisible()
+
+    // 验证预置位列表中有 3 个项目
+    const presetItems = drawer.locator('.preset-item')
+    await expect(presetItems).toHaveCount(3)
+
+    // 验证预置位名称显示正确
+    await expect(drawer.locator('.preset-name:has-text("门口")')).toBeVisible()
+    await expect(drawer.locator('.preset-name:has-text("窗户")')).toBeVisible()
+    await expect(drawer.locator('.preset-name:has-text("走廊")')).toBeVisible()
+  })
+
+  /**
+   * 测试摄像头信息显示
+   */
+  test('摄像头能力信息正确显示', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream-manage/list')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // Mock PTZ API 响应
+    await page.route('**/camera/control/*/preset/list', async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({ code: 200, msg: 'success', data: [] })
+      })
+    })
+
+    await page.route('**/camera/control/*/ptz/capabilities', async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({
+          code: 200,
+          msg: 'success',
+          data: {
+            maxPresetNum: 255,
+            controlProtocol: {
+              options: ['ISAPI', 'ONVIF'],
+              current: 'ISAPI'
+            },
+            absoluteZoom: {
+              min: 1,
+              max: 30
+            },
+            support3DPosition: true,
+            supportPtzLimits: true
+          }
+        })
+      })
+    })
+
+    // 找到有 cameraId 的行并点击播放按钮
+    const rows = page.locator('tbody tr')
+    const rowCount = await rows.count()
+
+    for (let i = 0; i < rowCount; i++) {
+      const row = rows.nth(i)
+      const cameraIdCell = await row.locator('td').nth(3).textContent()
+      if (cameraIdCell && cameraIdCell.trim() !== '-' && cameraIdCell.trim() !== '') {
+        const playButton = row.locator('[data-id="live-stream-play-btn"]')
+        await playButton.click()
+        break
+      }
+    }
+
+    // 等待抽屉打开
+    const drawer = page.locator('.el-drawer')
+    await expect(drawer).toBeVisible({ timeout: 5000 })
+
+    // 等待能力信息加载
+    await page.waitForTimeout(1500)
+
+    // 展开摄像头信息面板
+    const cameraInfoHeader = drawer.locator('.el-collapse-item__header').filter({ hasText: '摄像头信息' })
+    await expect(cameraInfoHeader).toBeVisible()
+
+    // 验证能力信息显示
+    const cameraInfoContent = drawer.locator('.camera-info-content')
+    await expect(cameraInfoContent).toBeVisible()
+
+    // 验证最大预置位数显示
+    await expect(cameraInfoContent.locator('text=255')).toBeVisible()
+
+    // 验证控制协议显示
+    await expect(cameraInfoContent.locator('text=ISAPI')).toBeVisible()
+
+    // 验证变焦倍数显示
+    await expect(cameraInfoContent.locator('text=/1.*30/')).toBeVisible()
+  })
+
+  /**
+   * 测试播放器控制按钮
+   */
+  test('播放器控制按钮存在', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream-manage/list')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // Mock PTZ API 响应
+    await page.route('**/camera/control/**', async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({ code: 200, msg: 'success', data: null })
+      })
+    })
+
+    // 点击第一行的播放按钮
+    const playButton = page.locator('[data-id="live-stream-play-btn"]').first()
+    await playButton.click()
+
+    // 等待抽屉打开
+    const drawer = page.locator('.el-drawer')
+    await expect(drawer).toBeVisible({ timeout: 5000 })
+
+    // 验证播放器控制按钮存在
+    const controls = drawer.locator('.player-controls')
+    await expect(controls).toBeVisible()
+
+    // 验证各个控制按钮
+    await expect(controls.locator('button:has-text("播放")')).toBeVisible()
+    await expect(controls.locator('button:has-text("暂停")')).toBeVisible()
+    await expect(controls.locator('button:has-text("停止")')).toBeVisible()
+    await expect(controls.locator('button:has-text("截图")')).toBeVisible()
+    await expect(controls.locator('button:has-text("全屏")')).toBeVisible()
+
+    // 验证静音开关存在
+    await expect(controls.locator('.el-switch')).toBeVisible()
+  })
+
+  /**
+   * 测试时间轴组件
+   */
+  test('巡航时间轴组件存在', async ({ page }) => {
+    await login(page)
+    await page.goto('/live-stream-manage/list')
+
+    // 等待表格加载
+    await page.waitForSelector('tbody tr', { timeout: 10000 })
+
+    // Mock PTZ API 响应
+    await page.route('**/camera/control/**', async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({ code: 200, msg: 'success', data: null })
+      })
+    })
+
+    // 点击播放按钮
+    const playButton = page.locator('[data-id="live-stream-play-btn"]').first()
+    await playButton.click()
+
+    // 等待抽屉打开
+    const drawer = page.locator('.el-drawer')
+    await expect(drawer).toBeVisible({ timeout: 5000 })
+
+    // 验证时间轴容器存在
+    const timeline = drawer.locator('.timeline-container')
+    await expect(timeline).toBeVisible()
+
+    // 验证时间轴头部
+    await expect(timeline.locator('text=巡航时间轴')).toBeVisible()
+
+    // 验证时间选择器
+    await expect(timeline.locator('.el-select')).toBeVisible()
+
+    // 验证添加点按钮
+    await expect(timeline.locator('button:has-text("添加点")')).toBeVisible()
+
+    // 验证播放巡航按钮
+    await expect(timeline.locator('button:has-text("播放巡航")')).toBeVisible()
+
+    // 验证时间轴轨道
+    await expect(timeline.locator('.timeline-track')).toBeVisible()
+  })
+})

+ 0 - 2
vitest.config.ts

@@ -27,7 +27,6 @@ export default defineConfig({
         // Exclude demo and test views that are not core functionality
         'src/views/demo/**',
         'src/views/test/**',
-        'src/views/cc/**',
         'src/views/monitor/**',
         'src/views/stream/**',
         'src/views/camera/channel.vue',
@@ -44,7 +43,6 @@ export default defineConfig({
         'src/components/HelloWorld.vue',
         'src/composables/**',
         // Exclude complex API modules with external dependencies
-        'src/api/ptz.ts',
         'src/api/stream.ts',
         'src/api/cloudflare-stream.ts',
         'src/store/stream.ts',