云台控制{{ selectedChannelName }}
@@ -171,6 +172,10 @@ export default {
return {
config: {},
channels: [],
+ treeChannelsLoaded: false,
+ treeChannelCodes: new Set(),
+ controlPanelExpanded: false,
+ streamsInitialized: false,
selectedCode: '',
layout: 9,
layouts: [
@@ -187,6 +192,7 @@ export default {
ptzSpeed: 4,
ptzActive: false,
ptzCameraCode: '',
+ ptzRepeatTimer: null,
presets: [],
presetId: null,
newPresetId: null,
@@ -240,11 +246,14 @@ export default {
},
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: {
@@ -254,9 +263,12 @@ export default {
try {
const config = await getNvrConfig()
this.config = config || {}
- this.channels = Array.isArray(config.channels) ? config.channels : []
- const selected = this.channels.find(item => item.cameraCode === config.defaultCameraCode) || this.channels[0]
- if (selected) this.selectChannel(selected)
+ // 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) {
@@ -272,13 +284,79 @@ export default {
}
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) {
+ async startChannel(channel, targetIndex = this.activeIndex, attemptedCameraCodes = []) {
if (!channel || !channel.cameraCode) return
- const index = this.activeIndex
+ const index = targetIndex
const current = this.slots[index]
if (current && current.cameraCode === channel.cameraCode) {
this.controlTarget = current
@@ -291,11 +369,14 @@ export default {
streamSource: { hlsUrl: '', flvUrl: '' },
muted: true,
errorNotified: false,
+ attemptedCameraCodes: [...attemptedCameraCodes, channel.cameraCode],
timer: null
}
this.$set(this.slots, index, slot)
- this.controlTarget = slot
- this.selectedCode = channel.cameraCode
+ if (index === this.activeIndex) {
+ this.controlTarget = slot
+ this.selectedCode = channel.cameraCode
+ }
this.presets = []
this.presetId = null
try {
@@ -303,9 +384,10 @@ export default {
if (this.slots[index] !== slot) return
this.applyLiveResult(index, slot, result)
if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) {
- throw new Error('实时视频接口未返回可播放地址')
+ this.pollLive(index, slot, 0)
+ return
}
- this.loadPresets(true)
+ if (index === this.activeIndex) this.loadPresets(true)
} catch (error) {
if (this.slots[index] === slot) {
this.$set(this.slots, index, null)
@@ -324,14 +406,7 @@ export default {
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)
},
replaceStreamHost(url) {
- if (!url || !window.location.hostname) return url
- try {
- const parsed = new URL(url, window.location.origin)
- parsed.hostname = window.location.hostname
- return parsed.toString()
- } catch (_) {
- return url
- }
+ return url
},
pollLive(index, slot, attempt) {
if (this.slots[index] !== slot) return
@@ -369,13 +444,13 @@ export default {
},
changeLayout(layout) {
if (layout === this.layout) return
- if (layout < this.layout) {
- this.slots.forEach((slot, index) => { if (slot && index >= layout) this.closeSlot(index, false) })
- }
this.layout = layout
this.$root.$emit('video-layout-updated', layout)
- if (this.activeIndex >= layout) this.selectSlot(0)
- this.placementCursor %= layout
+ this.populateRandomLayout()
+ },
+ changeControlPanel(expanded) {
+ this.controlPanelExpanded = Boolean(expanded)
+ this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
},
selectSlot(index) {
if (index === this.activeIndex) {
@@ -399,7 +474,30 @@ export default {
if (!slot || slot.errorNotified) return
slot.loading = false
slot.errorNotified = true
- this.$message.error((error && error.message) || '视频播放失败')
+ 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]
@@ -419,6 +517,8 @@ export default {
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
}
@@ -427,13 +527,20 @@ export default {
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return }
this.ptzActive = true
this.ptzCameraCode = this.controlTarget.cameraCode
- this.sendPtz(code, '2', String(this.ptzSpeed), this.ptzCameraCode).catch(error => this.$message.error(error.message))
+ 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) {
diff --git a/src/views/video/components/NvrDeviceTree.vue b/src/views/video/components/NvrDeviceTree.vue
index faf992c..f248924 100644
--- a/src/views/video/components/NvrDeviceTree.vue
+++ b/src/views/video/components/NvrDeviceTree.vue
@@ -51,7 +51,6 @@
没有匹配的设备通道