|
@@ -0,0 +1,2632 @@
|
|
|
|
|
+<script lang="ts" setup>
|
|
|
|
|
+import {
|
|
|
|
|
+ HikvisionApi,
|
|
|
|
|
+ type HikvisionCameraNode,
|
|
|
|
|
+ type HikvisionPtzCommand,
|
|
|
|
|
+ type HikvisionStreamType,
|
|
|
|
|
+ type HikvisionTreeNode
|
|
|
|
|
+} from '@/api/pms/hikvision'
|
|
|
|
|
+import grid16Icon from '@/assets/icons/grid-16.svg'
|
|
|
|
|
+import gridFourIcon from '@/assets/icons/grid-four.svg'
|
|
|
|
|
+import gridNineIcon from '@/assets/icons/grid-nine.svg'
|
|
|
|
|
+import gridOneIcon from '@/assets/icons/grid-one.svg'
|
|
|
|
|
+import {
|
|
|
|
|
+ getHikvisionPlayerBasePath,
|
|
|
|
|
+ loadHikvisionPlayer,
|
|
|
|
|
+ type HikvisionPlayerInstance,
|
|
|
|
|
+ type HikvisionSplitColumns
|
|
|
|
|
+} from '@/utils/hikvisionH5Player'
|
|
|
|
|
+import { Loading, Location, Search, VideoCamera } from '@element-plus/icons-vue'
|
|
|
|
|
+import type { LoadFunction } from 'element-plus/es/components/tree/src/tree.type'
|
|
|
|
|
+import { IotDeviceApi } from '@/api/pms/device'
|
|
|
|
|
+import { IotOpeationFillApi } from '@/api/pms/iotopeationfill'
|
|
|
|
|
+
|
|
|
|
|
+defineOptions({ name: 'HikvisionVideoCenter' })
|
|
|
|
|
+
|
|
|
|
|
+const layoutTriggerRef = ref<HTMLElement>()
|
|
|
|
|
+const playerContainerRef = ref<HTMLElement>()
|
|
|
|
|
+// 原生全屏必须挂在同时包含播放器、悬浮层和工具栏的共同容器上。
|
|
|
|
|
+// 如果继续让 H5Player 仅全屏自己的挂载节点,兄弟节点形式的 Vue 悬浮层会被浏览器排除在全屏画面外。
|
|
|
|
|
+const videoStageRef = ref<HTMLElement>()
|
|
|
|
|
+const keyword = ref('')
|
|
|
|
|
+const treeData = ref<HikvisionTreeNode[]>([])
|
|
|
|
|
+const expandedKeys = ref<string[]>([])
|
|
|
|
|
+const treeLoading = ref(false)
|
|
|
|
|
+// 搜索结果是完整树;普通目录仍按需加载。每次请求后重建树,避免复用旧的懒加载缓存。
|
|
|
|
|
+const treeSearchMode = ref(false)
|
|
|
|
|
+const treeVersion = ref(0)
|
|
|
|
|
+let treeRequestId = 0
|
|
|
|
|
+const playerContainerId = 'hikvision-preview-player'
|
|
|
|
|
+const previewLoadingWindowIndexes = ref<number[]>([])
|
|
|
|
|
+const previewWindowIndexes = ref<number[]>([])
|
|
|
|
|
+const previewWindowCameras = ref<Record<number, HikvisionCameraNode>>({})
|
|
|
|
|
+const previewWindowStreamTypes = ref<Record<number, HikvisionStreamType>>({})
|
|
|
|
|
+const interruptedWindowIndexes = ref<number[]>([])
|
|
|
|
|
+const previewError = ref('')
|
|
|
|
|
+const captureAllLoading = ref(false)
|
|
|
|
|
+const stopAllLoading = ref(false)
|
|
|
|
|
+const soundWindowIndex = ref<number>()
|
|
|
|
|
+const soundControlLoading = ref(false)
|
|
|
|
|
+const talkWindowIndex = ref<number>()
|
|
|
|
|
+const talkTargetWindowIndex = ref<number>()
|
|
|
|
|
+const talkControlLoading = ref(false)
|
|
|
|
|
+const zoomWindowIndexes = ref<number[]>([])
|
|
|
|
|
+const zoomLoadingWindowIndexes = ref<number[]>([])
|
|
|
|
|
+const currentSplitCount = ref<SplitCount>(4)
|
|
|
|
|
+const activeWindowIndex = ref(0)
|
|
|
|
|
+const hoveredWindowIndex = ref<number>()
|
|
|
|
|
+// 鼠标进入页面叠加按钮后 SDK 会触发 windowEventOut,单独记录按钮悬浮以保持栏可见。
|
|
|
|
|
+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)
|
|
|
|
|
+// undefined 表示整体分屏全屏;有值时表示双击后只放大对应的播放器窗口。
|
|
|
|
|
+const singleFullscreenWindowIndex = ref<number>()
|
|
|
|
|
+const fullscreenControlLoading = ref(false)
|
|
|
|
|
+
|
|
|
|
|
+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 },
|
|
|
|
|
+ { count: 4, columns: 2, icon: gridFourIcon },
|
|
|
|
|
+ { count: 9, columns: 3, icon: gridNineIcon },
|
|
|
|
|
+ { 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
|
|
|
|
|
+let previewErrorTimer: ReturnType<typeof window.setTimeout> | undefined
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 记录每个窗口当前显示 loading 的请求编号。
|
|
|
|
|
+ * 关闭 loading 时会核对编号,防止旧请求把新请求的 loading 提前关闭。
|
|
|
|
|
+ */
|
|
|
|
|
+const previewLoadingWindowRequests = new Map<number, number>()
|
|
|
|
|
+
|
|
|
|
|
+/** 每个窗口最近一次预览请求的编号,用于让不同窗口并发、同一窗口只保留最新请求。 */
|
|
|
|
|
+const previewWindowRequests = new Map<number, number>()
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 已经进入 JS_Play 阶段的请求。
|
|
|
|
|
+ * 该状态用来区分“旧视频在请求新地址期间触发的回调”和“新视频自身触发的回调”。
|
|
|
|
|
+ */
|
|
|
|
|
+const previewPlayingWindowRequests = new Map<number, number>()
|
|
|
|
|
+/** 正在主动关闭的窗口,用于合并重复点击,并忽略 JS_Stop 触发的播放结束回调。 */
|
|
|
|
|
+const stoppingWindowIndexes = new Set<number>()
|
|
|
|
|
+/** 正在抓图的窗口,避免连续点击触发重复下载。 */
|
|
|
|
|
+const capturingWindowIndexes = new Set<number>()
|
|
|
|
|
+let nextPreviewRequestId = 0
|
|
|
|
|
+// 获取地址、停止旧对讲和开启新对讲均为异步操作,用编号阻止关闭视口后的旧任务重新开启麦克风。
|
|
|
|
|
+let talkOperationId = 0
|
|
|
|
|
+let nextZoomOperationId = 0
|
|
|
|
|
+// 页面同一时刻只允许一个方向处于按压状态,符合云台一次执行一个持续动作的语义。
|
|
|
|
|
+let activePtzPress: ActivePtzPress | undefined
|
|
|
|
|
+// 从按下开始直至停止请求完成前保持 true,防止用户快速连续按键产生交叉请求。
|
|
|
|
|
+let ptzControlBusy = false
|
|
|
|
|
+/** 每个窗口独立记录电子放大操作,避免异步开启完成后覆盖关闭或切流操作。 */
|
|
|
|
|
+const zoomWindowOperations = new Map<number, number>()
|
|
|
|
|
+
|
|
|
|
|
+// SDK 异步加载完成前页面可能已经卸载,使用该标记阻止卸载后继续创建播放器。
|
|
|
|
|
+let componentUnmounted = false
|
|
|
|
|
+
|
|
|
|
|
+// ResizeObserver 负责容器尺寸变化,requestAnimationFrame 用于合并同一帧内的重复 resize。
|
|
|
|
|
+let playerResizeObserver: ResizeObserver | undefined
|
|
|
|
|
+let playerResizeFrame: number | undefined
|
|
|
|
|
+let playerResizeInProgress = false
|
|
|
|
|
+let playerResizePending = false
|
|
|
|
|
+
|
|
|
|
|
+const currentSplitColumns = computed(() => Math.sqrt(currentSplitCount.value))
|
|
|
|
|
+const currentSplitRows = computed(() => currentSplitColumns.value)
|
|
|
|
|
+const currentSplitWindowIndexes = computed(() =>
|
|
|
|
|
+ Array.from({ length: currentSplitCount.value }, (_, index) => index)
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+const treeProps = {
|
|
|
|
|
+ children: 'children',
|
|
|
|
|
+ label: 'name',
|
|
|
|
|
+ isLeaf: (data: HikvisionTreeNode) => data.nodeType === 'camera'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearPreviewErrorTimer() {
|
|
|
|
|
+ if (previewErrorTimer === undefined) return
|
|
|
|
|
+ window.clearTimeout(previewErrorTimer)
|
|
|
|
|
+ previewErrorTimer = undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearPreviewError() {
|
|
|
|
|
+ clearPreviewErrorTimer()
|
|
|
|
|
+ previewError.value = ''
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function showPreviewError(message: string) {
|
|
|
|
|
+ if (componentUnmounted) return
|
|
|
|
|
+ clearPreviewErrorTimer()
|
|
|
|
|
+ previewError.value = message
|
|
|
|
|
+ previewErrorTimer = setTimeout(() => {
|
|
|
|
|
+ previewError.value = ''
|
|
|
|
|
+ previewErrorTimer = undefined
|
|
|
|
|
+ }, 2000)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 更新指定窗口的 loading,并确保旧请求不能覆盖新请求的状态。 */
|
|
|
|
|
+function setWindowPreviewLoading(windowIndex: number, loading: boolean, requestId?: number) {
|
|
|
|
|
+ if (loading) {
|
|
|
|
|
+ if (requestId !== undefined) previewLoadingWindowRequests.set(windowIndex, requestId)
|
|
|
|
|
+ if (!previewLoadingWindowIndexes.value.includes(windowIndex)) {
|
|
|
|
|
+ previewLoadingWindowIndexes.value = [...previewLoadingWindowIndexes.value, windowIndex]
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (requestId !== undefined && previewLoadingWindowRequests.get(windowIndex) !== requestId) return
|
|
|
|
|
+
|
|
|
|
|
+ previewLoadingWindowRequests.delete(windowIndex)
|
|
|
|
|
+ previewLoadingWindowIndexes.value = previewLoadingWindowIndexes.value.filter(
|
|
|
|
|
+ (loadingWindowIndex) => loadingWindowIndex !== windowIndex
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearWindowPreviewLoading() {
|
|
|
|
|
+ previewLoadingWindowRequests.clear()
|
|
|
|
|
+ previewLoadingWindowIndexes.value = []
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 为指定窗口创建新请求编号;只会替换该窗口,不影响其他窗口正在进行的预览。 */
|
|
|
|
|
+function beginWindowPreviewRequest(windowIndex: number) {
|
|
|
|
|
+ const requestId = ++nextPreviewRequestId
|
|
|
|
|
+ previewWindowRequests.set(windowIndex, requestId)
|
|
|
|
|
+ previewPlayingWindowRequests.delete(windowIndex)
|
|
|
|
|
+ setWindowPreviewLoading(windowIndex, true, requestId)
|
|
|
|
|
+ return requestId
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 判断异步结果是否仍属于该窗口的最新一次操作。 */
|
|
|
|
|
+function isCurrentWindowPreviewRequest(windowIndex: number, requestId: number) {
|
|
|
|
|
+ return previewWindowRequests.get(windowIndex) === requestId
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 取消指定窗口的待处理请求,同时移除该窗口的 loading。 */
|
|
|
|
|
+function cancelWindowPreviewRequest(windowIndex: number) {
|
|
|
|
|
+ previewWindowRequests.delete(windowIndex)
|
|
|
|
|
+ previewPlayingWindowRequests.delete(windowIndex)
|
|
|
|
|
+ setWindowPreviewLoading(windowIndex, false)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 停止全部操作时统一使所有尚未返回的异步结果失效。 */
|
|
|
|
|
+function cancelAllWindowPreviewRequests() {
|
|
|
|
|
+ previewWindowRequests.clear()
|
|
|
|
|
+ previewPlayingWindowRequests.clear()
|
|
|
|
|
+ clearWindowPreviewLoading()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setWindowPreviewSelected(windowIndex: number, selected: boolean) {
|
|
|
|
|
+ if (selected) {
|
|
|
|
|
+ if (!previewWindowIndexes.value.includes(windowIndex)) {
|
|
|
|
|
+ previewWindowIndexes.value = [...previewWindowIndexes.value, windowIndex]
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ previewWindowIndexes.value = previewWindowIndexes.value.filter(
|
|
|
|
|
+ (previewWindowIndex) => previewWindowIndex !== windowIndex
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setWindowPreviewCamera(windowIndex: number, camera?: HikvisionCameraNode) {
|
|
|
|
|
+ const nextWindowCameras = { ...previewWindowCameras.value }
|
|
|
|
|
+ if (camera) {
|
|
|
|
|
+ nextWindowCameras[windowIndex] = camera
|
|
|
|
|
+ } else {
|
|
|
|
|
+ delete nextWindowCameras[windowIndex]
|
|
|
|
|
+ }
|
|
|
|
|
+ previewWindowCameras.value = nextWindowCameras
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setWindowPreviewStreamType(windowIndex: number, streamType?: HikvisionStreamType) {
|
|
|
|
|
+ const nextWindowStreamTypes = { ...previewWindowStreamTypes.value }
|
|
|
|
|
+ if (streamType !== undefined) {
|
|
|
|
|
+ nextWindowStreamTypes[windowIndex] = streamType
|
|
|
|
|
+ } else {
|
|
|
|
|
+ delete nextWindowStreamTypes[windowIndex]
|
|
|
|
|
+ }
|
|
|
|
|
+ previewWindowStreamTypes.value = nextWindowStreamTypes
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setWindowInterrupted(windowIndex: number, interrupted: boolean) {
|
|
|
|
|
+ if (interrupted) {
|
|
|
|
|
+ if (!interruptedWindowIndexes.value.includes(windowIndex)) {
|
|
|
|
|
+ interruptedWindowIndexes.value = [...interruptedWindowIndexes.value, windowIndex]
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ interruptedWindowIndexes.value = interruptedWindowIndexes.value.filter(
|
|
|
|
|
+ (interruptedWindowIndex) => interruptedWindowIndex !== windowIndex
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearWindowSoundState(windowIndex: number) {
|
|
|
|
|
+ if (soundWindowIndex.value === windowIndex) soundWindowIndex.value = undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearWindowTalkState(windowIndex: number) {
|
|
|
|
|
+ if (talkWindowIndex.value === windowIndex) talkWindowIndex.value = undefined
|
|
|
|
|
+ if (talkTargetWindowIndex.value !== windowIndex) return
|
|
|
|
|
+ talkOperationId += 1
|
|
|
|
|
+ talkTargetWindowIndex.value = undefined
|
|
|
|
|
+ talkControlLoading.value = false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setWindowZoomEnabled(windowIndex: number, enabled: boolean) {
|
|
|
|
|
+ if (enabled) {
|
|
|
|
|
+ if (!zoomWindowIndexes.value.includes(windowIndex)) {
|
|
|
|
|
+ zoomWindowIndexes.value = [...zoomWindowIndexes.value, windowIndex]
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ zoomWindowIndexes.value = zoomWindowIndexes.value.filter(
|
|
|
|
|
+ (zoomWindowIndex) => zoomWindowIndex !== windowIndex
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setWindowZoomLoading(windowIndex: number, loading: boolean) {
|
|
|
|
|
+ if (loading) {
|
|
|
|
|
+ if (!zoomLoadingWindowIndexes.value.includes(windowIndex)) {
|
|
|
|
|
+ zoomLoadingWindowIndexes.value = [...zoomLoadingWindowIndexes.value, windowIndex]
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ zoomLoadingWindowIndexes.value = zoomLoadingWindowIndexes.value.filter(
|
|
|
|
|
+ (zoomWindowIndex) => zoomWindowIndex !== windowIndex
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearWindowZoomState(windowIndex: number) {
|
|
|
|
|
+ zoomWindowOperations.delete(windowIndex)
|
|
|
|
|
+ setWindowZoomEnabled(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) {
|
|
|
|
|
+ cancelWindowPreviewRequest(windowIndex)
|
|
|
|
|
+ setWindowPreviewSelected(windowIndex, false)
|
|
|
|
|
+ setWindowPreviewCamera(windowIndex)
|
|
|
|
|
+ setWindowPreviewStreamType(windowIndex)
|
|
|
|
|
+ setWindowInterrupted(windowIndex, false)
|
|
|
|
|
+ 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 = {}
|
|
|
|
|
+ interruptedWindowIndexes.value = []
|
|
|
|
|
+ soundWindowIndex.value = undefined
|
|
|
|
|
+ talkWindowIndex.value = undefined
|
|
|
|
|
+ talkTargetWindowIndex.value = undefined
|
|
|
|
|
+ talkControlLoading.value = false
|
|
|
|
|
+ zoomWindowOperations.clear()
|
|
|
|
|
+ zoomWindowIndexes.value = []
|
|
|
|
|
+ zoomLoadingWindowIndexes.value = []
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getWindowPreviewStatus(windowIndex: number) {
|
|
|
|
|
+ if (previewLoadingWindowIndexes.value.includes(windowIndex)) return '正在加载'
|
|
|
|
|
+ if (interruptedWindowIndexes.value.includes(windowIndex)) return '画面中断'
|
|
|
|
|
+ if (previewWindowIndexes.value.includes(windowIndex)) return '正在预览'
|
|
|
|
|
+ return '未预览'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getWindowPreviewTitle(windowIndex: number) {
|
|
|
|
|
+ return previewWindowCameras.value[windowIndex]?.name || '未选择监控'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getWindowStreamType(windowIndex: number): HikvisionStreamType {
|
|
|
|
|
+ return previewWindowStreamTypes.value[windowIndex] ?? 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getWindowStreamSwitchTitle(windowIndex: number) {
|
|
|
|
|
+ return getWindowStreamType(windowIndex) === 0 ? '切换为子码流' : '切换为主码流'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isWindowSoundEnabled(windowIndex: number) {
|
|
|
|
|
+ return soundWindowIndex.value === windowIndex
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getWindowSoundTitle(windowIndex: number) {
|
|
|
|
|
+ return isWindowSoundEnabled(windowIndex) ? '关闭声音' : '开启声音'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isWindowTalking(windowIndex: number) {
|
|
|
|
|
+ return talkWindowIndex.value === windowIndex
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getWindowTalkTitle(windowIndex: number) {
|
|
|
|
|
+ if (talkControlLoading.value && talkTargetWindowIndex.value === windowIndex) return '正在开启对讲'
|
|
|
|
|
+ if (talkControlLoading.value && talkWindowIndex.value === windowIndex) return '正在停止对讲'
|
|
|
|
|
+ return isWindowTalking(windowIndex) ? '停止对讲' : '开启对讲'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isWindowZoomEnabled(windowIndex: number) {
|
|
|
|
|
+ return zoomWindowIndexes.value.includes(windowIndex)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getWindowZoomTitle(windowIndex: number) {
|
|
|
|
|
+ if (zoomLoadingWindowIndexes.value.includes(windowIndex)) {
|
|
|
|
|
+ return isWindowZoomEnabled(windowIndex) ? '正在关闭电子放大' : '正在开启电子放大'
|
|
|
|
|
+ }
|
|
|
|
|
+ return isWindowZoomEnabled(windowIndex) ? '关闭电子放大' : '开启电子放大'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getTalkErrorMessage(errorCode: number) {
|
|
|
|
|
+ if (errorCode === 0x12f950000) return '麦克风采集失败,请检查 HTTPS 环境和麦克风权限'
|
|
|
|
|
+ if (errorCode === 0x12f950001) return '监控设备的对讲音频编码不受支持'
|
|
|
|
|
+ return `对讲异常(${errorCode})`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const globalSoundTitle = computed(() =>
|
|
|
|
|
+ soundWindowIndex.value === undefined ? '开启当前监控声音' : '关闭全部监控声音'
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+async function loadRootTree(searchKeyword = '', requestId = ++treeRequestId) {
|
|
|
|
|
+ treeLoading.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = searchKeyword
|
|
|
|
|
+ ? await HikvisionApi.searchTree(searchKeyword)
|
|
|
|
|
+ : await HikvisionApi.getTreeNodes('-1')
|
|
|
|
|
+ // 新搜索或清空会使旧请求失效,防止慢搜索覆盖最新目录。
|
|
|
|
|
+ if (componentUnmounted || requestId !== treeRequestId) return
|
|
|
|
|
+ treeSearchMode.value = Boolean(searchKeyword)
|
|
|
|
|
+ treeData.value = data
|
|
|
|
|
+ const root = treeData.value[0]
|
|
|
|
|
+ expandedKeys.value = !searchKeyword && root?.nodeType === 'region' ? [root.indexCode] : []
|
|
|
|
|
+ treeVersion.value += 1
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ if (componentUnmounted || requestId !== treeRequestId) return
|
|
|
|
|
+ treeData.value = []
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (!componentUnmounted && requestId === treeRequestId) treeLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const loadTreeNode: LoadFunction = async (node, resolve, reject) => {
|
|
|
|
|
+ if (node.level === 0) {
|
|
|
|
|
+ resolve(treeData.value)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = node.data as HikvisionTreeNode
|
|
|
|
|
+ if (data.nodeType !== 'region') {
|
|
|
|
|
+ resolve([])
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (data.children.length) {
|
|
|
|
|
+ resolve(data.children)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ resolve(await HikvisionApi.getTreeNodes(data.indexCode))
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ reject()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function initPlayer(): Promise<HikvisionPlayerInstance> {
|
|
|
|
|
+ if (player) return Promise.resolve(player)
|
|
|
|
|
+ if (playerInitPromise) return playerInitPromise
|
|
|
|
|
+
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+ playerInitPromise = (async () => {
|
|
|
|
|
+ // 初始化未完成前不写入全局 player,便于页面卸载或注册回调失败时完整回收半成品实例。
|
|
|
|
|
+ let initializingPlayer: HikvisionPlayerInstance | undefined
|
|
|
|
|
+ try {
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ const JSPlugin = await loadHikvisionPlayer()
|
|
|
|
|
+ if (componentUnmounted) throw new Error('页面已卸载,取消初始化播放器')
|
|
|
|
|
+
|
|
|
|
|
+ initializingPlayer = new JSPlugin({
|
|
|
|
|
+ szId: playerContainerId,
|
|
|
|
|
+ szBasePath: getHikvisionPlayerBasePath(),
|
|
|
|
|
+ iMaxSplit: MAX_SPLIT_COLUMNS,
|
|
|
|
|
+ iCurrentSplit: DEFAULT_SPLIT_COLUMNS,
|
|
|
|
|
+ // 指南建议高分辨率画面开启多线程解码;仅在页面具备跨源隔离时启用,
|
|
|
|
|
+ // 避免未配置 COOP/COEP 的部署环境错误使用 SharedArrayBuffer。
|
|
|
|
|
+ mseWorkerEnable: window.crossOriginIsolated,
|
|
|
|
|
+ // SDK 的双击全屏只包含其自身 DOM,无法带上页面叠加的标题和操作栏。
|
|
|
|
|
+ // 页面改为捕获播放器双击并全屏整个 video-stage,因此关闭 SDK 内置双击行为。
|
|
|
|
|
+ bSupporDoubleClickFull: false,
|
|
|
|
|
+ oStyle: { borderSelect: '#facc15' }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 开发指南要求创建实例后先注册事件,再调用 JS_Play 等播放接口。
|
|
|
|
|
+ await initializingPlayer.JS_SetWindowControlCallback({
|
|
|
|
|
+ windowEventSelect: (windowIndex) => {
|
|
|
|
|
+ activeWindowIndex.value = windowIndex
|
|
|
|
|
+ },
|
|
|
|
|
+ windowEventOver: (windowIndex) => {
|
|
|
|
|
+ // 使用 SDK 提供的窗口进入事件控制悬浮工具栏,无需自行计算鼠标坐标。
|
|
|
|
|
+ hoveredWindowIndex.value = windowIndex
|
|
|
|
|
+ },
|
|
|
|
|
+ windowEventOut: (windowIndex) => {
|
|
|
|
|
+ if (hoveredWindowIndex.value === windowIndex) hoveredWindowIndex.value = undefined
|
|
|
|
|
+ },
|
|
|
|
|
+ firstFrameDisplay: (windowIndex) => {
|
|
|
|
|
+ // JS_Play 成功只代表播放命令已接受;首帧回调才代表用户真正看到了画面。
|
|
|
|
|
+ const playingRequestId = previewPlayingWindowRequests.get(windowIndex)
|
|
|
|
|
+ if (
|
|
|
|
|
+ playingRequestId === undefined ||
|
|
|
|
|
+ !isCurrentWindowPreviewRequest(windowIndex, playingRequestId)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ setWindowInterrupted(windowIndex, false)
|
|
|
|
|
+ setWindowPreviewSelected(windowIndex, true)
|
|
|
|
|
+ cancelWindowPreviewRequest(windowIndex)
|
|
|
|
|
+ },
|
|
|
|
|
+ InterruptStream: (windowIndex, interruptTime) => {
|
|
|
|
|
+ // 获取新地址期间收到的回调可能属于旧流;新流尚未进入 JS_Play 时忽略该旧回调。
|
|
|
|
|
+ if (
|
|
|
|
|
+ previewWindowRequests.has(windowIndex) &&
|
|
|
|
|
+ !previewPlayingWindowRequests.has(windowIndex)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!previewWindowCameras.value[windowIndex]) return
|
|
|
|
|
+ cancelWindowPreviewRequest(windowIndex)
|
|
|
|
|
+ setWindowInterrupted(windowIndex, true)
|
|
|
|
|
+ clearWindowSoundState(windowIndex)
|
|
|
|
|
+ void stopWindowTalk(windowIndex)
|
|
|
|
|
+ void stopWindowZoom(windowIndex)
|
|
|
|
|
+ showPreviewError(`监控画面已中断(${interruptTime} 秒未收到码流)`)
|
|
|
|
|
+ },
|
|
|
|
|
+ StreamEnd: (windowIndex) => {
|
|
|
|
|
+ // 用户主动关闭时由 stopWindowPreview 统一清理,不显示“画面已结束”的异常提示。
|
|
|
|
|
+ if (stoppingWindowIndexes.has(windowIndex)) return
|
|
|
|
|
+ // 与断流回调相同,避免旧流结束事件清掉正在请求的新监控点。
|
|
|
|
|
+ if (
|
|
|
|
|
+ previewWindowRequests.has(windowIndex) &&
|
|
|
|
|
+ !previewPlayingWindowRequests.has(windowIndex)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!previewWindowCameras.value[windowIndex]) return
|
|
|
|
|
+ void stopWindowTalk(windowIndex)
|
|
|
|
|
+ void stopWindowZoom(windowIndex)
|
|
|
|
|
+ clearWindowPreview(windowIndex)
|
|
|
|
|
+ showPreviewError('监控画面已结束')
|
|
|
|
|
+ },
|
|
|
|
|
+ performanceLack: () => {
|
|
|
|
|
+ // SDK 已经完成性能判断,页面只负责给出短暂提示,不自行推断设备性能。
|
|
|
|
|
+ showPreviewError('当前设备性能不足,监控画面可能卡顿')
|
|
|
|
|
+ },
|
|
|
|
|
+ pluginErrorHandler: (windowIndex, errorCode, error) => {
|
|
|
|
|
+ console.error('H5player 播放器错误', errorCode, error)
|
|
|
|
|
+ // 新地址仍在请求时,窗口里的 SDK 错误属于即将替换的旧流,不应取消新请求。
|
|
|
|
|
+ if (
|
|
|
|
|
+ windowIndex >= 0 &&
|
|
|
|
|
+ previewWindowRequests.has(windowIndex) &&
|
|
|
|
|
+ !previewPlayingWindowRequests.has(windowIndex)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (windowIndex >= 0) {
|
|
|
|
|
+ void stopWindowTalk(windowIndex)
|
|
|
|
|
+ void stopWindowZoom(windowIndex)
|
|
|
|
|
+ clearWindowPreview(windowIndex)
|
|
|
|
|
+ }
|
|
|
|
|
+ showPreviewError(`播放器异常(${errorCode})`)
|
|
|
|
|
+ },
|
|
|
|
|
+ talkPluginErrorHandler: (errorCode, error) => {
|
|
|
|
|
+ console.error('H5player 对讲错误', errorCode, error)
|
|
|
|
|
+ talkOperationId += 1
|
|
|
|
|
+ talkWindowIndex.value = undefined
|
|
|
|
|
+ talkTargetWindowIndex.value = undefined
|
|
|
|
|
+ talkControlLoading.value = false
|
|
|
|
|
+ showPreviewError(getTalkErrorMessage(errorCode))
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ if (componentUnmounted) throw new Error('页面已卸载,取消初始化播放器')
|
|
|
|
|
+
|
|
|
|
|
+ // 回调注册成功且页面仍存在后,播放器实例才正式对其他操作可见。
|
|
|
|
|
+ player = initializingPlayer
|
|
|
|
|
+ initializingPlayer = undefined
|
|
|
|
|
+ schedulePlayerResize()
|
|
|
|
|
+ return player
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // 初始化失败也要销毁半成品实例,否则 Worker、WASM 或媒体资源可能残留。
|
|
|
|
|
+ await initializingPlayer?.JS_StopRealPlayAll().catch(() => undefined)
|
|
|
|
|
+ await Promise.resolve(initializingPlayer?.JS_Destroy()).catch(() => undefined)
|
|
|
|
|
+ if (!componentUnmounted) {
|
|
|
|
|
+ console.error('H5player 初始化失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '播放器初始化失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ throw error
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ playerInitPromise = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ })()
|
|
|
|
|
+
|
|
|
|
|
+ return playerInitPromise
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function playCameraPreview(camera: HikvisionCameraNode) {
|
|
|
|
|
+ if (stopAllLoading.value || splitControlLoading.value) {
|
|
|
|
|
+ showPreviewError('播放器正在切换状态,请稍后再选择监控点')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 点击监控点时固定目标窗口,之后用户切换选中窗口不会改变本次请求的播放位置。
|
|
|
|
|
+ const windowIndex = activeWindowIndex.value
|
|
|
|
|
+ const requestId = beginWindowPreviewRequest(windowIndex)
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // SDK 初始化和后端预览地址互不依赖,可以并行等待以缩短首次点播耗时。
|
|
|
|
|
+ const [currentPlayer, previewUrl] = await Promise.all([
|
|
|
|
|
+ initPlayer(),
|
|
|
|
|
+ HikvisionApi.getPreviewUrl(camera.indexCode)
|
|
|
|
|
+ ])
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ 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)
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ await currentPlayer.JS_Stop(windowIndex).catch(() => undefined)
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ clearWindowSoundState(windowIndex)
|
|
|
|
|
+ setWindowPreviewSelected(windowIndex, false)
|
|
|
|
|
+ setWindowInterrupted(windowIndex, false)
|
|
|
|
|
+ setWindowPreviewCamera(windowIndex, camera)
|
|
|
|
|
+ setWindowPreviewStreamType(windowIndex, 0)
|
|
|
|
|
+
|
|
|
|
|
+ // 在调用 JS_Play 前记录阶段,使首帧、断流和错误回调能够识别当前新流。
|
|
|
|
|
+ previewPlayingWindowRequests.set(windowIndex, requestId)
|
|
|
|
|
+ await currentPlayer.JS_Play(
|
|
|
|
|
+ previewUrl,
|
|
|
|
|
+ {
|
|
|
|
|
+ playURL: previewUrl,
|
|
|
|
|
+ mode: 0,
|
|
|
|
|
+ keepDecoder: 0
|
|
|
|
|
+ },
|
|
|
|
|
+ windowIndex
|
|
|
|
|
+ )
|
|
|
|
|
+ // 这里不关闭 loading;只有 firstFrameDisplay、错误或断流回调才能结束窗口 loading。
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ clearWindowPreview(windowIndex)
|
|
|
|
|
+ console.error('监控点预览失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '获取预览地址或播放失败')
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 获取另一种码流地址,并在原窗口停止旧流后重新播放。 */
|
|
|
|
|
+async function switchWindowStream(windowIndex: number) {
|
|
|
|
|
+ const camera = previewWindowCameras.value[windowIndex]
|
|
|
|
|
+ if (!camera) return
|
|
|
|
|
+
|
|
|
|
|
+ const nextStreamType: HikvisionStreamType = getWindowStreamType(windowIndex) === 0 ? 1 : 0
|
|
|
|
|
+ const requestId = beginWindowPreviewRequest(windowIndex)
|
|
|
|
|
+ let stopped = false
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const [currentPlayer, previewUrl] = await Promise.all([
|
|
|
|
|
+ initPlayer(),
|
|
|
|
|
+ HikvisionApi.getPreviewUrl(camera.indexCode, nextStreamType)
|
|
|
|
|
+ ])
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ if (!previewUrl) throw new Error('切换码流的预览地址为空')
|
|
|
|
|
+
|
|
|
|
|
+ // 主、子码流编码格式可能不同,先停止并回收旧解码器,再使用新地址播放最稳妥。
|
|
|
|
|
+ // 切换码流期间关闭持续云台动作,避免画面重载时失去方向按钮的释放事件。
|
|
|
|
|
+ await stopActivePtzControl(windowIndex)
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ await stopWindowTalk(windowIndex)
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ await stopWindowZoom(windowIndex)
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ await currentPlayer.JS_Stop(windowIndex)
|
|
|
|
|
+ stopped = true
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ clearWindowSoundState(windowIndex)
|
|
|
|
|
+
|
|
|
|
|
+ setWindowPreviewSelected(windowIndex, false)
|
|
|
|
|
+ setWindowInterrupted(windowIndex, false)
|
|
|
|
|
+ setWindowPreviewStreamType(windowIndex, nextStreamType)
|
|
|
|
|
+ previewPlayingWindowRequests.set(windowIndex, requestId)
|
|
|
|
|
+ await currentPlayer.JS_Play(
|
|
|
|
|
+ previewUrl,
|
|
|
|
|
+ {
|
|
|
|
|
+ playURL: previewUrl,
|
|
|
|
|
+ mode: 0,
|
|
|
|
|
+ keepDecoder: 0
|
|
|
|
|
+ },
|
|
|
|
|
+ windowIndex
|
|
|
|
|
+ )
|
|
|
|
|
+ // 与首次预览相同,等待 firstFrameDisplay 后再结束 loading。
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ if (stopped) {
|
|
|
|
|
+ clearWindowPreview(windowIndex)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ cancelWindowPreviewRequest(windowIndex)
|
|
|
|
|
+ }
|
|
|
|
|
+ console.error('切换监控码流失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '切换监控码流失败')
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function createCaptureFileName(camera: HikvisionCameraNode) {
|
|
|
|
|
+ const safeCameraName = camera.name.replace(/[\\/:*?"<>|]/g, '_').trim() || camera.indexCode
|
|
|
|
|
+ const now = new Date()
|
|
|
|
|
+ const pad = (value: number) => String(value).padStart(2, '0')
|
|
|
|
|
+ const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(
|
|
|
|
|
+ now.getHours()
|
|
|
|
|
+ )}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
|
|
|
+ return `${safeCameraName}_${timestamp}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 使用 SDK 抓取指定窗口当前帧,并直接下载为 JPEG 文件。 */
|
|
|
|
|
+async function captureWindowPicture(windowIndex: number, notify = true): Promise<boolean> {
|
|
|
|
|
+ const camera = previewWindowCameras.value[windowIndex]
|
|
|
|
|
+ const currentPlayer = player
|
|
|
|
|
+ if (!camera || !currentPlayer) return false
|
|
|
|
|
+ if (
|
|
|
|
|
+ previewLoadingWindowIndexes.value.includes(windowIndex) ||
|
|
|
|
|
+ !previewWindowIndexes.value.includes(windowIndex)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ if (notify) showPreviewError('请等待监控画面首帧显示后再抓图')
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ if (interruptedWindowIndexes.value.includes(windowIndex)) {
|
|
|
|
|
+ if (notify) showPreviewError('监控画面已中断,暂时无法抓图')
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ if (capturingWindowIndexes.has(windowIndex)) return false
|
|
|
|
|
+
|
|
|
|
|
+ capturingWindowIndexes.add(windowIndex)
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+ try {
|
|
|
|
|
+ await currentPlayer.JS_CapturePicture(windowIndex, createCaptureFileName(camera), 'JPEG')
|
|
|
|
|
+ if (notify) ElMessage.success('抓图已保存到本地')
|
|
|
|
|
+ return true
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('监控画面抓图失败', error)
|
|
|
|
|
+ if (notify) showPreviewError(error instanceof Error ? error.message : '监控画面抓图失败')
|
|
|
|
|
+ return false
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ capturingWindowIndexes.delete(windowIndex)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 对当前分屏内所有可抓图窗口逐一调用 SDK,并汇总本次下载结果。 */
|
|
|
|
|
+async function captureAllWindowPictures() {
|
|
|
|
|
+ if (captureAllLoading.value) return
|
|
|
|
|
+
|
|
|
|
|
+ const previewingWindowIndexes = currentSplitWindowIndexes.value.filter(
|
|
|
|
|
+ (windowIndex) =>
|
|
|
|
|
+ previewWindowCameras.value[windowIndex] && previewWindowIndexes.value.includes(windowIndex)
|
|
|
|
|
+ )
|
|
|
|
|
+ if (!previewingWindowIndexes.length) {
|
|
|
|
|
+ ElMessage.warning('当前没有可抓图的监控画面')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ captureAllLoading.value = true
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+ try {
|
|
|
|
|
+ const results = await Promise.all(
|
|
|
|
|
+ previewingWindowIndexes.map((windowIndex) => captureWindowPicture(windowIndex, false))
|
|
|
|
|
+ )
|
|
|
|
|
+ const successCount = results.filter(Boolean).length
|
|
|
|
|
+ const failedCount = results.length - successCount
|
|
|
|
|
+ if (!successCount) {
|
|
|
|
|
+ ElMessage.error('全部抓图失败,请确认监控画面已正常显示')
|
|
|
|
|
+ } else if (failedCount) {
|
|
|
|
|
+ ElMessage.warning(`已保存 ${successCount} 张,${failedCount} 个画面抓图失败`)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ElMessage.success(`已完成全部抓图,共保存 ${successCount} 张`)
|
|
|
|
|
+ }
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ captureAllLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 切换指定窗口的声音。SDK 同一实例只支持一路声音,开启新窗口时会自动关闭旧窗口。
|
|
|
|
|
+ */
|
|
|
|
|
+async function toggleWindowSound(windowIndex: number, notify = true): Promise<boolean> {
|
|
|
|
|
+ if (soundControlLoading.value) return false
|
|
|
|
|
+
|
|
|
|
|
+ const currentPlayer = player
|
|
|
|
|
+ const soundEnabled = isWindowSoundEnabled(windowIndex)
|
|
|
|
|
+ if (!currentPlayer) return false
|
|
|
|
|
+ if (
|
|
|
|
|
+ !soundEnabled &&
|
|
|
|
|
+ (previewLoadingWindowIndexes.value.includes(windowIndex) ||
|
|
|
|
|
+ !previewWindowIndexes.value.includes(windowIndex) ||
|
|
|
|
|
+ interruptedWindowIndexes.value.includes(windowIndex))
|
|
|
|
|
+ ) {
|
|
|
|
|
+ if (notify) showPreviewError('请等待监控画面正常显示后再开启声音')
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ soundControlLoading.value = true
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (soundEnabled) {
|
|
|
|
|
+ await currentPlayer.JS_CloseSound(windowIndex)
|
|
|
|
|
+ clearWindowSoundState(windowIndex)
|
|
|
|
|
+ if (notify) ElMessage.success('声音已关闭')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await currentPlayer.JS_OpenSound(windowIndex)
|
|
|
|
|
+ soundWindowIndex.value = windowIndex
|
|
|
|
|
+ if (notify) ElMessage.success('声音已开启')
|
|
|
|
|
+ }
|
|
|
|
|
+ return true
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('切换监控声音失败', error)
|
|
|
|
|
+ if (notify) showPreviewError(error instanceof Error ? error.message : '切换监控声音失败')
|
|
|
|
|
+ return false
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ soundControlLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 全局声音按钮:有声时全部静音,无声时开启当前选中或首个可播放窗口。 */
|
|
|
|
|
+async function toggleGlobalSound() {
|
|
|
|
|
+ if (soundControlLoading.value) return
|
|
|
|
|
+ if (soundWindowIndex.value !== undefined) {
|
|
|
|
|
+ await toggleWindowSound(soundWindowIndex.value)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const targetWindowIndex = currentSplitWindowIndexes.value.find(
|
|
|
|
|
+ (windowIndex) =>
|
|
|
|
|
+ windowIndex === activeWindowIndex.value &&
|
|
|
|
|
+ previewWindowIndexes.value.includes(windowIndex) &&
|
|
|
|
|
+ !previewLoadingWindowIndexes.value.includes(windowIndex) &&
|
|
|
|
|
+ !interruptedWindowIndexes.value.includes(windowIndex)
|
|
|
|
|
+ )
|
|
|
|
|
+ const fallbackWindowIndex = currentSplitWindowIndexes.value.find(
|
|
|
|
|
+ (windowIndex) =>
|
|
|
|
|
+ previewWindowIndexes.value.includes(windowIndex) &&
|
|
|
|
|
+ !previewLoadingWindowIndexes.value.includes(windowIndex) &&
|
|
|
|
|
+ !interruptedWindowIndexes.value.includes(windowIndex)
|
|
|
|
|
+ )
|
|
|
|
|
+ const windowIndex = targetWindowIndex ?? fallbackWindowIndex
|
|
|
|
|
+ if (windowIndex === undefined) {
|
|
|
|
|
+ ElMessage.warning('当前没有可开启声音的监控画面')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ await toggleWindowSound(windowIndex)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 停止指定窗口关联的对讲。对讲没有窗口参数,因此页面必须记录当前归属窗口并统一清理。
|
|
|
|
|
+ */
|
|
|
|
|
+async function stopWindowTalk(windowIndex: number, notify = false): Promise<boolean> {
|
|
|
|
|
+ const isPending = talkTargetWindowIndex.value === windowIndex
|
|
|
|
|
+ const isTalking = talkWindowIndex.value === windowIndex
|
|
|
|
|
+ if (!isPending && !isTalking) return true
|
|
|
|
|
+
|
|
|
|
|
+ const operationId = ++talkOperationId
|
|
|
|
|
+ talkTargetWindowIndex.value = undefined
|
|
|
|
|
+ talkControlLoading.value = isTalking
|
|
|
|
|
+ if (!isTalking) return true
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await player?.JS_StopTalk()
|
|
|
|
|
+ if (operationId !== talkOperationId) return false
|
|
|
|
|
+ talkWindowIndex.value = undefined
|
|
|
|
|
+ if (notify) ElMessage.success('对讲已停止')
|
|
|
|
|
+ return true
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('停止监控对讲失败', error)
|
|
|
|
|
+ if (notify) showPreviewError(error instanceof Error ? error.message : '停止对讲失败')
|
|
|
|
|
+ return false
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (operationId === talkOperationId) talkControlLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 获取对讲地址并开启麦克风;切换窗口前先停止实例中已有的一路对讲。 */
|
|
|
|
|
+async function toggleWindowTalk(windowIndex: number) {
|
|
|
|
|
+ if (talkControlLoading.value) return
|
|
|
|
|
+ if (isWindowTalking(windowIndex)) {
|
|
|
|
|
+ await stopWindowTalk(windowIndex, true)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const camera = previewWindowCameras.value[windowIndex]
|
|
|
|
|
+ const currentPlayer = player
|
|
|
|
|
+ if (!camera || !currentPlayer) return
|
|
|
|
|
+ if (!window.isSecureContext) {
|
|
|
|
|
+ showPreviewError('对讲功能仅支持 HTTPS 安全环境')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (
|
|
|
|
|
+ previewLoadingWindowIndexes.value.includes(windowIndex) ||
|
|
|
|
|
+ !previewWindowIndexes.value.includes(windowIndex) ||
|
|
|
|
|
+ interruptedWindowIndexes.value.includes(windowIndex)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ showPreviewError('请等待监控画面正常显示后再开启对讲')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const operationId = ++talkOperationId
|
|
|
|
|
+ talkTargetWindowIndex.value = windowIndex
|
|
|
|
|
+ talkControlLoading.value = true
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+ try {
|
|
|
|
|
+ const talkUrl = await HikvisionApi.getTalkUrl(camera.indexCode)
|
|
|
|
|
+ if (operationId !== talkOperationId) return
|
|
|
|
|
+ if (!talkUrl) throw new Error('对讲地址为空')
|
|
|
|
|
+
|
|
|
|
|
+ if (talkWindowIndex.value !== undefined) {
|
|
|
|
|
+ await currentPlayer.JS_StopTalk()
|
|
|
|
|
+ if (operationId !== talkOperationId) return
|
|
|
|
|
+ talkWindowIndex.value = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await currentPlayer.JS_StartTalk(talkUrl)
|
|
|
|
|
+ // 视口可能在 SDK 请求麦克风权限期间被关闭;若任务已失效,立即回收刚开启的对讲。
|
|
|
|
|
+ if (operationId !== talkOperationId) {
|
|
|
|
|
+ await currentPlayer.JS_StopTalk().catch(() => undefined)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ talkWindowIndex.value = windowIndex
|
|
|
|
|
+ ElMessage.success('对讲已开启')
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (operationId !== talkOperationId) return
|
|
|
|
|
+ console.error('开启监控对讲失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '开启对讲失败,请检查麦克风权限')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (operationId === talkOperationId) {
|
|
|
|
|
+ talkTargetWindowIndex.value = undefined
|
|
|
|
|
+ talkControlLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 关闭指定窗口的电子放大,并使尚未完成的旧开启任务失效。 */
|
|
|
|
|
+async function stopWindowZoom(windowIndex: number): Promise<boolean> {
|
|
|
|
|
+ const hasPendingOperation = zoomWindowOperations.has(windowIndex)
|
|
|
|
|
+ if (!hasPendingOperation && !isWindowZoomEnabled(windowIndex)) return true
|
|
|
|
|
+
|
|
|
|
|
+ const operationId = ++nextZoomOperationId
|
|
|
|
|
+ zoomWindowOperations.set(windowIndex, operationId)
|
|
|
|
|
+ setWindowZoomLoading(windowIndex, true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ await player?.JS_DisableZoom(windowIndex)
|
|
|
|
|
+ if (zoomWindowOperations.get(windowIndex) !== operationId) return false
|
|
|
|
|
+ setWindowZoomEnabled(windowIndex, false)
|
|
|
|
|
+ return true
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('关闭电子放大失败', error)
|
|
|
|
|
+ return false
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (zoomWindowOperations.get(windowIndex) === operationId) {
|
|
|
|
|
+ zoomWindowOperations.delete(windowIndex)
|
|
|
|
|
+ setWindowZoomLoading(windowIndex, false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 切换指定窗口的电子放大,窗口之间互不影响。 */
|
|
|
|
|
+async function toggleWindowZoom(windowIndex: number) {
|
|
|
|
|
+ if (zoomLoadingWindowIndexes.value.includes(windowIndex)) return
|
|
|
|
|
+
|
|
|
|
|
+ const currentPlayer = player
|
|
|
|
|
+ if (!currentPlayer) return
|
|
|
|
|
+ if (!window.isSecureContext) {
|
|
|
|
|
+ showPreviewError('电子放大仅支持 HTTPS 安全环境')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (
|
|
|
|
|
+ previewLoadingWindowIndexes.value.includes(windowIndex) ||
|
|
|
|
|
+ !previewWindowIndexes.value.includes(windowIndex) ||
|
|
|
|
|
+ interruptedWindowIndexes.value.includes(windowIndex)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ showPreviewError('请等待监控画面正常显示后再开启电子放大')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const enableZoom = !isWindowZoomEnabled(windowIndex)
|
|
|
|
|
+ const operationId = ++nextZoomOperationId
|
|
|
|
|
+ zoomWindowOperations.set(windowIndex, operationId)
|
|
|
|
|
+ setWindowZoomLoading(windowIndex, true)
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (enableZoom) {
|
|
|
|
|
+ await currentPlayer.JS_EnableZoom(windowIndex)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await currentPlayer.JS_DisableZoom(windowIndex)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (zoomWindowOperations.get(windowIndex) !== operationId) {
|
|
|
|
|
+ if (enableZoom) await currentPlayer.JS_DisableZoom(windowIndex).catch(() => undefined)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ setWindowZoomEnabled(windowIndex, enableZoom)
|
|
|
|
|
+ ElMessage.success(enableZoom ? '电子放大已开启,请在画面中框选区域' : '电子放大已关闭')
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (zoomWindowOperations.get(windowIndex) !== operationId) return
|
|
|
|
|
+ console.error('切换电子放大失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '切换电子放大失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (zoomWindowOperations.get(windowIndex) === operationId) {
|
|
|
|
|
+ zoomWindowOperations.delete(windowIndex)
|
|
|
|
|
+ setWindowZoomLoading(windowIndex, false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 按开发指南停止指定窗口的播放,并仅清理该窗口对应的前端状态。 */
|
|
|
|
|
+async function stopWindowPreview(windowIndex: number) {
|
|
|
|
|
+ if (stoppingWindowIndexes.has(windowIndex)) return
|
|
|
|
|
+
|
|
|
|
|
+ stoppingWindowIndexes.add(windowIndex)
|
|
|
|
|
+ // 先使该窗口尚未完成的异步预览失效,避免关闭后旧请求继续调用 JS_Play。
|
|
|
|
|
+ cancelWindowPreviewRequest(windowIndex)
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 优先停止云台,防止后续播放器停止耗时期间摄像机仍持续转动。
|
|
|
|
|
+ await stopActivePtzControl(windowIndex)
|
|
|
|
|
+ await stopWindowTalk(windowIndex)
|
|
|
|
|
+ await stopWindowZoom(windowIndex)
|
|
|
|
|
+ await player?.JS_Stop(windowIndex)
|
|
|
|
|
+ clearWindowPreview(windowIndex)
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('关闭监控画面失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '关闭监控画面失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ stoppingWindowIndexes.delete(windowIndex)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleSplitChange(count: SplitCount, columns: HikvisionSplitColumns) {
|
|
|
|
|
+ splitPopoverVisible.value = false
|
|
|
|
|
+ if (splitControlLoading.value || currentSplitCount.value === count) return
|
|
|
|
|
+ if (stopAllLoading.value) {
|
|
|
|
|
+ showPreviewError('正在关闭全部监控画面,请稍后切换分屏')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ splitControlLoading.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const currentPlayer = await initPlayer()
|
|
|
|
|
+
|
|
|
|
|
+ // 从大分屏切换到小分屏时,先计算将被隐藏的窗口,例如 9 分屏切到 4 分屏会清理 4~8。
|
|
|
|
|
+ const hiddenWindowIndexes = Array.from(
|
|
|
|
|
+ { length: Math.max(currentSplitCount.value - count, 0) },
|
|
|
|
|
+ (_, index) => count + index
|
|
|
|
|
+ )
|
|
|
|
|
+ const hiddenTalkWindowIndex = hiddenWindowIndexes.find(
|
|
|
|
|
+ (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(
|
|
|
|
|
+ hiddenWindowIndexes.map((windowIndex) =>
|
|
|
|
|
+ currentPlayer.JS_Stop(windowIndex).catch(() => undefined)
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ // SDK 流和页面状态必须同时清理,避免隐藏窗口继续占用连接及解码资源。
|
|
|
|
|
+ hiddenWindowIndexes.forEach(clearWindowPreview)
|
|
|
|
|
+ await currentPlayer.JS_ArrangeWindow(columns)
|
|
|
|
|
+ currentSplitCount.value = count
|
|
|
|
|
+ activeWindowIndex.value = 0
|
|
|
|
|
+
|
|
|
|
|
+ // 分屏后显式同步 SDK 的选中窗口,保证黄色边框和页面 activeWindowIndex 一致。
|
|
|
|
|
+ await currentPlayer.JS_SelectWnd(0).catch(() => undefined)
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ schedulePlayerResize()
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('切换监控分屏失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '切换分屏失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ splitControlLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleNodeClick(data: HikvisionTreeNode) {
|
|
|
|
|
+ if (data.nodeType !== 'camera') return
|
|
|
|
|
+
|
|
|
|
|
+ // 已知离线或不可用的监控点直接提示,不请求后端预览地址,也不影响当前正在播放的画面。
|
|
|
|
|
+ if (data.available === false || data.onlineStatus === false) {
|
|
|
|
|
+ showPreviewError(`${data.name} 不在线,无法预览`)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ void playCameraPreview(data)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function resizePlayer() {
|
|
|
|
|
+ playerResizeFrame = undefined
|
|
|
|
|
+ const currentPlayer = player
|
|
|
|
|
+ if (!currentPlayer) return
|
|
|
|
|
+
|
|
|
|
|
+ // ResizeObserver 在侧栏动画期间可能连续触发。SDK resize 尚未结束时只保留一次尾调用,
|
|
|
|
|
+ // 避免多个 WASM/Canvas 尺寸计算并发堆积。
|
|
|
|
|
+ if (playerResizeInProgress) {
|
|
|
|
|
+ playerResizePending = true
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const playerContainer = playerContainerRef.value
|
|
|
|
|
+ if (!playerContainer) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { clientWidth, clientHeight } = playerContainer
|
|
|
|
|
+ if (!clientWidth || !clientHeight) return
|
|
|
|
|
+ playerResizeInProgress = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ await currentPlayer.JS_Resize(clientWidth, clientHeight)
|
|
|
|
|
+ syncSingleWindowPlayerLayout()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // 容器变化和播放器销毁可能发生在同一帧,尺寸同步失败无需打断预览。
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ playerResizeInProgress = false
|
|
|
|
|
+ if (playerResizePending && !componentUnmounted) {
|
|
|
|
|
+ playerResizePending = false
|
|
|
|
|
+ schedulePlayerResize()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 合并同一帧内的尺寸变化,避免侧栏动画或布局变化时连续调用昂贵的 SDK resize。 */
|
|
|
|
|
+function schedulePlayerResize() {
|
|
|
|
|
+ if (playerResizeFrame !== undefined) cancelAnimationFrame(playerResizeFrame)
|
|
|
|
|
+ playerResizeFrame = requestAnimationFrame(resizePlayer)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function stopAllPreviews() {
|
|
|
|
|
+ if (stopAllLoading.value) return
|
|
|
|
|
+ if (splitControlLoading.value) {
|
|
|
|
|
+ showPreviewError('正在切换分屏,请稍后关闭全部画面')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 先让所有未完成的接口请求失效,防止停止完成后某个旧请求又调用 JS_Play。
|
|
|
|
|
+ cancelAllWindowPreviewRequests()
|
|
|
|
|
+ clearPreviewError()
|
|
|
|
|
+
|
|
|
|
|
+ stopAllLoading.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 云台属于设备侧持续动作,应先于播放器媒体资源停止。
|
|
|
|
|
+ await stopActivePtzControl()
|
|
|
|
|
+ const currentTalkWindowIndex = talkWindowIndex.value ?? talkTargetWindowIndex.value
|
|
|
|
|
+ if (currentTalkWindowIndex !== undefined) await stopWindowTalk(currentTalkWindowIndex)
|
|
|
|
|
+ const zoomWindowIndexesToStop = [
|
|
|
|
|
+ ...new Set([...zoomWindowIndexes.value, ...zoomWindowOperations.keys()])
|
|
|
|
|
+ ]
|
|
|
|
|
+ await Promise.all(zoomWindowIndexesToStop.map((windowIndex) => stopWindowZoom(windowIndex)))
|
|
|
|
|
+ await player?.JS_StopRealPlayAll()
|
|
|
|
|
+ clearWindowPreviewSelected()
|
|
|
|
|
+ activeWindowIndex.value = 0
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('关闭全部监控画面失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '关闭全部监控画面失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ stopAllLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * H5Player 的 JS_FullScreenSingle 会只全屏 SDK 内部节点,使 Vue 悬浮操作层再次被浏览器排除。
|
|
|
|
|
+ * 因此仍由 video-stage 承载原生全屏,只把被双击的 SDK 子窗口铺满播放器容器。
|
|
|
|
|
+ */
|
|
|
|
|
+function syncSingleWindowPlayerLayout() {
|
|
|
|
|
+ const targetWindowIndex = stageFullscreen.value ? singleFullscreenWindowIndex.value : undefined
|
|
|
|
|
+ const playerWindows = playerContainerRef.value?.querySelectorAll<HTMLElement>('.sub-wnd') ?? []
|
|
|
|
|
+
|
|
|
|
|
+ playerWindows.forEach((playerWindow, windowIndex) => {
|
|
|
|
|
+ playerWindow.classList.toggle('is-single-fullscreen-target', targetWindowIndex === windowIndex)
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handlePlayerDoubleClick() {
|
|
|
|
|
+ if (fullscreenControlLoading.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // H5Player 会先通过 windowEventSelect 回调同步当前选中窗口,双击只消费 SDK 给出的下标,
|
|
|
|
|
+ // 不根据坐标重复推算分屏位置,避免边框、缩放或 SDK 布局调整造成误判。
|
|
|
|
|
+ const windowIndex = activeWindowIndex.value
|
|
|
|
|
+ // 空视口没有可放大的监控画面,也不应改变当前全屏状态。
|
|
|
|
|
+ if (!previewWindowCameras.value[windowIndex]) return
|
|
|
|
|
+
|
|
|
|
|
+ const videoStage = videoStageRef.value
|
|
|
|
|
+ if (!videoStage) return
|
|
|
|
|
+
|
|
|
|
|
+ fullscreenControlLoading.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 已经处于单视口全屏时,再次双击等同于退出全屏。
|
|
|
|
|
+ if (
|
|
|
|
|
+ document.fullscreenElement === videoStage &&
|
|
|
|
|
+ singleFullscreenWindowIndex.value !== undefined
|
|
|
|
|
+ ) {
|
|
|
|
|
+ await document.exitFullscreen()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ activeWindowIndex.value = windowIndex
|
|
|
|
|
+ singleFullscreenWindowIndex.value = windowIndex
|
|
|
|
|
+
|
|
|
|
|
+ if (document.fullscreenElement === videoStage) {
|
|
|
|
|
+ // 从整体分屏全屏切换到单视口时无需再次请求 Fullscreen API,只更新内部布局。
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ syncSingleWindowPlayerLayout()
|
|
|
|
|
+ schedulePlayerResize()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await videoStage.requestFullscreen()
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ singleFullscreenWindowIndex.value = undefined
|
|
|
|
|
+ syncSingleWindowPlayerLayout()
|
|
|
|
|
+ console.error('切换单视口全屏失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '切换单视口全屏失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ fullscreenControlLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function toggleFullscreen() {
|
|
|
|
|
+ if (fullscreenControlLoading.value) return
|
|
|
|
|
+
|
|
|
|
|
+ const videoStage = videoStageRef.value
|
|
|
|
|
+ if (!videoStage) {
|
|
|
|
|
+ showPreviewError('视频区域尚未加载完成')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fullscreenControlLoading.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 浏览器全屏只展示目标元素及其后代。video-stage 同时包含 SDK 播放器、Vue 悬浮层和底部工具栏,
|
|
|
|
|
+ // 因而全屏后 windowEventOver/Out 仍能驱动原有悬浮状态,按钮也仍可接收指针事件。
|
|
|
|
|
+ if (document.fullscreenElement === videoStage) {
|
|
|
|
|
+ await document.exitFullscreen()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 工具栏的全屏按钮保持“整体分屏全屏”语义,只有双击视口才设置单窗口下标。
|
|
|
|
|
+ singleFullscreenWindowIndex.value = undefined
|
|
|
|
|
+ await videoStage.requestFullscreen()
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('切换监控全屏失败', error)
|
|
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '切换全屏失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ fullscreenControlLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleFullscreenChange() {
|
|
|
|
|
+ // Esc、浏览器菜单等都可能退出全屏,必须以 Fullscreen API 的真实状态同步按钮图标。
|
|
|
|
|
+ stageFullscreen.value = document.fullscreenElement === videoStageRef.value
|
|
|
|
|
+ if (!stageFullscreen.value) singleFullscreenWindowIndex.value = undefined
|
|
|
|
|
+ void nextTick(() => {
|
|
|
|
|
+ syncSingleWindowPlayerLayout()
|
|
|
|
|
+ schedulePlayerResize()
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function toggleSidebar() {
|
|
|
|
|
+ sidebarCollapsed.value = !sidebarCollapsed.value
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ schedulePlayerResize()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function destroyPlayer() {
|
|
|
|
|
+ // 卸载后不再接收异步请求结果、错误定时器或任何尺寸变化通知。
|
|
|
|
|
+ cancelAllWindowPreviewRequests()
|
|
|
|
|
+ clearPreviewErrorTimer()
|
|
|
|
|
+ window.removeEventListener('resize', schedulePlayerResize)
|
|
|
|
|
+ playerResizeObserver?.disconnect()
|
|
|
|
|
+ playerResizeObserver = undefined
|
|
|
|
|
+ if (playerResizeFrame !== undefined) cancelAnimationFrame(playerResizeFrame)
|
|
|
|
|
+ playerResizeFrame = undefined
|
|
|
|
|
+ playerResizePending = false
|
|
|
|
|
+ if (!player) return
|
|
|
|
|
+
|
|
|
|
|
+ const currentPlayer = player
|
|
|
|
|
+ const zoomWindowIndexesToStop = [
|
|
|
|
|
+ ...new Set([...zoomWindowIndexes.value, ...zoomWindowOperations.keys()])
|
|
|
|
|
+ ]
|
|
|
|
|
+ await Promise.all(zoomWindowIndexesToStop.map((windowIndex) => stopWindowZoom(windowIndex)))
|
|
|
|
|
+ player = undefined
|
|
|
|
|
+
|
|
|
|
|
+ // 多分屏场景必须停止全部实时流;只停止窗口 0 会遗留其他窗口连接。
|
|
|
|
|
+ await currentPlayer.JS_StopTalk().catch(() => undefined)
|
|
|
|
|
+ await currentPlayer.JS_StopRealPlayAll().catch(() => undefined)
|
|
|
|
|
+ await Promise.resolve(currentPlayer.JS_Destroy()).catch(() => undefined)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleTreeSearch(event: KeyboardEvent) {
|
|
|
|
|
+ // 中文输入法确认候选词时的回车不提交搜索。
|
|
|
|
|
+ if (event.isComposing || event.keyCode === 229) return
|
|
|
|
|
+ void loadRootTree(keyword.value.trim())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+watch(keyword, (value, previousValue) => {
|
|
|
|
|
+ // 输入文字不请求;点击清空或手动删空时立即恢复初始树。
|
|
|
|
|
+ if (!value.trim() && previousValue.trim()) void loadRootTree()
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ componentUnmounted = false
|
|
|
|
|
+ window.addEventListener('resize', schedulePlayerResize)
|
|
|
|
|
+ document.addEventListener('fullscreenchange', handleFullscreenChange)
|
|
|
|
|
+ if (playerContainerRef.value) {
|
|
|
|
|
+ // window.resize 无法覆盖侧栏折叠等局部布局变化,因此额外观察播放器容器本身。
|
|
|
|
|
+ playerResizeObserver = new ResizeObserver(schedulePlayerResize)
|
|
|
|
|
+ playerResizeObserver.observe(playerContainerRef.value)
|
|
|
|
|
+ }
|
|
|
|
|
+ void loadRootTree()
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+onBeforeUnmount(() => {
|
|
|
|
|
+ componentUnmounted = true
|
|
|
|
|
+ treeRequestId += 1
|
|
|
|
|
+ document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
|
|
|
+ // 页面离开时补发停止;即使开始请求仍在途中,停止流程也会等待并保持正确顺序。
|
|
|
|
|
+ void stopActivePtzControl()
|
|
|
|
|
+ void destroyPlayer()
|
|
|
|
|
+})
|
|
|
|
|
+</script>
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="hikvision-shell h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
|
|
|
|
|
+ <aside class="monitor-panel" :class="{ 'is-collapsed': sidebarCollapsed }">
|
|
|
|
|
+ <div class="panel-title">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div class="eyebrow">MONITOR DIRECTORY</div>
|
|
|
|
|
+ <h2>监控点目录</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <el-input
|
|
|
|
|
+ v-model="keyword"
|
|
|
|
|
+ :prefix-icon="Search"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ placeholder="输入监控点名称,回车搜索"
|
|
|
|
|
+ @keydown.enter="handleTreeSearch" />
|
|
|
|
|
+
|
|
|
|
|
+ <div v-loading="treeLoading" class="monitor-tree-wrap">
|
|
|
|
|
+ <el-tree
|
|
|
|
|
+ v-if="!treeLoading"
|
|
|
|
|
+ :key="treeVersion"
|
|
|
|
|
+ class="monitor-tree"
|
|
|
|
|
+ :data="treeData"
|
|
|
|
|
+ :props="treeProps"
|
|
|
|
|
+ :load="loadTreeNode"
|
|
|
|
|
+ :default-expanded-keys="expandedKeys"
|
|
|
|
|
+ node-key="indexCode"
|
|
|
|
|
+ :lazy="!treeSearchMode"
|
|
|
|
|
+ :default-expand-all="treeSearchMode"
|
|
|
|
|
+ highlight-current
|
|
|
|
|
+ :expand-on-click-node="false"
|
|
|
|
|
+ empty-text="暂无监控点"
|
|
|
|
|
+ @node-click="handleNodeClick">
|
|
|
|
|
+ <template #default="{ data }">
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="tree-node"
|
|
|
|
|
+ :class="{
|
|
|
|
|
+ 'is-camera': data.nodeType === 'camera',
|
|
|
|
|
+ 'is-unavailable': data.available === false
|
|
|
|
|
+ }">
|
|
|
|
|
+ <el-icon class="node-icon">
|
|
|
|
|
+ <Location v-if="data.nodeType === 'region'" />
|
|
|
|
|
+ <VideoCamera v-else />
|
|
|
|
|
+ </el-icon>
|
|
|
|
|
+ <span class="node-name" :title="data.name">{{ data.name }}</span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-if="data.nodeType === 'camera'"
|
|
|
|
|
+ class="status-dot"
|
|
|
|
|
+ :class="{
|
|
|
|
|
+ online: data.onlineStatus === true,
|
|
|
|
|
+ offline: data.onlineStatus === false
|
|
|
|
|
+ }"
|
|
|
|
|
+ :title="
|
|
|
|
|
+ data.onlineStatus === true
|
|
|
|
|
+ ? '在线'
|
|
|
|
|
+ : data.onlineStatus === false
|
|
|
|
|
+ ? '离线'
|
|
|
|
|
+ : '状态未知'
|
|
|
|
|
+ "></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-tree>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="panel-tip">搜索仅筛选当前已加载的节点</div>
|
|
|
|
|
+ </aside>
|
|
|
|
|
+
|
|
|
|
|
+ <main class="video-workspace">
|
|
|
|
|
+ <section
|
|
|
|
|
+ ref="videoStageRef"
|
|
|
|
|
+ class="video-stage"
|
|
|
|
|
+ :class="{
|
|
|
|
|
+ 'is-single-window-fullscreen':
|
|
|
|
|
+ stageFullscreen && singleFullscreenWindowIndex !== undefined
|
|
|
|
|
+ }">
|
|
|
|
|
+ <div
|
|
|
|
|
+ :id="playerContainerId"
|
|
|
|
|
+ ref="playerContainerRef"
|
|
|
|
|
+ class="player-container"
|
|
|
|
|
+ @dblclick.capture="handlePlayerDoubleClick"></div>
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="window-loading-layer"
|
|
|
|
|
+ :style="{
|
|
|
|
|
+ gridTemplateColumns: `repeat(${currentSplitColumns}, minmax(0, 1fr))`,
|
|
|
|
|
+ gridTemplateRows: `repeat(${currentSplitRows}, minmax(0, 1fr))`
|
|
|
|
|
+ }">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="windowIndex in currentSplitWindowIndexes"
|
|
|
|
|
+ :key="windowIndex"
|
|
|
|
|
+ class="window-loading-cell"
|
|
|
|
|
+ :class="{
|
|
|
|
|
+ 'is-active': activeWindowIndex === windowIndex,
|
|
|
|
|
+ 'is-single-fullscreen-target': singleFullscreenWindowIndex === windowIndex,
|
|
|
|
|
+ 'is-hovered':
|
|
|
|
|
+ hoveredWindowIndex === windowIndex ||
|
|
|
|
|
+ hoveredControlWindowIndex === windowIndex ||
|
|
|
|
|
+ (ptzPopoverVisible && ptzPopoverWindowIndex === windowIndex)
|
|
|
|
|
+ }">
|
|
|
|
|
+ <div v-if="previewWindowCameras[windowIndex]" class="window-hover-title">
|
|
|
|
|
+ <span class="window-title-main">
|
|
|
|
|
+ <i class="i-lucide:video size-4"></i>
|
|
|
|
|
+ <span>{{ getWindowPreviewTitle(windowIndex) }}</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="window-title-status">
|
|
|
|
|
+ {{ getWindowPreviewStatus(windowIndex) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="window-close-button"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ title="关闭预览"
|
|
|
|
|
+ aria-label="关闭预览"
|
|
|
|
|
+ @mouseenter="hoveredControlWindowIndex = windowIndex"
|
|
|
|
|
+ @mouseleave="hoveredControlWindowIndex = undefined"
|
|
|
|
|
+ @focus="hoveredControlWindowIndex = windowIndex"
|
|
|
|
|
+ @blur="hoveredControlWindowIndex = undefined"
|
|
|
|
|
+ @click.stop="stopWindowPreview(windowIndex)">
|
|
|
|
|
+ <i class="i-lucide:circle-x size-4"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-if="previewLoadingWindowIndexes.includes(windowIndex)"
|
|
|
|
|
+ class="window-loading-indicator">
|
|
|
|
|
+ <el-icon class="is-loading"><Loading /></el-icon>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-else-if="!previewWindowIndexes.includes(windowIndex)"
|
|
|
|
|
+ class="window-empty-indicator">
|
|
|
|
|
+ <el-icon><VideoCamera /></el-icon>
|
|
|
|
|
+ <span>暂无监控</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-if="previewWindowCameras[windowIndex]"
|
|
|
|
|
+ class="window-hover-actions"
|
|
|
|
|
+ @mouseenter="hoveredControlWindowIndex = windowIndex"
|
|
|
|
|
+ @mouseleave="hoveredControlWindowIndex = undefined">
|
|
|
|
|
+ <el-tooltip
|
|
|
|
|
+ :content="getWindowStreamSwitchTitle(windowIndex)"
|
|
|
|
|
+ placement="top"
|
|
|
|
|
+ :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="window-action-button"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ :aria-label="getWindowStreamSwitchTitle(windowIndex)"
|
|
|
|
|
+ @focus="hoveredControlWindowIndex = windowIndex"
|
|
|
|
|
+ @blur="hoveredControlWindowIndex = undefined"
|
|
|
|
|
+ @click.stop="switchWindowStream(windowIndex)">
|
|
|
|
|
+ <i class="i-lucide:refresh-cw"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ <el-tooltip content="抓取当前画面" placement="top" :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="window-action-button"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ aria-label="抓取当前画面"
|
|
|
|
|
+ @focus="hoveredControlWindowIndex = windowIndex"
|
|
|
|
|
+ @blur="hoveredControlWindowIndex = undefined"
|
|
|
|
|
+ @click.stop="captureWindowPicture(windowIndex)">
|
|
|
|
|
+ <i class="i-lucide:camera"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ <el-tooltip
|
|
|
|
|
+ :content="getWindowZoomTitle(windowIndex)"
|
|
|
|
|
+ placement="top"
|
|
|
|
|
+ :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="window-action-button"
|
|
|
|
|
+ :class="{ 'is-zooming': isWindowZoomEnabled(windowIndex) }"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ :aria-label="getWindowZoomTitle(windowIndex)"
|
|
|
|
|
+ :aria-pressed="isWindowZoomEnabled(windowIndex)"
|
|
|
|
|
+ :disabled="zoomLoadingWindowIndexes.includes(windowIndex)"
|
|
|
|
|
+ @focus="hoveredControlWindowIndex = windowIndex"
|
|
|
|
|
+ @blur="hoveredControlWindowIndex = undefined"
|
|
|
|
|
+ @click.stop="toggleWindowZoom(windowIndex)">
|
|
|
|
|
+ <i
|
|
|
|
|
+ v-if="zoomLoadingWindowIndexes.includes(windowIndex)"
|
|
|
|
|
+ class="i-lucide:loader-circle animate-spin"></i>
|
|
|
|
|
+ <i
|
|
|
|
|
+ v-else
|
|
|
|
|
+ :class="
|
|
|
|
|
+ isWindowZoomEnabled(windowIndex) ? 'i-lucide:zoom-out' : 'i-lucide:zoom-in'
|
|
|
|
|
+ "></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ <el-tooltip
|
|
|
|
|
+ :content="getWindowSoundTitle(windowIndex)"
|
|
|
|
|
+ placement="top"
|
|
|
|
|
+ :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="window-action-button"
|
|
|
|
|
+ :class="{ active: isWindowSoundEnabled(windowIndex) }"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ :aria-label="getWindowSoundTitle(windowIndex)"
|
|
|
|
|
+ :aria-pressed="isWindowSoundEnabled(windowIndex)"
|
|
|
|
|
+ :disabled="soundControlLoading"
|
|
|
|
|
+ @focus="hoveredControlWindowIndex = windowIndex"
|
|
|
|
|
+ @blur="hoveredControlWindowIndex = undefined"
|
|
|
|
|
+ @click.stop="toggleWindowSound(windowIndex)">
|
|
|
|
|
+ <i
|
|
|
|
|
+ :class="
|
|
|
|
|
+ isWindowSoundEnabled(windowIndex) ? 'i-lucide:volume-2' : 'i-lucide:volume-x'
|
|
|
|
|
+ "></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ <el-tooltip
|
|
|
|
|
+ :content="getWindowTalkTitle(windowIndex)"
|
|
|
|
|
+ placement="top"
|
|
|
|
|
+ :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="window-action-button"
|
|
|
|
|
+ :class="{ 'is-talking': isWindowTalking(windowIndex) }"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ :aria-label="getWindowTalkTitle(windowIndex)"
|
|
|
|
|
+ :aria-pressed="isWindowTalking(windowIndex)"
|
|
|
|
|
+ :disabled="talkControlLoading"
|
|
|
|
|
+ @focus="hoveredControlWindowIndex = windowIndex"
|
|
|
|
|
+ @blur="hoveredControlWindowIndex = undefined"
|
|
|
|
|
+ @click.stop="toggleWindowTalk(windowIndex)">
|
|
|
|
|
+ <i
|
|
|
|
|
+ v-if="
|
|
|
|
|
+ talkControlLoading &&
|
|
|
|
|
+ (talkTargetWindowIndex === windowIndex || talkWindowIndex === windowIndex)
|
|
|
|
|
+ "
|
|
|
|
|
+ class="i-lucide:loader-circle animate-spin"></i>
|
|
|
|
|
+ <i
|
|
|
|
|
+ v-else
|
|
|
|
|
+ :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"
|
|
|
|
|
+ :teleported="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 v-if="previewError" class="stage-placeholder is-error">
|
|
|
|
|
+ <el-icon><VideoCamera /></el-icon>
|
|
|
|
|
+ <p>{{ previewError }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="video-toolbar">
|
|
|
|
|
+ <el-tooltip
|
|
|
|
|
+ :content="sidebarCollapsed ? '展开监控点目录' : '收起监控点目录'"
|
|
|
|
|
+ placement="top"
|
|
|
|
|
+ :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="sidebar-toggle"
|
|
|
|
|
+ :aria-label="sidebarCollapsed ? '展开监控点目录' : '收起监控点目录'"
|
|
|
|
|
+ :aria-expanded="!sidebarCollapsed"
|
|
|
|
|
+ @click="toggleSidebar">
|
|
|
|
|
+ <i v-if="sidebarCollapsed" class="i-lucide:panel-left-open"></i>
|
|
|
|
|
+ <i v-else class="i-lucide:panel-left-close"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="toolbar-actions">
|
|
|
|
|
+ <el-tooltip content="关闭全部监控画面" placement="top" :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="stop-all-trigger"
|
|
|
|
|
+ aria-label="关闭全部监控画面"
|
|
|
|
|
+ :aria-busy="stopAllLoading"
|
|
|
|
|
+ :disabled="stopAllLoading"
|
|
|
|
|
+ @click="stopAllPreviews">
|
|
|
|
|
+ <i v-if="stopAllLoading" class="i-lucide:loader-circle animate-spin"></i>
|
|
|
|
|
+ <i v-else class="i-lucide:video-off"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+
|
|
|
|
|
+ <el-tooltip content="全部抓图" placement="top" :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="capture-all-trigger"
|
|
|
|
|
+ aria-label="全部抓图"
|
|
|
|
|
+ :aria-busy="captureAllLoading"
|
|
|
|
|
+ :disabled="captureAllLoading"
|
|
|
|
|
+ @click="captureAllWindowPictures">
|
|
|
|
|
+ <i v-if="captureAllLoading" class="i-lucide:loader-circle animate-spin"></i>
|
|
|
|
|
+ <i v-else class="i-lucide:images"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+
|
|
|
|
|
+ <el-tooltip :content="globalSoundTitle" placement="top" :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="sound-all-trigger"
|
|
|
|
|
+ :class="{ active: soundWindowIndex !== undefined }"
|
|
|
|
|
+ :aria-label="globalSoundTitle"
|
|
|
|
|
+ :aria-pressed="soundWindowIndex !== undefined"
|
|
|
|
|
+ :disabled="soundControlLoading"
|
|
|
|
|
+ @click="toggleGlobalSound">
|
|
|
|
|
+ <i
|
|
|
|
|
+ :class="
|
|
|
|
|
+ soundWindowIndex !== undefined ? 'i-lucide:volume-2' : 'i-lucide:volume-x'
|
|
|
|
|
+ "></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+
|
|
|
|
|
+ <el-tooltip
|
|
|
|
|
+ content="分屏布局"
|
|
|
|
|
+ placement="top"
|
|
|
|
|
+ :disabled="splitPopoverVisible"
|
|
|
|
|
+ :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ ref="layoutTriggerRef"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="layout-trigger"
|
|
|
|
|
+ :class="{ active: splitPopoverVisible }"
|
|
|
|
|
+ aria-label="选择分屏布局"
|
|
|
|
|
+ :aria-busy="splitControlLoading"
|
|
|
|
|
+ :aria-expanded="splitPopoverVisible"
|
|
|
|
|
+ :disabled="splitControlLoading">
|
|
|
|
|
+ <i v-if="splitControlLoading" class="i-lucide:loader-circle animate-spin"></i>
|
|
|
|
|
+ <i v-else class="i-lucide:layout-grid"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+
|
|
|
|
|
+ <el-popover
|
|
|
|
|
+ v-model:visible="splitPopoverVisible"
|
|
|
|
|
+ :virtual-ref="layoutTriggerRef"
|
|
|
|
|
+ placement="top-end"
|
|
|
|
|
+ trigger="click"
|
|
|
|
|
+ :width="176"
|
|
|
|
|
+ :show-arrow="false"
|
|
|
|
|
+ :teleported="false"
|
|
|
|
|
+ virtual-triggering
|
|
|
|
|
+ popper-class="hikvision-split-popover">
|
|
|
|
|
+ <div class="split-menu" role="group" aria-label="分屏布局">
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-for="option in splitOptions"
|
|
|
|
|
+ :key="option.count"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="split-menu-button"
|
|
|
|
|
+ :class="{ active: currentSplitCount === option.count }"
|
|
|
|
|
+ :title="`${option.count} 分屏`"
|
|
|
|
|
+ :aria-label="`切换为 ${option.count} 分屏`"
|
|
|
|
|
+ :aria-pressed="currentSplitCount === option.count"
|
|
|
|
|
+ :disabled="splitControlLoading"
|
|
|
|
|
+ @click="handleSplitChange(option.count, option.columns)">
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="split-menu-icon"
|
|
|
|
|
+ :style="{ '--split-icon': `url(${option.icon})` }"
|
|
|
|
|
+ aria-hidden="true"></span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-popover>
|
|
|
|
|
+
|
|
|
|
|
+ <el-tooltip
|
|
|
|
|
+ :content="stageFullscreen ? '退出全屏' : '全屏'"
|
|
|
|
|
+ placement="top"
|
|
|
|
|
+ :teleported="false">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="fullscreen-trigger"
|
|
|
|
|
+ :aria-label="stageFullscreen ? '退出全屏' : '全屏'"
|
|
|
|
|
+ :aria-pressed="stageFullscreen"
|
|
|
|
|
+ :aria-busy="fullscreenControlLoading"
|
|
|
|
|
+ :disabled="fullscreenControlLoading"
|
|
|
|
|
+ @click="toggleFullscreen">
|
|
|
|
|
+ <i v-if="fullscreenControlLoading" class="i-lucide:loader-circle animate-spin"></i>
|
|
|
|
|
+ <i v-else-if="stageFullscreen" class="i-lucide:minimize"></i>
|
|
|
|
|
+ <i v-else class="i-lucide:maximize"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </main>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="scss">
|
|
|
|
|
+@media (width <= 1200px) {
|
|
|
|
|
+ .monitor-panel {
|
|
|
|
|
+ --monitor-panel-width: 240px;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.hikvision-shell {
|
|
|
|
|
+ --navy: #0f2742;
|
|
|
|
|
+ --video-toolbar-height: 49px;
|
|
|
|
|
+
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ color: #1e293b;
|
|
|
|
|
+ background: #e8edf3;
|
|
|
|
|
+ border: 1px solid #cbd5e1;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.monitor-panel {
|
|
|
|
|
+ --monitor-panel-width: 280px;
|
|
|
|
|
+
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ width: var(--monitor-panel-width);
|
|
|
|
|
+ padding: 18px 14px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ background: #f8fafc;
|
|
|
|
|
+ border-right: 1px solid #cbd5e1;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ flex: 0 0 var(--monitor-panel-width);
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 14px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.monitor-panel.is-collapsed {
|
|
|
|
|
+ width: 0;
|
|
|
|
|
+ flex-basis: 0;
|
|
|
|
|
+ padding-right: 0;
|
|
|
|
|
+ padding-left: 0;
|
|
|
|
|
+ border-right: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.panel-title {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.panel-title h2 {
|
|
|
|
|
+ margin: 3px 0 0;
|
|
|
|
|
+ font-size: 20px;
|
|
|
|
|
+ color: var(--navy);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.eyebrow {
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ letter-spacing: 1.5px;
|
|
|
|
|
+ color: #64748b;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.monitor-tree-wrap {
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ margin-right: -14px;
|
|
|
|
|
+ overflow: auto;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.monitor-tree {
|
|
|
|
|
+ min-width: 100%;
|
|
|
|
|
+ padding-right: 26px;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+
|
|
|
|
|
+ :deep(.el-tree-node__content) {
|
|
|
|
|
+ height: 38px;
|
|
|
|
|
+ margin-bottom: 3px;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ :deep(.el-tree-node__content:hover) {
|
|
|
|
|
+ background: #e9eff6;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ :deep(.el-tree-node.is-current > .el-tree-node__content) {
|
|
|
|
|
+ color: #0f3d69;
|
|
|
|
|
+ background: #dce9f6;
|
|
|
|
|
+ box-shadow: inset 3px 0 #2563eb;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.tree-node {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 7px;
|
|
|
|
|
+ padding-right: 8px;
|
|
|
|
|
+ color: #334155;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.tree-node.is-camera .node-icon {
|
|
|
|
|
+ color: #2563eb;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.tree-node.is-unavailable {
|
|
|
|
|
+ color: #94a3b8;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.node-icon {
|
|
|
|
|
+ flex: none;
|
|
|
|
|
+ color: #d97706;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.node-name {
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.status-dot {
|
|
|
|
|
+ width: 7px;
|
|
|
|
|
+ height: 7px;
|
|
|
|
|
+ flex: none;
|
|
|
|
|
+ background: #94a3b8;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.status-dot.online {
|
|
|
|
|
+ background: #22c55e;
|
|
|
|
|
+ box-shadow: 0 0 0 3px rgb(34 197 94 / 12%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.status-dot.offline {
|
|
|
|
|
+ background: #ef4444;
|
|
|
|
|
+ box-shadow: 0 0 0 3px rgb(239 68 68 / 10%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.panel-tip {
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: #94a3b8;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-workspace {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ background: #0b1118;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-stage {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ background: #0b1118;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/*
|
|
|
|
|
+ * 全屏只展示监控画面和各视口自己的悬浮操作栏,不保留页面级底部工具栏。
|
|
|
|
|
+ * 将预留高度归零后,播放器和透明悬浮网格会同步补满工具栏释放出的 49px 空间。
|
|
|
|
|
+ */
|
|
|
|
|
+.video-stage:fullscreen {
|
|
|
|
|
+ --video-toolbar-height: 0px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-stage:fullscreen .video-toolbar {
|
|
|
|
|
+ display: none;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/*
|
|
|
|
|
+ * 双击视口时只放大目标窗口。SDK 窗口与 Vue 悬浮单元格使用相同的下标,
|
|
|
|
|
+ * 两层同时隐藏非目标项,保证画面、标题和操作按钮仍准确重叠。
|
|
|
|
|
+ */
|
|
|
|
|
+.video-stage.is-single-window-fullscreen .player-container :deep(.parent-wnd) {
|
|
|
|
|
+ position: relative !important;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-stage.is-single-window-fullscreen .player-container :deep(.sub-wnd) {
|
|
|
|
|
+ display: none !important;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-stage.is-single-window-fullscreen
|
|
|
|
|
+ .player-container
|
|
|
|
|
+ :deep(.sub-wnd.is-single-fullscreen-target) {
|
|
|
|
|
+ position: absolute !important;
|
|
|
|
|
+ display: block !important;
|
|
|
|
|
+ width: 100% !important;
|
|
|
|
|
+ height: 100% !important;
|
|
|
|
|
+ inset: 0 !important;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-stage.is-single-window-fullscreen .window-loading-layer {
|
|
|
|
|
+ grid-template-rows: minmax(0, 1fr) !important;
|
|
|
|
|
+ grid-template-columns: minmax(0, 1fr) !important;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-stage.is-single-window-fullscreen .window-loading-cell {
|
|
|
|
|
+ display: none;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-stage.is-single-window-fullscreen .window-loading-cell.is-single-fullscreen-target {
|
|
|
|
|
+ display: block;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.player-container {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: calc(100% - var(--video-toolbar-height));
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ background: #0b1118;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ inset: 0 0 var(--video-toolbar-height);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.player-container :deep(.parent-wnd) {
|
|
|
|
|
+ width: 100% !important;
|
|
|
|
|
+ height: 100% !important;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.player-container :deep(.sub-wnd) {
|
|
|
|
|
+ box-sizing: border-box !important;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-loading-layer {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ z-index: 2;
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ inset: 0 0 var(--video-toolbar-height);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-loading-cell {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-loading-cell.is-active::after {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ z-index: 6;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ border: 2px solid #facc15;
|
|
|
|
|
+ content: '';
|
|
|
|
|
+ box-shadow:
|
|
|
|
|
+ inset 0 0 0 1px rgb(250 204 21 / 62%),
|
|
|
|
|
+ 0 0 12px rgb(250 204 21 / 32%);
|
|
|
|
|
+ inset: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-hover-title,
|
|
|
|
|
+.window-hover-actions {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ z-index: 5;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ height: 40px;
|
|
|
|
|
+ padding: 0 14px;
|
|
|
|
|
+ color: #f8fafc;
|
|
|
|
|
+ text-shadow: 0 1px 2px rgb(0 0 0 / 72%);
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ opacity: 0;
|
|
|
|
|
+ visibility: hidden;
|
|
|
|
|
+ backdrop-filter: blur(3px) saturate(115%);
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ transition:
|
|
|
|
|
+ opacity 0.18s ease,
|
|
|
|
|
+ transform 0.18s ease,
|
|
|
|
|
+ visibility 0.18s ease;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-loading-cell.is-hovered .window-hover-title,
|
|
|
|
|
+.window-loading-cell.is-hovered .window-hover-actions {
|
|
|
|
|
+ opacity: 1;
|
|
|
|
|
+ visibility: visible;
|
|
|
|
|
+ transform: translateY(0);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-hover-title {
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ background: linear-gradient(180deg, rgb(2 6 23 / 88%), rgb(15 23 42 / 54%));
|
|
|
|
|
+ border-bottom: 1px solid rgb(255 255 255 / 10%);
|
|
|
|
|
+ transform: translateY(-4px);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-title-main,
|
|
|
|
|
+.window-title-status {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-title-main {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ letter-spacing: 0.01em;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-title-main span {
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-title-status {
|
|
|
|
|
+ flex: none;
|
|
|
|
|
+ margin-left: 14px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ color: #e2e8f0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-close-button {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ width: 28px;
|
|
|
|
|
+ height: 28px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ margin-left: 6px;
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ color: #e2e8f0;
|
|
|
|
|
+ pointer-events: auto;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ transform: translateY(1px);
|
|
|
|
|
+ transition:
|
|
|
|
|
+ color 0.16s ease,
|
|
|
|
|
+ background-color 0.16s ease;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-close-button:hover {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: rgb(239 68 68 / 78%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-hover-actions {
|
|
|
|
|
+ bottom: 0;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ pointer-events: auto;
|
|
|
|
|
+ background: linear-gradient(0deg, rgb(2 6 23 / 88%), rgb(15 23 42 / 48%));
|
|
|
|
|
+ border-top: 1px solid rgb(255 255 255 / 8%);
|
|
|
|
|
+ transform: translateY(4px);
|
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-action-button {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ width: 30px;
|
|
|
|
|
+ height: 30px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ color: #e2e8f0;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ background: rgb(255 255 255 / 8%);
|
|
|
|
|
+ border: 1px solid rgb(255 255 255 / 12%);
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ transform: translateY(-2px);
|
|
|
|
|
+ transition:
|
|
|
|
|
+ color 0.16s ease,
|
|
|
|
|
+ background-color 0.16s ease,
|
|
|
|
|
+ border-color 0.16s ease;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-action-button:hover {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: rgb(59 130 246 / 68%);
|
|
|
|
|
+ border-color: rgb(147 197 253 / 72%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-action-button.active {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: rgb(34 197 94 / 58%);
|
|
|
|
|
+ border-color: rgb(134 239 172 / 70%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-action-button.is-talking {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: rgb(239 68 68 / 68%);
|
|
|
|
|
+ border-color: rgb(252 165 165 / 78%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-action-button.is-zooming {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: rgb(59 130 246 / 68%);
|
|
|
|
|
+ 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;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-loading-indicator {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ z-index: 2;
|
|
|
|
|
+ inset: 0;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ font-size: 24px;
|
|
|
|
|
+ color: #60a5fa;
|
|
|
|
|
+ background: rgb(2 6 23 / 36%);
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-empty-indicator {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ z-index: 1;
|
|
|
|
|
+ inset: 0;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: rgb(148 163 184 / 82%);
|
|
|
|
|
+ background: rgb(2 6 23 / 18%);
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-empty-indicator .el-icon {
|
|
|
|
|
+ font-size: 22px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stage-placeholder {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ z-index: 3;
|
|
|
|
|
+ color: #64748b;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stage-placeholder.is-error {
|
|
|
|
|
+ padding: 24px;
|
|
|
|
|
+ background: rgb(255 255 255 / 92%);
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stage-placeholder.is-error .el-icon {
|
|
|
|
|
+ color: #ef4444;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stage-placeholder .el-icon {
|
|
|
|
|
+ font-size: 54px;
|
|
|
|
|
+ color: #cbd5e1;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stage-placeholder p {
|
|
|
|
|
+ margin: 12px 0 5px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: #334155;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-toolbar {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ bottom: 0;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ z-index: 4;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ height: 49px;
|
|
|
|
|
+ padding: 0 14px;
|
|
|
|
|
+ color: #cbd5e1;
|
|
|
|
|
+ background: #18212c;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.toolbar-actions {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.sidebar-toggle,
|
|
|
|
|
+.stop-all-trigger,
|
|
|
|
|
+.capture-all-trigger,
|
|
|
|
|
+.sound-all-trigger,
|
|
|
|
|
+.fullscreen-trigger,
|
|
|
|
|
+.layout-trigger {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ width: 34px;
|
|
|
|
|
+ height: 34px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ font-size: 22px;
|
|
|
|
|
+ color: #cbd5e1;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.sidebar-toggle:hover,
|
|
|
|
|
+.stop-all-trigger:hover:not(:disabled),
|
|
|
|
|
+.capture-all-trigger:hover:not(:disabled),
|
|
|
|
|
+.sound-all-trigger:hover:not(:disabled),
|
|
|
|
|
+.sound-all-trigger.active,
|
|
|
|
|
+.fullscreen-trigger:hover:not(:disabled),
|
|
|
|
|
+.fullscreen-trigger[aria-pressed='true'],
|
|
|
|
|
+.layout-trigger:hover:not(:disabled),
|
|
|
|
|
+.layout-trigger.active {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: #2b3746;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stop-all-trigger:disabled,
|
|
|
|
|
+.capture-all-trigger:disabled,
|
|
|
|
|
+.sound-all-trigger:disabled,
|
|
|
|
|
+.fullscreen-trigger:disabled,
|
|
|
|
|
+.layout-trigger:disabled,
|
|
|
|
|
+.split-menu-button:disabled {
|
|
|
|
|
+ color: #64748b;
|
|
|
|
|
+ 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;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.split-menu-button {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ width: 34px;
|
|
|
|
|
+ height: 34px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ color: #cbd5e1;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ border: 1px solid transparent;
|
|
|
|
|
+ border-radius: 3px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.split-menu-button:hover {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: #4a515b;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.split-menu-button.active {
|
|
|
|
|
+ color: #f43f5e;
|
|
|
|
|
+ background: rgb(244 63 94 / 12%);
|
|
|
|
|
+ border-color: rgb(244 63 94 / 52%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.split-menu-icon {
|
|
|
|
|
+ width: 24px;
|
|
|
|
|
+ height: 24px;
|
|
|
|
|
+ background: currentcolor;
|
|
|
|
|
+ mask: var(--split-icon) center / contain no-repeat;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+:global(.hikvision-split-popover.el-popper) {
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ padding: 7px;
|
|
|
|
|
+ background: #3b4149;
|
|
|
|
|
+ border-color: #545c66;
|
|
|
|
|
+ box-shadow: 0 8px 20px rgb(15 23 42 / 32%);
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|