|
|
@ -1,60 +1,80 @@ |
|
|
<template> |
|
|
<template> |
|
|
<aside class="nvr-tree"> |
|
|
<aside class="nvr-tree"> |
|
|
<div class="tree-title"> |
|
|
<div class="tree-title"> |
|
|
<span><i class="el-icon-s-platform" /> 设备通道</span> |
|
|
|
|
|
<strong>{{ filteredCount }}</strong> |
|
|
|
|
|
|
|
|
<span><i class="el-icon-s-platform" /> 设备列表</span> |
|
|
|
|
|
<strong>{{ channelNodes.length }}</strong> |
|
|
</div> |
|
|
</div> |
|
|
<el-input |
|
|
|
|
|
v-model.trim="query" |
|
|
|
|
|
clearable |
|
|
|
|
|
size="small" |
|
|
|
|
|
prefix-icon="el-icon-search" |
|
|
|
|
|
placeholder="搜索通道 / 分组" |
|
|
|
|
|
/> |
|
|
|
|
|
<div v-loading="loading" class="tree-content"> |
|
|
|
|
|
<template v-if="visibleNvrs.length"> |
|
|
|
|
|
<section v-for="nvr in visibleNvrs" :key="nvr.key" class="nvr-node"> |
|
|
|
|
|
<button type="button" class="nvr-row" @click="toggle(nvr.key)"> |
|
|
|
|
|
<i :class="isOpen(nvr.key) ? 'el-icon-arrow-down' : 'el-icon-arrow-right'" /> |
|
|
|
|
|
<i class="el-icon-connection" /> |
|
|
|
|
|
<span>{{ nvr.name }}</span> |
|
|
|
|
|
<small>{{ nvr.channels.length }}</small> |
|
|
|
|
|
</button> |
|
|
|
|
|
<div v-show="isOpen(nvr.key)" class="nvr-groups"> |
|
|
|
|
|
<section v-for="group in nvr.groups" :key="group.key" class="channel-group"> |
|
|
|
|
|
<button type="button" class="group-row" @click="toggle(group.key)"> |
|
|
|
|
|
<i :class="isOpen(group.key) ? 'el-icon-caret-bottom' : 'el-icon-caret-right'" /> |
|
|
|
|
|
<span>{{ group.name }}</span> |
|
|
|
|
|
<small>{{ group.channels.length }}</small> |
|
|
|
|
|
</button> |
|
|
|
|
|
<button |
|
|
|
|
|
v-for="channel in group.channels" |
|
|
|
|
|
v-show="isOpen(group.key)" |
|
|
|
|
|
:key="channel.cameraCode" |
|
|
|
|
|
type="button" |
|
|
|
|
|
class="channel-row" |
|
|
|
|
|
:class="{ active: channel.cameraCode === selectedCode }" |
|
|
|
|
|
@click="$emit('select', channel)" |
|
|
|
|
|
@dblclick="$emit('open', channel)" |
|
|
|
|
|
> |
|
|
|
|
|
<i class="el-icon-video-camera" /> |
|
|
|
|
|
<span>{{ channel.name || `通道 ${channel.channel}` }}</span> |
|
|
|
|
|
<small>CH{{ channel.channel }}</small> |
|
|
|
|
|
</button> |
|
|
|
|
|
</section> |
|
|
|
|
|
</div> |
|
|
|
|
|
</section> |
|
|
|
|
|
</template> |
|
|
|
|
|
<div v-else class="tree-empty"> |
|
|
|
|
|
|
|
|
<div class="tree-filters"> |
|
|
|
|
|
<el-select v-model="onlineStatus" size="small" placeholder="状态" clearable> |
|
|
|
|
|
<el-option label="全部" value="" /> |
|
|
|
|
|
<el-option label="在线" value="1" /> |
|
|
|
|
|
<el-option label="离线" value="0" /> |
|
|
|
|
|
</el-select> |
|
|
|
|
|
<el-input |
|
|
|
|
|
v-model.trim="keywords" |
|
|
|
|
|
size="small" |
|
|
|
|
|
clearable |
|
|
|
|
|
prefix-icon="el-icon-search" |
|
|
|
|
|
placeholder="请输入内容" |
|
|
|
|
|
@input="refreshTree" |
|
|
|
|
|
/> |
|
|
|
|
|
<el-button size="small" icon="el-icon-refresh" title="刷新列表" @click="loadTree" /> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div v-loading="isLoading" class="tree-content"> |
|
|
|
|
|
<el-tree |
|
|
|
|
|
ref="treeRef" |
|
|
|
|
|
:data="treeData" |
|
|
|
|
|
:props="treeProps" |
|
|
|
|
|
node-key="id" |
|
|
|
|
|
highlight-current |
|
|
|
|
|
:default-expanded-keys="expandedKeys" |
|
|
|
|
|
@node-click="handleNodeClick" |
|
|
|
|
|
> |
|
|
|
|
|
<el-tooltip |
|
|
|
|
|
slot-scope="{ data }" |
|
|
|
|
|
:content="data.label || data.name || ''" |
|
|
|
|
|
placement="right" |
|
|
|
|
|
:open-delay="300" |
|
|
|
|
|
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'" /> |
|
|
|
|
|
</span> |
|
|
|
|
|
</el-tooltip> |
|
|
|
|
|
</el-tree> |
|
|
|
|
|
<div v-if="!loading && !treeData.length" class="tree-empty"> |
|
|
<i class="el-icon-video-camera-solid" /> |
|
|
<i class="el-icon-video-camera-solid" /> |
|
|
<span>{{ loading ? '正在读取设备配置' : '没有匹配的设备通道' }}</span> |
|
|
|
|
|
|
|
|
<span>没有匹配的设备通道</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="tree-tip">{{ openHint }}</div> |
|
|
|
|
|
|
|
|
<div class="tree-tip">单击通道打开预览或切换当前控制对象</div> |
|
|
</aside> |
|
|
</aside> |
|
|
</template> |
|
|
</template> |
|
|
|
|
|
|
|
|
<script> |
|
|
<script> |
|
|
|
|
|
import { getAreaEqubookTreeSelect } from '@/api/videosMonitor/index' |
|
|
|
|
|
|
|
|
|
|
|
function flatten(nodes, result = []) { |
|
|
|
|
|
;(nodes || []).forEach(node => { |
|
|
|
|
|
result.push(node) |
|
|
|
|
|
if (Array.isArray(node.children)) flatten(node.children, result) |
|
|
|
|
|
}) |
|
|
|
|
|
return result |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function filterTree(nodes, keyword, onlineStatus) { |
|
|
|
|
|
return (nodes || []).reduce((result, node) => { |
|
|
|
|
|
const children = filterTree(node.children, keyword, onlineStatus) |
|
|
|
|
|
const text = `${node.label || ''} ${node.name || ''} ${node.patroldeviceCode || ''} ${node.channelCode || ''}`.toLowerCase() |
|
|
|
|
|
const matches = !keyword || text.includes(keyword.toLowerCase()) |
|
|
|
|
|
const onlineMatches = node.areaFlag === 'Yes' || !onlineStatus || String(node.patroldeviceOnlineStatus) === onlineStatus |
|
|
|
|
|
if (matches && onlineMatches || children.length) result.push({ ...node, children }) |
|
|
|
|
|
return result |
|
|
|
|
|
}, []) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
export default { |
|
|
export default { |
|
|
name: 'NvrDeviceTree', |
|
|
name: 'NvrDeviceTree', |
|
|
props: { |
|
|
props: { |
|
|
@ -62,125 +82,88 @@ export default { |
|
|
channels: { type: Array, default: () => [] }, |
|
|
channels: { type: Array, default: () => [] }, |
|
|
selectedCode: { type: String, default: '' }, |
|
|
selectedCode: { type: String, default: '' }, |
|
|
loading: { type: Boolean, default: false }, |
|
|
loading: { type: Boolean, default: false }, |
|
|
openHint: { type: String, default: '双击通道打开实时预览' } |
|
|
|
|
|
|
|
|
openHint: { type: String, default: '单击通道打开实时预览' } |
|
|
}, |
|
|
}, |
|
|
data() { |
|
|
data() { |
|
|
return { |
|
|
return { |
|
|
query: '', |
|
|
|
|
|
expanded: {} |
|
|
|
|
|
|
|
|
treeNodes: [], |
|
|
|
|
|
treeData: [], |
|
|
|
|
|
expandedKeys: [], |
|
|
|
|
|
keywords: '', |
|
|
|
|
|
onlineStatus: '', |
|
|
|
|
|
internalLoading: false, |
|
|
|
|
|
treeProps: { children: 'children', label: 'label' } |
|
|
} |
|
|
} |
|
|
}, |
|
|
}, |
|
|
computed: { |
|
|
computed: { |
|
|
visibleNvrs() { |
|
|
|
|
|
const query = this.query.toLowerCase() |
|
|
|
|
|
const configured = Array.isArray(this.config.nvrs) ? this.config.nvrs : [] |
|
|
|
|
|
const source = configured.length ? configured : this.inferredNvrs |
|
|
|
|
|
return source.reduce((result, item, index) => { |
|
|
|
|
|
const nvrIp = String(item.nvrIp || item.ip || '') |
|
|
|
|
|
const ownChannels = Array.isArray(item.channels) && item.channels.length |
|
|
|
|
|
? item.channels |
|
|
|
|
|
: this.channels.filter(channel => String(channel.nvrIp || '') === nvrIp) |
|
|
|
|
|
const nvrName = item.name || item.nvrName || nvrIp || `NVR ${index + 1}` |
|
|
|
|
|
const nvrMatches = [nvrName, nvrIp, item.nvrId].filter(Boolean).join(' ').toLowerCase().includes(query) |
|
|
|
|
|
const channels = !query || nvrMatches ? ownChannels : ownChannels.filter(channel => { |
|
|
|
|
|
return [channel.name, channel.group, channel.channel, channel.cameraCode] |
|
|
|
|
|
.filter(Boolean).join(' ').toLowerCase().includes(query) |
|
|
|
|
|
}) |
|
|
|
|
|
if (query && !nvrMatches && !channels.length) return result |
|
|
|
|
|
const key = `nvr:${nvrIp || index}` |
|
|
|
|
|
const grouped = channels.reduce((groups, channel) => { |
|
|
|
|
|
const name = String(channel.group || '未分组') |
|
|
|
|
|
if (!groups[name]) groups[name] = [] |
|
|
|
|
|
groups[name].push(channel) |
|
|
|
|
|
return groups |
|
|
|
|
|
}, {}) |
|
|
|
|
|
result.push({ |
|
|
|
|
|
...item, |
|
|
|
|
|
key, |
|
|
|
|
|
name: nvrName, |
|
|
|
|
|
channels, |
|
|
|
|
|
groups: Object.keys(grouped).map(name => ({ |
|
|
|
|
|
key: `${key}:group:${name}`, |
|
|
|
|
|
name, |
|
|
|
|
|
channels: grouped[name] |
|
|
|
|
|
})) |
|
|
|
|
|
}) |
|
|
|
|
|
return result |
|
|
|
|
|
}, []) |
|
|
|
|
|
}, |
|
|
|
|
|
inferredNvrs() { |
|
|
|
|
|
const map = new Map() |
|
|
|
|
|
this.channels.forEach(channel => { |
|
|
|
|
|
const key = String(channel.nvrIp || channel.nvrName || 'default') |
|
|
|
|
|
if (!map.has(key)) map.set(key, { nvrIp: channel.nvrIp, name: channel.nvrName || channel.nvrIp, channels: [] }) |
|
|
|
|
|
map.get(key).channels.push(channel) |
|
|
|
|
|
}) |
|
|
|
|
|
return Array.from(map.values()) |
|
|
|
|
|
}, |
|
|
|
|
|
filteredCount() { |
|
|
|
|
|
return this.visibleNvrs.reduce((count, nvr) => count + nvr.channels.length, 0) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
isLoading() { return this.loading || this.internalLoading }, |
|
|
|
|
|
channelNodes() { return flatten(this.treeNodes).filter(node => node.channelFlag) } |
|
|
}, |
|
|
}, |
|
|
watch: { |
|
|
watch: { |
|
|
visibleNvrs: { |
|
|
|
|
|
immediate: true, |
|
|
|
|
|
handler(nvrs) { |
|
|
|
|
|
nvrs.forEach(nvr => { |
|
|
|
|
|
if (this.expanded[nvr.key] === undefined) this.$set(this.expanded, nvr.key, true) |
|
|
|
|
|
nvr.groups.forEach(group => { |
|
|
|
|
|
if (this.expanded[group.key] === undefined) this.$set(this.expanded, group.key, true) |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
onlineStatus: 'refreshTree', |
|
|
|
|
|
selectedCode(value) { |
|
|
|
|
|
const node = this.channelNodes.find(item => this.toCameraCode(item) === value) |
|
|
|
|
|
if (node && this.$refs.treeRef) this.$refs.treeRef.setCurrentKey(node.id) |
|
|
} |
|
|
} |
|
|
}, |
|
|
}, |
|
|
|
|
|
mounted() { this.loadTree() }, |
|
|
methods: { |
|
|
methods: { |
|
|
toggle(key) { |
|
|
|
|
|
this.$set(this.expanded, key, !this.isOpen(key)) |
|
|
|
|
|
|
|
|
async loadTree() { |
|
|
|
|
|
this.internalLoading = true |
|
|
|
|
|
try { |
|
|
|
|
|
const response = await getAreaEqubookTreeSelect({ patroldeviceName: '', onlineStatus: '', patroldeviceTypeFlag: 'ipc' }) |
|
|
|
|
|
this.treeNodes = Array.isArray(response.data) ? response.data : [] |
|
|
|
|
|
this.expandedKeys = [] |
|
|
|
|
|
const walk = nodes => (nodes || []).forEach(node => { if (node.children && node.children.length) { this.expandedKeys.push(node.id); walk(node.children) } }) |
|
|
|
|
|
walk(this.treeNodes) |
|
|
|
|
|
this.refreshTree() |
|
|
|
|
|
const first = this.channelNodes[0] |
|
|
|
|
|
if (first && !this.selectedCode) this.$emit('open', this.normalizeChannel(first)) |
|
|
|
|
|
} catch (error) { |
|
|
|
|
|
this.treeNodes = [] |
|
|
|
|
|
this.$message.error(error.message || '设备树加载失败') |
|
|
|
|
|
} finally { this.internalLoading = false } |
|
|
|
|
|
}, |
|
|
|
|
|
refreshTree() { this.treeData = filterTree(this.treeNodes, this.keywords, this.onlineStatus) }, |
|
|
|
|
|
toCameraCode(node) { return node.cameraCode || node.channelCode || '' }, |
|
|
|
|
|
normalizeChannel(node) { |
|
|
|
|
|
return { ...node, cameraCode: this.toCameraCode(node), name: node.label || node.name, channel: node.channelCode, nvrIp: node.nvrIp || '' } |
|
|
}, |
|
|
}, |
|
|
isOpen(key) { |
|
|
|
|
|
return this.expanded[key] !== false |
|
|
|
|
|
|
|
|
handleNodeClick(node) { |
|
|
|
|
|
if (!node.channelFlag) return |
|
|
|
|
|
const channel = this.normalizeChannel(node) |
|
|
|
|
|
this.$emit('open', channel) |
|
|
|
|
|
}, |
|
|
|
|
|
nodeIcon(node) { |
|
|
|
|
|
if (node.channelFlag) return 'el-icon-video-camera' |
|
|
|
|
|
if (node.patroldeviceType == 13) return 'el-icon-position' |
|
|
|
|
|
if (node.areaFlag === 'Yes') return 'el-icon-location-outline' |
|
|
|
|
|
return 'el-icon-connection' |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
</script> |
|
|
</script> |
|
|
|
|
|
|
|
|
<style lang="scss" scoped> |
|
|
<style lang="scss" scoped> |
|
|
.nvr-tree { |
|
|
|
|
|
width: 258px; |
|
|
|
|
|
min-width: 258px; |
|
|
|
|
|
height: 100%; |
|
|
|
|
|
display: flex; |
|
|
|
|
|
flex-direction: column; |
|
|
|
|
|
border-right: 1px solid rgba(41, 139, 255, .55); |
|
|
|
|
|
background: rgba(7, 24, 78, .82); |
|
|
|
|
|
color: #d7e9ff; |
|
|
|
|
|
} |
|
|
|
|
|
.tree-title { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(41, 139, 255, .35); color: #54ffff; font-size: 14px; } |
|
|
|
|
|
|
|
|
.nvr-tree { width: 280px; min-width: 280px; height: 100%; display: flex; flex-direction: column; border-right: 1px solid rgba(41,139,255,.55); background: transparent; color: #d7e9ff; } |
|
|
|
|
|
.tree-title { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(41,139,255,.35); color: #54ffff; font-size: 14px; } |
|
|
.tree-title i { margin-right: 6px; } |
|
|
.tree-title i { margin-right: 6px; } |
|
|
.tree-title strong { min-width: 28px; height: 20px; padding: 0 7px; line-height: 20px; text-align: center; color: #b7d8ff; border: 1px solid rgba(55, 151, 255, .4); background: #0d3472; font-size: 12px; } |
|
|
|
|
|
::v-deep .el-input { margin: 10px 12px; width: calc(100% - 24px); } |
|
|
|
|
|
::v-deep .el-input__inner { color: #d7e9ff; border-color: rgba(56, 151, 255, .45); background: rgba(7, 30, 88, .75); } |
|
|
|
|
|
.tree-content { |
|
|
|
|
|
flex: 1; |
|
|
|
|
|
min-height: 0; |
|
|
|
|
|
overflow-y: auto; |
|
|
|
|
|
padding: 0 8px 8px; |
|
|
|
|
|
scrollbar-width: none; |
|
|
|
|
|
-ms-overflow-style: none; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
.tree-title strong { min-width: 28px; text-align: center; color: #b7d8ff; } |
|
|
|
|
|
.tree-filters { display: flex; gap: 6px; padding: 10px 8px; } |
|
|
|
|
|
.tree-filters .el-select { width: 78px; } |
|
|
|
|
|
.tree-filters .el-input { flex: 1; min-width: 0; } |
|
|
|
|
|
.tree-filters .el-button { flex: 0 0 32px; padding: 0; } |
|
|
|
|
|
.tree-content { flex: 1; min-height: 0; overflow: auto; padding: 0 8px 8px; scrollbar-width: none; -ms-overflow-style: none; } |
|
|
.tree-content::-webkit-scrollbar { display: none; } |
|
|
.tree-content::-webkit-scrollbar { display: none; } |
|
|
.nvr-row, .group-row, .channel-row { width: 100%; border: 0; color: inherit; background: transparent; cursor: pointer; text-align: left; letter-spacing: 0; } |
|
|
|
|
|
.nvr-row { height: 36px; display: flex; align-items: center; gap: 7px; color: #cce4ff; font-weight: 600; } |
|
|
|
|
|
.nvr-row span, .channel-row span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|
|
|
|
|
.nvr-row small, .group-row small { color: #78a6d8; font-weight: 400; } |
|
|
|
|
|
.group-row { height: 30px; padding-left: 17px; color: #8ebce8; } |
|
|
|
|
|
.group-row i { margin-right: 5px; } |
|
|
|
|
|
.channel-row { height: 34px; padding: 0 7px 0 34px; display: flex; align-items: center; gap: 8px; border-left: 2px solid transparent; color: #b9d8f8; } |
|
|
|
|
|
.channel-row:hover { background: rgba(25, 105, 194, .22); } |
|
|
|
|
|
.channel-row.active { color: #54ffff; border-left-color: #26d8ff; background: rgba(19, 100, 196, .38); } |
|
|
|
|
|
.channel-row small { color: #668fbd; font-size: 10px; } |
|
|
|
|
|
.tree-empty { height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #7298c3; } |
|
|
|
|
|
.tree-empty i { font-size: 32px; } |
|
|
|
|
|
.tree-tip { min-height: 35px; padding: 9px 12px; border-top: 1px solid rgba(41, 139, 255, .3); color: #6f9ecc; font-size: 11px; line-height: 16px; } |
|
|
|
|
|
|
|
|
.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; } |
|
|
|
|
|
::v-deep .el-tree { background: transparent; color: inherit; font-size: 14px; } |
|
|
|
|
|
::v-deep .el-tree-node__content { height: 34px; } |
|
|
|
|
|
::v-deep .el-tree-node__content:hover, ::v-deep .el-tree-node:focus > .el-tree-node__content { background: transparent; } |
|
|
|
|
|
::v-deep .el-tree-node.is-current > .el-tree-node__content { color: #54ffff; background: transparent; } |
|
|
</style> |
|
|
</style> |