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.
 
 
 
 

644 lines
33 KiB

<template>
<div class="video-console live-preview">
<div class="console-body">
<nvr-device-tree
:config="config"
:channels="channels"
:selected-code="selectedCode"
:loading="loading"
@channels-loaded="handleTreeChannels"
@select="selectChannel"
@open="openChannel"
/>
<main class="monitor-workspace">
<div class="monitor-head">
<span>监控画面</span>
<small>双击设备树通道打开单击画面切换控制对象</small>
</div>
<div class="camera-grid" :style="gridStyle">
<article
v-for="(slot, index) in visibleSlots"
:key="index"
ref="cameraCards"
class="camera-card"
:class="{ active: activeIndex === index, empty: !slot }"
@click="selectSlot(index)"
@dblclick="slot && fullscreen(index)"
>
<template v-if="slot">
<nvr-stream-player
:source="slot.streamSource"
:loading="slot.loading"
:muted="slot.muted !== false"
@error="handleStreamError($event, index)"
/>
<div class="camera-label">
<span><i />{{ slot.name }}</span>
</div>
<div class="camera-actions">
<button type="button" title="历史回放" @click.stop="openPlayback(slot)"><i class="el-icon-time" /></button>
<button type="button" title="全屏" @click.stop="fullscreen(index)"><i class="el-icon-full-screen" /></button>
<button type="button" title="关闭" @click.stop="closeSlot(index)"><i class="el-icon-close" /></button>
</div>
</template>
<div v-else class="empty-slot">
<i class="el-icon-video-camera" />
<span>双击左侧通道打开预览</span>
</div>
</article>
</div>
</main>
<aside v-show="controlPanelExpanded" class="control-panel">
<section class="control-section">
<div class="section-title"><span>云台控制</span><small>{{ selectedChannelName }}</small></div>
<div class="ptz-grid">
<button
v-for="button in ptzButtons"
:key="button.action"
type="button"
:title="button.label"
@mousedown.prevent="startPtz(button.action)"
@mouseup.prevent="stopPtz"
@mouseleave="stopPtz"
@touchstart.prevent="startPtz(button.action)"
@touchend.prevent="stopPtz"
>
<i :class="button.icon" />
</button>
</div>
<div class="lens-actions">
<div v-for="tool in lensTools" :key="tool.label" class="lens-tool" :title="tool.label">
<img :src="tool.icon" :alt="tool.label">
<button
type="button"
:aria-label="tool.leftLabel"
:title="tool.leftLabel"
@mousedown.prevent="startPtz(tool.leftAction)"
@mouseup.prevent="stopPtz"
@mouseleave="stopPtz"
@touchstart.prevent="startPtz(tool.leftAction)"
@touchend.prevent="stopPtz"
/>
<button
type="button"
:aria-label="tool.rightLabel"
:title="tool.rightLabel"
@mousedown.prevent="startPtz(tool.rightAction)"
@mouseup.prevent="stopPtz"
@mouseleave="stopPtz"
@touchstart.prevent="startPtz(tool.rightAction)"
@touchend.prevent="stopPtz"
/>
</div>
</div>
<div class="speed-row">
<span>速度</span>
<input v-model.number="ptzSpeed" type="range" min="1" max="7" :style="{ background: `linear-gradient(to right, #2787ed 0%, #2787ed ${((ptzSpeed - 1) / 6) * 100}%, transparent ${((ptzSpeed - 1) / 6) * 100}%, transparent 100%)` }">
<el-input-number
v-model="ptzSpeed"
class="speed-input"
:min="1"
:max="7"
size="mini"
:controls="false"
/>
</div>
<div class="preset-quick-actions">
<button type="button" title="调用预置位" @click="callPreset">
<img :src="presetIcons.call" alt="调用预置位">
</button>
<button type="button" title="新增预置位" @click="prepareNewPreset">
<img :src="presetIcons.add" alt="新增预置位">
</button>
<button type="button" title="编辑预置位" @click="prepareEditPreset">
<img :src="presetIcons.edit" alt="编辑预置位">
</button>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>预置位</span><button type="button" @click="loadPresets"><i class="el-icon-refresh" /></button></div>
<el-select v-model="presetId" size="small" placeholder="请选择预置位" style="width:100%">
<el-option v-for="item in presets" :key="item.id" :label="`${item.id} · ${item.name}`" :value="item.id" />
</el-select>
<div class="preset-create">
<el-input-number v-model="newPresetId" :min="1" :max="255" size="mini" :controls="false" placeholder="编号" />
<el-input v-model.trim="newPresetName" size="mini" maxlength="32" placeholder="名称" />
</div>
<div class="section-actions">
<button type="button" @click="callPreset">调用</button>
<button type="button" class="primary" @click="savePreset">保存</button>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>操作</span></div>
<div class="operation-grid">
<button type="button" class="wide" @click="capture"><i class="el-icon-camera" />抓图</button>
<button type="button" @click="showPendingOperation('录像')"><i class="el-icon-video-camera" />录像</button>
<button type="button" :class="{ active: !activeMuted }" @click="toggleMuted">
<i :class="activeMuted ? 'el-icon-turn-off-microphone' : 'el-icon-microphone'" />静音
</button>
<button type="button" @click="showPendingOperation('雨刷器')"><i class="el-icon-umbrella" />雨刷器</button>
<button type="button" @click="showPendingOperation('3D定位')"><i class="el-icon-aim" />3D定位</button>
</div>
</section>
</aside>
</div>
</div>
</template>
<script>
import NvrDeviceTree from './components/NvrDeviceTree.vue'
import NvrStreamPlayer from './components/NvrStreamPlayer.vue'
import {
getNvrConfig, startLive, getLiveStatus, stopLive, controlPtz,
getPresetList, createPreset, createSnapshot, downloadSnapshot
} from '@/api/nvrVideo'
const PTZ_CODES = {
UP: 2, DOWN: 3, LEFT: 4, UP_LEFT: 5, DOWN_LEFT: 6,
RIGHT: 7, UP_RIGHT: 8, DOWN_RIGHT: 9,
IRIS_OPEN: 21, IRIS_CLOSE: 22,
ZOOM_IN: 23, ZOOM_OUT: 24, FOCUS_NEAR: 25, FOCUS_FAR: 26
}
export default {
name: 'VideoLivePreview',
components: { NvrDeviceTree, NvrStreamPlayer },
data() {
return {
config: {},
channels: [],
treeChannelsLoaded: false,
treeChannelCodes: new Set(),
controlPanelExpanded: false,
streamsInitialized: false,
selectedCode: '',
layout: 9,
layouts: [
{ value: 1, label: '单画面' }, { value: 4, label: '四画面' },
{ value: 9, label: '九画面' }, { value: 16, label: '十六画面' }
],
slots: Array.from({ length: 16 }, () => null),
activeIndex: 0,
controlTarget: null,
placementCursor: 0,
loading: false,
connectionText: '读取配置',
connectionState: '',
ptzSpeed: 4,
ptzActive: false,
ptzCameraCode: '',
ptzRepeatTimer: null,
presets: [],
presetId: null,
newPresetId: null,
newPresetName: '',
presetIcons: {
call: require('@/assets/images/r6.png'),
add: require('@/assets/images/r7.png'),
edit: require('@/assets/images/r8.png')
},
ptzButtons: [
{ action: 'UP_LEFT', icon: 'el-icon-top-left', label: '左上' },
{ action: 'UP', icon: 'el-icon-top', label: '向上' },
{ action: 'UP_RIGHT', icon: 'el-icon-top-right', label: '右上' },
{ action: 'LEFT', icon: 'el-icon-back', label: '向左' },
{ action: 'STOP', icon: 'el-icon-video-pause', label: '停止' },
{ action: 'RIGHT', icon: 'el-icon-right', label: '向右' },
{ action: 'DOWN_LEFT', icon: 'el-icon-bottom-left', label: '左下' },
{ action: 'DOWN', icon: 'el-icon-bottom', label: '向下' },
{ action: 'DOWN_RIGHT', icon: 'el-icon-bottom-right', label: '右下' }
],
lensTools: [
{
label: '变倍', icon: require('@/assets/images/r1.png'),
leftAction: 'ZOOM_OUT', leftLabel: '变倍缩小',
rightAction: 'ZOOM_IN', rightLabel: '变倍放大'
},
{
label: '聚焦', icon: require('@/assets/images/r2.png'),
leftAction: 'FOCUS_FAR', leftLabel: '远聚焦',
rightAction: 'FOCUS_NEAR', rightLabel: '近聚焦'
},
{
label: '光圈', icon: require('@/assets/images/r3.png'),
leftAction: 'IRIS_CLOSE', leftLabel: '关闭光圈',
rightAction: 'IRIS_OPEN', rightLabel: '打开光圈'
}
]
}
},
computed: {
capacity() { return this.layout },
visibleSlots() { return this.slots.slice(0, this.capacity) },
activeSlot() { return this.slots[this.activeIndex] || null },
activeMuted() { return this.controlTarget ? this.controlTarget.muted !== false : true },
gridStyle() {
const columns = { 1: 1, 4: 2, 9: 3, 16: 4 }[this.layout]
return { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${columns}, minmax(0, 1fr))` }
},
selectedChannel() { return this.controlTarget },
selectedChannelName() { return this.controlTarget ? (this.controlTarget.name || `通道 ${this.controlTarget.channel}`) : '未选择画面' }
},
created() {
this.$root.$on('video-layout-change', this.changeLayout)
this.$root.$on('video-control-panel-change', this.changeControlPanel)
this.$root.$emit('video-layout-updated', this.layout)
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
this.loadConfig()
},
beforeDestroy() {
this.$root.$off('video-layout-change', this.changeLayout)
this.$root.$off('video-control-panel-change', this.changeControlPanel)
this.closeAll()
},
methods: {
async loadConfig() {
this.loading = true
this.connectionText = '读取配置'
try {
const config = await getNvrConfig()
this.config = config || {}
// Keep config as metadata only until the device tree has supplied the
// channels that are actually visible/selectable by the user.
const configChannels = Array.isArray(config.channels) ? config.channels : []
this.setAvailableChannels(this.treeChannelsLoaded
? configChannels.filter(channel => channel && this.treeChannelCodes.has(channel.cameraCode))
: configChannels, false)
this.connectionText = '设备服务在线'
this.connectionState = 'online'
} catch (error) {
this.connectionText = '服务不可达'
this.connectionState = 'error'
this.$message.error(error.message)
} finally { this.loading = false }
},
selectChannel(channel) {
if (!channel) return
if (!this.channels.some(item => item.cameraCode === channel.cameraCode)) {
this.channels.push(channel)
}
this.selectedCode = channel.cameraCode
},
setAvailableChannels(channels, initializeStreams = false) {
const knownCodes = new Set(this.channels.map(item => item.cameraCode))
;(channels || []).forEach(channel => {
if (!channel || !channel.cameraCode) return
const channelName = channel.name || channel.label || channel.channelName || channel.cameraName || channel.deviceName || ''
if (knownCodes.has(channel.cameraCode)) {
const existing = this.channels.find(item => item.cameraCode === channel.cameraCode)
if (existing) {
// Device-tree entries contain the authoritative channel metadata
// (NVR address, channel number, display name). Merge it over any
// lightweight config entry with the same camera code.
const existingName = existing.name
Object.assign(existing, channel)
if (this.isCameraCodeName(channelName) && !this.isCameraCodeName(existingName)) {
this.$set(existing, 'name', existingName)
} else if (channelName) {
this.$set(existing, 'name', channelName)
}
}
return
}
knownCodes.add(channel.cameraCode)
this.channels.push({
...channel,
name: channelName || (channel.channelCode && !this.isCameraCodeName(channel.channelCode) ? channel.channelCode : `通道 ${channel.channel || ''}`)
})
})
if (initializeStreams && !this.streamsInitialized && this.channels.length) {
this.streamsInitialized = true
this.populateRandomLayout()
}
},
isCameraCodeName(name) {
if (!name) return true
return /^(camera|cam|channel)[-_ ]?\d+$/i.test(String(name).trim())
},
handleTreeChannels(channels) {
this.treeChannelsLoaded = true
const treeCodes = new Set((channels || []).filter(item => item && item.cameraCode).map(item => item.cameraCode))
this.treeChannelCodes = treeCodes
// Remove config-only channels from the selectable/default pool. They can
// still be returned by the config endpoint, but must not appear in the
// default layout unless present in the device tree.
if (treeCodes.size) this.channels = this.channels.filter(item => treeCodes.has(item.cameraCode))
this.setAvailableChannels(channels, true)
},
shuffleChannels() {
const channels = this.channels.slice()
for (let index = channels.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1))
const current = channels[index]
channels[index] = channels[randomIndex]
channels[randomIndex] = current
}
return channels
},
async populateRandomLayout() {
const channels = this.shuffleChannels().slice(0, this.capacity)
if (!channels.length) return
await Promise.all(this.slots.map((slot, index) => slot ? this.closeSlot(index, false) : null))
this.activeIndex = 0
this.controlTarget = null
for (let index = 0; index < channels.length; index += 1) {
await this.startChannel(channels[index], index)
}
},
openChannel(channel) {
this.selectChannel(channel)
this.startChannel(channel)
},
async startChannel(channel, targetIndex = this.activeIndex, attemptedCameraCodes = []) {
if (!channel || !channel.cameraCode) return
const index = targetIndex
const current = this.slots[index]
if (current && current.cameraCode === channel.cameraCode) {
this.controlTarget = current
return
}
if (this.slots[index]) await this.closeSlot(index, false)
const slot = {
...channel,
loading: true,
streamSource: { hlsUrl: '', flvUrl: '' },
muted: true,
errorNotified: false,
attemptedCameraCodes: [...attemptedCameraCodes, channel.cameraCode],
timer: null
}
this.$set(this.slots, index, slot)
if (index === this.activeIndex) {
this.controlTarget = slot
this.selectedCode = channel.cameraCode
}
this.presets = []
this.presetId = null
try {
const result = await startLive(channel)
if (this.slots[index] !== slot) return
this.applyLiveResult(index, slot, result)
if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) {
this.pollLive(index, slot, 0)
return
}
if (index === this.activeIndex) this.loadPresets(true)
} catch (error) {
if (this.slots[index] === slot) {
this.$set(this.slots, index, null)
this.$message.error(error.message)
}
}
},
applyLiveResult(index, slot, result) {
if (this.slots[index] !== slot || !result) return
const payload = result.data || result
const mediaUrl = payload.url || payload.playUrl || payload.mediaUrl || payload.address || ''
const hlsUrl = payload.hlsUrl || payload.hls || (/\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
const flvUrl = payload.flvUrl || payload.flv || (/\.flv(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
slot.streamSource.hlsUrl = this.replaceStreamHost(hlsUrl)
slot.streamSource.flvUrl = this.replaceStreamHost(flvUrl)
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)
},
replaceStreamHost(url) {
return url
},
pollLive(index, slot, attempt) {
if (this.slots[index] !== slot) return
if (attempt >= 20) {
slot.loading = false
this.$message.error('视频流启动超时,请检查摄像机或流媒体服务')
return
}
slot.timer = setTimeout(async() => {
try {
const result = await getLiveStatus(slot.cameraCode)
this.applyLiveResult(index, slot, result)
if (!result || result.status !== 'PLAYING' || !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)) {
this.pollLive(index, slot, attempt + 1)
}
} catch (_) { this.pollLive(index, slot, attempt + 1) }
}, 800)
},
async closeSlot(index, notify = true) {
const slot = this.slots[index]
if (!slot) return
if (index === this.activeIndex && this.ptzActive) this.stopPtz()
clearTimeout(slot.timer)
this.$set(this.slots, index, null)
if (this.controlTarget === slot) this.controlTarget = null
if (index === this.activeIndex) {
this.presets = []
this.presetId = null
}
try { await stopLive(slot.cameraCode) } catch (_) { /* release the UI slot first */ }
if (notify) this.$message.success('视频通道已关闭')
},
closeAll() {
this.slots.forEach((slot, index) => { if (slot) this.closeSlot(index, false) })
},
changeLayout(layout) {
if (layout === this.layout) return
this.layout = layout
this.$root.$emit('video-layout-updated', layout)
this.populateRandomLayout()
},
changeControlPanel(expanded) {
this.controlPanelExpanded = Boolean(expanded)
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
},
selectSlot(index) {
if (index === this.activeIndex) {
const current = this.slots[index]
this.controlTarget = current || null
if (current) this.selectedCode = current.cameraCode
return
}
if (this.ptzActive) this.stopPtz()
this.activeIndex = index
const slot = this.slots[index]
this.controlTarget = slot || null
this.presets = []
this.presetId = null
if (!slot) return
this.selectedCode = slot.cameraCode
this.loadPresets(true)
},
handleStreamError(error, index) {
const slot = this.slots[index]
if (!slot || slot.errorNotified) return
slot.loading = false
slot.errorNotified = true
this.switchToNextChannel(index, slot, error)
},
async switchToNextChannel(index, failedSlot, error) {
if (this.slots[index] !== failedSlot) return
const attemptedCodes = new Set(failedSlot.attemptedCameraCodes || [failedSlot.cameraCode])
const occupiedCodes = new Set(this.slots
.filter((slot, slotIndex) => slot && slotIndex !== index)
.map(slot => slot.cameraCode))
const currentIndex = this.channels.findIndex(channel => channel.cameraCode === failedSlot.cameraCode)
let nextChannel = null
for (let offset = 1; offset <= this.channels.length; offset += 1) {
const candidate = this.channels[(currentIndex + offset + this.channels.length) % this.channels.length]
if (candidate && !attemptedCodes.has(candidate.cameraCode) && !occupiedCodes.has(candidate.cameraCode)) {
nextChannel = candidate
break
}
}
this.$set(this.slots, index, null)
try { await stopLive(failedSlot.cameraCode) } catch (_) { /* continue switching cameras */ }
if (nextChannel) {
await this.startChannel(nextChannel, index, Array.from(attemptedCodes))
} else {
this.$message.error((error && error.message) || '视频播放失败,暂无其他可用摄像头')
}
},
fullscreen(index) {
const card = this.$refs.cameraCards && this.$refs.cameraCards[index]
if (card && card.requestFullscreen) card.requestFullscreen()
},
openPlayback(slot) {
this.$router.push({ name: 'VideoHistoryPlayback', query: { cameraCode: slot.cameraCode }})
},
async sendPtz(controlCode, controlPara1 = '', controlPara2 = '', cameraCode = '') {
const targetCode = cameraCode || (this.controlTarget && this.controlTarget.cameraCode)
if (!targetCode) { this.$message.warning('请先选择有视频的画面'); return }
const result = await controlPtz({ cameraCode: targetCode, controlCode, controlPara1, controlPara2 })
if (!result || Number(result.resultCode) !== 0) throw new Error('云台控制失败')
},
startPtz(action) {
if (action === 'STOP') {
const cameraCode = this.ptzCameraCode || (this.controlTarget && this.controlTarget.cameraCode)
this.ptzActive = false
this.ptzCameraCode = ''
clearInterval(this.ptzRepeatTimer)
this.ptzRepeatTimer = null
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message))
return
}
const code = PTZ_CODES[action]
if (!code || this.ptzActive) return
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return }
this.ptzActive = true
this.ptzCameraCode = this.controlTarget.cameraCode
const sendMove = () => this.sendPtz(code, '2', String(this.ptzSpeed), this.ptzCameraCode).catch(error => {
if (this.ptzActive) this.$message.error(error.message)
})
sendMove()
clearInterval(this.ptzRepeatTimer)
this.ptzRepeatTimer = setInterval(sendMove, 300)
},
stopPtz() {
if (!this.ptzActive) return
const cameraCode = this.ptzCameraCode
this.ptzActive = false
this.ptzCameraCode = ''
clearInterval(this.ptzRepeatTimer)
this.ptzRepeatTimer = null
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message))
},
async loadPresets(silent = false) {
const cameraCode = this.controlTarget && this.controlTarget.cameraCode
if (!cameraCode) return
try {
const result = await getPresetList(cameraCode)
if (!this.controlTarget || this.controlTarget.cameraCode !== cameraCode) return
const source = result && result.ptzPresetInfoList && result.ptzPresetInfoList.ptzPresetInfo
this.presets = (Array.isArray(source) ? source : []).map(item => ({ id: Number(item.presetIndex), name: item.presetName || `预置位 ${item.presetIndex}` })).filter(item => item.id > 0)
if (this.presets.length && !this.presets.some(item => item.id === this.presetId)) this.presetId = this.presets[0].id
if (!silent) this.$message.success(`读取到 ${this.presets.length} 个预置位`)
} catch (error) { if (!silent) this.$message.error(error.message) }
},
async callPreset() {
if (!this.presetId) { this.$message.warning('请选择预置位'); return }
try { await this.sendPtz(11, String(this.presetId)); this.$message.success('预置位调用成功') } catch (error) { this.$message.error(error.message) }
},
prepareNewPreset() {
const used = new Set(this.presets.map(item => Number(item.id)))
let nextId = 1
while (used.has(nextId) && nextId < 255) nextId += 1
this.newPresetId = nextId
this.newPresetName = ''
this.$message.info(`请填写预置位 ${nextId} 的名称后保存`)
},
prepareEditPreset() {
const preset = this.presets.find(item => item.id === this.presetId)
if (!preset) { this.$message.warning('请先选择需要编辑的预置位'); return }
this.newPresetId = preset.id
this.newPresetName = preset.name || ''
this.$message.info(`正在编辑预置位 ${preset.id}`)
},
async savePreset() {
const channel = this.selectedChannel
if (!channel) { this.$message.warning('请先选择摄像机'); return }
if (!Number.isInteger(this.newPresetId) || this.newPresetId < 1 || this.newPresetId > 255) {
this.$message.warning('请输入 1-256 的预置位编号')
return
}
try {
await this.$confirm(`确认保存预置位 ${this.newPresetId}${this.newPresetName ? ` · ${this.newPresetName}` : ''}`, '保存预置位')
await createPreset({ cameraCode: channel.cameraCode, nvrIp: channel.nvrIp, channel: Number(channel.channel), preset: this.newPresetId, presetName: this.newPresetName || null })
await this.loadPresets(true)
this.$message.success('预置位保存成功')
} catch (error) { if (error !== 'cancel' && error !== 'close') this.$message.error(error.message) }
},
async capture() {
const cameraCode = this.controlTarget && this.controlTarget.cameraCode
if (!cameraCode) { this.$message.warning('请先选择有视频的画面'); return }
try {
const command = await createSnapshot(cameraCode)
if (!command || Number(command.resultCode) !== 0 || !command.taskID) throw new Error('抓图任务创建失败')
const blob = await downloadSnapshot(command.taskID)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `camera-${cameraCode.replace(/[^A-Za-z0-9_-]/g, '_')}-${Date.now()}.jpg`
link.click()
setTimeout(() => URL.revokeObjectURL(url), 2000)
this.$message.success('抓图已下载')
} catch (error) { this.$message.error(error.message) }
},
toggleMuted() {
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return }
this.$set(this.controlTarget, 'muted', !(this.controlTarget.muted !== false))
},
showPendingOperation(name) {
this.$message.info(`${name}控制接口暂未接入`)
}
}
}
</script>
<style lang="scss" scoped>
.video-console { height: 100%; min-height: 0; display: flex; flex-direction: column; color: #dcecff; background: #0b1248; }
.console-body { flex: 1; min-height: 0; display: flex; gap: 10px; }
.monitor-workspace { flex: 1; min-width: 0; height: 100%; display: flex; flex-direction: column; padding: 10px; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; box-sizing: border-box; }
.monitor-head { height: 32px; display: flex; align-items: center; justify-content: space-between; color: #cfe5ff; }.monitor-head small { color: #648fbc; }
.camera-grid { flex: 1; min-height: 0; display: grid; gap: 3px; background: rgba(14, 75, 146, .5); border: 1px solid #176bc2; }
.camera-card { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid transparent; background: #050b24; cursor: pointer; }.camera-card.active { border-color: #54ffff; box-shadow: inset 0 0 0 1px rgba(84,255,255,.35); }
.camera-label { position: absolute; left: 0; right: 0; top: 0; height: 28px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; color: #d8eaff; background: linear-gradient(180deg, rgba(0,10,36,.92), transparent); font-size: 11px; pointer-events: none; }.camera-label i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #24e39f; box-shadow: 0 0 6px #24e39f; }
.camera-actions { position: absolute; right: 5px; bottom: 5px; display: none; gap: 3px; }.camera-card:hover .camera-actions { display: flex; }.camera-actions button { width: 26px; height: 25px; border: 1px solid rgba(66, 167, 255, .55); color: #bfe0ff; background: rgba(4,28,78,.88); cursor: pointer; }
.empty-slot { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #466f9c; background: radial-gradient(circle at center, #0b2a61, #050b24 70%); }.empty-slot i { font-size: 28px; }.empty-slot span { font-size: 11px; }
.control-panel { width: 242px; min-width: 242px; height: 100%; align-self: stretch; margin: 0; padding: 10px; overflow-y: auto; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: rgba(7, 24, 78, .82); box-sizing: border-box; }
.control-section { padding-bottom: 12px; margin-bottom: 12px; border-bottom: 1px solid rgba(39, 128, 220, .28); }.section-title { height: 30px; display: flex; align-items: center; justify-content: space-between; color: #54ffff; }.section-title small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #719bc8; }.section-title button { border: 0; color: #75bfff; background: transparent; cursor: pointer; }
.ptz-grid { width: 168px; margin: 6px auto 12px; display: grid; grid-template-columns: repeat(3, 50px); gap: 9px; }.ptz-grid button { width: 50px; height: 50px; border: 1px solid #3988ff; border-radius: 7px; color: #43c0ff; background: #01016b; box-shadow: none; cursor: pointer; transition: color .15s, background .15s, transform .15s; }.ptz-grid button i { font-size: 18px; font-weight: 700; }.ptz-grid button:hover { color: #43c0ff; background: #01016b; }.ptz-grid button:active { color: #ffffff; background: #01016b; transform: scale(.96); }
.speed-row { display: grid; grid-template-columns: 34px minmax(0, 1fr) 48px; align-items: center; gap: 7px; color: #8eb7df; font-size: 12px; }.speed-row > input[type="range"] { min-width: 0; height: 8px; box-sizing: border-box; margin: 0; appearance: none; border: 1px solid #2787ed; border-radius: 4px; outline: none; cursor: pointer; }.speed-row > input[type="range"]::-webkit-slider-runnable-track { height: 6px; border: 0; border-radius: 3px; background: transparent; }.speed-row > input[type="range"]::-webkit-slider-thumb { width: 14px; height: 14px; margin-top: -4px; appearance: none; border: 0; border-radius: 50%; background: #2787ed; }.speed-row > input[type="range"]::-moz-range-track { height: 6px; border: 0; border-radius: 3px; background: transparent; }.speed-row > input[type="range"]::-moz-range-progress { background: transparent; }.speed-row > input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #2787ed; }.speed-input { width: 48px; }
.preset-quick-actions { display: flex; align-items: center; gap: 7px; margin: 9px 0 0 1px; }.preset-quick-actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 1px solid rgba(48, 151, 248, .5); color: #58bfff; background: transparent; cursor: pointer; }.preset-quick-actions button:hover { background: rgba(31, 126, 222, .25); }.preset-quick-actions img { width: 18px; height: 18px; object-fit: contain; }
.lens-actions { display: flex; align-items: center; justify-content: center; gap: 11px; min-height: 28px; margin: 0 0 10px; }.lens-tool { position: relative; width: 48px; height: 22px; overflow: hidden; }.lens-tool img { display: block; width: 100%; height: 100%; object-fit: fill; }.lens-tool button { position: absolute; top: 0; bottom: 0; width: 50%; padding: 0; border: 0; background: transparent; cursor: pointer; }.lens-tool button:first-of-type { left: 0; }.lens-tool button:last-of-type { right: 0; }.lens-tool button:active { background: rgba(73, 184, 255, .28); }
.operation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 9px; }.operation-grid button, .section-actions button { min-height: 29px; border: 1px solid rgba(47, 145, 241, .5); color: #abd4ff; background: #0b2b67; cursor: pointer; }.operation-grid button i { margin-right: 5px; }.operation-grid button.active, .section-actions button.primary { color: #fff; background: #176bc2; }
.operation-grid .wide { grid-column: 1 / -1; }
::v-deep .el-select .el-input__inner, ::v-deep .preset-create .el-input__inner, ::v-deep .speed-input .el-input__inner { color: #cce6ff; border-color: rgba(52, 147, 241, .5); background: #09265e; }
::v-deep .speed-input .el-input__inner { height: 26px; padding: 0 7px; line-height: 26px; text-align: center; }
::v-deep .speed-input.el-input-number--mini { line-height: 24px; }
::v-deep .preset-create .el-input__inner { height: 28px; padding: 0 8px; line-height: 28px; }
::v-deep .preset-create .el-input-number .el-input__inner { text-align: center; }
::v-deep .preset-create .el-input-number--mini { line-height: 26px; }
.preset-create { display: grid; grid-template-columns: 68px minmax(0, 1fr); align-items: center; gap: 6px; margin-top: 8px; }.preset-create .el-input-number { width: 68px; }.preset-create .el-input { width: 100%; }.section-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
@media (max-width: 1100px) { .control-panel { width: 218px; min-width: 218px; }.nvr-tree { width: 230px; min-width: 230px; } }
</style>