Browse Source

feat(video-center): 接入海康云台方向控制

- 新增云台控制接口及请求类型
- 添加八方向云台控制弹层
- 支持按下开始、松开停止控制
- 增加指针取消和页面卸载停止兜底
- 完善云台交互状态及代码注释
Zimo 1 week ago
parent
commit
c4aad3d1b3
2 changed files with 447 additions and 1 deletions
  1. 41 0
      src/api/pms/hikvision/index.ts
  2. 406 1
      src/views/pms/video_center/haikang/index.vue

+ 41 - 0
src/api/pms/hikvision/index.ts

@@ -21,6 +21,33 @@ export interface HikvisionCameraNode {
 
 
 export type HikvisionTreeNode = HikvisionRegionNode | HikvisionCameraNode
 export type HikvisionTreeNode = HikvisionRegionNode | HikvisionCameraNode
 export type HikvisionStreamType = 0 | 1
 export type HikvisionStreamType = 0 | 1
+/**
+ * 海康云台方向命令。
+ *
+ * 接口要求 command 使用大写字符串,并通过下划线连接组合方向;这里使用联合类型
+ * 约束页面只能提交后端支持的八个方向,避免大小写或拼写错误直到运行时才被发现。
+ */
+export type HikvisionPtzCommand =
+  | 'LEFT' // 向左
+  | 'RIGHT' // 向右
+  | 'UP' // 向上
+  | 'DOWN' // 向下
+  | 'LEFT_UP' // 左上
+  | 'LEFT_DOWN' // 左下
+  | 'RIGHT_UP' // 右上
+  | 'RIGHT_DOWN' // 右下
+
+/** `/vms/ptz-control` 云台控制接口的请求体。 */
+export interface HikvisionPtzControlRequest {
+  /** 监控点唯一标识,对应当前播放窗口绑定摄像机的 indexCode。 */
+  cameraIndexCode: string
+  /** 操作类型:0 表示开始持续转动,1 表示停止当前方向转动。 */
+  action: 0 | 1
+  /** 本次需要执行或停止的云台命令。 */
+  command: HikvisionPtzCommand
+  /** 云台速度,允许范围为 1~100;不传时由海康平台使用默认值。 */
+  speed?: number
+}
 
 
 export const HikvisionApi = {
 export const HikvisionApi = {
   getTreeNodes: (regionIndexCode: string): Promise<HikvisionTreeNode[]> => {
   getTreeNodes: (regionIndexCode: string): Promise<HikvisionTreeNode[]> => {
@@ -51,5 +78,19 @@ export const HikvisionApi = {
       url: '/vms/talk-url',
       url: '/vms/talk-url',
       params: { cameraIndexCode }
       params: { cameraIndexCode }
     })
     })
+  },
+
+  /**
+   * 开始或停止监控点的云台方向控制。
+   *
+   * Axios 实例的 baseURL 已包含 `/admin-api`,因此这里保留业务路径即可,浏览器最终
+   * 请求地址为 `/admin-api/vms/ptz-control`。方向控制为持续动作,调用方必须保证
+   * `action: 0` 与 `action: 1` 成对发送。
+   */
+  controlPtz: (data: HikvisionPtzControlRequest): Promise<void> => {
+    return request.post({
+      url: '/vms/ptz-control',
+      data
+    })
   }
   }
 }
 }

+ 406 - 1
src/views/pms/video_center/haikang/index.vue

@@ -2,6 +2,7 @@
 import {
 import {
   HikvisionApi,
   HikvisionApi,
   type HikvisionCameraNode,
   type HikvisionCameraNode,
+  type HikvisionPtzCommand,
   type HikvisionStreamType,
   type HikvisionStreamType,
   type HikvisionTreeNode
   type HikvisionTreeNode
 } from '@/api/pms/hikvision'
 } from '@/api/pms/hikvision'
