feat: 重建 Sense 单路实时监看 (#68)
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>实时监看</h3>
|
||||
<p>按设备选择一路已验证视频;关闭窗口后不会继续占用播放器连接。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="queryForm" :model="queryParams" inline label-position="left" class="search-form" @submit.prevent>
|
||||
<el-form-item label="设备或位置" prop="keyword">
|
||||
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入设备、位置或码流名称" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!loading && total === 0"
|
||||
title="没有可监看的视频。请先在“视频接入”完成验证,再到“视频服务”确认路径状态。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="empty-alert"
|
||||
/>
|
||||
|
||||
<el-table v-loading="loading" :data="routes" border stripe>
|
||||
<el-table-column prop="deviceName" label="设备" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceLocation" label="安装位置" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.deviceLocation || '未填写' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="码流" min-width="170">
|
||||
<template #default="scope">
|
||||
<div>{{ profileLabel(scope.row) }}</div>
|
||||
<small class="muted-text">{{ resolutionLabel(scope.row) }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="routeStatus(scope.row.actual).type" size="small">{{ routeStatus(scope.row.actual).label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="readers" label="当前观看" width="100" />
|
||||
<el-table-column prop="detail" label="说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:liveview:view']" type="primary" link @click="handleWatch(scope.row)">实时查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:page="queryParams.pageIndex"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="playerOpen" width="min(1000px, 92vw)" :close-on-click-modal="false" destroy-on-close @closed="closePlayer">
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<strong>{{ currentRoute.deviceName || '实时视频' }}</strong>
|
||||
<span>{{ profileLabel(currentRoute) }} · {{ currentRoute.deviceLocation || '未填写位置' }}</span>
|
||||
</div>
|
||||
<el-tag :type="playbackStatusType(playerState)">{{ playbackStatusLabel(playerState) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SenseVideoPlayer
|
||||
v-if="playerOpen"
|
||||
:state="playerState"
|
||||
:detail="playerDetail"
|
||||
:player-url="session.playerUrl"
|
||||
:retrying="retrying"
|
||||
@retry="openSession"
|
||||
/>
|
||||
|
||||
<el-descriptions :column="3" border class="player-details">
|
||||
<el-descriptions-item label="设备">{{ currentRoute.deviceName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="码流">{{ profileLabel(currentRoute) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分辨率">{{ resolutionLabel(currentRoute) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理建议" :span="3">{{ playbackStatusDetail(playerState, playerDetail) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="playerOpen = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="retrying" @click="openSession">重新连接</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createLiveviewSession, getLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||
import SenseVideoPlayer from '@/components/sense/video-player'
|
||||
import { playbackStatusDetail, playbackStatusLabel, playbackStatusType, routeStatus } from './playbackStatus'
|
||||
|
||||
export default {
|
||||
name: 'SenseLiveview',
|
||||
components: { SenseVideoPlayer },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
routes: [],
|
||||
total: 0,
|
||||
queryParams: { keyword: '', pageIndex: 1, pageSize: 10 },
|
||||
playerOpen: false,
|
||||
currentRoute: {},
|
||||
session: {},
|
||||
playerState: 'loading',
|
||||
playerDetail: '',
|
||||
retrying: false,
|
||||
pollTimer: null,
|
||||
timeoutTimer: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopTimers()
|
||||
},
|
||||
methods: {
|
||||
playbackStatusDetail,
|
||||
playbackStatusLabel,
|
||||
playbackStatusType,
|
||||
routeStatus,
|
||||
profileLabel(route) {
|
||||
if (!route || !route.id) return '—'
|
||||
const kind = { main: '主码流', sub: '子码流', other: '其他码流' }[route.profileKind] || '码流'
|
||||
return route.profileName ? `${kind}(${route.profileName})` : kind
|
||||
},
|
||||
resolutionLabel(route) {
|
||||
if (!route || !route.width || !route.height) return '未取得分辨率'
|
||||
return `${route.width} × ${route.height}${route.encoding ? ` · ${route.encoding}` : ''}`
|
||||
},
|
||||
async getList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await listLiveviewRoutes(this.queryParams)
|
||||
this.routes = response.data.list || []
|
||||
this.total = response.data.count || 0
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.$refs.queryForm.resetFields()
|
||||
this.handleQuery()
|
||||
},
|
||||
handleWatch(route) {
|
||||
this.currentRoute = { ...route }
|
||||
this.playerOpen = true
|
||||
this.openSession()
|
||||
},
|
||||
async openSession() {
|
||||
if (!this.currentRoute.id || this.retrying) return
|
||||
this.stopTimers()
|
||||
this.retrying = true
|
||||
this.playerState = 'loading'
|
||||
this.playerDetail = ''
|
||||
this.session = {}
|
||||
try {
|
||||
const response = await createLiveviewSession(this.currentRoute.id)
|
||||
this.session = response.data
|
||||
this.playerState = response.data.status
|
||||
this.playerDetail = response.data.detail
|
||||
this.startPolling()
|
||||
} catch (error) {
|
||||
this.playerState = 'offline'
|
||||
this.playerDetail = error.message || ''
|
||||
} finally {
|
||||
this.retrying = false
|
||||
}
|
||||
},
|
||||
startPolling() {
|
||||
this.pollTimer = window.setInterval(this.refreshSession, 2000)
|
||||
this.timeoutTimer = window.setTimeout(() => {
|
||||
if (['loading', 'waiting'].includes(this.playerState)) {
|
||||
this.playerState = 'timeout'
|
||||
this.playerDetail = ''
|
||||
this.stopTimers()
|
||||
}
|
||||
}, 20000)
|
||||
},
|
||||
async refreshSession() {
|
||||
if (!this.session.id || !this.playerOpen) return
|
||||
try {
|
||||
const response = await getLiveviewSession(this.session.id)
|
||||
this.session = response.data
|
||||
this.playerState = response.data.status
|
||||
this.playerDetail = response.data.detail
|
||||
if (this.playerState === 'ready') {
|
||||
window.clearTimeout(this.timeoutTimer)
|
||||
this.timeoutTimer = null
|
||||
} else if (!['loading', 'waiting'].includes(this.playerState)) {
|
||||
this.stopTimers()
|
||||
}
|
||||
} catch (error) {
|
||||
this.playerState = 'expired'
|
||||
this.playerDetail = ''
|
||||
this.stopTimers()
|
||||
}
|
||||
},
|
||||
stopTimers() {
|
||||
window.clearInterval(this.pollTimer)
|
||||
window.clearTimeout(this.timeoutTimer)
|
||||
this.pollTimer = null
|
||||
this.timeoutTimer = null
|
||||
},
|
||||
closePlayer() {
|
||||
this.stopTimers()
|
||||
this.session = {}
|
||||
this.currentRoute = {}
|
||||
this.playerState = 'loading'
|
||||
this.playerDetail = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.dialog-header span,
|
||||
.muted-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.dialog-header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-form,
|
||||
.empty-alert,
|
||||
.player-details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-input) {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.search-form :deep(.el-input) {
|
||||
width: min(280px, 70vw);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
const labels = {
|
||||
loading: '正在连接',
|
||||
waiting: '等待视频',
|
||||
ready: '播放正常',
|
||||
authentication_failed: '摄像头认证失败',
|
||||
stream_not_found: '未找到视频流',
|
||||
service_unavailable: '视频服务不可用',
|
||||
timeout: '连接超时',
|
||||
expired: '播放会话已过期',
|
||||
offline: '视频已断开',
|
||||
stopped: '视频已停止'
|
||||
}
|
||||
|
||||
const details = {
|
||||
loading: '正在建立短期播放会话,请稍候。',
|
||||
waiting: '播放器已连接,正在等待摄像头开始传输画面。',
|
||||
ready: '摄像头视频正在传输。',
|
||||
authentication_failed: '请到“设备管理”更新摄像头账号或密码,再重新验证视频接入。',
|
||||
stream_not_found: '请到“视频服务”执行对账,确认媒体路径已经恢复。',
|
||||
service_unavailable: '请到“视频服务”检查 MediaMTX 进程和端口配置。',
|
||||
timeout: '20 秒内未收到画面,请检查摄像头网络后重新连接。',
|
||||
expired: '短期播放会话已失效,请重新连接。',
|
||||
offline: '无法取得最新播放状态,请检查网络后重新连接。',
|
||||
stopped: '该视频路径已停止,请先到“视频服务”恢复。'
|
||||
}
|
||||
|
||||
export function playbackStatusLabel(value) {
|
||||
return labels[value] || '暂时无法播放'
|
||||
}
|
||||
|
||||
export function playbackStatusDetail(value, detail) {
|
||||
return detail || details[value] || details.offline
|
||||
}
|
||||
|
||||
export function playbackStatusType(value) {
|
||||
if (value === 'ready') return 'success'
|
||||
if (value === 'loading' || value === 'waiting') return 'warning'
|
||||
if (value === 'stopped') return 'info'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
export function routeStatus(value) {
|
||||
if (value === 'ready') return { label: '可观看', type: 'success' }
|
||||
if (value === 'waiting') return { label: '等待观看', type: 'warning' }
|
||||
return { label: '需要处理', type: 'danger' }
|
||||
}
|
||||
Reference in New Issue
Block a user