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-10-24 10:01
最后编辑:admin  更新时间:2024-11-03 03:52