@@ -50,6 +51,12 @@ const hoveredWindowIndex = ref<number>()
 const hoveredControlWindowIndex = ref<number>()
 const hoveredControlWindowIndex = ref<number>()
 const splitPopoverVisible = ref(false)
 const splitPopoverVisible = ref(false)
 const splitControlLoading = ref(false)
 const splitControlLoading = ref(false)
+// 云台 Popover 的全局展示状态和所属窗口,用于确保弹层打开时对应视口工具栏保持可见。
+const ptzPopoverVisible = ref(false)
+const ptzPopoverWindowIndex = ref<number>()
+// 当前被按住的方向及窗口,仅用于按钮按压态展示;松开时会立即清空。
+const activePtzCommand = ref<HikvisionPtzCommand>()
+const activePtzWindowIndex = ref<number>()
 const sidebarCollapsed = ref(false)
 const sidebarCollapsed = ref(false)
 const stageFullscreen = ref(false)
 const stageFullscreen = ref(false)
 const fullscreenControlLoading = ref(false)
 const fullscreenControlLoading = ref(false)
@@ -59,6 +66,8 @@ type SplitCount = 1 | 4 | 9 | 16
 // SDK 的分屏参数是边长:4 表示最大 4×4,2 表示默认 2×2。
 // SDK 的分屏参数是边长:4 表示最大 4×4,2 表示默认 2×2。
 const MAX_SPLIT_COLUMNS = 4
 const MAX_SPLIT_COLUMNS = 4
 const DEFAULT_SPLIT_COLUMNS = 2
 const DEFAULT_SPLIT_COLUMNS = 2
