|
|
<template>
|
|
|
<div class="video-console live-preview" :class="{ 'is-embed': embed }">
|
|
|
<div class="console-body">
|
|
|
<div
|
|
|
v-show="!embed || embedTreeVisible"
|
|
|
class="tree-panel"
|
|
|
:style="embedTreeStyle"
|
|
|
@mousedown="startEmbedPanelDrag('tree', $event)"
|
|
|
>
|
|
|
<nvr-device-tree
|
|
|
v-if="!embed || sessionReady"
|
|
|
:config="config"
|
|
|
:channels="channels"
|
|
|
:selected-code="selectedCode"
|
|
|
:loading="loading"
|
|
|
:use-agg-api="isAggMode"
|
|
|
:station-code="embedStationCode"
|
|
|
:default-expanded="embedTreeExpanded"
|
|
|
@channels-loaded="handleTreeChannels"
|
|
|
@select="selectChannel"
|
|
|
@open="openChannel"
|
|
|
/>
|
|
|
<div
|
|
|
v-if="!embed"
|
|
|
class="tree-resize-handle"
|
|
|
:class="{ resizing: isTreeResizing }"
|
|
|
title="拖拽调整设备树宽度"
|
|
|
@mousedown="startTreeResize"
|
|
|
>
|
|
|
<span />
|
|
|
<span />
|
|
|
<span />
|
|
|
</div>
|
|
|
<button
|
|
|
v-if="embed"
|
|
|
type="button"
|
|
|
class="embed-toggle embed-toggle-left"
|
|
|
:title="embedTreeVisible ? '隐藏设备列表' : '显示设备列表'"
|
|
|
@mousedown.stop
|
|
|
@click.stop="embedTreeVisible = !embedTreeVisible"
|
|
|
>
|
|
|
<img :src="treeToggleIcon" alt="">
|
|
|
</button>
|
|
|
</div>
|
|
|
<button
|
|
|
v-if="embed && !embedTreeVisible"
|
|
|
type="button"
|
|
|
class="embed-toggle embed-toggle-left is-collapsed"
|
|
|
title="显示设备列表"
|
|
|
@click="embedTreeVisible = true"
|
|
|
>
|
|
|
<img :src="treeCollapseIcon" alt="">
|
|
|
</button>
|
|
|
|
|
|
<main class="monitor-workspace">
|
|
|
<div v-if="!embed" 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 v-if="!embed" 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="embed ? embedControlVisible : controlPanelExpanded"
|
|
|
class="control-panel"
|
|
|
:style="embedControlStyle"
|
|
|
@mousedown="startEmbedPanelDrag('control', $event)"
|
|
|
>
|
|
|
<div class="control-panel-body">
|
|
|
<section v-if="embed" class="control-section embed-layout-section">
|
|
|
<div class="section-title"><span>画面布局</span></div>
|
|
|
<div class="embed-layout-switch" aria-label="画面布局">
|
|
|
<button
|
|
|
v-for="item in layouts"
|
|
|
:key="item.value"
|
|
|
type="button"
|
|
|
:class="{ active: layout === item.value }"
|
|
|
:title="item.label"
|
|
|
:aria-label="item.label"
|
|
|
@click="changeLayout(item.value)"
|
|
|
>
|
|
|
<span
|
|
|
class="embed-layout-icon"
|
|
|
:class="`embed-layout-icon-${item.columns}`"
|
|
|
:style="{ gridTemplateColumns: `repeat(${item.columns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${item.columns}, minmax(0, 1fr))` }"
|
|
|
>
|
|
|
<i v-for="cell in item.value" :key="cell" />
|
|
|
</span>
|
|
|
</button>
|
|
|
</div>
|
|
|
</section>
|
|
|
<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 v-if="!embed" 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" :disabled="wiperRunning" @click="wipeOnce">
|
|
|
<i :class="wiperRunning ? 'el-icon-loading' : 'el-icon-umbrella'" />{{ wiperRunning ? '擦拭中' : '雨刷器' }}
|
|
|
</button>
|
|
|
<button type="button" @click="showPendingOperation('3D定位')"><i class="el-icon-aim" />3D定位</button>
|
|
|
</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
<button
|
|
|
type="button"
|
|
|
class="embed-toggle embed-toggle-right"
|
|
|
:class="{ 'is-page': !embed }"
|
|
|
title="隐藏云台控制"
|
|
|
@mousedown.stop
|
|
|
@click.stop="toggleControlPanel(false)"
|
|
|
>
|
|
|
<img :src="treeToggleIcon" alt="">
|
|
|
</button>
|
|
|
</aside>
|
|
|
<button
|
|
|
v-if="!(embed ? embedControlVisible : controlPanelExpanded)"
|
|
|
type="button"
|
|
|
class="embed-toggle embed-toggle-right is-collapsed"
|
|
|
title="显示云台控制"
|
|
|
@click="toggleControlPanel(true)"
|
|
|
>
|
|
|
<img :src="treeCollapseIcon" alt="">
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
import NvrDeviceTree from './components/NvrDeviceTree.vue'
|
|
|
import NvrStreamPlayer from './components/NvrStreamPlayer.vue'
|
|
|
import treeToggleIcon from '@/assets/portal/tree-toggle.png'
|
|
|
import treeCollapseIcon from '@/assets/portal/tree-collapse.png'
|
|
|
import {
|
|
|
getNvrConfig, startLive, getLiveStatus, stopLive, controlPtz,
|
|
|
getPresetList, createPreset, createSnapshot, downloadSnapshot, controlWiper
|
|
|
} from '@/api/nvrVideo'
|
|
|
import { startRealtimeVideo, stopRealtimeVideo, aggPtzControl, aggPresetList, aggPresetCreate } from '@/api/portal'
|
|
|
|
|
|
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 },
|
|
|
props: {
|
|
|
embed: { type: Boolean, default: false },
|
|
|
// 嵌入页先出画面,token 就绪后再拉设备和视频
|
|
|
sessionReady: { type: Boolean, default: true },
|
|
|
// 地址参数:只看某一个场站,空字符串为全部场站
|
|
|
embedStationCode: { type: String, default: '' },
|
|
|
// 地址参数:导航树默认展开
|
|
|
embedTreeExpanded: { type: Boolean, default: false },
|
|
|
// 地址参数:云台控制面板默认展开
|
|
|
embedControlExpanded: { type: Boolean, default: false },
|
|
|
// 地址参数:默认宫格,1 / 4 / 9 / 16
|
|
|
embedLayout: { type: Number, default: 9 }
|
|
|
},
|
|
|
data() {
|
|
|
return {
|
|
|
config: {},
|
|
|
channels: [],
|
|
|
treeChannelsLoaded: false,
|
|
|
treeChannelCodes: new Set(),
|
|
|
controlPanelExpanded: false,
|
|
|
embedTreeVisible: false,
|
|
|
embedControlVisible: false,
|
|
|
embedPanelDrag: {
|
|
|
tree: { x: 0, y: 0, moved: false },
|
|
|
control: { x: 0, y: 0, moved: false }
|
|
|
},
|
|
|
embedDragState: null,
|
|
|
treeToggleIcon,
|
|
|
treeCollapseIcon,
|
|
|
treePanelWidth: 242,
|
|
|
controlPanelWidth: 242,
|
|
|
isTreeResizing: false,
|
|
|
treeResizeStartX: 0,
|
|
|
treeResizeStartWidth: 0,
|
|
|
streamsInitialized: false,
|
|
|
pageActive: true,
|
|
|
lifecycleId: 0,
|
|
|
streamCleanupPromise: null,
|
|
|
selectedCode: '',
|
|
|
layout: 9,
|
|
|
fullscreenIndex: -1,
|
|
|
layouts: [
|
|
|
{ value: 1, columns: 1, label: '单画面' }, { value: 4, columns: 2, label: '四画面' },
|
|
|
{ value: 9, columns: 3, label: '九画面' }, { value: 16, columns: 4, label: '十六画面' }
|
|
|
],
|
|
|
slots: Array.from({ length: 16 }, () => null),
|
|
|
// 实际开过的视频,按开播顺序记录。宫格变少时从末尾往前留。
|
|
|
playbackHistory: [],
|
|
|
activeIndex: 0,
|
|
|
controlTarget: null,
|
|
|
placementCursor: 0,
|
|
|
loading: false,
|
|
|
connectionText: '读取配置',
|
|
|
connectionState: '',
|
|
|
ptzSpeed: 4,
|
|
|
ptzActive: false,
|
|
|
ptzCameraCode: '',
|
|
|
ptzRepeatTimer: null,
|
|
|
wiperRunning: 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: {
|
|
|
// 嵌入页,以及从汇聚门户进入的动环巡检,走聚合视频接口;直接打开的站端走单站接口。
|
|
|
isAggMode() {
|
|
|
const path = this.$route && this.$route.path
|
|
|
if (path === '/video-preview-embed') return true
|
|
|
return this.$store.getters.fromPortal
|
|
|
},
|
|
|
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 },
|
|
|
// 单画面用主码流,4/9/16 宫格用辅码流,减轻多路视频卡顿
|
|
|
streamType() { return this.layout === 1 ? 1 : 2 },
|
|
|
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}`) : '未选择画面' },
|
|
|
embedTreeStyle() {
|
|
|
if (!this.embed) return { width: this.treePanelWidth + 'px' }
|
|
|
const drag = this.embedPanelDrag.tree
|
|
|
return drag.moved
|
|
|
? { width: this.treePanelWidth + 'px', left: drag.x + 'px' }
|
|
|
: { width: this.treePanelWidth + 'px' }
|
|
|
},
|
|
|
embedControlStyle() {
|
|
|
const width = { width: this.controlPanelWidth + 'px' }
|
|
|
const drag = this.embedPanelDrag.control
|
|
|
return drag.moved ? { ...width, left: drag.x + 'px', right: 'auto' } : width
|
|
|
}
|
|
|
},
|
|
|
watch: {
|
|
|
sessionReady(ready) {
|
|
|
if (ready && this.embed) this.loadConfig()
|
|
|
}
|
|
|
},
|
|
|
created() {
|
|
|
if (!this.embed) {
|
|
|
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)
|
|
|
}
|
|
|
document.addEventListener('fullscreenchange', this.handleFullscreenChange)
|
|
|
if (this.embed) {
|
|
|
this.layout = this.normalizeLayout(this.embedLayout)
|
|
|
this.embedTreeVisible = this.embedTreeExpanded
|
|
|
this.embedControlVisible = this.embedControlExpanded
|
|
|
}
|
|
|
if (!this.embed || this.sessionReady) this.loadConfig()
|
|
|
},
|
|
|
async activated() {
|
|
|
if (this.pageActive) return
|
|
|
const lifecycleId = ++this.lifecycleId
|
|
|
if (this.streamCleanupPromise) await this.streamCleanupPromise
|
|
|
if (lifecycleId !== this.lifecycleId) return
|
|
|
this.pageActive = true
|
|
|
this.layout = 9
|
|
|
this.$root.$emit('video-layout-updated', this.layout)
|
|
|
if (this.channels.length) {
|
|
|
this.streamsInitialized = true
|
|
|
this.populateDefaultLayout()
|
|
|
}
|
|
|
},
|
|
|
deactivated() {
|
|
|
this.pauseStreams()
|
|
|
},
|
|
|
beforeDestroy() {
|
|
|
this.stopTreeResize()
|
|
|
this.stopEmbedPanelDrag()
|
|
|
this.lifecycleId += 1
|
|
|
this.pageActive = false
|
|
|
document.removeEventListener('fullscreenchange', this.handleFullscreenChange)
|
|
|
if (!this.embed) {
|
|
|
this.$root.$off('video-layout-change', this.changeLayout)
|
|
|
this.$root.$off('video-control-panel-change', this.changeControlPanel)
|
|
|
}
|
|
|
this.closeAll()
|
|
|
},
|
|
|
methods: {
|
|
|
startTreeResize(event) {
|
|
|
if (event.button !== 0) return
|
|
|
this.isTreeResizing = true
|
|
|
this.treeResizeStartX = event.clientX
|
|
|
this.treeResizeStartWidth = this.treePanelWidth
|
|
|
document.body.style.cursor = 'col-resize'
|
|
|
document.body.style.userSelect = 'none'
|
|
|
document.addEventListener('mousemove', this.onTreeResize)
|
|
|
document.addEventListener('mouseup', this.stopTreeResize)
|
|
|
},
|
|
|
onTreeResize(event) {
|
|
|
if (!this.isTreeResizing) return
|
|
|
const width = this.treeResizeStartWidth + event.clientX - this.treeResizeStartX
|
|
|
this.treePanelWidth = Math.max(218, Math.min(620, width))
|
|
|
},
|
|
|
stopTreeResize() {
|
|
|
if (!this.isTreeResizing) return
|
|
|
this.isTreeResizing = false
|
|
|
document.removeEventListener('mousemove', this.onTreeResize)
|
|
|
document.removeEventListener('mouseup', this.stopTreeResize)
|
|
|
document.body.style.cursor = ''
|
|
|
document.body.style.userSelect = ''
|
|
|
},
|
|
|
startEmbedPanelDrag(panel, event) {
|
|
|
if (event.button !== 0) return
|
|
|
if (!this.embed && panel !== 'control') return
|
|
|
if (event.target.closest('button, a, input, textarea, .el-input, .el-select, .el-input-number, .el-tree-node, .tree-content, .embed-toggle')) return
|
|
|
const bounds = event.currentTarget.getBoundingClientRect()
|
|
|
const parent = event.currentTarget.offsetParent
|
|
|
? event.currentTarget.offsetParent.getBoundingClientRect()
|
|
|
: { left: 0, top: 0 }
|
|
|
this.embedDragState = {
|
|
|
panel,
|
|
|
startX: event.clientX,
|
|
|
startY: event.clientY,
|
|
|
originX: bounds.left - parent.left,
|
|
|
originY: bounds.top - parent.top
|
|
|
}
|
|
|
window.getSelection && window.getSelection().removeAllRanges()
|
|
|
document.body.style.userSelect = 'none'
|
|
|
document.addEventListener('mousemove', this.onEmbedPanelDrag)
|
|
|
document.addEventListener('mouseup', this.stopEmbedPanelDrag)
|
|
|
},
|
|
|
onEmbedPanelDrag(event) {
|
|
|
const drag = this.embedDragState
|
|
|
if (!drag) return
|
|
|
const panel = this.embedPanelDrag[drag.panel]
|
|
|
const parent = this.$el.querySelector('.console-body')
|
|
|
const box = parent ? parent.getBoundingClientRect() : { width: window.innerWidth, height: window.innerHeight }
|
|
|
const node = parent ? parent.querySelector(drag.panel === 'tree' ? '.tree-panel' : '.control-panel') : null
|
|
|
const width = node ? node.offsetWidth : 0
|
|
|
panel.x = Math.max(0, Math.min(box.width - width, drag.originX + event.clientX - drag.startX))
|
|
|
panel.moved = true
|
|
|
},
|
|
|
stopEmbedPanelDrag() {
|
|
|
if (!this.embedDragState) return
|
|
|
this.embedDragState = null
|
|
|
document.body.style.userSelect = ''
|
|
|
window.getSelection && window.getSelection().removeAllRanges()
|
|
|
document.removeEventListener('mousemove', this.onEmbedPanelDrag)
|
|
|
document.removeEventListener('mouseup', this.stopEmbedPanelDrag)
|
|
|
},
|
|
|
async loadConfig() {
|
|
|
this.loading = true
|
|
|
this.connectionText = '读取配置'
|
|
|
try {
|
|
|
if (this.isAggMode) {
|
|
|
// 汇聚模式没有单站 NVR 配置,树由聚合接口提供
|
|
|
this.config = {}
|
|
|
this.connectionText = '汇聚模式'
|
|
|
this.connectionState = 'online'
|
|
|
return
|
|
|
}
|
|
|
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.pageActive && !this.streamsInitialized && this.channels.length) {
|
|
|
this.streamsInitialized = true
|
|
|
this.populateDefaultLayout()
|
|
|
}
|
|
|
},
|
|
|
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)
|
|
|
},
|
|
|
isChannelOnline(channel) {
|
|
|
if (!channel) return false
|
|
|
const status = channel.patroldeviceOnlineStatus
|
|
|
if (status === undefined || status === null || status === '') return true
|
|
|
return String(status) === '1'
|
|
|
},
|
|
|
onlineChannels(channels) {
|
|
|
return (channels || []).filter(item => this.isChannelOnline(item))
|
|
|
},
|
|
|
shuffleChannels(channels) {
|
|
|
const list = (channels || []).slice()
|
|
|
for (let index = list.length - 1; index > 0; index -= 1) {
|
|
|
const swapIndex = Math.floor(Math.random() * (index + 1))
|
|
|
const current = list[index]
|
|
|
list[index] = list[swapIndex]
|
|
|
list[swapIndex] = current
|
|
|
}
|
|
|
return list
|
|
|
},
|
|
|
playbackSnapshot(channel) {
|
|
|
if (!channel || !channel.cameraCode) return null
|
|
|
return {
|
|
|
cameraCode: channel.cameraCode,
|
|
|
stationCode: channel.stationCode,
|
|
|
name: channel.name || channel.cameraName,
|
|
|
channel: channel.channel,
|
|
|
channelCode: channel.channelCode,
|
|
|
nvrIp: channel.nvrIp || ''
|
|
|
}
|
|
|
},
|
|
|
rememberPlayback(channel, replacedCode) {
|
|
|
const snapshot = this.playbackSnapshot(channel)
|
|
|
if (!snapshot) return
|
|
|
if (replacedCode && replacedCode !== snapshot.cameraCode) {
|
|
|
const stillPlaying = this.slots.some(slot => slot && slot.cameraCode === replacedCode)
|
|
|
if (!stillPlaying) {
|
|
|
this.playbackHistory = this.playbackHistory.filter(item => item.cameraCode !== replacedCode)
|
|
|
}
|
|
|
}
|
|
|
this.playbackHistory = this.playbackHistory.filter(item => item.cameraCode !== snapshot.cameraCode)
|
|
|
this.playbackHistory.push(snapshot)
|
|
|
},
|
|
|
forgetPlayback(cameraCode) {
|
|
|
if (!cameraCode) return
|
|
|
const stillPlaying = this.slots.some(slot => slot && slot.cameraCode === cameraCode)
|
|
|
if (stillPlaying) return
|
|
|
this.playbackHistory = this.playbackHistory.filter(item => item.cameraCode !== cameraCode)
|
|
|
},
|
|
|
async playChannels(channels, highlightCode) {
|
|
|
await Promise.all(this.slots.map((slot, index) => slot ? this.closeSlot(index, false) : null))
|
|
|
if (!this.pageActive) return
|
|
|
this.playbackHistory = []
|
|
|
this.activeIndex = 0
|
|
|
this.controlTarget = null
|
|
|
for (let index = 0; index < channels.length; index += 1) {
|
|
|
if (!this.pageActive) return
|
|
|
await this.startChannel(channels[index], index)
|
|
|
}
|
|
|
if (highlightCode) {
|
|
|
const highlightIndex = channels.findIndex(item => item && item.cameraCode === highlightCode)
|
|
|
if (highlightIndex >= 0) this.selectSlot(highlightIndex)
|
|
|
}
|
|
|
},
|
|
|
async populateDefaultLayout() {
|
|
|
if (!this.pageActive) return
|
|
|
// 初次打开按宫格数量随机抽在线通道,离线的不进默认画面
|
|
|
const channels = this.shuffleChannels(this.onlineChannels(this.channels)).slice(0, this.capacity)
|
|
|
if (!channels.length) return
|
|
|
await this.playChannels(channels)
|
|
|
},
|
|
|
openChannel(channel) {
|
|
|
this.selectChannel(channel)
|
|
|
this.startChannel(channel)
|
|
|
},
|
|
|
async startChannel(channel, targetIndex = this.activeIndex, attemptedCameraCodes = [], streamType = this.streamType) {
|
|
|
if (!this.pageActive || !channel || !channel.cameraCode) return
|
|
|
const index = targetIndex
|
|
|
const current = this.slots[index]
|
|
|
if (current && current.cameraCode === channel.cameraCode && current.streamType === streamType) {
|
|
|
this.controlTarget = current
|
|
|
this.rememberPlayback(current)
|
|
|
return
|
|
|
}
|
|
|
const replacedCode = current && current.cameraCode
|
|
|
if (this.slots[index]) await this.closeSlot(index, false)
|
|
|
if (!this.pageActive) return
|
|
|
const slot = {
|
|
|
...channel,
|
|
|
loading: true,
|
|
|
streamSource: { hlsUrl: '', flvUrl: '' },
|
|
|
startPromise: null,
|
|
|
muted: true,
|
|
|
errorNotified: false,
|
|
|
attemptedCameraCodes: [...attemptedCameraCodes, channel.cameraCode],
|
|
|
timer: null,
|
|
|
streamType
|
|
|
}
|
|
|
this.$set(this.slots, index, slot)
|
|
|
this.rememberPlayback(channel, replacedCode)
|
|
|
if (index === this.activeIndex) {
|
|
|
this.controlTarget = slot
|
|
|
this.selectedCode = channel.cameraCode
|
|
|
}
|
|
|
this.presets = []
|
|
|
this.presetId = null
|
|
|
try {
|
|
|
// 汇聚模式:拉流走首页同款聚合接口(响应含 hls/flv,applyLiveResult 直接兼容)
|
|
|
slot.startPromise = this.isAggMode
|
|
|
? startRealtimeVideo({ stationCode: channel.stationCode, cameraCode: channel.cameraCode })
|
|
|
: startLive(channel, streamType)
|
|
|
const result = await slot.startPromise
|
|
|
if (!this.pageActive || this.slots[index] !== slot) return
|
|
|
this.applyLiveResult(index, slot, result)
|
|
|
if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) {
|
|
|
if (this.isAggMode) {
|
|
|
slot.loading = false
|
|
|
this.$message.error('视频流启动失败,请稍后重试')
|
|
|
} else {
|
|
|
this.pollLive(index, slot, 0)
|
|
|
}
|
|
|
return
|
|
|
}
|
|
|
if (index === this.activeIndex) this.loadPresets(true)
|
|
|
} catch (error) {
|
|
|
// 切页取消的拉流不是播放失败,不能再弹错误,也不能继续占着画面。
|
|
|
if (!this.pageActive || (error && error.code === 'ERR_CANCELED')) return
|
|
|
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 || ''
|
|
|
slot.streamSource.hlsUrl = payload.hlsUrl || payload.hls || (/\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
|
|
|
slot.streamSource.flvUrl = payload.flvUrl || payload.flv || (/\.flv(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
|
|
|
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)
|
|
|
},
|
|
|
pollLive(index, slot, attempt) {
|
|
|
if (!this.pageActive || this.slots[index] !== slot) return
|
|
|
if (attempt >= 20) {
|
|
|
slot.loading = false
|
|
|
this.$message.error('视频流启动超时,请检查摄像机或流媒体服务')
|
|
|
return
|
|
|
}
|
|
|
slot.timer = setTimeout(async() => {
|
|
|
if (!this.pageActive || this.slots[index] !== slot) return
|
|
|
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
|
|
|
const cameraCode = slot.cameraCode
|
|
|
if (index === this.activeIndex && this.ptzActive) this.stopPtz()
|
|
|
clearTimeout(slot.timer)
|
|
|
this.$set(this.slots, index, null)
|
|
|
this.forgetPlayback(cameraCode)
|
|
|
if (this.controlTarget === slot) this.controlTarget = null
|
|
|
if (index === this.activeIndex) {
|
|
|
this.presets = []
|
|
|
this.presetId = null
|
|
|
}
|
|
|
const startWasPending = slot.loading && slot.startPromise && typeof slot.startPromise.cancel === 'function'
|
|
|
if (startWasPending) {
|
|
|
// 拉流还在排队或卡住时直接取消,不能等它回来再停流,否则浏览器连接一直被占着。
|
|
|
slot.startPromise.cancel()
|
|
|
return
|
|
|
}
|
|
|
if (slot.startPromise) {
|
|
|
try { await slot.startPromise } catch (_) { /* no stream session was created */ }
|
|
|
}
|
|
|
try {
|
|
|
if (this.isAggMode && slot.stationCode) {
|
|
|
await stopRealtimeVideo({ stationCode: slot.stationCode, cameraCode: slot.cameraCode })
|
|
|
} else {
|
|
|
await stopLive(slot.cameraCode)
|
|
|
}
|
|
|
} catch (_) { /* release the UI slot first */ }
|
|
|
if (notify) this.$message.success('视频通道已关闭')
|
|
|
},
|
|
|
closeAll() {
|
|
|
return Promise.all(this.slots.map((slot, index) => slot ? this.closeSlot(index, false) : null))
|
|
|
},
|
|
|
pauseStreams() {
|
|
|
this.lifecycleId += 1
|
|
|
this.pageActive = false
|
|
|
this.streamsInitialized = false
|
|
|
if (this.ptzActive) this.stopPtz()
|
|
|
clearInterval(this.ptzRepeatTimer)
|
|
|
this.ptzRepeatTimer = null
|
|
|
this.streamCleanupPromise = this.closeAll()
|
|
|
},
|
|
|
normalizeLayout(layout) {
|
|
|
const value = Number(layout)
|
|
|
return [1, 4, 9, 16].includes(value) ? value : 9
|
|
|
},
|
|
|
changeLayout(layout) {
|
|
|
const nextLayout = this.normalizeLayout(layout)
|
|
|
if (nextLayout === this.layout) return
|
|
|
const previousCapacity = this.capacity
|
|
|
const highlighted = this.playbackSnapshot(this.slots[this.activeIndex])
|
|
|
const history = this.playbackHistory.map(item => this.playbackSnapshot(item)).filter(Boolean)
|
|
|
const playing = this.slots.slice(0, previousCapacity).map(slot => this.playbackSnapshot(slot))
|
|
|
this.layout = nextLayout
|
|
|
this.$root.$emit('video-layout-updated', layout)
|
|
|
if (nextLayout < previousCapacity) {
|
|
|
this.playReducedLayout(nextLayout, highlighted, history)
|
|
|
return
|
|
|
}
|
|
|
this.expandLayout(playing, nextLayout)
|
|
|
},
|
|
|
playReducedLayout(capacity, highlighted, history) {
|
|
|
let channels
|
|
|
if (capacity === 1) {
|
|
|
// 一宫格只保留当前高亮的那一路
|
|
|
channels = highlighted ? [highlighted] : history.slice(-1)
|
|
|
} else {
|
|
|
// 其余少宫格按开播记录从后往前留,中途换过的画面排在最后
|
|
|
channels = history.slice(-capacity)
|
|
|
}
|
|
|
if (!channels.length) {
|
|
|
this.populateDefaultLayout()
|
|
|
return
|
|
|
}
|
|
|
this.playChannels(channels, highlighted && highlighted.cameraCode)
|
|
|
},
|
|
|
async expandLayout(playing, capacity) {
|
|
|
const kept = playing.filter(Boolean)
|
|
|
const used = new Set(kept.map(item => item.cameraCode))
|
|
|
const extras = this.shuffleChannels(this.onlineChannels(this.channels).filter(item => item && !used.has(item.cameraCode)))
|
|
|
const channels = kept.concat(extras).slice(0, capacity)
|
|
|
await this.playChannels(channels, kept[0] && kept[0].cameraCode)
|
|
|
},
|
|
|
changeControlPanel(expanded) {
|
|
|
this.controlPanelExpanded = Boolean(expanded)
|
|
|
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
|
|
|
},
|
|
|
toggleControlPanel(expanded) {
|
|
|
if (this.embed) {
|
|
|
this.embedControlVisible = expanded
|
|
|
return
|
|
|
}
|
|
|
this.changeControlPanel(expanded)
|
|
|
},
|
|
|
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.pageActive || 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 {
|
|
|
if (this.isAggMode && failedSlot.stationCode) {
|
|
|
await stopRealtimeVideo({ stationCode: failedSlot.stationCode, cameraCode: failedSlot.cameraCode })
|
|
|
} else {
|
|
|
await stopLive(failedSlot.cameraCode)
|
|
|
}
|
|
|
} catch (_) { /* continue switching cameras */ }
|
|
|
if (!this.pageActive) return
|
|
|
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()
|
|
|
},
|
|
|
// 放大(全屏)观看时切主码流,退出全屏后恢复当前布局对应的码流
|
|
|
async handleFullscreenChange() {
|
|
|
const fsElement = document.fullscreenElement
|
|
|
if (fsElement) {
|
|
|
const cards = this.$refs.cameraCards || []
|
|
|
const index = cards.indexOf(fsElement)
|
|
|
if (index >= 0) {
|
|
|
this.fullscreenIndex = index
|
|
|
await this.switchSlotStream(index, 1)
|
|
|
}
|
|
|
} else if (this.fullscreenIndex >= 0) {
|
|
|
const index = this.fullscreenIndex
|
|
|
this.fullscreenIndex = -1
|
|
|
await this.switchSlotStream(index, this.streamType)
|
|
|
}
|
|
|
},
|
|
|
async switchSlotStream(index, streamType) {
|
|
|
const slot = this.slots[index]
|
|
|
if (!this.pageActive || !slot || slot.streamType === streamType) return
|
|
|
await this.closeSlot(index, false)
|
|
|
if (!this.pageActive) return
|
|
|
await this.startChannel(slot, index, [], streamType)
|
|
|
},
|
|
|
openPlayback(slot) {
|
|
|
this.$router.push({ name: 'VideoHistoryPlayback', query: { cameraCode: slot.cameraCode }})
|
|
|
},
|
|
|
async sendPtz(controlCode, controlPara1 = '', controlPara2 = '', cameraCode = '') {
|
|
|
const target = cameraCode
|
|
|
? this.slots.find(slot => slot && slot.cameraCode === cameraCode) || this.controlTarget
|
|
|
: this.controlTarget
|
|
|
const targetCode = (target && target.cameraCode) || cameraCode
|
|
|
if (!targetCode) { this.$message.warning('请先选择有视频的画面'); return }
|
|
|
let result
|
|
|
if (this.isAggMode) {
|
|
|
// 汇聚模式:stationCode 取当前控制摄像头所属的站(而非右上角站名筛选)
|
|
|
const stationCode = target && target.stationCode
|
|
|
if (!stationCode) { this.$message.warning('未获取到该摄像头所属站点信息'); return }
|
|
|
result = await aggPtzControl({ stationCode, cameraCode: targetCode, controlCode, controlPara1, controlPara2 })
|
|
|
} else {
|
|
|
result = await controlPtz({ cameraCode: targetCode, controlCode, controlPara1, controlPara2 })
|
|
|
}
|
|
|
// 兼容两种响应:聚合代理可能包一层 data;resultCode 非 0 视为失败
|
|
|
const body = result && Object.prototype.hasOwnProperty.call(result, 'data') && result.data ? result.data : result
|
|
|
if (body && body.resultCode !== undefined && Number(body.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 {
|
|
|
let result
|
|
|
if (this.isAggMode) {
|
|
|
// 汇聚模式:stationCode 取当前摄像头所属的站
|
|
|
const stationCode = this.controlTarget && this.controlTarget.stationCode
|
|
|
if (!stationCode) { this.$message.warning('未获取到该摄像头所属站点信息'); return }
|
|
|
result = await aggPresetList({ stationCode, cameraCode })
|
|
|
} else {
|
|
|
result = await getPresetList(cameraCode)
|
|
|
}
|
|
|
// 兼容聚合代理包一层 data 的响应
|
|
|
result = result && Object.prototype.hasOwnProperty.call(result, 'data') && result.data ? result.data : result
|
|
|
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}` : ''}?`, '保存预置位')
|
|
|
if (this.isAggMode) {
|
|
|
// 汇聚模式:保存走代理,stationCode 取该摄像头所属的站
|
|
|
if (!channel.stationCode) { this.$message.warning('未获取到该摄像头所属站点信息'); return }
|
|
|
await aggPresetCreate({
|
|
|
stationCode: channel.stationCode,
|
|
|
cameraCode: channel.cameraCode,
|
|
|
nvrIp: channel.nvrIp,
|
|
|
channel: Number(channel.channel),
|
|
|
preset: this.newPresetId,
|
|
|
presetName: this.newPresetName || null
|
|
|
})
|
|
|
} else {
|
|
|
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))
|
|
|
},
|
|
|
getIvsIdentity() {
|
|
|
const firstValue = value => Array.isArray(value) ? value[0] : value
|
|
|
const query = this.$route.query || {}
|
|
|
const username = firstValue(query.username) || sessionStorage.getItem('username') || this.$store.getters.name
|
|
|
const joinbrightToken = firstValue(query['joinbright-token']) || firstValue(query.token) || sessionStorage.getItem('joinbright-token')
|
|
|
return { username: username || undefined, joinbrightToken: joinbrightToken || undefined }
|
|
|
},
|
|
|
async sendWiperCommand(cameraCode, controlCode) {
|
|
|
try {
|
|
|
const result = await controlWiper({
|
|
|
cameraCode,
|
|
|
controlCode,
|
|
|
controlPara1: '1',
|
|
|
controlPara2: '',
|
|
|
...this.getIvsIdentity()
|
|
|
})
|
|
|
if (!result || typeof result !== 'object') {
|
|
|
const error = new Error('雨刷器接口响应无效')
|
|
|
error.resultUncertain = true
|
|
|
throw error
|
|
|
}
|
|
|
if (Number(result.resultCode) !== 0) {
|
|
|
const error = new Error(result.message || '雨刷器控制失败')
|
|
|
error.resultUncertain = false
|
|
|
throw error
|
|
|
}
|
|
|
return result
|
|
|
} catch (error) {
|
|
|
if (typeof error.resultUncertain !== 'boolean') {
|
|
|
const status = error.response && error.response.status
|
|
|
error.resultUncertain = !status || status >= 500
|
|
|
}
|
|
|
throw error
|
|
|
}
|
|
|
},
|
|
|
wiperErrorMessage(error) {
|
|
|
return (error.response && error.response.data && error.response.data.message) || error.message || '未知错误'
|
|
|
},
|
|
|
async wipeOnce() {
|
|
|
const cameraCode = this.controlTarget && this.controlTarget.cameraCode
|
|
|
if (!cameraCode) { this.$message.warning('请先选择有视频的画面'); return }
|
|
|
if (this.wiperRunning) return
|
|
|
|
|
|
this.wiperRunning = true
|
|
|
let opened = false
|
|
|
let openError = null
|
|
|
let closeError = null
|
|
|
try {
|
|
|
try {
|
|
|
await this.sendWiperCommand(cameraCode, 27)
|
|
|
opened = true
|
|
|
await new Promise(resolve => setTimeout(resolve, 5000))
|
|
|
} catch (error) {
|
|
|
openError = error
|
|
|
}
|
|
|
|
|
|
if (opened || (openError && openError.resultUncertain !== false)) {
|
|
|
try {
|
|
|
await this.sendWiperCommand(cameraCode, 28)
|
|
|
} catch (error) {
|
|
|
closeError = error
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (closeError) {
|
|
|
this.$message({
|
|
|
type: 'error',
|
|
|
message: `雨刷器关闭失败,请立即人工停止:${this.wiperErrorMessage(closeError)}`,
|
|
|
duration: 0,
|
|
|
showClose: true
|
|
|
})
|
|
|
} else if (openError) {
|
|
|
const prefix = openError.resultUncertain === false ? '雨刷器启动失败' : '雨刷器启动结果不确定,已尝试发送关闭命令'
|
|
|
this.$message.error(`${prefix}:${this.wiperErrorMessage(openError)}`)
|
|
|
} else {
|
|
|
this.$message.success('雨刷器擦拭完成')
|
|
|
}
|
|
|
} finally {
|
|
|
this.wiperRunning = 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 { position: relative; flex: 1; min-height: 0; display: flex; gap: 10px; }
|
|
|
.tree-panel { position: relative; flex: 0 0 auto; width: 242px; min-width: 242px; max-width: 620px; height: 100%; }
|
|
|
.tree-panel .nvr-tree { width: 100%; min-width: 0; max-width: 100%; }
|
|
|
.tree-resize-handle { position: absolute; z-index: 3; top: 0; right: -6px; width: 12px; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; cursor: col-resize; }
|
|
|
.tree-resize-handle span { display: block; width: 3px; height: 3px; border-radius: 50%; background: #4b9eff; box-shadow: 0 0 4px rgba(75,158,255,.8); }
|
|
|
.tree-resize-handle:hover span, .tree-resize-handle.resizing span { background: #54ffff; }
|
|
|
.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; user-select: 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%); user-select: none; }.empty-slot i { font-size: 28px; }.empty-slot span { font-size: 11px; }
|
|
|
.control-panel { position: absolute; top: 0; right: 0; bottom: 0; z-index: 4; width: 242px; min-width: 242px; height: auto; margin: 0; overflow: visible; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: rgba(7, 24, 78, .82); box-sizing: border-box; cursor: move; user-select: none; }
|
|
|
.control-panel-body { height: 100%; padding: 10px; overflow-x: hidden; overflow-y: auto; 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 button:disabled { color: #6385aa; background: #081f4d; cursor: not-allowed; }
|
|
|
.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) { .video-console:not(.is-embed) .control-panel { width: 218px; min-width: 218px; } .video-console:not(.is-embed) .tree-panel { width: 218px; min-width: 218px; } .tree-panel .nvr-tree { width: 100%; } }
|
|
|
.is-embed .console-body { position: relative; }
|
|
|
.is-embed .monitor-workspace { position: absolute; inset: 0; z-index: 1; }
|
|
|
.is-embed .tree-panel,
|
|
|
.is-embed .control-panel { position: absolute; top: 0; bottom: 0; z-index: 4; overflow: visible; background: rgba(7, 24, 78, .8); }
|
|
|
.is-embed .tree-panel { left: 0; }
|
|
|
.is-embed .tree-panel .nvr-tree { background: transparent; }
|
|
|
.is-embed .control-panel { right: 0; }
|
|
|
.is-embed .tree-panel button,
|
|
|
.is-embed .tree-panel input,
|
|
|
.is-embed .tree-panel .tree-content,
|
|
|
.control-panel button,
|
|
|
.control-panel input,
|
|
|
.control-panel .el-input,
|
|
|
.control-panel .el-select,
|
|
|
.is-embed .tree-panel button,
|
|
|
.is-embed .tree-panel input,
|
|
|
.is-embed .tree-panel .tree-content { cursor: auto; }
|
|
|
.embed-toggle { position: absolute; top: 50%; z-index: 6; width: 16px; height: 70px; padding: 0; border: 0; outline: 0; background: transparent; cursor: pointer; transform: translateY(-50%); }
|
|
|
.embed-toggle img { display: block; width: 100%; height: 100%; object-fit: fill; }
|
|
|
.embed-toggle:hover img, .embed-toggle:focus img { filter: brightness(1.2) drop-shadow(0 0 6px rgba(32, 218, 255, .65)); }
|
|
|
.embed-toggle-left { right: -17px; left: auto; transform: translateY(-50%) rotate(180deg); }
|
|
|
.embed-toggle-left.is-collapsed { left: 0; right: auto; transform: translateY(-50%) rotate(180deg); }
|
|
|
.embed-toggle-right { left: -17px; right: auto; transform: translateY(-50%); }
|
|
|
.embed-toggle-right.is-page { left: -16px; }
|
|
|
.embed-toggle-right.is-collapsed { right: 0; left: auto; transform: translateY(-50%); }
|
|
|
.embed-layout-switch { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
|
|
.embed-layout-switch button { height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; border: 1px solid rgba(39,135,237,.55); color: #7eb6ef; background: rgba(10,43,103,.55); cursor: pointer; }
|
|
|
.embed-layout-switch button:hover, .embed-layout-switch button.active { color: #54ffff; border-color: #54ffff; background: rgba(23,107,194,.72); }
|
|
|
.embed-layout-icon { display: grid; width: 16px; height: 16px; gap: 1px; box-sizing: border-box; }
|
|
|
.embed-layout-icon i { width: auto; height: auto; border: 1px solid currentColor; box-sizing: border-box; }
|
|
|
</style>
|