1.项目介绍
1.1技术栈及开发环境
本项目使用vite+vue3框架开发,结合vue-router做路由管理,pinia做状态管理。UI框架使用的是Element Plus,系统整体上实现了对PC及平板电脑的适配。
作者本地node环境版本为v14.19.1。使用者可根据自身情况升级调整(可能会有依赖不兼容的情况需要处理),或使用nvm管理工具做多版本管理:
多语言集成使用vue-i18n,推荐安装i18n Ally插件,可以统计翻译状况以及在开发界面中显示对应的翻译内容。
本系统使用组件式开发,关联性不强的功能应尽可能拆分出来,减少页面冗余。
1.2项目启动
启动项目需要先安装依赖,这里使用的npm包管理工具,也可以根据自身情况选择yarn。
在项目根目录打开终端窗口(或cmd工具),运行npm insall
命令,等待依赖安装完毕:
依赖安装完毕后,继续运行npm run serve
来启动项目。项目启动后会自动在浏览器中打开入口页面,更多开发配置请查看vite.config.js
,并查阅vite文档:
配置请求代理?
本系统使用单一API来源开发,开发环境下的代理api地址定义在vite.api.js
文件中,生产环境下使用部署环境同源地址,无需指定api地址。
api请求封装在src>api
目录中:
启动后的界面(vite项目首次构建加载会比较慢,构建优化过程可能会重载):
当登陆后,系统会在浏览器中自动打开另一个tab页面,此页面内容为系统定义的亮暗主题的对比色,如以熟知此页的打开方式,可在页面views>layout>HeaderBar>handleShowColorsPanel
中注释掉自动弹出:
至此已经完成项目启动了。
1.3开发规范
本项目使用Eslint的校验规则为“plugin:vue/vue3-strongly-recommended+standard”,更多配置可查看.eslintrc.js
配置文件:
推荐使用vscode编辑器,添加Eslint扩展可快速格式化符合规则的代码。
1.4项目打包
在项目正常运行,没有报错的情况下,运行npm run build
即可打包用于更新生产环境的项目,打包后的目录为distTG,可以根据实际情况在vite.config.js
中修改。
2.重点功能模块说明
2.1实时监控
此模块主体位于views>monitor>monitorMonitor.vue
,主要由设备树,地图界面,设备列表及资产详情组成
<section
class="monitorMonitor"
v-loading="showLoading"
>
<!-- 动态列表配置 该组件层级太深会导致在ipad上的fixed定位被父组件截取 -->
<DynamicProps
ref="dynamicProps"
@props-changed="handlePropsChanged"
/>
<!-- 设备分享 -->
<AssetShare
ref="assetShare"
:share-type="1"
/>
<!-- 指令执行 -->
<CommandDialog ref="commandDialog" />
<!-- 页面内容区域 -->
<div class="page-main flex-row">
<BaseTree
ref="baseTree"
:tree-type="treeType"
:tree-multi="true"
@node-click="handleNodeClick"
@nodes-checked="handleNodesChecked"
>
<template #top>
<div class="tree-radios">
<el-radio-group
v-model="treeType"
@change="handleTreeTypeChanged"
>
<el-radio value="3-1">
{{ $t('labels.treeDevice') }}
</el-radio>
<el-radio value="2-1">
{{ $t('labels.treeAsset') }}
</el-radio>
</el-radio-group>
</div>
</template>
</BaseTree>
<div class="page-content flex-col">
<div class="is-top flex-row">
<div class="is-map">
<BaseMap
ref="baseMap"
@win-click="handleWinClick"
@monitor-click="handleMonitorClick"
@area-search-filter="handleAreaSearchFilter"
/>
</div>
<DeviceDetail
v-if="activeRow.id"
ref="deviceDetail"
/>
</div>
<div
id="bc_table_list"
:class="['is-bottom', `tb-${tableHeight}`]"
>
<!-- 拖动条 -->
<div
v-drag="['#bc_table_list']"
class="bc-drag-line"
:title="$t('tips.touchDrag')"
/>
<!-- 列表组件 -->
<DeviceTable
ref="deviceTable"
@table-bar-operate="handleTableBarOperate"
@monitor-click="handleMonitorClick"
:mointor-data="mointorData"
:tree-type="treeType"
/>
</div>
</div>
</div>
</section>
2.2视频监控
此模块主体位于views>monitor>monitorVideoPlayReal.vue
,主要由设备树,地图界面、流媒体播放器及语音对讲组成
<section class="monitorVideoPlayReal">
<!-- 页面内容区域 -->
<div class="page-main flex-row">
<BaseTree
ref="baseTree"
tree-type="3-1"
:tree-multi="false"
:min-width="300"
@node-click="handleNodeClick"
/>
<div class="page-content flex-row">
<div class="page-left flex-col">
<OperationPanel :hide-props="true">
<template #left>
<PageTitle />
<NetworkingStatus
ref="networkingStatus"
:with-border="!!activeNode.name"
:show-label="activeNode.name"
/>
<!-- 播放器宫格布局 -->
<GridCells
ref="gridCells"
@grids-updated="grids => gridCellsCount = grids"
/>
</template>
<template #right>
<!-- <SearchFilter /> -->
<!-- 播放器切换 -->
<PlayerTypes
ref="playerTypes"
@player-updated="handlePlayerTypeChanged"
/>
</template>
</OperationPanel>
<div class="video-players">
<VideoPlayers
ref="videoPlayers"
:active-node="activeNode"
:grid-cells-count="gridCellsCount"
:player-type="playerType"
@close-stream-cmd="handleCloseStreamCmd"
@pull-stream-cmd="handlePullStreamCmd"
@player-opts="handlePlayerOpts"
/>
</div>
</div>
<div class="page-right flex-col">
<AudioTalker
ref="audioTalker"
:active-node="activeNode"
:active-player="activePlayer || {}"
@pull-stream-request="handlePullStreamRequest"
@close-stream-request="handleCloseStreamRequest"
/>
<PlayRealMap
ref="playRealMap"
:active-node="activeNode"
/>
</div>
</div>
</div>
</section>
播放器选择
系统针对不同的流媒体文件类型集成了三款不同的播放器,播放器实现主体文件位于
播放器组件:src>components>media>VideoPlayers.vue
播放器初始化:src>utils>videoHandler.js
export default function streamPlayerInit (obj, type) {
// 需要统一实现的方法
// start() 播放
// stop() 暂停
// screenshot() 截屏
// fullscreen() 全屏
// setVolume() 音量调节
// vLoading 加载中?
// stats // 帧率、网速
// status 0未播放 1播放中
try {
const $container = document.getElementById(`video_${obj.key}`)
switch (type) {
case 1: { // nodeplayer
NodePlayer.load(() => {
const player = new NodePlayer()
if (!player.destroy) {
player.destroy = () => {
console.warn('player.destroy')
}
}
obj.player = player
player.setView(`video_${obj.key}`)
player.start(obj.url)
// 设置最大缓冲时长,单位毫秒,只在软解时有效
player.setBufferTime(1000)
player.on('stats', stats => {
// console.log('player on stats=', stats)
obj.stats = {
...stats,
abps: stats.abps / 8,
vbps: stats.vbps / 8
}
if (stats.vbps) obj.vLoading = false
})
player.on('start', () => {
console.log('player on start')
obj.status = 1
obj.clickStop = false
obj.player.setVolume(obj.mute ? 0 : obj.volume / 100)
})
player.on('stop', () => {
console.log('player on stop', obj.clickStop)
if (!obj.clickStop) {
// 如果不是用户手动暂停的 重新拉流
} else {
obj.status = 0
}
})
player.on('error', e => {
console.log('player on error', e)
if (typeof e !== 'string') {
console.log('异常失败 重新拉流')
obj.player.stop()
return
}
let errorMsg = _$t('tips.videoPullErr')
switch (e.split(' ')[0]) {
case 'screenshot':
errorMsg = obj.status !== 1 ? _$t('tips.videoCurPlaying') : _$t('tips.videoCutErr')
break
}
$msgTips(errorMsg)
})
player.on('videoInfo', (w, h, codec) => {
console.log('player on video info width=' + w + ' height=' + h + ' codec=' + codec)
})
player.on('audioInfo', (r, c, codec) => {
console.log('player on audio info samplerate=' + r + ' channels=' + c + ' codec=' + codec)
})
})
break
}
case 2: { // jessibuca
const jessibuca = obj.player = new window.JessibucaPro({
container: $container,
videoBuffer: 0.2, // 缓存时长
videoBufferDelay: 1,
decoder: 'jessibuca/decoder-pro.js',
isResize: false,
text: '',
loadingText: '',
controlAutoHide: true, // 隐藏默认的控制栏
debug: false,
debugLevel: 'warn',
useMSE: true,
decoderErrorAutoWasm: false,
useSIMD: false,
useWCS: false,
useMThreading: true,
showBandwidth: false, // 显示网速
showPerformance: false, // 显示性能
operateBtns: {
fullscreen: false,
screenshot: false,
play: false,
audio: false,
ptz: false,
quality: false,
performance: false
},
timeout: 10,
qualityConfig: ['480', '720', '1080', '4K', '8K'],
forceNoOffscreen: true,
isNotMute: true,
heartTimeout: 10,
ptzClickType: 'mouseDownAndUp',
ptzZoomShow: true,
ptzMoreArrowShow: true,
ptzApertureShow: true,
ptzFocusShow: true,
useCanvasRender: false,
useWebGPU: true,
demuxUseWorker: true,
controlHtml: '',
// audioEngine:"worklet",
// isFlv: true
pauseAndNextPlayUseLastFrameShow: true,
heartTimeoutReplayUseLastFrameShow: false,
replayUseLastFrameShow: true, // 重播使用上一帧显示
replayShowLoadingIcon: false, // 重播显示loading
mseDecoderUseWorker: true
})
// 自定义统一方法
jessibuca.start = (url) => {
jessibuca.play(url)
}
jessibuca.stop = () => {
jessibuca.pause()
// jessibuca.destroy()
}
jessibuca.fullscreen = () => {
jessibuca.setFullscreen(true)
}
// 执行播放
jessibuca.start(obj.url)
jessibuca.on('stats', (stats) => {
// console.log('stats', stats)
obj.stats = { ...stats }
if (stats.vbps) obj.vLoading = false
if (!jessibuca.isRecording()) {
obj.recording = false
}
if (!jessibuca.isZoomOpen()) {
obj.zooming = false
}
})
jessibuca.on('start', function () {
console.log('player on start')
obj.status = 1
obj.clickStop = false
obj.player.setVolume(obj.mute ? 0 : obj.volume / 100)
})
jessibuca.on('pause', function () {
console.log('player on pause')
if (!obj.clickStop) {
// 如果不是用户手动暂停的 重新拉流
} else {
obj.status = 0
}
})
jessibuca.on('error', function (e) {
console.log('player on error', e)
obj.player.stop()
obj.status = 0
})
jessibuca.on('audioInfo', function (audioInfo) {
console.log('audioInfo', audioInfo)
})
jessibuca.on('videoInfo', function (videoInfo) {
console.log('videoInfo', videoInfo)
})
// jessibuca.on('recordStart', function () {
// console.log('record start')
// })
// 这个事件不会触发
// jessibuca.on('recordEnd', function () {
// obj.recording = false
// })
break
}
case 3: { // webrtc
const ZLMRTC = window.ZLMRTCClient
const webrtcPlayer = obj.player = new ZLMRTC.Endpoint({
element: $container,
debug: false, // 是否打印日志
zlmsdpUrl: decodeURIComponent(obj.url), // 流地址
simulecast: false,
useCamera: false,
audioEnable: true,
videoEnable: true,
recvOnly: true,
usedatachannel: false
})
// console.log(webrtcPlayer)
webrtcPlayer.start = () => {
console.warn('method of start')
webrtcPlayer.start()
}
webrtcPlayer.stop = () => {
console.warn('method of stop')
webrtcPlayer.close()
obj.status = 0
// obj.player = null
}
webrtcPlayer.screenshot = () => {
toolsScreenshotByDom($container)
}
webrtcPlayer.fullscreen = () => {
$container.requestFullscreen()
}
webrtcPlayer.setVolume = () => {
$container.volume = obj.mute ? 0 : obj.volume / 100
}
webrtcPlayer.on(ZLMRTC.Events.WEBRTC_ICE_CANDIDATE_ERROR, function (e) {
// ICE 协商出错
console.log('ICE 协商出错')
})
webrtcPlayer.on(ZLMRTC.Events.WEBRTC_ON_REMOTE_STREAMS, function (e) {
// 获取到了远端流,可以播放
console.log('播放成功', e.streams)
obj.status = 1
obj.clickStop = false
obj.vLoading = false
obj.player.setVolume(obj.mute ? 0 : obj.volume / 100)
})
webrtcPlayer.on(ZLMRTC.Events.WEBRTC_OFFER_ANWSER_EXCHANGE_FAILED, function (e) {
// offer anwser 交换失败
console.log('offer anwser 交换失败', e)
// stop()
})
webrtcPlayer.on(ZLMRTC.Events.WEBRTC_ON_LOCAL_STREAM, function (s) {
// 获取到了本地流
$container.srcObject = s
$container.muted = obj.mute
})
webrtcPlayer.on(ZLMRTC.Events.CAPTURE_STREAM_FAILED, function (s) {
// 获取本地流失败
console.log('获取本地流失败')
})
webrtcPlayer.on(ZLMRTC.Events.WEBRTC_ON_CONNECTION_STATE_CHANGE, function (state) {
// RTC 状态变化 ,详情参考 https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState
console.log('当前状态==>', state)
})
webrtcPlayer.on(ZLMRTC.Events.WEBRTC_ON_DATA_CHANNEL_OPEN, function (event) {
console.log('rtc datachannel 打开 :', event)
})
webrtcPlayer.on(ZLMRTC.Events.WEBRTC_ON_DATA_CHANNEL_MSG, function (event) {
console.log('rtc datachannel 消息 :', event.data)
document.getElementById('msgrecv').value = event.data
})
webrtcPlayer.on(ZLMRTC.Events.WEBRTC_ON_DATA_CHANNEL_ERR, function (event) {
console.log('rtc datachannel 错误 :', event)
})
webrtcPlayer.on(ZLMRTC.Events.WEBRTC_ON_DATA_CHANNEL_CLOSE, function (event) {
console.log('rtc datachannel 关闭 :', event)
})
break
}
}
return obj.player
} catch (error) {
console.warn(error)
}
}
对讲实现
对讲组件:src>components>media>AudioTalker.vue
,分为国标和部标
<!-- 部标对讲 -->
<AudioTalkerBB
v-if="isBuBiao"
ref="audioTalkerBB"
:active-node="activeNode"
:active-player="activePlayer"
@pull-stream-request="params => $emit('pullStreamRequest', params)"
@close-stream-request="params => $emit('closeStreamRequest', params)"
/>
<!-- 国标对讲 -->
<AudioTalkerGB
v-else
ref="audioTalkerGB"
:active-node="activeNode"
:active-player="activePlayer"
@pull-stream-request="params => $emit('pullStreamRequest', params)"
@close-stream-request="params => $emit('closeStreamRequest', params)"
/>
2.3AI报警
AI报警的前端实现用于展示报警信息及其附件,其主体文件位于views>report>reportAlarmAI.vue
,分为列表模式(使用基础列表组件BaseTable展示)和附件模式(为提高加载性能,使用懒加载实现)两种,顾名思义前者以列表为主,后者以附件为主。
附件模式(AttachFiles):
附件主要为图片、视频、其他文件(含bin文件)
列表模式(BaseTable+TableDetailR):
列表中也可针对单条报警查看位置及附件信息
2.4个性化配置
个性化配置主要涉及列表个性化配置,和用户相关的日期格式、里程单位、默认地图、导航栏风格、默认页、系统主题色的配置,和组织有关的系统logo及终端登录,和当前浏览器有关的面包屑导航栏、暗黑模式、历史浏览记录,报警通知等
和用户有关的配置:
主体文件位于components>admin>UserConfig.vue
和组织有关的配置:
主体文件位于components>admin>OrgConfig.vue
和浏览器有关的配置:
主体文件位于views>layout>HeaderBar.vue
下面重点说一下列表个性化配置
本系统中的列表大多使用BaseTable组件,
列表主体:src>components>BaseTable.vue
需要引入table组件+设置表格参数(handleSetHeaders)+设置文件内容(handleGetTableDataByParams)
列表配置:src>components>DynamicProps.vue
用于列表显示时的具体配置
配置文件:src>utils>propsHandler.js
根据页面path配置所需展示的列表内容
列表项:src>components>CircleColumn.vue
<template>
<el-table-column
:key="props.index+props.header.colProp"
:label="props.header.colSubLabel || props.header.colLabel"
:property="props.header.colProp"
:prop="props.header.colProp"
:sortable="props.header.colSortable"
:show-overflow-tooltip="!props.header.colTips&&(!noTipProps.includes(props.header.colProp)&&!props.header.isJson)"
:width="props.header.colMinWidth?'':props.header.colWidth"
:class-name="`${props.header.colClass || ''} color-${props.header.colColor === '#6D6D6D' ? 'basic' : ''}`"
:min-width="props.header.colMinWidth?props.header.colMinWidth:''"
:fixed="props.header.colFixed === 'no' ? false : props.header.colFixed"
:align="props.header.colAlign || 'left'"
:sort-method="(a, b) => commonSortHandler(a, b, props.header.colProp)"
>
<template #default="{row}">
<template v-if="!props.header.colCustom">
{{ formatCommon(row, props.header) }}
<!-- {{ row[header.colProp] }} -->
</template>
<slot
v-else
:row="row"
/>
</template>
</el-table-column>
</template>
<script setup>
import { toolsParseMileageByUnit, toolsFormatTime } from '@utils/commonHandler'
const props = defineProps(['index', 'header'])
const noTipProps = ['replyContent']
// 显示内容处理
const formatCommon = (row, header) => {
let cellValue = row[header.colShowProp || header.colProp]
if (header.colFormatter) {
return this[header.colFormatter](row, header, cellValue)
}
// console.log(row.address, header.colProp)
// console.log('=======', cellValue)
// 速度/里程单位换算
if (cellValue && header.milSpeed) {
const milTag = Number(sessionStorage.milStyle) || 0 // 0km 1mile
cellValue = toolsParseMileageByUnit(cellValue)
cellValue += ` ${(milTag === 1 ? 'mi' : 'km') + (header.milSpeed === 2 ? '/h' : '')}`
}
return (header.colUnitL ? `${header.colUnitL} ` : '') + `${cellValue || (cellValue === 0 ? 0 : '--')}` + (header.colUnitR ? ` ${header.colUnitR}` : '')
}
// 日期排序(日期被格式化之后排序默认的会有问题)
const dateTimeSortHandler = (a, b, sortKey) => {
// console.log(a, b, sortKey)
const aVal = a[sortKey] ? toolsFormatTime(a[sortKey], 'YYYY-MM-DD HH:mm:ss') : ''
const bVal = b[sortKey] ? toolsFormatTime(b[sortKey], 'YYYY-MM-DD HH:mm:ss') : ''
const aCell = String(aVal || '')
const bCell = String(bVal || '')
return aCell.localeCompare(bCell)
}
// 普通的排序
const commonSortHandler = (a, b, sortKey) => {
if (sortKey.toLowerCase().includes('time')) {
return dateTimeSortHandler(a, b, sortKey)
}
// console.log(a, b, sortKey)
const aVal = a[sortKey]
const bVal = b[sortKey]
if (!isNaN(aVal) && !isNaN(bVal)) {
return aVal - bVal
}
const aCell = String(aVal || '')
const bCell = String(bVal || '')
return aCell.localeCompare(bCell)
}
</script>
<style lang="scss">
</style>
2.5主题
主题主要是系统主题色及暗黑主题配置
配置主题的主体文件位于src>utils>themeHandler.js>systemThemeInitHandler/systemThemeChangeHandler
其中暗黑模式跟随系统配置,同样可以手动修改配置
export default function systemThemeInitHandler () {
onMounted(() => {
try {
// js 监听系统主题模式
const scheme = window.matchMedia('(prefers-color-scheme: dark)')
const _tag = sessionStorage.darkTheme
sessionStorage.darkTheme = (_tag ? _tag === '1' : !!scheme.matches) ? 1 : 0
systemThemeChangeHandler()
scheme.addEventListener('change', event => {
sessionStorage.darkTheme = Number(!!event.matches)
systemThemeChangeHandler()
})
} catch (e) {}
})
}
暗黑模式下的特殊样式src>style>dark.scss
2.6地图集成
本系统目前集成了百度(GL1.0)+Mapv、高德(V2)以及谷歌地图,其中前两款主要用于中国,后者可用于中国以外的区域。
地图资源及附属资源包需在html文件中引入(需要提前准备好地图key):
如果不需要只需要集成谷歌地图,请在文件src>components>map>utils/commonUtils.js
中进行设置,系统将根据有效的地图配置来加载默认地图。
地图主体文件位于src>components>map>BaseMap.vue
,主要涉及功能为海量点(monitorShowClusterLayer)、多边形绘制工具(fenceDrawPolygonBoundary)、圆形绘制工具(fenceDrawCircleBoundary)、围栏绘制(fenceShowBoundaries)、图标绘制(mapSetMarker)、轨迹线绘制(mapSetLine)以及其他的拓展功能。
2.7图表集成
本系统图表展示使用的是Echarts来实现。
另外看板位置views>home>compts>StatisticsLocation.vue
的地图分布用到了HighCharts
2.8指令集成
指令模块主体位于views>command>commandInsRemote.vue
,主要由设备树,指令列表,指令面板,指令记录组成
指令列表views>command>compts>commandInsRemote>InsList.vue
部分指令包含二级指令,二级指令可能是单选或者多选:
指令面板views>command>compts>commandInsRemote>InsPanel.vue
面板的展示根据设备协议来划分,最终根据选择的具体一级或二级指令展示单个或者多个指令组合。
指令记录views>command>compts>commandInsRemote>InsRecord.vue
,用于记录所选设备的历史下发指令
2.9虚拟树
通过树来展示数据往往很直观,尤其是鲜明的层次结构。然而当元素过多时将导致页面卡顿,有很严重的性能问题。原因在于当树节点过多时,界面需要显示太多DOM元素而导致卡顿。因为我们使用了虚拟树,简而言之就是只渲染视图中可见的元素,其余元素当数据呈现在界面中时再渲染。即始终保持只渲染可视区域中的元素数量,大大减少DOM元素的渲染,减轻渲染压力。
逻辑代码所在位置:src>stores>treeStore.js
:
<el-tree-v2
ref="treeRef"
:data="treeData"
:props="treeProps"
:filter-method="filterMethod"
highlight-current
:height="treeHeight"
:indent="treeIndent"
:empty-text="''"
:show-checkbox="treeMulti"
:check-strictly="checkStrictly"
:check-on-click-node="treeMulti"
:expand-on-click-node="clickExpand || (!treeMulti && !treeType.startsWith('1-'))"
@check="handleNodeChecked"
@node-expand="handleAdjustTreeWidth(true)"
@node-collapse="handleAdjustTreeWidth(false)"
>
<template #default="{ node }">
</template>
</el-tree-v2>
目前系统借助pinia状态管理工具定义了五个树数据仓库,用于赋值、取值、及监听变化。分别是组织树、资产树、设备树、按类型设备树、用户树。可以在所需的地方及时更新数据的变化,从而做出响应。
由于数据是共享的,当系统中A页面在加载Tree1时,会检测此时仓库中是否有Tree1对应的数据,如果已存在则直接使用,否则自动请求数据并存放到仓库共享。此时如果B页面也加载Tree1数据,就不会再次请求数据了。同样的,如果页面B中触发了数据更新,也会导致页面A中的树组件发生更新。
界面代码所在位置:src>components>trees>BaseTree.vue
虚拟树元素使用了Element Plus提供的组件el-tree-v2
,并对其进行了完善优化。比如自适应树宽高、显示搜索命中节点及其子节点
关于树搜索的改造解读:
目前市面上的树结构组件都是只会显示关键字匹配到的节点,其余节点都会被隐藏掉。
考虑到我们大多数情况下匹配到了某一级别的节点,还需要能够查看该节点的子节点(比如公司+设备树,当关键字匹配到公司节点后,我们想要在当前的结果中查看到该公司下的设备),这就需要对树结构及搜索算法进行改进。我们的实现方式是针对每一个节点均加上一个前缀。格式要求,子节点的前缀必须以父节点的前缀开头(这样的话,如当前节点【1-1】被命中,其余以【1-1】开头的节点【1-1-…】均为其子元素,也应该被显示出来)。比如 prefix:
该实现在src>stores>treeStore.js>handleParseTreeToAddPrefix
中完成
搜索过程中是否展会某一层节点?使用递归遍历最顶层的元素及其子元素,如果当前元素的子元素有任
一被关键字命中或者当前元素的子元素中有展开的元素,则当前元素也应该展开,否则就应该折叠起来:
该实现在src>components>trees>BaseTree.vue>filterMethod
中完成
2.10虚拟列表
虚拟列表在实现原理上等同于上述的虚拟树,都是仅渲染可视区域内的数据元素来减少DOM元素数量,从而提升性能。
虽然虚拟列表性能比较优越,没有分页,看起来很美观,但系统中仅在少数界面使用了虚拟列表来实现,大多数地方仍然使用分页列表。原因在于虚拟列表实现较为复杂,不利于维护解读,灵活度低。所以仅在需要一次性显示全部数据的地方使用,使用的Element Plus提供的组件el-table-v2
,并对其进行了改造优化:
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2
ref="V2Table"
:cache="5"
:columns="tableHeaders"
:data="V2TableData"
:width="width"
:height="height"
:header-height="V2RowHeight"
:row-height="V2RowHeight"
:fixed="V2TableFixed"
:row-class="V2TableRowClassHandler"
:sort-state="V2SortState"
:scrollbar-always-on="true"
@column-sort="V2TableSortChangeHandler"
@scroll="V2TableScrollChangeHandler"
>
<template #row="props">
<V2TableRowGroupHandler v-bind="props" />
</template>
</el-table-v2>
</template>
</el-auto-resizer>
比如资产监控、轨迹回放views>monitor>compts>common>TableV2.vue
设备、资产数据导入src>components>common>VirtualTable.vue
。
2.11权限控制
权限控制主要由角色和用户权限来实现,本系统角色和用户并没有强制绑定,角色只是用于快捷设置用于的权限,具体应以用户实际配置权限为准
用户权限配置文件位于:views>admin>compts>adminRoles>MenuFuncs.vue
所有页面权限
权限控制实现分为以下几步:
1.权限赋值:views>layout>compts>MenuPanel.vue>setPagesFuncs
权限的释义:通过接口queryMenuFunction可查询
此处权限和具体页面相关联,存储结构以具体页面的path做为key
2.权限存储:src>stores>menuStore.js>pagesFuncs
3.权限分配:src>utils>menuFuncsHandler.js>getPageFuncs/limitFuncsColVisual
import { ref } from 'vue'
import pinia from '@stores/index'
import { useMenuStore } from '@stores/menuStore'
const menuStore = useMenuStore(pinia)
export default function getPageFuncs (routeName = sessionStorage.routeName, immediate = false) {
if (immediate) {
return menuStore.pagesFuncs[routeName] || {}
}
const pageFuncs = ref(menuStore.pagesFuncs[routeName] || {})
menuStore.$subscribe((mutation, state) => {
pageFuncs.value = menuStore.pagesFuncs[routeName] || {}
})
return pageFuncs
}
export function limitFuncsColVisual (pageLimits, limits = '') {
if (typeof limits !== 'string') return false
limits = (limits || '').replace(/\s+/g, '').split(',')
for (let i = 0; i < limits.length; i++) {
if (pageLimits[limits[i]]) return true
}
return false
}
4.权限控制:getPageFuncs/limitFuncsColVisual
2.12报警提醒
此处使用websocket实现报警/事件的实时提醒,并且结合了弹框+语音提醒
主体文件位于scr>utils>pushHandler.js>pushSubscribe
// socket连接
const socketConnectHandler = () => {
const { host, protocol } = window.location
const isHttps = protocol.startsWith('https')
let socketURL
// 开发环境直接访问api数据源 不走代理
// 生产环境为了保证不跨域 后端做了代理(接口前缀 prop/ 后端会根据这个标识代理请求到8080端口的api)
if (isDev) {
const devHost = API_DEV.split('//')[1]
socketURL = `${isHttps ? 'wss' : 'ws'}://${devHost}/system/notice/${socketParams.connectId}`
} else {
socketURL = `${isHttps ? 'wss' : 'ws'}://${host}/prod/system/notice/${socketParams.connectId}`
}
socketIns = new WebSocket(socketURL)
socketIns.onopen = function (evt) {
// console.log('Connection open')
// 连接成功后复位
socketParams.tryTimes = 0
socketStatusListen()
}
socketIns.onmessage = function (evt) {
// console.log(evt)
socketParams.tryTimes = 0
if (!evt.data) return
// 数据简单处理
pushInfoParse(evt.data)
}
}
2.13资产分享H5
该项目使用vue3+webpack,UI框架使用vant4,用于查看资产详情、资产分享、终端登录
2.14推送报警H5
该项目使用vue3+vite,功能较为简单,不做过多介绍
最后编辑:admin 更新时间:2024-11-03 03:52