Browse Source

修改视频的操作,和切换视频按照框的位置

master
douyage 1 month ago
parent
commit
9b72edacdf
3 changed files with 90 additions and 42 deletions
  1. +4
    -4
      src/router/index.js
  2. +81
    -31
      src/views/video/LivePreview.vue
  3. +5
    -7
      src/views/video/components/NvrDeviceTree.vue

+ 4
- 4
src/router/index.js View File

@ -127,7 +127,7 @@ export const constantRoutes = [
{
path: '',
component: Layout,
redirect: 'index',
redirect: '/inspection-workspace/task',
hidden: true,
children: [
{
@ -347,12 +347,12 @@ export const dynamicRoutes = [
meta: { title: '修改生成配置', activeMenu: '/tool/gen' }
}
]
},
}
]
// 防止连续点击多次路由报错
let routerPush = Router.prototype.push;
let routerReplace = Router.prototype.replace;
const routerPush = Router.prototype.push
const routerReplace = Router.prototype.replace
// push
Router.prototype.push = function push(location) {
return routerPush.call(this, location).catch(err => err)


+ 81
- 31
src/views/video/LivePreview.vue View File

@ -22,19 +22,18 @@
ref="cameraCards"
class="camera-card"
:class="{ active: activeIndex === index, empty: !slot }"
@click="activeIndex = index"
@click="selectSlot(index)"
@dblclick="slot && fullscreen(index)"
>
<template v-if="slot">
<nvr-stream-player
:source="slot.streamSource"
:loading="slot.loading"
:muted="muted"
:muted="slot.muted !== false"
@error="handleStreamError($event, index)"
/>
<div class="camera-label">
<span><i />{{ slot.name }}</span>
<small>CH{{ slot.channel }}</small>
</div>
<div class="camera-actions">
<button type="button" title="历史回放" @click.stop="openPlayback(slot)"><i class="el-icon-time" /></button>
@ -138,8 +137,8 @@
<div class="operation-grid">
<button type="button" class="wide" @click="capture"><i class="el-icon-camera" />抓图</button>
<button type="button" @click="showPendingOperation('录像')"><i class="el-icon-video-camera" />录像</button>
<button type="button" :class="{ active: !muted }" @click="muted = !muted">
<i :class="muted ? 'el-icon-turn-off-microphone' : 'el-icon-microphone'" />静音
<button 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>
@ -180,13 +179,14 @@ export default {
],
slots: Array.from({ length: 16 }, () => null),
activeIndex: 0,
controlTarget: null,
placementCursor: 0,
loading: false,
connectionText: '读取配置',
connectionState: '',
muted: true,
ptzSpeed: 4,
ptzActive: false,
ptzCameraCode: '',
presets: [],
presetId: null,
newPresetId: null,
@ -229,12 +229,14 @@ export default {
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.channels.find(item => item.cameraCode === this.selectedCode) || null },
selectedChannelName() { return this.selectedChannel ? (this.selectedChannel.name || `通道 ${this.selectedChannel.channel}`) : '未选择' }
selectedChannel() { return this.controlTarget },
selectedChannelName() { return this.controlTarget ? (this.controlTarget.name || `通道 ${this.controlTarget.channel}`) : '未选择画面' }
},
created() {
this.$root.$on('video-layout-change', this.changeLayout)
@ -269,7 +271,6 @@ export default {
this.channels.push(channel)
}
this.selectedCode = channel.cameraCode
this.loadPresets(true)
},
openChannel(channel) {
this.selectChannel(channel)
@ -277,29 +278,39 @@ export default {
},
async startChannel(channel) {
if (!channel || !channel.cameraCode) return
const existing = this.slots.findIndex(slot => slot && slot.cameraCode === channel.cameraCode)
if (existing >= 0) { this.activeIndex = existing; return }
let index = this.visibleSlots.findIndex(slot => !slot)
if (index < 0) index = this.placementCursor % this.capacity
const index = this.activeIndex
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,
timer: null
}
this.$set(this.slots, index, slot)
this.activeIndex = index
this.placementCursor = (index + 1) % this.capacity
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) {
throw new Error('实时视频接口未返回可播放地址')
}
this.loadPresets(true)
} catch (error) {
this.$set(this.slots, index, null)
this.$message.error(error.message)
if (this.slots[index] === slot) {
this.$set(this.slots, index, null)
this.$message.error(error.message)
}
}
},
applyLiveResult(index, slot, result) {
@ -330,8 +341,14 @@ export default {
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('视频通道已关闭')
},
@ -345,13 +362,32 @@ export default {
}
this.layout = layout
this.$root.$emit('video-layout-updated', layout)
if (this.activeIndex >= layout) this.activeIndex = 0
if (this.activeIndex >= layout) this.selectSlot(0)
this.placementCursor %= layout
},
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.loading = false
this.$message.error(error.message)
if (!slot || slot.errorNotified) return
slot.loading = false
slot.errorNotified = true
this.$message.error((error && error.message) || '视频播放失败')
},
fullscreen(index) {
const card = this.$refs.cameraCards && this.$refs.cameraCards[index]
@ -360,31 +396,40 @@ export default {
openPlayback(slot) {
this.$router.push({ name: 'VideoHistoryPlayback', query: { cameraCode: slot.cameraCode }})
},
async sendPtz(controlCode, controlPara1 = '', controlPara2 = '') {
if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return }
const result = await controlPtz({ cameraCode: this.selectedCode, controlCode, controlPara1, controlPara2 })
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.sendPtz(1).catch(error => this.$message.error(error.message))
this.ptzCameraCode = ''
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.sendPtz(code, '2', String(this.ptzSpeed)).catch(error => this.$message.error(error.message))
this.ptzCameraCode = this.controlTarget.cameraCode
this.sendPtz(code, '2', String(this.ptzSpeed), this.ptzCameraCode).catch(error => this.$message.error(error.message))
},
stopPtz() {
if (!this.ptzActive) return
const cameraCode = this.ptzCameraCode
this.ptzActive = false
this.sendPtz(1).catch(error => this.$message.error(error.message))
this.ptzCameraCode = ''
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message))
},
async loadPresets(silent = false) {
if (!this.selectedCode) return
const cameraCode = this.controlTarget && this.controlTarget.cameraCode
if (!cameraCode) return
try {
const result = await getPresetList(this.selectedCode)
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
@ -425,20 +470,25 @@ export default {
} catch (error) { if (error !== 'cancel' && error !== 'close') this.$message.error(error.message) }
},
async capture() {
if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return }
const cameraCode = this.controlTarget && this.controlTarget.cameraCode
if (!cameraCode) { this.$message.warning('请先选择有视频的画面'); return }
try {
const command = await createSnapshot(this.selectedCode)
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-${this.selectedCode.replace(/[^A-Za-z0-9_-]/g, '_')}-${Date.now()}.jpg`
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}控制接口暂未接入`)
}


