|
|
@ -6,6 +6,7 @@ |
|
|
:channels="channels" |
|
|
:channels="channels" |
|
|
:selected-code="selectedCode" |
|
|
:selected-code="selectedCode" |
|
|
:loading="loading" |
|
|
:loading="loading" |
|
|
|
|
|
@channels-loaded="handleTreeChannels" |
|
|
@select="selectChannel" |
|
|
@select="selectChannel" |
|
|
@open="openChannel" |
|
|
@open="openChannel" |
|
|
/> |
|
|
/> |
|
|
@ -49,7 +50,7 @@ |
|
|
</div> |
|
|
</div> |
|
|
</main> |
|
|
</main> |
|
|
|
|
|
|
|
|
<aside class="control-panel"> |
|
|
|
|
|
|
|
|
<aside v-show="controlPanelExpanded" class="control-panel"> |
|
|
<section class="control-section"> |
|
|
<section class="control-section"> |
|
|
<div class="section-title"><span>云台控制</span><small>{{ selectedChannelName }}</small></div> |
|
|
<div class="section-title"><span>云台控制</span><small>{{ selectedChannelName }}</small></div> |
|
|
<div class="ptz-grid"> |
|
|
<div class="ptz-grid"> |
|
|
@ -171,6 +172,10 @@ export default { |
|
|
return { |
|
|
return { |
|
|
config: {}, |
|
|
config: {}, |
|
|
channels: [], |
|
|
channels: [], |
|
|
|
|
|
treeChannelsLoaded: false, |
|
|
|
|
|
treeChannelCodes: new Set(), |
|
|
|
|
|
controlPanelExpanded: false, |
|
|
|
|
|
streamsInitialized: false, |
|
|
selectedCode: '', |
|
|
selectedCode: '', |
|
|
layout: 9, |
|
|
layout: 9, |
|
|
layouts: [ |
|
|
layouts: [ |
|
|
@ -187,6 +192,7 @@ export default { |
|
|
ptzSpeed: 4, |
|
|
ptzSpeed: 4, |
|
|
ptzActive: false, |
|
|
ptzActive: false, |
|
|
ptzCameraCode: '', |
|
|
ptzCameraCode: '', |
|
|
|
|
|
ptzRepeatTimer: null, |
|
|
presets: [], |
|
|
presets: [], |
|
|
presetId: null, |
|
|
presetId: null, |
|
|
newPresetId: null, |
|
|
newPresetId: null, |
|
|
@ -240,11 +246,14 @@ export default { |
|
|
}, |
|
|
}, |
|
|
created() { |
|
|
created() { |
|
|
this.$root.$on('video-layout-change', this.changeLayout) |
|
|
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-layout-updated', this.layout) |
|
|
|
|
|
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded) |
|
|
this.loadConfig() |
|
|
this.loadConfig() |
|
|
}, |
|
|
}, |
|
|
beforeDestroy() { |
|
|
beforeDestroy() { |
|
|
this.$root.$off('video-layout-change', this.changeLayout) |
|
|
this.$root.$off('video-layout-change', this.changeLayout) |
|
|
|
|
|
this.$root.$off('video-control-panel-change', this.changeControlPanel) |
|
|
this.closeAll() |
|
|
this.closeAll() |
|
|
}, |
|
|
}, |
|
|
methods: { |
|
|
methods: { |
|
|
@ -254,9 +263,12 @@ export default { |
|
|
try { |
|
|
try { |
|
|
const config = await getNvrConfig() |
|
|
const config = await getNvrConfig() |
|
|
this.config = config || {} |
|
|
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.connectionText = '设备服务在线' |
|
|
this.connectionState = 'online' |
|
|
this.connectionState = 'online' |
|
|
} catch (error) { |
|
|
} catch (error) { |
|
|
@ -272,13 +284,79 @@ export default { |
|
|
} |
|
|
} |
|
|
this.selectedCode = channel.cameraCode |
|
|
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) { |
|
|
openChannel(channel) { |
|
|
this.selectChannel(channel) |
|
|
this.selectChannel(channel) |
|
|
this.startChannel(channel) |
|
|
this.startChannel(channel) |
|
|
}, |
|
|
}, |
|
|
async startChannel(channel) { |
|
|
|
|
|
|
|
|
async startChannel(channel, targetIndex = this.activeIndex, attemptedCameraCodes = []) { |
|
|
if (!channel || !channel.cameraCode) return |
|
|
if (!channel || !channel.cameraCode) return |
|
|
const index = this.activeIndex |
|
|
|
|
|
|
|
|
const index = targetIndex |
|
|
const current = this.slots[index] |
|
|
const current = this.slots[index] |
|
|
if (current && current.cameraCode === channel.cameraCode) { |
|
|
if (current && current.cameraCode === channel.cameraCode) { |
|
|
this.controlTarget = current |
|
|
this.controlTarget = current |
|
|
@ -291,11 +369,14 @@ export default { |
|
|
streamSource: { hlsUrl: '', flvUrl: '' }, |
|
|
streamSource: { hlsUrl: '', flvUrl: '' }, |
|
|
muted: true, |
|
|
muted: true, |
|
|
errorNotified: false, |
|
|
errorNotified: false, |
|
|
|
|
|
attemptedCameraCodes: [...attemptedCameraCodes, channel.cameraCode], |
|
|
timer: null |
|
|
timer: null |
|
|
} |
|
|
} |
|
|
this.$set(this.slots, index, slot) |
|
|
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.presets = [] |
|
|
this.presetId = null |
|
|
this.presetId = null |
|
|
try { |
|
|
try { |
|
|
@ -303,9 +384,10 @@ export default { |
|
|
if (this.slots[index] !== slot) return |
|
|
if (this.slots[index] !== slot) return |
|
|
this.applyLiveResult(index, slot, result) |
|
|
this.applyLiveResult(index, slot, result) |
|
|
if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) { |
|
|
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) { |
|
|
} catch (error) { |
|
|
if (this.slots[index] === slot) { |
|
|
if (this.slots[index] === slot) { |
|
|
this.$set(this.slots, index, null) |
|
|
this.$set(this.slots, index, null) |
|
|
@ -324,14 +406,7 @@ export default { |
|
|
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl) |
|
|
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl) |
|
|
}, |
|
|
}, |
|
|
replaceStreamHost(url) { |
|
|
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) { |
|
|
pollLive(index, slot, attempt) { |
|
|
if (this.slots[index] !== slot) return |
|
|
if (this.slots[index] !== slot) return |
|
|
@ -369,13 +444,13 @@ export default { |
|
|
}, |
|
|
}, |
|
|
changeLayout(layout) { |
|
|
changeLayout(layout) { |
|
|
if (layout === this.layout) return |
|
|
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.layout = layout |
|
|
this.$root.$emit('video-layout-updated', 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) { |
|
|
selectSlot(index) { |
|
|
if (index === this.activeIndex) { |
|
|
if (index === this.activeIndex) { |
|
|
@ -399,7 +474,30 @@ export default { |
|
|
if (!slot || slot.errorNotified) return |
|
|
if (!slot || slot.errorNotified) return |
|
|
slot.loading = false |
|
|
slot.loading = false |
|
|
slot.errorNotified = true |
|
|
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) { |
|
|
fullscreen(index) { |
|
|
const card = this.$refs.cameraCards && this.$refs.cameraCards[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) |
|
|
const cameraCode = this.ptzCameraCode || (this.controlTarget && this.controlTarget.cameraCode) |
|
|
this.ptzActive = false |
|
|
this.ptzActive = false |
|
|
this.ptzCameraCode = '' |
|
|
this.ptzCameraCode = '' |
|
|
|
|
|
clearInterval(this.ptzRepeatTimer) |
|
|
|
|
|
this.ptzRepeatTimer = null |
|
|
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message)) |
|
|
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message)) |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
@ -427,13 +527,20 @@ export default { |
|
|
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return } |
|
|
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return } |
|
|
this.ptzActive = true |
|
|
this.ptzActive = true |
|
|
this.ptzCameraCode = this.controlTarget.cameraCode |
|
|
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() { |
|
|
stopPtz() { |
|
|
if (!this.ptzActive) return |
|
|
if (!this.ptzActive) return |
|
|
const cameraCode = this.ptzCameraCode |
|
|
const cameraCode = this.ptzCameraCode |
|
|
this.ptzActive = false |
|
|
this.ptzActive = false |
|
|
this.ptzCameraCode = '' |
|
|
this.ptzCameraCode = '' |
|
|
|
|
|
clearInterval(this.ptzRepeatTimer) |
|
|
|
|
|
this.ptzRepeatTimer = null |
|
|
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message)) |
|
|
this.sendPtz(1, '', '', cameraCode).catch(error => this.$message.error(error.message)) |
|
|
}, |
|
|
}, |
|
|
async loadPresets(silent = false) { |
|
|
async loadPresets(silent = false) { |
|
|
|