You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1212 lines
58 KiB

<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>