+// 云台速度允许 1~100;当前统一使用较平缓的 30,便于按压控制时精确调整画面方向。
+const PTZ_CONTROL_SPEED = 30
 
 
 const splitOptions: Array<{ count: SplitCount; columns: HikvisionSplitColumns; icon: string }> = [
 const splitOptions: Array<{ count: SplitCount; columns: HikvisionSplitColumns; icon: string }> = [
   { count: 1, columns: 1, icon: gridOneIcon },
   { count: 1, columns: 1, icon: gridOneIcon },
@@ -67,6 +76,61 @@ const splitOptions: Array<{ count: SplitCount; columns: HikvisionSplitColumns; i
   { count: 16, columns: 4, icon: grid16Icon }
   { count: 16, columns: 4, icon: grid16Icon }
 ]
 ]
 
 
+/**
+ * 八方向控制盘的展示配置与接口命令映射。
+ * gridArea 将八个按钮固定在 3×3 网格四周,中间单元格留给不可点击的云台标识。
+ */
+const ptzDirections: Array<{
+  /** 页面展示和无障碍朗读使用的中文方向名。 */
+  label: string
+  /** UnoCSS Lucide 图标类名。 */
+  icon: string
+  /** CSS Grid 行列位置。 */
+  gridArea: string
+  /** `/vms/ptz-control` 接口要求的大写命令。 */
+  command: HikvisionPtzCommand
+}> = [
+  { label: '左上', icon: 'i-lucide:arrow-up-left', gridArea: '1 / 1', command: 'LEFT_UP' },
+  { label: '向上', icon: 'i-lucide:arrow-up', gridArea: '1 / 2', command: 'UP' },
+  { label: '右上', icon: 'i-lucide:arrow-up-right', gridArea: '1 / 3', command: 'RIGHT_UP' },
+  { label: '向左', icon: 'i-lucide:arrow-left', gridArea: '2 / 1', command: 'LEFT' },
+  { label: '向右', icon: 'i-lucide:arrow-right', gridArea: '2 / 3', command: 'RIGHT' },
+  {
+    label: '左下',
+    icon: 'i-lucide:arrow-down-left',
+    gridArea: '3 / 1',
+    command: 'LEFT_DOWN'
+  },
+  { label: '向下', icon: 'i-lucide:arrow-down', gridArea: '3 / 2', command: 'DOWN' },
+  {
+    label: '右下',
+    icon: 'i-lucide:arrow-down-right',
+    gridArea: '3 / 3',
+    command: 'RIGHT_DOWN'
+  }
+]
+
+/**
+ * 一次尚未完整结束的云台按压操作。
+ *
+ * 开始和停止请求必须使用相同的摄像机与命令,因此按下时保存快照,不能在松开时重新
+ * 读取可能已经切换过的视口状态。startRequest 用于保证停止请求排在开始请求之后发送。
+ */
+interface ActivePtzPress {
+  /** 按下瞬间对应的监控点标识。 */
+  cameraIndexCode: string
+  /** 按下的方向命令。 */
+  command: HikvisionPtzCommand
+  /** Pointer Events 分配的指针编号,用于过滤其他鼠标或触摸点的释放事件。 */
+  pointerId: number
+  /** 触发按钮,用于主动释放指针捕获。 */
+  target: HTMLElement
+  /** 云台操作所属的播放器窗口。 */
+  windowIndex: number
+  /** 开始请求;停止流程会等待它结束,避免两个请求在服务端乱序。 */
+  startRequest: Promise<boolean>
+}
+
 let player: HikvisionPlayerInstance | undefined
 let player: HikvisionPlayerInstance | undefined
 // 同一时刻只允许一个播放器初始化任务,避免快速操作时创建多个 JSPlugin 实例。
 // 同一时刻只允许一个播放器初始化任务,避免快速操作时创建多个 JSPlugin 实例。
 let playerInitPromise: Promise<HikvisionPlayerInstance> | undefined
 let playerInitPromise: Promise<HikvisionPlayerInstance> | undefined
@@ -94,6 +158,10 @@ let nextPreviewRequestId = 0
 // 获取地址、停止旧对讲和开启新对讲均为异步操作,用编号阻止关闭视口后的旧任务重新开启麦克风。
 // 获取地址、停止旧对讲和开启新对讲均为异步操作,用编号阻止关闭视口后的旧任务重新开启麦克风。
 let talkOperationId = 0
 let talkOperationId = 0
 let nextZoomOperationId = 0
 let nextZoomOperationId = 0
+// 页面同一时刻只允许一个方向处于按压状态,符合云台一次执行一个持续动作的语义。
+let activePtzPress: ActivePtzPress | undefined
+// 从按下开始直至停止请求完成前保持 true,防止用户快速连续按键产生交叉请求。
+let ptzControlBusy = false
 /** 每个窗口独立记录电子放大操作,避免异步开启完成后覆盖关闭或切流操作。 */
 /** 每个窗口独立记录电子放大操作,避免异步开启完成后覆盖关闭或切流操作。 */
 const zoomWindowOperations = new Map<number, number>()
 const zoomWindowOperations = new Map<number, number>()
 
 
@@ -278,6 +346,129 @@ function clearWindowZoomState(windowIndex: number) {
   setWindowZoomLoading(windowIndex, false)
   setWindowZoomLoading(windowIndex, false)
 }
 }
 
 
