| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131 |
- <template>
- <Teleport to="body">
- <div v-if="state.open" class="video-monitor-backdrop">
- <section class="video-monitor" role="dialog" aria-modal="true" aria-labelledby="video-monitor-title" data-video-monitor>
- <header>
- <div><h2 id="video-monitor-title">{{ state.device?.deviceName || '视频监控设备' }}</h2><p>{{ state.device?.deviceCode }}</p></div>
- <button type="button" aria-label="关闭监控" @click="video.close">关闭</button>
- </header>
- <div class="video-monitor__tabs">
- <button type="button" :aria-pressed="mode === 'live'" @click="switchMode('live')">实时视频</button>
- <button type="button" :aria-pressed="mode === 'history'" :disabled="!state.device?.capabilities?.records"
- :title="state.device?.capabilities?.records ? '' : '此设备未确认录像能力'" @click="switchMode('history')">历史录像</button>
- </div>
- <template v-if="mode === 'history'">
- <div class="video-monitor__range">
- <label>开始 <input v-model="range.startTime" type="text" placeholder="yyyy-MM-dd HH:mm:ss" /></label>
- <label>结束 <input v-model="range.endTime" type="text" placeholder="yyyy-MM-dd HH:mm:ss" /></label>
- <button type="button" :disabled="state.historyPhase === 'querying'" @click="video.queryHistory(range.startTime, range.endTime)">查询录像</button>
- </div>
- <p v-if="state.historyPhase === 'querying'">正在查询录像…</p>
- <p v-else-if="state.historyError" role="alert">{{ state.historyError }}</p>
- <p v-else-if="state.historyPhase === 'listed' && state.empty">该时间段没有录像(查询范围不代表留存期限)</p>
- <ul v-else-if="state.records?.length" class="video-monitor__records">
- <li v-for="(record, index) in state.records" :key="index">
- <span>{{ record.name || '录像' }} {{ record.startTime }} ~ {{ record.endTime }}</span>
- <button type="button" @click="playRecord(record)">播放</button>
- </li>
- </ul>
- </template>
- <p class="video-monitor__status" role="status">{{ labels[state.phase] || '未播放' }}</p>
- <p v-if="retryNote" class="video-monitor__status" role="status">{{ retryNote }}</p>
- <div class="video-monitor__body">
- <aside><h3>云台控制</h3><button type="button" disabled title="停止保障尚未验证,不提供物理控制">方向/变倍</button><p>能力尚未开放</p></aside>
- <div class="video-monitor__screen">
- <VideoPlayer v-if="state.media" :media="state.media" :options="playerOptions" @ready="video.ready" @error="video.fail" />
- <video v-else-if="state.playbackMedia" :src="state.playbackMedia.url" controls autoplay muted playsinline
- controlslist="nodownload noremoteplayback noplaybackrate" disablepictureinpicture disableremoteplayback
- aria-label="历史录像画面" @contextmenu.prevent />
- <p v-else-if="state.phase === 'retrying'">等待自动重连…</p>
- <p v-else>{{ state.error || '等待视频连接' }}</p>
- </div>
- </div>
- <p v-if="state.error && state.media" role="alert">{{ state.error }}</p>
- <p v-if="state.cleanupError" class="video-monitor__warning" role="alert">{{ state.cleanupError }}</p>
- <footer>
- <button v-if="!state.media && !['starting','connecting','retrying'].includes(state.phase)" type="button" :disabled="Boolean(state.cleanupError)" @click="video.retry">重新播放</button>
- <button v-if="state.cleanupError" type="button" @click="video.retryCleanup">重试清理</button>
- <span>默认静音 · 不提供截图、下载或录制</span>
- </footer>
- </section>
- </div>
- </Teleport>
- </template>
- <script setup>
- import { watch, onMounted, onBeforeUnmount, nextTick, computed, ref, reactive } from 'vue'
- import { storeToRefs } from 'pinia'
- import { useVideoStore } from '../stores/videoStore.js'
- import { useUiStore } from '../stores/uiStore.js'
- import { useDetailStore } from '../stores/detailStore.js'
- import { useEnhancementDevicesStore } from '../stores/enhancementDevicesStore.js'
- import VideoPlayer from './VideoPlayer.vue'
- const video = useVideoStore(), ui = useUiStore(), detail = useDetailStore(), enhancement = useEnhancementDevicesStore()
- const { state, playerOptions } = storeToRefs(video)
- const labels = { idle: '未播放', starting: '正在建立视频会话', connecting: '等待视频画面', playing: '正在播放', retrying: '连接中断,正在自动重连', failed: '播放失败', querying: '正在查询录像', listed: '已返回录像列表' }
- // Switching tabs ends the current stream but must keep the monitor open.
- const mode = ref('live')
- const range = reactive(video.historyWindow())
- const switchMode = async (next) => {
- if (next === mode.value) return
- if (next === 'history') {
- await video.stopLive()
- mode.value = 'history'
- await video.queryHistory(range.startTime, range.endTime)
- } else {
- await video.stopPlayback()
- mode.value = 'live'
- await video.startLive()
- }
- }
- const playRecord = (record) => video.play(record.startTime, record.endTime)
- const retryNote = computed(() => state.value.phase !== 'retrying' || !state.value.retryAttempt
- ? ''
- : `第 ${state.value.retryAttempt} 次自动重试,等待 ${Math.round((state.value.retryDelayMs || 0) / 1000)} 秒(剩余 ${state.value.retriesLeft ?? 0} 次)`)
- watch(() => ui.mode, () => { void video.close() }, { flush: 'sync' })
- watch(() => [state.value.ownerSource, detail.selection.deviceCode, enhancement.selected?.code], ([ownerSource, detailCode, enhancementCode]) => {
- const currentCode = ownerSource === 'enhancement' ? enhancementCode : detailCode
- if (state.value.open && currentCode !== state.value.device?.deviceCode) void video.close()
- }, { flush: 'sync' })
- let trigger
- watch(() => state.value.open, async (open) => {
- if (!open) { if (trigger?.isConnected) trigger.focus?.(); return }
- trigger = document.activeElement
- await nextTick()
- if (state.value.open) document.querySelector('[data-video-monitor] [aria-label="关闭监控"]')?.focus()
- })
- const keyboard = (event) => {
- if (!state.value.open) return
- if (event.key === 'Escape') { event.preventDefault(); void video.close(); return }
- if (event.key !== 'Tab') return
- const items = Array.from(document.querySelectorAll('[data-video-monitor] button:not(:disabled), [data-video-monitor] video'))
- if (!items.length) return
- const index = items.indexOf(document.activeElement)
- if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === items.length - 1)) {
- event.preventDefault(); items[event.shiftKey ? items.length - 1 : 0].focus?.()
- }
- }
- const hidden = () => { if (document.hidden) void video.close() }
- const leave = () => { void video.close() }
- onMounted(() => { document.addEventListener('visibilitychange', hidden); document.addEventListener('keydown', keyboard); window.addEventListener('pagehide', leave) })
- onBeforeUnmount(() => { document.removeEventListener('visibilitychange', hidden); document.removeEventListener('keydown', keyboard); window.removeEventListener('pagehide', leave); void video.close() })
- </script>
- <style scoped>
- .video-monitor-backdrop { position: fixed; inset: 0; z-index: 15000; display: grid; place-items: center; padding: 24px; background: #0009; }
- .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; }
- .video-monitor header { display: flex; justify-content: space-between; gap: 20px; align-items: start; }
- .video-monitor h2, .video-monitor h3 { margin: 0 0 8px; }
- .video-monitor header p { margin: 0; color: #9dbbc8; overflow-wrap: anywhere; }
- .video-monitor button { padding: 7px 18px; color: #d8f8ff; background: #14556b; border: 1px solid #448ba0; border-radius: 4px; cursor: pointer; }
- .video-monitor button:disabled { color: #8399a3; background: #263c46; border-color: #49616b; cursor: not-allowed; }
- .video-monitor__tabs { display: flex; gap: 20px; align-items: center; margin-top: 18px; }
- .video-monitor__status { color: #a3d5e3; }
- .video-monitor__body { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 18px; }
- .video-monitor aside { color: #a7c3d0; background: #09212c; padding: 15px; }
- .video-monitor aside p, .video-monitor footer { font-size: 13px; color: #9cb9c5; }
- .video-monitor__screen { min-height: 300px; aspect-ratio: 16 / 9; display: grid; place-items: center; background: #02090e; }
- .video-monitor__screen > :deep(*) { width: 100%; height: 100%; }
- .video-monitor__screen > p { display: grid; place-items: center; margin: 0; }
- .video-monitor__warning { color: #ffc478; }
- .video-monitor footer { display: flex; gap: 16px; align-items: center; margin-top: 15px; }
- @media (max-width: 720px) { .video-monitor { width: 100%; } .video-monitor__body { grid-template-columns: 1fr; } .video-monitor aside { display: none; } }
- </style>
|