|
|
@@ -0,0 +1,1211 @@
|
|
|
+<script lang="ts" setup>
|
|
|
+import { HikvisionApi, type HikvisionCameraNode, 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'
|
|
|
+
|
|
|
+defineOptions({ name: 'HikvisionVideoCenter' })
|
|
|
+
|
|
|
+const treeRef = ref()
|
|
|
+const layoutTriggerRef = ref<HTMLElement>()
|
|
|
+const playerContainerRef = ref<HTMLElement>()
|
|
|
+const keyword = ref('')
|
|
|
+const treeData = ref<HikvisionTreeNode[]>([])
|
|
|
+const expandedKeys = ref<string[]>([])
|
|
|
+const treeLoading = ref(false)
|
|
|
+const playerContainerId = 'hikvision-preview-player'
|
|
|
+const previewLoadingWindowIndexes = ref<number[]>([])
|
|
|
+const previewWindowIndexes = ref<number[]>([])
|
|
|
+const previewWindowCameras = ref<Record<number, HikvisionCameraNode>>({})
|
|
|
+const interruptedWindowIndexes = ref<number[]>([])
|
|
|
+const previewError = ref('')
|
|
|
+const currentSplitCount = ref<SplitCount>(4)
|
|
|
+const activeWindowIndex = ref(0)
|
|
|
+const hoveredWindowIndex = ref<number>()
|
|
|
+const splitPopoverVisible = ref(false)
|
|
|
+const sidebarCollapsed = ref(false)
|
|
|
+const stageFullscreen = 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
|
|
|
+
|
|
|
+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 }
|
|
|
+]
|
|
|
+
|
|
|
+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>()
|
|
|
+let nextPreviewRequestId = 0
|
|
|
+
|
|
|
+// SDK 异步加载完成前页面可能已经卸载,使用该标记阻止卸载后继续创建播放器。
|
|
|
+let componentUnmounted = false
|
|
|
+
|
|
|
+// ResizeObserver 负责容器尺寸变化,requestAnimationFrame 用于合并同一帧内的重复 resize。
|
|
|
+let playerResizeObserver: ResizeObserver | undefined
|
|
|
+let playerResizeFrame: number | undefined
|
|
|
+
|
|
|
+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) {
|
|
|
+ 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 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 clearWindowPreview(windowIndex: number) {
|
|
|
+ cancelWindowPreviewRequest(windowIndex)
|
|
|
+ setWindowPreviewSelected(windowIndex, false)
|
|
|
+ setWindowPreviewCamera(windowIndex)
|
|
|
+ setWindowInterrupted(windowIndex, false)
|
|
|
+}
|
|
|
+
|
|
|
+function clearWindowPreviewSelected() {
|
|
|
+ previewWindowIndexes.value = []
|
|
|
+ previewWindowCameras.value = {}
|
|
|
+ interruptedWindowIndexes.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 || '未选择监控'
|
|
|
+}
|
|
|
+
|
|
|
+async function loadRootTree() {
|
|
|
+ treeLoading.value = true
|
|
|
+ try {
|
|
|
+ treeData.value = await HikvisionApi.getTreeNodes('-1')
|
|
|
+ const root = treeData.value[0]
|
|
|
+ expandedKeys.value = root?.nodeType === 'region' ? [root.indexCode] : []
|
|
|
+ } catch {
|
|
|
+ treeData.value = []
|
|
|
+ } finally {
|
|
|
+ treeLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const loadTreeNode: LoadFunction = async (node, resolve, reject) => {
|
|
|
+ if (node.level === 0) {
|
|
|
+ resolve([])
|
|
|
+ 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 filterTreeNode(value: string, data: HikvisionTreeNode) {
|
|
|
+ if (!value.trim()) return true
|
|
|
+ return data.name.toLowerCase().includes(value.trim().toLowerCase())
|
|
|
+}
|
|
|
+
|
|
|
+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,
|
|
|
+ mseWorkerEnable: false,
|
|
|
+ bSupporDoubleClickFull: true,
|
|
|
+ oStyle: { borderSelect: '#facc15' }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 开发指南要求创建实例后先注册事件,再调用 JS_Play 等播放接口。
|
|
|
+ await Promise.resolve(
|
|
|
+ initializingPlayer.JS_SetWindowControlCallback({
|
|
|
+ windowEventSelect: (windowIndex) => {
|
|
|
+ activeWindowIndex.value = windowIndex
|
|
|
+ },
|
|
|
+ windowEventOver: (windowIndex) => {
|
|
|
+ // 使用 SDK 提供的窗口进入事件控制悬浮工具栏,无需自行计算鼠标坐标。
|
|
|
+ hoveredWindowIndex.value = windowIndex
|
|
|
+ },
|
|
|
+ windowEventOut: (windowIndex) => {
|
|
|
+ if (hoveredWindowIndex.value === windowIndex) hoveredWindowIndex.value = undefined
|
|
|
+ },
|
|
|
+ windowFullCcreenChange: (fullscreen) => {
|
|
|
+ // 双击也能触发全屏,因此以 SDK 回调作为页面全屏状态的最终来源。
|
|
|
+ stageFullscreen.value = fullscreen
|
|
|
+ schedulePlayerResize()
|
|
|
+ },
|
|
|
+ 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)
|
|
|
+ showPreviewError(`监控画面已中断(${interruptTime} 秒未收到码流)`)
|
|
|
+ },
|
|
|
+ StreamEnd: (windowIndex) => {
|
|
|
+ // 与断流回调相同,避免旧流结束事件清掉正在请求的新监控点。
|
|
|
+ if (
|
|
|
+ previewWindowRequests.has(windowIndex) &&
|
|
|
+ !previewPlayingWindowRequests.has(windowIndex)
|
|
|
+ ) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!previewWindowCameras.value[windowIndex]) return
|
|
|
+ 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) clearWindowPreview(windowIndex)
|
|
|
+ showPreviewError(`播放器异常(${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)
|
|
|
+ console.error('H5player 初始化失败', error)
|
|
|
+ if (!componentUnmounted) {
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '播放器初始化失败')
|
|
|
+ }
|
|
|
+ throw error
|
|
|
+ } finally {
|
|
|
+ playerInitPromise = undefined
|
|
|
+ }
|
|
|
+ })()
|
|
|
+
|
|
|
+ return playerInitPromise
|
|
|
+}
|
|
|
+
|
|
|
+async function playCameraPreview(camera: HikvisionCameraNode) {
|
|
|
+ // 点击监控点时固定目标窗口,之后用户切换选中窗口不会改变本次请求的播放位置。
|
|
|
+ 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 时切换监控点前先停止旧流,确保旧解码资源得到回收。
|
|
|
+ await currentPlayer.JS_Stop(windowIndex).catch(() => undefined)
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
+ setWindowPreviewSelected(windowIndex, false)
|
|
|
+ setWindowInterrupted(windowIndex, false)
|
|
|
+ setWindowPreviewCamera(windowIndex, camera)
|
|
|
+
|
|
|
+ // 在调用 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 handleSplitChange(count: SplitCount, columns: HikvisionSplitColumns) {
|
|
|
+ splitPopoverVisible.value = false
|
|
|
+ if (currentSplitCount.value === count) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ const currentPlayer = await initPlayer()
|
|
|
+
|
|
|
+ // 从大分屏切换到小分屏时,先计算将被隐藏的窗口,例如 9 分屏切到 4 分屏会清理 4~8。
|
|
|
+ const hiddenWindowIndexes = Array.from(
|
|
|
+ { length: Math.max(currentSplitCount.value - count, 0) },
|
|
|
+ (_, index) => count + index
|
|
|
+ )
|
|
|
+ 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 : '切换分屏失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleNodeClick(data: HikvisionTreeNode) {
|
|
|
+ if (data.nodeType !== 'camera') return
|
|
|
+
|
|
|
+ // 已知离线或不可用的监控点直接提示,不请求后端预览地址,也不影响当前正在播放的画面。
|
|
|
+ if (data.available === false || data.onlineStatus === false) {
|
|
|
+ showPreviewError(`${data.name} 不在线,无法预览`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ void playCameraPreview(data)
|
|
|
+}
|
|
|
+
|
|
|
+function resizePlayer() {
|
|
|
+ playerResizeFrame = undefined
|
|
|
+ const playerContainer = playerContainerRef.value
|
|
|
+ if (!playerContainer) {
|
|
|
+ player?.JS_Resize().catch(() => undefined)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const { clientWidth, clientHeight } = playerContainer
|
|
|
+ if (!clientWidth || !clientHeight) return
|
|
|
+ player?.JS_Resize(clientWidth, clientHeight).catch(() => undefined)
|
|
|
+}
|
|
|
+
|
|
|
+/** 合并同一帧内的尺寸变化,避免侧栏动画或布局变化时连续调用昂贵的 SDK resize。 */
|
|
|
+function schedulePlayerResize() {
|
|
|
+ if (playerResizeFrame !== undefined) cancelAnimationFrame(playerResizeFrame)
|
|
|
+ playerResizeFrame = requestAnimationFrame(resizePlayer)
|
|
|
+}
|
|
|
+
|
|
|
+async function stopAllPreviews() {
|
|
|
+ // 先让所有未完成的接口请求失效,防止停止完成后某个旧请求又调用 JS_Play。
|
|
|
+ cancelAllWindowPreviewRequests()
|
|
|
+ clearPreviewError()
|
|
|
+
|
|
|
+ try {
|
|
|
+ await player?.JS_StopRealPlayAll()
|
|
|
+ clearWindowPreviewSelected()
|
|
|
+ activeWindowIndex.value = 0
|
|
|
+ } catch (error) {
|
|
|
+ console.error('关闭全部监控画面失败', error)
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '关闭全部监控画面失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleFullscreen() {
|
|
|
+ try {
|
|
|
+ const currentPlayer = await initPlayer()
|
|
|
+ await currentPlayer.JS_FullScreenDisplay(!stageFullscreen.value)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('切换监控全屏失败', error)
|
|
|
+ showPreviewError(error instanceof Error ? error.message : '切换全屏失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+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
|
|
|
+ if (!player) return
|
|
|
+
|
|
|
+ const currentPlayer = player
|
|
|
+ player = undefined
|
|
|
+
|
|
|
+ // 多分屏场景必须停止全部实时流;只停止窗口 0 会遗留其他窗口连接。
|
|
|
+ await currentPlayer.JS_StopRealPlayAll().catch(() => undefined)
|
|
|
+ await Promise.resolve(currentPlayer.JS_Destroy()).catch(() => undefined)
|
|
|
+}
|
|
|
+
|
|
|
+watch(keyword, (value) => treeRef.value?.filter(value))
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ componentUnmounted = false
|
|
|
+ window.addEventListener('resize', schedulePlayerResize)
|
|
|
+ if (playerContainerRef.value) {
|
|
|
+ // window.resize 无法覆盖侧栏折叠等局部布局变化,因此额外观察播放器容器本身。
|
|
|
+ playerResizeObserver = new ResizeObserver(schedulePlayerResize)
|
|
|
+ playerResizeObserver.observe(playerContainerRef.value)
|
|
|
+ }
|
|
|
+ void loadRootTree()
|
|
|
+ void initPlayer().catch(() => undefined)
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ componentUnmounted = true
|
|
|
+ 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="搜索已加载监控点" />
|
|
|
+
|
|
|
+ <div v-loading="treeLoading" class="monitor-tree-wrap">
|
|
|
+ <el-tree
|
|
|
+ ref="treeRef"
|
|
|
+ class="monitor-tree"
|
|
|
+ :data="treeData"
|
|
|
+ :props="treeProps"
|
|
|
+ :load="loadTreeNode"
|
|
|
+ :default-expanded-keys="expandedKeys"
|
|
|
+ node-key="indexCode"
|
|
|
+ lazy
|
|
|
+ highlight-current
|
|
|
+ :expand-on-click-node="false"
|
|
|
+ :filter-node-method="filterTreeNode"
|
|
|
+ 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 class="video-stage">
|
|
|
+ <div :id="playerContainerId" ref="playerContainerRef" class="player-container"></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-hovered': hoveredWindowIndex === windowIndex
|
|
|
+ }">
|
|
|
+ <div class="window-hover-title">
|
|
|
+ <span class="window-title-main">
|
|
|
+ <i class="i-lucide:video"></i>
|
|
|
+ <span>{{ getWindowPreviewTitle(windowIndex) }}</span>
|
|
|
+ </span>
|
|
|
+ <span class="window-title-status">
|
|
|
+ {{ getWindowPreviewStatus(windowIndex) }}
|
|
|
+ <i class="i-lucide:circle-x"></i>
|
|
|
+ </span>
|
|
|
+ </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 class="window-hover-actions" aria-hidden="true">
|
|
|
+ <button type="button" tabindex="-1">
|
|
|
+ <i class="i-lucide:volume-2"></i>
|
|
|
+ </button>
|
|
|
+ <button type="button" tabindex="-1">
|
|
|
+ <i class="i-lucide:camera"></i>
|
|
|
+ </button>
|
|
|
+ <button type="button" tabindex="-1">
|
|
|
+ <i class="i-lucide:zoom-in"></i>
|
|
|
+ </button>
|
|
|
+ <button type="button" tabindex="-1">
|
|
|
+ <i class="i-lucide:user-round"></i>
|
|
|
+ </button>
|
|
|
+ <button type="button" tabindex="-1">
|
|
|
+ <i class="i-lucide:settings"></i>
|
|
|
+ </button>
|
|
|
+ </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">
|
|
|
+ <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">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="stop-all-trigger"
|
|
|
+ aria-label="关闭全部监控画面"
|
|
|
+ @click="stopAllPreviews">
|
|
|
+ <i class="i-lucide:video-off"></i>
|
|
|
+ </button>
|
|
|
+ </el-tooltip>
|
|
|
+
|
|
|
+ <el-tooltip content="分屏布局" placement="top" :disabled="splitPopoverVisible">
|
|
|
+ <button
|
|
|
+ ref="layoutTriggerRef"
|
|
|
+ type="button"
|
|
|
+ class="layout-trigger"
|
|
|
+ :class="{ active: splitPopoverVisible }"
|
|
|
+ aria-label="选择分屏布局"
|
|
|
+ :aria-expanded="splitPopoverVisible">
|
|
|
+ <i 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"
|
|
|
+ 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"
|
|
|
+ @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">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="fullscreen-trigger"
|
|
|
+ :aria-label="stageFullscreen ? '退出全屏' : '全屏'"
|
|
|
+ :aria-pressed="stageFullscreen"
|
|
|
+ @click="toggleFullscreen">
|
|
|
+ <i v-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">
|
|
|
+.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;
|
|
|
+}
|
|
|
+
|
|
|
+.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: 4;
|
|
|
+ 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: 30px;
|
|
|
+ padding: 0 10px;
|
|
|
+ color: #f8fafc;
|
|
|
+ pointer-events: none;
|
|
|
+ opacity: 0;
|
|
|
+ box-sizing: border-box;
|
|
|
+ align-items: center;
|
|
|
+ transition: opacity 0.16s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.window-loading-cell.is-hovered .window-hover-title,
|
|
|
+.window-loading-cell.is-hovered .window-hover-actions {
|
|
|
+ opacity: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.window-hover-title {
|
|
|
+ top: 0;
|
|
|
+ justify-content: space-between;
|
|
|
+ background: linear-gradient(180deg, rgb(15 23 42 / 86%), rgb(15 23 42 / 12%));
|
|
|
+}
|
|
|
+
|
|
|
+.window-title-main,
|
|
|
+.window-title-status {
|
|
|
+ display: inline-flex;
|
|
|
+ min-width: 0;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.window-title-main {
|
|
|
+ flex: 1;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.window-title-main span {
|
|
|
+ min-width: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.window-title-status {
|
|
|
+ flex: none;
|
|
|
+ margin-left: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.window-hover-actions {
|
|
|
+ bottom: 0;
|
|
|
+ justify-content: flex-end;
|
|
|
+ background: linear-gradient(0deg, rgb(15 23 42 / 88%), rgb(15 23 42 / 10%));
|
|
|
+}
|
|
|
+
|
|
|
+.window-hover-actions button {
|
|
|
+ display: inline-flex;
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ padding: 0;
|
|
|
+ font-size: 15px;
|
|
|
+ color: #e2e8f0;
|
|
|
+ background: transparent;
|
|
|
+ border: 0;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.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,
|
|
|
+.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,
|
|
|
+.fullscreen-trigger:hover,
|
|
|
+.fullscreen-trigger[aria-pressed='true'],
|
|
|
+.layout-trigger:hover,
|
|
|
+.layout-trigger.active {
|
|
|
+ color: #fff;
|
|
|
+ background: #2b3746;
|
|
|
+}
|
|
|
+
|
|
|
+.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%);
|
|
|
+}
|
|
|
+
|
|
|
+@media (width <= 1200px) {
|
|
|
+ .monitor-panel {
|
|
|
+ --monitor-panel-width: 240px;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|