czspjk.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. <!--场站视频监控-->
  2. <template>
  3. <div class="gas-video-monitor">
  4. <!-- 页面头部 -->
  5. <div class="page-header">
  6. <div class="header-left">
  7. <el-icon><VideoCamera /></el-icon>
  8. <span class="title">沅陵县燃气场站视频监控中心</span>
  9. <div class="stats-badge">
  10. <span class="stat-item online"><i class="dot"></i>在线 {{ onlineCount }}</span>
  11. <span class="stat-item offline"><i class="dot"></i>离线 {{ offlineCount }}</span>
  12. <span class="stat-item alert"><i class="dot"></i>告警场站 {{ alertCount }}</span>
  13. </div>
  14. </div>
  15. <div class="header-right">
  16. <el-input
  17. v-model="searchKeyword"
  18. placeholder="搜索场站名称"
  19. clearable
  20. :prefix-icon="Search"
  21. style="width: 200px"
  22. />
  23. <el-button type="primary" plain :icon="Refresh" @click="refreshData">刷新</el-button>
  24. <el-button :icon="FullScreen" @click="toggleFullscreen">全屏</el-button>
  25. </div>
  26. </div>
  27. <!-- 主内容区:左侧场站列表 + 右侧视频监控墙 -->
  28. <div class="main-layout">
  29. <!-- 左侧场站列表 -->
  30. <div class="station-list-panel">
  31. <div class="list-header">
  32. <span>沅陵县燃气场站</span>
  33. <span class="total-num">共 {{ filteredStations.length }} 座场站</span>
  34. </div>
  35. <div class="station-list">
  36. <div
  37. v-for="station in filteredStations"
  38. :key="station.id"
  39. class="station-item"
  40. :class="{
  41. active: selectedStation?.id === station.id,
  42. alert: station.alertStatus,
  43. online: station.videoStatus === 'online'
  44. }"
  45. @click="selectStation(station)"
  46. >
  47. <div class="station-avatar">
  48. <el-icon v-if="station.videoStatus === 'online'"><VideoCamera /></el-icon>
  49. <el-icon v-else><VideoCameraFilled /></el-icon>
  50. </div>
  51. <div class="station-info">
  52. <div class="station-name">{{ station.name }}</div>
  53. <div class="station-address">{{ station.address }}</div>
  54. </div>
  55. <div class="station-badge">
  56. <el-tag v-if="station.alertStatus" type="danger" size="small">告警</el-tag>
  57. <el-tag v-else :type="station.videoStatus === 'online' ? 'success' : 'info'" size="small">
  58. {{ station.videoStatus === 'online' ? '在线' : '离线' }}
  59. </el-tag>
  60. </div>
  61. </div>
  62. </div>
  63. </div>
  64. <!-- 右侧视频监控区 -->
  65. <div class="video-panel">
  66. <div class="video-header" v-if="selectedStation">
  67. <div class="video-title">
  68. <span class="station-name">{{ selectedStation.name }}</span>
  69. <el-tag :type="selectedStation.videoStatus === 'online' ? 'success' : 'danger'" size="small">
  70. {{ selectedStation.videoStatus === 'online' ? '视频在线' : '视频离线' }}
  71. </el-tag>
  72. <el-tag v-if="selectedStation.alertStatus" type="danger" size="small" effect="dark">
  73. 监测告警中
  74. </el-tag>
  75. </div>
  76. <div class="video-actions">
  77. <el-button size="small" :icon="Refresh" @click="refreshVideo">刷新</el-button>
  78. <el-button size="small" :icon="FullScreen" @click="toggleVideoFullscreen">全屏</el-button>
  79. <el-button size="small" :icon="Download" @click="captureSnapshot">抓拍</el-button>
  80. </div>
  81. </div>
  82. <!-- 视频播放区域 -->
  83. <div class="video-container" v-if="selectedStation">
  84. <div class="main-video" ref="videoContainer">
  85. <video
  86. ref="videoPlayer"
  87. :src="selectedStation.videoUrl"
  88. autoplay
  89. muted
  90. loop
  91. controls
  92. class="video-player"
  93. v-if="selectedStation.videoStatus === 'online'"
  94. ></video>
  95. <div class="video-placeholder" v-else>
  96. <el-icon><VideoCameraFilled /></el-icon>
  97. <span>视频信号离线,请检查设备连接</span>
  98. </div>
  99. <!-- 监测数据叠加层 -->
  100. <div class="monitor-overlay" v-if="selectedStation">
  101. <div class="overlay-item methane" :class="{ warning: selectedStation.methane > selectedStation.methaneThreshold }">
  102. <span class="label">甲烷浓度</span>
  103. <span class="value">{{ selectedStation.methane }} ppm</span>
  104. </div>
  105. <div class="overlay-item pressure">
  106. <span class="label">进口压力</span>
  107. <span class="value">{{ selectedStation.inPressure }} kPa</span>
  108. </div>
  109. <div class="overlay-item pressure">
  110. <span class="label">出口压力</span>
  111. <span class="value">{{ selectedStation.outPressure }} kPa</span>
  112. </div>
  113. </div>
  114. </div>
  115. <!-- 监测数据卡片 -->
  116. <div class="monitor-cards">
  117. <div class="card" :class="{ warning: selectedStation.methane > selectedStation.methaneThreshold }" @click="showDetail('methane')">
  118. <div class="card-icon"><el-icon><WindPower /></el-icon></div>
  119. <div class="card-info">
  120. <div class="card-label">甲烷浓度</div>
  121. <div class="card-value">{{ selectedStation.methane }} <span class="unit">ppm</span></div>
  122. </div>
  123. </div>
  124. <div class="card" @click="showDetail('pressure')">
  125. <div class="card-icon"><el-icon><Connection /></el-icon></div>
  126. <div class="card-info">
  127. <div class="card-label">进口压力</div>
  128. <div class="card-value">{{ selectedStation.inPressure }} <span class="unit">kPa</span></div>
  129. </div>
  130. </div>
  131. <div class="card" @click="showDetail('pressure')">
  132. <div class="card-icon"><el-icon><Connection /></el-icon></div>
  133. <div class="card-info">
  134. <div class="card-label">出口压力</div>
  135. <div class="card-value">{{ selectedStation.outPressure }} <span class="unit">kPa</span></div>
  136. </div>
  137. </div>
  138. <div class="card" @click="showDetail('temperature')">
  139. <div class="card-icon"><el-icon></el-icon></div>
  140. <div class="card-info">
  141. <div class="card-label">环境温度</div>
  142. <div class="card-value">{{ selectedStation.temperature }} <span class="unit">°C</span></div>
  143. </div>
  144. </div>
  145. </div>
  146. <!-- 监控点位列表(多摄像头) -->
  147. <div class="camera-list" v-if="selectedStation.cameras?.length">
  148. <div class="camera-header">
  149. <span><el-icon><Camera /></el-icon> 监控点位 ({{ selectedStation.cameras.length }})</span>
  150. </div>
  151. <div class="camera-grid">
  152. <div
  153. v-for="camera in selectedStation.cameras"
  154. :key="camera.id"
  155. class="camera-item"
  156. :class="{ active: currentCamera?.id === camera.id }"
  157. @click="switchCamera(camera)"
  158. >
  159. <div class="camera-preview">
  160. <el-icon><VideoCamera /></el-icon>
  161. </div>
  162. <div class="camera-name">{{ camera.name }}</div>
  163. </div>
  164. </div>
  165. </div>
  166. </div>
  167. <!-- 未选中状态 -->
  168. <div v-else class="empty-video">
  169. <el-empty description="请从左侧选择沅陵县燃气场站查看监控视频" :image-size="120" />
  170. </div>
  171. </div>
  172. </div>
  173. <!-- 告警联动弹窗(监测数据异常时自动弹出) -->
  174. <el-drawer v-model="alertDialogVisible" title="监测告警联动" size="500px" class="detail-drawer" direction="rtl">
  175. <div class="alert-content">
  176. <el-alert
  177. :title="`${selectedStation?.name} 监测数据异常`"
  178. type="error"
  179. show-icon
  180. :closable="false"
  181. />
  182. <div class="alert-detail">
  183. <p>甲烷浓度: {{ selectedStation?.methane }} ppm (阈值: {{ selectedStation?.methaneThreshold }} ppm)</p>
  184. <p>报警时间: {{ alertTime }}</p>
  185. <p>建议措施: 立即联系场站值班人员确认现场情况,排查泄漏点</p>
  186. </div>
  187. <div class="alert-actions">
  188. <el-button type="primary" @click="viewAlertVideo">查看现场视频</el-button>
  189. <el-button @click="alertDialogVisible = false">关闭</el-button>
  190. </div>
  191. </div>
  192. </el-drawer>
  193. <!-- 全屏视频对话框 -->
  194. <el-drawer v-model="fullscreenVideoVisible" title="全屏监控" size="100%" :show-close="true" class="detail-drawer" direction="rtl">
  195. <video
  196. ref="fullscreenVideo"
  197. :src="selectedStation?.videoUrl"
  198. autoplay
  199. muted
  200. loop
  201. controls
  202. style="width: 100%; height: 100%; object-fit: contain;"
  203. v-if="selectedStation?.videoStatus === 'online'"
  204. ></video>
  205. <div v-else class="fullscreen-placeholder">
  206. <el-icon><VideoCameraFilled /></el-icon>
  207. <span>视频信号离线</span>
  208. </div>
  209. </el-drawer>
  210. </div>
  211. </template>
  212. <script setup>
  213. import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
  214. import { VideoCamera, VideoCameraFilled, Search, Refresh, FullScreen, Download, WindPower, Connection, Camera } from '@element-plus/icons-vue'
  215. import { ElMessage } from 'element-plus'
  216. // 模拟视频流地址
  217. const mockVideoUrl = 'https://vjs.zencdn.net/v/oceans.mp4'
  218. // 沅陵县燃气场站数据
  219. const mockStations = ref([
  220. {
  221. id: 1, name: '沅陵天然气门站', address: '沅陵县太常片区辰州北路888号',
  222. methane: 28, methaneThreshold: 20, inPressure: 385, outPressure: 235, temperature: 26.5,
  223. alertStatus: true, videoStatus: 'online', videoUrl: mockVideoUrl,
  224. cameras: [
  225. { id: 1, name: '大门出入口', url: mockVideoUrl },
  226. { id: 2, name: '工艺装置区', url: mockVideoUrl },
  227. { id: 3, name: '调压计量区', url: mockVideoUrl },
  228. { id: 4, name: '消防通道', url: mockVideoUrl }
  229. ]
  230. },
  231. {
  232. id: 2, name: '太常片区调压站', address: '沅陵县太常片区迎宾西路200号',
  233. methane: 5, methaneThreshold: 20, inPressure: 350, outPressure: 210, temperature: 25.8,
  234. alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
  235. cameras: [
  236. { id: 1, name: '工艺区', url: mockVideoUrl },
  237. { id: 2, name: '控制室', url: mockVideoUrl }
  238. ]
  239. },
  240. {
  241. id: 3, name: '沅陵城南CNG储配站', address: '沅陵县太常片区建设南路168号',
  242. methane: 12, methaneThreshold: 20, inPressure: 420, outPressure: 280, temperature: 24.5,
  243. alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
  244. cameras: [
  245. { id: 1, name: '场站全景', url: mockVideoUrl },
  246. { id: 2, name: '储气罐区', url: mockVideoUrl },
  247. { id: 3, name: '卸气区', url: mockVideoUrl },
  248. { id: 4, name: '消防泵房', url: mockVideoUrl }
  249. ]
  250. },
  251. {
  252. id: 4, name: '沅陵工业园区调压站', address: '沅陵县太常片区工业大道500号',
  253. methane: 3, methaneThreshold: 20, inPressure: 310, outPressure: 185, temperature: 27.2,
  254. alertStatus: false, videoStatus: 'offline', videoUrl: '',
  255. cameras: []
  256. },
  257. {
  258. id: 5, name: '沅陵城北供气站', address: '沅陵县太常片区天宁北路88号',
  259. methane: 18, methaneThreshold: 20, inPressure: 340, outPressure: 205, temperature: 25.0,
  260. alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
  261. cameras: [
  262. { id: 1, name: '主入口', url: mockVideoUrl },
  263. { id: 2, name: '调压区', url: mockVideoUrl },
  264. { id: 3, name: '值班室', url: mockVideoUrl }
  265. ]
  266. },
  267. {
  268. id: 6, name: '沅陵城东门站', address: '沅陵县太常片区辰州东路666号',
  269. methane: 25, methaneThreshold: 20, inPressure: 370, outPressure: 230, temperature: 26.0,
  270. alertStatus: true, videoStatus: 'online', videoUrl: mockVideoUrl,
  271. cameras: [
  272. { id: 1, name: '大门出入口', url: mockVideoUrl },
  273. { id: 2, name: '过滤调压区', url: mockVideoUrl },
  274. { id: 3, name: '加臭装置区', url: mockVideoUrl },
  275. { id: 4, name: '周边环境', url: mockVideoUrl }
  276. ]
  277. },
  278. {
  279. id: 7, name: '凉水井镇调压站', address: '沅陵县凉水井镇集镇路18号',
  280. methane: 1, methaneThreshold: 20, inPressure: 290, outPressure: 175, temperature: 25.3,
  281. alertStatus: false, videoStatus: 'online', videoUrl: mockVideoUrl,
  282. cameras: [
  283. { id: 1, name: '场站入口', url: mockVideoUrl },
  284. { id: 2, name: '工艺区', url: mockVideoUrl }
  285. ]
  286. },
  287. {
  288. id: 8, name: '筲箕湾镇供气站', address: '沅陵县筲箕湾镇车站路32号',
  289. methane: 6, methaneThreshold: 20, inPressure: 275, outPressure: 165, temperature: 25.7,
  290. alertStatus: false, videoStatus: 'offline', videoUrl: '',
  291. cameras: []
  292. }
  293. ])
  294. // 统计数据
  295. const onlineCount = computed(() => mockStations.value.filter(s => s.videoStatus === 'online').length)
  296. const offlineCount = computed(() => mockStations.value.filter(s => s.videoStatus === 'offline').length)
  297. const alertCount = computed(() => mockStations.value.filter(s => s.alertStatus).length)
  298. // 筛选
  299. const searchKeyword = ref('')
  300. const selectedStation = ref(mockStations.value[0])
  301. const currentCamera = ref(null)
  302. const alertDialogVisible = ref(false)
  303. const fullscreenVideoVisible = ref(false)
  304. const alertTime = ref('')
  305. const filteredStations = computed(() => {
  306. let list = mockStations.value
  307. if (searchKeyword.value) {
  308. const kw = searchKeyword.value.toLowerCase()
  309. list = list.filter(s => s.name.toLowerCase().includes(kw))
  310. }
  311. return list
  312. })
  313. // 视频播放器引用
  314. const videoPlayer = ref(null)
  315. const fullscreenVideo = ref(null)
  316. const videoContainer = ref(null)
  317. // 选择场站
  318. const selectStation = (station) => {
  319. selectedStation.value = station
  320. currentCamera.value = station.cameras?.[0] || null
  321. // 检查是否有告警,自动弹出联动弹窗
  322. if (station.alertStatus && station.methane > station.methaneThreshold) {
  323. alertTime.value = new Date().toLocaleString()
  324. alertDialogVisible.value = true
  325. }
  326. }
  327. // 切换摄像头
  328. const switchCamera = (camera) => {
  329. currentCamera.value = camera
  330. if (videoPlayer.value) {
  331. videoPlayer.value.src = camera.url
  332. videoPlayer.value.load()
  333. videoPlayer.value.play()
  334. }
  335. ElMessage.success(`切换到摄像头: ${camera.name}`)
  336. }
  337. // 刷新视频
  338. const refreshVideo = () => {
  339. if (videoPlayer.value) {
  340. videoPlayer.value.load()
  341. videoPlayer.value.play()
  342. ElMessage.success('视频已刷新')
  343. }
  344. }
  345. // 全屏视频
  346. const toggleVideoFullscreen = () => {
  347. fullscreenVideoVisible.value = true
  348. }
  349. // 页面全屏
  350. const toggleFullscreen = () => {
  351. const element = document.documentElement
  352. if (!document.fullscreenElement) {
  353. element.requestFullscreen()
  354. } else {
  355. document.exitFullscreen()
  356. }
  357. }
  358. // 抓拍截图
  359. const captureSnapshot = () => {
  360. ElMessage.success('截图已保存(演示功能)')
  361. }
  362. // 查看告警现场视频
  363. const viewAlertVideo = () => {
  364. alertDialogVisible.value = false
  365. toggleVideoFullscreen()
  366. }
  367. // 查看监测详情
  368. const showDetail = (type) => {
  369. ElMessage.info(`查看${type === 'methane' ? '甲烷浓度' : type === 'pressure' ? '压力' : '温度'}详细数据`)
  370. }
  371. // 刷新数据
  372. const refreshData = () => {
  373. ElMessage.success('数据已刷新')
  374. }
  375. // 监听告警状态变化自动弹窗
  376. watch(() => selectedStation.value?.alertStatus, (newVal) => {
  377. if (newVal && selectedStation.value?.methane > selectedStation.value?.methaneThreshold) {
  378. alertTime.value = new Date().toLocaleString()
  379. alertDialogVisible.value = true
  380. }
  381. })
  382. onMounted(() => {
  383. // 模拟自动播放策略处理
  384. nextTick(() => {
  385. if (videoPlayer.value) {
  386. videoPlayer.value.play().catch(e => console.log('自动播放被阻止,用户点击后播放'))
  387. }
  388. })
  389. })
  390. </script>
  391. <style scoped>
  392. .gas-video-monitor {
  393. background: #f0f2f6;
  394. height: 100vh;
  395. display: flex;
  396. flex-direction: column;
  397. overflow: hidden;
  398. }
  399. .page-header {
  400. background: white;
  401. padding: 12px 24px;
  402. display: flex;
  403. justify-content: space-between;
  404. align-items: center;
  405. border-bottom: 1px solid #e9ecef;
  406. flex-shrink: 0;
  407. }
  408. .header-left { display: flex; align-items: center; gap: 20px; }
  409. .header-left .title { font-size: 1.3rem; font-weight: 600; color: #1f2d3d; }
  410. .stats-badge { display: flex; gap: 20px; }
  411. .stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
  412. .stat-item.online { color: #67c23a; }
  413. .stat-item.offline { color: #909399; }
  414. .stat-item.alert { color: #f56c6c; }
  415. .stat-item .dot { width: 8px; height: 8px; border-radius: 50%; background: #67c23a; }
  416. .stat-item.online .dot { background: #67c23a; }
  417. .stat-item.offline .dot { background: #909399; }
  418. .stat-item.alert .dot { background: #f56c6c; }
  419. .header-right { display: flex; gap: 12px; }
  420. .main-layout {
  421. display: flex;
  422. flex: 1;
  423. overflow: hidden;
  424. padding: 12px;
  425. gap: 12px;
  426. }
  427. .station-list-panel {
  428. width: 320px;
  429. background: white;
  430. border-radius: 12px;
  431. display: flex;
  432. flex-direction: column;
  433. overflow: hidden;
  434. }
  435. .list-header {
  436. padding: 16px;
  437. border-bottom: 1px solid #e9ecef;
  438. display: flex;
  439. justify-content: space-between;
  440. align-items: center;
  441. font-weight: 600;
  442. }
  443. .total-num { font-size: 12px; color: #909399; font-weight: normal; }
  444. .station-list {
  445. flex: 1;
  446. overflow-y: auto;
  447. padding: 8px;
  448. }
  449. .station-item {
  450. display: flex;
  451. align-items: center;
  452. gap: 12px;
  453. padding: 12px;
  454. margin-bottom: 8px;
  455. border-radius: 12px;
  456. background: #fafbfc;
  457. cursor: pointer;
  458. transition: all 0.2s;
  459. border: 1px solid #e9ecef;
  460. }
  461. .station-item.active { background: #ecf5ff; border-color: #409eff; }
  462. .station-item.alert { border-left: 3px solid #f56c6c; }
  463. .station-item.online .station-avatar .el-icon { color: #409eff; }
  464. .station-avatar .el-icon { font-size: 32px; color: #909399; }
  465. .station-info { flex: 1; }
  466. .station-name { font-weight: 600; margin-bottom: 4px; }
  467. .station-address { font-size: 11px; color: #909399; }
  468. .video-panel {
  469. flex: 1;
  470. background: white;
  471. border-radius: 12px;
  472. display: flex;
  473. flex-direction: column;
  474. overflow: hidden;
  475. }
  476. .video-header {
  477. padding: 12px 16px;
  478. border-bottom: 1px solid #e9ecef;
  479. display: flex;
  480. justify-content: space-between;
  481. align-items: center;
  482. }
  483. .video-title { display: flex; align-items: center; gap: 12px; }
  484. .video-title .station-name { font-size: 16px; font-weight: 600; }
  485. .video-actions { display: flex; gap: 8px; }
  486. .video-container {
  487. flex: 1;
  488. padding: 16px;
  489. overflow-y: auto;
  490. }
  491. .main-video {
  492. position: relative;
  493. background: #000;
  494. border-radius: 12px;
  495. overflow: hidden;
  496. aspect-ratio: 16 / 9;
  497. margin-bottom: 16px;
  498. }
  499. .video-player {
  500. width: 100%;
  501. height: 100%;
  502. object-fit: cover;
  503. }
  504. .video-placeholder {
  505. width: 100%;
  506. height: 100%;
  507. display: flex;
  508. flex-direction: column;
  509. align-items: center;
  510. justify-content: center;
  511. background: #1a1a2e;
  512. color: #fff;
  513. gap: 16px;
  514. }
  515. .video-placeholder .el-icon { font-size: 64px; color: #909399; }
  516. .monitor-overlay {
  517. position: absolute;
  518. top: 16px;
  519. right: 16px;
  520. background: rgba(0,0,0,0.7);
  521. border-radius: 8px;
  522. padding: 8px 12px;
  523. color: white;
  524. font-size: 12px;
  525. }
  526. .overlay-item { margin: 4px 0; }
  527. .overlay-item .label { color: #aaa; margin-right: 8px; }
  528. .overlay-item.warning .value { color: #f56c6c; font-weight: bold; }
  529. .monitor-cards {
  530. display: grid;
  531. grid-template-columns: repeat(4, 1fr);
  532. gap: 12px;
  533. margin-bottom: 16px;
  534. }
  535. .card {
  536. background: #f8f9fc;
  537. border-radius: 12px;
  538. padding: 12px;
  539. display: flex;
  540. align-items: center;
  541. gap: 12px;
  542. cursor: pointer;
  543. transition: all 0.2s;
  544. }
  545. .card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
  546. .card.warning { background: #fff8f0; border: 1px solid #f56c6c; }
  547. .card-icon .el-icon { font-size: 28px; color: #409eff; }
  548. .card.warning .card-icon .el-icon { color: #f56c6c; }
  549. .card-info .card-label { font-size: 12px; color: #909399; }
  550. .card-info .card-value { font-size: 18px; font-weight: bold; color: #1f2d3d; }
  551. .card-info .unit { font-size: 11px; font-weight: normal; }
  552. .camera-list {
  553. background: #f8f9fc;
  554. border-radius: 12px;
  555. padding: 12px;
  556. }
  557. .camera-header { font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
  558. .camera-grid { display: flex; gap: 12px; flex-wrap: wrap; }
  559. .camera-item {
  560. width: 100px;
  561. text-align: center;
  562. cursor: pointer;
  563. padding: 8px;
  564. border-radius: 8px;
  565. transition: all 0.2s;
  566. }
  567. .camera-item.active { background: #ecf5ff; }
  568. .camera-preview {
  569. background: #e9ecef;
  570. border-radius: 8px;
  571. padding: 16px;
  572. margin-bottom: 4px;
  573. }
  574. .camera-preview .el-icon { font-size: 32px; color: #909399; }
  575. .camera-name { font-size: 11px; color: #606266; }
  576. .empty-video {
  577. flex: 1;
  578. display: flex;
  579. align-items: center;
  580. justify-content: center;
  581. }
  582. .alert-content { padding: 8px 0; }
  583. .alert-detail { margin: 16px 0; padding: 12px; background: #f5f7fa; border-radius: 8px; }
  584. .alert-actions { display: flex; gap: 12px; justify-content: flex-end; }
  585. .fullscreen-placeholder {
  586. width: 100%;
  587. height: 100%;
  588. display: flex;
  589. flex-direction: column;
  590. align-items: center;
  591. justify-content: center;
  592. background: #1a1a2e;
  593. color: #fff;
  594. }
  595. </style>