| @ -0,0 +1,80 @@ | |||
| import axios from 'axios' | |||
| const nvr = axios.create({ | |||
| baseURL: process.env.VUE_APP_NVR_API || '/nvr-api', | |||
| timeout: 30000, | |||
| withCredentials: false | |||
| }) | |||
| nvr.interceptors.response.use( | |||
| response => response, | |||
| error => { | |||
| const response = error.response | |||
| const data = response && response.data | |||
| const message = data && (data.message || data.msg) | |||
| const normalized = new Error(message || error.message || 'NVR 服务请求失败') | |||
| normalized.status = response && response.status | |||
| normalized.response = response | |||
| return Promise.reject(normalized) | |||
| } | |||
| ) | |||
| const data = promise => promise.then(response => response.data) | |||
| const uiBase = '/hik/nvr/playback/hls/ui' | |||
| const playbackBase = '/hik/nvr/playback/hls' | |||
| export function getNvrConfig() { | |||
| return data(nvr.get(`${uiBase}/config`)) | |||
| } | |||
| export function startLive(cameraCode) { | |||
| return data(nvr.post(`${uiBase}/actions/live/start`, { cameraCode, streamType: 1 })) | |||
| } | |||
| export function getLiveStatus(cameraCode) { | |||
| return data(nvr.get(`${uiBase}/actions/live/status`, { params: { cameraCode }})) | |||
| } | |||
| export function stopLive(cameraCode) { | |||
| return data(nvr.post(`${uiBase}/actions/live/stop`, { cameraCode })) | |||
| } | |||
| export function controlPtz(payload) { | |||
| return data(nvr.post('/device/ptzcontrol', payload)) | |||
| } | |||
| export function getPresetList(deviceCode, domainCode) { | |||
| return data(nvr.get(`/device/ptzpresetlist/${encodeURIComponent(deviceCode)}/${encodeURIComponent(domainCode)}`)) | |||
| } | |||
| export function createPreset(payload) { | |||
| return data(nvr.post(`${uiBase}/actions/preset/create`, payload)) | |||
| } | |||
| export function createSnapshot(deviceCode, domainCode) { | |||
| return data(nvr.get(`/platform/platformSnapshot/${encodeURIComponent(deviceCode)}/${encodeURIComponent(domainCode)}`)) | |||
| } | |||
| export function downloadSnapshot(taskId) { | |||
| return nvr.get(`/platform/snapshot/${encodeURIComponent(taskId)}`, { responseType: 'blob' }) | |||
| } | |||
| export function createPlaybackSession(payload) { | |||
| return data(nvr.post(`${uiBase}/sessions`, payload)) | |||
| } | |||
| export function getPlaybackSession(sessionId) { | |||
| return data(nvr.get(`${playbackBase}/sessions/${encodeURIComponent(sessionId)}`)) | |||
| } | |||
| export function controlPlayback(sessionId, payload) { | |||
| return data(nvr.post(`${uiBase}/sessions/${encodeURIComponent(sessionId)}/controls`, payload)) | |||
| } | |||
| export function deletePlaybackSession(sessionId) { | |||
| return data(nvr.delete(`${playbackBase}/sessions/${encodeURIComponent(sessionId)}`)) | |||
| } | |||
| export function downloadPlaybackClip(payload) { | |||
| return nvr.post('/hik/nvr/playback/clips/download', payload, { responseType: 'blob', timeout: 120000 }) | |||
| } | |||
| @ -0,0 +1,357 @@ | |||
| <template> | |||
| <div class="playback-console"> | |||
| <div class="playback-body"> | |||
| <nvr-device-tree | |||
| :config="config" | |||
| :channels="channels" | |||
| :selected-code="selectedCode" | |||
| :loading="loading" | |||
| open-hint="单击通道选择历史回放设备" | |||
| @select="selectChannel" | |||
| @open="selectChannel" | |||
| /> | |||
| <main class="playback-workspace"> | |||
| <div class="query-bar"> | |||
| <label><span>日期</span><input v-model="queryDate" type="date"></label> | |||
| <label><span>开始时间</span><input v-model="startTime" type="time" step="1"></label> | |||
| <em>至</em> | |||
| <label><span>结束时间</span><input v-model="endTime" type="time" step="1"></label> | |||
| <label><span>下载时刻</span><input v-model="clipTime" type="time" step="1" title="留空时使用当前回放时刻"></label> | |||
| <div class="query-actions"> | |||
| <button type="button" class="primary" :disabled="busy" @click="queryPlayback"><i class="el-icon-search" />查询录像</button> | |||
| <button type="button" :disabled="clipDownloading" @click="downloadClip"><i class="el-icon-download" />{{ clipDownloading ? '正在生成' : '下载1分钟' }}</button> | |||
| <button type="button" @click="reset"><i class="el-icon-refresh-left" />重置</button> | |||
| </div> | |||
| </div> | |||
| <section ref="videoStage" class="video-stage"> | |||
| <video ref="video" playsinline muted /> | |||
| <div class="stage-head"> | |||
| <span><i :class="{ playing: sessionStatus === 'PLAYING' }" />{{ statusLabel }}</span> | |||
| <small>{{ selectedChannelName }}</small> | |||
| <em>HLS / BEIJING</em> | |||
| </div> | |||
| <div v-if="overlayVisible" class="stage-overlay"> | |||
| <i :class="busy ? 'el-icon-loading' : 'el-icon-video-camera'" /> | |||
| <strong>{{ overlayTitle }}</strong> | |||
| <span>{{ overlayText }}</span> | |||
| </div> | |||
| <div class="stage-clock">{{ currentClock }}</div> | |||
| </section> | |||
| <section class="timeline-panel"> | |||
| <div class="timeline-head"> | |||
| <span><i />录像轨道</span> | |||
| <small>{{ timelineRangeText }}</small> | |||
| </div> | |||
| <div class="timeline" @click="seekByTimeline"> | |||
| <div class="timeline-track"> | |||
| <div class="actual-range" :style="actualRangeStyle" /> | |||
| <div class="timeline-marker" :style="markerStyle"><span>{{ currentClock }}</span></div> | |||
| </div> | |||
| <div v-if="!hasActualRange" class="timeline-empty">查询后显示实际录像区间</div> | |||
| <div class="timeline-labels"><span v-for="label in timelineLabels" :key="label">{{ label }}</span></div> | |||
| </div> | |||
| </section> | |||
| <section class="playback-controls"> | |||
| <div class="transport"> | |||
| <button type="button" title="跳到录像起点" @click="seekBoundary('start')"><i class="el-icon-d-arrow-left" /></button> | |||
| <button type="button" class="play" title="播放或暂停" @click="togglePlay"><i :class="sessionStatus === 'PLAYING' ? 'el-icon-video-pause' : 'el-icon-video-play'" /></button> | |||
| <button type="button" title="跳到录像末尾" @click="seekBoundary('end')"><i class="el-icon-d-arrow-right" /></button> | |||
| <div class="time-readout"><strong>{{ currentClock }}</strong><span>{{ timelineRangeText }}</span></div> | |||
| </div> | |||
| <div class="control-center"> | |||
| <div class="volume"><i :class="volume ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'" /><input v-model.number="volume" type="range" min="0" max="1" step="0.05" @input="syncVolume"></div> | |||
| <div class="speed-group"> | |||
| <button v-for="speed in speeds" :key="speed" type="button" :class="{ active: playbackSpeed === speed }" @click="setSpeed(speed)">{{ speed }}x</button> | |||
| </div> | |||
| <div class="jump-control"><span>跳转</span><input v-model="jumpTime" type="time" step="1"><button type="button" @click="jumpToTime">定位</button></div> | |||
| </div> | |||
| <button type="button" class="fullscreen" title="全屏" @click="fullscreen"><i class="el-icon-full-screen" /></button> | |||
| </section> | |||
| <div class="status-line" :class="messageType"><i /><span>{{ statusMessage }}</span></div> | |||
| </main> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import Hls from 'hls.js' | |||
| import NvrDeviceTree from './components/NvrDeviceTree.vue' | |||
| import { | |||
| getNvrConfig, createPlaybackSession, getPlaybackSession, | |||
| controlPlayback, deletePlaybackSession, downloadPlaybackClip | |||
| } from '@/api/nvrVideo' | |||
| const pad = value => String(value).padStart(2, '0') | |||
| const compactToDate = value => { | |||
| if (!value || !/^\d{14}$/.test(value)) return null | |||
| return new Date(Date.UTC(+value.slice(0, 4), +value.slice(4, 6) - 1, +value.slice(6, 8), +value.slice(8, 10), +value.slice(10, 12), +value.slice(12, 14))) | |||
| } | |||
| const dateToCompact = date => `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}` | |||
| const compactToInput = value => { | |||
| const date = compactToDate(value) | |||
| return date ? `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` : '' | |||
| } | |||
| const compactToClock = value => { | |||
| const date = compactToDate(value) | |||
| return date ? `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` : '--:--:--' | |||
| } | |||
| export default { | |||
| name: 'VideoHistoryPlayback', | |||
| components: { NvrDeviceTree }, | |||
| data() { | |||
| const now = new Date(Date.now() + 8 * 60 * 60 * 1000) | |||
| return { | |||
| config: {}, channels: [], selectedCode: '', loading: false, | |||
| connectionText: '读取配置', connectionState: '', | |||
| queryDate: now.toISOString().slice(0, 10), startTime: '00:00:00', endTime: '23:59:59', | |||
| clipTime: '', jumpTime: '', volume: 0.7, speeds: [1, 2, 4, 8], playbackSpeed: 1, | |||
| session: null, pollTimer: null, hls: null, hlsRetryTimer: null, loadedUrl: '', busy: false, clipDownloading: false, | |||
| queryStart: null, queryEnd: null, mediaReady: false, | |||
| overlayTitle: '选择时间范围开始回放', overlayText: '页面会自动创建 HLS 会话并等待首个可播放切片', overlayVisible: true, | |||
| statusMessage: '正在读取服务端设备配置', messageType: 'info' | |||
| } | |||
| }, | |||
| computed: { | |||
| selectedChannel() { return this.channels.find(item => item.cameraCode === this.selectedCode) || null }, | |||
| selectedChannelName() { return this.selectedChannel ? (this.selectedChannel.name || `通道 ${this.selectedChannel.channel}`) : '未选择通道' }, | |||
| sessionStatus() { return (this.session && this.session.status) || 'IDLE' }, | |||
| statusLabel() { | |||
| return { IDLE: '待查询', STARTING: '等待首帧', PLAYING: '回放中', PAUSED: '已暂停', STOPPED: '已停止', FAILED: '播放失败' }[this.sessionStatus] || this.sessionStatus | |||
| }, | |||
| currentClock() { return compactToClock(this.session && this.session.currentTime) }, | |||
| actualRange() { return this.session && Array.isArray(this.session.actualTimeRange) ? this.session.actualTimeRange : null }, | |||
| hasActualRange() { return Boolean(this.actualRange && this.actualRange.length === 2) }, | |||
| timelineRangeText() { return this.hasActualRange ? `${compactToClock(this.actualRange[0])} — ${compactToClock(this.actualRange[1])}` : '等待查询范围' }, | |||
| rangeMetrics() { | |||
| if (!this.queryStart || !this.queryEnd || !this.hasActualRange) return { left: 0, width: 0, marker: 0 } | |||
| const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd) | |||
| const actualStart = compactToDate(this.actualRange[0]); const actualEnd = compactToDate(this.actualRange[1]) | |||
| if (!start || !end || !actualStart || !actualEnd || end <= start) return { left: 0, width: 0, marker: 0 } | |||
| const total = end - start | |||
| const left = Math.max(0, Math.min(100, ((actualStart - start) / total) * 100)) | |||
| const width = Math.max(1, Math.min(100 - left, ((actualEnd - actualStart) / total) * 100)) | |||
| const current = compactToDate(this.session.currentTime) || actualStart | |||
| return { left, width, marker: Math.max(0, Math.min(100, ((current - start) / total) * 100)) } | |||
| }, | |||
| actualRangeStyle() { return { left: `${this.rangeMetrics.left}%`, width: `${this.rangeMetrics.width}%` } }, | |||
| markerStyle() { return { left: `${this.rangeMetrics.marker}%` } }, | |||
| timelineLabels() { | |||
| if (!this.queryStart || !this.queryEnd) return ['00:00', '06:00', '12:00', '18:00', '24:00'] | |||
| const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd) | |||
| if (!start || !end) return [] | |||
| return [0, 0.25, 0.5, 0.75, 1].map(value => { | |||
| const date = new Date(start.getTime() + (end - start) * value) | |||
| return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}` | |||
| }) | |||
| } | |||
| }, | |||
| created() { this.loadConfig() }, | |||
| mounted() { | |||
| const video = this.$refs.video | |||
| video.volume = this.volume | |||
| video.addEventListener('playing', () => { this.mediaReady = true; this.overlayVisible = false }) | |||
| video.addEventListener('ratechange', () => { if (video.playbackRate !== 1) video.playbackRate = 1 }) | |||
| }, | |||
| beforeDestroy() { this.stopSession() }, | |||
| methods: { | |||
| setStatus(message, type = 'info') { this.statusMessage = message; this.messageType = type }, | |||
| async loadConfig() { | |||
| this.loading = true | |||
| try { | |||
| const config = await getNvrConfig() | |||
| this.config = config || {} | |||
| this.channels = Array.isArray(config.channels) ? config.channels : [] | |||
| const routeCode = this.$route.query.cameraCode | |||
| const selected = this.channels.find(item => item.cameraCode === routeCode) || this.channels.find(item => item.cameraCode === config.defaultCameraCode) || this.channels[0] | |||
| if (selected) this.selectChannel(selected, false) | |||
| this.connectionText = '设备服务在线'; this.connectionState = 'online' | |||
| this.setStatus('选择通道和时间范围后查询录像') | |||
| } catch (error) { | |||
| this.connectionText = '服务不可达'; this.connectionState = 'error' | |||
| this.overlayTitle = '无法读取设备配置'; this.overlayText = error.message | |||
| this.setStatus(error.message, 'error') | |||
| } finally { this.loading = false } | |||
| }, | |||
| async selectChannel(channel, stop = true) { | |||
| if (!channel || channel.cameraCode === this.selectedCode) return | |||
| if (stop) await this.stopSession() | |||
| this.selectedCode = channel.cameraCode | |||
| this.$router.replace({ query: { ...this.$route.query, cameraCode: channel.cameraCode }}).catch(() => undefined) | |||
| this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '已切换通道,请查询录像' | |||
| }, | |||
| values() { | |||
| if (!this.queryDate || !this.startTime || !this.endTime) throw new Error('请选择完整的日期、开始时间和结束时间') | |||
| const start = `${this.queryDate} ${this.startTime}` | |||
| const end = `${this.queryDate} ${this.endTime}` | |||
| const startCompact = start.replace(/[- :]/g, ''); const endCompact = end.replace(/[- :]/g, '') | |||
| if (startCompact.length !== 14 || endCompact.length !== 14 || endCompact <= startCompact) throw new Error('结束时间必须晚于开始时间') | |||
| return { start, end, startCompact, endCompact } | |||
| }, | |||
| async queryPlayback() { | |||
| if (this.busy) return | |||
| if (!this.selectedCode) { this.setStatus('请先选择摄像机', 'warn'); return } | |||
| let values | |||
| try { values = this.values() } catch (error) { this.setStatus(error.message, 'error'); return } | |||
| await this.stopSession() | |||
| this.busy = true; this.queryStart = values.startCompact; this.queryEnd = values.endCompact | |||
| this.overlayVisible = true; this.overlayTitle = '正在检索录像'; this.overlayText = 'NVR 正在返回实际连续录像区间' | |||
| this.setStatus(`正在查询 ${values.start} 至 ${values.end}`) | |||
| try { | |||
| this.session = await createPlaybackSession({ cameraCode: this.selectedCode, streamType: 1, startTime: values.start, endTime: values.end }) | |||
| this.applySession(this.session) | |||
| this.schedulePoll(0) | |||
| } catch (error) { | |||
| this.overlayTitle = error.status === 404 ? '这个时间段没有录像' : '回放创建失败'; this.overlayText = error.message | |||
| this.setStatus(error.message, error.status === 404 ? 'warn' : 'error') | |||
| } finally { this.busy = false } | |||
| }, | |||
| schedulePoll(delay = 1000) { | |||
| clearTimeout(this.pollTimer) | |||
| if (!this.session || ['STOPPED', 'FAILED'].includes(this.session.status)) return | |||
| this.pollTimer = setTimeout(this.pollSession, delay) | |||
| }, | |||
| async pollSession() { | |||
| if (!this.session) return | |||
| try { | |||
| this.session = await getPlaybackSession(this.session.sessionId) | |||
| this.applySession(this.session) | |||
| this.schedulePoll(this.session.status === 'STARTING' ? 650 : 1200) | |||
| } catch (error) { | |||
| this.setStatus(error.message, 'error'); this.overlayVisible = true; this.overlayTitle = '回放流启动失败'; this.overlayText = error.message | |||
| this.destroyHls(); this.session = null | |||
| } | |||
| }, | |||
| applySession(session) { | |||
| this.playbackSpeed = Number(session.speed || 1) | |||
| if (session.hlsUrl && ['STARTING', 'PLAYING'].includes(session.status)) this.attachHls(session.hlsUrl) | |||
| if (session.status === 'PLAYING') { | |||
| if (this.mediaReady) this.overlayVisible = false | |||
| this.setStatus(`正在播放 ${this.timelineRangeText} · ${this.playbackSpeed}x`, 'success') | |||
| this.$refs.video.play().catch(() => undefined) | |||
| } else if (session.status === 'PAUSED') { | |||
| this.$refs.video.pause(); this.overlayVisible = true; this.overlayTitle = '回放已暂停'; this.overlayText = '点击播放按钮继续' | |||
| } else if (session.status === 'STARTING') { | |||
| this.overlayVisible = true; this.overlayTitle = '正在等待回放流'; this.overlayText = 'HLS 正在等待首个可播放切片' | |||
| } | |||
| }, | |||
| attachHls(url) { | |||
| if (this.loadedUrl === url && this.hls) return | |||
| this.destroyHls(); this.loadedUrl = url; this.mediaReady = false | |||
| const video = this.$refs.video | |||
| if (Hls.isSupported()) { | |||
| const hls = new Hls({ | |||
| enableWorker: true, | |||
| lowLatencyMode: false, | |||
| backBufferLength: 30, | |||
| maxBufferLength: 16, | |||
| manifestLoadingMaxRetry: 8, | |||
| levelLoadingMaxRetry: 8, | |||
| fragLoadingMaxRetry: 8 | |||
| }) | |||
| this.hls = hls | |||
| hls.on(Hls.Events.MEDIA_ATTACHED, () => { | |||
| if (this.hls === hls) hls.loadSource(`${url}${url.includes('?') ? '&' : '?'}uiGeneration=${this.session ? this.session.generation : Date.now()}`) | |||
| }) | |||
| hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => undefined)) | |||
| hls.on(Hls.Events.ERROR, (_, detail) => { | |||
| if (this.hls !== hls || !detail || !detail.fatal) return | |||
| this.setStatus('HLS 播放异常,正在自动恢复', 'warn') | |||
| if (detail.type === Hls.ErrorTypes.NETWORK_ERROR) { | |||
| clearTimeout(this.hlsRetryTimer) | |||
| this.hlsRetryTimer = setTimeout(() => { | |||
| if (this.hls === hls) hls.startLoad() | |||
| }, 800) | |||
| } else if (detail.type === Hls.ErrorTypes.MEDIA_ERROR) { | |||
| hls.recoverMediaError() | |||
| } | |||
| }) | |||
| hls.attachMedia(video) | |||
| } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; video.play().catch(() => undefined) } | |||
| }, | |||
| destroyHls() { | |||
| clearTimeout(this.hlsRetryTimer); this.hlsRetryTimer = null | |||
| if (this.hls) { this.hls.destroy(); this.hls = null } | |||
| this.loadedUrl = ''; this.mediaReady = false | |||
| const video = this.$refs.video | |||
| if (video) { video.pause(); video.removeAttribute('src'); video.load() } | |||
| }, | |||
| async sendControl(action, payload = {}) { | |||
| if (!this.session || this.busy) return | |||
| this.busy = true | |||
| try { | |||
| this.session = await controlPlayback(this.session.sessionId, { cameraCode: this.selectedCode, action, ...payload }) | |||
| this.applySession(this.session); this.schedulePoll(this.session.status === 'STARTING' ? 650 : 1200) | |||
| } catch (error) { this.setStatus(error.message, 'error') } finally { this.busy = false } | |||
| }, | |||
| togglePlay() { if (!this.session) this.setStatus('请先查询录像', 'warn'); else this.sendControl(this.sessionStatus === 'PLAYING' ? 'PAUSE' : 'RESUME') }, | |||
| setSpeed(speed) { this.sendControl('SET_SPEED', { speed }) }, | |||
| seek(value) { if (value) this.sendControl('SEEK', { seekTime: compactToInput(value) }) }, | |||
| seekBoundary(side) { | |||
| if (!this.hasActualRange) return | |||
| if (side === 'start') this.seek(this.actualRange[0]) | |||
| else { | |||
| const end = compactToDate(this.actualRange[1]); end.setUTCSeconds(end.getUTCSeconds() - 1); this.seek(dateToCompact(end)) | |||
| } | |||
| }, | |||
| seekByTimeline(event) { | |||
| if (!this.session || !this.queryStart || !this.queryEnd) return | |||
| const rect = event.currentTarget.getBoundingClientRect(); const fraction = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) | |||
| const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd) | |||
| this.seek(dateToCompact(new Date(start.getTime() + (end - start) * fraction))) | |||
| }, | |||
| jumpToTime() { | |||
| if (!this.jumpTime) { this.setStatus('请输入跳转时间', 'warn'); return } | |||
| const time = this.jumpTime.length === 5 ? `${this.jumpTime}:00` : this.jumpTime | |||
| this.seek(`${this.queryDate.replace(/-/g, '')}${time.replace(/:/g, '')}`) | |||
| }, | |||
| syncVolume() { const video = this.$refs.video; video.volume = this.volume; video.muted = this.volume === 0 }, | |||
| fullscreen() { const stage = this.$refs.videoStage; if (stage && stage.requestFullscreen) stage.requestFullscreen() }, | |||
| async stopSession() { | |||
| clearTimeout(this.pollTimer) | |||
| const session = this.session | |||
| this.session = null | |||
| if (session && session.sessionId) { | |||
| try { await controlPlayback(session.sessionId, { cameraCode: session.cameraCode || this.selectedCode, action: 'STOP' }) } catch (_) { /* cleanup */ } | |||
| try { await deletePlaybackSession(session.sessionId) } catch (_) { /* cleanup */ } | |||
| } | |||
| this.destroyHls() | |||
| }, | |||
| reset() { | |||
| this.stopSession(); this.startTime = '00:00:00'; this.endTime = '23:59:59'; this.clipTime = ''; this.jumpTime = '' | |||
| this.queryStart = null; this.queryEnd = null; this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '页面会自动创建 HLS 会话并等待首个可播放切片'; this.setStatus('选择通道和时间范围后查询录像') | |||
| }, | |||
| async downloadClip() { | |||
| if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return } | |||
| const centerTime = this.clipTime ? `${this.queryDate} ${this.clipTime}` : compactToInput(this.session && this.session.currentTime) | |||
| if (!centerTime) { this.$message.warning('请输入下载时刻,或先开始回放'); return } | |||
| this.clipDownloading = true | |||
| try { | |||
| const response = await downloadPlaybackClip({ cameraCode: this.selectedCode, streamType: 1, centerTime, beforeSeconds: 30, afterSeconds: 30 }) | |||
| const disposition = response.headers['content-disposition'] || '' | |||
| const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';]+)/i) | |||
| const filename = match ? decodeURIComponent(match[1]) : `playback-${centerTime.replace(/[- :]/g, '')}.mp4` | |||
| const url = URL.createObjectURL(response.data); const link = document.createElement('a') | |||
| link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 2000) | |||
| this.$message.success('录像片段已下载') | |||
| } catch (error) { this.$message.error(error.message) } finally { this.clipDownloading = false } | |||
| } | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .playback-console { height: 100%; min-height: 0; display: flex; flex-direction: column; color: #dcecff; background: #0b1248; } | |||
| .playback-body { flex: 1; min-height: 0; display: flex; }.playback-workspace { flex: 1; min-width: 0; min-height: 0; padding: 10px 14px; display: flex; flex-direction: column; gap: 9px; } | |||
| .query-bar { min-height: 58px; display: flex; align-items: flex-end; gap: 9px; padding: 7px 10px; border: 1px solid rgba(42,133,230,.38); background: rgba(7,30,84,.68); }.query-bar label { display: flex; flex-direction: column; gap: 5px; color: #7fa8d1; font-size: 11px; }.query-bar input { width: 126px; height: 29px; padding: 0 8px; border: 1px solid rgba(55,151,247,.48); color: #d4eaff; background: #09265e; outline: none; }.query-bar label:first-child input { width: 140px; }.query-bar em { height: 29px; line-height: 29px; color: #6c97c2; font-style: normal; }.query-actions { margin-left: auto; display: flex; gap: 6px; }.query-actions button { height: 30px; padding: 0 11px; border: 1px solid rgba(50,147,244,.52); color: #a9d3ff; background: #0a2b67; cursor: pointer; }.query-actions button.primary { color: #fff; background: #176bc2; }.query-actions button i { margin-right: 5px; }.query-actions button:disabled { opacity: .55; cursor: not-allowed; } | |||
| .video-stage { position: relative; flex: 1; min-height: 260px; overflow: hidden; border: 1px solid #176bc2; background: #03081d; }.video-stage video { width: 100%; height: 100%; display: block; object-fit: contain; }.stage-head { position: absolute; z-index: 2; top: 0; left: 0; right: 0; height: 34px; padding: 0 10px; display: flex; align-items: center; gap: 12px; color: #c9e3ff; background: linear-gradient(180deg,rgba(2,9,31,.94),transparent); font-size: 11px; }.stage-head span { display: flex; align-items: center; gap: 6px; }.stage-head span i { width: 7px; height: 7px; border-radius: 50%; background: #d09231; }.stage-head span i.playing { background: #25dfa2; box-shadow: 0 0 8px #25dfa2; }.stage-head small { color: #7da8d4; }.stage-head em { margin-left: auto; color: #5e8dbb; font-style: normal; }.stage-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; color: #6d9bc8; background: radial-gradient(circle at center,rgba(10,45,96,.65),rgba(3,8,29,.92)); }.stage-overlay i { font-size: 36px; }.stage-overlay strong { color: #b9d8f7; font-size: 14px; }.stage-overlay span { font-size: 11px; }.stage-clock { position: absolute; right: 10px; bottom: 8px; color: #a7caec; font-family: Consolas,monospace; font-size: 12px; } | |||
| .timeline-panel { padding: 8px 10px 4px; border: 1px solid rgba(43,133,226,.38); background: rgba(7,29,80,.72); }.timeline-head { display: flex; align-items: center; justify-content: space-between; color: #bcd9f7; font-size: 11px; }.timeline-head span { display: flex; align-items: center; gap: 6px; }.timeline-head i { width: 6px; height: 6px; border-radius: 50%; background: #1a8cff; }.timeline-head small { color: #6d98c2; }.timeline { position: relative; height: 46px; margin-top: 4px; cursor: pointer; }.timeline-track { position: absolute; left: 0; right: 0; top: 12px; height: 7px; background: #071b44; border: 1px solid #183f73; }.actual-range { position: absolute; top: -1px; height: 7px; background: #1989ee; box-shadow: 0 0 7px rgba(25,137,238,.6); }.timeline-marker { position: absolute; z-index: 2; top: -5px; width: 1px; height: 17px; background: #54ffff; }.timeline-marker span { position: absolute; left: 50%; top: -17px; transform: translateX(-50%); color: #54ffff; font-size: 9px; white-space: nowrap; }.timeline-empty { position: absolute; top: 3px; left: 50%; transform: translateX(-50%); color: #587fa8; font-size: 10px; }.timeline-labels { position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-between; color: #557da7; font-size: 9px; } | |||
| .playback-controls { height: 48px; min-height: 48px; padding: 0 9px; display: flex; align-items: center; border: 1px solid rgba(43,133,226,.38); background: #081e53; }.transport { display: flex; align-items: center; gap: 5px; }.playback-controls button { border: 1px solid rgba(53,146,238,.5); color: #a8d2fa; background: #0a2b67; cursor: pointer; }.transport>button { width: 29px; height: 29px; }.transport button.play { width: 35px; height: 35px; border-radius: 50%; color: #fff; background: #176bc2; }.time-readout { width: 170px; display: flex; flex-direction: column; margin-left: 6px; }.time-readout strong { color: #54ffff; font: 13px Consolas,monospace; }.time-readout span { color: #638fb9; font-size: 9px; }.control-center { flex: 1; display: flex; align-items: center; justify-content: center; gap: 18px; }.volume { display: flex; align-items: center; gap: 5px; color: #71a7d7; }.volume input { width: 76px; accent-color: #228ef1; }.speed-group { display: flex; }.speed-group button { width: 34px; height: 27px; border-right: 0; }.speed-group button:last-child { border-right: 1px solid rgba(53,146,238,.5); }.speed-group button.active { color: #fff; background: #176bc2; }.jump-control { display: flex; align-items: center; gap: 5px; color: #729dcc; font-size: 10px; }.jump-control input { width: 100px; height: 27px; padding: 0 5px; border: 1px solid rgba(53,146,238,.5); color: #d1e7ff; background: #081f53; }.jump-control button { height: 27px; }.fullscreen { width: 30px; height: 30px; } | |||
| .status-line { min-height: 29px; padding: 0 10px; display: flex; align-items: center; gap: 7px; color: #7fa8d0; border: 1px solid rgba(43,133,226,.25); background: rgba(6,25,70,.6); font-size: 11px; }.status-line i { width: 6px; height: 6px; border-radius: 50%; background: #428fda; }.status-line.success i { background: #2bdb9f; }.status-line.warn i { background: #e5a83d; }.status-line.error i { background: #ff5c72; } | |||
| @media (max-width: 1250px) { .query-bar { flex-wrap: wrap; }.query-actions { margin-left: 0; }.control-center { gap: 8px; }.time-readout { width: 130px; } } | |||
| </style> | |||
| @ -0,0 +1,475 @@ | |||
| <template> | |||
| <div class="video-console live-preview"> | |||
| <div class="console-body"> | |||
| <nvr-device-tree | |||
| :config="config" | |||
| :channels="channels" | |||
| :selected-code="selectedCode" | |||
| :loading="loading" | |||
| @select="selectChannel" | |||
| @open="openChannel" | |||
| /> | |||
| <main class="monitor-workspace"> | |||
| <div class="monitor-head"> | |||
| <span>监控画面</span> | |||
| <small>双击设备树通道打开,单击画面切换控制对象</small> | |||
| </div> | |||
| <div class="camera-grid" :style="gridStyle"> | |||
| <article | |||
| v-for="(slot, index) in visibleSlots" | |||
| :key="index" | |||
| ref="cameraCards" | |||
| class="camera-card" | |||
| :class="{ active: activeIndex === index, empty: !slot }" | |||
| @click="activeIndex = index" | |||
| @dblclick="slot && fullscreen(index)" | |||
| > | |||
| <template v-if="slot"> | |||
| <nvr-stream-player | |||
| :source="slot.streamSource" | |||
| :loading="slot.loading" | |||
| :muted="muted" | |||
| @error="handleStreamError($event, index)" | |||
| /> | |||
| <div class="camera-label"> | |||
| <span><i />{{ slot.name }}</span> | |||
| <small>CH{{ slot.channel }}</small> | |||
| </div> | |||
| <div class="camera-actions"> | |||
| <button type="button" title="历史回放" @click.stop="openPlayback(slot)"><i class="el-icon-time" /></button> | |||
| <button type="button" title="全屏" @click.stop="fullscreen(index)"><i class="el-icon-full-screen" /></button> | |||
| <button type="button" title="关闭" @click.stop="closeSlot(index)"><i class="el-icon-close" /></button> | |||
| </div> | |||
| </template> | |||
| <div v-else class="empty-slot"> | |||
| <i class="el-icon-video-camera" /> | |||
| <span>双击左侧通道打开预览</span> | |||
| </div> | |||
| </article> | |||
| </div> | |||
| </main> | |||
| <aside class="control-panel"> | |||
| <section class="control-section"> | |||
| <div class="section-title"><span>云台控制</span><small>{{ selectedChannelName }}</small></div> | |||
| <div class="ptz-grid"> | |||
| <button | |||
| v-for="button in ptzButtons" | |||
| :key="button.action" | |||
| type="button" | |||
| :title="button.label" | |||
| @mousedown.prevent="startPtz(button.action)" | |||
| @mouseup.prevent="stopPtz" | |||
| @mouseleave="stopPtz" | |||
| @touchstart.prevent="startPtz(button.action)" | |||
| @touchend.prevent="stopPtz" | |||
| > | |||
| <i :class="button.icon" /> | |||
| </button> | |||
| </div> | |||
| <div class="lens-actions"> | |||
| <div v-for="tool in lensTools" :key="tool.label" class="lens-tool" :title="tool.label"> | |||
| <img :src="tool.icon" :alt="tool.label"> | |||
| <button | |||
| type="button" | |||
| :aria-label="tool.leftLabel" | |||
| :title="tool.leftLabel" | |||
| @mousedown.prevent="startPtz(tool.leftAction)" | |||
| @mouseup.prevent="stopPtz" | |||
| @mouseleave="stopPtz" | |||
| @touchstart.prevent="startPtz(tool.leftAction)" | |||
| @touchend.prevent="stopPtz" | |||
| /> | |||
| <button | |||
| type="button" | |||
| :aria-label="tool.rightLabel" | |||
| :title="tool.rightLabel" | |||
| @mousedown.prevent="startPtz(tool.rightAction)" | |||
| @mouseup.prevent="stopPtz" | |||
| @mouseleave="stopPtz" | |||
| @touchstart.prevent="startPtz(tool.rightAction)" | |||
| @touchend.prevent="stopPtz" | |||
| /> | |||
| </div> | |||
| </div> | |||
| <div class="speed-row"> | |||
| <span>速度</span> | |||
| <input v-model.number="ptzSpeed" type="range" min="1" max="10"> | |||
| <el-input-number | |||
| v-model="ptzSpeed" | |||
| class="speed-input" | |||
| :min="1" | |||
| :max="10" | |||
| size="mini" | |||
| :controls="false" | |||
| /> | |||
| </div> | |||
| <div class="preset-quick-actions"> | |||
| <button type="button" title="调用预置位" @click="callPreset"> | |||
| <img :src="presetIcons.call" alt="调用预置位"> | |||
| </button> | |||
| <button type="button" title="新增预置位" @click="prepareNewPreset"> | |||
| <img :src="presetIcons.add" alt="新增预置位"> | |||
| </button> | |||
| <button type="button" title="编辑预置位" @click="prepareEditPreset"> | |||
| <img :src="presetIcons.edit" alt="编辑预置位"> | |||
| </button> | |||
| </div> | |||
| </section> | |||
| <section class="control-section"> | |||
| <div class="section-title"><span>预置位</span><button type="button" @click="loadPresets"><i class="el-icon-refresh" /></button></div> | |||
| <el-select v-model="presetId" size="small" placeholder="请选择预置位" style="width:100%"> | |||
| <el-option v-for="item in presets" :key="item.id" :label="`${item.id} · ${item.name}`" :value="item.id" /> | |||
| </el-select> | |||
| <div class="preset-create"> | |||
| <el-input-number v-model="newPresetId" :min="1" :max="256" size="mini" :controls="false" placeholder="编号" /> | |||
| <el-input v-model.trim="newPresetName" size="mini" maxlength="32" placeholder="名称" /> | |||
| </div> | |||
| <div class="section-actions"> | |||
| <button type="button" @click="callPreset">调用</button> | |||
| <button type="button" class="primary" @click="savePreset">保存</button> | |||
| </div> | |||
| </section> | |||
| <section class="control-section"> | |||
| <div class="section-title"><span>操作</span></div> | |||
| <div class="operation-grid"> | |||
| <button type="button" class="wide" @click="capture"><i class="el-icon-camera" />抓图</button> | |||
| <button type="button" @click="showPendingOperation('录像')"><i class="el-icon-video-camera" />录像</button> | |||
| <button type="button" :class="{ active: !muted }" @click="muted = !muted"> | |||
| <i :class="muted ? 'el-icon-turn-off-microphone' : 'el-icon-microphone'" />静音 | |||
| </button> | |||
| <button type="button" @click="showPendingOperation('雨刷器')"><i class="el-icon-umbrella" />雨刷器</button> | |||
| <button type="button" @click="showPendingOperation('3D定位')"><i class="el-icon-aim" />3D定位</button> | |||
| </div> | |||
| </section> | |||
| </aside> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import NvrDeviceTree from './components/NvrDeviceTree.vue' | |||
| import NvrStreamPlayer from './components/NvrStreamPlayer.vue' | |||
| import { | |||
| getNvrConfig, startLive, getLiveStatus, stopLive, controlPtz, | |||
| getPresetList, createPreset, createSnapshot, downloadSnapshot | |||
| } from '@/api/nvrVideo' | |||
| const PTZ_CODES = { | |||
| UP: 2, DOWN: 3, LEFT: 4, UP_LEFT: 5, DOWN_LEFT: 6, | |||
| RIGHT: 7, UP_RIGHT: 8, DOWN_RIGHT: 9, | |||
| IRIS_OPEN: 21, IRIS_CLOSE: 22, | |||
| ZOOM_IN: 23, ZOOM_OUT: 24, FOCUS_NEAR: 25, FOCUS_FAR: 26 | |||
| } | |||
| export default { | |||
| name: 'VideoLivePreview', | |||
| components: { NvrDeviceTree, NvrStreamPlayer }, | |||
| data() { | |||
| return { | |||
| config: {}, | |||
| channels: [], | |||
| selectedCode: '', | |||
| layout: 9, | |||
| layouts: [ | |||
| { value: 1, label: '单画面' }, { value: 4, label: '四画面' }, | |||
| { value: 9, label: '九画面' }, { value: 16, label: '十六画面' } | |||
| ], | |||
| slots: Array.from({ length: 16 }, () => null), | |||
| activeIndex: 0, | |||
| placementCursor: 0, | |||
| loading: false, | |||
| connectionText: '读取配置', | |||
| connectionState: '', | |||
| muted: true, | |||
| ptzSpeed: 4, | |||
| ptzActive: false, | |||
| presets: [], | |||
| presetId: null, | |||
| newPresetId: null, | |||
| newPresetName: '', | |||
| presetIcons: { | |||
| call: require('@/assets/images/r6.png'), | |||
| add: require('@/assets/images/r7.png'), | |||
| edit: require('@/assets/images/r8.png') | |||
| }, | |||
| ptzButtons: [ | |||
| { action: 'UP_LEFT', icon: 'el-icon-top-left', label: '左上' }, | |||
| { action: 'UP', icon: 'el-icon-top', label: '向上' }, | |||
| { action: 'UP_RIGHT', icon: 'el-icon-top-right', label: '右上' }, | |||
| { action: 'LEFT', icon: 'el-icon-back', label: '向左' }, | |||
| { action: 'STOP', icon: 'el-icon-video-pause', label: '停止' }, | |||
| { action: 'RIGHT', icon: 'el-icon-right', label: '向右' }, | |||
| { action: 'DOWN_LEFT', icon: 'el-icon-bottom-left', label: '左下' }, | |||
| { action: 'DOWN', icon: 'el-icon-bottom', label: '向下' }, | |||
| { action: 'DOWN_RIGHT', icon: 'el-icon-bottom-right', label: '右下' } | |||
| ], | |||
| lensTools: [ | |||
| { | |||
| label: '变倍', icon: require('@/assets/images/r1.png'), | |||
| leftAction: 'ZOOM_OUT', leftLabel: '变倍缩小', | |||
| rightAction: 'ZOOM_IN', rightLabel: '变倍放大' | |||
| }, | |||
| { | |||
| label: '聚焦', icon: require('@/assets/images/r2.png'), | |||
| leftAction: 'FOCUS_FAR', leftLabel: '远聚焦', | |||
| rightAction: 'FOCUS_NEAR', rightLabel: '近聚焦' | |||
| }, | |||
| { | |||
| label: '光圈', icon: require('@/assets/images/r3.png'), | |||
| leftAction: 'IRIS_CLOSE', leftLabel: '关闭光圈', | |||
| rightAction: 'IRIS_OPEN', rightLabel: '打开光圈' | |||
| } | |||
| ] | |||
| } | |||
| }, | |||
| computed: { | |||
| capacity() { return this.layout }, | |||
| visibleSlots() { return this.slots.slice(0, this.capacity) }, | |||
| gridStyle() { | |||
| const columns = { 1: 1, 4: 2, 9: 3, 16: 4 }[this.layout] | |||
| return { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${columns}, minmax(0, 1fr))` } | |||
| }, | |||
| selectedChannel() { return this.channels.find(item => item.cameraCode === this.selectedCode) || null }, | |||
| selectedChannelName() { return this.selectedChannel ? (this.selectedChannel.name || `通道 ${this.selectedChannel.channel}`) : '未选择' } | |||
| }, | |||
| created() { | |||
| this.$root.$on('video-layout-change', this.changeLayout) | |||
| this.$root.$emit('video-layout-updated', this.layout) | |||
| this.loadConfig() | |||
| }, | |||
| beforeDestroy() { | |||
| this.$root.$off('video-layout-change', this.changeLayout) | |||
| this.closeAll() | |||
| }, | |||
| methods: { | |||
| async loadConfig() { | |||
| this.loading = true | |||
| this.connectionText = '读取配置' | |||
| try { | |||
| const config = await getNvrConfig() | |||
| this.config = config || {} | |||
| this.channels = Array.isArray(config.channels) ? config.channels : [] | |||
| const selected = this.channels.find(item => item.cameraCode === config.defaultCameraCode) || this.channels[0] | |||
| if (selected) this.selectChannel(selected) | |||
| this.connectionText = '设备服务在线' | |||
| this.connectionState = 'online' | |||
| } catch (error) { | |||
| this.connectionText = '服务不可达' | |||
| this.connectionState = 'error' | |||
| this.$message.error(error.message) | |||
| } finally { this.loading = false } | |||
| }, | |||
| selectChannel(channel) { | |||
| if (!channel) return | |||
| this.selectedCode = channel.cameraCode | |||
| this.loadPresets(true) | |||
| }, | |||
| openChannel(channel) { | |||
| this.selectChannel(channel) | |||
| this.startChannel(channel) | |||
| }, | |||
| async startChannel(channel) { | |||
| if (!channel || !channel.cameraCode) return | |||
| const existing = this.slots.findIndex(slot => slot && slot.cameraCode === channel.cameraCode) | |||
| if (existing >= 0) { this.activeIndex = existing; return } | |||
| let index = this.visibleSlots.findIndex(slot => !slot) | |||
| if (index < 0) index = this.placementCursor % this.capacity | |||
| if (this.slots[index]) await this.closeSlot(index, false) | |||
| const slot = { | |||
| ...channel, | |||
| loading: true, | |||
| streamSource: { hlsUrl: '', flvUrl: '' }, | |||
| timer: null | |||
| } | |||
| this.$set(this.slots, index, slot) | |||
| this.activeIndex = index | |||
| this.placementCursor = (index + 1) % this.capacity | |||
| try { | |||
| const result = await startLive(channel.cameraCode) | |||
| this.applyLiveResult(index, slot, result) | |||
| if (!result || result.status !== 'PLAYING') this.pollLive(index, slot, 0) | |||
| } catch (error) { | |||
| this.$set(this.slots, index, null) | |||
| this.$message.error(error.message) | |||
| } | |||
| }, | |||
| applyLiveResult(index, slot, result) { | |||
| if (this.slots[index] !== slot || !result) return | |||
| if (result.hlsUrl) slot.streamSource.hlsUrl = result.hlsUrl | |||
| if (result.flvUrl) slot.streamSource.flvUrl = result.flvUrl | |||
| slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl) | |||
| }, | |||
| pollLive(index, slot, attempt) { | |||
| if (this.slots[index] !== slot) return | |||
| if (attempt >= 20) { | |||
| slot.loading = false | |||
| this.$message.error('视频流启动超时,请检查摄像机或流媒体服务') | |||
| return | |||
| } | |||
| slot.timer = setTimeout(async() => { | |||
| try { | |||
| const result = await getLiveStatus(slot.cameraCode) | |||
| this.applyLiveResult(index, slot, result) | |||
| if (!result || result.status !== 'PLAYING' || !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)) { | |||
| this.pollLive(index, slot, attempt + 1) | |||
| } | |||
| } catch (_) { this.pollLive(index, slot, attempt + 1) } | |||
| }, 800) | |||
| }, | |||
| async closeSlot(index, notify = true) { | |||
| const slot = this.slots[index] | |||
| if (!slot) return | |||
| clearTimeout(slot.timer) | |||
| this.$set(this.slots, index, null) | |||
| try { await stopLive(slot.cameraCode) } catch (_) { /* release the UI slot first */ } | |||
| if (notify) this.$message.success('视频通道已关闭') | |||
| }, | |||
| closeAll() { | |||
| this.slots.forEach((slot, index) => { if (slot) this.closeSlot(index, false) }) | |||
| }, | |||
| changeLayout(layout) { | |||
| if (layout === this.layout) return | |||
| if (layout < this.layout) { | |||
| this.slots.forEach((slot, index) => { if (slot && index >= layout) this.closeSlot(index, false) }) | |||
| } | |||
| this.layout = layout | |||
| this.$root.$emit('video-layout-updated', layout) | |||
| if (this.activeIndex >= layout) this.activeIndex = 0 | |||
| this.placementCursor %= layout | |||
| }, | |||
| handleStreamError(error, index) { | |||
| const slot = this.slots[index] | |||
| if (slot) slot.loading = false | |||
| this.$message.error(error.message) | |||
| }, | |||
| fullscreen(index) { | |||
| const card = this.$refs.cameraCards && this.$refs.cameraCards[index] | |||
| if (card && card.requestFullscreen) card.requestFullscreen() | |||
| }, | |||
| openPlayback(slot) { | |||
| this.$router.push({ name: 'VideoHistoryPlayback', query: { cameraCode: slot.cameraCode }}) | |||
| }, | |||
| async sendPtz(controlCode, controlPara1 = '', controlPara2 = '') { | |||
| if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return } | |||
| const result = await controlPtz({ cameraCode: this.selectedCode, controlCode, controlPara1, controlPara2 }) | |||
| if (!result || Number(result.resultCode) !== 0) throw new Error('云台控制失败') | |||
| }, | |||
| startPtz(action) { | |||
| if (action === 'STOP') { | |||
| this.ptzActive = false | |||
| this.sendPtz(1).catch(error => this.$message.error(error.message)) | |||
| return | |||
| } | |||
| const code = PTZ_CODES[action] | |||
| if (!code || this.ptzActive) return | |||
| this.ptzActive = true | |||
| this.sendPtz(code, '2', String(this.ptzSpeed)).catch(error => this.$message.error(error.message)) | |||
| }, | |||
| stopPtz() { | |||
| if (!this.ptzActive) return | |||
| this.ptzActive = false | |||
| this.sendPtz(1).catch(error => this.$message.error(error.message)) | |||
| }, | |||
| splitCameraCode() { | |||
| const parts = String(this.selectedCode || '').split('#') | |||
| if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error('摄像机编码格式不正确') | |||
| return parts | |||
| }, | |||
| async loadPresets(silent = false) { | |||
| if (!this.selectedCode) return | |||
| try { | |||
| const [deviceCode, domainCode] = this.splitCameraCode() | |||
| const result = await getPresetList(deviceCode, domainCode) | |||
| const source = result && result.ptzPresetInfoList && result.ptzPresetInfoList.ptzPresetInfo | |||
| this.presets = (Array.isArray(source) ? source : []).map(item => ({ id: Number(item.presetIndex), name: item.presetName || `预置位 ${item.presetIndex}` })).filter(item => item.id > 0) | |||
| if (this.presets.length && !this.presets.some(item => item.id === this.presetId)) this.presetId = this.presets[0].id | |||
| if (!silent) this.$message.success(`读取到 ${this.presets.length} 个预置位`) | |||
| } catch (error) { if (!silent) this.$message.error(error.message) } | |||
| }, | |||
| async callPreset() { | |||
| if (!this.presetId) { this.$message.warning('请选择预置位'); return } | |||
| try { await this.sendPtz(11, String(this.presetId)); this.$message.success('预置位调用成功') } catch (error) { this.$message.error(error.message) } | |||
| }, | |||
| prepareNewPreset() { | |||
| const used = new Set(this.presets.map(item => Number(item.id))) | |||
| let nextId = 1 | |||
| while (used.has(nextId) && nextId < 256) nextId += 1 | |||
| this.newPresetId = nextId | |||
| this.newPresetName = '' | |||
| this.$message.info(`请填写预置位 ${nextId} 的名称后保存`) | |||
| }, | |||
| prepareEditPreset() { | |||
| const preset = this.presets.find(item => item.id === this.presetId) | |||
| if (!preset) { this.$message.warning('请先选择需要编辑的预置位'); return } | |||
| this.newPresetId = preset.id | |||
| this.newPresetName = preset.name || '' | |||
| this.$message.info(`正在编辑预置位 ${preset.id}`) | |||
| }, | |||
| async savePreset() { | |||
| const channel = this.selectedChannel | |||
| if (!channel) { this.$message.warning('请先选择摄像机'); return } | |||
| if (!Number.isInteger(this.newPresetId) || this.newPresetId < 1 || this.newPresetId > 256) { | |||
| this.$message.warning('请输入 1-256 的预置位编号') | |||
| return | |||
| } | |||
| try { | |||
| await this.$confirm(`确认保存预置位 ${this.newPresetId}${this.newPresetName ? ` · ${this.newPresetName}` : ''}?`, '保存预置位') | |||
| await createPreset({ nvrIp: channel.nvrIp, channel: Number(channel.channel), preset: this.newPresetId, presetName: this.newPresetName || null }) | |||
| await this.loadPresets(true) | |||
| this.$message.success('预置位保存成功') | |||
| } catch (error) { if (error !== 'cancel' && error !== 'close') this.$message.error(error.message) } | |||
| }, | |||
| async capture() { | |||
| if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return } | |||
| try { | |||
| const [deviceCode, domainCode] = this.splitCameraCode() | |||
| const command = await createSnapshot(deviceCode, domainCode) | |||
| if (!command || Number(command.resultCode) !== 0 || !command.taskID) throw new Error('抓图任务创建失败') | |||
| const response = await downloadSnapshot(command.taskID) | |||
| const url = URL.createObjectURL(response.data) | |||
| const link = document.createElement('a') | |||
| link.href = url | |||
| link.download = `camera-${this.selectedCode.replace(/[^A-Za-z0-9_-]/g, '_')}-${Date.now()}.jpg` | |||
| link.click() | |||
| setTimeout(() => URL.revokeObjectURL(url), 2000) | |||
| this.$message.success('抓图已下载') | |||
| } catch (error) { this.$message.error(error.message) } | |||
| }, | |||
| showPendingOperation(name) { | |||
| this.$message.info(`${name}控制接口暂未接入`) | |||
| } | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .video-console { height: 100%; min-height: 0; display: flex; flex-direction: column; color: #dcecff; background: #0b1248; } | |||
| .console-body { flex: 1; min-height: 0; display: flex; } | |||
| .monitor-workspace { flex: 1; min-width: 0; display: flex; flex-direction: column; padding: 10px; } | |||
| .monitor-head { height: 32px; display: flex; align-items: center; justify-content: space-between; color: #cfe5ff; }.monitor-head small { color: #648fbc; } | |||
| .camera-grid { flex: 1; min-height: 0; display: grid; gap: 3px; background: rgba(14, 75, 146, .5); border: 1px solid #176bc2; } | |||
| .camera-card { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid transparent; background: #050b24; cursor: pointer; }.camera-card.active { border-color: #54ffff; box-shadow: inset 0 0 0 1px rgba(84,255,255,.35); } | |||
| .camera-label { position: absolute; left: 0; right: 0; top: 0; height: 28px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; color: #d8eaff; background: linear-gradient(180deg, rgba(0,10,36,.92), transparent); font-size: 11px; pointer-events: none; }.camera-label i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #24e39f; box-shadow: 0 0 6px #24e39f; } | |||
| .camera-actions { position: absolute; right: 5px; bottom: 5px; display: none; gap: 3px; }.camera-card:hover .camera-actions { display: flex; }.camera-actions button { width: 26px; height: 25px; border: 1px solid rgba(66, 167, 255, .55); color: #bfe0ff; background: rgba(4,28,78,.88); cursor: pointer; } | |||
| .empty-slot { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #466f9c; background: radial-gradient(circle at center, #0b2a61, #050b24 70%); }.empty-slot i { font-size: 28px; }.empty-slot span { font-size: 11px; } | |||
| .control-panel { width: 242px; min-width: 242px; padding: 10px; overflow-y: auto; border-left: 1px solid rgba(41, 139, 255, .45); background: rgba(7, 24, 78, .82); } | |||
| .control-section { padding-bottom: 12px; margin-bottom: 12px; border-bottom: 1px solid rgba(39, 128, 220, .28); }.section-title { height: 30px; display: flex; align-items: center; justify-content: space-between; color: #54ffff; }.section-title small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #719bc8; }.section-title button { border: 0; color: #75bfff; background: transparent; cursor: pointer; } | |||
| .ptz-grid { width: 168px; margin: 6px auto 12px; display: grid; grid-template-columns: repeat(3, 50px); gap: 9px; }.ptz-grid button { width: 50px; height: 50px; border: 1px solid #b9d5fb; border-radius: 7px; color: #2874da; background: #e8f2ff; box-shadow: inset 0 0 0 1px rgba(255,255,255,.7); cursor: pointer; transition: color .15s, background .15s, transform .15s; }.ptz-grid button i { font-size: 18px; font-weight: 700; }.ptz-grid button:hover { color: #075cc6; background: #f6faff; }.ptz-grid button:active { color: #fff; background: #1670ca; transform: scale(.96); } | |||
| .speed-row { display: grid; grid-template-columns: 34px minmax(0, 1fr) 48px; align-items: center; gap: 7px; color: #8eb7df; font-size: 12px; }.speed-row > input { min-width: 0; accent-color: #2497ff; }.speed-input { width: 48px; } | |||
| .preset-quick-actions { display: flex; align-items: center; gap: 7px; margin: 9px 0 0 1px; }.preset-quick-actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 1px solid rgba(48, 151, 248, .5); color: #58bfff; background: transparent; cursor: pointer; }.preset-quick-actions button:hover { background: rgba(31, 126, 222, .25); }.preset-quick-actions img { width: 18px; height: 18px; object-fit: contain; } | |||
| .lens-actions { display: flex; align-items: center; justify-content: center; gap: 11px; min-height: 28px; margin: 0 0 10px; }.lens-tool { position: relative; width: 48px; height: 22px; overflow: hidden; }.lens-tool img { display: block; width: 100%; height: 100%; object-fit: fill; }.lens-tool button { position: absolute; top: 0; bottom: 0; width: 50%; padding: 0; border: 0; background: transparent; cursor: pointer; }.lens-tool button:first-of-type { left: 0; }.lens-tool button:last-of-type { right: 0; }.lens-tool button:active { background: rgba(73, 184, 255, .28); } | |||
| .operation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 9px; }.operation-grid button, .section-actions button { min-height: 29px; border: 1px solid rgba(47, 145, 241, .5); color: #abd4ff; background: #0b2b67; cursor: pointer; }.operation-grid button i { margin-right: 5px; }.operation-grid button.active, .section-actions button.primary { color: #fff; background: #176bc2; } | |||
| .operation-grid .wide { grid-column: 1 / -1; } | |||
| ::v-deep .el-select .el-input__inner, ::v-deep .preset-create .el-input__inner, ::v-deep .speed-input .el-input__inner { color: #cce6ff; border-color: rgba(52, 147, 241, .5); background: #09265e; } | |||
| ::v-deep .speed-input .el-input__inner { height: 26px; padding: 0 7px; line-height: 26px; text-align: center; } | |||
| ::v-deep .speed-input.el-input-number--mini { line-height: 24px; } | |||
| ::v-deep .preset-create .el-input__inner { height: 28px; padding: 0 8px; line-height: 28px; } | |||
| ::v-deep .preset-create .el-input-number .el-input__inner { text-align: center; } | |||
| ::v-deep .preset-create .el-input-number--mini { line-height: 26px; } | |||
| .preset-create { display: grid; grid-template-columns: 68px minmax(0, 1fr); align-items: center; gap: 6px; margin-top: 8px; }.preset-create .el-input-number { width: 68px; }.preset-create .el-input { width: 100%; }.section-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; } | |||
| @media (max-width: 1100px) { .control-panel { width: 218px; min-width: 218px; }.nvr-tree { width: 230px; min-width: 230px; } } | |||
| </style> | |||
| @ -0,0 +1,186 @@ | |||
| <template> | |||
| <aside class="nvr-tree"> | |||
| <div class="tree-title"> | |||
| <span><i class="el-icon-s-platform" /> 设备通道</span> | |||
| <strong>{{ filteredCount }}</strong> | |||
| </div> | |||
| <el-input | |||
| v-model.trim="query" | |||
| clearable | |||
| size="small" | |||
| prefix-icon="el-icon-search" | |||
| placeholder="搜索通道 / 分组" | |||
| /> | |||
| <div v-loading="loading" class="tree-content"> | |||
| <template v-if="visibleNvrs.length"> | |||
| <section v-for="nvr in visibleNvrs" :key="nvr.key" class="nvr-node"> | |||
| <button type="button" class="nvr-row" @click="toggle(nvr.key)"> | |||
| <i :class="isOpen(nvr.key) ? 'el-icon-arrow-down' : 'el-icon-arrow-right'" /> | |||
| <i class="el-icon-connection" /> | |||
| <span>{{ nvr.name }}</span> | |||
| <small>{{ nvr.channels.length }}</small> | |||
| </button> | |||
| <div v-show="isOpen(nvr.key)" class="nvr-groups"> | |||
| <section v-for="group in nvr.groups" :key="group.key" class="channel-group"> | |||
| <button type="button" class="group-row" @click="toggle(group.key)"> | |||
| <i :class="isOpen(group.key) ? 'el-icon-caret-bottom' : 'el-icon-caret-right'" /> | |||
| <span>{{ group.name }}</span> | |||
| <small>{{ group.channels.length }}</small> | |||
| </button> | |||
| <button | |||
| v-for="channel in group.channels" | |||
| v-show="isOpen(group.key)" | |||
| :key="channel.cameraCode" | |||
| type="button" | |||
| class="channel-row" | |||
| :class="{ active: channel.cameraCode === selectedCode }" | |||
| @click="$emit('select', channel)" | |||
| @dblclick="$emit('open', channel)" | |||
| > | |||
| <i class="el-icon-video-camera" /> | |||
| <span>{{ channel.name || `通道 ${channel.channel}` }}</span> | |||
| <small>CH{{ channel.channel }}</small> | |||
| </button> | |||
| </section> | |||
| </div> | |||
| </section> | |||
| </template> | |||
| <div v-else class="tree-empty"> | |||
| <i class="el-icon-video-camera-solid" /> | |||
| <span>{{ loading ? '正在读取设备配置' : '没有匹配的设备通道' }}</span> | |||
| </div> | |||
| </div> | |||
| <div class="tree-tip">{{ openHint }}</div> | |||
| </aside> | |||
| </template> | |||
| <script> | |||
| export default { | |||
| name: 'NvrDeviceTree', | |||
| props: { | |||
| config: { type: Object, default: () => ({}) }, | |||
| channels: { type: Array, default: () => [] }, | |||
| selectedCode: { type: String, default: '' }, | |||
| loading: { type: Boolean, default: false }, | |||
| openHint: { type: String, default: '双击通道打开实时预览' } | |||
| }, | |||
| data() { | |||
| return { | |||
| query: '', | |||
| expanded: {} | |||
| } | |||
| }, | |||
| computed: { | |||
| visibleNvrs() { | |||
| const query = this.query.toLowerCase() | |||
| const configured = Array.isArray(this.config.nvrs) ? this.config.nvrs : [] | |||
| const source = configured.length ? configured : this.inferredNvrs | |||
| return source.reduce((result, item, index) => { | |||
| const nvrIp = String(item.nvrIp || item.ip || '') | |||
| const ownChannels = Array.isArray(item.channels) && item.channels.length | |||
| ? item.channels | |||
| : this.channels.filter(channel => String(channel.nvrIp || '') === nvrIp) | |||
| const nvrName = item.name || item.nvrName || nvrIp || `NVR ${index + 1}` | |||
| const nvrMatches = [nvrName, nvrIp, item.nvrId].filter(Boolean).join(' ').toLowerCase().includes(query) | |||
| const channels = !query || nvrMatches ? ownChannels : ownChannels.filter(channel => { | |||
| return [channel.name, channel.group, channel.channel, channel.cameraCode] | |||
| .filter(Boolean).join(' ').toLowerCase().includes(query) | |||
| }) | |||
| if (query && !nvrMatches && !channels.length) return result | |||
| const key = `nvr:${nvrIp || index}` | |||
| const grouped = channels.reduce((groups, channel) => { | |||
| const name = String(channel.group || '未分组') | |||
| if (!groups[name]) groups[name] = [] | |||
| groups[name].push(channel) | |||
| return groups | |||
| }, {}) | |||
| result.push({ | |||
| ...item, | |||
| key, | |||
| name: nvrName, | |||
| channels, | |||
| groups: Object.keys(grouped).map(name => ({ | |||
| key: `${key}:group:${name}`, | |||
| name, | |||
| channels: grouped[name] | |||
| })) | |||
| }) | |||
| return result | |||
| }, []) | |||
| }, | |||
| inferredNvrs() { | |||
| const map = new Map() | |||
| this.channels.forEach(channel => { | |||
| const key = String(channel.nvrIp || channel.nvrName || 'default') | |||
| if (!map.has(key)) map.set(key, { nvrIp: channel.nvrIp, name: channel.nvrName || channel.nvrIp, channels: [] }) | |||
| map.get(key).channels.push(channel) | |||
| }) | |||
| return Array.from(map.values()) | |||
| }, | |||
| filteredCount() { | |||
| return this.visibleNvrs.reduce((count, nvr) => count + nvr.channels.length, 0) | |||
| } | |||
| }, | |||
| watch: { | |||
| visibleNvrs: { | |||
| immediate: true, | |||
| handler(nvrs) { | |||
| nvrs.forEach(nvr => { | |||
| if (this.expanded[nvr.key] === undefined) this.$set(this.expanded, nvr.key, true) | |||
| nvr.groups.forEach(group => { | |||
| if (this.expanded[group.key] === undefined) this.$set(this.expanded, group.key, true) | |||
| }) | |||
| }) | |||
| } | |||
| } | |||
| }, | |||
| methods: { | |||
| toggle(key) { | |||
| this.$set(this.expanded, key, !this.isOpen(key)) | |||
| }, | |||
| isOpen(key) { | |||
| return this.expanded[key] !== false | |||
| } | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .nvr-tree { | |||
| width: 258px; | |||
| min-width: 258px; | |||
| height: 100%; | |||
| display: flex; | |||
| flex-direction: column; | |||
| border-right: 1px solid rgba(41, 139, 255, .55); | |||
| background: rgba(7, 24, 78, .82); | |||
| color: #d7e9ff; | |||
| } | |||
| .tree-title { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(41, 139, 255, .35); color: #54ffff; font-size: 14px; } | |||
| .tree-title i { margin-right: 6px; } | |||
| .tree-title strong { min-width: 28px; height: 20px; padding: 0 7px; line-height: 20px; text-align: center; color: #b7d8ff; border: 1px solid rgba(55, 151, 255, .4); background: #0d3472; font-size: 12px; } | |||
| ::v-deep .el-input { margin: 10px 12px; width: calc(100% - 24px); } | |||
| ::v-deep .el-input__inner { color: #d7e9ff; border-color: rgba(56, 151, 255, .45); background: rgba(7, 30, 88, .75); } | |||
| .tree-content { | |||
| flex: 1; | |||
| min-height: 0; | |||
| overflow-y: auto; | |||
| padding: 0 8px 8px; | |||
| scrollbar-width: none; | |||
| -ms-overflow-style: none; | |||
| } | |||
| .tree-content::-webkit-scrollbar { display: none; } | |||
| .nvr-row, .group-row, .channel-row { width: 100%; border: 0; color: inherit; background: transparent; cursor: pointer; text-align: left; letter-spacing: 0; } | |||
| .nvr-row { height: 36px; display: flex; align-items: center; gap: 7px; color: #cce4ff; font-weight: 600; } | |||
| .nvr-row span, .channel-row span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |||
| .nvr-row small, .group-row small { color: #78a6d8; font-weight: 400; } | |||
| .group-row { height: 30px; padding-left: 17px; color: #8ebce8; } | |||
| .group-row i { margin-right: 5px; } | |||
| .channel-row { height: 34px; padding: 0 7px 0 34px; display: flex; align-items: center; gap: 8px; border-left: 2px solid transparent; color: #b9d8f8; } | |||
| .channel-row:hover { background: rgba(25, 105, 194, .22); } | |||
| .channel-row.active { color: #54ffff; border-left-color: #26d8ff; background: rgba(19, 100, 196, .38); } | |||
| .channel-row small { color: #668fbd; font-size: 10px; } | |||
| .tree-empty { height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #7298c3; } | |||
| .tree-empty i { font-size: 32px; } | |||
| .tree-tip { min-height: 35px; padding: 9px 12px; border-top: 1px solid rgba(41, 139, 255, .3); color: #6f9ecc; font-size: 11px; line-height: 16px; } | |||
| </style> | |||
| @ -0,0 +1,154 @@ | |||
| <template> | |||
| <div class="stream-player" :class="{ ready }"> | |||
| <video ref="video" autoplay playsinline :muted="muted" /> | |||
| <div v-if="!ready" class="stream-placeholder"> | |||
| <i :class="loading ? 'el-icon-loading' : 'el-icon-video-camera'" /> | |||
| <span>{{ loading ? '正在连接视频流' : placeholder }}</span> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import Hls from 'hls.js' | |||
| import flvjs from 'flv.js' | |||
| export default { | |||
| name: 'NvrStreamPlayer', | |||
| props: { | |||
| source: { type: Object, default: () => ({}) }, | |||
| muted: { type: Boolean, default: true }, | |||
| autoplay: { type: Boolean, default: true }, | |||
| loading: { type: Boolean, default: false }, | |||
| placeholder: { type: String, default: '等待打开视频通道' } | |||
| }, | |||
| data() { | |||
| return { hls: null, flv: null, ready: false, hlsRetryTimer: null } | |||
| }, | |||
| watch: { | |||
| source: { | |||
| deep: true, | |||
| handler() { this.attach() } | |||
| }, | |||
| muted(value) { | |||
| if (this.$refs.video) this.$refs.video.muted = value | |||
| } | |||
| }, | |||
| mounted() { | |||
| this.attach() | |||
| }, | |||
| beforeDestroy() { | |||
| this.destroyPlayer() | |||
| }, | |||
| methods: { | |||
| play() { | |||
| const video = this.$refs.video | |||
| return video ? video.play() : Promise.resolve() | |||
| }, | |||
| pause() { | |||
| if (this.$refs.video) this.$refs.video.pause() | |||
| }, | |||
| destroyPlayer() { | |||
| clearTimeout(this.hlsRetryTimer) | |||
| this.hlsRetryTimer = null | |||
| if (this.hls) { | |||
| this.hls.destroy() | |||
| this.hls = null | |||
| } | |||
| if (this.flv) { | |||
| try { this.flv.destroy() } catch (_) { /* player already released */ } | |||
| this.flv = null | |||
| } | |||
| const video = this.$refs.video | |||
| if (video) { | |||
| video.pause() | |||
| video.removeAttribute('src') | |||
| video.load() | |||
| } | |||
| this.ready = false | |||
| }, | |||
| attach() { | |||
| this.$nextTick(() => { | |||
| const video = this.$refs.video | |||
| if (!video) return | |||
| const flvUrl = this.source && this.source.flvUrl | |||
| const hlsUrl = this.source && this.source.hlsUrl | |||
| this.destroyPlayer() | |||
| video.muted = this.muted | |||
| const markReady = () => { | |||
| this.ready = true | |||
| this.$emit('ready') | |||
| } | |||
| video.onloadeddata = markReady | |||
| video.oncanplay = markReady | |||
| video.onplaying = markReady | |||
| video.onerror = () => this.$emit('error', new Error('视频播放失败')) | |||
| if (flvUrl && flvjs.isSupported()) { | |||
| this.flv = flvjs.createPlayer( | |||
| { type: 'flv', url: flvUrl, isLive: true }, | |||
| { enableStashBuffer: false, lazyLoad: false, autoCleanupSourceBuffer: true } | |||
| ) | |||
| this.flv.attachMediaElement(video) | |||
| this.flv.load() | |||
| if (this.autoplay) Promise.resolve(this.flv.play()).catch(() => undefined) | |||
| this.flv.on(flvjs.Events.ERROR, () => { | |||
| if (hlsUrl) this.attachHls(video, hlsUrl) | |||
| else this.$emit('error', new Error('HTTP-FLV 播放失败')) | |||
| }) | |||
| return | |||
| } | |||
| if (hlsUrl) this.attachHls(video, hlsUrl) | |||
| }) | |||
| }, | |||
| attachHls(video, url) { | |||
| if (this.flv) { | |||
| try { this.flv.destroy() } catch (_) { /* ignore */ } | |||
| this.flv = null | |||
| } | |||
| if (Hls.isSupported()) { | |||
| const hls = new Hls({ | |||
| enableWorker: true, | |||
| lowLatencyMode: true, | |||
| liveDurationInfinity: true, | |||
| manifestLoadingMaxRetry: 8, | |||
| levelLoadingMaxRetry: 8, | |||
| fragLoadingMaxRetry: 8 | |||
| }) | |||
| this.hls = hls | |||
| hls.on(Hls.Events.MEDIA_ATTACHED, () => { | |||
| if (this.hls === hls) hls.loadSource(url) | |||
| }) | |||
| hls.on(Hls.Events.MANIFEST_PARSED, () => { | |||
| if (this.autoplay) video.play().catch(() => undefined) | |||
| }) | |||
| hls.on(Hls.Events.ERROR, (_, detail) => { | |||
| if (this.hls !== hls || !detail || !detail.fatal) return | |||
| if (detail.type === Hls.ErrorTypes.NETWORK_ERROR) { | |||
| clearTimeout(this.hlsRetryTimer) | |||
| this.hlsRetryTimer = setTimeout(() => { | |||
| if (this.hls === hls) hls.startLoad() | |||
| }, 800) | |||
| } else if (detail.type === Hls.ErrorTypes.MEDIA_ERROR) { | |||
| hls.recoverMediaError() | |||
| } else { | |||
| this.$emit('error', new Error('HLS 播放失败')) | |||
| } | |||
| }) | |||
| hls.attachMedia(video) | |||
| } else if (video.canPlayType('application/vnd.apple.mpegurl')) { | |||
| video.src = url | |||
| if (this.autoplay) video.play().catch(() => undefined) | |||
| } else { | |||
| this.$emit('error', new Error('当前浏览器不支持 HLS 播放')) | |||
| } | |||
| } | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .stream-player { position: relative; width: 100%; height: 100%; overflow: hidden; background: #050b24; } | |||
| video { width: 100%; height: 100%; display: block; object-fit: contain; background: #050b24; } | |||
| .stream-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: #527ca8; background: radial-gradient(circle at center, rgba(12, 47, 103, .45), rgba(3, 9, 32, .92)); } | |||
| .stream-placeholder i { font-size: 34px; } | |||
| .stream-placeholder span { font-size: 12px; } | |||
| </style> | |||