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

This commit is contained in:
dzc committed 2026-10-27 15:35:21 +08:00
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>
+189 -227
View File
@@ -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>
+342 -309
View File
@@ -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;
}
+269
View File
@@ -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();
});
});
+141
View File
@@ -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);
});
});
+86 -2
View File
@@ -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
View File
@@ -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'] } },
},
});