diff --git a/src/components/rtsp-plugin-video/lib/videoPlayer.js b/src/components/rtsp-plugin-video/lib/videoPlayer.js index e433455..25b3e53 100644 --- a/src/components/rtsp-plugin-video/lib/videoPlayer.js +++ b/src/components/rtsp-plugin-video/lib/videoPlayer.js @@ -1465,11 +1465,15 @@ let dom = document.querySelector(`#${that.setting.videoId}`) // 创建回包可能晚于卸载;此时不能丢弃句柄,也不能覆盖同 ID 的新实例。 if (that.disposed || !dom || window.dhPlayerControl.videoList[that.setting.videoId] !== that) { - if (data.data && typeof data.data.hwnd === 'number') { - that.send({ - method: 'window.destroy', - info: { hwnd: data.data.hwnd, isRefresh: false } - }) + try { + that.dispose() + } finally { + if (data.data && typeof data.data.hwnd === 'number') { + that.send({ + method: 'window.destroy', + info: { hwnd: data.data.hwnd, isRefresh: false } + }) + } } return } diff --git a/src/components/rtsp-plugin-video/video-plugin.vue b/src/components/rtsp-plugin-video/video-plugin.vue index e30eeeb..885fc9a 100644 --- a/src/components/rtsp-plugin-video/video-plugin.vue +++ b/src/components/rtsp-plugin-video/video-plugin.vue @@ -49,6 +49,13 @@ const initResolversByType = new Map() const readyTimers = new Set() const closingPlayers = new Map() let pendingClose = Promise.resolve() +let cancelPlaybackWait +let playbackCancellation = new Promise((resolve) => { cancelPlaybackWait = resolve }) + +function cancelWaitingPlayback() { + cancelPlaybackWait(null) + playbackCancellation = new Promise((resolve) => { cancelPlaybackWait = resolve }) +} let latestRealToken = 0 let latestPlaybackToken = 0 const loading = ref(false) @@ -465,6 +472,20 @@ function initVideoPlayer(type = props.windowType) { // 关闭视频窗口回调 closeWindowSuccess: ({ isAll, snum, channelList }) => { if (!isCurrent() || VIDEO_PLAYER !== player) return + // 原生工具栏手动关闭的通道也要从恢复列表移除,避免切回页面时重新开流。 + // 分页的程序化关闭可能仍在等待回包,此时不能覆盖排队中的新请求。 + if (!closingPlayers.has(player) && (isAll || Array.isArray(channelList))) { + const remaining = new Set((isAll ? [] : channelList).map((item) => `${item.channelId}|${item.snum}`)) + const keepOpen = (item) => remaining.has(`${item.channelId}|${item.snum}`) + if (type === 0 && lastRealRequest) { + lastRealRequest = lastRealRequest.filter(keepOpen) + lastRealSignature = "" + } + if (type !== 0 && lastPlaybackRequest) { + lastPlaybackRequest = lastPlaybackRequest.filter(keepOpen) + lastPlaybackSignature = "" + } + } console.log(TAG, "关闭视频窗口,isAll:", isAll, "snum:", snum, "channelList:", channelList) emitSetting() }, @@ -522,6 +543,8 @@ function initVideoPlayer(type = props.windowType) { async function startReal(channels) { if (!canUsePlayer() || !Array.isArray(channels)) return if (!channels.length) return closeVideo() + cancelWaitingPlayback() + const cancelled = playbackCancellation channels = clampByDivision(channels) const myToken = ++latestRealToken ++latestPlaybackToken @@ -529,8 +552,9 @@ async function startReal(channels) { realFallbackAttempted.clear() const showBtn = ["BTN_STREAM", "BTN_VOICE", "BTN_TALK", "BTN_RECORD", "BTN_PIC", "BTN_ENLARGE"] loading.value = true - const player = await initVideoPlayer(0) - await pendingClose + const player = await Promise.race([initVideoPlayer(0), cancelled]) + if (!player) return + await Promise.race([pendingClose, cancelled]) if (!player || !canUsePlayer() || !requestedVisible || document.hidden || myToken !== latestRealToken) return activatePlayer(0, player) const now = Date.now() @@ -557,13 +581,16 @@ async function startReal(channels) { async function startPlayback(items) { if (!canUsePlayer() || !Array.isArray(items)) return if (!items.length) return closeVideo() + cancelWaitingPlayback() + const cancelled = playbackCancellation items = clampByDivision(items) const myToken = ++latestPlaybackToken ++latestRealToken lastPlaybackRequest = items loading.value = true - const player = await initVideoPlayer(3) - await pendingClose + const player = await Promise.race([initVideoPlayer(3), cancelled]) + if (!player) return + await Promise.race([pendingClose, cancelled]) if (!player || !canUsePlayer() || !requestedVisible || document.hidden || myToken !== latestPlaybackToken) return activatePlayer(3, player) const now = Date.now() @@ -602,10 +629,12 @@ function updateVisible(visible) { async function handleVisibilityChange() { if (!canUsePlayer() || !requestedVisible) return if (document.hidden) { + cancelWaitingPlayback() ++latestRealToken ++latestPlaybackToken if (!VIDEO_PLAYER) return if (currentWindowType.value === 0 && Array.isArray(lastRealRequest) && lastRealRequest.length) { + lastRealSignature = "" stopOwnedPlayers() } if ((currentWindowType.value === 3 || currentWindowType.value === 7) && Array.isArray(lastPlaybackRequest)) { @@ -756,6 +785,7 @@ function stopOwnedPlayers() { } function closeVideo() { + cancelWaitingPlayback() ++latestRealToken ++latestPlaybackToken lastRealRequest = null @@ -769,6 +799,7 @@ function closeVideo() { } function destroy(preserveRequests = false) { + cancelWaitingPlayback() ++generation ++latestRealToken ++latestPlaybackToken @@ -882,7 +913,7 @@ onActivated(() => { isActivated = true if (!props.active || !requestedVisible) return if (currentWindowType.value === 0 && lastRealRequest?.length) startReal(lastRealRequest) - else if (lastPlaybackRequest?.length) startPlayback(lastPlaybackRequest) + else if ((currentWindowType.value === 3 || currentWindowType.value === 7) && lastPlaybackRequest?.length) startPlayback(lastPlaybackRequest) }) watch(() => props.active, (active) => { diff --git a/src/views/device/components/preview-video/preview-video-dialog.vue b/src/views/device/components/preview-video/preview-video-dialog.vue index d7c4050..363e50b 100644 --- a/src/views/device/components/preview-video/preview-video-dialog.vue +++ b/src/views/device/components/preview-video/preview-video-dialog.vue @@ -1,126 +1,116 @@ - - - - - 视频预览 - {{ - deviceName - }} - - - - - - - - 实时预览 - - {{ playingChannels.length }} 个通道 - · {{ division }} 画面 - - - - - - {{ - loading ? "正在加载通道" : "选择通道,开始预览" - }} - 支持单通道预览与多通道分屏 - - - - {{ playingSummary }} - - - - - - - - + + + + + 视频预览 + {{ deviceName }} + + + + + + + + 实时预览 + + {{ playingChannels.length }} 个通道 · {{ division }} 画面 + + + + + + {{ loading ? '正在加载通道' : '选择通道,开始预览' }} + 支持单通道预览与多通道分屏 + + + + {{ playingSummary }} + + + + + + + + diff --git a/src/views/device/components/replay-video/replay-video-dialog.vue b/src/views/device/components/replay-video/replay-video-dialog.vue index 0536f91..688278b 100644 --- a/src/views/device/components/replay-video/replay-video-dialog.vue +++ b/src/views/device/components/replay-video/replay-video-dialog.vue @@ -1,231 +1,184 @@ - - - - - 录像回放 - {{ - deviceName - }} - - - - - - - - - - - - - - - - 查询回放 - - - - 录像回放 - - {{ playingChannels.length }} 个通道 - · {{ division }} 画面 - - - - - - {{ - loading ? "正在加载通道" : "选择通道,查看录像" - }} - 选择录像时间,支持多通道分屏回放 - - - - {{ playingSummary }} - - - - - - - - + + + + + 录像回放 + {{ deviceName }} + + + + + + + + + + + + + + + + 查询回放 + + + + 录像回放 + + {{ playingChannels.length }} 个通道 · {{ division }} 画面 + + + + + + {{ loading ? '正在加载通道' : '选择通道,查看录像' }} + 选择录像时间,支持多通道分屏回放 + + + + {{ playingSummary }} + + + + + + + + diff --git a/src/views/device/components/useVideoDialog.ts b/src/views/device/components/useVideoDialog.ts index 046f1ad..a12f44a 100644 --- a/src/views/device/components/useVideoDialog.ts +++ b/src/views/device/components/useVideoDialog.ts @@ -1,248 +1,210 @@ -import { computed, nextTick, onBeforeUnmount, ref } from "vue"; -import * as DeviceManageApi from "/@/api/device/deviceManage"; -import * as DeviceChannelApi from "/@/api/device/deviceChannel"; -import { useMessage } from "/@/hooks/message"; +import { computed, nextTick, onBeforeUnmount, ref } from 'vue'; +import * as DeviceManageApi from '/@/api/device/deviceManage'; +import * as DeviceChannelApi from '/@/api/device/deviceChannel'; +import { useMessage } from '/@/hooks/message'; export interface VideoChannel { - channelCode: string; - channelName?: string; - channelSeq?: string | number; - deviceName?: string; + channelCode: string; + channelName?: string; + channelSeq?: string | number; + deviceName?: string; } -export type ChannelSelectionMode = "single" | "multiple"; +export type ChannelSelectionMode = 'single' | 'multiple'; interface VideoDialogOptions { - mode: "preview" | "replay"; - getPlayOptions: () => Record | null; + mode: 'preview' | 'replay'; + getPlayOptions: () => Record | null; } interface DialogPlayer { - startReal: (channels: Record[]) => Promise; - startPlayback: (channels: Record[]) => Promise; - closeVideo: () => Promise; - destroy?: () => void; - updateVisible: (visible: boolean) => void; + startReal: (channels: Record[]) => Promise; + startPlayback: (channels: Record[]) => Promise; + closeVideo: () => Promise; + setTotal?: (total: number) => void; + destroy?: () => void; + updateVisible: (visible: boolean) => void; } export function useVideoDialog(options: VideoDialogOptions) { - const dialogVisible = ref(false); - const loading = ref(false); - const loadError = ref(""); - const channels = ref([]); - const selectedCodes = ref([]); - const playingCodes = ref([]); - const selectionMode = ref("single"); - const playerRef = ref(); - const deviceName = ref(""); - const currentPage = ref(1); - const pageSize = 16; - const divisionOptions = [1, 4, 9, 16]; - let sourceType = ""; - let sourceCode = ""; - let session = 0; - let playRequest = 0; - let appliedOptions: Record = {}; + const dialogVisible = ref(false); + const loading = ref(false); + const loadError = ref(''); + const channels = ref([]); + const selectedCodes = ref([]); + const playingCodes = ref([]); + const selectionMode = ref('single'); + const playerRef = ref(); + const deviceName = ref(''); + const currentPage = ref(1); + const pageSize = 16; + const divisionOptions = [1, 4, 9, 16]; + let sourceType = ''; + let sourceCode = ''; + let session = 0; + let playRequest = 0; + let appliedOptions: Record = {}; - const playingChannels = computed(() => { - const codes = new Set(playingCodes.value); - return channels.value.filter((channel) => codes.has(channel.channelCode)); - }); - const pageChannels = computed(() => - playingChannels.value.slice( - (currentPage.value - 1) * pageSize, - currentPage.value * pageSize - ) - ); - const division = computed( - () => - divisionOptions.find((count) => count >= pageChannels.value.length) || - pageSize - ); - const playingSummary = computed(() => { - if (!playingChannels.value.length) return "尚未选择播放通道"; - if (playingChannels.value.length === 1) - return playingChannels.value[0].channelName || "未命名通道"; - const summary = `共 ${playingChannels.value.length} 个通道`; - return playingChannels.value.length > pageSize - ? `${summary} · 当前第 ${ - (currentPage.value - 1) * pageSize + 1 - }–${Math.min( - currentPage.value * pageSize, - playingChannels.value.length - )} 路` - : summary; - }); + const playingChannels = computed(() => { + const codes = new Set(playingCodes.value); + return channels.value.filter((channel) => codes.has(channel.channelCode)); + }); + const pageChannels = computed(() => playingChannels.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize)); + const displayedCodes = computed(() => pageChannels.value.map((channel) => channel.channelCode)); + const division = computed(() => divisionOptions.find((count) => count >= pageChannels.value.length) || pageSize); + const playingSummary = computed(() => { + if (!playingChannels.value.length) return '尚未选择播放通道'; + if (playingChannels.value.length === 1) return playingChannels.value[0].channelName || '未命名通道'; + const summary = `共 ${playingChannels.value.length} 个通道`; + return playingChannels.value.length > pageSize + ? `${summary} · 当前第 ${(currentPage.value - 1) * pageSize + 1}–${Math.min(currentPage.value * pageSize, playingChannels.value.length)} 路` + : summary; + }); - async function playPage() { - const request = ++playRequest; - const activeSession = session; - const list = pageChannels.value.map((channel, snum) => ({ - ...appliedOptions, - channelId: channel.channelCode, - channelName: channel.channelName, - snum, - })); - await nextTick(); - const player = playerRef.value; - if (!player || !dialogVisible.value || request !== playRequest) return; - try { - // 切换前清空旧窗口,避免取消选择的通道继续播放。 - await player.closeVideo(); - if ( - !dialogVisible.value || - activeSession !== session || - request !== playRequest || - player !== playerRef.value - ) - return; - if (options.mode === "preview") await player.startReal(list); - else await player.startPlayback(list); - } catch { - if (dialogVisible.value && request === playRequest) - useMessage().error("视频播放失败,请重试"); - } - } + async function playPage() { + const request = ++playRequest; + const activeSession = session; + const list = pageChannels.value.map((channel, snum) => ({ + ...appliedOptions, + channelId: channel.channelCode, + channelName: channel.channelName, + snum, + })); + await nextTick(); + const player = playerRef.value; + if (!player || !dialogVisible.value || request !== playRequest) return; + try { + // 切换前清空旧窗口,避免取消选择的通道继续播放。 + await player.closeVideo(); + if (!dialogVisible.value || activeSession !== session || request !== playRequest || player !== playerRef.value) return; + if (options.mode === 'preview') { + // 预览保留插件云台入口,分页在弹窗状态栏统一显示。 + player.setTotal?.(list.length); + await player.startReal(list); + } else await player.startPlayback(list); + } catch { + if (dialogVisible.value && request === playRequest) useMessage().error('视频播放失败,请重试'); + } + } - async function playSelected() { - if (loading.value || !selectedCodes.value.length) return; - const nextOptions = options.getPlayOptions(); - if (!nextOptions) return; - // 翻页沿用本次筛选快照,不受尚未提交的表单修改影响。 - appliedOptions = { ...nextOptions }; - playingCodes.value = [...selectedCodes.value]; - currentPage.value = 1; - await playPage(); - } + async function playSelected() { + if (loading.value || !selectedCodes.value.length) return; + const nextOptions = options.getPlayOptions(); + if (!nextOptions) return; + // 翻页沿用本次筛选快照,不受尚未提交的表单修改影响。 + appliedOptions = { ...nextOptions }; + playingCodes.value = [...selectedCodes.value]; + currentPage.value = 1; + await playPage(); + } - function changeSelection(codes: string[]) { - const available = new Set( - channels.value.map((channel) => channel.channelCode) - ); - selectedCodes.value = [...new Set(codes)].filter((code) => - available.has(code) - ); - if (selectionMode.value === "single") { - selectedCodes.value = selectedCodes.value.slice(0, 1); - void playSelected(); - } - } + function changeSelection(codes: string[]) { + const available = new Set(channels.value.map((channel) => channel.channelCode)); + selectedCodes.value = [...new Set(codes)].filter((code) => available.has(code)); + if (selectionMode.value === 'single') { + selectedCodes.value = selectedCodes.value.slice(0, 1); + void playSelected(); + } + } - function changeSelectionMode(mode: ChannelSelectionMode) { - selectionMode.value = mode; - if (mode === "single") { - selectedCodes.value = [ - selectedCodes.value[0] || - playingCodes.value[0] || - channels.value[0]?.channelCode, - ].filter(Boolean); - void playSelected(); - } - } + function changeSelectionMode(mode: ChannelSelectionMode) { + selectionMode.value = mode; + if (mode === 'single') { + selectedCodes.value = [selectedCodes.value[0] || playingCodes.value[0] || channels.value[0]?.channelCode].filter(Boolean); + void playSelected(); + } + } - async function changePage(page: number) { - currentPage.value = Math.max( - 1, - Math.min(page, Math.ceil(playingChannels.value.length / pageSize) || 1) - ); - await playPage(); - } + async function changePage(page: number) { + currentPage.value = Math.max(1, Math.min(page, Math.ceil(playingChannels.value.length / pageSize) || 1)); + await playPage(); + } - function closeDialog() { - ++session; - ++playRequest; - dialogVisible.value = false; - loading.value = false; - playerRef.value?.updateVisible(false); - if (playerRef.value?.destroy) playerRef.value.destroy(); - else void playerRef.value?.closeVideo(); - } + function closeDialog() { + ++session; + ++playRequest; + dialogVisible.value = false; + loading.value = false; + playerRef.value?.updateVisible(false); + if (playerRef.value?.destroy) playerRef.value.destroy(); + else void playerRef.value?.closeVideo(); + } - async function openDialog(type: string, code: string) { - if (!type || !code) return; - closeDialog(); - const activeSession = ++session; - sourceType = type; - sourceCode = code; - channels.value = []; - selectedCodes.value = []; - playingCodes.value = []; - deviceName.value = ""; - loadError.value = ""; - selectionMode.value = "single"; - currentPage.value = 1; - dialogVisible.value = true; - loading.value = true; - try { - const query: Record = {}; - if (type === "device") { - // 预览入口传设备 ID,回放入口传设备编码。 - const result = - options.mode === "preview" - ? await DeviceManageApi.getObj(code) - : await DeviceManageApi.getByCode({ code }); - if (activeSession !== session) return; - if (result.code != 0 || !result.data?.deviceCode) - throw new Error("获取设备信息失败"); - query.deviceCode = result.data.deviceCode; - deviceName.value = result.data.deviceName || ""; - } else if (type === "channel") query.channelCode = code; - else if (type === "playbackGroup") query.playbackGroupChannelCode = code; - else throw new Error("不支持的视频来源"); - const result = await DeviceChannelApi.list(query); - if (activeSession !== session) return; - if (result.code != 0) throw new Error("加载通道失败,请重试"); - const seen = new Set(); - channels.value = (Array.isArray(result.data) ? result.data : []) - .filter((channel: VideoChannel) => { - const channelCode = - channel.channelCode == null ? "" : String(channel.channelCode); - if (!channelCode || seen.has(channelCode)) return false; - seen.add(channelCode); - return true; - }) - .map((channel: VideoChannel) => ({ - ...channel, - channelCode: String(channel.channelCode), - })); - deviceName.value ||= - channels.value[0]?.deviceName || - (type === "playbackGroup" ? "回放分组" : "设备视频"); - selectedCodes.value = channels.value - .slice(0, 1) - .map((channel) => channel.channelCode); - } catch (error) { - if (activeSession === session) - loadError.value = - error instanceof Error ? error.message : "加载通道失败,请重试"; - } finally { - if (activeSession === session) loading.value = false; - } - if (activeSession === session && dialogVisible.value) void playSelected(); - } + async function openDialog(type: string, code: string) { + if (!type || !code) return; + closeDialog(); + const activeSession = ++session; + sourceType = type; + sourceCode = code; + channels.value = []; + selectedCodes.value = []; + playingCodes.value = []; + deviceName.value = ''; + loadError.value = ''; + selectionMode.value = 'single'; + currentPage.value = 1; + dialogVisible.value = true; + loading.value = true; + try { + const query: Record = {}; + if (type === 'device') { + // 预览入口传设备 ID,回放入口传设备编码。 + const result = options.mode === 'preview' ? await DeviceManageApi.getObj(code) : await DeviceManageApi.getByCode({ code }); + if (activeSession !== session) return; + if (result.code != 0 || !result.data?.deviceCode) throw new Error('获取设备信息失败'); + query.deviceCode = result.data.deviceCode; + deviceName.value = result.data.deviceName || ''; + } else if (type === 'channel') query.channelCode = code; + else if (type === 'playbackGroup') query.playbackGroupChannelCode = code; + else throw new Error('不支持的视频来源'); + const result = await DeviceChannelApi.list(query); + if (activeSession !== session) return; + if (result.code != 0) throw new Error('加载通道失败,请重试'); + const seen = new Set(); + channels.value = (Array.isArray(result.data) ? result.data : []) + .filter((channel: VideoChannel) => { + const channelCode = channel.channelCode == null ? '' : String(channel.channelCode); + if (!channelCode || seen.has(channelCode)) return false; + seen.add(channelCode); + return true; + }) + .map((channel: VideoChannel) => ({ + ...channel, + channelCode: String(channel.channelCode), + })); + deviceName.value ||= channels.value[0]?.deviceName || (type === 'playbackGroup' ? '回放分组' : '设备视频'); + selectedCodes.value = channels.value.slice(0, 1).map((channel) => channel.channelCode); + } catch (error) { + if (activeSession === session) loadError.value = error instanceof Error ? error.message : '加载通道失败,请重试'; + } finally { + if (activeSession === session) loading.value = false; + } + if (activeSession === session && dialogVisible.value) void playSelected(); + } - onBeforeUnmount(closeDialog); + onBeforeUnmount(closeDialog); - return { - dialogVisible, - channels, - selectedCodes, - selectionMode, - loading, - loadError, - playingCodes, - playingChannels, - deviceName, - playerRef, - currentPage, - pageSize, - division, - divisionOptions, - playingSummary, - openDialog, - closeDialog, - changeSelection, - changeSelectionMode, - playSelected, - changePage, - reloadChannels: () => openDialog(sourceType, sourceCode), - }; + return { + dialogVisible, + channels, + selectedCodes, + selectionMode, + loading, + loadError, + playingCodes, + displayedCodes, + playingChannels, + deviceName, + playerRef, + currentPage, + pageSize, + division, + divisionOptions, + playingSummary, + openDialog, + closeDialog, + changeSelection, + changeSelectionMode, + playSelected, + changePage, + reloadChannels: () => openDialog(sourceType, sourceCode), + }; } diff --git a/src/views/device/components/video-channel-panel.vue b/src/views/device/components/video-channel-panel.vue index 5c58eb8..da8d434 100644 --- a/src/views/device/components/video-channel-panel.vue +++ b/src/views/device/components/video-channel-panel.vue @@ -1,404 +1,394 @@ - + diff --git a/src/views/device/components/video-dialog.scss b/src/views/device/components/video-dialog.scss index e00b62c..eae7683 100644 --- a/src/views/device/components/video-dialog.scss +++ b/src/views/device/components/video-dialog.scss @@ -1,322 +1,355 @@ // 限定到视频弹窗,覆盖项目弹窗的全局滚动与高度规则。 .el-overlay .el-overlay-dialog .el-dialog.device-video-dialog { - --device-video-stage-bg: #111820; - --device-video-stage-text: #aebaca; - display: flex; - flex-direction: column; - max-width: 1560px; - height: min(860px, calc(100vh - 48px)); - height: min(860px, calc(100dvh - 48px)); - border-radius: 12px; + --device-video-stage-bg: #111820; + --device-video-stage-text: #aebaca; + display: flex; + flex-direction: column; + padding: 0; + max-width: 1560px; + height: min(860px, calc(100vh - 48px)); + height: min(860px, calc(100dvh - 48px)); + border-radius: 12px; - .el-dialog__header { - flex: 0 0 auto; - padding: 18px 24px; - margin: 0; - border-bottom: 1px solid var(--el-border-color-lighter); - } - .el-dialog__headerbtn { - top: 12px; - right: 16px; - width: 40px; - height: 40px; - &:focus-visible { - outline: 2px solid var(--el-color-primary) !important; - outline-offset: -4px; - } - } - .el-dialog__body { - display: flex; - flex: 1; - min-height: 0; - max-height: none !important; - padding: 16px !important; - overflow: hidden; - background: var(--el-fill-color-lighter); - } - .el-dialog__footer { - flex: 0 0 auto; - padding: 12px 20px; - border-top: 1px solid var(--el-border-color-lighter); - } - .el-button:focus-visible, - .el-radio-button:has(input:focus-visible) .el-radio-button__inner { - outline: 2px solid var(--el-color-primary) !important; - outline-offset: 2px; - } - .el-input__wrapper:focus-within, - .el-select .el-input.is-focus .el-input__wrapper { - box-shadow: 0 0 0 2px var(--el-color-primary) inset; - } + .el-dialog__header { + flex: 0 0 auto; + padding: 18px 24px; + margin: 0; + border-bottom: 1px solid var(--el-border-color-lighter); + } + .el-dialog__headerbtn { + top: 12px; + right: 16px; + width: 40px; + height: 40px; + &:focus-visible { + outline: 2px solid var(--el-color-primary) !important; + outline-offset: -4px; + } + } + .el-dialog__body { + display: flex; + flex: 1; + min-height: 0; + max-height: none !important; + padding: 16px !important; + overflow: hidden; + background: var(--el-fill-color-lighter); + scrollbar-width: thin; + scrollbar-color: var(--el-border-color-darker) var(--el-fill-color-lighter); + &::-webkit-scrollbar-track, + &::-webkit-scrollbar-track-piece { + background: var(--el-fill-color-lighter); + } + &::-webkit-scrollbar-thumb { + background: var(--el-border-color-darker); + border-radius: 6px; + } + } + .el-dialog__footer { + flex: 0 0 auto; + padding: 12px 20px; + border-top: 1px solid var(--el-border-color-lighter); + } + .el-button:focus-visible, + .el-radio-button:has(input:focus-visible) .el-radio-button__inner { + outline: 2px solid var(--el-color-primary) !important; + outline-offset: 2px; + } + .el-input__wrapper:focus-within, + .el-select .el-input.is-focus .el-input__wrapper { + box-shadow: 0 0 0 2px var(--el-color-primary) inset; + } - .device-video-heading { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; - padding-right: 40px; - h2 { - margin: 0; - white-space: nowrap; - line-height: 28px; - } - } - .device-video-heading__icon { - display: grid; - place-items: center; - width: 32px; - height: 32px; - flex: 0 0 32px; - border-radius: 8px; - color: var(--el-color-primary); - background: var(--el-color-primary-light-9); - font-size: 18px; - } - .device-video-heading__device { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 13px; - color: var(--el-text-color-regular); - border-left: 1px solid var(--el-border-color); - padding-left: 12px; - } - .device-video-layout { - display: flex; - width: 100%; - min-height: 0; - min-width: 0; - gap: 16px; - } - .device-video-workspace { - display: flex; - flex: 1; - flex-direction: column; - min-width: 0; - min-height: 0; - overflow: hidden; - background: var(--el-bg-color); - border: 1px solid var(--el-border-color-light); - border-radius: 8px; - } - .device-video-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex: 0 0 auto; - min-height: 48px; - padding: 10px 16px; - border-bottom: 1px solid var(--el-border-color-lighter); - } - .device-video-toolbar__title { - display: flex; - align-items: center; - gap: 8px; - color: var(--el-text-color-primary); - font-weight: 500; - .el-icon { - color: var(--el-text-color-regular); - font-size: 16px; - } - } - .device-video-toolbar__meta { - color: var(--el-text-color-regular); - font-size: 12px; - white-space: nowrap; - } - .device-video-stage { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - min-width: 0; - min-height: 0; - overflow: hidden; - background: var(--device-video-stage-bg); - container-type: size; - .video-container { - flex: 0 0 auto; - width: 100%; - max-height: 100%; - // 原生插件取实际 DOM 尺寸,保持 16:9 且不超出可用高度。 - @supports (width: 1cqh) { - width: min(100cqw, calc(100cqh * 16 / 9)); - } - background: var(--device-video-stage-bg); - } - .loading-mask { - display: grid; - place-items: center; - background: var(--device-video-stage-bg); - color: var(--device-video-stage-text); - font-size: 13px; - } - } - .device-video-empty { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - padding: 24px; - text-align: center; - color: var(--device-video-stage-text); - > .el-icon { - margin-bottom: 8px; - font-size: 40px; - } - strong { - color: #e4ebf3; - font-size: 15px; - font-weight: 500; - } - span { - font-size: 13px; - } - } - .device-video-status { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex: 0 0 auto; - min-height: 42px; - padding: 7px 16px; - color: var(--el-text-color-regular); - font-size: 12px; - > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - .device-video-query { - display: grid; - grid-template-columns: minmax(330px, 1fr) minmax(100px, 132px) minmax( - 100px, - 132px - ) auto; - gap: 12px; - align-items: end; - flex: 0 0 auto; - padding: 16px; - border-bottom: 1px solid var(--el-border-color-lighter); - .el-form-item { - min-width: 0; - margin: 0; - } - .el-form-item__label { - padding: 0; - margin-bottom: 6px; - font-size: 12px; - line-height: 18px; - } - .el-form-item__content { - min-width: 0; - } - .el-date-editor, - .el-select { - width: 100%; - min-width: 0; - } - .el-date-editor .el-range-input { - font-size: 12px; - } - .el-date-editor .el-range-separator { - flex: 0 0 20px; - font-size: 12px; - } - > .el-button { - margin: 0; - } - } - .device-video-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - > span { - color: var(--el-text-color-regular); - font-size: 12px; - text-align: left; - } - } + .device-video-heading { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding-right: 40px; + h2 { + margin: 0; + white-space: nowrap; + line-height: 28px; + } + } + .device-video-heading__icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + flex: 0 0 32px; + border-radius: 8px; + color: var(--el-color-primary); + background: var(--el-color-primary-light-9); + font-size: 18px; + } + .device-video-heading__device { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: var(--el-text-color-regular); + border-left: 1px solid var(--el-border-color); + padding-left: 12px; + } + .device-video-layout { + display: flex; + width: 100%; + min-height: 0; + min-width: 0; + gap: 16px; + } + .device-video-workspace { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--el-bg-color); + border: 1px solid var(--el-border-color-light); + border-radius: 8px; + } + .device-video-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex: 0 0 auto; + min-height: 48px; + padding: 10px 16px; + border-bottom: 1px solid var(--el-border-color-lighter); + } + .device-video-toolbar__title { + display: flex; + align-items: center; + gap: 8px; + color: var(--el-text-color-primary); + font-weight: 500; + .el-icon { + color: var(--el-text-color-regular); + font-size: 16px; + } + } + .device-video-toolbar__meta { + color: var(--el-text-color-regular); + font-size: 12px; + white-space: nowrap; + } + .device-video-stage { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--device-video-stage-bg); + container-type: size; + .video-container { + flex: 0 0 auto; + width: 100%; + max-height: 100%; + // 原生插件取实际 DOM 尺寸,保持 16:9 且不超出可用高度。 + @supports (width: 1cqh) { + width: min(100cqw, calc(100cqh * 16 / 9)); + } + background: var(--device-video-stage-bg); + } + .loading-mask { + display: grid; + place-items: center; + background: var(--device-video-stage-bg); + color: var(--device-video-stage-text); + font-size: 13px; + } + .video-container-page { + width: auto !important; + max-width: none !important; + height: 36px !important; + left: 8px !important; + bottom: 8px !important; + padding: 0 8px !important; + border-radius: 6px; + .video-pager { + display: none; + } + .video-yt { + margin-left: 0; + } + } + } + .device-video-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 24px; + text-align: center; + color: var(--device-video-stage-text); + > .el-icon { + margin-bottom: 8px; + font-size: 40px; + } + strong { + color: #e4ebf3; + font-size: 15px; + font-weight: 500; + } + span { + font-size: 13px; + } + } + .device-video-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex: 0 0 auto; + min-height: 42px; + padding: 7px 16px; + color: var(--el-text-color-regular); + font-size: 12px; + > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + .device-video-query { + display: grid; + grid-template-columns: + minmax(330px, 1fr) minmax(100px, 132px) minmax(100px, 132px) + auto; + gap: 12px; + align-items: end; + flex: 0 0 auto; + padding: 16px; + border-bottom: 1px solid var(--el-border-color-lighter); + .el-form-item { + min-width: 0; + margin: 0; + } + .el-form-item__label { + padding: 0; + margin-bottom: 6px; + font-size: 12px; + line-height: 18px; + } + .el-form-item__content { + min-width: 0; + } + .el-date-editor, + .el-select { + width: 100%; + min-width: 0; + } + .el-date-editor .el-range-input { + font-size: 12px; + } + .el-date-editor .el-range-separator { + flex: 0 0 20px; + font-size: 12px; + } + > .el-button { + margin: 0; + } + } + .device-video-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + > span { + color: var(--el-text-color-regular); + font-size: 12px; + text-align: left; + } + } } @media (max-width: 1250px) { - .el-overlay - .el-overlay-dialog - .el-dialog.device-video-dialog - .device-video-query { - grid-template-columns: minmax(110px, 1fr) minmax(110px, 1fr) auto; - .device-video-query__date { - grid-column: 1 / -1; - } - } + .el-overlay .el-overlay-dialog .el-dialog.device-video-dialog .device-video-query { + grid-template-columns: minmax(110px, 1fr) minmax(110px, 1fr) auto; + .device-video-query__date { + grid-column: 1 / -1; + } + } } @media (max-width: 700px) { - .el-overlay .el-overlay-dialog .el-dialog.device-video-dialog { - width: calc(100% - 16px) !important; - height: calc(100vh - 16px); - height: calc(100dvh - 16px); - .el-dialog__header { - padding: 12px 16px; - } - .el-dialog__body { - padding: 8px !important; - overflow-y: auto; - } - .el-dialog__footer { - padding: 10px 16px; - } - .device-video-heading { - gap: 8px; - } - .device-video-heading__device { - padding-left: 8px; - font-size: 12px; - } - .device-video-layout { - flex-direction: column; - gap: 8px; - min-height: min-content; - } - .device-video-workspace { - flex: 0 0 auto; - min-height: 330px; - overflow: visible; - } - .device-video-stage { - flex: 0 0 auto; - height: auto; - min-height: 180px; - aspect-ratio: 16 / 9; - } - .device-video-query { - padding: 12px; - gap: 8px; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - > .el-button { - grid-column: 1 / -1; - } - } - .device-video-toolbar, - .device-video-status { - padding-left: 12px; - padding-right: 12px; - } - .device-video-footer > span { - max-width: 75%; - line-height: 18px; - } - } + .el-overlay .el-overlay-dialog .el-dialog.device-video-dialog { + width: calc(100% - 16px) !important; + height: calc(100vh - 16px); + height: calc(100dvh - 16px); + .el-dialog__header { + padding: 12px 16px; + } + .el-dialog__body { + padding: 8px !important; + overflow-y: auto; + } + .el-dialog__footer { + padding: 10px 16px; + } + .device-video-heading { + gap: 8px; + } + .device-video-heading__device { + padding-left: 8px; + font-size: 12px; + } + .device-video-layout { + flex-direction: column; + gap: 8px; + min-height: min-content; + } + .device-video-workspace { + flex: 0 0 auto; + min-height: 330px; + overflow: visible; + } + .device-video-stage { + flex: 0 0 auto; + height: auto; + min-height: 180px; + aspect-ratio: 16 / 9; + } + .device-video-query { + padding: 12px; + gap: 8px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + > .el-button { + grid-column: 1 / -1; + } + } + .device-video-toolbar, + .device-video-status { + padding-left: 12px; + padding-right: 12px; + } + .device-video-footer > span { + max-width: 75%; + line-height: 18px; + } + } +} + +@media (max-height: 600px) and (min-width: 701px) { + .el-overlay .el-overlay-dialog .el-dialog.device-video-dialog { + .el-dialog__body { + overflow-y: auto; + } + .device-video-layout { + min-height: 520px; + } + } } @media (prefers-reduced-motion: reduce) { - .el-dialog.device-video-dialog { - --el-transition-duration: 0s; - .el-dialog__close { - transition: none !important; - } - } + .el-dialog.device-video-dialog { + --el-transition-duration: 0s; + .el-dialog__close { + transition: none !important; + } + } } diff --git a/src/views/device/deviceGroupPlay/index.vue b/src/views/device/deviceGroupPlay/index.vue index 298bd99..176023a 100644 --- a/src/views/device/deviceGroupPlay/index.vue +++ b/src/views/device/deviceGroupPlay/index.vue @@ -118,6 +118,8 @@ const handleGetSelectNode = async (curClickedNode: any, checkedTreeNodes: any, i } const list = checkedTreeNodes.filter((v: any) => v.deep == 2).map((v: any) => v.code) if (!list || !list.length) { + state.channelCodeList = [] + state.deviceTreeMap = [] await destroy() return; } diff --git a/tests/video-dialog.test.ts b/tests/video-dialog.test.ts new file mode 100644 index 0000000..9b4b3fa --- /dev/null +++ b/tests/video-dialog.test.ts @@ -0,0 +1,269 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { flushPromises, mount } from '@vue/test-utils'; +import { useVideoDialog } from '../src/views/device/components/useVideoDialog'; +import VideoChannelPanel from '../src/views/device/components/video-channel-panel.vue'; + +const api = vi.hoisted(() => ({ list: vi.fn(), getObj: vi.fn(), getByCode: vi.fn(), error: vi.fn() })); +vi.mock('/@/api/device/deviceChannel', () => ({ list: api.list })); +vi.mock('/@/api/device/deviceManage', () => ({ getObj: api.getObj, getByCode: api.getByCode })); +vi.mock('/@/hooks/message', () => ({ useMessage: () => ({ error: api.error }) })); + +const makeChannels = (count = 3) => + Array.from({ length: count }, (_, index) => ({ + channelCode: `camera-${index}`, + channelName: `Camera ${index}`, + channelSeq: index, + deviceName: '测试设备', + })); +const makePlayer = () => ({ + startReal: vi.fn().mockResolvedValue(undefined), + startPlayback: vi.fn().mockResolvedValue(undefined), + closeVideo: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn(), + updateVisible: vi.fn(), +}); +const mounted: ReturnType[] = []; +const slotStub = defineComponent({ + setup(_, { slots }) { + return () => h('div', slots.default?.()); + }, +}); +const panelStubs = { + ElInput: defineComponent({ + props: ['modelValue'], + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h('input', { + class: 'el-input__inner', + value: props.modelValue, + onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value), + }); + }, + }), + ElButton: defineComponent({ + props: { disabled: Boolean }, + setup(props, { slots }) { + return () => h('button', { disabled: props.disabled }, slots.default?.()); + }, + }), + ElIcon: slotStub, + ElRadioGroup: slotStub, + ElRadioButton: slotStub, +}; + +function setupDialog(mode: 'preview' | 'replay' = 'preview', getPlayOptions = () => ({ streamType: 2 } as Record | null)) { + let dialog!: ReturnType; + mounted.push( + mount( + defineComponent({ + setup() { + dialog = useVideoDialog({ mode, getPlayOptions }); + return () => h('div'); + }, + }) + ) + ); + const player = makePlayer(); + dialog.playerRef.value = player; + return { dialog, player }; +} + +beforeEach(() => { + vi.resetAllMocks(); + api.list.mockResolvedValue({ code: 0, data: makeChannels() }); + api.getObj.mockResolvedValue({ code: 0, data: { deviceCode: 'device-code', deviceName: '测试设备' } }); + api.getByCode.mockResolvedValue({ code: 0, data: { deviceCode: 'device-code', deviceName: '测试设备' } }); +}); +afterEach(() => { + mounted.splice(0).forEach((wrapper) => wrapper.unmount()); +}); + +describe('视频弹窗通道播放', () => { + it('预览按设备 ID 查询,默认仅播放第一个通道', async () => { + const { dialog, player } = setupDialog(); + await dialog.openDialog('device', 'device-id'); + await flushPromises(); + expect(api.getObj).toHaveBeenCalledWith('device-id'); + expect(api.list).toHaveBeenCalledWith({ deviceCode: 'device-code' }); + expect(player.startReal).toHaveBeenLastCalledWith([{ channelId: 'camera-0', channelName: 'Camera 0', streamType: 2, snum: 0 }]); + expect(dialog.division.value).toBe(1); + }); + + it('回放保留设备编码入口和回放分组入口', async () => { + const { dialog } = setupDialog('replay'); + await dialog.openDialog('device', 'device-code'); + expect(api.getByCode).toHaveBeenCalledWith({ code: 'device-code' }); + expect(api.getObj).not.toHaveBeenCalled(); + await dialog.openDialog('playbackGroup', 'group-1'); + expect(api.list).toHaveBeenLastCalledWith({ playbackGroupChannelCode: 'group-1' }); + }); + + it('单选切换前清空旧画面,新通道从窗口 0 播放', async () => { + const { dialog, player } = setupDialog(); + await dialog.openDialog('channel', 'camera-0'); + await flushPromises(); + player.startReal.mockClear(); + player.closeVideo.mockClear(); + dialog.changeSelection(['camera-2']); + await flushPromises(); + expect(api.list).toHaveBeenCalledWith({ channelCode: 'camera-0' }); + expect(player.closeVideo).toHaveBeenCalledOnce(); + expect(player.startReal).toHaveBeenLastCalledWith([expect.objectContaining({ channelId: 'camera-2', snum: 0 })]); + expect(player.closeVideo.mock.invocationCallOrder[0]).toBeLessThan(player.startReal.mock.invocationCallOrder[0]); + }); + + it('多选暂存后统一播放,切回单选恢复单画面', async () => { + const { dialog, player } = setupDialog(); + await dialog.openDialog('channel', 'camera-0'); + await flushPromises(); + player.startReal.mockClear(); + dialog.changeSelectionMode('multiple'); + dialog.changeSelection(['camera-0', 'camera-1', 'camera-2', 'camera-2', 'missing']); + await flushPromises(); + expect(player.startReal).not.toHaveBeenCalled(); + expect(dialog.playingCodes.value).toEqual(['camera-0']); + await dialog.playSelected(); + expect(player.startReal.mock.lastCall?.[0]).toHaveLength(3); + expect(dialog.division.value).toBe(4); + dialog.changeSelectionMode('single'); + await flushPromises(); + expect(player.startReal.mock.lastCall?.[0]).toHaveLength(1); + expect(dialog.division.value).toBe(1); + }); + + it('超出 16 路按所选通道分页,窗口号每页从零开始', async () => { + api.list.mockResolvedValue({ code: 0, data: makeChannels(19) }); + const { dialog, player } = setupDialog(); + await dialog.openDialog('device', 'device-id'); + await flushPromises(); + dialog.changeSelectionMode('multiple'); + dialog.changeSelection(makeChannels(19).map((channel) => channel.channelCode)); + await dialog.playSelected(); + expect(player.startReal.mock.lastCall?.[0]).toHaveLength(16); + await dialog.changePage(2); + expect(player.startReal.mock.lastCall?.[0].map((channel: any) => [channel.channelId, channel.snum])).toEqual([ + ['camera-16', 0], + ['camera-17', 1], + ['camera-18', 2], + ]); + expect(dialog.division.value).toBe(4); + dialog.changeSelection(['camera-18']); + await dialog.playSelected(); + expect(dialog.currentPage.value).toBe(1); + expect(player.startReal.mock.lastCall?.[0]).toEqual([expect.objectContaining({ channelId: 'camera-18', snum: 0 })]); + }); + + it('回放翻页沿用已提交的时间、码流、来源', async () => { + api.list.mockResolvedValue({ code: 0, data: makeChannels(18) }); + const filters = { startTime: '2026-10-25 00:00:00', endTime: '2026-10-27 23:59:59', streamType: 0, recordSource: 2 }; + const snapshot = { ...filters }; + const { dialog, player } = setupDialog('replay', () => filters); + await dialog.openDialog('device', 'device-code'); + await flushPromises(); + dialog.changeSelectionMode('multiple'); + dialog.changeSelection(makeChannels(18).map((channel) => channel.channelCode)); + await dialog.playSelected(); + filters.startTime = '2026-10-26 00:00:00'; + filters.streamType = 1; + filters.recordSource = 3; + await dialog.changePage(2); + expect(player.startPlayback.mock.lastCall?.[0][0]).toMatchObject(snapshot); + await dialog.playSelected(); + expect(player.startPlayback.mock.lastCall?.[0][0]).toMatchObject(filters); + }); + + it('关闭弹窗后丢弃晚到的通道响应', async () => { + let resolve!: (value: unknown) => void; + api.list.mockReturnValue( + new Promise((done) => { + resolve = done; + }) + ); + const { dialog, player } = setupDialog(); + const opening = dialog.openDialog('channel', 'camera-0'); + dialog.closeDialog(); + resolve({ code: 0, data: makeChannels() }); + await opening; + await flushPromises(); + expect(dialog.channels.value).toEqual([]); + expect(player.startReal).not.toHaveBeenCalled(); + expect(player.destroy).toHaveBeenCalled(); + }); + + it('连续打开不同来源时,旧响应不能覆盖新来源', async () => { + let resolve!: (value: unknown) => void; + api.list + .mockReturnValueOnce( + new Promise((done) => { + resolve = done; + }) + ) + .mockResolvedValueOnce({ code: 0, data: [makeChannels()[2]] }); + const { dialog } = setupDialog(); + const older = dialog.openDialog('channel', 'older'); + await dialog.openDialog('channel', 'newer'); + resolve({ code: 0, data: makeChannels() }); + await older; + await flushPromises(); + expect(dialog.channels.value.map((channel) => channel.channelCode)).toEqual(['camera-2']); + }); + + it('播放切换等待期间关闭弹窗,不再启动旧播放', async () => { + const { dialog, player } = setupDialog(); + await dialog.openDialog('channel', 'camera-0'); + await flushPromises(); + player.startReal.mockClear(); + let resolve!: () => void; + player.closeVideo.mockReturnValue( + new Promise((done) => { + resolve = done; + }) + ); + dialog.changeSelection(['camera-1']); + await nextTick(); + dialog.closeDialog(); + resolve(); + await flushPromises(); + expect(player.startReal).not.toHaveBeenCalled(); + }); + + it('设备详情失败不会发起无范围的通道查询', async () => { + api.getObj.mockResolvedValue({ code: 1, data: null }); + const { dialog, player } = setupDialog(); + await dialog.openDialog('device', 'missing'); + await flushPromises(); + expect(api.list).not.toHaveBeenCalled(); + expect(player.startReal).not.toHaveBeenCalled(); + expect(dialog.loadError.value).toBe('获取设备信息失败'); + }); +}); + +describe('通道选择面板', () => { + it('搜索全选只添加匹配项,保留已有选择,清空后禁用播放', async () => { + const wrapper = mount(VideoChannelPanel, { + props: { channels: makeChannels(), modelValue: ['camera-0'], mode: 'multiple', loading: false, playingCodes: ['camera-0'] }, + global: { stubs: panelStubs, directives: { loading: () => undefined } }, + }); + mounted.push(wrapper); + await wrapper.get('input.el-input__inner').setValue('Camera 2'); + expect(wrapper.findAll('.video-channel-row')).toHaveLength(1); + const selectAll = wrapper.findAll('button').find((button) => button.text() === '全选结果')!; + await selectAll.trigger('click'); + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['camera-0', 'camera-2']]); + await wrapper + .findAll('button') + .find((button) => button.text() === '清空')! + .trigger('click'); + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([[]]); + await wrapper.setProps({ modelValue: [] }); + expect( + wrapper + .findAll('button') + .find((button) => button.text() === '播放所选')! + .attributes('disabled') + ).toBeDefined(); + }); +}); diff --git a/tests/video-player-sdk.lifecycle.test.ts b/tests/video-player-sdk.lifecycle.test.ts new file mode 100644 index 0000000..2169ed0 --- /dev/null +++ b/tests/video-player-sdk.lifecycle.test.ts @@ -0,0 +1,141 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const sdkSource = readFileSync(resolve('src/components/rtsp-plugin-video/lib/videoPlayer.js'), 'utf8'); +let sdkWindow: any; +let control: any; +let sent: any[]; + +function player(id = 'player-one', hwnd: number | string = 101) { + const instance = Object.create(sdkWindow.VideoPlayer.prototype); + instance.setting = { + videoId: id, hwnd, show: true, visible: true, channelList: [], division: 1, windowType: 0, + outContent: {}, language: 'zh', refreshTimer: 41, stopRefresh: false, request: {}, + }; + control.videoList[id] = instance; + if (typeof hwnd === 'number') control.hwndList[hwnd] = id; + return instance; +} + +beforeEach(() => { + vi.useFakeTimers(); + sent = []; + sdkWindow = { + addEventListener: vi.fn(), removeEventListener: vi.fn(), + cancelAnimationFrame: vi.fn(), requestAnimationFrame: vi.fn(() => 42), + innerWidth: 1280, innerHeight: 720, outerWidth: 1280, devicePixelRatio: 1, + screen: { availLeft: 0 }, screenX: 0, + location: { hostname: 'localhost' }, document, + }; + sdkWindow.parent = sdkWindow; + sdkWindow.top = sdkWindow; + runInNewContext(sdkSource, { + window: sdkWindow, document, navigator: { userAgent: 'Mozilla/5.0 Chrome/120.0 Windows NT 10.0' }, + localStorage: {}, console: { log: vi.fn() }, setTimeout, clearTimeout, setInterval, clearInterval, + }); + control = sdkWindow.dhPlayerControl; + control.videoWS = { readyState: 1, send: vi.fn((value) => sent.push(JSON.parse(value))), close: vi.fn() }; +}); +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + document.body.innerHTML = ''; +}); + +describe('真实 SDK 源码的单实例销毁', () => { + it('隐藏并销毁自身句柄,移除登记和刷新任务,保留共享连接与其他实例', () => { + const first = player(); + const other = player('player-two', 202); + first.dispose(); + expect(sent.map(message => message.method)).toEqual(['window.show', 'window.destroy']); + expect(sent.at(-1).info.hwnd).toBe(101); + expect(control.videoList['player-one']).toBeUndefined(); + expect(control.hwndList[101]).toBeUndefined(); + expect(control.videoList['player-two']).toBe(other); + expect(control.hwndList[202]).toBe('player-two'); + expect(control.videoWS.close).not.toHaveBeenCalled(); + expect(sdkWindow.cancelAnimationFrame).toHaveBeenCalledWith(41); + }); + + it('销毁是幂等的,晚到播放、显示与位置刷新不会再发送命令', () => { + const instance = player(); + instance.dispose(); + const count = sent.length; + instance.dispose(); + instance.show(); + instance.handleAdjust(); + instance.send({ method: 'video.real', info: {} }); + expect(sent).toHaveLength(count); + expect(sdkWindow.requestAnimationFrame).not.toHaveBeenCalled(); + }); + + it('不会删除已登记到相同 ID 下的新实例', () => { + const old = player(); + const replacement = player('player-one', 303); + old.dispose(); + expect(control.videoList['player-one']).toBe(replacement); + expect(control.hwndList[303]).toBe('player-one'); + expect(sent.at(-1).info.hwnd).toBe(101); + }); + + it('原有 destroy 仍可供内部重建使用,不会永久禁用实例', async () => { + const instance = player(); + const destroying = instance.destroy(); + const message = sent.at(-1); + control.callBackList[message.id].onSuccess({ code: 0 }); + await destroying; + expect(instance.disposed).toBeUndefined(); + expect(instance.setting.hwnd).toBe(''); + }); + + it('创建请求发出后卸载,晚到的新句柄仍发送单窗口销毁', async () => { + const instance = player('late-create', ''); + document.body.innerHTML = ''; + instance.create(); + await vi.advanceTimersByTimeAsync(0); + const createMessage = sent.find(message => message.method === 'window.create'); + expect(createMessage).toBeDefined(); + const callback = control.callBackList[createMessage.id]; + instance.dispose(); + document.body.innerHTML = ''; + callback.onSuccess({ data: { hwnd: 505 } }); + expect(sent.at(-1)).toMatchObject({ method: 'window.destroy', info: { hwnd: 505, isRefresh: false } }); + expect(control.videoList['late-create']).toBeUndefined(); + }); + + it('创建回包时容器已被删除,同样回收句柄和 SDK 登记', async () => { + const instance = player('missing-dom', ''); + document.body.innerHTML = ''; + instance.create(); + await vi.advanceTimersByTimeAsync(0); + const message = sent.find(message => message.method === 'window.create'); + document.body.innerHTML = ''; + control.callBackList[message.id].onSuccess({ data: { hwnd: 606 } }); + expect(sent.at(-1)).toMatchObject({ method: 'window.destroy', info: { hwnd: 606 } }); + expect(instance.disposed).toBe(true); + expect(control.videoList['missing-dom']).toBeUndefined(); + }); + + it('内部旧窗口销毁回包晚于 dispose 时,不会继续创建新窗口', async () => { + const instance = player('cancel-create'); + document.body.innerHTML = ''; + instance.create(); + const destroyMessage = sent.find(message => message.method === 'window.destroy'); + const callback = control.callBackList[destroyMessage.id]; + instance.dispose(); + callback.onSuccess({ code: 0 }); + await vi.advanceTimersByTimeAsync(0); + expect(sent.some(message => message.method === 'window.create')).toBe(false); + }); + + it('手动隐藏的实例不会在 SDK show 调用时重新显示', () => { + const instance = player(); + instance.hide(); + instance.setting.visible = false; + const count = sent.length; + instance.show(); + expect(sent).toHaveLength(count); + }); +}); diff --git a/tests/video-plugin.lifecycle.test.ts b/tests/video-plugin.lifecycle.test.ts index a9c0899..13e75a8 100644 --- a/tests/video-plugin.lifecycle.test.ts +++ b/tests/video-plugin.lifecycle.test.ts @@ -37,11 +37,11 @@ class MockPlayer { const wrappers: VueWrapper[] = []; const mountOptions = { - attachTo: document.body, global: { stubs: { Yt: true, 'el-pagination': true, 'el-icon': true }, directives: { loading: () => undefined } }, }; function createPlayer(props = {}) { const wrapper = mount(VideoPlugin, { ...mountOptions, props }); + document.body.appendChild(wrapper.element); wrappers.push(wrapper); return wrapper; } @@ -70,7 +70,7 @@ beforeEach(() => { mocks.getSetting.mockReset().mockResolvedValue({ code: 0, data: { dhIccSetting: {} } }); }); afterEach(() => { - wrappers.splice(0).forEach((wrapper) => wrapper.unmount()); + wrappers.splice(0).forEach((wrapper) => { if (wrapper.exists()) wrapper.unmount(); }); vi.clearAllTimers(); vi.useRealTimers(); document.body.innerHTML = ''; @@ -150,6 +150,21 @@ describe('原生播放器组件的关闭时序', () => { expect(player.startReal).not.toHaveBeenCalled(); }); + it('原生工具栏关闭的通道,不会在浏览器重新可见时复播', async () => { + const wrapper = createPlayer(); + const player = await play(wrapper); + player.setting.channelList = []; + player.options.closeWindowSuccess({ isAll: true, channelList: [] }); + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(0); + expect(player.startReal).toHaveBeenCalledTimes(1); + await wrapper.vm.startReal(channels()); + expect(player.startReal).toHaveBeenCalledTimes(2); + }); + it('未 await 的关闭也会在新播放前等待旧 SDK 关闭确认', async () => { const wrapper = createPlayer(); const player = await play(wrapper); @@ -164,6 +179,18 @@ describe('原生播放器组件的关闭时序', () => { expect(player.startReal).toHaveBeenLastCalledWith(channels('next')); }); + it('快速切出再切回浏览器,仍恢复正在观看的预览,不会被防重入间隔拦掉', async () => { + const wrapper = createPlayer(); + const player = await play(wrapper); + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(0); + expect(player.closeVideo).toHaveBeenCalledTimes(1); + expect(player.startReal).toHaveBeenCalledTimes(2); + }); + it('同时关闭多个缓存实例,单个 SDK 不回包也不妨碍销毁其他窗口', async () => { const wrapper = createPlayer(); const real = await play(wrapper); @@ -192,6 +219,21 @@ describe('原生播放器组件的关闭时序', () => { await Promise.all([first, second]); }); + it('销毁会中断对旧关闭回包的等待,旧 SDK 永不回包也能重新打开', async () => { + const wrapper = createPlayer(); + const old = await play(wrapper); + old.closeVideo.mockReturnValue(new Promise(() => undefined)); + void wrapper.vm.closeVideo(); + const waiting = wrapper.vm.startReal(channels('waiting')); + await vi.advanceTimersByTimeAsync(0); + wrapper.vm.destroy(); + await waiting; + expect(old.startReal).toHaveBeenCalledTimes(1); + const replacement = await play(wrapper); + expect(instances).toHaveLength(2); + expect(replacement.startReal).toHaveBeenCalledWith(channels()); + }); + it('两个组件的容器 ID 不重叠,关闭其中一个不会销毁另一个', async () => { const first = createPlayer(); const second = createPlayer(); @@ -225,6 +267,28 @@ describe('原生播放器组件的关闭时序', () => { expect(replacement.startReal).toHaveBeenCalledTimes(1); }); + it('SDK 在构造函数内同步报告失败时,同样销毁失败实例并结束等待', async () => { + (window as any).VideoPlayer = class extends MockPlayer { + constructor(options: any) { + super(options); + options.createError({ code: 1002, message: 'unsupported system' }); + } + }; + const wrapper = createPlayer(); + await wrapper.vm.startReal(channels()); + expect(instances[0].dispose).toHaveBeenCalledTimes(1); + expect(instances[0].startReal).not.toHaveBeenCalled(); + }); + + it('正常播放仍保留辅码流失败后只重试一次主码流的行为', async () => { + const wrapper = createPlayer(); + const player = await play(wrapper); + player.options.realError(channels()[0], { code: 408 }); + player.options.realError(channels()[0], { code: 408 }); + expect(player.startReal).toHaveBeenCalledTimes(2); + expect(player.startReal).toHaveBeenLastCalledWith([{ ...channels()[0], streamType: 1 }]); + }); + it('弹窗 active=false 会立即销毁,离场期间的晚到播放调用被忽略', async () => { const wrapper = createPlayer(); const player = await play(wrapper); @@ -249,6 +313,7 @@ describe('原生播放器组件的关闭时序', () => { it('KeepAlive 停用时销毁窗口,重新激活时只重建当前播放', async () => { const visible = ref(true); const host = mount(defineComponent({ render: () => h(KeepAlive, {}, () => visible.value ? h(VideoPlugin) : null) }), mountOptions); + document.body.appendChild(host.element); wrappers.push(host); const child = host.findComponent(VideoPlugin); const oldPlayer = await play(child); @@ -263,4 +328,23 @@ describe('原生播放器组件的关闭时序', () => { await ready(instances[1]); expect(instances[1].startReal).toHaveBeenCalledWith(channels()); }); + + it('当前预览已手动关闭时,重新激活不能误恢复此前缓存的回放', async () => { + const visible = ref(true); + const host = mount(defineComponent({ render: () => h(KeepAlive, {}, () => visible.value ? h(VideoPlugin) : null) }), mountOptions); + document.body.appendChild(host.element); + wrappers.push(host); + const child = host.findComponent(VideoPlugin); + const real = await play(child); + await play(child, 'playback'); + await child.vm.startReal(channels()); + real.setting.channelList = []; + real.options.closeWindowSuccess({ isAll: true, channelList: [] }); + visible.value = false; + await nextTick(); + visible.value = true; + await nextTick(); + await vi.advanceTimersByTimeAsync(0); + expect(instances).toHaveLength(2); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index b86f679..29526b2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,10 @@ import { resolve } from 'node:path'; export default defineConfig({ plugins: [vue(), AutoImport({ imports: ['vue', 'vue-router', 'pinia'], dts: false })], resolve: { alias: { '/@': resolve(__dirname, 'src') } }, - test: { environment: 'jsdom', include: ['tests/**/*.test.{js,ts}'], restoreMocks: true }, + test: { + environment: 'jsdom', + include: ['tests/**/*.test.{js,ts}'], + restoreMocks: true, + server: { deps: { inline: ['element-plus', '@popperjs/core'] } }, + }, });