fix: 完善视频播放关闭流程和弹窗通道交互
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
744455243d
commit
86533f0c87
12 files changed
+1701
-1237
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,126 +1,116 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
class="device-video-dialog"
|
||||
title="视频预览"
|
||||
width="calc(100% - 48px)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:before-close="closeDialog"
|
||||
>
|
||||
<template #header="{ titleId, titleClass }">
|
||||
<div class="device-video-heading">
|
||||
<span class="device-video-heading__icon"
|
||||
><el-icon aria-hidden="true"><VideoCamera /></el-icon
|
||||
></span>
|
||||
<h2 :id="titleId" :class="titleClass">视频预览</h2>
|
||||
<span class="device-video-heading__device" :title="deviceName">{{
|
||||
deviceName
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="device-video-layout">
|
||||
<VideoChannelPanel
|
||||
:channels="channels"
|
||||
:model-value="selectedCodes"
|
||||
:mode="selectionMode"
|
||||
:loading="loading"
|
||||
:playing-codes="playingCodes"
|
||||
:error="loadError"
|
||||
@update:model-value="changeSelection"
|
||||
@update:mode="changeSelectionMode"
|
||||
@play="playSelected"
|
||||
@retry="reloadChannels"
|
||||
/>
|
||||
<section class="device-video-workspace" aria-label="视频预览画面">
|
||||
<div class="device-video-toolbar">
|
||||
<div class="device-video-toolbar__title">
|
||||
<el-icon aria-hidden="true"><VideoPlay /></el-icon
|
||||
><span>实时预览</span>
|
||||
</div>
|
||||
<span class="device-video-toolbar__meta"
|
||||
>{{ playingChannels.length }} 个通道<span
|
||||
v-if="playingChannels.length"
|
||||
>
|
||||
· {{ division }} 画面</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="device-video-stage">
|
||||
<RtspPluginVideo
|
||||
v-if="dialogVisible && playingChannels.length"
|
||||
ref="playerRef"
|
||||
:active="dialogVisible"
|
||||
:division-setting-list="divisionOptions"
|
||||
/>
|
||||
<div v-else class="device-video-empty">
|
||||
<el-icon aria-hidden="true"><VideoCamera /></el-icon>
|
||||
<strong>{{
|
||||
loading ? "正在加载通道" : "选择通道,开始预览"
|
||||
}}</strong>
|
||||
<span>支持单通道预览与多通道分屏</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-video-status">
|
||||
<span>{{ playingSummary }}</span>
|
||||
<el-pagination
|
||||
v-if="playingChannels.length > pageSize"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="playingChannels.length"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="changePage"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="device-video-footer">
|
||||
<span>单选即播放,多选后点击「播放所选」</span
|
||||
><el-button @click="closeDialog">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
class="device-video-dialog"
|
||||
title="视频预览"
|
||||
width="calc(100% - 48px)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:before-close="closeDialog"
|
||||
>
|
||||
<template #header="{ titleId, titleClass }">
|
||||
<div class="device-video-heading">
|
||||
<span class="device-video-heading__icon"
|
||||
><el-icon aria-hidden="true"><VideoCamera /></el-icon
|
||||
></span>
|
||||
<h2 :id="titleId" :class="titleClass">视频预览</h2>
|
||||
<span class="device-video-heading__device" :title="deviceName">{{ deviceName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="device-video-layout">
|
||||
<VideoChannelPanel
|
||||
:channels="channels"
|
||||
:model-value="selectedCodes"
|
||||
:mode="selectionMode"
|
||||
:loading="loading"
|
||||
:playing-codes="playingCodes"
|
||||
:visible-codes="displayedCodes"
|
||||
:error="loadError"
|
||||
@update:model-value="changeSelection"
|
||||
@update:mode="changeSelectionMode"
|
||||
@play="playSelected"
|
||||
@retry="reloadChannels"
|
||||
/>
|
||||
<section class="device-video-workspace" aria-label="视频预览画面">
|
||||
<div class="device-video-toolbar">
|
||||
<div class="device-video-toolbar__title">
|
||||
<el-icon aria-hidden="true"><VideoPlay /></el-icon><span>实时预览</span>
|
||||
</div>
|
||||
<span class="device-video-toolbar__meta"
|
||||
>{{ playingChannels.length }} 个通道<span v-if="playingChannels.length"> · {{ division }} 画面</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="device-video-stage">
|
||||
<RtspPluginVideo
|
||||
v-if="dialogVisible && playingChannels.length"
|
||||
ref="playerRef"
|
||||
:active="dialogVisible"
|
||||
:division-setting-list="divisionOptions"
|
||||
/>
|
||||
<div v-else class="device-video-empty">
|
||||
<el-icon aria-hidden="true"><VideoCamera /></el-icon>
|
||||
<strong>{{ loading ? '正在加载通道' : '选择通道,开始预览' }}</strong>
|
||||
<span>支持单通道预览与多通道分屏</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-video-status">
|
||||
<span>{{ playingSummary }}</span>
|
||||
<el-pagination
|
||||
v-if="playingChannels.length > pageSize"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="playingChannels.length"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="changePage"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="device-video-footer"><span>单选即播放,多选后点击「播放所选」</span><el-button @click="closeDialog">关闭</el-button></div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="PreviewVideo">
|
||||
import { VideoCamera, VideoPlay } from "@element-plus/icons-vue";
|
||||
import VideoChannelPanel from "../video-channel-panel.vue";
|
||||
import { useVideoDialog } from "../useVideoDialog";
|
||||
import { VideoCamera, VideoPlay } from '@element-plus/icons-vue';
|
||||
import VideoChannelPanel from '../video-channel-panel.vue';
|
||||
import { useVideoDialog } from '../useVideoDialog';
|
||||
|
||||
const {
|
||||
dialogVisible,
|
||||
channels,
|
||||
selectedCodes,
|
||||
selectionMode,
|
||||
loading,
|
||||
loadError,
|
||||
playingCodes,
|
||||
playingChannels,
|
||||
deviceName,
|
||||
playerRef,
|
||||
currentPage,
|
||||
pageSize,
|
||||
division,
|
||||
divisionOptions,
|
||||
playingSummary,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
changeSelection,
|
||||
changeSelectionMode,
|
||||
playSelected,
|
||||
changePage,
|
||||
reloadChannels,
|
||||
dialogVisible,
|
||||
channels,
|
||||
selectedCodes,
|
||||
selectionMode,
|
||||
loading,
|
||||
loadError,
|
||||
playingCodes,
|
||||
displayedCodes,
|
||||
playingChannels,
|
||||
deviceName,
|
||||
playerRef,
|
||||
currentPage,
|
||||
pageSize,
|
||||
division,
|
||||
divisionOptions,
|
||||
playingSummary,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
changeSelection,
|
||||
changeSelectionMode,
|
||||
playSelected,
|
||||
changePage,
|
||||
reloadChannels,
|
||||
} = useVideoDialog({
|
||||
mode: "preview",
|
||||
getPlayOptions: () => ({ streamType: 2 }),
|
||||
mode: 'preview',
|
||||
getPlayOptions: () => ({ streamType: 2 }),
|
||||
});
|
||||
|
||||
defineExpose({ openDialog });
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../video-dialog.scss";
|
||||
@import '../video-dialog.scss';
|
||||
</style>
|
||||
@@ -1,231 +1,184 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
class="device-video-dialog"
|
||||
title="录像回放"
|
||||
width="calc(100% - 48px)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:before-close="closeDialog"
|
||||
>
|
||||
<template #header="{ titleId, titleClass }">
|
||||
<div class="device-video-heading">
|
||||
<span class="device-video-heading__icon"
|
||||
><el-icon aria-hidden="true"><Film /></el-icon
|
||||
></span>
|
||||
<h2 :id="titleId" :class="titleClass">录像回放</h2>
|
||||
<span class="device-video-heading__device" :title="deviceName">{{
|
||||
deviceName
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="device-video-layout">
|
||||
<VideoChannelPanel
|
||||
:channels="channels"
|
||||
:model-value="selectedCodes"
|
||||
:mode="selectionMode"
|
||||
:loading="loading"
|
||||
:playing-codes="playingCodes"
|
||||
:error="loadError"
|
||||
@update:model-value="changeSelection"
|
||||
@update:mode="changeSelectionMode"
|
||||
@play="playSelected"
|
||||
@retry="reloadChannels"
|
||||
/>
|
||||
<section class="device-video-workspace" aria-label="录像回放画面">
|
||||
<el-form
|
||||
class="device-video-query"
|
||||
:model="queryForm"
|
||||
label-position="top"
|
||||
@submit.prevent="playSelected"
|
||||
>
|
||||
<el-form-item
|
||||
class="device-video-query__date"
|
||||
label="录像时间"
|
||||
prop="videoDateList"
|
||||
>
|
||||
<el-date-picker
|
||||
v-model="queryForm.videoDateList"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="MM-DD HH:mm:ss"
|
||||
type="datetimerange"
|
||||
:disabled-date="disabledDate"
|
||||
:clearable="false"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
range-separator="至"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="码流" prop="streamType">
|
||||
<dict-select
|
||||
v-model="queryForm.streamType"
|
||||
:options="dh_video_stream_type"
|
||||
placeholder="选择码流"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="录像来源" prop="recordSource">
|
||||
<dict-select
|
||||
v-model="queryForm.recordSource"
|
||||
:options="dh_replay_source"
|
||||
placeholder="选择来源"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
:icon="Search"
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:disabled="loading || !selectedCodes.length"
|
||||
>查询回放</el-button
|
||||
>
|
||||
</el-form>
|
||||
<div class="device-video-toolbar">
|
||||
<div class="device-video-toolbar__title">
|
||||
<el-icon aria-hidden="true"><Clock /></el-icon><span>录像回放</span>
|
||||
</div>
|
||||
<span class="device-video-toolbar__meta"
|
||||
>{{ playingChannels.length }} 个通道<span
|
||||
v-if="playingChannels.length"
|
||||
>
|
||||
· {{ division }} 画面</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="device-video-stage">
|
||||
<RtspPluginVideo
|
||||
v-if="dialogVisible && playingChannels.length"
|
||||
ref="playerRef"
|
||||
:active="dialogVisible"
|
||||
:window-type="3"
|
||||
:visible-yt="false"
|
||||
:division-setting-list="divisionOptions"
|
||||
/>
|
||||
<div v-else class="device-video-empty">
|
||||
<el-icon aria-hidden="true"><Film /></el-icon>
|
||||
<strong>{{
|
||||
loading ? "正在加载通道" : "选择通道,查看录像"
|
||||
}}</strong>
|
||||
<span>选择录像时间,支持多通道分屏回放</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-video-status">
|
||||
<span>{{ playingSummary }}</span>
|
||||
<el-pagination
|
||||
v-if="playingChannels.length > pageSize"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="playingChannels.length"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="changePage"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="device-video-footer">
|
||||
<span>录像时间最多可选 3 天 · 单选即播放,多选后点击「播放所选」</span
|
||||
><el-button @click="closeDialog">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
class="device-video-dialog"
|
||||
title="录像回放"
|
||||
width="calc(100% - 48px)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:before-close="closeDialog"
|
||||
>
|
||||
<template #header="{ titleId, titleClass }">
|
||||
<div class="device-video-heading">
|
||||
<span class="device-video-heading__icon"
|
||||
><el-icon aria-hidden="true"><Film /></el-icon
|
||||
></span>
|
||||
<h2 :id="titleId" :class="titleClass">录像回放</h2>
|
||||
<span class="device-video-heading__device" :title="deviceName">{{ deviceName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="device-video-layout">
|
||||
<VideoChannelPanel
|
||||
:channels="channels"
|
||||
:model-value="selectedCodes"
|
||||
:mode="selectionMode"
|
||||
:loading="loading"
|
||||
:playing-codes="playingCodes"
|
||||
:visible-codes="displayedCodes"
|
||||
:error="loadError"
|
||||
@update:model-value="changeSelection"
|
||||
@update:mode="changeSelectionMode"
|
||||
@play="playSelected"
|
||||
@retry="reloadChannels"
|
||||
/>
|
||||
<section class="device-video-workspace" aria-label="录像回放画面">
|
||||
<el-form class="device-video-query" :model="queryForm" label-position="top" @submit.prevent="playSelected">
|
||||
<el-form-item class="device-video-query__date" label="录像时间" prop="videoDateList">
|
||||
<el-date-picker
|
||||
v-model="queryForm.videoDateList"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
type="datetimerange"
|
||||
:disabled-date="disabledDate"
|
||||
:clearable="false"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
range-separator="至"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="码流" prop="streamType">
|
||||
<dict-select v-model="queryForm.streamType" :options="dh_video_stream_type" :clearable="false" placeholder="选择码流" />
|
||||
</el-form-item>
|
||||
<el-form-item label="录像来源" prop="recordSource">
|
||||
<dict-select v-model="queryForm.recordSource" :options="dh_replay_source" :clearable="false" placeholder="选择来源" />
|
||||
</el-form-item>
|
||||
<el-button :icon="Search" type="primary" native-type="submit" :disabled="loading || !selectedCodes.length">查询回放</el-button>
|
||||
</el-form>
|
||||
<div class="device-video-toolbar">
|
||||
<div class="device-video-toolbar__title">
|
||||
<el-icon aria-hidden="true"><Clock /></el-icon><span>录像回放</span>
|
||||
</div>
|
||||
<span class="device-video-toolbar__meta"
|
||||
>{{ playingChannels.length }} 个通道<span v-if="playingChannels.length"> · {{ division }} 画面</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="device-video-stage">
|
||||
<RtspPluginVideo
|
||||
v-if="dialogVisible && playingChannels.length"
|
||||
ref="playerRef"
|
||||
:active="dialogVisible"
|
||||
:window-type="3"
|
||||
:visible-yt="false"
|
||||
:division-setting-list="divisionOptions"
|
||||
/>
|
||||
<div v-else class="device-video-empty">
|
||||
<el-icon aria-hidden="true"><Film /></el-icon>
|
||||
<strong>{{ loading ? '正在加载通道' : '选择通道,查看录像' }}</strong>
|
||||
<span>选择录像时间,支持多通道分屏回放</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-video-status">
|
||||
<span>{{ playingSummary }}</span>
|
||||
<el-pagination
|
||||
v-if="playingChannels.length > pageSize"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="playingChannels.length"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="changePage"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="device-video-footer">
|
||||
<span>录像时间最多可选 3 天 · 单选即播放,多选后点击「播放所选」</span><el-button @click="closeDialog">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="ReplayVideoDialog">
|
||||
import { reactive } from "vue";
|
||||
import { Clock, Film, Search } from "@element-plus/icons-vue";
|
||||
import dayjs from "dayjs";
|
||||
import { useMessage } from "/@/hooks/message";
|
||||
import { useDict } from "/@/hooks/dict";
|
||||
import VideoChannelPanel from "../video-channel-panel.vue";
|
||||
import { useVideoDialog } from "../useVideoDialog";
|
||||
import { reactive } from 'vue';
|
||||
import { Clock, Film, Search } from '@element-plus/icons-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMessage } from '/@/hooks/message';
|
||||
import { useDict } from '/@/hooks/dict';
|
||||
import VideoChannelPanel from '../video-channel-panel.vue';
|
||||
import { useVideoDialog } from '../useVideoDialog';
|
||||
|
||||
const { dh_replay_source, dh_video_stream_type } = useDict(
|
||||
"dh_replay_source",
|
||||
"dh_video_stream_type"
|
||||
);
|
||||
const { dh_replay_source, dh_video_stream_type } = useDict('dh_replay_source', 'dh_video_stream_type');
|
||||
const getDefaultDateRange = () => [
|
||||
dayjs().subtract(2, "day").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
|
||||
dayjs().endOf("day").format("YYYY-MM-DD HH:mm:ss"),
|
||||
dayjs().subtract(2, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
||||
dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
||||
];
|
||||
const queryForm = reactive({
|
||||
streamType: "0",
|
||||
recordSource: "2",
|
||||
videoDateList: getDefaultDateRange() as string[] | null,
|
||||
streamType: '0',
|
||||
recordSource: '2',
|
||||
videoDateList: getDefaultDateRange() as string[] | null,
|
||||
});
|
||||
|
||||
const getPlayOptions = () => {
|
||||
const dates = queryForm.videoDateList;
|
||||
if (
|
||||
!dates ||
|
||||
dates.length !== 2 ||
|
||||
dates.some((date) => !dayjs(date).isValid())
|
||||
) {
|
||||
useMessage().warning("请选择完整的录像时间");
|
||||
return null;
|
||||
}
|
||||
const duration = dayjs(dates[1]).diff(dayjs(dates[0]), "second");
|
||||
if (duration < 0 || duration > 3 * 24 * 60 * 60) {
|
||||
useMessage().warning(
|
||||
"录像时间范围不能超过 3 天,且结束时间不能早于开始时间"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
streamType: Number(queryForm.streamType),
|
||||
recordSource: Number(queryForm.recordSource),
|
||||
startTime: dates[0],
|
||||
endTime: dates[1],
|
||||
};
|
||||
const dates = queryForm.videoDateList;
|
||||
if (!dates || dates.length !== 2 || dates.some((date) => !dayjs(date).isValid())) {
|
||||
useMessage().warning('请选择完整的录像时间');
|
||||
return null;
|
||||
}
|
||||
const duration = dayjs(dates[1]).diff(dayjs(dates[0]), 'second');
|
||||
if (duration < 0 || duration > 3 * 24 * 60 * 60) {
|
||||
useMessage().warning('录像时间范围不能超过 3 天,且结束时间不能早于开始时间');
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
streamType: Number(queryForm.streamType),
|
||||
recordSource: Number(queryForm.recordSource),
|
||||
startTime: dates[0],
|
||||
endTime: dates[1],
|
||||
};
|
||||
};
|
||||
|
||||
const {
|
||||
dialogVisible,
|
||||
channels,
|
||||
selectedCodes,
|
||||
selectionMode,
|
||||
loading,
|
||||
loadError,
|
||||
playingCodes,
|
||||
playingChannels,
|
||||
deviceName,
|
||||
playerRef,
|
||||
currentPage,
|
||||
pageSize,
|
||||
division,
|
||||
divisionOptions,
|
||||
playingSummary,
|
||||
openDialog: openVideoDialog,
|
||||
closeDialog,
|
||||
changeSelection,
|
||||
changeSelectionMode,
|
||||
playSelected,
|
||||
changePage,
|
||||
reloadChannels,
|
||||
} = useVideoDialog({ mode: "replay", getPlayOptions });
|
||||
dialogVisible,
|
||||
channels,
|
||||
selectedCodes,
|
||||
selectionMode,
|
||||
loading,
|
||||
loadError,
|
||||
playingCodes,
|
||||
displayedCodes,
|
||||
playingChannels,
|
||||
deviceName,
|
||||
playerRef,
|
||||
currentPage,
|
||||
pageSize,
|
||||
division,
|
||||
divisionOptions,
|
||||
playingSummary,
|
||||
openDialog: openVideoDialog,
|
||||
closeDialog,
|
||||
changeSelection,
|
||||
changeSelectionMode,
|
||||
playSelected,
|
||||
changePage,
|
||||
reloadChannels,
|
||||
} = useVideoDialog({ mode: 'replay', getPlayOptions });
|
||||
|
||||
const openDialog = (
|
||||
type: string,
|
||||
code: string,
|
||||
timeRange?: { startTime: string; endTime: string }
|
||||
) => {
|
||||
queryForm.videoDateList = timeRange
|
||||
? [timeRange.startTime, timeRange.endTime]
|
||||
: getDefaultDateRange();
|
||||
return openVideoDialog(type, code);
|
||||
const openDialog = (type: string, code: string, timeRange?: { startTime: string; endTime: string }) => {
|
||||
queryForm.videoDateList = timeRange ? [timeRange.startTime, timeRange.endTime] : getDefaultDateRange();
|
||||
return openVideoDialog(type, code);
|
||||
};
|
||||
|
||||
const handleDateChange = () => {
|
||||
if (queryForm.videoDateList && !getPlayOptions())
|
||||
queryForm.videoDateList = getDefaultDateRange();
|
||||
if (queryForm.videoDateList && !getPlayOptions()) queryForm.videoDateList = getDefaultDateRange();
|
||||
};
|
||||
const disabledDate = (time: Date) =>
|
||||
time.getTime() > dayjs().endOf("day").valueOf();
|
||||
const disabledDate = (time: Date) => time.getTime() > dayjs().endOf('day').valueOf();
|
||||
|
||||
defineExpose({ openDialog });
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../video-dialog.scss";
|
||||
@import '../video-dialog.scss';
|
||||
</style>
|
||||
@@ -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<string, string | number> | null;
|
||||
mode: 'preview' | 'replay';
|
||||
getPlayOptions: () => Record<string, string | number> | null;
|
||||
}
|
||||
interface DialogPlayer {
|
||||
startReal: (channels: Record<string, unknown>[]) => Promise<void>;
|
||||
startPlayback: (channels: Record<string, unknown>[]) => Promise<void>;
|
||||
closeVideo: () => Promise<void>;
|
||||
destroy?: () => void;
|
||||
updateVisible: (visible: boolean) => void;
|
||||
startReal: (channels: Record<string, unknown>[]) => Promise<void>;
|
||||
startPlayback: (channels: Record<string, unknown>[]) => Promise<void>;
|
||||
closeVideo: () => Promise<void>;
|
||||
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<VideoChannel[]>([]);
|
||||
const selectedCodes = ref<string[]>([]);
|
||||
const playingCodes = ref<string[]>([]);
|
||||
const selectionMode = ref<ChannelSelectionMode>("single");
|
||||
const playerRef = ref<DialogPlayer>();
|
||||
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<string, string | number> = {};
|
||||
const dialogVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadError = ref('');
|
||||
const channels = ref<VideoChannel[]>([]);
|
||||
const selectedCodes = ref<string[]>([]);
|
||||
const playingCodes = ref<string[]>([]);
|
||||
const selectionMode = ref<ChannelSelectionMode>('single');
|
||||
const playerRef = ref<DialogPlayer>();
|
||||
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<string, string | number> = {};
|
||||
|
||||
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<string, string> = {};
|
||||
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<string>();
|
||||
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<string, string> = {};
|
||||
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<string>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,404 +1,394 @@
|
||||
<template>
|
||||
<aside class="video-channel-panel" aria-label="通道选择">
|
||||
<div class="video-channel-panel__heading">
|
||||
<h3>通道列表</h3>
|
||||
<span>{{ channels.length }}</span>
|
||||
</div>
|
||||
<div class="video-channel-panel__tools">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索通道 / 设备"
|
||||
aria-label="搜索通道或设备"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
:disabled="loading"
|
||||
/>
|
||||
<el-radio-group
|
||||
class="video-channel-panel__mode"
|
||||
:model-value="mode"
|
||||
aria-label="通道选择方式"
|
||||
:disabled="loading"
|
||||
@update:model-value="
|
||||
emit('update:mode', $event as ChannelSelectionMode)
|
||||
"
|
||||
>
|
||||
<el-radio-button label="single">单选</el-radio-button>
|
||||
<el-radio-button label="multiple">多选</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="video-channel-panel__bulk">
|
||||
<span>{{
|
||||
keyword
|
||||
? `找到 ${filteredChannels.length} 个通道`
|
||||
: mode === "single"
|
||||
? "选择后直接播放"
|
||||
: "勾选通道后统一播放"
|
||||
}}</span>
|
||||
<div v-if="mode === 'multiple'">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="loading || !filteredChannels.length"
|
||||
@click="selectAll"
|
||||
>{{ keyword ? "全选结果" : "全选" }}</el-button
|
||||
>
|
||||
<el-button
|
||||
link
|
||||
:disabled="loading || !modelValue.length"
|
||||
@click="emit('update:modelValue', [])"
|
||||
>清空</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-loading="loading"
|
||||
class="video-channel-panel__list"
|
||||
:role="mode === 'single' ? 'radiogroup' : 'group'"
|
||||
aria-label="可选通道"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<label
|
||||
v-for="channel in filteredChannels"
|
||||
:key="channel.channelCode"
|
||||
class="video-channel-row"
|
||||
:class="{ 'is-selected': modelValue.includes(channel.channelCode) }"
|
||||
>
|
||||
<input
|
||||
:type="mode === 'single' ? 'radio' : 'checkbox'"
|
||||
:name="inputName"
|
||||
:value="channel.channelCode"
|
||||
:checked="modelValue.includes(channel.channelCode)"
|
||||
:disabled="loading"
|
||||
@change="
|
||||
toggleChannel(
|
||||
channel.channelCode,
|
||||
($event.target as HTMLInputElement).checked
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="video-channel-row__icon"
|
||||
><el-icon aria-hidden="true"><VideoCamera /></el-icon
|
||||
></span>
|
||||
<span class="video-channel-row__text">
|
||||
<strong :title="channel.channelName || '未命名通道'">{{
|
||||
channel.channelName || "未命名通道"
|
||||
}}</strong>
|
||||
<span :title="channel.deviceName"
|
||||
>通道 {{ channel.channelSeq ?? "—"
|
||||
}}<template v-if="channel.deviceName">
|
||||
· {{ channel.deviceName }}</template
|
||||
></span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
v-if="playingCodes.includes(channel.channelCode)"
|
||||
class="video-channel-row__current"
|
||||
>当前</span
|
||||
>
|
||||
</label>
|
||||
<div
|
||||
v-if="!loading && !filteredChannels.length"
|
||||
class="video-channel-panel__empty"
|
||||
>
|
||||
<el-icon aria-hidden="true"
|
||||
><Search v-if="keyword" /><VideoCamera v-else
|
||||
/></el-icon>
|
||||
<span>{{
|
||||
error || (keyword ? "未找到匹配通道" : "暂无可用通道")
|
||||
}}</span>
|
||||
<el-button v-if="error" link type="primary" @click="emit('retry')"
|
||||
>重新加载</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-channel-panel__footer">
|
||||
<div class="video-channel-panel__selection" aria-live="polite">
|
||||
<span
|
||||
>已选 <strong>{{ modelValue.length }}</strong> 个通道</span
|
||||
><span v-if="hasChanges" class="video-channel-panel__pending"
|
||||
>待播放</span
|
||||
>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="mode === 'multiple'"
|
||||
type="primary"
|
||||
:icon="VideoPlay"
|
||||
:disabled="loading || !modelValue.length"
|
||||
@click="emit('play')"
|
||||
>播放所选</el-button
|
||||
>
|
||||
</div>
|
||||
</aside>
|
||||
<aside class="video-channel-panel" aria-label="通道选择">
|
||||
<div class="video-channel-panel__heading">
|
||||
<h3>通道列表</h3>
|
||||
<span>{{ channels.length }}</span>
|
||||
</div>
|
||||
<div class="video-channel-panel__tools">
|
||||
<el-input v-model="keyword" placeholder="搜索通道 / 设备" label="搜索通道或设备" :prefix-icon="Search" clearable :disabled="loading" />
|
||||
<el-radio-group
|
||||
class="video-channel-panel__mode"
|
||||
:model-value="mode"
|
||||
aria-label="通道选择方式"
|
||||
:disabled="loading"
|
||||
@update:model-value="emit('update:mode', $event as ChannelSelectionMode)"
|
||||
>
|
||||
<el-radio-button label="single">单选</el-radio-button>
|
||||
<el-radio-button label="multiple">多选</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="video-channel-panel__bulk">
|
||||
<span>{{ keyword ? `找到 ${filteredChannels.length} 个通道` : mode === 'single' ? '选择后直接播放' : '勾选通道后统一播放' }}</span>
|
||||
<div v-if="mode === 'multiple'">
|
||||
<el-button link type="primary" :disabled="loading || !filteredChannels.length" @click="selectAll">{{
|
||||
keyword ? '全选结果' : '全选'
|
||||
}}</el-button>
|
||||
<el-button link :disabled="loading || !modelValue.length" @click="emit('update:modelValue', [])">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-loading="loading"
|
||||
class="video-channel-panel__list"
|
||||
:role="mode === 'single' ? 'radiogroup' : 'group'"
|
||||
aria-label="可选通道"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<label
|
||||
v-for="channel in filteredChannels"
|
||||
:key="channel.channelCode"
|
||||
class="video-channel-row"
|
||||
:class="{ 'is-selected': modelValue.includes(channel.channelCode) }"
|
||||
>
|
||||
<input
|
||||
:type="mode === 'single' ? 'radio' : 'checkbox'"
|
||||
:name="inputName"
|
||||
:value="channel.channelCode"
|
||||
:checked="modelValue.includes(channel.channelCode)"
|
||||
:disabled="loading"
|
||||
@change="toggleChannel(channel.channelCode, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="video-channel-row__icon"
|
||||
><el-icon aria-hidden="true"><VideoCamera /></el-icon
|
||||
></span>
|
||||
<span class="video-channel-row__text">
|
||||
<strong :title="channel.channelName || '未命名通道'">{{ channel.channelName || '未命名通道' }}</strong>
|
||||
<span :title="channel.deviceName"
|
||||
>通道 {{ channel.channelSeq ?? '—' }}<template v-if="channel.deviceName"> · {{ channel.deviceName }}</template></span
|
||||
>
|
||||
</span>
|
||||
<span v-if="(visibleCodes || playingCodes).includes(channel.channelCode)" class="video-channel-row__current">当前</span>
|
||||
</label>
|
||||
<div v-if="!loading && !filteredChannels.length" class="video-channel-panel__empty">
|
||||
<el-icon aria-hidden="true"><Search v-if="keyword" /><VideoCamera v-else /></el-icon>
|
||||
<span>{{ error || (keyword ? '未找到匹配通道' : '暂无可用通道') }}</span>
|
||||
<el-button v-if="error" link type="primary" @click="emit('retry')">重新加载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-channel-panel__footer">
|
||||
<div class="video-channel-panel__selection" aria-live="polite">
|
||||
<span
|
||||
>已选 <strong>{{ modelValue.length }}</strong> 个通道</span
|
||||
><span v-if="hasChanges" class="video-channel-panel__pending">待播放</span>
|
||||
</div>
|
||||
<el-button v-if="mode === 'multiple'" type="primary" :icon="VideoPlay" :disabled="loading || !modelValue.length" @click="emit('play')"
|
||||
>播放所选</el-button
|
||||
>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, ref } from "vue";
|
||||
import { Search, VideoCamera, VideoPlay } from "@element-plus/icons-vue";
|
||||
import type { ChannelSelectionMode, VideoChannel } from "./useVideoDialog";
|
||||
import { computed, getCurrentInstance, ref, watch } from 'vue';
|
||||
import { Search, VideoCamera, VideoPlay } from '@element-plus/icons-vue';
|
||||
import type { ChannelSelectionMode, VideoChannel } from './useVideoDialog';
|
||||
|
||||
const props = defineProps<{
|
||||
channels: VideoChannel[];
|
||||
modelValue: string[];
|
||||
mode: ChannelSelectionMode;
|
||||
loading: boolean;
|
||||
playingCodes: string[];
|
||||
error?: string;
|
||||
channels: VideoChannel[];
|
||||
modelValue: string[];
|
||||
mode: ChannelSelectionMode;
|
||||
loading: boolean;
|
||||
playingCodes: string[];
|
||||
visibleCodes?: string[];
|
||||
error?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", codes: string[]): void;
|
||||
(event: "update:mode", mode: ChannelSelectionMode): void;
|
||||
(event: "play"): void;
|
||||
(event: "retry"): void;
|
||||
(event: 'update:modelValue', codes: string[]): void;
|
||||
(event: 'update:mode', mode: ChannelSelectionMode): void;
|
||||
(event: 'play'): void;
|
||||
(event: 'retry'): void;
|
||||
}>();
|
||||
const keyword = ref("");
|
||||
const keyword = ref('');
|
||||
watch(
|
||||
() => props.channels,
|
||||
() => {
|
||||
keyword.value = '';
|
||||
}
|
||||
);
|
||||
const inputName = `video-channel-${getCurrentInstance()?.uid}`;
|
||||
const filteredChannels = computed(() => {
|
||||
const query = keyword.value.trim().toLowerCase();
|
||||
return props.channels.filter((channel) =>
|
||||
[
|
||||
channel.channelName,
|
||||
channel.channelSeq,
|
||||
channel.deviceName,
|
||||
channel.channelCode,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
const query = keyword.value.trim().toLowerCase();
|
||||
return props.channels.filter((channel) =>
|
||||
[channel.channelName, channel.channelSeq, channel.deviceName, channel.channelCode].join(' ').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
const hasChanges = computed(
|
||||
() =>
|
||||
props.mode === "multiple" &&
|
||||
(props.modelValue.length !== props.playingCodes.length ||
|
||||
props.modelValue.some((code) => !props.playingCodes.includes(code)))
|
||||
() =>
|
||||
props.mode === 'multiple' &&
|
||||
(props.modelValue.length !== props.playingCodes.length || props.modelValue.some((code) => !props.playingCodes.includes(code)))
|
||||
);
|
||||
const toggleChannel = (code: string, checked: boolean) => {
|
||||
if (props.mode === "single") emit("update:modelValue", [code]);
|
||||
else
|
||||
emit(
|
||||
"update:modelValue",
|
||||
checked
|
||||
? [...new Set([...props.modelValue, code])]
|
||||
: props.modelValue.filter((value) => value !== code)
|
||||
);
|
||||
if (props.mode === 'single') emit('update:modelValue', [code]);
|
||||
else emit('update:modelValue', checked ? [...new Set([...props.modelValue, code])] : props.modelValue.filter((value) => value !== code));
|
||||
};
|
||||
const selectAll = () =>
|
||||
emit("update:modelValue", [
|
||||
...new Set([
|
||||
...props.modelValue,
|
||||
...filteredChannels.value.map((channel) => channel.channelCode),
|
||||
]),
|
||||
]);
|
||||
emit('update:modelValue', [...new Set([...props.modelValue, ...filteredChannels.value.map((channel) => channel.channelCode)])]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-channel-panel {
|
||||
width: 260px;
|
||||
flex: 0 0 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: 260px;
|
||||
flex: 0 0 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 18px 16px 14px;
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
> span {
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
&__tools {
|
||||
padding: 0 12px;
|
||||
}
|
||||
&__mode {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
:deep(.el-radio-button) {
|
||||
flex: 1;
|
||||
}
|
||||
:deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
&__bulk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
min-height: 42px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
.el-button {
|
||||
font-size: 12px;
|
||||
}
|
||||
.el-button + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
&__list {
|
||||
flex: 1;
|
||||
min-height: 100px;
|
||||
padding: 0 8px 8px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 36px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
> .el-icon {
|
||||
font-size: 28px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
&__footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
&__selection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
strong {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
&__pending {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
&__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 18px 16px 14px;
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
> span {
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
&__tools {
|
||||
padding: 0 12px;
|
||||
}
|
||||
&__mode {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
:deep(.el-radio-button) {
|
||||
flex: 1;
|
||||
}
|
||||
:deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
&__bulk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
min-height: 42px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
.el-button {
|
||||
font-size: 12px;
|
||||
}
|
||||
.el-button + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
&__list {
|
||||
flex: 1;
|
||||
min-height: 100px;
|
||||
padding: 0 8px 8px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--el-border-color-darker) var(--el-bg-color);
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar-track-piece {
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--el-border-color-darker);
|
||||
border-radius: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 36px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
> .el-icon {
|
||||
font-size: 28px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
&__footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
&__selection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
strong {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
&__pending {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.video-channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 66px;
|
||||
padding: 10px 8px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
&.is-selected {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
}
|
||||
&:has(input:focus-visible) {
|
||||
outline: 2px solid var(--el-color-primary) !important;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 15px;
|
||||
margin: 0;
|
||||
accent-color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
&__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 5px;
|
||||
}
|
||||
&.is-selected &__icon {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
strong,
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
> span {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
&__current {
|
||||
font-size: 11px;
|
||||
color: var(--el-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 66px;
|
||||
padding: 10px 8px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
&.is-selected {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
}
|
||||
&:has(input:focus-visible) {
|
||||
outline: 2px solid var(--el-color-primary) !important;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
input {
|
||||
// 保留原生单选/复选语义,外观使用项目主题,避免继承系统深色实心底。
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--el-text-color-secondary);
|
||||
border-radius: 4px;
|
||||
background: var(--el-bg-color);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
&:checked {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
&[type='checkbox']:checked::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
margin-top: -2px;
|
||||
border: solid currentColor;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
&[type='radio'] {
|
||||
border-radius: 50%;
|
||||
&:checked {
|
||||
background: var(--el-bg-color);
|
||||
&::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
&__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 5px;
|
||||
}
|
||||
&.is-selected &__icon {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
strong,
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
> span {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
&__current {
|
||||
font-size: 11px;
|
||||
color: var(--el-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.video-channel-panel {
|
||||
width: 232px;
|
||||
flex-basis: 232px;
|
||||
}
|
||||
.video-channel-panel {
|
||||
width: 232px;
|
||||
flex-basis: 232px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.video-channel-panel {
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
max-height: 290px;
|
||||
&__heading {
|
||||
padding-top: 12px;
|
||||
}
|
||||
&__list {
|
||||
min-height: 68px;
|
||||
}
|
||||
&__footer {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
.video-channel-row {
|
||||
min-height: 58px;
|
||||
}
|
||||
.video-channel-panel {
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
max-height: 290px;
|
||||
&__heading {
|
||||
padding-top: 12px;
|
||||
}
|
||||
&__list {
|
||||
min-height: 68px;
|
||||
}
|
||||
&__footer {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
.video-channel-row {
|
||||
min-height: 58px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.video-channel-row {
|
||||
transition: none;
|
||||
}
|
||||
.video-channel-row {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof mount>[] = [];
|
||||
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<string, string | number> | null)) {
|
||||
let dialog!: ReturnType<typeof useVideoDialog>;
|
||||
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<void>((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();
|
||||
});
|
||||
});
|
||||
@@ -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 = '<div id="late-create"></div>';
|
||||
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 = '<div id="missing-dom"></div>';
|
||||
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 = '<div id="cancel-create"></div>';
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+6
-1
@@ -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'] } },
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user