+ 5
- 7
src/views/video/components/NvrDeviceTree.vue View File

@ -38,9 +38,8 @@
effect="dark"
>
<span class="custom-tree-node">
<i :class="nodeIcon(data)" />
<span class="node-label">{{ data.label || data.name }}</span>
<i v-if="data.channelFlag" :class="data.patroldeviceOnlineStatus == 1 ? 'online-dot' : 'offline-dot'" />
<i :class="nodeIcon(data)" />
<span class="node-label">{{ data.label || data.name }}</span>
</span>
</el-tooltip>
</el-tree>
@ -57,7 +56,8 @@
import { getAreaEqubookTreeSelect } from '@/api/videosMonitor/index'
function flatten(nodes, result = []) {
;(nodes || []).forEach(node => {
const source = nodes || []
source.forEach(node => {
result.push(node)
if (Array.isArray(node.children)) flatten(node.children, result)
})
@ -136,7 +136,7 @@ export default {
},
nodeIcon(node) {
if (node.channelFlag) return 'el-icon-video-camera'
if (node.patroldeviceType == 13) return 'el-icon-position'
if (node.patroldeviceType === 13) return 'el-icon-position'
if (node.areaFlag === 'Yes') return 'el-icon-location-outline'
return 'el-icon-connection'
}
@ -158,8 +158,6 @@ export default {
.tree-content::-webkit-scrollbar { display: none; }
.custom-tree-node { display: flex; align-items: center; width: 100%; gap: 6px; font-size: 14px; }
.node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.online-dot, .offline-dot { width: 7px; height: 7px; border-radius: 50%; background: #20d47a; }
.offline-dot { background: #f05b68; }
.tree-empty { padding: 40px 0; text-align: center; color: #7298c3; }
.tree-empty i { display: block; margin-bottom: 10px; font-size: 28px; }
.tree-tip { padding: 9px 12px; border-top: 1px solid rgba(41,139,255,.3); color: #6f9ecc; font-size: 11px; }


Loading…
Cancel
Save