W2: 视频墙多路播放+HLS回放 全部对接网关真实数据

This commit is contained in:
2026-05-17 15:42:00 +08:00
parent 4d257552cb
commit dd26ebfe3a
2 changed files with 174 additions and 1705 deletions

View File

@@ -3,25 +3,17 @@
<div class="monitor-header">
<h2>历史回放</h2>
<div class="monitor-controls">
<el-button type="primary" size="small">刷新</el-button>
<el-button type="primary" size="small" @click="loadCameras">刷新</el-button>
<el-button size="small" plain>全屏显示</el-button>
</div>
</div>
<!-- 日期时间选择 -->
<div class="date-filter">
<h3>时间段选择</h3>
<div class="date-filter-controls">
<el-date-picker
v-model="dateTimeRange"
type="daterange"
range-separator=""
start-placeholder="开始日期时间"
end-placeholder="结束日期时间"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
style="width: 400px;"
></el-date-picker>
<el-date-picker v-model="dateTimeRange" type="daterange" range-separator="至"
start-placeholder="开始日期时间" end-placeholder="结束日期时间"
value-format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss" style="width: 400px;" />
<el-button type="primary" size="small" @click="searchRecordings">查询</el-button>
<el-button size="small" plain @click="resetDateTimeRange">重置</el-button>
</div>
@@ -33,9 +25,7 @@
<div class="camera-info">
<div class="camera-name">{{ camera.name }}</div>
<div class="camera-location">{{ camera.location }}</div>
<div class="camera-status" :class="camera.status">
{{ camera.status === 'online' ? '在线' : '离线' }}
</div>
<div class="camera-status" :class="camera.status">{{ camera.status === 'online' ? '在线' : '离线' }}</div>
</div>
</el-card>
</div>
@@ -43,29 +33,12 @@
<div class="main-view">
<h3>历史回放画面</h3>
<div class="video-container" v-if="selectedCamera">
<div class="video-placeholder">
<video v-if="playbackUrl" :src="playbackUrl" autoplay controls style="width:100%;height:400px;background:#000" />
<div v-else class="video-placeholder">
<el-icon><VideoPlay /></el-icon>
<p>正在加载 {{ selectedCamera.name }} 的历史画面...</p>
<p>{{ playbackLoading ? '正在加载...' : '' }} {{ selectedCamera.name }} 的历史画面</p>
<p v-if="dateTimeRange && dateTimeRange.length === 2">{{ formatDateTime(dateTimeRange[0]) }} - {{ formatDateTime(dateTimeRange[1]) }}</p>
</div>
<!-- 播放进度条 -->
<div class="video-progress">
<el-slider v-model="playProgress" :min="0" :max="100" show-tooltip="always" />
<div class="time-display">{{ formatProgressTime() }} / {{ totalDuration }}</div>
</div>
<div class="video-controls">
<el-button size="small" icon="el-icon-video-play" @click="togglePlay">{{ isPlaying ? '暂停' : '播放' }}</el-button>
<el-button size="small" icon="el-icon-refresh-right" @click="stepForward">前进10秒</el-button>
<el-button size="small" icon="el-icon-refresh-left" @click="stepBackward">后退10秒</el-button>
<el-button size="small" icon="el-icon-camera" @click="screenshot">截图</el-button>
<el-select v-model="playSpeed" placeholder="播放速度" size="small">
<el-option label="0.5x" :value="0.5"></el-option>
<el-option label="1.0x" :value="1"></el-option>
<el-option label="1.5x" :value="1.5"></el-option>
<el-option label="2.0x" :value="2"></el-option>
</el-select>
<el-button size="small" icon="el-icon-full-screen" @click="toggleFullScreen">全屏</el-button>
</div>
</div>
<div class="no-selection" v-else>
<el-empty description="请从左侧选择摄像头查看历史画面" />
@@ -79,8 +52,6 @@
<el-descriptions-item label="在线设备">{{ onlineCount }}</el-descriptions-item>
<el-descriptions-item label="离线设备">{{ offlineCount }}</el-descriptions-item>
<el-descriptions-item label="当前时间">{{ currentTime }}</el-descriptions-item>
<el-descriptions-item label="回放状态" v-if="selectedCamera">{{ isPlaying ? '播放中' : '已暂停' }}</el-descriptions-item>
<el-descriptions-item label="选择时间" v-if="dateTimeRange && dateTimeRange.length === 2">{{ formatDate(dateTimeRange[0]) }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
@@ -90,438 +61,70 @@
import { ref, computed, onMounted } from 'vue'
import { VideoPlay } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchCameras, gwGet, type Camera } from '@/api/gateway'
interface Camera {
id: string
name: string
location: string
status: 'online' | 'offline'
streamUrl?: string
}
// 摄像头数据
const cameras = ref<Camera[]>([
{
id: 'camera1',
name: '仓库入口',
location: '仓库正门',
status: 'online'
},
{
id: 'camera2',
name: '货架区域A',
location: '仓库东区',
status: 'online'
},
{
id: 'camera3',
name: '货架区域B',
location: '仓库西区',
status: 'online'
},
{
id: 'camera4',
name: '装卸平台',
location: '仓库后门',
status: 'offline'
},
{
id: 'camera5',
name: '办公区域',
location: '办公楼',
status: 'online'
}
])
// 当前选中的摄像头
const cameras = ref<Camera[]>([])
const selectedCamera = ref<Camera | null>(null)
// 日期时间范围
const dateTimeRange = ref<string[]>([])
const playbackUrl = ref('')
const playbackLoading = ref(false)
const currentTime = ref('')
// 播放进度
const playProgress = ref(0)
const isPlaying = ref(false)
const playSpeed = ref(1)
// 总时长(模拟值)
const totalDuration = ref('00:30:00')
// 计算在线和离线设备数量
const onlineCount = computed(() => cameras.value.filter(c => c.status === 'online').length)
const offlineCount = computed(() => cameras.value.filter(c => c.status === 'offline').length)
// 当前时间
const currentTime = ref('')
const loadCameras = async () => { try { cameras.value = await fetchCameras('Owl:main') } catch {} }
// 选择摄像头
const selectCamera = (camera: Camera) => {
selectedCamera.value = camera
playProgress.value = 0
isPlaying.value = false
// 设置默认日期时间范围(前一天到今天)
if (!dateTimeRange.value || dateTimeRange.value.length === 0) {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const today = new Date()
dateTimeRange.value = [
yesterday.toISOString().slice(0, 19).replace('T', ' '),
today.toISOString().slice(0, 19).replace('T', ' ')
]
}
ElMessage.success(`已选择 ${camera.name}`)
}
// 搜索录像记录
const searchRecordings = () => {
if (!selectedCamera.value) {
ElMessage.warning('请先选择摄像头')
return
}
if (!dateTimeRange.value || dateTimeRange.value.length !== 2) {
ElMessage.warning('请选择有效的日期时间范围')
return
}
ElMessage.success(`正在查询 ${selectedCamera.value.name} 的历史记录`)
// 重置播放状态
playProgress.value = 0
isPlaying.value = false
}
// 重置日期时间范围
const resetDateTimeRange = () => {
dateTimeRange.value = []
}
// 切换播放/暂停
const togglePlay = () => {
isPlaying.value = !isPlaying.value
ElMessage.info(isPlaying.value ? '开始播放' : '暂停播放')
// 模拟播放进度更新
if (isPlaying.value) {
simulatePlayback()
if (!dateTimeRange.value.length) {
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1)
dateTimeRange.value = [formatISOLocal(yesterday), formatISOLocal(new Date())]
}
}
// 快退10秒
const stepBackward = () => {
playProgress.value = Math.max(0, playProgress.value - 5)
ElMessage.info('后退10秒')
const searchRecordings = async () => {
if (!selectedCamera.value) return ElMessage.warning('请先选择摄像头')
if (!dateTimeRange.value || dateTimeRange.value.length !== 2) return ElMessage.warning('请选择时间段')
playbackLoading.value = true; playbackUrl.value = ''
try {
const cam = selectedCamera.value
const start = new Date(dateTimeRange.value[0]).toISOString()
const end = new Date(dateTimeRange.value[1]).toISOString()
const data = await gwGet(`/api/gateway/streams/${cam.adapterCode}/${cam.sourceId}/playback?start=${start}&end=${end}`)
playbackUrl.value = data.hls || ''
if (!playbackUrl.value) ElMessage.warning('未获取到回放流地址')
} catch { ElMessage.error('获取回放流失败') }
finally { playbackLoading.value = false }
}
// 快进10秒
const stepForward = () => {
playProgress.value = Math.min(100, playProgress.value + 5)
ElMessage.info('前进10秒')
}
const resetDateTimeRange = () => { dateTimeRange.value = []; playbackUrl.value = '' }
const formatISOLocal = (d: Date) => d.toISOString().slice(0, 19).replace('T', ' ')
const formatDateTime = (s: string) => s
// 截图
const screenshot = () => {
if (!selectedCamera.value) {
ElMessage.warning('请先选择摄像头')
return
}
ElMessage.success('截图成功')
}
// 切换全屏
const toggleFullScreen = () => {
ElMessage.info('全屏显示')
// 实际项目中需要实现全屏逻辑
}
// 模拟播放进度
let playbackInterval: number | null = null
const simulatePlayback = () => {
if (playbackInterval) {
clearInterval(playbackInterval)
}
playbackInterval = window.setInterval(() => {
if (isPlaying.value && playProgress.value < 100) {
playProgress.value += 0.1 * playSpeed.value
// 播放结束
if (playProgress.value >= 100) {
playProgress.value = 100
isPlaying.value = false
if (playbackInterval) {
clearInterval(playbackInterval)
}
ElMessage.info('播放结束')
}
}
}, 100)
}
// 格式化日期时间
const formatDateTime = (dateTimeStr: string) => {
const date = new Date(dateTimeStr)
return date.toLocaleString('zh-CN')
}
// 格式化日期
const formatDate = (dateTimeStr: string) => {
const date = new Date(dateTimeStr)
return date.toLocaleDateString('zh-CN')
}
// 格式化进度时间
const formatProgressTime = () => {
const [hours, minutes, seconds] = totalDuration.value.split(':')
const totalSeconds = parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds)
const currentSeconds = Math.floor(totalSeconds * (playProgress.value / 100))
const h = Math.floor(currentSeconds / 3600).toString().padStart(2, '0')
const m = Math.floor((currentSeconds % 3600) / 60).toString().padStart(2, '0')
const s = (currentSeconds % 60).toString().padStart(2, '0')
return `${h}:${m}:${s}`
}
// 更新时间
const updateTime = () => {
currentTime.value = new Date().toLocaleString('zh-CN')
}
// 组件挂载时初始化
onMounted(() => {
updateTime()
setInterval(updateTime, 1000)
// 设置默认日期时间范围
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const today = new Date()
dateTimeRange.value = [
yesterday.toISOString().slice(0, 19).replace('T', ' '),
today.toISOString().slice(0, 19).replace('T', ' ')
]
})
const updateTime = () => { currentTime.value = new Date().toLocaleString('zh-CN') }
onMounted(() => { loadCameras(); updateTime(); setInterval(updateTime, 1000) })
</script>
<style scoped>
.history-playback {
height: 100%;
display: flex;
flex-direction: column;
padding: 20px;
}
.monitor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.monitor-header h2 {
margin: 0;
color: #fff;
font-size: 20px;
font-weight: 500;
}
.monitor-controls {
display: flex;
gap: 10px;
}
.date-filter {
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 15px;
}
.date-filter h3 {
margin: 0 0 10px 0;
color: #fff;
font-size: 16px;
font-weight: 500;
}
.date-filter-controls {
display: flex;
align-items: center;
gap: 10px;
}
.camera-list {
margin-bottom: 20px;
}
.camera-list h3 {
margin: 0 0 15px 0;
color: #fff;
font-size: 16px;
font-weight: 500;
}
.camera-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
cursor: pointer;
transition: all 0.3s ease;
}
.camera-item:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
}
.camera-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.camera-name {
font-weight: 500;
font-size: 14px;
}
.camera-location {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.camera-status {
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
}
.camera-status.online {
background: rgba(103, 194, 58, 0.2);
color: #67c23a;
}
.camera-status.offline {
background: rgba(245, 108, 108, 0.2);
color: #f56c6c;
}
.main-view {
flex: 1;
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
.main-view h3 {
margin: 0 0 15px 0;
color: #fff;
font-size: 16px;
font-weight: 500;
}
.video-container {
flex: 1;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.video-placeholder {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: rgba(255, 255, 255, 0.5);
padding: 20px;
}
.video-placeholder .el-icon {
font-size: 64px;
margin-bottom: 16px;
}
.video-placeholder p {
margin: 5px 0;
}
.video-progress {
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.time-display {
color: #fff;
text-align: center;
margin-top: 5px;
font-size: 12px;
}
.video-controls {
padding: 10px;
background: rgba(0, 0, 0, 0.3);
display: flex;
gap: 10px;
justify-content: center;
align-items: center;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.no-selection {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
}
.system-info {
margin-bottom: 10px;
}
.system-info h3 {
margin: 0 0 15px 0;
color: #fff;
font-size: 16px;
font-weight: 500;
}
.el-descriptions {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.el-descriptions__label {
color: rgba(255, 255, 255, 0.9);
}
.el-descriptions__content {
color: #fff;
}
.el-card.is-hover-shadow:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.el-slider__runway {
background-color: rgba(255, 255, 255, 0.2);
}
.el-slider__bar {
background-color: #409eff;
}
.el-slider__button-wrapper {
border-color: #409eff;
}
</style>
.history-playback { height: 100%; display: flex; flex-direction: column; padding: 20px; }
.monitor-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.monitor-header h2 { margin: 0; color: #fff; font-size: 20px; font-weight: 500; }
.monitor-controls { display: flex; gap: 10px; }
.date-filter h3 { color: #fff; margin-bottom: 10px; }
.date-filter-controls { display: flex; gap: 10px; align-items: center; margin-bottom: 20px; }
.camera-list { margin-bottom: 20px; }
.camera-list h3 { color: #fff; }
.camera-item { background: rgba(255,255,255,0.05); cursor: pointer; }
.camera-name { font-size: 14px; color: #fff; }
.camera-location { font-size: 12px; color: #999; }
.camera-status.online { color: #67c23a; }
.camera-status.offline { color: #f56c6c; }
.main-view { flex: 1; }
.main-view h3 { color: #fff; margin-bottom: 10px; }
.video-container { background: #000; border-radius: 8px; overflow: hidden; }
.video-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 400px; color: #fff; }
.no-selection { flex: 1; display: flex; align-items: center; justify-content: center; }
.system-info { margin-top: 20px; }
.system-info h3 { color: #fff; margin-bottom: 10px; }
</style>

File diff suppressed because it is too large Load Diff