NextGB, web demo powerd by vue
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user