thingsboard-html-demo/src/components/GB2818Record.vue

861 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
const GATEWAY_URL = 'http://localhost:8080'
// 查询参数
const channelId = ref(1)
const startTime = ref('')
const endTime = ref('')
const queryLoading = ref(false)
const queryError = ref('')
const recordList = ref<any[]>([])
const selectedRecord = ref<any>(null)
// 初始化时间为今天全天
const initTime = () => {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
startTime.value = `${year}-${month}-${day} 00:00:00`
endTime.value = `${year}-${month}-${day} 23:59:59`
}
// 初始化时间
initTime()
// 查询录像列表
const queryRecordList = async () => {
if (!channelId.value) {
queryError.value = '请输入通道ID'
return
}
if (!startTime.value || !endTime.value) {
queryError.value = '请选择开始时间和结束时间'
return
}
queryLoading.value = true
queryError.value = ''
recordList.value = []
selectedRecord.value = null
try {
const params = new URLSearchParams({
channelId: channelId.value.toString(),
startTime: startTime.value,
endTime: endTime.value
})
const response = await fetch(`${GATEWAY_URL}/wvp/api/common/channel/playback/query?${params}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if (data.code === 0 && data.data) {
recordList.value = data.data
if (recordList.value.length === 0) {
queryError.value = '未找到录像记录'
}
} else {
queryError.value = data.msg || '查询录像列表失败'
}
} catch (error) {
queryError.value = error instanceof Error ? error.message : '查询录像列表失败'
} finally {
queryLoading.value = false
}
}
// 播放相关
const playLoading = ref(false)
const playError = ref('')
const playbackData = ref<any>(null)
const currentStreamId = ref<string>('')
// Jessibuca 播放器
let jessibucaPlayer: any = null
const playerContainerRef = ref<HTMLDivElement | null>(null)
const isPlaying = ref(false)
const kBps = ref(0)
// 停止当前播放流调用API
const stopPlaybackStream = async () => {
if (!currentStreamId.value) {
return
}
try {
const params = new URLSearchParams({
channelId: channelId.value.toString(),
stream: currentStreamId.value
})
const response = await fetch(`${GATEWAY_URL}/wvp/api/common/channel/playback/stop?${params}`)
if (!response.ok) {
console.error('停止播放流失败:', response.status)
}
const data = await response.json()
if (data.code === 0) {
console.log('停止播放流成功')
} else {
console.error('停止播放流失败:', data.msg)
}
} catch (error) {
console.error('停止播放流异常:', error)
} finally {
currentStreamId.value = ''
}
}
// 播放录像
const playRecord = async (record: any) => {
selectedRecord.value = record
playLoading.value = true
playError.value = ''
// 先停止当前播放的流(如果有)
if (currentStreamId.value) {
await stopPlaybackStream()
}
// 停止播放器
stopPlay()
try {
const params = new URLSearchParams({
channelId: channelId.value.toString(),
startTime: record.startTime,
endTime: record.endTime
})
const response = await fetch(`${GATEWAY_URL}/wvp/api/common/channel/playback?${params}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if (data.code === 0 && data.data) {
playbackData.value = data.data
playError.value = ''
// 保存stream ID用于后续停止
if (data.data.stream) {
currentStreamId.value = data.data.stream
}
// 自动播放WebSocket-FLV流
const playUrl = getPlayUrl(data.data)
if (playUrl) {
setTimeout(() => {
playJessibuca(playUrl)
}, 500)
} else {
playError.value = '未找到可用的播放地址'
}
} else {
playError.value = data.msg || '获取播放地址失败'
}
} catch (error) {
playError.value = error instanceof Error ? error.message : '获取播放地址失败'
} finally {
playLoading.value = false
}
}
// 获取播放地址优先使用WebSocket-FLV
const getPlayUrl = (data: any) => {
// 根据协议选择合适的流地址
if (location.protocol === 'https:') {
return data.wss_flv || data.ws_flv || data.flv
} else {
return data.ws_flv || data.wss_flv || data.flv
}
}
// 使用Jessibuca播放器播放
const playJessibuca = (url: string) => {
playError.value = ''
if (!playerContainerRef.value) {
playError.value = '播放器容器未找到'
return
}
if (jessibucaPlayer) {
jessibucaPlayer.destroy()
jessibucaPlayer = null
}
try {
// @ts-ignore
jessibucaPlayer = new window.Jessibuca({
container: playerContainerRef.value,
videoBuffer: 0,
isResize: true,
useMSE: true,
useWCS: false,
text: '',
controlAutoHide: false,
debug: false,
hotKey: true,
// @ts-ignore
decoder: '/static/js/jessibuca/decoder.js',
timeout: 10,
recordType: 'mp4',
isFlv: false,
forceNoOffscreen: true,
hasAudio: true,
heartTimeout: 5,
heartTimeoutReplay: true,
heartTimeoutReplayTimes: 3,
hiddenAutoPause: false,
isNotMute: true,
keepScreenOn: true,
loadingText: '请稍等, 视频加载中......',
loadingTimeout: 10,
loadingTimeoutReplay: true,
loadingTimeoutReplayTimes: 3,
operateBtns: {
fullscreen: false,
screenshot: false,
play: false,
audio: false,
recorder: false
},
showBandwidth: false,
supportDblclickFullscreen: false,
useWebFullSreen: true,
wasmDecodeErrorReplay: true
})
jessibucaPlayer.on('play', () => {
isPlaying.value = true
})
jessibucaPlayer.on('pause', () => {
isPlaying.value = false
})
jessibucaPlayer.on('kBps', (val: number) => {
kBps.value = Math.round(val)
})
jessibucaPlayer.on('error', (error: any) => {
console.error('Jessibuca error:', error)
playError.value = '播放错误: ' + (error.message || error)
isPlaying.value = false
})
jessibucaPlayer.on('timeout', () => {
playError.value = '播放超时'
isPlaying.value = false
})
jessibucaPlayer.play(url)
} catch (error) {
playError.value = error instanceof Error ? error.message : '播放失败'
isPlaying.value = false
}
}
// 停止播放仅停止播放器不调用API
const stopPlay = () => {
if (jessibucaPlayer) {
jessibucaPlayer.pause()
jessibucaPlayer.destroy()
jessibucaPlayer = null
}
isPlaying.value = false
kBps.value = 0
}
// 完全停止播放停止播放器并调用API
const stopPlayComplete = async () => {
// 停止播放器
stopPlay()
// 调用API停止流
await stopPlaybackStream()
// 清空播放数据
playbackData.value = null
selectedRecord.value = null
}
// 格式化文件大小
const formatFileSize = (size: number | null) => {
if (!size) return '-'
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(2) + ' MB'
return (size / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
// 页面关闭前停止播放
const handleBeforeUnload = async () => {
stopPlay()
await stopPlaybackStream()
}
// 页面可见性变化处理(当用户切换标签页或最小化窗口时)
const handleVisibilityChange = async () => {
if (document.hidden && isPlaying.value) {
console.log('页面不可见,停止播放以节省资源')
// 页面不可见时停止播放
await stopPlayComplete()
}
}
// 网络状态变化处理
const handleOnline = () => {
console.log('网络已恢复')
}
const handleOffline = async () => {
console.log('网络已断开,停止播放')
if (isPlaying.value) {
// 网络断开时停止播放器但不调用API因为网络已断开
stopPlay()
playError.value = '网络连接已断开'
}
}
// 组件挂载时添加各种事件监听
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
}
// 组件卸载时清理播放器和停止流
onUnmounted(async () => {
// 移除所有事件监听
if (typeof window !== 'undefined') {
window.removeEventListener('beforeunload', handleBeforeUnload)
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
// 停止播放器和流
stopPlay()
await stopPlaybackStream()
})
</script>
<template>
<div class="gb28181-record-container">
<h2>GB28181 国标录像回放</h2>
<div class="api-section">
<h3>1. 查询录像列表</h3>
<div class="api-description">
<p>通过网关调用: <code>GET /wvp/api/common/channel/playback/query</code></p>
<p>实际转发到: <code>http://114.67.89.4:9090/api/common/channel/playback/query</code></p>
<p>网关会自动注入 access-token 请求头</p>
</div>
<div class="form-group">
<label>通道ID (channelId) - 必填:</label>
<input
v-model.number="channelId"
type="number"
min="1"
placeholder="请输入通道ID"
/>
<small style="color: #666; display: block; margin-top: 5px;">
提示通道ID是数据库主键IDgbId不是国标设备ID
</small>
</div>
<div class="form-row">
<div class="form-group">
<label>开始时间 (startTime):</label>
<input
v-model="startTime"
type="text"
placeholder="2025-12-10 00:00:00"
/>
</div>
<div class="form-group">
<label>结束时间 (endTime):</label>
<input
v-model="endTime"
type="text"
placeholder="2025-12-10 23:59:59"
/>
</div>
</div>
<div class="button-group">
<button @click="queryRecordList" :disabled="queryLoading">
{{ queryLoading ? '查询中...' : '搜索' }}
</button>
<button @click="initTime" class="secondary-button">
重置为今天
</button>
</div>
<div v-if="queryError" class="error">
错误: {{ queryError }}
</div>
<div v-if="recordList.length > 0" class="record-list">
<h4>录像列表 (共 {{ recordList.length }} 条):</h4>
<table class="record-table">
<thead>
<tr>
<th>序号</th>
<th>开始时间</th>
<th>结束时间</th>
<th>文件大小</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="(record, index) in recordList"
:key="index"
:class="{ 'selected': selectedRecord === record }"
>
<td>{{ index + 1 }}</td>
<td>{{ record.startTime }}</td>
<td>{{ record.endTime }}</td>
<td>{{ formatFileSize(record.fileSize) }}</td>
<td>
<button
@click="playRecord(record)"
class="play-button"
:disabled="playLoading && selectedRecord === record"
>
{{ (playLoading && selectedRecord === record) ? '加载中...' : '播放' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="api-section" v-if="playbackData">
<h3>2. 录像回放</h3>
<div class="api-description">
<p>播放接口: <code>GET /wvp/api/common/channel/playback</code></p>
<p>实际转发到: <code>http://114.67.89.4:9090/api/common/channel/playback</code></p>
</div>
<div class="selected-record-info">
<h4>当前播放的录像:</h4>
<p><strong>开始时间:</strong> {{ selectedRecord?.startTime }}</p>
<p><strong>结束时间:</strong> {{ selectedRecord?.endTime }}</p>
<p><strong>文件大小:</strong> {{ formatFileSize(selectedRecord?.fileSize) }}</p>
</div>
<div class="player-section">
<h4>视频播放器 (Jessibuca):</h4>
<div class="video-container">
<div
ref="playerContainerRef"
class="video-player"
style="width: 100%; height: 100%; background-color: #000; position: relative;"
></div>
</div>
<div class="player-info" v-if="isPlaying">
<span class="info-text">播放中</span>
<span class="info-text" style="margin-left: 20px;">码率: {{ kBps }} kb/s</span>
</div>
<div class="button-group">
<button @click="stopPlayComplete" :disabled="!isPlaying" class="stop-button">
停止播放
</button>
</div>
<div v-if="playError" class="error">
播放错误: {{ playError }}
</div>
</div>
<div class="play-urls-info">
<h4>播放地址:</h4>
<div class="url-list">
<div v-if="playbackData.ws_flv" class="url-item">
<strong>WebSocket-FLV:</strong>
<code>{{ playbackData.ws_flv }}</code>
<button @click="playJessibuca(playbackData.ws_flv)" class="small-button">播放</button>
</div>
<div v-if="playbackData.wss_flv" class="url-item">
<strong>WebSocket-FLV (SSL):</strong>
<code>{{ playbackData.wss_flv }}</code>
<button @click="playJessibuca(playbackData.wss_flv)" class="small-button">播放</button>
</div>
<div v-if="playbackData.flv" class="url-item">
<strong>HTTP-FLV:</strong>
<code>{{ playbackData.flv }}</code>
</div>
<div v-if="playbackData.hls" class="url-item">
<strong>HLS:</strong>
<code>{{ playbackData.hls }}</code>
</div>
<div v-if="playbackData.rtmp" class="url-item">
<strong>RTMP:</strong>
<code>{{ playbackData.rtmp }}</code>
</div>
<div v-if="playbackData.rtsp" class="url-item">
<strong>RTSP:</strong>
<code>{{ playbackData.rtsp }}</code>
</div>
<div v-if="playbackData.rtc" class="url-item">
<strong>WebRTC:</strong>
<code>{{ playbackData.rtc }}</code>
</div>
</div>
</div>
</div>
<div class="info-box">
<h4>使用说明:</h4>
<p>1. 输入通道ID数据库主键ID不是国标设备ID</p>
<p>2. 选择开始时间和结束时间默认为今天全天</p>
<p>3. 点击"搜索"按钮查询录像列表</p>
<p>4. 在录像列表中点击"播放"按钮播放对应的录像</p>
<p>5. 录像会使用Jessibuca播放器自动播放WebSocket-FLV流</p>
<p><strong>注意:</strong> 时间格式为 YYYY-MM-DD HH:mm:ss例如2025-12-10 00:00:00</p>
<p><strong>播放器:</strong> 使用Jessibuca播放器支持WebSocket-FLV协议低延迟高性能</p>
</div>
</div>
</template>
<style scoped>
.gb28181-record-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h2 {
margin-bottom: 30px;
color: #333;
}
.api-section {
background-color: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.api-section h3 {
margin-top: 0;
margin-bottom: 15px;
color: #2196f3;
font-size: 18px;
}
.api-description {
background-color: #f5f5f5;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
}
.api-description p {
margin: 5px 0;
font-size: 14px;
color: #666;
}
.api-description code {
background-color: #e0e0e0;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #d32f2f;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
font-size: 14px;
}
.form-group input[type="text"],
.form-group input[type="number"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #2196f3;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
button {
padding: 10px 20px;
background-color: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.3s ease;
}
button:hover:not(:disabled) {
background-color: #1976d2;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.secondary-button {
background-color: #757575;
}
.secondary-button:hover:not(:disabled) {
background-color: #616161;
}
.stop-button {
background-color: #f44336;
}
.stop-button:hover:not(:disabled) {
background-color: #d32f2f;
}
.play-button {
padding: 5px 15px;
font-size: 12px;
}
.small-button {
padding: 5px 10px;
font-size: 12px;
margin-left: 10px;
}
.error {
margin-top: 15px;
padding: 12px;
background-color: #ffebee;
color: #c62828;
border-radius: 4px;
font-size: 14px;
}
.record-list {
margin-top: 20px;
}
.record-list h4 {
margin-bottom: 10px;
color: #333;
}
.record-table {
width: 100%;
border-collapse: collapse;
background-color: white;
border-radius: 4px;
overflow: hidden;
border: 1px solid #e0e0e0;
}
.record-table thead {
background-color: #2196f3;
color: white;
}
.record-table th,
.record-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.record-table th {
font-weight: 600;
font-size: 14px;
}
.record-table td {
font-size: 13px;
color: #333;
}
.record-table tbody tr:hover {
background-color: #f5f5f5;
}
.record-table tbody tr.selected {
background-color: #e3f2fd;
}
.record-table tbody tr:last-child td {
border-bottom: none;
}
.selected-record-info {
background-color: #e3f2fd;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.selected-record-info h4 {
margin-top: 0;
margin-bottom: 10px;
color: #1976d2;
}
.selected-record-info p {
margin: 5px 0;
font-size: 14px;
color: #333;
}
.player-section {
margin-top: 20px;
}
.player-section h4 {
margin-bottom: 10px;
color: #333;
}
.video-container {
background-color: #000;
border-radius: 4px;
overflow: hidden;
margin-bottom: 15px;
}
.video-player {
width: 100%;
min-height: 500px;
max-height: 600px;
display: block;
background-color: #000;
}
.player-info {
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
margin-top: 10px;
display: flex;
align-items: center;
}
.info-text {
color: #2196f3;
font-size: 14px;
font-weight: 500;
}
.play-urls-info {
margin-top: 20px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 4px;
}
.play-urls-info h4 {
margin-top: 0;
margin-bottom: 15px;
color: #333;
}
.url-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.url-item {
display: flex;
align-items: center;
padding: 10px;
background-color: white;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
.url-item strong {
min-width: 80px;
color: #2196f3;
font-size: 14px;
}
.url-item code {
flex: 1;
background-color: #f5f5f5;
padding: 5px 10px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #333;
word-break: break-all;
}
.info-box {
margin-top: 20px;
padding: 15px;
background-color: #e3f2fd;
border-radius: 4px;
border-left: 4px solid #2196f3;
}
.info-box h4 {
margin-top: 0;
margin-bottom: 10px;
color: #1976d2;
}
.info-box p {
margin: 8px 0;
font-size: 14px;
color: #333;
}
</style>