NextGB, web demo powerd by vue

This commit is contained in:
chenhaibo
2025-02-03 16:27:46 +08:00
parent 0b7126b12b
commit c80247286e
113 changed files with 16731 additions and 9944 deletions
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ArrowRight, VideoCamera, Search } from '@element-plus/icons-vue'
const props = defineProps<{
title?: string
}>()
const emit = defineEmits<{
search: [{ start: string; end: string }]
}>()
const isCollapsed = ref(false)
const startDateTime = ref('')
const endDateTime = ref('')
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
const handleShortcut = (type: string) => {
const now = new Date()
const start = new Date()
switch (type) {
case 'today': {
start.setHours(0, 0, 0, 0)
break
}
case 'yesterday': {
start.setDate(start.getDate() - 1)
start.setHours(0, 0, 0, 0)
now.setDate(now.getDate() - 1)
break
}
case 'lastWeek': {
start.setDate(start.getDate() - 7)
start.setHours(0, 0, 0, 0)
break
}
}
startDateTime.value = formatDateTime(start)
now.setHours(23, 59, 59)
endDateTime.value = formatDateTime(now)
}
const handleSearch = () => {
if (!startDateTime.value || !endDateTime.value) return
emit('search', {
start: startDateTime.value,
end: endDateTime.value,
})
}
</script>
<template>
<div class="datetime-range-panel" :class="{ collapsed: isCollapsed }">
<div class="panel-header" @click="isCollapsed = !isCollapsed">
<div class="header-title">
<el-icon class="collapse-arrow" :class="{ collapsed: isCollapsed }">
<ArrowRight />
</el-icon>
<el-icon class="title-icon"><VideoCamera /></el-icon>
<span>{{ title || '时间范围' }}</span>
</div>
</div>
<div class="panel-content">
<div class="search-form">
<div class="form-item calendar-wrapper">
<div class="datetime-range">
<div class="datetime-item">
<div class="datetime-label">开始时间</div>
<el-date-picker
v-model="startDateTime"
type="datetime"
:editable="false"
placeholder="开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
<div class="datetime-item">
<div class="datetime-label">结束时间</div>
<el-date-picker
v-model="endDateTime"
type="datetime"
:editable="false"
placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
</div>
<div class="shortcuts">
<el-button text size="small" @click="handleShortcut('today')"> 今天 </el-button>
<el-button text size="small" @click="handleShortcut('yesterday')"> 昨天 </el-button>
<el-button text size="small" @click="handleShortcut('lastWeek')"> 最近一周 </el-button>
</div>
</div>
<template v-if="!isCollapsed">
<el-button
type="primary"
:disabled="!startDateTime || !endDateTime"
@click="handleSearch"
style="width: 100%"
>
<el-icon><Search /></el-icon>
查询
</el-button>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.datetime-range-panel {
background-color: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
box-shadow: var(--el-box-shadow-lighter);
}
.panel-header {
padding: 8px 12px;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--el-fill-color-light);
}
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--el-text-color-primary);
}
.collapse-arrow {
font-size: 12px;
transition: transform 0.2s ease;
color: var(--el-text-color-secondary);
&.collapsed {
transform: rotate(-90deg);
}
}
.title-icon {
font-size: 14px;
color: var(--el-color-primary);
}
.panel-content {
transition: all 0.2s ease;
overflow: hidden;
}
.datetime-range-panel.collapsed .panel-content {
height: 0;
padding: 0;
opacity: 0;
}
.datetime-range {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.datetime-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.datetime-label {
font-size: 12px;
color: var(--el-text-color-secondary);
padding-left: 4px;
}
.shortcuts {
display: flex;
gap: 8px;
padding: 0 4px;
:deep(.el-button) {
height: 24px;
padding: 0 8px;
&.is-disabled {
color: var(--el-text-color-disabled);
}
}
}
.calendar-wrapper {
:deep(.el-input__wrapper) {
padding: 0 8px;
height: 32px;
}
:deep(.el-input__inner) {
font-size: 13px;
}
:deep(.el-date-editor) {
--el-date-editor-width: 100%;
}
}
.search-form {
padding: 12px;
}
:deep(.el-picker-panel) {
--el-datepicker-border-color: var(--el-border-color-lighter);
}
</style>
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Search, Refresh, Expand, List } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
interface Props {
loading?: boolean
showViewModeSwitch?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
showViewModeSwitch: false,
})
const searchQuery = ref('')
const viewMode = ref<'tree' | 'list'>('tree')
const tooltipRef = ref()
const emit = defineEmits<{
(e: 'update:searchQuery', value: string): void
(e: 'update:viewMode', value: 'tree' | 'list'): void
(e: 'refresh'): void
}>()
const handleSearchInput = (value: string) => {
searchQuery.value = value
emit('update:searchQuery', value)
}
const handleViewModeChange = () => {
viewMode.value = viewMode.value === 'tree' ? 'list' : 'tree'
emit('update:viewMode', viewMode.value)
}
const handleRefresh = () => {
emit('refresh')
tooltipRef.value?.hide()
}
</script>
<template>
<div class="search-box">
<div class="search-wrapper">
<el-input
v-model="searchQuery"
placeholder="搜索设备或通道..."
clearable
size="small"
@input="handleSearchInput"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="action-buttons">
<el-tooltip
v-if="showViewModeSwitch"
:content="viewMode === 'tree' ? '切换到列表视图' : '切换到树形视图'"
placement="top"
>
<el-button
class="action-btn"
:icon="viewMode === 'list' ? Expand : List"
size="small"
@click="handleViewModeChange"
/>
</el-tooltip>
<el-tooltip ref="tooltipRef" content="刷新设备列表" placement="top">
<el-button
class="action-btn refresh-btn"
:icon="Refresh"
size="small"
:loading="loading"
@click="handleRefresh"
/>
</el-tooltip>
</div>
</div>
</div>
</template>
<style scoped>
.search-box {
margin-bottom: 16px;
}
.search-wrapper {
display: flex;
gap: 8px;
.el-input {
flex: 1;
}
.action-buttons {
display: flex;
gap: 0;
margin-left: auto;
.action-btn {
color: var(--el-text-color-regular);
border-color: transparent;
background-color: transparent;
padding: 5px 8px;
height: 32px;
width: 32px;
border-radius: 4px;
&:hover {
color: var(--el-color-primary);
background-color: var(--el-fill-color-light);
}
&:focus {
outline: none;
}
:deep(.el-icon) {
font-size: 16px;
}
&.refresh-btn {
color: var(--el-color-primary);
&:hover {
background-color: var(--el-color-primary-light-9);
}
}
}
}
}
</style>
@@ -0,0 +1,766 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, onMounted, onActivated, onDeactivated } from 'vue'
import {
VideoCamera,
Close,
Camera,
FullScreen,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type * as Types from '@/api/types'
import { deviceApi } from '@/api'
import { createMediaServer } from '@/api/mediaserver/factory'
import { useDefaultMediaServer } from '@/stores/mediaServer'
import type { LayoutConfig } from '@/types/layout'
import { MediaServerType } from '@/api/mediaserver/types'
interface DeviceWithChannel extends Types.Device {
channelInfo?: Types.ChannelInfo
player?: any
error?: boolean
id?: string
channel?: Types.ChannelInfo
isMuted?: boolean
url?: string
}
const props = defineProps<{
modelValue: string
defaultMuted?: boolean
layouts: Record<string, LayoutConfig>
showBorder?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'window-select': [data: { deviceId: string; channelId: string } | null]
}>()
const currentLayout = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const selectedDevices = ref<(DeviceWithChannel | null)[]>([])
const isFullscreen = ref(false)
// 使用共享的默认媒体服务器
const defaultMediaServer = useDefaultMediaServer()
// 计算属性
const gridStyle = computed(() => {
const layout = props.layouts[currentLayout.value]
return {
gridTemplateColumns: `repeat(${layout.cols}, 1fr)`,
gridTemplateRows: `repeat(${layout.rows}, 1fr)`,
}
})
const maxDevices = computed(() => props.layouts[currentLayout.value].size)
// 视频流控制
const startWebRTCPlay = async (url: string, index: number, device: DeviceWithChannel) => {
if (!defaultMediaServer?.value) {
throw new Error('未找到可用的媒体服务器')
}
// 目前只有SRS支持WebRTC播放
const serverType = defaultMediaServer.value.type.toLowerCase()
console.log('当前媒体服务器类型:', serverType)
const mediaServer = createMediaServer(defaultMediaServer.value)
const player = (mediaServer as any).createRtcPlayer()
device.player = player
player.ontrack = (event: RTCTrackEvent) => {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement && event.streams?.[0]) {
videoElement.srcObject = event.streams[0]
}
}
await player.play(url)
}
const startStream = async (
device: DeviceWithChannel,
index: number,
play_type: number = 0,
start_time: number = 0,
end_time: number = 0,
) => {
try {
device.error = false
if (!defaultMediaServer?.value) {
throw new Error('未找到可用的媒体服务器,请先在流媒体服务页面设置默认服务器')
}
if (defaultMediaServer.value.status === 0) {
throw new Error('默认流媒体服务器不在线,请检查服务器状态')
}
const response = await deviceApi.invite({
media_server_id: defaultMediaServer.value.id,
device_id: device.channel!.parent_id,
channel_id: device.channel!.device_id,
sub_stream: 0,
play_type,
start_time,
end_time,
})
const streamData = response.data as unknown as Types.InviteResponse
if (!streamData?.url) {
throw new Error('播放地址不存在')
}
device.url = streamData.url
await startWebRTCPlay(streamData.url, index, device)
} catch (error) {
console.error('启动播放失败:', error)
device.error = true
ElMessage.error(error instanceof Error ? error.message : '启动播放失败')
}
}
// 设备管理
const cleanupDevice = async (device: DeviceWithChannel) => {
if (device.player) {
try {
await device.player.close()
device.player = null
if (!device.url) return
await deviceApi.bye({
device_id: device.device_id,
channel_id: device.channel!.device_id,
url: device.url!,
})
} catch (err) {
console.error('关闭播放器失败:', err)
}
}
}
const play = async (
device: Types.Device & {
channel: Types.ChannelInfo
play_type?: number
start_time?: number
end_time?: number
},
) => {
let index = selectedDevices.value.findIndex((d) => d === null)
if (index === -1) {
if (selectedDevices.value.length >= maxDevices.value) {
ElMessage.warning('已达到最大分屏数量')
return
}
index = selectedDevices.value.length
}
try {
const deviceWithChannel: DeviceWithChannel = {
...device,
channelInfo: device.channel,
channel: device.channel,
error: false,
isMuted: props.defaultMuted,
url: '',
}
while (selectedDevices.value.length <= index) {
selectedDevices.value.push(null)
}
selectedDevices.value[index] = deviceWithChannel
await startStream(
deviceWithChannel,
index,
device.play_type,
device.start_time,
device.end_time,
)
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement) {
videoElement.muted = props.defaultMuted ?? true
}
} catch (error) {
console.error('添加设备失败:', error)
ElMessage.error('添加设备失败')
}
}
const pause = async (index: number) => {
const device = selectedDevices.value[index]
if (!device) return
await deviceApi.pause({
device_id: device.device_id,
channel_id: device.channel!.device_id,
url: device.url!,
})
}
const resume = async (index: number) => {
const device = selectedDevices.value[index]
if (!device) return
await deviceApi.resume({
device_id: device.device_id,
channel_id: device.channel!.device_id,
url: device.url!,
})
}
const speed = async (index: number, speed: number) => {
const device = selectedDevices.value[index]
if (!device) return
await deviceApi.speed({
device_id: device.device_id,
channel_id: device.channel!.device_id,
url: device.url!,
speed,
})
}
const stop = async (index: number) => {
const device = selectedDevices.value[index]
if (!device) return
await cleanupDevice(device)
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement?.srcObject) {
const stream = videoElement.srcObject as MediaStream
stream.getTracks().forEach((track) => track.stop())
videoElement.srcObject = null
}
// 将位置设为 null 而不是删除
selectedDevices.value[index] = null
}
// 视频控制
const handleVideoDoubleClick = (index: number) => {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (!videoElement) return
try {
if (!document.fullscreenElement) {
videoElement.requestFullscreen()
} else {
document.exitFullscreen()
}
} catch (err) {
console.error('视频全屏切换失败:', err)
ElMessage.error('全屏切换失败')
}
}
const handleVideoError = (index: number, event: Event) => {
console.error('视频播放错误:', event)
const device = selectedDevices.value[index]
if (device) {
device.error = true
}
}
const capture = async (index: number) => {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (!videoElement) {
ElMessage.error('未找到视频元素')
return
}
try {
const canvas = document.createElement('canvas')
canvas.width = videoElement.videoWidth
canvas.height = videoElement.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建canvas上下文')
}
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height)
const device = selectedDevices.value[index]
if (!device) {
throw new Error('设备不存在')
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${device.name || 'capture'}-${timestamp}.png`
const link = document.createElement('a')
link.download = filename
link.href = canvas.toDataURL('image/png')
link.click()
ElMessage.success('抓图成功')
} catch (err) {
console.error('抓图失败:', err)
ElMessage.error('抓图失败')
}
}
// 清空所有设备
const clear = async () => {
try {
await ElMessageBox.confirm('确定要清空所有设备吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
for (let i = 0; i < selectedDevices.value.length; i++) {
if (selectedDevices.value[i]) {
await stop(i)
}
}
// 用 null 填充数组而不是清空
selectedDevices.value = new Array(props.layouts[currentLayout.value].size).fill(null)
ElMessage.success('已清空所有设备')
} catch (err) {
if (err !== 'cancel') {
console.error('清空设备失败:', err)
ElMessage.error('清空设备失败')
}
}
}
// 布局切换处理
watch(currentLayout, async (newLayout, oldLayout) => {
const maxSize = props.layouts[newLayout].size
const activeDevices = selectedDevices.value.filter((d) => d !== null).length
if (activeDevices > maxSize) {
try {
await ElMessageBox.confirm(
`切换布局将移除${activeDevices - maxSize}个设备,是否继续?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
)
// 从后往前移除超出设备
for (let i = selectedDevices.value.length - 1; i >= 0; i--) {
if (selectedDevices.value[i] && i >= maxSize) {
await stop(i)
}
}
// 调整数组大小
selectedDevices.value.length = maxSize
ElMessage.success('布局切换成功')
} catch (err) {
if (err !== 'cancel') {
console.error('布局切换失败:', err)
ElMessage.error('布局切换失败')
}
currentLayout.value = oldLayout
}
} else {
// 如果设备数量不超过新布局,只需调整数组大小
if (selectedDevices.value.length > maxSize) {
selectedDevices.value.length = maxSize
} else {
while (selectedDevices.value.length < maxSize) {
selectedDevices.value.push(null)
}
}
}
})
// 生命周期钩子
onMounted(() => {
// 确保有初始网格
if (selectedDevices.value.length === 0) {
selectedDevices.value = new Array(props.layouts[currentLayout.value].size).fill(null)
}
document.addEventListener('fullscreenchange', () => {
isFullscreen.value = !!document.fullscreenElement
})
})
onBeforeUnmount(() => {
// 只在组件真正销毁时清理资源
if (!(document as any)._vue_app_is_switching_route) {
selectedDevices.value.forEach((device) => {
if (device?.player) {
try {
device.player.close()
} catch (err) {
console.error('关闭播放器失败:', err)
}
}
})
}
})
defineExpose({
play,
pause,
resume,
speed,
clear,
stop,
})
const toggleMute = (index: number) => {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (!videoElement) return
const device = selectedDevices.value[index]
if (!device) return
device.isMuted = !device.isMuted
videoElement.muted = device.isMuted
}
const activeIndex = ref<number | null>(null)
const handleVideoClick = (index: number) => {
const device = selectedDevices.value[index]
activeIndex.value = index
if (device && device.channel) {
emit('window-select', {
deviceId: device.device_id,
channelId: device.channel.device_id,
})
} else {
emit('window-select', null)
}
}
// 添加激活/停用处理
onActivated(() => {
console.log('MonitorGrid activated')
// 检查并恢复所有视频播放
selectedDevices.value.forEach((device, index) => {
if (device) {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement && videoElement.paused) {
videoElement.play().catch((err) => {
console.error('恢复视频播放失败:', err)
})
}
}
})
})
onDeactivated(() => {
console.log('MonitorGrid deactivated')
// 可以选择暂停视频播放,但不销毁资源
selectedDevices.value.forEach((device, index) => {
if (device) {
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
if (videoElement && !videoElement.paused) {
// 可以选择是否暂停视频
// videoElement.pause()
}
}
})
})
</script>
<template>
<div class="monitor-grid">
<div class="grid-container" :style="gridStyle">
<div
v-for="(device, index) in selectedDevices"
:key="index"
class="grid-item"
:class="{
active: index === activeIndex,
'with-border': props.showBorder,
}"
@click="handleVideoClick(index)"
>
<template v-if="device !== null">
<video
:id="`video-player-${index}`"
class="video-player"
autoplay
:muted="device.isMuted ?? true"
@dblclick="handleVideoDoubleClick(index)"
@error="handleVideoError(index, $event)"
/>
<div class="video-overlay">
<div class="device-info">{{ device.name }} - {{ device.channel?.name ?? '' }}</div>
<div class="video-controls">
<div class="control-bar">
<el-button
class="control-btn"
@click.stop="toggleMute(index)"
:title="device.isMuted ? '取消静音' : '静音'"
>
<el-icon>
<component :is="device.isMuted ? 'Mute' : 'Microphone'" />
</el-icon>
</el-button>
<el-button
class="control-btn"
@click.stop="capture(index)"
:title="'抓图'"
:disabled="device.error"
>
<el-icon><Camera /></el-icon>
</el-button>
<el-button
class="control-btn"
@click.stop="handleVideoDoubleClick(index)"
:title="'全屏'"
>
<el-icon><FullScreen /></el-icon>
</el-button>
<el-button
class="control-btn is-danger"
@click.stop="stop(index)"
:title="'关闭'"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
<div v-else class="empty-slot">
<el-icon><VideoCamera /></el-icon>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.monitor-grid {
height: 100%;
display: flex;
flex-direction: column;
background: var(--el-bg-color);
border-radius: 4px;
box-shadow: var(--el-box-shadow-lighter);
}
.grid-container {
flex: 1;
display: grid;
gap: 0px;
padding: 0px;
height: 100%;
background: var(--el-bg-color-page);
border-radius: 4px;
&.is-fullscreen {
padding: 16px;
background: #000;
gap: 16px;
}
}
.grid-item {
position: relative;
background-color: var(--el-fill-color-darker);
transition: border-color 0.2s ease;
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
&.with-border {
border: 2px solid transparent;
&.active {
border-color: var(--el-color-primary);
}
}
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
padding: 0;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, transparent 30%);
opacity: 0;
transition: opacity 0.3s ease;
}
.grid-item:hover .video-overlay {
opacity: 1;
}
.device-info {
display: none;
}
.video-controls {
display: flex;
justify-content: flex-end;
}
.control-bar {
display: flex;
gap: 2px;
background: rgba(0, 0, 0, 0.5);
padding: 2px;
border-radius: 0 0 0 4px;
backdrop-filter: blur(4px);
}
.control-btn {
padding: 0 !important;
border: none !important;
background: transparent !important;
color: #fff !important;
&:hover {
background: rgba(255, 255, 255, 0.1) !important;
color: var(--el-color-primary) !important;
}
&.is-danger:hover {
color: var(--el-color-danger) !important;
}
}
/* 根据布局调整按钮大小 */
.grid-container[style*='repeat(1, 1fr)'] .control-btn {
width: 32px !important;
height: 32px !important;
font-size: 16px !important;
}
.grid-container[style*='repeat(2, 1fr)'] .control-btn {
width: 28px !important;
height: 28px !important;
font-size: 14px !important;
}
.grid-container[style*='repeat(3, 1fr)'] .control-btn {
width: 20px !important;
height: 20px !important;
font-size: 10px !important;
}
.grid-container[style*='repeat(4, 1fr)'] .control-btn {
width: 16px !important;
height: 16px !important;
font-size: 8px !important;
}
.control-bar {
.el-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
.empty-slot {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a1a;
.el-icon {
font-size: 24px;
opacity: 0.5;
color: #fff;
}
&:hover {
background: #242424;
.el-icon {
opacity: 0.8;
color: var(--el-color-primary);
}
}
}
.monitor-grid {
height: 100%;
display: flex;
flex-direction: column;
background: var(--el-bg-color);
border-radius: 4px;
box-shadow: var(--el-box-shadow-lighter);
}
.grid-toolbar {
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--el-bg-color);
border-radius: 4px 4px 0 0;
}
:deep(.el-button-group .el-button--small) {
padding: 5px 11px;
}
:deep(.el-radio-group .el-radio-button__inner) {
padding: 5px 15px;
}
.video-container {
width: 100%;
height: 100%;
position: relative;
transition: all 0.3s ease;
}
.video-container:active {
transform: none;
}
:deep(.el-radio-group) {
--el-button-bg-color: var(--el-fill-color-blank);
--el-button-hover-bg-color: var(--el-fill-color);
--el-button-active-bg-color: var(--el-color-primary);
--el-button-text-color: var(--el-text-color-regular);
--el-button-hover-text-color: var(--el-text-color-primary);
--el-button-active-text-color: #fff;
--el-button-border-color: var(--el-border-color);
--el-button-hover-border-color: var(--el-border-color-hover);
}
</style>