添加GB2818的例子

This commit is contained in:
孙小云 2025-12-10 18:13:03 +08:00
parent 500d5f688a
commit 6888d46ea2
2 changed files with 823 additions and 1 deletions

View File

@ -4,8 +4,9 @@ import WebSocketDemo from './components/WebSocketDemo.vue'
import Zlm from './components/Zlm.vue'
import Wvp from './components/Wvp.vue'
import GB2818Live from './components/GB2818Live.vue'
import GB2818Record from './components/GB2818Record.vue'
type TabType = 'websocket' | 'zlm' | 'wvp' | 'gb28181'
type TabType = 'websocket' | 'zlm' | 'wvp' | 'gb28181' | 'gb28181-record'
const activeTab = ref<TabType>('websocket')
@ -41,6 +42,12 @@ const switchTab = (tab: TabType) => {
>
GB28181 Live
</button>
<button
:class="{ active: activeTab === 'gb28181-record' }"
@click="switchTab('gb28181-record')"
>
GB28181 Record
</button>
</div>
<div class="tab-content">
@ -48,6 +55,7 @@ const switchTab = (tab: TabType) => {
<Zlm v-if="activeTab === 'zlm'" />
<Wvp v-if="activeTab === 'wvp'" />
<GB2818Live v-if="activeTab === 'gb28181'" />
<GB2818Record v-if="activeTab === 'gb28181-record'" />
</div>
</div>
</template>

View File

@ -0,0 +1,814 @@
<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'
}
//
onUnmounted(async () => {
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>