VideoMonitorPanel.vue 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. <template>
  2. <Teleport to="body">
  3. <div v-if="state.open" class="video-monitor-backdrop">
  4. <section class="video-monitor" role="dialog" aria-modal="true" aria-labelledby="video-monitor-title" data-video-monitor>
  5. <header>
  6. <div><h2 id="video-monitor-title">{{ state.device?.deviceName || '视频监控设备' }}</h2><p>{{ state.device?.deviceCode }}</p></div>
  7. <button type="button" aria-label="关闭监控" @click="video.close">关闭</button>
  8. </header>
  9. <div class="video-monitor__tabs">
  10. <button type="button" :aria-pressed="mode === 'live'" @click="switchMode('live')">实时视频</button>
  11. <button type="button" :aria-pressed="mode === 'history'" :disabled="!state.device?.capabilities?.records"
  12. :title="state.device?.capabilities?.records ? '' : '此设备未确认录像能力'" @click="switchMode('history')">历史录像</button>
  13. </div>
  14. <template v-if="mode === 'history'">
  15. <div class="video-monitor__range">
  16. <label>开始 <input v-model="range.startTime" type="text" placeholder="yyyy-MM-dd HH:mm:ss" /></label>
  17. <label>结束 <input v-model="range.endTime" type="text" placeholder="yyyy-MM-dd HH:mm:ss" /></label>
  18. <button type="button" :disabled="state.historyPhase === 'querying'" @click="video.queryHistory(range.startTime, range.endTime)">查询录像</button>
  19. </div>
  20. <p v-if="state.historyPhase === 'querying'">正在查询录像…</p>
  21. <p v-else-if="state.historyError" role="alert">{{ state.historyError }}</p>
  22. <p v-else-if="state.historyPhase === 'listed' && state.empty">该时间段没有录像(查询范围不代表留存期限)</p>
  23. <ul v-else-if="state.records?.length" class="video-monitor__records">
  24. <li v-for="(record, index) in state.records" :key="index">
  25. <span>{{ record.name || '录像' }} {{ record.startTime }} ~ {{ record.endTime }}</span>
  26. <button type="button" @click="playRecord(record)">播放</button>
  27. </li>
  28. </ul>
  29. </template>
  30. <p class="video-monitor__status" role="status">{{ labels[state.phase] || '未播放' }}</p>
  31. <p v-if="retryNote" class="video-monitor__status" role="status">{{ retryNote }}</p>
  32. <div class="video-monitor__body">
  33. <aside><h3>云台控制</h3><button type="button" disabled title="停止保障尚未验证,不提供物理控制">方向/变倍</button><p>能力尚未开放</p></aside>
  34. <div class="video-monitor__screen">
  35. <VideoPlayer v-if="state.media" :media="state.media" :options="playerOptions" @ready="video.ready" @error="video.fail" />
  36. <video v-else-if="state.playbackMedia" :src="state.playbackMedia.url" controls autoplay muted playsinline
  37. controlslist="nodownload noremoteplayback noplaybackrate" disablepictureinpicture disableremoteplayback
  38. aria-label="历史录像画面" @contextmenu.prevent />
  39. <p v-else-if="state.phase === 'retrying'">等待自动重连…</p>
  40. <p v-else>{{ state.error || '等待视频连接' }}</p>
  41. </div>
  42. </div>
  43. <p v-if="state.error && state.media" role="alert">{{ state.error }}</p>
  44. <p v-if="state.cleanupError" class="video-monitor__warning" role="alert">{{ state.cleanupError }}</p>
  45. <footer>
  46. <button v-if="!state.media && !['starting','connecting','retrying'].includes(state.phase)" type="button" :disabled="Boolean(state.cleanupError)" @click="video.retry">重新播放</button>
  47. <button v-if="state.cleanupError" type="button" @click="video.retryCleanup">重试清理</button>
  48. <span>默认静音 · 不提供截图、下载或录制</span>
  49. </footer>
  50. </section>
  51. </div>
  52. </Teleport>
  53. </template>
  54. <script setup>
  55. import { watch, onMounted, onBeforeUnmount, nextTick, computed, ref, reactive } from 'vue'
  56. import { storeToRefs } from 'pinia'
  57. import { useVideoStore } from '../stores/videoStore.js'
  58. import { useUiStore } from '../stores/uiStore.js'
  59. import { useDetailStore } from '../stores/detailStore.js'
  60. import { useEnhancementDevicesStore } from '../stores/enhancementDevicesStore.js'
  61. import VideoPlayer from './VideoPlayer.vue'
  62. const video = useVideoStore(), ui = useUiStore(), detail = useDetailStore(), enhancement = useEnhancementDevicesStore()
  63. const { state, playerOptions } = storeToRefs(video)
  64. const labels = { idle: '未播放', starting: '正在建立视频会话', connecting: '等待视频画面', playing: '正在播放', retrying: '连接中断,正在自动重连', failed: '播放失败', querying: '正在查询录像', listed: '已返回录像列表' }
  65. // Switching tabs ends the current stream but must keep the monitor open.
  66. const mode = ref('live')
  67. const range = reactive(video.historyWindow())
  68. const switchMode = async (next) => {
  69. if (next === mode.value) return
  70. if (next === 'history') {
  71. await video.stopLive()
  72. mode.value = 'history'
  73. await video.queryHistory(range.startTime, range.endTime)
  74. } else {
  75. await video.stopPlayback()
  76. mode.value = 'live'
  77. await video.startLive()
  78. }
  79. }
  80. const playRecord = (record) => video.play(record.startTime, record.endTime)
  81. const retryNote = computed(() => state.value.phase !== 'retrying' || !state.value.retryAttempt
  82. ? ''
  83. : `第 ${state.value.retryAttempt} 次自动重试,等待 ${Math.round((state.value.retryDelayMs || 0) / 1000)} 秒(剩余 ${state.value.retriesLeft ?? 0} 次)`)
  84. watch(() => ui.mode, () => { void video.close() }, { flush: 'sync' })
  85. watch(() => [state.value.ownerSource, detail.selection.deviceCode, enhancement.selected?.code], ([ownerSource, detailCode, enhancementCode]) => {
  86. const currentCode = ownerSource === 'enhancement' ? enhancementCode : detailCode
  87. if (state.value.open && currentCode !== state.value.device?.deviceCode) void video.close()
  88. }, { flush: 'sync' })
  89. let trigger
  90. watch(() => state.value.open, async (open) => {
  91. if (!open) { if (trigger?.isConnected) trigger.focus?.(); return }
  92. trigger = document.activeElement
  93. await nextTick()
  94. if (state.value.open) document.querySelector('[data-video-monitor] [aria-label="关闭监控"]')?.focus()
  95. })
  96. const keyboard = (event) => {
  97. if (!state.value.open) return
  98. if (event.key === 'Escape') { event.preventDefault(); void video.close(); return }
  99. if (event.key !== 'Tab') return
  100. const items = Array.from(document.querySelectorAll('[data-video-monitor] button:not(:disabled), [data-video-monitor] video'))
  101. if (!items.length) return
  102. const index = items.indexOf(document.activeElement)
  103. if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === items.length - 1)) {
  104. event.preventDefault(); items[event.shiftKey ? items.length - 1 : 0].focus?.()
  105. }
  106. }
  107. const hidden = () => { if (document.hidden) void video.close() }
  108. const leave = () => { void video.close() }
  109. onMounted(() => { document.addEventListener('visibilitychange', hidden); document.addEventListener('keydown', keyboard); window.addEventListener('pagehide', leave) })
  110. onBeforeUnmount(() => { document.removeEventListener('visibilitychange', hidden); document.removeEventListener('keydown', keyboard); window.removeEventListener('pagehide', leave); void video.close() })
  111. </script>
  112. <style scoped>
  113. .video-monitor-backdrop { position: fixed; inset: 0; z-index: 15000; display: grid; place-items: center; padding: 24px; background: #0009; }
  114. .video-monitor { width: min(76vw, 1400px); max-height: 92vh; overflow: auto; color: #e0f6ff; background: #092635f5; border: 1px solid #3a91a5; border-radius: 8px; padding: 20px; box-shadow: 0 20px 90px #0009; font-family: sans-serif; }
  115. .video-monitor header { display: flex; justify-content: space-between; gap: 20px; align-items: start; }
  116. .video-monitor h2, .video-monitor h3 { margin: 0 0 8px; }
  117. .video-monitor header p { margin: 0; color: #9dbbc8; overflow-wrap: anywhere; }
  118. .video-monitor button { padding: 7px 18px; color: #d8f8ff; background: #14556b; border: 1px solid #448ba0; border-radius: 4px; cursor: pointer; }
  119. .video-monitor button:disabled { color: #8399a3; background: #263c46; border-color: #49616b; cursor: not-allowed; }
  120. .video-monitor__tabs { display: flex; gap: 20px; align-items: center; margin-top: 18px; }
  121. .video-monitor__status { color: #a3d5e3; }
  122. .video-monitor__body { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 18px; }
  123. .video-monitor aside { color: #a7c3d0; background: #09212c; padding: 15px; }
  124. .video-monitor aside p, .video-monitor footer { font-size: 13px; color: #9cb9c5; }
  125. .video-monitor__screen { min-height: 300px; aspect-ratio: 16 / 9; display: grid; place-items: center; background: #02090e; }
  126. .video-monitor__screen > :deep(*) { width: 100%; height: 100%; }
  127. .video-monitor__screen > p { display: grid; place-items: center; margin: 0; }
  128. .video-monitor__warning { color: #ffc478; }
  129. .video-monitor footer { display: flex; gap: 16px; align-items: center; margin-top: 15px; }
  130. @media (max-width: 720px) { .video-monitor { width: 100%; } .video-monitor__body { grid-template-columns: 1fr; } .video-monitor aside { display: none; } }
  131. </style>