11
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user