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

View File

@ -0,0 +1,729 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch } from 'vue'
import DeviceTree from './DeviceTree.vue'
import MonitorGrid from '@/components/monitor/MonitorGrid.vue'
import DateTimeRangePanel from '@/components/common/DateTimeRangePanel.vue'
import type { Device, ChannelInfo, RecordInfoResponse } from '@/api/types'
import type { LayoutConfig } from '@/types/layout'
import { deviceApi } from '@/api'
import dayjs from 'dayjs'
import { VideoPlay, VideoPause, CloseBold, Microphone } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
type LayoutKey = '1'
type LayoutConfigs = Record<LayoutKey, LayoutConfig>
// 布局配置
const layouts: LayoutConfigs = {
'1': { cols: 1, rows: 1, size: 1, label: '单屏' },
} as const
const monitorGridRef = ref()
const selectedChannels = ref<{ device: Device | undefined; channel: ChannelInfo }[]>([])
const volume = ref(100)
const currentLayout = ref<'1'>('1') // 固定为单屏模式
const defaultMuted = ref(true)
const activeWindow = ref<{ deviceId: string; channelId: string } | null>(null)
const isPlaying = ref(false) // 添加播放状态变量
const isFirstPlay = ref(true)
// 时间轴刻度显示控制
const timelineWidth = ref(0)
const showAllLabels = computed(() => timelineWidth.value >= 720) // 当宽度大于720px时显示所有标签
const showMediumLabels = computed(() => timelineWidth.value >= 480) // 当宽度大于480px时显示中等标签
// 时间轴光标位置
const cursorPosition = ref(0)
const cursorTime = ref('')
const isTimelineHovered = ref(false)
const getTimeFromEvent = (event: MouseEvent, element: HTMLElement) => {
const rect = element.getBoundingClientRect()
const position = ((event.clientX - rect.left) / rect.width) * 100
const normalizedPosition = Math.max(0, Math.min(100, position))
const totalMinutes = 24 * 60
const minutes = Math.floor((normalizedPosition / 100) * totalMinutes)
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return {
position: normalizedPosition,
time: dayjs().startOf('day').add(hours, 'hour').add(mins, 'minute'),
}
}
const handleTimelineMouseMove = (event: MouseEvent) => {
const timeline = event.currentTarget as HTMLElement
const { position, time } = getTimeFromEvent(event, timeline)
cursorPosition.value = position
cursorTime.value = time.format('HH:mm:ss')
}
// 处理时间轴鼠标进入/离开
const handleTimelineMouseEnter = () => {
isTimelineHovered.value = true
}
const handleTimelineMouseLeave = () => {
isTimelineHovered.value = false
}
// 屏幕尺寸类型
const screenType = computed(() => {
if (timelineWidth.value >= 720) return '大屏'
if (timelineWidth.value >= 480) return '中屏'
return '小屏'
})
// 根据屏幕宽度调整时间轴高度
const timelineHeight = computed(() => {
if (timelineWidth.value >= 720) return 60
if (timelineWidth.value >= 480) return 48
return 36
})
// 监听时间轴宽度变化
const updateTimelineWidth = () => {
const timeline = document.querySelector('.timeline-scale')
if (timeline) {
timelineWidth.value = timeline.clientWidth
console.log(`时间轴宽度: ${timelineWidth.value}px, 当前屏幕: ${screenType.value}`)
}
}
// 监听窗口大小变化
onMounted(() => {
updateTimelineWidth()
window.addEventListener('resize', updateTimelineWidth)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateTimelineWidth)
})
const handleWindowSelect = (data: { deviceId: string; channelId: string } | null) => {
activeWindow.value = data
}
const recordSegments = ref<RecordInfoResponse[]>([])
const handleQueryRecord = async ({ start, end }: { start: string; end: string }) => {
if (selectedChannels.value.length === 0) {
ElMessage.warning('请先选择要查询的通道')
return
}
try {
const promises = selectedChannels.value.map(async ({ device, channel }) => {
if (!device?.device_id || !channel.device_id) return []
const response = await deviceApi.queryRecord({
device_id: device.device_id,
channel_id: channel.device_id,
start_time: dayjs(start).unix(),
end_time: dayjs(end).unix(),
})
return Array.isArray(response.data) ? response.data : []
})
const results = await Promise.all<RecordInfoResponse[]>(promises)
recordSegments.value = results.flat()
// 自动激活第一个选中的通道
if (selectedChannels.value.length > 0) {
const firstChannel = selectedChannels.value[0]
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
activeWindow.value = {
deviceId: firstChannel.device.device_id,
channelId: firstChannel.channel.device_id
}
}
}
} catch (error) {
console.error('查询录像失败:', error)
ElMessage.error('查询录像失败')
recordSegments.value = []
}
}
const handleStop = () => {
monitorGridRef.value?.stop(0)
isPlaying.value = false // 设置播放状态为 false
// 确保 activeWindow 始终指向第一个屏幕
if (selectedChannels.value.length > 0) {
const firstChannel = selectedChannels.value[0]
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
activeWindow.value = {
deviceId: firstChannel.device.device_id,
channelId: firstChannel.channel.device_id
}
}
}
}
// 监听 selectedChannels 变化,确保 activeWindow 始终指向第一个屏幕
watch(selectedChannels, (newChannels) => {
if (newChannels.length > 0) {
const firstChannel = newChannels[0]
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
activeWindow.value = {
deviceId: firstChannel.device.device_id,
channelId: firstChannel.channel.device_id
}
}
}
}, { immediate: true })
const calculatePosition = (time: string) => {
const hour = dayjs(time).hour()
const minute = dayjs(time).minute()
return ((hour * 60 + minute) / (24 * 60)) * 100
}
const calculateWidth = (start: string, end: string) => {
const startMinutes = dayjs(start).hour() * 60 + dayjs(start).minute()
const endMinutes = dayjs(end).hour() * 60 + dayjs(end).minute()
return ((endMinutes - startMinutes) / (24 * 60)) * 100
}
// 添加激活/停用处理
onActivated(() => {
console.log('PlaybackView activated')
})
onDeactivated(() => {
console.log('PlaybackView deactivated')
})
// 组件名称(用于 keep-alive
defineOptions({
name: 'PlaybackView',
})
const handleTimelineDoubleClick = async (event: MouseEvent) => {
if (selectedChannels.value.length === 0) {
ElMessage.warning('请先选择要播放的通道')
return
}
const timeline = event.currentTarget as HTMLElement
const { time } = getTimeFromEvent(event, timeline)
const endTime = dayjs().endOf('day')
// 只播放第一个选中的通道
const { device, channel } = selectedChannels.value[0]
if (!device?.device_id || !channel.device_id) {
ElMessage.warning('设备信息不完整')
return
}
try {
monitorGridRef.value?.play({
...device,
channel: channel,
play_type: 1, // 1 表示回放
start_time: time.unix(),
end_time: endTime.unix(), // 使用当天 23:59:59 的时间戳
})
isPlaying.value = true // 设置播放状态为 true
} catch (error) {
console.error('播放录像失败:', error)
ElMessage.error('播放录像失败')
}
}
// 处理播放/暂停切换
const handlePlayPause = async () => {
if (!activeWindow.value || selectedChannels.value.length === 0) return
if (!isPlaying.value) {
// 开始播放
const { device, channel } = selectedChannels.value[0]
if (!device?.device_id || !channel.device_id) {
ElMessage.warning('设备信息不完整')
return
}
try {
// 如果有录像段,则根据是否是第一次播放来决定是调用 play 还是 resume
if (recordSegments.value.length === 0) {
ElMessage.warning('没有可播放的录像')
return
}
if (isFirstPlay.value) {
monitorGridRef.value?.play({
...device,
channel: channel,
play_type: 1, // 1 表示回放
start_time: recordSegments.value[0].start_time,
end_time: recordSegments.value[0].end_time,
})
isFirstPlay.value = false
} else {
monitorGridRef.value?.resume(0)
}
isPlaying.value = true
} catch (error) {
console.error('播放录像失败:', error)
ElMessage.error('播放录像失败')
return
}
} else {
// 暂停播放
try {
const { device, channel } = selectedChannels.value[0]
if (!device?.device_id || !channel.device_id) {
ElMessage.warning('设备信息不完整')
return
}
console.log('暂停录像')
monitorGridRef.value?.pause(0)
isPlaying.value = false
} catch (error) {
console.error('暂停录像失败:', error)
ElMessage.error('暂停录像失败')
}
}
}
</script>
<template>
<div class="playback-container">
<div class="left-panel">
<DeviceTree v-model:selectedChannels="selectedChannels" />
<DateTimeRangePanel title="录像查询" @search="handleQueryRecord" />
</div>
<div class="right-panel">
<div class="playback-panel">
<MonitorGrid
ref="monitorGridRef"
v-model="currentLayout"
:layouts="layouts"
:default-muted="defaultMuted"
:show-border="false"
@window-select="handleWindowSelect"
/>
<div class="timeline-panel" :style="{ height: `${timelineHeight}px` }">
<div class="timeline-ruler">
<div
class="timeline-scale"
@mousemove="handleTimelineMouseMove"
@mouseenter="handleTimelineMouseEnter"
@mouseleave="handleTimelineMouseLeave"
@dblclick="handleTimelineDoubleClick"
>
<div class="timeline-marks">
<div
v-for="hour in 24"
:key="hour"
class="hour-mark"
:class="{
'major-mark': (hour - 1) % 6 === 0,
'medium-mark': (hour - 1) % 3 === 0 && (hour - 1) % 6 !== 0,
'minor-mark': (hour - 1) % 3 !== 0,
}"
>
<div
v-if="
(hour - 1) % 6 === 0 ||
(showMediumLabels && (hour - 1) % 3 === 0) ||
showAllLabels
"
class="hour-label"
>
{{ (hour - 1).toString().padStart(2, '0') }}
</div>
<div class="hour-line"></div>
<div class="half-hour-mark"></div>
</div>
<div class="hour-mark major-mark" style="flex: 0 0 auto">
<div class="hour-label">24</div>
<div class="hour-line"></div>
</div>
</div>
<div
class="timeline-cursor"
:class="{ visible: isTimelineHovered }"
:style="{ left: `${cursorPosition}%` }"
>
<div class="cursor-time" :class="{ visible: isTimelineHovered }">
{{ cursorTime }}
</div>
</div>
<div class="record-segments">
<div
v-for="(segment, index) in recordSegments"
:key="index"
class="record-segment"
:style="{
left: `${calculatePosition(segment.start_time)}%`,
width: `${calculateWidth(segment.start_time, segment.end_time)}%`,
}"
:title="`${dayjs(segment.start_time).format('HH:mm:ss')} - ${dayjs(segment.end_time).format('HH:mm:ss')}`"
/>
</div>
</div>
</div>
</div>
<div class="control-panel">
<div class="control-group">
<el-button-group>
<el-button
:icon="isPlaying ? VideoPause : VideoPlay"
:disabled="!activeWindow"
size="large"
:title="isPlaying ? '暂停' : '播放'"
@click="handlePlayPause"
/>
<el-button
:icon="CloseBold"
:disabled="!activeWindow"
@click="handleStop"
size="large"
title="停止"
/>
</el-button-group>
</div>
<div class="volume-control">
<el-icon><Microphone /></el-icon>
<el-slider
v-model="volume"
:max="100"
:min="0"
:disabled="!activeWindow"
size="small"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.playback-container {
height: 100%;
display: flex;
gap: 16px;
}
.left-panel {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.right-panel {
flex: 1;
background-color: #fff;
border-radius: 4px;
display: flex;
flex-direction: column;
}
.playback-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.control-panel {
height: 60px;
background-color: #1a1a1a;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
gap: 24px;
}
.control-group {
display: flex;
align-items: center;
gap: 16px;
}
.volume-control {
display: flex;
align-items: center;
gap: 8px;
width: 140px;
color: rgba(255, 255, 255, 0.8);
:deep(.el-icon) {
font-size: 18px;
}
}
.el-button-group {
.el-button {
width: 36px;
height: 36px;
padding: 0;
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
--el-button-hover-bg-color: rgba(255, 255, 255, 0.1);
--el-button-hover-border-color: transparent;
--el-button-active-bg-color: rgba(255, 255, 255, 0.15);
--el-button-text-color: rgba(255, 255, 255, 0.85);
--el-button-disabled-text-color: rgba(255, 255, 255, 0.3);
--el-button-disabled-bg-color: transparent;
--el-button-disabled-border-color: transparent;
:deep(.el-icon) {
font-size: 20px;
}
&:hover:not(:disabled) {
--el-button-text-color: var(--el-color-primary);
}
}
}
:deep(.el-slider) {
--el-slider-main-bg-color: var(--el-color-primary);
--el-slider-runway-bg-color: rgba(255, 255, 255, 0.15);
--el-slider-stop-bg-color: rgba(255, 255, 255, 0.2);
--el-slider-disabled-color: rgba(255, 255, 255, 0.1);
.el-slider__runway {
height: 3px;
}
.el-slider__button {
border: none;
width: 10px;
height: 10px;
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease;
&:hover {
transform: scale(1.3);
}
}
.el-slider__bar {
height: 3px;
}
}
.timeline-panel {
background-color: #242424;
position: relative;
overflow: visible;
user-select: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px 0;
}
.timeline-ruler {
height: 100%;
position: relative;
padding: 0 24px;
display: flex;
align-items: flex-end;
}
.timeline-marks {
height: 100%;
display: flex;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
pointer-events: none;
}
.timeline-scale {
height: 100%;
position: relative;
width: 100%;
cursor: pointer;
}
.hour-mark {
flex: 1;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-top: 16px;
}
.hour-label {
position: absolute;
left: 0;
top: 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
transform: translateX(-50%);
white-space: nowrap;
line-height: 1;
}
.hour-line {
position: relative;
width: 1px;
height: 8px;
background-color: rgba(255, 255, 255, 0.15);
}
.half-hour-mark {
position: absolute;
left: 50%;
bottom: 0;
width: 1px;
height: 6px;
background-color: rgba(255, 255, 255, 0.1);
}
.timeline-cursor {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--el-color-warning);
transform: translateX(-50%);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
&.visible {
opacity: 1;
}
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 4px;
transform: translateX(-50%);
background: linear-gradient(
90deg,
transparent,
rgba(var(--el-color-warning-rgb), 0.2),
transparent
);
}
}
.cursor-time {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
background-color: var(--el-color-warning);
color: #000;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
&.visible {
opacity: 1;
}
}
.major-mark {
.hour-line {
height: 16px;
background-color: rgba(255, 255, 255, 0.4);
width: 2px;
}
.hour-label {
color: rgba(255, 255, 255, 0.95);
font-weight: 500;
font-size: 12px;
}
}
.medium-mark {
.hour-line {
height: 12px;
background-color: rgba(255, 255, 255, 0.25);
width: 1.5px;
}
.hour-label {
color: rgba(255, 255, 255, 0.7);
}
}
.minor-mark {
.hour-line {
height: 8px;
background-color: rgba(255, 255, 255, 0.15);
width: 1px;
}
.hour-label {
color: rgba(255, 255, 255, 0.4);
font-size: 10px;
opacity: 0.8;
}
}
.timeline-ruler:hover .timeline-pointer {
box-shadow: 0 0 8px rgba(var(--el-color-primary-rgb), 0.2);
}
.timeline-panel::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1) 20%,
rgba(255, 255, 255, 0.1) 80%,
transparent
);
}
.record-segments {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 24px;
pointer-events: none;
}
.record-segment {
position: absolute;
height: 8px;
bottom: 12px;
background-color: var(--el-color-success);
opacity: 0.85;
pointer-events: auto;
cursor: pointer;
& + & {
margin-left: 2px;
}
}
</style>