+/**
+ * 关闭指定窗口的云台弹层状态。
+ * 若方向按钮仍处于按压状态,会同时触发停止兜底,防止弹层消失后云台继续转动。
+ */
+function closeWindowPtzPopover(windowIndex: number) {
+  // 无论当前展示的是哪个弹层,都先尝试停止该窗口正在进行的云台动作。
+  void stopActivePtzControl(windowIndex)
+  // 旧 Popover 的延迟 hide 事件不能清掉另一个窗口刚打开的 Popover 状态。
+  if (ptzPopoverWindowIndex.value !== windowIndex) return
+  ptzPopoverVisible.value = false
+  ptzPopoverWindowIndex.value = undefined
+}
+
+/** 记录当前打开云台弹层的窗口,让该窗口的悬浮操作栏在鼠标移入弹层后仍然可见。 */
+function handleWindowPtzPopoverShow(windowIndex: number) {
+  ptzPopoverWindowIndex.value = windowIndex
+  ptzPopoverVisible.value = true
+}
+
+/** 响应 Element Plus Popover 的关闭事件,并执行云台停止兜底。 */
+function handleWindowPtzPopoverHide(windowIndex: number) {
+  closeWindowPtzPopover(windowIndex)
+}
+
+/** 按下方向按钮后发送开始命令,并捕获指针以确保在按钮外松开时仍能收到停止事件。 */
+function startWindowPtzControl(
+  event: PointerEvent,
+  windowIndex: number,
+  command: HikvisionPtzCommand
+) {
+  // 只响应主指针的鼠标左键/单指触控,并禁止尚未停止时开启第二个方向。
+  if (!event.isPrimary || event.button !== 0 || activePtzPress || ptzControlBusy) return
+
+  // 云台接口必须使用摄像机 indexCode;视口尚未绑定监控点时不发送请求。
+  const camera = previewWindowCameras.value[windowIndex]
+  if (!camera) return
+
+  // 阻止触屏上的滚动、选中文本等默认行为干扰持续按压。
+  event.preventDefault()
+  const target = event.currentTarget as HTMLElement
+  try {
+    // 捕获后,即使指针移出按钮,pointerup 仍会派发给该按钮,从而可靠发送停止命令。
+    target.setPointerCapture(event.pointerId)
+  } catch {
+    // 个别旧浏览器不支持指针捕获,仍可通过按钮上的 pointerup 完成停止。
+  }
+
+  // 保存按下瞬间的数据快照,避免播放窗口在异步请求期间切换到另一台摄像机。
+  const press: ActivePtzPress = {
+    cameraIndexCode: camera.indexCode,
+    command,
+    pointerId: event.pointerId,
+    target,
+    windowIndex,
+    startRequest: Promise.resolve(true)
+  }
+  activePtzPress = press
+  ptzControlBusy = true
+  // 先同步更新视觉状态,使按钮按下反馈不必等待网络请求返回。
+  activePtzCommand.value = command
+  activePtzWindowIndex.value = windowIndex
+  clearPreviewError()
+
+  // action=0 表示开始;开始动作携带固定速度,停止动作不需要重复传递速度。
+  press.startRequest = HikvisionApi.controlPtz({
+    cameraIndexCode: camera.indexCode,
+    action: 0,
+    command,
+    speed: PTZ_CONTROL_SPEED
+  })
+    .then(() => true)
+    .catch((error) => {
+      // 将异常转换为 false,确保松开流程等待该 Promise 时不会产生未处理的拒绝。
+      console.error('开启云台方向控制失败', error)
+      showPreviewError(error instanceof Error ? error.message : '开启云台方向控制失败')
+      return false
+    })
+}
+
+/** 松开、取消、关闭弹层或销毁页面时统一发送停止命令。 */
+async function stopActivePtzControl(windowIndex?: number, pointerId?: number) {
+  const press = activePtzPress
+  if (!press) return
+  // windowIndex 用于隔离不同视口,pointerId 用于忽略其他触摸点或鼠标产生的事件。
+  if (windowIndex !== undefined && press.windowIndex !== windowIndex) return
+  if (pointerId !== undefined && press.pointerId !== pointerId) return
+
+  // 在第一个 await 之前清空按压状态,防止 pointerup 与 lostpointercapture 重复发送停止请求。
+  activePtzPress = undefined
+  activePtzCommand.value = undefined
+  activePtzWindowIndex.value = undefined
+  try {
+    // 主动释放捕获;若浏览器已自动释放或元素已销毁,catch 会安全忽略。
+    if (press.target.hasPointerCapture(press.pointerId)) {
+      press.target.releasePointerCapture(press.pointerId)
+    }
+  } catch {
+    // 引用元素可能已随视口或弹层销毁,无需额外处理。
+  }
+
+  // 等待开始请求完成,保证同一摄像机的 action=1 不会先于 action=0 到达后端。
+  await press.startRequest
+  try {
+    // 即使开始请求的响应失败也发送停止命令:服务端可能已开始转动,只是响应在途中失败。
+    await HikvisionApi.controlPtz({
+      cameraIndexCode: press.cameraIndexCode,
+      action: 1,
+      command: press.command
+    })
+  } catch (error) {
+    console.error('停止云台方向控制失败', error)
+    showPreviewError(error instanceof Error ? error.message : '停止云台方向控制失败')
+  } finally {
+    // 停止请求结束后才允许下一次按压,避免不同方向请求交叉执行。
+    ptzControlBusy = false
+  }
+}
+
+/** 将按钮产生的释放类 PointerEvent 转交给统一停止流程。 */
+function stopWindowPtzControl(event: PointerEvent, windowIndex: number) {
+  void stopActivePtzControl(windowIndex, event.pointerId)
+}
+
 /** 清空一个窗口在前端维护的请求、监控点、播放和断流状态。 */
 /** 清空一个窗口在前端维护的请求、监控点、播放和断流状态。 */
 function clearWindowPreview(windowIndex: number) {
 function clearWindowPreview(windowIndex: number) {
   cancelWindowPreviewRequest(windowIndex)
   cancelWindowPreviewRequest(windowIndex)
@@ -288,9 +479,16 @@ function clearWindowPreview(windowIndex: number) {
   clearWindowSoundState(windowIndex)
   clearWindowSoundState(windowIndex)
   clearWindowTalkState(windowIndex)
   clearWindowTalkState(windowIndex)
   clearWindowZoomState(windowIndex)
   clearWindowZoomState(windowIndex)
+  // 视口状态清空会销毁对应 Popover,销毁前先通过统一入口停止可能存在的云台动作。
+  closeWindowPtzPopover(windowIndex)
 }
 }
 
 
 function clearWindowPreviewSelected() {
 function clearWindowPreviewSelected() {
+  // 全量清理不逐个经过 clearWindowPreview,因此在这里单独停止全局唯一的云台动作。
+  void stopActivePtzControl()
+  // 同步清除弹层归属,避免停止全部预览后工具栏仍保留云台高亮状态。
+  ptzPopoverVisible.value = false
+  ptzPopoverWindowIndex.value = undefined
   previewWindowIndexes.value = []
   previewWindowIndexes.value = []
   previewWindowCameras.value = {}
   previewWindowCameras.value = {}
   previewWindowStreamTypes.value = {}
   previewWindowStreamTypes.value = {}
@@ -568,6 +766,9 @@ async function playCameraPreview(camera: HikvisionCameraNode) {
     if (!previewUrl) throw new Error('预览地址为空')
     if (!previewUrl) throw new Error('预览地址为空')
 
 
     // keepDecoder 为 0 时切换监控点前先停止旧流,确保旧解码资源得到回收。
     // keepDecoder 为 0 时切换监控点前先停止旧流,确保旧解码资源得到回收。
+    // 替换摄像机前停止旧监控点的云台动作,停止请求必须使用旧 indexCode。
+    await stopActivePtzControl(windowIndex)
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
     await stopWindowTalk(windowIndex)
     await stopWindowTalk(windowIndex)
     if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
     if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
     await stopWindowZoom(windowIndex)
     await stopWindowZoom(windowIndex)
@@ -619,6 +820,9 @@ async function switchWindowStream(windowIndex: number) {
     if (!previewUrl) throw new Error('切换码流的预览地址为空')
     if (!previewUrl) throw new Error('切换码流的预览地址为空')
 
 
     // 主、子码流编码格式可能不同,先停止并回收旧解码器,再使用新地址播放最稳妥。
     // 主、子码流编码格式可能不同,先停止并回收旧解码器,再使用新地址播放最稳妥。
+    // 切换码流期间关闭持续云台动作,避免画面重载时失去方向按钮的释放事件。
+    await stopActivePtzControl(windowIndex)
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
     await stopWindowTalk(windowIndex)
     await stopWindowTalk(windowIndex)
     if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
     if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
     await stopWindowZoom(windowIndex)
     await stopWindowZoom(windowIndex)
@@ -970,6 +1174,8 @@ async function stopWindowPreview(windowIndex: number) {
   clearPreviewError()
   clearPreviewError()
 
 
   try {
   try {
+    // 优先停止云台,防止后续播放器停止耗时期间摄像机仍持续转动。
+    await stopActivePtzControl(windowIndex)
     await stopWindowTalk(windowIndex)
     await stopWindowTalk(windowIndex)
     await stopWindowZoom(windowIndex)
     await stopWindowZoom(windowIndex)
     await player?.JS_Stop(windowIndex)
     await player?.JS_Stop(windowIndex)
@@ -1003,6 +1209,11 @@ async function handleSplitChange(count: SplitCount, columns: HikvisionSplitColum
       (windowIndex) =>
       (windowIndex) =>
         talkWindowIndex.value === windowIndex || talkTargetWindowIndex.value === windowIndex
         talkWindowIndex.value === windowIndex || talkTargetWindowIndex.value === windowIndex
     )
     )
+    const hiddenPtzWindowIndex = hiddenWindowIndexes.find(
+      (windowIndex) => activePtzWindowIndex.value === windowIndex
+    )
+    // 缩小分屏会卸载隐藏视口的按钮,因此必须在清理视口前主动停止云台。
+    if (hiddenPtzWindowIndex !== undefined) await stopActivePtzControl(hiddenPtzWindowIndex)
     if (hiddenTalkWindowIndex !== undefined) await stopWindowTalk(hiddenTalkWindowIndex)
     if (hiddenTalkWindowIndex !== undefined) await stopWindowTalk(hiddenTalkWindowIndex)
     await Promise.all(hiddenWindowIndexes.map((windowIndex) => stopWindowZoom(windowIndex)))
     await Promise.all(hiddenWindowIndexes.map((windowIndex) => stopWindowZoom(windowIndex)))
     await Promise.all(
     await Promise.all(
@@ -1091,6 +1302,8 @@ async function stopAllPreviews() {
 
 
   stopAllLoading.value = true
   stopAllLoading.value = true
   try {
   try {
+    // 云台属于设备侧持续动作,应先于播放器媒体资源停止。
+    await stopActivePtzControl()
     const currentTalkWindowIndex = talkWindowIndex.value ?? talkTargetWindowIndex.value
     const currentTalkWindowIndex = talkWindowIndex.value ?? talkTargetWindowIndex.value
     if (currentTalkWindowIndex !== undefined) await stopWindowTalk(currentTalkWindowIndex)
     if (currentTalkWindowIndex !== undefined) await stopWindowTalk(currentTalkWindowIndex)
     const zoomWindowIndexesToStop = [
     const zoomWindowIndexesToStop = [
@@ -1169,6 +1382,8 @@ onMounted(() => {
 
 
 onBeforeUnmount(() => {
 onBeforeUnmount(() => {
   componentUnmounted = true
   componentUnmounted = true
+  // 页面离开时补发停止;即使开始请求仍在途中,停止流程也会等待并保持正确顺序。
+  void stopActivePtzControl()
   void destroyPlayer()
   void destroyPlayer()
 })
 })
 </script>
 </script>
@@ -1250,7 +1465,9 @@ onBeforeUnmount(() => {
             :class="{
             :class="{
               'is-active': activeWindowIndex === windowIndex,
               'is-active': activeWindowIndex === windowIndex,
               'is-hovered':
               'is-hovered':
-                hoveredWindowIndex === windowIndex || hoveredControlWindowIndex === windowIndex
+                hoveredWindowIndex === windowIndex ||
+                hoveredControlWindowIndex === windowIndex ||
+                (ptzPopoverVisible && ptzPopoverWindowIndex === windowIndex)
             }">
             }">
             <div v-if="previewWindowCameras[windowIndex]" class="window-hover-title">
             <div v-if="previewWindowCameras[windowIndex]" class="window-hover-title">
               <span class="window-title-main">
               <span class="window-title-main">
@@ -1371,6 +1588,78 @@ onBeforeUnmount(() => {
                     :class="isWindowTalking(windowIndex) ? 'i-lucide:mic' : 'i-lucide:mic-off'"></i>
                     :class="isWindowTalking(windowIndex) ? 'i-lucide:mic' : 'i-lucide:mic-off'"></i>
                 </button>
                 </button>
               </el-tooltip>
               </el-tooltip>
+              <!--
+                云台按钮与弹层是一一对应关系,直接使用 reference 插槽触发,无需 virtual-ref。
+                show/hide 事件只负责同步所属窗口和执行停止兜底,方向控制由内部按钮的指针事件完成。
+              -->
+              <el-popover
+                placement="top-end"
+                trigger="click"
+                :width="154"
+                :show-arrow="false"
+                popper-class="hikvision-ptz-popover"
+                @show="handleWindowPtzPopoverShow(windowIndex)"
+                @hide="handleWindowPtzPopoverHide(windowIndex)">
+                <!-- 操作栏最右侧的云台入口;弹层打开时 is-ptz-open 提供高亮反馈。 -->
+                <template #reference>
+                  <button
+                    class="window-action-button"
+                    :class="{
+                      'is-ptz-open': ptzPopoverVisible && ptzPopoverWindowIndex === windowIndex
+                    }"
+                    type="button"
+                    aria-label="打开云台控制"
+                    :aria-expanded="ptzPopoverVisible && ptzPopoverWindowIndex === windowIndex"
+                    @focus="hoveredControlWindowIndex = windowIndex"
+                    @blur="hoveredControlWindowIndex = undefined">
+                    <i class="i-lucide:move"></i>
+                  </button>
+                </template>
+                <!-- 弹层标题与八方向控制盘。中间图标只做视觉定位,不会发送接口请求。 -->
+                <div class="ptz-popover-content">
+                  <div class="ptz-control-title">
+                    <i class="i-lucide:move"></i>
+                    <span>云台控制</span>
+                  </div>
+                  <div
+                    class="ptz-control-panel"
+                    role="group"
+                    :aria-label="`视口 ${windowIndex + 1} 云台方向控制`">
+                    <!--
+                      pointerdown 开始转动;pointerup、pointercancel 和丢失指针捕获均停止。
+                      多个释放事件可能连续到达,脚本中的 activePtzPress 会保证只发送一次停止。
+                    -->
+                    <button
+                      v-for="direction in ptzDirections"
+                      :key="direction.label"
+                      type="button"
+                      class="ptz-direction-button"
+                      :class="{
+                        'is-active':
+                          activePtzWindowIndex === windowIndex &&
+                          activePtzCommand === direction.command
+                      }"
+                      :style="{ gridArea: direction.gridArea }"
+                      :title="direction.label"
+                      :aria-label="direction.label"
+                      :aria-pressed="
+                        activePtzWindowIndex === windowIndex &&
+                        activePtzCommand === direction.command
+                      "
+                      @pointerdown="startWindowPtzControl($event, windowIndex, direction.command)"
+                      @pointerup="stopWindowPtzControl($event, windowIndex)"
+                      @pointercancel="stopWindowPtzControl($event, windowIndex)"
+                      @lostpointercapture="stopWindowPtzControl($event, windowIndex)"
+                      @contextmenu.prevent
+                      @dragstart.prevent>
+                      <i :class="direction.icon"></i>
+                    </button>
+                    <span class="ptz-control-center" aria-hidden="true">
+                      <i class="i-lucide:scan"></i>
+                    </span>
+                  </div>
+                </div>
+              </el-popover>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
@@ -1857,6 +2146,13 @@ onBeforeUnmount(() => {
   border-color: rgb(147 197 253 / 78%);
   border-color: rgb(147 197 253 / 78%);
 }
 }
 
 
+/* 云台 Popover 打开时高亮入口,使用户能识别弹层当前归属的播放窗口。 */
+.window-action-button.is-ptz-open {
+  color: #fff;
+  background: rgb(14 165 233 / 72%);
+  border-color: rgb(125 211 252 / 82%);
+}
+
 .window-action-button:disabled {
 .window-action-button:disabled {
   cursor: not-allowed;
   cursor: not-allowed;
   opacity: 0.56;
   opacity: 0.56;
@@ -1985,6 +2281,115 @@ onBeforeUnmount(() => {
   cursor: not-allowed;
   cursor: not-allowed;
 }
 }
 
 
+/* 弹层采用“标题 + 控制盘”的纵向结构。 */
+.ptz-popover-content {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+/* 云台标题区与方向按钮通过分隔线形成清晰层级。 */
+.ptz-control-title {
+  display: flex;
+  padding-bottom: 9px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #f8fafc;
+  border-bottom: 1px solid rgb(148 163 184 / 20%);
+  align-items: center;
+  gap: 7px;
+}
+
+.ptz-control-title i {
+  font-size: 15px;
+  color: #7dd3fc;
+  transform: translateY(-1px);
+}
+
+/* 3×3 网格承载八个方向,中间位置由 .ptz-control-center 占用。 */
+.ptz-control-panel {
+  position: relative;
+  display: grid;
+  grid-template-columns: repeat(3, 36px);
+  grid-template-rows: repeat(3, 36px);
+  gap: 6px;
+  justify-content: center;
+}
+
+/*
+ * touch-action 和 user-select 用于避免长按时触发页面滚动或文本选择,
+ * 从而保证 pointerup/pointercancel 能稳定结束云台动作。
+ */
+.ptz-direction-button {
+  display: inline-flex;
+  width: 36px;
+  height: 36px;
+  padding: 0;
+  font-size: 17px;
+  color: #dbeafe;
+  cursor: pointer;
+  background: #273544;
+  border: 1px solid #46576a;
+  border-radius: 7px;
+  transition:
+    color 0.16s ease,
+    background-color 0.16s ease,
+    border-color 0.16s ease,
+    transform 0.16s ease;
+  user-select: none;
+  touch-action: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.ptz-direction-button:hover,
+.ptz-direction-button:focus-visible {
+  color: #fff;
+  background: #0ea5e9;
+  border-color: #7dd3fc;
+  outline: none;
+  transform: translateY(-1px);
+}
+
+/* 按压状态由接口操作状态驱动,不仅依赖浏览器瞬时的 :active。 */
+.ptz-direction-button.is-active {
+  color: #fff;
+  background: #0284c7;
+  border-color: #bae6fd;
+  transform: translateY(0);
+  box-shadow: 0 0 0 2px rgb(14 165 233 / 24%);
+}
+
+.ptz-direction-button:active {
+  background: #0284c7;
+  transform: translateY(0);
+}
+
+/* 中间仅作为云台中心的视觉标识,不可点击,也不会触发接口。 */
+.ptz-control-center {
+  display: inline-flex;
+  grid-area: 2 / 2;
+  width: 36px;
+  height: 36px;
+  font-size: 16px;
+  color: #7dd3fc;
+  background: rgb(14 165 233 / 10%);
+  border: 1px solid rgb(125 211 252 / 24%);
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+}
+
+/* Popover 会 Teleport 到 body,使用全局选择器覆盖 Element Plus 默认浅色外观。 */
+:global(.hikvision-ptz-popover.el-popper) {
+  min-width: 0;
+  padding: 12px;
+  background: #18212c;
+  border-color: #46576a;
+  box-shadow: 0 10px 28px rgb(2 6 23 / 46%);
+}
+
 .split-menu {
 .split-menu {
   display: flex;
   display: flex;
   gap: 6px;
   gap: 6px;