W1: 实时视频页对接网关 真实摄像机+WS-FLV播放+云台控制

This commit is contained in:
2026-05-17 15:38:34 +08:00
parent 21f1df4dac
commit 4d257552cb

View File

@@ -3,7 +3,7 @@
<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>
@@ -16,14 +16,12 @@
<div class="stat-label">设备总数</div>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-value">{{ qualifiedCount }}</div>
<div class="stat-label">达标总数</div>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-value">{{ qualifiedRate }}</div>
@@ -48,15 +46,30 @@
<div class="main-view">
<h3>主监控画面</h3>
<div class="video-container" v-if="selectedCamera">
<div class="video-placeholder">
<el-icon><VideoCamera /></el-icon>
<p>正在加载 {{ selectedCamera.name }} 的实时画面...</p>
<!-- 视频画面 -->
<div class="video-area">
<video v-if="streamUrl" :src="streamUrl" autoplay muted controls style="width:100%;height:100%"></video>
<div v-else class="video-placeholder">
<el-icon><VideoCamera /></el-icon>
<p>{{ streamLoading ? '正在加载' : '' }} {{ selectedCamera.name }} 的实时画面...</p>
</div>
</div>
<div class="video-controls">
<el-button size="small" icon="el-icon-video-play">播放</el-button>
<el-button size="small" icon="el-icon-video-pause">暂停</el-button>
<el-button size="small" icon="el-icon-refresh">刷新</el-button>
<el-button size="small" icon="el-icon-full-screen">全屏</el-button>
<!-- 云台控制面板 -->
<div class="ptz-panel" v-if="selectedCamera.hasPtz">
<h4>云台控制</h4>
<div class="ptz-pad">
<el-button circle @mousedown="ptzGo('up')" @mouseup="ptzStop" @mouseleave="ptzStop"><el-icon><ArrowUp /></el-icon></el-button>
<div class="ptz-row">
<el-button circle @mousedown="ptzGo('left')" @mouseup="ptzStop"><el-icon><ArrowLeft /></el-icon></el-button>
<el-button circle type="danger" size="small" @click="ptzGo('stop')"><el-icon><Close /></el-icon></el-button>
<el-button circle @mousedown="ptzGo('right')" @mouseup="ptzStop"><el-icon><ArrowRight /></el-icon></el-button>
</div>
<el-button circle @mousedown="ptzGo('down')" @mouseup="ptzStop"><el-icon><ArrowDown /></el-icon></el-button>
<div class="ptz-row" style="margin-top:8px">
<el-button circle size="small" @mousedown="ptzGo('zoom_in')" @mouseup="ptzStop">+</el-button>
<el-button circle size="small" @mousedown="ptzGo('zoom_out')" @mouseup="ptzStop"></el-button>
</div>
</div>
</div>
</div>
<div class="no-selection" v-else>
@@ -79,299 +92,96 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { VideoCamera } from '@element-plus/icons-vue'
import { fetchCameras, gwGet, gwPost, 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 streamUrl = ref('')
const streamLoading = ref(false)
const currentTime = ref('')
// 计算在线和离线设备数量
const onlineCount = computed(() => cameras.value.filter(c => c.status === 'online').length)
const offlineCount = computed(() => cameras.value.filter(c => c.status === 'offline').length)
// 统计数据 - 设备总数(直接使用cameras.length)
// 达标总数 - 基于在线设备模拟为在线数量减1
const qualifiedCount = computed(() => Math.max(0, onlineCount.value - 1))
// 达标率 - 计算达标率百分比
const qualifiedRate = computed(() => {
if (cameras.value.length === 0) return '0%'
return `${((qualifiedCount.value / cameras.value.length) * 100).toFixed(1)}%`
})
// 当前时间
const currentTime = ref('')
// ── 加载摄像机列表 ──
const loadCameras = async () => {
try {
cameras.value = await fetchCameras('Owl:main')
} catch (e) {
console.error('获取摄像机列表失败:', e)
}
}
// 选择摄像头
const selectCamera = (camera: Camera) => {
// ── 选中摄像机 → 取流 ──
const selectCamera = async (camera: Camera) => {
selectedCamera.value = camera
if (camera.status !== 'online') return
streamLoading.value = true
streamUrl.value = ''
try {
const data = await gwGet(`/api/gateway/streams/${camera.adapterCode}/${camera.sourceId}/live`)
streamUrl.value = data.wsFlv || data.httpFlv || data.hls || ''
} catch (e) {
console.error('取流失败:', e)
} finally {
streamLoading.value = false
}
}
// 更新时间
const updateTime = () => {
currentTime.value = new Date().toLocaleString('zh-CN')
// ── 云台控制 ──
const ptzSend = (direction: string, speed = 0.5) => {
const cam = selectedCamera.value
if (!cam) return
gwPost(`/api/gateway/streams/${cam.adapterCode}/${cam.sourceId}/ptz`, {
direction,
action: direction === 'stop' ? 'stop' : 'continuous',
speed
})
}
const ptzGo = (d: string) => ptzSend(d)
const ptzStop = () => ptzSend('stop')
const updateTime = () => { currentTime.value = new Date().toLocaleString('zh-CN') }
// 组件挂载时初始化
onMounted(() => {
loadCameras()
updateTime()
setInterval(updateTime, 1000)
})
</script>
<style scoped>
.live-monitor {
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;
}
.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);
}
.video-placeholder .el-icon {
font-size: 48px;
margin-bottom: 16px;
}
.video-controls {
padding: 10px;
background: rgba(0, 0, 0, 0.3);
display: flex;
gap: 10px;
justify-content: center;
}
.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);
}
/* 统计卡片样式 */
.stats-cards {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
transition: all 0.3s ease;
}
.stat-card:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 0;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #67c23a;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
}
/* 响应式布局 */
@media (max-width: 768px) {
.stats-cards {
flex-direction: column;
}
.stat-value {
font-size: 24px;
}
}
</style>
.live-monitor { 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; }
.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); 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; }
.stats-cards { display: flex; gap: 16px; margin-bottom: 20px; }
.stat-card { flex: 1; background: rgba(255,255,255,0.05); }
.stat-value { font-size: 24px; color: #409eff; font-weight: bold; }
.stat-label { font-size: 12px; color: #999; }
.main-view { flex: 1; }
.main-view h3 { color: #fff; margin-bottom: 10px; }
.video-container { display: flex; gap: 12px; }
.video-area { flex: 1; min-height: 400px; background: #000; display: flex; align-items: center; justify-content: center; }
.video-placeholder { color: #fff; text-align: center; }
.video-placeholder .el-icon { font-size: 48px; margin-bottom: 8px; }
.ptz-panel { width: 120px; padding: 10px; background: rgba(255,255,255,0.05); border-radius: 8px; }
.ptz-panel h4 { color: #fff; text-align: center; margin: 0 0 8px 0; font-size: 13px; }
.ptz-pad { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.ptz-row { display: flex; gap: 4px; }
.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>