This commit is contained in:
dzc committed 2026-10-27 15:07:04 +08:00
1 parent 7ed145bb14
commit 744455243d
5 files changed
+1239 -1018

No files matched your search

@@ -1,260 +1,126 @@
<template>
<el-dialog destroy-on-close v-model="state.dialogVisible" title="预览" width="80%" append-to-body
:before-close="handleClose">
<div class="dialog-video-layout">
<div class="min-w-[25%]">
<el-tree :data="channel.treeList" :props="{
label: 'name',
children: 'children',
value: 'id',
}" ref="orgTreeRef" :loading="state.treeLoading" node-key="id" highlight-current
:default-expand-all="true" :expand-on-click-node="false" @node-click="handleNodeClick">
<template #default="{ node, data }" v-if="$slots.default">
<slot :node="node" :data="data"></slot>
</template>
</el-tree>
</div>
<div class="dialog-video-player">
<RtspPluginVideo ref="previewVideoRef" :active="state.dialogVisible" @changePage="videoChangePage" />
</div>
<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>
<template #footer>
<div class="dialog-footer">
<div />
<el-button @click="handleClose">取消</el-button>
</div>
</template>
</el-dialog>
<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 { useMessage } from "/@/hooks/message";
import { VideoCamera, VideoPlay } from "@element-plus/icons-vue";
import VideoChannelPanel from "../video-channel-panel.vue";
import { useVideoDialog } from "../useVideoDialog";
import * as DeviceManageApi from "/@/api/device/deviceManage"
import * as DeviceChannelApi from "/@/api/device/deviceChannel"
const previewVideoRef = ref()
let requestSequence = 0
const state = reactive<any>({
dialogVisible: false,
// device-设备维度 code=设备id;channel-通道维度 code=通道编码
type: "device",
code: null,
channelList: [],
loading: false,
treeLoading: false,
current: 1,
size: 64,
})
const detail = reactive<any>({})
const channel = reactive<any>({
treeList: [],
list: []
})
const handleClose = () => {
++requestSequence
state.dialogVisible = false
previewVideoRef.value?.destroy?.()
}
// 打开弹窗
const openDialog = async (type: string, code: string) => {
if (!type || !code) return
const requestId = ++requestSequence
state.type = type
state.code = code
state.dialogVisible = true
if (state.type === "device") {
// 获取deviceManage信息
await getdeviceManageData(code)
if (requestId !== requestSequence || !state.dialogVisible) return
await getDeviceChannelList()
return
}
if (state.type === "channel") {
await getDeviceChannelList()
return
}
};
const videoChangePage = async ({ current, size }: any) => {
const requestId = requestSequence
state.current = current
state.size = size
console.log("state:", state);
const channelList: any[] = []
for (let i = 0; i < channel.list.length; i++) {
const v: any = channel.list[i]
channelList.push({
channelId: v.channelCode,
channelName: v.channelName,
// 默认辅码流
streamType: 2
})
}
// 按照分页截取
const start = (state.current - 1) * state.size
const end = start + state.size
const pageList = channelList.slice(start, end).map((v: any, idx: number) => ({
...v,
snum: idx
}))
await previewVideoRef.value?.closeVideo()
if (requestId !== requestSequence || !state.dialogVisible) return
previewVideoRef.value?.setTotal(channelList.length)
previewVideoRef.value?.startReal(pageList)
}
// 点击树
const handleNodeClick = (e: any) => {
if (e.id == -1) {
// 获取所有的channelCodeList
const channelList: any[] = []
for (let i = 0; i < channel.list.length; i++) {
const v: any = channel.list[i]
channelList.push({
channelId: v.channelCode,
channelName: v.channelName,
snum: i,
// 默认辅码流
streamType: 2
})
}
// 按照分页截取
const start = (state.current - 1) * state.size
const end = start + state.size
const pageList = channelList.slice(start, end).map((v: any, idx: number) => ({
...v,
snum: idx
}))
previewVideoRef.value?.setTotal(channelList.length)
previewVideoRef.value?.startReal(pageList)
return
}
const targetChannel = channel.list.find((v: any) => v.channelCode == e.channelCode)
previewVideoRef.value?.startReal([
{
channelId: targetChannel.channelCode,
channelName: targetChannel.channelName,
snum: 0,
// 默认辅码流
streamType: 2
}
])
}
// 初始化表单数据
const getdeviceManageData = async (id: string) => {
const requestId = requestSequence
// 获取数据
state.loading = true
try {
const { data, code } = await DeviceManageApi.getObj(id)
if (requestId !== requestSequence || !state.dialogVisible) return
if (code != 0) throw new Error()
Object.assign(detail, data)
} catch (error: any) {
console.error(error);
useMessage().error(error?.msg ?? "意外的错误");
} finally {
state.loading = false
}
};
// 查询通道树
const getDeviceChannelList = async () => {
const requestId = requestSequence
state.treeLoading = true
try {
const _data: any = {}
if (state.type == "device") {
_data.deviceCode = detail.deviceCode
}
if (state.type == "channel") {
_data.channelCode = state.code
}
const { data, code } = await DeviceChannelApi.list(_data)
if (requestId !== requestSequence || !state.dialogVisible) return
if (code != 0) throw new Error()
channel.treeList = data
channel.list = data
// 处理树
if (data && data.length) {
const tree = data.map((v: any) => {
return {
...v,
name: `通道:${v.channelSeq}-设备:${v.deviceName}-通道:${v.channelName}`,
children: v.children
}
})
channel.treeList = [
{
id: "-1",
name: '全部通道',
children: tree
}
]
}
// 获取所有的channelCodeList
const channelList: any[] = []
for (let i = 0; i < channel.list.length; i++) {
const v: any = channel.list[i]
channelList.push({
channelId: v.channelCode,
channelName: v.channelName,
snum: i,
// 默认辅码流
streamType: 2
})
}
// 按照分页截取
const start = (state.current - 1) * state.size
const end = start + state.size
const pageList = channelList.slice(start, end)
previewVideoRef.value?.setTotal(channelList.length)
previewVideoRef.value?.startReal(pageList)
} catch (error: any) {
console.error("|查询通道树|,意外的错误", error);
useMessage().error(error?.msg ?? "意外的错误");
} finally {
state.treeLoading = false
}
}
defineExpose({
openDialog
const {
dialogVisible,
channels,
selectedCodes,
selectionMode,
loading,
loadError,
playingCodes,
playingChannels,
deviceName,
playerRef,
currentPage,
pageSize,
division,
divisionOptions,
playingSummary,
openDialog,
closeDialog,
changeSelection,
changeSelectionMode,
playSelected,
changePage,
reloadChannels,
} = useVideoDialog({
mode: "preview",
getPlayOptions: () => ({ streamType: 2 }),
});
defineExpose({ openDialog });
</script>
<style lang="scss" scoped>
.dialog-video-layout {
display: flex;
align-items: flex-start;
min-width: 0;
}
.dialog-video-player {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
justify-content: center;
overflow: hidden;
}
.dialog-video-player :deep(.video-container) {
flex: 0 1 auto;
max-width: min(100%, calc((100vh - 180px) * 16 / 9));
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-content: center;
}
<style lang="scss">
@import "../video-dialog.scss";
</style>
@@ -1,410 +1,231 @@
<template>
<el-dialog destroy-on-close v-model="state.dialogVisible" title="回放" width="80%" :before-close="handleClose">
<div class="dialog-video-layout">
<div v-if="state.type == 'device'" class="min-w-[25%]">
<el-tree :data="channel.treeList" :props="{
label: 'name',
children: 'children',
value: 'id',
}" ref="orgTreeRef" :loading="state.treeLoading" node-key="id" highlight-current
:default-expand-all="true" :expand-on-click-node="false" @node-click="handleNodeClick">
<template #default="{ node, data }" v-if="$slots.default">
<slot :node="node" :data="data"></slot>
</template>
</el-tree>
</div>
<div class="dialog-video-content">
<el-form :model="queryForm" ref="queryRef" :inline="true" @keyup.enter="doSearch">
<el-form-item style="width: 450px;" label="录像时间" prop="videoDateList">
<el-date-picker v-model="queryForm.videoDateList" date-format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss" type="datetimerange" :disabled-date="disabledDate"
placeholder="请选择日期范围" start-placeholder="开始日期" end-placeholder="结束日期"
@change="handleDateChange" />
</el-form-item>
<el-form-item style="width: 250px;" label="码流" prop="streamType">
<dict-select v-model="queryForm.streamType" :options="dh_video_stream_type"
placeholder="请选择码流" />
</el-form-item>
<el-form-item style="width: 250px;" label="录像来源" prop="recordSource">
<dict-select v-model="queryForm.recordSource" :options="dh_replay_source"
placeholder="请选择录像来源" />
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="doSearch">
确定
</el-button>
</el-form-item>
</el-form>
<div class="dialog-video-player">
<RtspPluginVideo ref="replayVideoRef" :active="state.dialogVisible" @changePage="videoChangePage" />
</div>
</div>
<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>
<template #footer>
<div class="dialog-footer">
<div />
<el-button @click="handleClose">取消</el-button>
</div>
</template>
</el-dialog>
<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 { useMessage } from "/@/hooks/message";
import { useDict } from '/@/hooks/dict';
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 * as AlarmReplayVideoListApi from "/@/api/third/deviceCameraReplay"
import * as DeviceManageApi from "/@/api/device/deviceManage"
import * as DeviceChannelApi from "/@/api/device/deviceChannel"
// 定义查询字典
const { dh_replay_source, dh_video_stream_type } = useDict("dh_replay_source", "dh_video_stream_type")
let replayVideo: any = null
const replayVideoRef = ref()
let requestSequence = 0
const state = reactive<any>({
startTimestamp: null,
endTimestamp: null,
dialogVisible: false,
loading: false,
treeLoading: false,
numList: [1, 4, 9, 16, 25],
type: "device",
code: null,
current: 1,
size: 64,
})
const detail = reactive<any>({})
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"),
];
const queryForm = reactive({
ownerCode: null,
orgCodes: null,
isInclude: null,
streamType: "0",
recordSource: "2",
videoDateList: getDefaultDateRange(),
current: 1,
size: 64,
})
streamType: "0",
recordSource: "2",
videoDateList: getDefaultDateRange() as string[] | null,
});
const channel = reactive<any>({
treeList: [],
list: [],
})
// 同步时间戳(秒)到 state,来源于 YYYY-MM-DD HH:mm:ss 字符串
function syncTimestampsFromQuery() {
const range = queryForm.videoDateList || []
if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) {
state.startTimestamp = dayjs(range[0]).unix()
state.endTimestamp = dayjs(range[1]).unix()
}
}
// 初始化一次
syncTimestampsFromQuery()
// 打开弹窗
const openDialog = async (type: string, code: string, timeRange?: { startTime: string, endTime: string }) => {
if (!type || !code) return
state.type = type
state.code = code
state.dialogVisible = true
// 如果传入了时间范围,则设置查询表单的时间
if (timeRange) {
queryForm.videoDateList = [timeRange.startTime, timeRange.endTime]
// 同时设置时间戳
state.startTimestamp = dayjs(timeRange.startTime).unix()
state.endTimestamp = dayjs(timeRange.endTime).unix()
}
// 保底同步一次(适配未传 timeRange 的情况)
syncTimestampsFromQuery()
await doSearch()
}
const doSearch = async () => {
const requestId = ++requestSequence
// 确保接口查询与播放器时间戳一致
syncTimestampsFromQuery()
// 设备详情
if (state.type === "device") {
await getdeviceManageData(state.code);
if (requestId !== requestSequence || !state.dialogVisible) return
}
// 设备通道详情
await getDeviceChannelList(state.code)
}
const handleClose = () => {
++requestSequence
state.dialogVisible = false
replayVideoRef.value?.destroy?.()
}
const videoChangePage = async ({ current, size }: any) => {
const requestId = requestSequence
state.current = current
state.size = size
console.log("state:", state);
const channelList: any[] = []
for (let i = 0; i < channel.list.length; i++) {
const v: any = channel.list[i]
channelList.push({
channelId: v.channelCode,
channelName: v.channelName,
snum: i,
// 默认辅码流
streamType: Number(queryForm.streamType),
// 默认前3天(格式YYYY-MM-DD HH:mm:ss)
startTime: queryForm.videoDateList[0],
endTime: queryForm.videoDateList[1],
// 录像来源:2-设备录像 3-中心录像
recordSource: Number(queryForm.recordSource)
})
}
// 按照分页截取
const start = (state.current - 1) * state.size
const end = start + state.size
const pageList = channelList.slice(start, end).map((v: any, idx: number) => ({
...v,
snum: idx
}))
await replayVideoRef.value?.closeVideo()
if (requestId !== requestSequence || !state.dialogVisible) return
replayVideoRef.value?.setTotal(channelList.length)
replayVideoRef.value?.startPlayback(pageList)
}
// 点击树
const handleNodeClick = (e: any) => {
if (e.id == -1) {
// 获取所有的channelCodeList
const channelList: any[] = []
for (let i = 0; i < channel.list.length; i++) {
const v: any = channel.list[i]
channelList.push({
channelId: v.channelCode,
channelName: v.channelName,
snum: i,
// 默认辅码流
streamType: Number(queryForm.streamType),
// 默认前3天(格式YYYY-MM-DD HH:mm:ss)
startTime: queryForm.videoDateList[0],
endTime: queryForm.videoDateList[1],
// 录像来源:2-设备录像 3-中心录像
recordSource: Number(queryForm.recordSource)
})
}
// 按照分页截取
const start = (state.current - 1) * state.size
const end = start + state.size
const pageList = channelList.slice(start, end).map((v: any, idx: number) => ({
...v,
snum: idx
}))
replayVideoRef.value?.setTotal(channelList.length)
replayVideoRef.value?.startPlayback(pageList)
return
}
const targetChannel = channel.list.find((v: any) => v.channelCode == e.channelCode)
const channelList = [
{
channelId: targetChannel.channelCode,
channelName: targetChannel.channelName,
snum: 0,
// 默认辅码流
streamType: Number(queryForm.streamType),
// 默认前3天(格式YYYY-MM-DD HH:mm:ss)
startTime: queryForm.videoDateList[0],
endTime: queryForm.videoDateList[1],
// 录像来源:2-设备录像 3-中心录像
recordSource: Number(queryForm.recordSource)
}
]
replayVideoRef.value?.startPlayback([...channelList])
}
// 初始化表单数据
const getdeviceManageData = async (deviceCode: string) => {
const requestId = requestSequence
// 获取数据
state.loading = true
try {
const { data, code } = await DeviceManageApi.getByCode({ code: deviceCode })
if (requestId !== requestSequence || !state.dialogVisible) return
if (code != 0) throw new Error()
Object.assign(detail, data)
} catch (error: any) {
console.error(error);
useMessage().error(error?.msg ?? "意外的错误");
} finally {
state.loading = false
}
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 getDeviceChannelList = async (codeStr: any) => {
const requestId = requestSequence
state.treeLoading = true
try {
const _data: any = {}
if (state.type == "device") {
_data.deviceCode = detail.deviceCode
}
if (state.type == "channel") {
_data.channelCode = codeStr
}
if (state.type == "playbackGroup") {
_data.playbackGroupChannelCode = codeStr
}
const { data, code } = await DeviceChannelApi.list(_data)
if (requestId !== requestSequence || !state.dialogVisible) return
if (code != 0) throw new Error()
if (!data || !data.length) {
useMessage().error("查询不到设备通道")
return
}
channel.treeList = data
channel.list = data
// 处理树
if (data && data.length) {
const tree = data.map((v: any) => {
return {
...v,
name: `通道:${v.channelSeq}-设备:${v.deviceName}-通道:${v.channelName}`,
children: v.children
}
})
channel.treeList = [
{
id: "-1",
name: '全部通道',
children: tree
}
]
}
// 获取所有的channelCodeList
const channelList: any[] = []
for (let i = 0; i < channel.list.length; i++) {
const v: any = channel.list[i]
channelList.push({
channelId: v.channelCode,
channelName: v.channelName,
snum: i,
// 默认辅码流
streamType: Number(queryForm.streamType),
// 默认前3天(格式YYYY-MM-DD HH:mm:ss)
startTime: queryForm.videoDateList[0],
endTime: queryForm.videoDateList[1],
// 录像来源:2-设备录像 3-中心录像
recordSource: Number(queryForm.recordSource)
})
}
// 按照分页截取
const start = (state.current - 1) * state.size
const end = start + state.size
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 });
const pageList = channelList.slice(start, end).map((v: any, idx: number) => ({
...v,
snum: idx
}))
const openDialog = (
type: string,
code: string,
timeRange?: { startTime: string; endTime: string }
) => {
queryForm.videoDateList = timeRange
? [timeRange.startTime, timeRange.endTime]
: getDefaultDateRange();
return openVideoDialog(type, code);
};
replayVideoRef.value?.setTotal(channelList.length)
replayVideoRef.value?.startPlayback(pageList)
} catch (error: any) {
console.error(error);
useMessage().error(error?.msg ?? "意外的错误");
} finally {
state.treeLoading = false
}
}
const handleDateChange = () => {
if (queryForm.videoDateList && !getPlayOptions())
queryForm.videoDateList = getDefaultDateRange();
};
const disabledDate = (time: Date) =>
time.getTime() > dayjs().endOf("day").valueOf();
// 获取默认日期范围(前三天)
function getDefaultDateRange() {
const today = new Date()
const threeDaysAgo = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000) // 前三天(包含今天)
// 设置开始时间为三天前的 00:00:00
threeDaysAgo.setHours(0, 0, 0, 0)
// 设置结束时间为今天的 23:59:59
today.setHours(23, 59, 59, 999)
return [dayjs(threeDaysAgo).format("YYYY-MM-DD HH:mm:ss"), dayjs(today).format("YYYY-MM-DD HH:mm:ss")]
}
// 点击时间
const handleDateChange = (dates: string[]) => {
if (dates && dates.length === 2) {
const startDate = new Date(dates[0])
const endDate = new Date(dates[1])
const diffTime = Math.abs(endDate.getTime() - startDate.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays > 3) {
useMessage().warning('最多只能选择3天的时间范围')
// 重置为默认值
queryForm.videoDateList = getDefaultDateRange()
return
}
// 选择合法后,同步秒级时间戳
state.startTimestamp = dayjs(dates[0]).unix()
state.endTimestamp = dayjs(dates[1]).unix()
}
}
// 禁用日期的逻辑
const disabledDate = (time: Date) => {
const today = new Date()
today.setHours(23, 59, 59, 999) // 设置为今天的最后一刻
// 禁用未来日期
if (time.getTime() > today.getTime()) {
return true
}
// 如果已经选择了开始日期,限制结束日期最多3天
const selectedDates = queryForm.videoDateList
if (selectedDates && selectedDates.length === 1) {
const startDate = new Date(selectedDates[0])
const maxEndDate = new Date(startDate.getTime() + 2 * 24 * 60 * 60 * 1000) // 最多3天
const minEndDate = new Date(startDate.getTime() - 2 * 24 * 60 * 60 * 1000) // 最少3天前
return time.getTime() > maxEndDate.getTime() || time.getTime() < minEndDate.getTime()
}
return false
}
// 暴露变量
defineExpose({
openDialog
});
defineExpose({ openDialog });
</script>
<style lang="scss" scoped>
.dialog-video-layout {
display: flex;
align-items: flex-start;
min-width: 0;
}
.dialog-video-content {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dialog-video-player {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
justify-content: center;
overflow: hidden;
}
.dialog-video-player :deep(.video-container) {
flex: 0 1 auto;
max-width: min(100%, calc((100vh - 250px) * 16 / 9));
}
<style lang="scss">
@import "../video-dialog.scss";
</style>
+227 -158
View File
@@ -1,179 +1,248 @@
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>;
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 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") 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,
playingChannels,
deviceName,
playerRef,
currentPage,
pageSize,
division,
divisionOptions,
playingSummary,
openDialog,
closeDialog,
changeSelection,
changeSelectionMode,
playSelected,
changePage,
reloadChannels: () => openDialog(sourceType, sourceCode),
};
}
@@ -1,169 +1,404 @@
<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="搜索通道 / 设备"
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>
</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 } 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[];
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("");
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))));
const hasChanges = computed(
() =>
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)])]);
const selectAll = () =>
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;
}
&__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 {
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;
}
}
@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>
+309 -79
View File
@@ -1,92 +1,322 @@
// 限定到视频弹窗,覆盖项目弹窗的全局滚动与高度规则。
.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;
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);
}
.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;
}
}
.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 (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;
}
}
}