| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631 |
- <!--场站视频监控-->
- <template>
- <div class="gas-video-monitor">
- <!-- 页面头部 -->
- <div class="page-header">
- <div class="header-left">
- <el-icon><VideoCamera /></el-icon>
- <span class="title">沅陵县燃气场站视频监控中心</span>
- <div class="stats-badge">
- <span class="stat-item online"><i class="dot"></i>在线 {{ onlineCount }}</span>
- <span class="stat-item offline"><i class="dot"></i>离线 {{ offlineCount }}</span>
- <span class="stat-item alert"><i class="dot"></i>告警场站 {{ alertCount }}</span>
- </div>
- </div>
- <div class="header-right">
- <el-input
- v-model="searchKeyword"
- placeholder="搜索场站名称"
- clearable
- :prefix-icon="Search"
- style="width: 200px"
- />
- <el-button type="primary" plain :icon="Refresh" @click="refreshData">刷新</el-button>
- <el-button :icon="FullScreen" @click="toggleFullscreen">全屏</el-button>
- </div>
- </div>
- <!-- 主内容区:左侧场站列表 + 右侧视频监控墙 -->
- <div class="main-layout">
- <!-- 左侧场站列表 -->
- <div class="station-list-panel">
- <div class="list-header">
- <span>沅陵县燃气场站</span>
- <span class="total-num">共 {{ filteredStations.length }} 座场站</span>
- </div>
- <div class="station-list">
- <div
- v-for="station in filteredStations"
- :key="station.id"
- class="station-item"
- :class="{
- active: selectedStation?.id === station.id,
- alert: station.alertStatus,
- online: station.videoStatus === 'online'
- }"
- @click="selectStation(station)"
- >
- <div class="station-avatar">
- <el-icon v-if="station.videoStatus === 'online'"><VideoCamera /></el-icon>
- <el-icon v-else><VideoCameraFilled /></el-icon>
- </div>
- <div class="station-info">
- <div class="station-name">{{ station.name }}</div>
- <div class="station-address">{{ station.address }}</div>
- </div>
- <div class="station-badge">
- <el-tag v-if="station.alertStatus" type="danger" size="small">告警</el-tag>
- <el-tag v-else :type="station.videoStatus === 'online' ? 'success' : 'info'" size="small">
- {{ station.videoStatus === 'online' ? '在线' : '离线' }}
- </el-tag>
- </div>
- </div>
- </div>
- </div>
- <!-- 右侧视频监控区 -->
- <div class="video-panel">
- <div class="video-header" v-if="selectedStation">
- <div class="video-title">
- <span class="station-name">{{ selectedStation.name }}</span>
- <el-tag :type="selectedStation.videoStatus === 'online' ? 'success' : 'danger'" size="small">
- {{ selectedStation.videoStatus === 'online' ? '视频在线' : '视频离线' }}
- </el-tag>
- <el-tag v-if="selectedStation.alertStatus" type="danger" size="small" effect="dark">
- 监测告警中
- </el-tag>
- </div>
- <div class="video-actions">
- <el-button size="small" :icon="Refresh" @click="refreshVideo">刷新</el-button>
- <el-button size="small" :icon="FullScreen" @click="toggleVideoFullscreen">全屏</el-button>
- <el-button size="small" :icon="Download" @click="captureSnapshot">抓拍</el-button>
- </div>
- </div>
- <!-- 视频播放区域 -->
- <div class="video-container" v-if="selectedStation">
- <div class="main-video" ref="videoContainer">
- <video
- ref="videoPlayer"
- :src="selectedStation.videoUrl"
- autoplay
- muted
- loop
- controls
- class="video-player"
- v-if="selectedStation.videoStatus === 'online'"
- ></video>
- <div class="video-placeholder" v-else>
- <el-icon><VideoCameraFilled /></el-icon>
- <span>视频信号离线,请检查设备连接</span>
- </div>
- <!-- 监测数据叠加层 -->
- <div class="monitor-overlay" v-if="selectedStation">
- <div class="overlay-item methane" :class="{ warning: selectedStation.methane > selectedStation.methaneThreshold }">
- <span class="label">甲烷浓度</span>
- <span class="value">{{ selectedStation.methane }} ppm</span>
- </div>
- <div class="overlay-item pressure">
- <span class="label">进口压力</span>
- <span class="value">{{ selectedStation.inPressure }} kPa</span>
- </div>
- <div class="overlay-item pressure">
- <span class="label">出口压力</span>
- <span class="value">{{ selectedStation.outPressure }} kPa</span>
- </div>
- </div>
- </div>
- <!-- 监测数据卡片 -->
- <div class="monitor-cards">
- <div class="card" :class="{ warning: selectedStation.methane > selectedStation.methaneThreshold }" @click="showDetail('methane')">
- <div class="card-icon"><el-icon><WindPower /></el-icon></div>
- <div class="card-info">
- <div class="card-label">甲烷浓度</div>
- <div class="card-value">{{ selectedStation.methane }} <span class="unit">ppm</span></div>
- </div>
- </div>
- <div class="card" @click="showDetail('pressure')">
- <div class="card-icon"><el-icon><Connection /></el-icon></div>
- <div class="card-info">
- <div class="card-label">进口压力</div>
- <div class="card-value">{{ selectedStation.inPressure }} <span class="unit">kPa</span></div>
- </div>
- </div>
- <div class="card" @click="showDetail('pressure')">
- <div class="card-icon"><el-icon><Connection /></el-icon></div>
- <div class="card-info">
- <div class="card-label">出口压力</div>
- <div class="card-value">{{ selectedStation.outPressure }} <span class="unit">kPa</span></div>
- </div>
- </div>
- <div class="card" @click="showDetail('temperature')">
- <div class="card-icon"><el-icon></el-icon></div>
- <div class="card-info">
- <div class="card-label">环境温度</div>
- <div class="card-value">{{ selectedStation.temperature }} <span class="unit">°C</span></div>
- </div>
- </div>
- </div>
- <!-- 监控点位列表(多摄像头) -->
- <div class="camera-list" v-if="selectedStation.cameras?.length">
- <div class="camera-header">
- <span><el-icon><Camera /></el-icon> 监控点位 ({{ selectedStation.cameras.length }})</span>
- </div>
- <div class="camera-grid">
- <div
- v-for="camera in selectedStation.cameras"
- :key="camera.id"
- class="camera-item"
- :class="{ active: currentCamera?.id === camera.id }"
- @click="switchCamera(camera)"
- >
- <div class="camera-preview">
- <el-icon><VideoCamera /></el-icon>
- </div>
- <div class="camera-name">{{ camera.name }}</div>
- </div>
- </div>
- </div>
- </div>
- <!-- 未选中状态 -->
- <div v-else class="empty-video">
- <el-empty description="请从左侧选择沅陵县燃气场站查看监控视频" :image-size="120" />
- </div>
- </div>
- </div>
- <!-- 告警联动弹窗(监测数据异常时自动弹出) -->
- <el-drawer v-model="alertDialogVisible" title="监测告警联动" size="500px" class="detail-drawer" direction="rtl">
- <div class="alert-content">
- <el-alert
- :title="`${selectedStation?.name} 监测数据异常`"
- type="error"
- show-icon
- :closable="false"
- />
- <div class="alert-detail">
- <p>甲烷浓度: {{ selectedStation?.methane }} ppm (阈值: {{ selectedStation?.methaneThreshold }} ppm)</p>
- <p>报警时间: {{ alertTime }}</p>
- <p>建议措施: 立即联系场站值班人员确认现场情况,排查泄漏点</p>
- </div>
- <div class="alert-actions">
- <el-button type="primary" @click="viewAlertVideo">查看现场视频</el-button>
- <el-button @click="alertDialogVisible = false">关闭</el-button>
- </div>
- </div>
- </el-drawer>
- <!-- 全屏视频对话框 -->
- <el-drawer v-model="fullscreenVideoVisible" title="全屏监控" size="100%" :show-close="true" class="detail-drawer" direction="rtl">
- <video
- ref="fullscreenVideo"
- :src="selectedStation?.videoUrl"
- autoplay
- muted
- loop
- controls
- style="width: 100%; height: 100%; object-fit: contain;"
- v-if="selectedStation?.videoStatus === 'online'"
- ></video>
- <div v-else class="fullscreen-placeholder">
- <el-icon><VideoCameraFilled /></el-icon>
- <span>视频信号离线</span>
- </div>
- </el-drawer>
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
- import { VideoCamera, VideoCameraFilled, Search, Refresh, FullScreen, Download, WindPower, Connection, Camera } from '@element-plus/icons-vue'
- import { ElMessage } from 'element-plus'
- // 模拟视频流地址
- const mockVideoUrl = 'https://vjs.zencdn.net/v/oceans.mp4'
- // 沅陵县燃气场站数据
- const mockStations = ref([
- {
- id: 1, name: '沅陵天然气门站', address: '沅陵县太常片区辰州北路888号',
- methane: 28, methaneThreshold: 20, inPressure: 385, outPressure: 235, temperature: 26.5,
- alertStatus: true, videoStatus: 'online', videoUrl: mockVideoUrl,
- cameras: [
- { id: 1, name: '大门出入口', url: mockVideoUrl },
- { id: 2, name: '工艺装置区', url: mockVideoUrl },
- { id: 3, name: '调压计量区', url: mockVideoUrl },
- { id: 4, name: '消防通道', url: mockVideoUrl }
- ]
- },
- {
- id: 2, name: '太常片区调压站', address: '沅陵县太常片区迎宾西路200号',
- methane: 5, methaneThreshold: 20, inPressure: 350, outPressure: 210, temperature: 25.8,
- alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
- cameras: [
- { id: 1, name: '工艺区', url: mockVideoUrl },
- { id: 2, name: '控制室', url: mockVideoUrl }
- ]
- },
- {
- id: 3, name: '沅陵城南CNG储配站', address: '沅陵县太常片区建设南路168号',
- methane: 12, methaneThreshold: 20, inPressure: 420, outPressure: 280, temperature: 24.5,
- alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
- cameras: [
- { id: 1, name: '场站全景', url: mockVideoUrl },
- { id: 2, name: '储气罐区', url: mockVideoUrl },
- { id: 3, name: '卸气区', url: mockVideoUrl },
- { id: 4, name: '消防泵房', url: mockVideoUrl }
- ]
- },
- {
- id: 4, name: '沅陵工业园区调压站', address: '沅陵县太常片区工业大道500号',
- methane: 3, methaneThreshold: 20, inPressure: 310, outPressure: 185, temperature: 27.2,
- alertStatus: false, videoStatus: 'offline', videoUrl: '',
- cameras: []
- },
- {
- id: 5, name: '沅陵城北供气站', address: '沅陵县太常片区天宁北路88号',
- methane: 18, methaneThreshold: 20, inPressure: 340, outPressure: 205, temperature: 25.0,
- alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
- cameras: [
- { id: 1, name: '主入口', url: mockVideoUrl },
- { id: 2, name: '调压区', url: mockVideoUrl },
- { id: 3, name: '值班室', url: mockVideoUrl }
- ]
- },
- {
- id: 6, name: '沅陵城东门站', address: '沅陵县太常片区辰州东路666号',
- methane: 25, methaneThreshold: 20, inPressure: 370, outPressure: 230, temperature: 26.0,
- alertStatus: true, videoStatus: 'online', videoUrl: mockVideoUrl,
- cameras: [
- { id: 1, name: '大门出入口', url: mockVideoUrl },
- { id: 2, name: '过滤调压区', url: mockVideoUrl },
- { id: 3, name: '加臭装置区', url: mockVideoUrl },
- { id: 4, name: '周边环境', url: mockVideoUrl }
- ]
- },
- {
- id: 7, name: '凉水井镇调压站', address: '沅陵县凉水井镇集镇路18号',
- methane: 1, methaneThreshold: 20, inPressure: 290, outPressure: 175, temperature: 25.3,
- alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
- cameras: [
- { id: 1, name: '场站入口', url: mockVideoUrl },
- { id: 2, name: '工艺区', url: mockVideoUrl }
- ]
- },
- {
- id: 8, name: '筲箕湾镇供气站', address: '沅陵县筲箕湾镇车站路32号',
- methane: 6, methaneThreshold: 20, inPressure: 275, outPressure: 165, temperature: 25.7,
- alertStatus: false, videoStatus: 'offline', videoUrl: '',
- cameras: []
- }
- ])
- // 统计数据
- const onlineCount = computed(() => mockStations.value.filter(s => s.videoStatus === 'online').length)
- const offlineCount = computed(() => mockStations.value.filter(s => s.videoStatus === 'offline').length)
- const alertCount = computed(() => mockStations.value.filter(s => s.alertStatus).length)
- // 筛选
- const searchKeyword = ref('')
- const selectedStation = ref(mockStations.value[0])
- const currentCamera = ref(null)
- const alertDialogVisible = ref(false)
- const fullscreenVideoVisible = ref(false)
- const alertTime = ref('')
- const filteredStations = computed(() => {
- let list = mockStations.value
- if (searchKeyword.value) {
- const kw = searchKeyword.value.toLowerCase()
- list = list.filter(s => s.name.toLowerCase().includes(kw))
- }
- return list
- })
- // 视频播放器引用
- const videoPlayer = ref(null)
- const fullscreenVideo = ref(null)
- const videoContainer = ref(null)
- // 选择场站
- const selectStation = (station) => {
- selectedStation.value = station
- currentCamera.value = station.cameras?.[0] || null
- // 检查是否有告警,自动弹出联动弹窗
- if (station.alertStatus && station.methane > station.methaneThreshold) {
- alertTime.value = new Date().toLocaleString()
- alertDialogVisible.value = true
- }
- }
- // 切换摄像头
- const switchCamera = (camera) => {
- currentCamera.value = camera
- if (videoPlayer.value) {
- videoPlayer.value.src = camera.url
- videoPlayer.value.load()
- videoPlayer.value.play()
- }
- ElMessage.success(`切换到摄像头: ${camera.name}`)
- }
- // 刷新视频
- const refreshVideo = () => {
- if (videoPlayer.value) {
- videoPlayer.value.load()
- videoPlayer.value.play()
- ElMessage.success('视频已刷新')
- }
- }
- // 全屏视频
- const toggleVideoFullscreen = () => {
- fullscreenVideoVisible.value = true
- }
- // 页面全屏
- const toggleFullscreen = () => {
- const element = document.documentElement
- if (!document.fullscreenElement) {
- element.requestFullscreen()
- } else {
- document.exitFullscreen()
- }
- }
- // 抓拍截图
- const captureSnapshot = () => {
- ElMessage.success('截图已保存(演示功能)')
- }
- // 查看告警现场视频
- const viewAlertVideo = () => {
- alertDialogVisible.value = false
- toggleVideoFullscreen()
- }
- // 查看监测详情
- const showDetail = (type) => {
- ElMessage.info(`查看${type === 'methane' ? '甲烷浓度' : type === 'pressure' ? '压力' : '温度'}详细数据`)
- }
- // 刷新数据
- const refreshData = () => {
- ElMessage.success('数据已刷新')
- }
- // 监听告警状态变化自动弹窗
- watch(() => selectedStation.value?.alertStatus, (newVal) => {
- if (newVal && selectedStation.value?.methane > selectedStation.value?.methaneThreshold) {
- alertTime.value = new Date().toLocaleString()
- alertDialogVisible.value = true
- }
- })
- onMounted(() => {
- // 模拟自动播放策略处理
- nextTick(() => {
- if (videoPlayer.value) {
- videoPlayer.value.play().catch(e => console.log('自动播放被阻止,用户点击后播放'))
- }
- })
- })
- </script>
- <style scoped>
- .gas-video-monitor {
- background: #f0f2f6;
- height: 100vh;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .page-header {
- background: white;
- padding: 12px 24px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- border-bottom: 1px solid #e9ecef;
- flex-shrink: 0;
- }
- .header-left { display: flex; align-items: center; gap: 20px; }
- .header-left .title { font-size: 1.3rem; font-weight: 600; color: #1f2d3d; }
- .stats-badge { display: flex; gap: 20px; }
- .stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
- .stat-item.online { color: #67c23a; }
- .stat-item.offline { color: #909399; }
- .stat-item.alert { color: #f56c6c; }
- .stat-item .dot { width: 8px; height: 8px; border-radius: 50%; background: #67c23a; }
- .stat-item.online .dot { background: #67c23a; }
- .stat-item.offline .dot { background: #909399; }
- .stat-item.alert .dot { background: #f56c6c; }
- .header-right { display: flex; gap: 12px; }
- .main-layout {
- display: flex;
- flex: 1;
- overflow: hidden;
- padding: 12px;
- gap: 12px;
- }
- .station-list-panel {
- width: 320px;
- background: white;
- border-radius: 12px;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .list-header {
- padding: 16px;
- border-bottom: 1px solid #e9ecef;
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-weight: 600;
- }
- .total-num { font-size: 12px; color: #909399; font-weight: normal; }
- .station-list {
- flex: 1;
- overflow-y: auto;
- padding: 8px;
- }
- .station-item {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 12px;
- margin-bottom: 8px;
- border-radius: 12px;
- background: #fafbfc;
- cursor: pointer;
- transition: all 0.2s;
- border: 1px solid #e9ecef;
- }
- .station-item.active { background: #ecf5ff; border-color: #409eff; }
- .station-item.alert { border-left: 3px solid #f56c6c; }
- .station-item.online .station-avatar .el-icon { color: #409eff; }
- .station-avatar .el-icon { font-size: 32px; color: #909399; }
- .station-info { flex: 1; }
- .station-name { font-weight: 600; margin-bottom: 4px; }
- .station-address { font-size: 11px; color: #909399; }
- .video-panel {
- flex: 1;
- background: white;
- border-radius: 12px;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .video-header {
- padding: 12px 16px;
- border-bottom: 1px solid #e9ecef;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .video-title { display: flex; align-items: center; gap: 12px; }
- .video-title .station-name { font-size: 16px; font-weight: 600; }
- .video-actions { display: flex; gap: 8px; }
- .video-container {
- flex: 1;
- padding: 16px;
- overflow-y: auto;
- }
- .main-video {
- position: relative;
- background: #000;
- border-radius: 12px;
- overflow: hidden;
- aspect-ratio: 16 / 9;
- margin-bottom: 16px;
- }
- .video-player {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- .video-placeholder {
- width: 100%;
- height: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- background: #1a1a2e;
- color: #fff;
- gap: 16px;
- }
- .video-placeholder .el-icon { font-size: 64px; color: #909399; }
- .monitor-overlay {
- position: absolute;
- top: 16px;
- right: 16px;
- background: rgba(0,0,0,0.7);
- border-radius: 8px;
- padding: 8px 12px;
- color: white;
- font-size: 12px;
- }
- .overlay-item { margin: 4px 0; }
- .overlay-item .label { color: #aaa; margin-right: 8px; }
- .overlay-item.warning .value { color: #f56c6c; font-weight: bold; }
- .monitor-cards {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 12px;
- margin-bottom: 16px;
- }
- .card {
- background: #f8f9fc;
- border-radius: 12px;
- padding: 12px;
- display: flex;
- align-items: center;
- gap: 12px;
- cursor: pointer;
- transition: all 0.2s;
- }
- .card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
- .card.warning { background: #fff8f0; border: 1px solid #f56c6c; }
- .card-icon .el-icon { font-size: 28px; color: #409eff; }
- .card.warning .card-icon .el-icon { color: #f56c6c; }
- .card-info .card-label { font-size: 12px; color: #909399; }
- .card-info .card-value { font-size: 18px; font-weight: bold; color: #1f2d3d; }
- .card-info .unit { font-size: 11px; font-weight: normal; }
- .camera-list {
- background: #f8f9fc;
- border-radius: 12px;
- padding: 12px;
- }
- .camera-header { font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
- .camera-grid { display: flex; gap: 12px; flex-wrap: wrap; }
- .camera-item {
- width: 100px;
- text-align: center;
- cursor: pointer;
- padding: 8px;
- border-radius: 8px;
- transition: all 0.2s;
- }
- .camera-item.active { background: #ecf5ff; }
- .camera-preview {
- background: #e9ecef;
- border-radius: 8px;
- padding: 16px;
- margin-bottom: 4px;
- }
- .camera-preview .el-icon { font-size: 32px; color: #909399; }
- .camera-name { font-size: 11px; color: #606266; }
- .empty-video {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .alert-content { padding: 8px 0; }
- .alert-detail { margin: 16px 0; padding: 12px; background: #f5f7fa; border-radius: 8px; }
- .alert-actions { display: flex; gap: 12px; justify-content: flex-end; }
- .fullscreen-placeholder {
- width: 100%;
- height: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- background: #1a1a2e;
- color: #fff;
- }
- </style>
|