|
|
@@ -2,6 +2,7 @@
|
|
|
import {
|
|
|
HikvisionApi,
|
|
|
type HikvisionCameraNode,
|
|
|
+ type HikvisionPtzCommand,
|
|
|
type HikvisionStreamType,
|
|
|
type HikvisionTreeNode
|
|
|
} from '@/api/pms/hikvision'
|
|
|
@@ -50,6 +51,12 @@ const hoveredWindowIndex = ref<number>()
|
|
|
const hoveredControlWindowIndex = ref<number>()
|
|
|
const splitPopoverVisible = 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 stageFullscreen = ref(false)
|
|
|
const fullscreenControlLoading = ref(false)
|
|
|
@@ -59,6 +66,8 @@ type SplitCount = 1 | 4 | 9 | 16
|
|
|
// SDK 的分屏参数是边长:4 表示最大 4×4,2 表示默认 2×2。
|
|
|
const MAX_SPLIT_COLUMNS = 4
|
|
|
const DEFAULT_SPLIT_COLUMNS = 2
|
|
|
+// 云台速度允许 1~100;当前统一使用较平缓的 30,便于按压控制时精确调整画面方向。
|
|
|
+const PTZ_CONTROL_SPEED = 30
|
|
|
|
|
|
const splitOptions: Array<{ count: SplitCount; columns: HikvisionSplitColumns; icon: string }> = [
|
|
|
{ count: 1, columns: 1, icon: gridOneIcon },
|
|
|
@@ -67,6 +76,61 @@ const splitOptions: Array<{ count: SplitCount; columns: HikvisionSplitColumns; i
|
|
|
{ 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
|
|
|
// 同一时刻只允许一个播放器初始化任务,避免快速操作时创建多个 JSPlugin 实例。
|
|
|
let playerInitPromise: Promise<HikvisionPlayerInstance> | undefined
|
|
|
@@ -94,6 +158,10 @@ let nextPreviewRequestId = 0
|
|
|
// 获取地址、停止旧对讲和开启新对讲均为异步操作,用编号阻止关闭视口后的旧任务重新开启麦克风。
|
|
|
let talkOperationId = 0
|
|
|
let nextZoomOperationId = 0
|
|
|
+// 页面同一时刻只允许一个方向处于按压状态,符合云台一次执行一个持续动作的语义。
|
|
|
+let activePtzPress: ActivePtzPress | undefined
|
|
|
+// 从按下开始直至停止请求完成前保持 true,防止用户快速连续按键产生交叉请求。
|
|
|
+let ptzControlBusy = false
|
|
|
/** 每个窗口独立记录电子放大操作,避免异步开启完成后覆盖关闭或切流操作。 */
|
|
|
const zoomWindowOperations = new Map<number, number>()
|
|
|
|
|
|
@@ -278,6 +346,129 @@ function clearWindowZoomState(windowIndex: number) {
|
|
|
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) {
|
|
|
cancelWindowPreviewRequest(windowIndex)
|
|
|
@@ -288,9 +479,16 @@ function clearWindowPreview(windowIndex: number) {
|
|
|
clearWindowSoundState(windowIndex)
|
|
|
clearWindowTalkState(windowIndex)
|
|
|
clearWindowZoomState(windowIndex)
|
|
|
+ // 视口状态清空会销毁对应 Popover,销毁前先通过统一入口停止可能存在的云台动作。
|
|
|
+ closeWindowPtzPopover(windowIndex)
|
|
|
}
|
|
|
|
|
|
function clearWindowPreviewSelected() {
|
|
|
+ // 全量清理不逐个经过 clearWindowPreview,因此在这里单独停止全局唯一的云台动作。
|
|
|
+ void stopActivePtzControl()
|
|
|
+ // 同步清除弹层归属,避免停止全部预览后工具栏仍保留云台高亮状态。
|
|
|
+ ptzPopoverVisible.value = false
|
|
|
+ ptzPopoverWindowIndex.value = undefined
|
|
|
previewWindowIndexes.value = []
|
|
|
previewWindowCameras.value = {}
|
|
|
previewWindowStreamTypes.value = {}
|
|
|
@@ -568,6 +766,9 @@ async function playCameraPreview(camera: HikvisionCameraNode) {
|
|
|
if (!previewUrl) throw new Error('预览地址为空')
|
|
|
|
|
|
// keepDecoder 为 0 时切换监控点前先停止旧流,确保旧解码资源得到回收。
|
|
|
+ // 替换摄像机前停止旧监控点的云台动作,停止请求必须使用旧 indexCode。
|
|
|
+ await stopActivePtzControl(windowIndex)
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
await stopWindowTalk(windowIndex)
|
|
|
if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
await stopWindowZoom(windowIndex)
|
|
|
@@ -619,6 +820,9 @@ async function switchWindowStream(windowIndex: number) {
|
|
|
if (!previewUrl) throw new Error('切换码流的预览地址为空')
|
|
|
|
|
|
// 主、子码流编码格式可能不同,先停止并回收旧解码器,再使用新地址播放最稳妥。
|
|
|
+ // 切换码流期间关闭持续云台动作,避免画面重载时失去方向按钮的释放事件。
|
|
|
+ await stopActivePtzControl(windowIndex)
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
await stopWindowTalk(windowIndex)
|
|
|
if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
await stopWindowZoom(windowIndex)
|
|
|
@@ -970,6 +1174,8 @@ async function stopWindowPreview(windowIndex: number) {
|
|
|
clearPreviewError()
|
|
|
|
|
|
try {
|
|
|
+ // 优先停止云台,防止后续播放器停止耗时期间摄像机仍持续转动。
|
|
|
+ await stopActivePtzControl(windowIndex)
|
|
|
await stopWindowTalk(windowIndex)
|
|
|
await stopWindowZoom(windowIndex)
|
|
|
await player?.JS_Stop(windowIndex)
|
|
|
@@ -1003,6 +1209,11 @@ async function handleSplitChange(count: SplitCount, columns: HikvisionSplitColum
|
|
|
(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)
|
|
|
await Promise.all(hiddenWindowIndexes.map((windowIndex) => stopWindowZoom(windowIndex)))
|
|
|
await Promise.all(
|
|
|
@@ -1091,6 +1302,8 @@ async function stopAllPreviews() {
|
|
|
|
|
|
stopAllLoading.value = true
|
|
|
try {
|
|
|
+ // 云台属于设备侧持续动作,应先于播放器媒体资源停止。
|
|
|
+ await stopActivePtzControl()
|
|
|
const currentTalkWindowIndex = talkWindowIndex.value ?? talkTargetWindowIndex.value
|
|
|
if (currentTalkWindowIndex !== undefined) await stopWindowTalk(currentTalkWindowIndex)
|
|
|
const zoomWindowIndexesToStop = [
|
|
|
@@ -1169,6 +1382,8 @@ onMounted(() => {
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
componentUnmounted = true
|
|
|
+ // 页面离开时补发停止;即使开始请求仍在途中,停止流程也会等待并保持正确顺序。
|
|
|
+ void stopActivePtzControl()
|
|
|
void destroyPlayer()
|
|
|
})
|
|
|
</script>
|
|
|
@@ -1250,7 +1465,9 @@ onBeforeUnmount(() => {
|
|
|
:class="{
|
|
|
'is-active': activeWindowIndex === windowIndex,
|
|
|
'is-hovered':
|
|
|
- hoveredWindowIndex === windowIndex || hoveredControlWindowIndex === windowIndex
|
|
|
+ hoveredWindowIndex === windowIndex ||
|
|
|
+ hoveredControlWindowIndex === windowIndex ||
|
|
|
+ (ptzPopoverVisible && ptzPopoverWindowIndex === windowIndex)
|
|
|
}">
|
|
|
<div v-if="previewWindowCameras[windowIndex]" class="window-hover-title">
|
|
|
<span class="window-title-main">
|
|
|
@@ -1371,6 +1588,78 @@ onBeforeUnmount(() => {
|
|
|
:class="isWindowTalking(windowIndex) ? 'i-lucide:mic' : 'i-lucide:mic-off'"></i>
|
|
|
</button>
|
|
|
</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>
|
|
|
@@ -1857,6 +2146,13 @@ onBeforeUnmount(() => {
|
|
|
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 {
|
|
|
cursor: not-allowed;
|
|
|
opacity: 0.56;
|
|
|
@@ -1985,6 +2281,115 @@ onBeforeUnmount(() => {
|
|
|
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 {
|
|
|
display: flex;
|
|
|
gap: 6px;
|