|
|
<template>
|
|
|
<div class="video-console live-preview">
|
|
|
<div class="console-body">
|
|
|
<nvr-device-tree
|
|
|
:config="config"
|
|
|
:channels="channels"
|
|
|
:selected-code="selectedCode"
|
|
|
:loading="loading"
|
|
|
@channels-loaded="handleTreeChannels"
|
|
|
@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="selectSlot(index)"
|
|
|
@dblclick="slot && fullscreen(index)"
|
|
|
>
|
|
|
<template v-if="slot">
|
|
|
<nvr-stream-player
|
|
|
:source="slot.streamSource"
|
|
|
:loading="slot.loading"
|
|
|
:muted="slot.muted !== false"
|
|
|
@error="handleStreamError($event, index)"
|
|
|
/>
|
|
|
<div class="camera-label">
|
|
|
<span><i />{{ slot.name }}</span>
|
|
|
</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 v-show="controlPanelExpanded" 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="7" :style="{ background: `linear-gradient(to right, #2787ed 0%, #2787ed ${((ptzSpeed - 1) / 6) * 100}%, transparent ${((ptzSpeed - 1) / 6) * 100}%, transparent 100%)` }">
|
|
|
<el-input-number
|
|
|
v-model="ptzSpeed"
|
|
|
class="speed-input"
|
|
|
:min="1"
|
|
|
:max="7"
|
|
|
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="255" 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: !activeMuted }" @click="toggleMuted">
|
|
|
<i :class="activeMuted ? '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: [],
|
|
|
treeChannelsLoaded: false,
|
|
|
treeChannelCodes: new Set(),
|
|
|
controlPanelExpanded: false,
|
|
|
streamsInitialized: false,
|
|
|
selectedCode: '',
|
|
|
layout: 9,
|
|
|
layouts: [
|
|
|
{ value: 1, label: '单画面' }, { value: 4, label: '四画面' },
|
|
|
{ value: 9, label: '九画面' }, { value: 16, label: '十六画面' }
|
|
|
],
|
|
|
slots: Array.from({ length: 16 }, () => null),
|
|
|
activeIndex: 0,
|
|
|
controlTarget: null,
|
|
|
placementCursor: 0,
|
|
|
loading: false,
|
|
|
connectionText: '读取配置',
|
|
|
connectionState: '',
|
|
|
ptzSpeed: 4,
|
|
|
ptzActive: false,
|
|
|
ptzCameraCode: '',
|
|
|
ptzRepeatTimer: null,
|
|
|
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) },
|
|
|
activeSlot() { return this.slots[this.activeIndex] || null },
|
|
|
activeMuted() { return this.controlTarget ? this.controlTarget.muted !== false : true },
|
|
|
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.controlTarget },
|
|
|
selectedChannelName() { return this.controlTarget ? (this.controlTarget.name || `通道 ${this.controlTarget.channel}`) : '未选择画面' }
|
|
|
},
|
|
|
created() {
|
|
|
this.$root.$on('video-layout-change', this.changeLayout)
|
|
|
this.$root.$on('video-control-panel-change', this.changeControlPanel)
|
|
|
this.$root.$emit('video-layout-updated', this.layout)
|
|
|
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
|
|
|
this.loadConfig()
|
|
|
},
|
|
|
beforeDestroy() {
|
|
|
this.$root.$off('video-layout-change', this.changeLayout)
|
|
|
this.$root.$off('video-control-panel-change', this.changeControlPanel)
|
|
|
this.closeAll()
|
|
|
},
|
|
|
methods: {
|
|
|
async loadConfig() {
|
|
|
this.loading = true
|
|
|
this.connectionText = '读取配置'
|
|
|
try {
|
|
|
const config = await getNvrConfig()
|
|
|
this.config = config || {}
|
|
|
// Keep config as metadata only until the device tree has supplied the
|
|
|
// channels that are actually visible/selectable by the user.
|
|
|
const configChannels = Array.isArray(config.channels) ? config.channels : []
|
|
|
this.setAvailableChannels(this.treeChannelsLoaded
|
|
|
? configChannels.filter(channel => channel && this.treeChannelCodes.has(channel.cameraCode))
|
|
|
: configChannels, false)
|
|
|
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
|
|
|
if (!this.channels.some(item => item.cameraCode === channel.cameraCode)) {
|
|
|
this.channels.push(channel)
|
|
|
}
|
|
|
this.selectedCode = channel.cameraCode
|
|
|
},
|
|
|
setAvailableChannels(channels, initializeStreams = false) {
|
|
|
const knownCodes = new Set(this.channels.map(item => item.cameraCode))
|
|
|
;(channels || []).forEach(channel => {
|
|
|
if (!channel || !channel.cameraCode) return
|
|
|
const channelName = channel.name || channel.label || channel.channelName || channel.cameraName || channel.deviceName || ''
|
|
|
if (knownCodes.has(channel.cameraCode)) {
|
|
|
const existing = this.channels.find(item => item.cameraCode === channel.cameraCode)
|
|
|
if (existing) {
|
|
|
// Device-tree entries contain the authoritative channel metadata
|
|
|
// (NVR address, channel number, display name). Merge it over any
|
|
|
// lightweight config entry with the same camera code.
|
|
|
const existingName = existing.name
|
|
|
Object.assign(existing, channel)
|
|
|
if (this.isCameraCodeName(channelName) && !this.isCameraCodeName(existingName)) {
|
|
|
this.$set(existing, 'name', existingName)
|
|
|
} else if (channelName) {
|
|
|
this.$set(existing, 'name', channelName)
|
|
|
}
|
|
|
}
|
|
|
return
|
|
|
}
|
|
|
knownCodes.add(channel.cameraCode)
|
|
|
this.channels.push({
|
|
|
...channel,
|
|
|
name: channelName || (channel.channelCode && !this.isCameraCodeName(channel.channelCode) ? channel.channelCode : `通道 ${channel.channel || ''}`)
|
|
|
})
|
|
|
})
|
|
|
if (initializeStreams && !this.streamsInitialized && this.channels.length) {
|
|
|
this.streamsInitialized = true
|
|
|
this.populateRandomLayout()
|
|
|
}
|
|
|
},
|
|
|
isCameraCodeName(name) {
|
|
|
if (!name) return true
|
|
|
return /^(camera|cam|channel)[-_ ]?\d+$/i.test(String(name).trim())
|
|
|
},
|
|
|
handleTreeChannels(channels) {
|
|
|
this.treeChannelsLoaded = true
|
|
|
const treeCodes = new Set((channels || []).filter(item => item && item.cameraCode).map(item => item.cameraCode))
|
|
|
this.treeChannelCodes = treeCodes
|
|
|
// Remove config-only channels from the selectable/default pool. They can
|
|
|
// still be returned by the config endpoint, but must not appear in the
|
|
|
// default layout unless present in the device tree.
|
|
|
if (treeCodes.size) this.channels = this.channels.filter(item => treeCodes.has(item.cameraCode))
|
|
|
this.setAvailableChannels(channels, true)
|
|
|
},
|
|
|
shuffleChannels() {
|
|
|
const channels = this.channels.slice()
|
|
|
for (let index = channels.length - 1; index > 0; index -= 1) {
|
|
|
const randomIndex = Math.floor(Math.random() * (index + 1))
|
|
|
const current = channels[index]
|
|
|
channels[index] = channels[randomIndex]
|
|
|
channels[randomIndex] = current
|
|
|
}
|
|
|
return channels
|
|
|
},
|
|
|
async populateRandomLayout() {
|
|
|
const channels = this.shuffleChannels().slice(0, this.capacity)
|
|
|
if (!channels.length) return
|
|
|
await Promise.all(this.slots.map((slot, index) => slot ? this.closeSlot(index, false) : null))
|
|
|
this.activeIndex = 0
|
|
|
this.controlTarget = null
|
|
|
for (let index = 0; index < channels.length; index += 1) {
|
|
|
await this.startChannel(channels[index], index)
|
|
|
}
|
|
|
},
|
|
|
openChannel(channel) {
|
|
|
this.selectChannel(channel)
|
|
|
this.startChannel(channel)
|
|
|
},
|
|
|
async startChannel(channel, targetIndex = this.activeIndex, attemptedCameraCodes = []) {
|
|
|
if (!channel || !channel.cameraCode) return
|
|
|
const index = targetIndex
|
|
|
const current = this.slots[index]
|
|
|
if (current && current.cameraCode === channel.cameraCode) {
|
|
|
this.controlTarget = current
|
|
|
return
|
|
|
}
|
|
|
if (this.slots[index]) await this.closeSlot(index, false)
|
|
|
const slot = {
|
|
|
...channel,
|
|
|
loading: true,
|
|
|
streamSource: { hlsUrl: '', flvUrl: '' },
|
|
|
muted: true,
|
|
|
errorNotified: false,
|
|
|
attemptedCameraCodes: [...attemptedCameraCodes, channel.cameraCode],
|
|
|
timer: null
|
|
|
}
|
|
|
this.$set(this.slots, index, slot)
|
|
|
if (index === this.activeIndex) {
|
|
|
this.controlTarget = slot
|
|
|
this.selectedCode = channel.cameraCode
|
|
|
}
|
|
|
this.presets = []
|
|
|
this.presetId = null
|
|
|
try {
|
|
|
const result = await startLive(channel)
|
|
|
if (this.slots[index] !== slot) return
|
|
|
this.applyLiveResult(index, slot, result)
|
|
|
if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) {
|
|
|
this.pollLive(index, slot, 0)
|
|
|
return
|
|
|
}
|
|
|
if (index === this.activeIndex) this.loadPresets(true)
|
|
|
} catch (error) {
|
|
|
if (this.slots[index] === slot) {
|
|
|
this.$set(this.slots, index, null)
|
|
|
this.$message.error(error.message)
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
applyLiveResult(index, slot, result) {
|
|
|
if (this.slots[index] !== slot || !result) return
|
|
|
const payload = result.data || result
|
|
|
const mediaUrl = payload.url || payload.playUrl || payload.mediaUrl || payload.address || ''
|
|
|
const hlsUrl = payload.hlsUrl || payload.hls || (/\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
|
|
|
const flvUrl = payload.flvUrl || payload.flv || (/\.flv(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
|
|
|
slot.streamSource.hlsUrl = this.replaceStreamHost(hlsUrl)
|
|
|
slot.streamSource.flvUrl = this.replaceStreamHost(flvUrl)
|
|
|
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)
|
|
|
},
|
|
|
replaceStreamHost(url) {
|
|
|
return url
|
|
|
},
|
|
|
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
|
|
|
if (index === this.activeIndex && this.ptzActive) this.stopPtz()
|
|
|
clearTimeout(slot.timer)
|
|
|
this.$set(this.slots, index, null)
|
|
|
if (this.controlTarget === slot) this.controlTarget = null
|
|
|
if (index === this.activeIndex) {
|
|
|
this.presets = []
|
|
|
this.presetId = 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
|
|
|
this.layout = layout
|
|
|
this.$root.$emit('video-layout-updated', layout)
|
|
|
this.populateRandomLayout()
|
|
|
},
|
|
|
changeControlPanel(expanded) {
|
|
|
this.controlPanelExpanded = Boolean(expanded)
|
|
|
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
|
|
|
},
|
|
|
selectSlot(index) {
|
|
|
if (index === this.activeIndex) {
|
|
|
const current = this.slots[index]
|
|
|
this.controlTarget = current || null
|
|
|
if (current) this.selectedCode = current.cameraCode
|
|
|
return
|
|
|
}
|
|
|
if (this.ptzActive) this.stopPtz()
|
|
|
this.activeIndex = index
|
|
|
const slot = this.slots[index]
|
|
|
this.controlTarget = slot || null
|
|
|
this.presets = []
|
|
|
this.presetId = null
|
|
|
if (!slot) return
|
|
|
this.selectedCode = slot.cameraCode
|
|
|
this.loadPresets(true)
|
|
|
},
|
|
|
handleStreamError(error, index) {
|
|
|
const slot = this.slots[index]
|
|
|
if (!slot || slot.errorNotified) return
|
|
|
slot.loading = false
|
|
|
slot.errorNotified = true
|
|
|
this.switchToNextChannel(index, slot, error)
|
|
|
},
|
|
|
async switchToNextChannel(index, failedSlot, error) {
|
|
|
if (this.slots[index] !== failedSlot) return
|
|
|
const attemptedCodes = new Set(failedSlot.attemptedCameraCodes || [failedSlot.cameraCode])
|
|
|
const occupiedCodes = new Set(this.slots
|
|
|
.filter((slot, slotIndex) => slot && slotIndex !== index)
|
|
|
.map(slot => slot.cameraCode))
|
|
|
const currentIndex = this.channels.findIndex(channel => channel.cameraCode === failedSlot.cameraCode)
|
|
|
let nextChannel = null
|
|
|
for (let offset = 1; offset <= this.channels.length; offset += 1) {
|
|
|
const candidate = this.channels[(currentIndex + offset + this.channels.length) % this.channels.length]
|
|
|
if (candidate && !attemptedCodes.has(candidate.cameraCode) && !occupiedCodes.has(candidate.cameraCode)) {
|
|
|
nextChannel = candidate
|
|
|
break
|
|
|
}
|
|
|
}
|
|
|
this.$set(this.slots, index, null)
|
|
|
try { await stopLive(failedSlot.cameraCode) } catch (_) { /* continue switching cameras */ }
|
|
|
if (nextChannel) {
|
|
|
await this.startChannel(nextChannel, index, Array.from(attemptedCodes))
|
|
|
} else {
|
|
|
this.$message.error((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 = '', cameraCode = '') {
|
|
|
const targetCode = cameraCode || (this.controlTarget && this.controlTarget.cameraCode)
|
|
|
if (!targetCode) { this.$message.warning('请先选择有视频的画面'); return }
|
|
|
const result = await controlPtz({ cameraCode: targetCode, controlCode, controlPara1, controlPara2 })
|
|
|
if (!result || Number(result.resultCode) !== 0) throw new Error('云台控制失败')
|
|
|
},
|
|
|
startPtz(action) {
|
|
|
if (action === 'STOP') {
|
|
|
const cameraCode = this.ptzCameraCode || (this.controlTarget && this.controlTarget.cameraCode)
|
|
|
this.ptzActive = false
|
|
|
this.ptzCameraCode = ''
|
|
|
clearInterval(this.ptzRepeatTimer)
|
|
|
this.ptzRepeatTimer = null
|
|
|
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message))
|
|
|
return
|
|
|
}
|
|
|
const code = PTZ_CODES[action]
|
|
|
if (!code || this.ptzActive) return
|
|
|
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return }
|
|
|
this.ptzActive = true
|
|
|
this.ptzCameraCode = this.controlTarget.cameraCode
|
|
|
const sendMove = () => this.sendPtz(code, '2', String(this.ptzSpeed), this.ptzCameraCode).catch(error => {
|
|
|
if (this.ptzActive) this.$message.error(error.message)
|
|
|
})
|
|
|
sendMove()
|
|
|
clearInterval(this.ptzRepeatTimer)
|
|
|
this.ptzRepeatTimer = setInterval(sendMove, 300)
|
|
|
},
|
|
|
stopPtz() {
|
|
|
if (!this.ptzActive) return
|
|
|
const cameraCode = this.ptzCameraCode
|
|
|
this.ptzActive = false
|
|
|
this.ptzCameraCode = ''
|
|
|
clearInterval(this.ptzRepeatTimer)
|
|
|
this.ptzRepeatTimer = null
|
|
|
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message))
|
|
|
},
|
|
|
async loadPresets(silent = false) {
|
|
|
const cameraCode = this.controlTarget && this.controlTarget.cameraCode
|
|
|
if (!cameraCode) return
|
|
|
try {
|
|
|
const result = await getPresetList(cameraCode)
|
|
|
if (!this.controlTarget || this.controlTarget.cameraCode !== cameraCode) return
|
|
|
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 < 255) 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 > 255) {
|
|
|
this.$message.warning('请输入 1-256 的预置位编号')
|
|
|
return
|
|
|
}
|
|
|
try {
|
|
|
await this.$confirm(`确认保存预置位 ${this.newPresetId}${this.newPresetName ? ` · ${this.newPresetName}` : ''}?`, '保存预置位')
|
|
|
await createPreset({ cameraCode: channel.cameraCode, 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() {
|
|
|
const cameraCode = this.controlTarget && this.controlTarget.cameraCode
|
|
|
if (!cameraCode) { this.$message.warning('请先选择有视频的画面'); return }
|
|
|
try {
|
|
|
const command = await createSnapshot(cameraCode)
|
|
|
if (!command || Number(command.resultCode) !== 0 || !command.taskID) throw new Error('抓图任务创建失败')
|
|
|
const blob = await downloadSnapshot(command.taskID)
|
|
|
const url = URL.createObjectURL(blob)
|
|
|
const link = document.createElement('a')
|
|
|
link.href = url
|
|
|
link.download = `camera-${cameraCode.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) }
|
|
|
},
|
|
|
toggleMuted() {
|
|
|
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return }
|
|
|
this.$set(this.controlTarget, 'muted', !(this.controlTarget.muted !== false))
|
|
|
},
|
|
|
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; gap: 10px; }
|
|
|
.monitor-workspace { flex: 1; min-width: 0; height: 100%; display: flex; flex-direction: column; padding: 10px; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; box-sizing: border-box; }
|
|
|
.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; height: 100%; align-self: stretch; margin: 0; padding: 10px; overflow-y: auto; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: rgba(7, 24, 78, .82); box-sizing: border-box; }
|
|
|
.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 #3988ff; border-radius: 7px; color: #43c0ff; background: #01016b; box-shadow: none; cursor: pointer; transition: color .15s, background .15s, transform .15s; }.ptz-grid button i { font-size: 18px; font-weight: 700; }.ptz-grid button:hover { color: #43c0ff; background: #01016b; }.ptz-grid button:active { color: #ffffff; background: #01016b; 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[type="range"] { min-width: 0; height: 8px; box-sizing: border-box; margin: 0; appearance: none; border: 1px solid #2787ed; border-radius: 4px; outline: none; cursor: pointer; }.speed-row > input[type="range"]::-webkit-slider-runnable-track { height: 6px; border: 0; border-radius: 3px; background: transparent; }.speed-row > input[type="range"]::-webkit-slider-thumb { width: 14px; height: 14px; margin-top: -4px; appearance: none; border: 0; border-radius: 50%; background: #2787ed; }.speed-row > input[type="range"]::-moz-range-track { height: 6px; border: 0; border-radius: 3px; background: transparent; }.speed-row > input[type="range"]::-moz-range-progress { background: transparent; }.speed-row > input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #2787ed; }.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>
|