Browse Source

feat: live stream data synchronization

Casper Dai 2 years ago
parent
commit
3fe6e0a867

+ 1 - 1
src/components/service/external/DevicePlayer/index.vue

@@ -167,7 +167,7 @@ export default {
     onMouseMove () {
       clearTimeout(this.$delayMoveTimer)
       this.floating = false
-      if (this.isFullscreen) {
+      if (!this.isFullscreen) {
         this.$delayMoveTimer = setTimeout(() => {
           this.floating = true
         }, 3000)

+ 16 - 2
src/components/service/external/player.js

@@ -64,6 +64,17 @@ export default {
     onVideoReset () {
       // todo
     },
+    onVideoProgress () {
+      const player = this.$refs.video
+      if (player) {
+        const end = player.buffered.end(0)
+        const delta = end - player.currentTime
+        if (delta > 5) {
+          console.log('progree delta', delta)
+          player.currentTime = end - 1
+        }
+      }
+    },
     playUrl (url) {
       if (mpegtsjs.isSupported()) {
         this.loading = true
@@ -94,7 +105,10 @@ export default {
           // })
           this.$player = player
           this.onVideoWaiting()
-          player.attachMediaElement(this.$refs.video)
+          const videoElm = this.$refs.video
+          player.attachMediaElement(videoElm)
+          videoElm.removeEventListener('progress', this.onVideoProgress)
+          videoElm.addEventListener('progress', this.onVideoProgress)
           player.load()
         } catch (error) {
           console.log('连接异常', error)
@@ -240,7 +254,7 @@ export default {
     onVideoDestroyByError (message) {
       this.destroyPlayer()
       if (this.retry) {
-        this.createPlayer()
+        this.$timer = setTimeout(this.createPlayer, 500)
       } else if (message) {
         console.log(message)
       }

+ 1 - 1
src/components/table/Table/index.vue

@@ -41,7 +41,7 @@
           <schema-select
             v-if="filter.type === 'select'"
             v-model="options.params[filter.key]"
-            class="u-width--xs"
+            :class="filter.className || 'u-width--xs'"
             :schema="filter"
             @change="onChange"
           />

+ 6 - 0
src/scss/bem/_object.scss

@@ -19,6 +19,12 @@
     height: $height--sm;
   }
 
+  &--lg {
+    min-width: $width--sm;
+    height: $height--md;
+    font-size: $font-size;
+  }
+
   &:hover {
     background-color: lighten($blue, 10%);
   }

+ 8 - 4
src/views/dashboard/v1/AlarmInfo.vue

@@ -111,11 +111,14 @@ export default {
 <style lang="scss" scoped>
 @keyframes animated-border {
     0% {
-    box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
+    box-shadow: 0 0 40px 0 rgba(244, 6, 69, 1);
+    }
+    50% {
+    box-shadow: 0px 0px 80px 0px rgba(244, 6, 69, 1);
     }
     100% {
-    box-shadow: 0px 0px 40px 0px rgba(244, 6, 69, 1);
-}
+    box-shadow: 0 0 40px 0 rgba(244, 6, 69, 1);
+    }
 }
 .info {
   color: #f40645;
@@ -123,9 +126,10 @@ export default {
     width: 640px;
     border: 3px solid rgba(244, 6, 69, 1);
     background-color: rgba(65, 4, 20, 1);
-    box-shadow: 0px 0px 40px 0px rgba(244, 6, 69, 1);
+    // box-shadow: 0px 0px 40px 0px rgba(244, 6, 69, 1);
     min-height: 320px;
     padding-bottom: 20px;
+    animation: animated-border 1.5s infinite;
   }
   &__row {
     height: 40px;

+ 3 - 1
src/views/dashboard/v1/Box.vue

@@ -7,7 +7,9 @@
         v-if="title"
         class="l-flex__none c-box__header l-flex--row"
       >
-        <div class="u-bold l-flex__fill">{{ title }}</div>
+        <div class="u-bold l-flex__fill">
+          <slot name="title">{{ title }}</slot>
+        </div>
         <div class="header__decoration">
           <div class="decoration__bg" />
           <div class="decoration__bg--under" />

+ 26 - 51
src/views/dashboard/v1/CameraScreen.vue

@@ -1,17 +1,29 @@
 <template>
   <box title="监控画面">
+    <template #title>
+      <div
+        class="has-active"
+        @click="() => $refs.dialog.show()"
+      >
+        监控画面
+      </div>
+    </template>
+    <transfer-camera-dialog
+      ref="dialog"
+      @change="onChange"
+    />
     <div
-      v-if="options.list.length"
+      v-if="cameras.length"
       class="l-flex__auto c-record-grid"
     >
       <div
-        v-for="item in options.list"
-        :key="item.id"
-        :class="{fullscreen:item.id===fullscreenId}"
+        v-for="item in cameras"
+        :key="item.identifier"
+        :class="{fullscreen:item.identifier===fullscreenId}"
         class="c-record-wrapper"
       >
         <i
-          v-if="item.id===fullscreenId"
+          v-if="item.identifier===fullscreenId"
           class="el-icon-circle-close c-record-close has-active"
           @click="fullscreenId=''"
         />
@@ -31,9 +43,9 @@
           </template> -->
         </camera-player>
         <i
-          v-if="item.id!==fullscreenId&&item.onlineStatus===1"
+          v-if="item.identifier!==fullscreenId"
           class="c-sibling-item el-icon-full-screen has-active c-record-full-screen"
-          @click="onFullScreen(item.id)"
+          @click="onFullScreen(item.identifier)"
         />
       </div>
     </div>
@@ -47,68 +59,31 @@
 </template>
 
 <script>
-import { getCameras } from '@/api/external'
-import { createListOptions } from '@/utils'
 import Box from './Box'
+import TransferCameraDialog from './TransferCameraDialog'
 
 export default {
   name: 'CameraScreen',
   components: {
-    Box
+    Box,
+    TransferCameraDialog
   },
   data () {
     return {
       fullscreenId: '',
-      options: createListOptions({ pageSize: 40 })
+      cameras: JSON.parse(window.localStorage.getItem('MSR_DASHBOARD_CAMERAS') || '[]')
     }
   },
-  created () {
-    this.getCameras()
-  },
-  beforeDestroy () {
-    clearInterval(this.$timer)
-    this.$timer = -1
-  },
   methods: {
+    onChange (cameras) {
+      this.cameras = [...cameras]
+    },
     onFullScreen (id) {
       if (this.fullscreenId === id) {
         this.fullscreenId = ''
       } else {
         this.fullscreenId = id
       }
-    },
-    getCameras () {
-      getCameras(this.options.params).then(
-        ({ data }) => {
-          data.sort((a, b) => (a.onlineStatus === 1 && b.onlineStatus !== 1) ? -1 : 1)
-          if (data.length <= 4) {
-            this.options.list = data
-          } else {
-            const cameras = []
-            const camerasOffline = []
-            const length = data.length
-            for (let i = 0; i < length; i++) {
-              if (data[i].onlineStatus === 1) {
-                cameras.push(data[i])
-              } else {
-                camerasOffline.push(data[i])
-              }
-              if (cameras.length === 4) {
-                break
-              }
-            }
-            if (cameras.length < 4) {
-              cameras.push(...camerasOffline.slice(0, 4 - cameras.length))
-            }
-            this.options.list = cameras
-          }
-        },
-        () => {
-          if (this.$timer !== -1) {
-            this.$timer = setTimeout(this.getCameras, 1000)
-          }
-        }
-      )
     }
   }
 }

+ 137 - 20
src/views/dashboard/v1/Map.vue

@@ -14,16 +14,16 @@
           v-if="isShowAlarm"
           :alarm="alarm"
           class="c-alarm cv"
-          @close="isShowAlarm=false"
+          @close="onMidAlarmClose"
         />
         <AlarmInfo
-          v-for="(item, index) in alarmList.slice(0,4)"
+          v-for="(item, index) in alarmList.slice(0, 4)"
           v-show="subAlarmShow"
           :key="item.id"
           :alarm="item"
           :style="alarmStyleMap[index]"
           class="c-alarm--sub"
-          @close="onAlarmClose(index)"
+          @close="onListAlarmClose(index)"
         />
       </div>
     </div>
@@ -85,8 +85,8 @@ export default {
       marks: [],
       alarmPosition: [],
       isShowAlarm: false,
-      alarm: {},
-      alarmList: [],
+      alarm: {}, // 中间的
+      alarmList: [], // 四角的
       subAlarmShow: false
     }
   },
@@ -170,7 +170,10 @@ export default {
               const markObj = new AMap.Marker({
                 position: [Number(longitude), Number(latitude)],
                 title: name,
-                icon: onlineStatus === 1 ? this.$onlineIcon : this.$offlineIcon
+                content: this.getMarkerContent(
+                  onlineStatus,
+                  this.getMarkActive(id)
+                )
               })
               this.marks.push(markObj)
               this.$deviceMap[id] = {
@@ -223,34 +226,72 @@ export default {
         }
         this.polylines = polylines
         this.resetView()
+        map.on('click', () => {
+          map.zoomIn()
+        })
+        polygon.on('click', () => {
+          map.zoomIn()
+        })
       })
     },
-    onAlarmClose (index) {
-      this.alarmList.splice(index, 1)
+    /* 关闭警告后的回调 */
+    handleAlarmClose (alarm) {
+      /* 重置地图缩放 */
+      if (!this.alarmList.length && !this.isShowAlarm) {
+        this.resetView()
+      }
+      /* 关闭标记点活跃 */
+      this.$alldeviceMap[alarm.deviceId]?.markObj?.setContent(
+        this.getMarkerContent(
+          this.$alldeviceMap[alarm.deviceId].onlineStatus,
+          this.getMarkActive(alarm.deviceId)
+        )
+      )
+      /* 联动警告列表关闭 */
+      this.$emit('closeAlarm', alarm)
     },
+    /* 处理关闭逻辑 */
+    onMidAlarmClose () {
+      this.handleAlarmClose(this.alarm)
+      this.isShowAlarm = false
+      this.alarm = {}
+    },
+    onListAlarmClose (index) {
+      const alarm = this.alarmList.splice(index, 1)[0]
+      this.handleAlarmClose(alarm)
+    },
+    /* * * * * * * * */
     closeOfflineAlarm (alarm, offlineMap) {
-      if (offlineMap.includes(this.alarm.deviceErrorId) && this.alarm.deviceId === alarm.deviceId) {
-        this.isShowAlarm = false
+      if (
+        offlineMap.includes(this.alarm.deviceErrorId)
+        && this.alarm.deviceId === alarm.deviceId
+      ) {
+        this.onMidAlarmClose()
         return
       }
-      const index = this.alarmList.findIndex(i => offlineMap.includes(i.deviceErrorId) && i.deviceId === alarm.deviceId)
+      const index = this.alarmList.findIndex(
+        i => offlineMap.includes(i.deviceErrorId) && i.deviceId === alarm.deviceId
+      )
       if (index > -1) {
-        this.alarmList.splice(index, 1)
+        this.onListAlarmClose(index)
       }
     },
     setNewAlarm (alarm) {
       const {
         markObj: marker,
         mac,
-        address
+        address,
+        onlineStatus
       } = this.$alldeviceMap?.[alarm.deviceId] || {}
       alarm = { ...alarm, mac, address, status: alarm.status.label }
-      if (!marker) { // 没有经纬度 周围展示不移动
+      if (!marker) {
+        // 没有经纬度 周围展示不移动
         this.alarmList.unshift(alarm)
         this.subAlarmShow = true
         return
       }
-      if (this.isShowAlarm || this.isMoving) { // 有中间占位警告 将当前中间的四周展示   或者是正在移动的警告
+      if (this.isShowAlarm || this.isMoving) {
+        // 有中间占位警告 将当前中间的四周展示   或者是正在移动的警告
         this.alarmList.unshift(this.alarm)
       }
       // 中间展示
@@ -261,15 +302,17 @@ export default {
       this.isShowAlarm = false
       this.subAlarmShow = false
       this.isMoving = true // 防止间隔过短的警告
-      this.$mapMoveTimer = setTimeout(() => { // 还是当前地点没有动地图
+      this.$mapMoveTimer = setTimeout(() => {
+        // 还是当前地点没有动地图
         this.onSetNewAlarmFitView()
       }, 1000)
       this.map.setFitView(marker, false, [60, 60, 60, 60], 20)
       this.curMarker = marker
+      // 标记点激活
+      marker.setContent(this.getMarkerContent(onlineStatus, true))
     },
     resetView () {
       this.map.setFitView(this.polylines)
-      this.isShowAlarm = false
     },
     onSetNewAlarmFitView () {
       clearTimeout(this.$mapMoveTimer)
@@ -312,6 +355,16 @@ export default {
       this.subAlarmShow = true
       this.isMoving = false
     },
+    getMarkerContent (onlineStatus, active) {
+      return `<div class="c-marker ${active ? 'active' : ''} ${
+        onlineStatus === 1 ? 'online' : 'offline'
+      }"></div>`
+    },
+    getMarkActive (deviceId) {
+      return this.alarmList
+        .concat(this.alarm)
+        .some(i => i.deviceId === deviceId)
+    },
     refreshMarkers () {
       const map = {}
       const arr = []
@@ -322,8 +375,8 @@ export default {
             if (device) {
               device.onlineStatus = onlineStatus
               device.markObj.setPosition([Number(longitude), Number(latitude)])
-              device.markObj.setIcon(
-                onlineStatus === 1 ? this.$onlineIcon : this.$offlineIcon
+              device.markObj.setContent(
+                this.getMarkerContent(onlineStatus, this.getMarkActive(id))
               )
               map[id] = device
               delete this.$deviceMap[id]
@@ -331,7 +384,10 @@ export default {
               const markObj = new this.$AMap.Marker({
                 position: [Number(longitude), Number(latitude)],
                 title: name,
-                icon: onlineStatus === 1 ? this.$onlineIcon : this.$offlineIcon
+                content: this.getMarkerContent(
+                  onlineStatus,
+                  this.getMarkActive(id)
+                )
               })
               map[id] = {
                 onlineStatus,
@@ -377,6 +433,67 @@ export default {
   .amap-container {
     background: none !important;
   }
+  ::v-deep {
+    .c-marker {
+      width: 14px;
+      height: 19px;
+      transform: translate(-50%, -50%);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      background-position: center;
+      position: relative;
+      &.online {
+        background-image: url("~@/assets/v1/icon_position1.svg");
+      }
+      &.offline {
+        background-image: url("~@/assets/v1/icon_position2.svg");
+      }
+      &.active::before {
+        // 阴影
+        content: "";
+        position: absolute;
+        top: 3px;
+        left: 0;
+        right: 0;
+        bottom: 3px;
+        border-radius: 50%;
+        background-color: rgba(241, 34, 30, 0.9);
+        animation: scale 2s infinite;
+      }
+      &.active::after {
+        // 阴影
+        content: "";
+        position: absolute;
+        top: 3px;
+        left: 0;
+        right: 0;
+        bottom: 3px;
+        border-radius: 50%;
+        background-color: rgba(241, 34, 30, 0.9);
+        animation: scale2 2s infinite;
+      }
+    }
+    @keyframes scale {
+      0% {
+        transform: scale(1);
+        opacity: 0.9;
+      }
+      100% {
+        transform: scale(6);
+        opacity: 0;
+      }
+    }
+    @keyframes scale2 {
+      0% {
+        transform: scale(1);
+        opacity: 0.9;
+      }
+      100% {
+        transform: scale(12);
+        opacity: 0;
+      }
+    }
+  }
   .c-alarm {
     position: absolute;
     z-index: 99;

+ 11 - 3
src/views/dashboard/v1/MessageNotice.vue

@@ -177,6 +177,14 @@ export default {
         this.listData = data
         // this.newAlarmList = data.filter(i => i.level === 2)
       })
+    },
+    closeNewAlarm (alarm) {
+      const index = this.newAlarmList.findIndex(
+        i => i.id === alarm.id
+      )
+      if (index > -1) {
+        this.newAlarmList.splice(index, 1)
+      }
     }
   }
 }
@@ -202,7 +210,7 @@ export default {
     border-bottom: 1px solid #313a5a;
 
     &.new {
-      color: #f40645;
+      // color: #f40645;
       font-weight: bold;
     }
   }
@@ -265,8 +273,8 @@ export default {
       // animation: none;
     }
   }
-  --color1: #b721ff;
-  --color2: #21d4fd;
+    --color1: #fc6076;
+    --color2: #f40606;
   @keyframes liuguang {
     0% {
       background: linear-gradient(

+ 55 - 64
src/views/dashboard/v1/Record.vue

@@ -1,19 +1,31 @@
 <template>
   <box title="大屏实时画面">
+    <template #title>
+      <div
+        class="has-active"
+        @click="() => $refs.dialog.show()"
+      >
+        大屏实时画面
+      </div>
+    </template>
+    <transfer-device-dialog
+      ref="dialog"
+      @change="onChange"
+    />
     <div
-      v-if="options.list.length"
+      v-if="devices.length"
       class="l-flex__auto c-record-grid"
     >
       <div
-        v-for="item in options.list"
+        v-for="item in devices"
         :key="item.id"
-        :class="{fullscreen:item.id===fullscreenId}"
+        :class="{fullscreen: item.id === fullscreenId}"
         class="c-record-wrapper"
       >
         <i
-          v-if="item.id===fullscreenId"
+          v-if="item.id === fullscreenId"
           class="el-icon-circle-close c-record-close has-active"
-          @click="fullscreenId=''"
+          @click="fullscreenId = ''"
         />
         <device-player
           :device="item"
@@ -34,12 +46,12 @@
         </template> -->
           <template #controls="{ waitingOrLoading, online, isPlaying }">
             <i
-              v-if="item.id!==fullscreenId&&item.onlineStatus===1"
+              v-if="item.id !== fullscreenId && item.onlineStatus === 1"
               class="c-sibling-item el-icon-full-screen has-active c-record-full-screen"
               @click="onFullScreen(item.id)"
             />
             <img
-              v-if="item.id!==fullscreenId"
+              v-if="item.id !== fullscreenId"
               class="c-sibling-item"
               :src="getStatusIcon(waitingOrLoading, online, isPlaying)"
             >
@@ -58,13 +70,15 @@
 
 <script>
 import { getSensorRecords } from '@/api/external'
-import { getDeviceAttentionList } from '@/api/device'
+import { getDevice } from '@/api/device'
 import { getSensorMap } from './api'
 import {
-  createListOptions, parseTime
+  createListOptions,
+  parseTime
 } from '@/utils'
 import { Record } from './config'
 import Box from './Box'
+import TransferDeviceDialog from './TransferDeviceDialog'
 import { ThirdPartyDevice } from '@/constant'
 
 const offlineIcon = require('@/assets/v1/icon_offline.svg')
@@ -74,22 +88,40 @@ const waitIcon = require('@/assets/v1/icon_wait.svg')
 export default {
   name: 'Record',
   components: {
-    Box
+    Box,
+    TransferDeviceDialog
   },
   data () {
     return {
       fullscreenId: '',
-      options: createListOptions({ activate: 1, pageSize: 4 })
+      options: createListOptions({ activate: 1, pageSize: 20 }),
+      devices: JSON.parse(window.localStorage.getItem('MSR_DASHBOARD_DEVICES') || '[]').map(this.transformDevice)
     }
   },
   created () {
-    this.getDevices()
-    this.$timer = setInterval(this.getDevices, Record.timer)
+    this.checkDevices()
+    this.$timer = setInterval(this.checkDevices, Record.timer)
   },
   beforeDestroy () {
     clearInterval(this.$timer)
   },
   methods: {
+    transformDevice ({ id, name }) {
+      return { id, name, onlineStatus: 0 }
+    },
+    checkDevices () {
+      this.devices.forEach(item => {
+        getDevice(item.id).then(({ data }) => {
+          if (data) {
+            item.onlineStatus = data.onlineStatus
+          }
+        })
+      })
+    },
+    onChange (devices) {
+      this.devices = devices.map(this.transformDevice)
+      this.checkDevices()
+    },
     onFullScreen (id) {
       if (this.fullscreenId === id) {
         this.fullscreenId = ''
@@ -147,55 +179,14 @@ export default {
       }
       return offlineIcon
     },
-    updateDevices (data) {
-      data.forEach(device => {
-        if (this.$deviceMap[device.id]) {
-          this.$deviceMap[device.id].onlineStatus = device.onlineStatus
-        }
-      })
-    },
-    getDevices () {
-      getDeviceAttentionList(this.options.params, { custom: true }).then(({ data }) => {
-        data.sort((a, b) => (a.onlineStatus === 1 && b.onlineStatus !== 1) ? -1 : 1)
-        if (this.options.list.length) {
-          this.updateDevices(data)
-        }
-        if (data.length <= 4) {
-          this.options.list = data
-        } else {
-          const devices = []
-          const devicesOffline = []
-          const length = data.length
-          for (let i = 0; i < length; i++) {
-            if (data[i].onlineStatus === 1) {
-              devices.push(data[i])
-            } else {
-              devicesOffline.push(data[i])
-            }
-            if (devices.length === 4) {
-              break
-            }
-          }
-          if (devices.length < 4) {
-            devices.push(...devicesOffline.slice(0, 4 - devices.length))
-          }
-          this.options.list = devices
-        }
-        const map = {}
-        this.options.list.forEach(device => {
-          map[device.id] = device
-        })
-        this.$deviceMap = map
-        // this.getSensorMap(this.options.list)
-      })
-    },
     // 单独接口 目前使用
-    getSensorMap (deviceList) {
-      if (!deviceList.length) {
+    getSensorMap () {
+      const devices = this.devices
+      if (!devices.length) {
         return
       }
       const now = Date.now()
-      for (const device of deviceList) {
+      for (const device of devices) {
         this.getData(device, ThirdPartyDevice.TEMPERATURE_SENSOR, 'temperature', now)
         this.getData(device, ThirdPartyDevice.LIGHT_SENSOR, 'brightness', now)
       }
@@ -213,8 +204,9 @@ export default {
       })
     },
     // 批量接口 目前不用
-    getSensorMapM (deviceList) {
-      if (!deviceList.length) { return }
+    getSensorMapM () {
+      const devices = this.devices
+      if (!devices.length) { return }
       const now = Date.now()
       function getParams (item, sensorType) {
         return {
@@ -224,18 +216,17 @@ export default {
           endTime: now
         }
       }
-
       getSensorMap(
-        deviceList
+        devices
           .map(i => getParams(i, ThirdPartyDevice.TEMPERATURE_SENSOR))
           .concat(
-            deviceList.map(i => getParams(i, ThirdPartyDevice.LIGHT_SENSOR))
+            devices.map(i => getParams(i, ThirdPartyDevice.LIGHT_SENSOR))
           )
       ).then(({ data }) => {
         for (const deviceid in data) {
           if (Object.hasOwnProperty.call(data, deviceid)) {
             const map = this.transfromData(data[deviceid])
-            for (const device of deviceList) {
+            for (const device of devices) {
               if (device.id === deviceid) {
                 this.$set(device, 'temperature', map.filter(i => i.type === ThirdPartyDevice.TEMPERATURE_SENSOR)[0]?.info)
                 this.$set(device, 'brightness', map.filter(i => i.type === ThirdPartyDevice.LIGHT_SENSOR)[0]?.info)

+ 140 - 0
src/views/dashboard/v1/TransferCameraDialog.vue

@@ -0,0 +1,140 @@
+<template>
+  <confirm-dialog
+    ref="dialog"
+    title="关注排序"
+    size="lg fixed"
+    append-to-body
+    @confirm="save"
+  >
+    <template #default>
+      <div class="l-flex__fill l-flex">
+        <div class="l-flex__fill l-flex--col c-sibling-item far">
+          <schema-table
+            ref="table"
+            :schema="schema"
+            @row-click="onRowClick"
+            @selection-change="onSelectionChange"
+          />
+        </div>
+        <div class="l-flex__none l-flex--row o-transfer-button">
+          <el-button
+            type="primary"
+            size="mini"
+            :disabled="!selectionVal.length"
+            @click="onAdd"
+          >
+            <i class="el-icon-arrow-right" />
+          </el-button>
+        </div>
+        <div class="l-flex__fill l-flex--col u-overflow-x--auto">
+          <draggable
+            v-model="cameras"
+            class="l-flex__auto l-flex--col u-font-size--sm u-overflow--auto"
+            handle=".mover"
+            animation="300"
+          >
+            <div
+              v-for="(item, index) in cameras"
+              :key="item.identifier"
+              class="l-flex--row c-sibling-item--v o-draggable-item"
+            >
+              <div class="l-flex__auto l-flex--row c-sibling-item mover">
+                <i class="l-flex__none o-draggable-item__mover el-icon-sort has-padding--h u-color--info u-font-size--md has-active" />
+                <div class="l-flex__auto has-padding--v u-ellipsis">
+                  {{ item.remark }}
+                </div>
+              </div>
+              <i
+                class="l-flex__none el-icon-delete has-padding--h u-color--info u-font-size--md has-active"
+                @click="onDel(index)"
+              />
+            </div>
+          </draggable>
+        </div>
+      </div>
+    </template>
+  </confirm-dialog>
+</template>
+
+<script>
+import { getCameras } from '@/api/external'
+import Draggable from 'vuedraggable'
+
+export default {
+  name: 'TransferCameraDIalog',
+  components: {
+    Draggable
+  },
+  data () {
+    return {
+      selectionVal: [],
+      cameras: [],
+      schema: {
+        list: getCameras,
+        filters: [
+          { key: 'remark', type: 'search', placeholder: '设备名称' }
+        ],
+        cols: [
+          { type: 'selection', selectable: this.selectable },
+          { prop: 'remark', label: '设备名称' }
+        ]
+      }
+    }
+  },
+  methods: {
+    show () {
+      this.cameras = JSON.parse(window.localStorage.getItem('MSR_DASHBOARD_CAMERAS') || '[]')
+      this.$refs.dialog.show()
+    },
+    save (done) {
+      window.localStorage.setItem('MSR_DASHBOARD_CAMERAS', JSON.stringify(this.cameras))
+      this.$emit('change', this.cameras)
+      done()
+    },
+    selectable ({ identifier }) {
+      return !this.cameras.some(camera => camera.identifier === identifier)
+    },
+    onRowClick (row) {
+      if (this.selectable(row)) {
+        this.$refs.table.getInst().toggleRowSelection(row)
+      }
+    },
+    onSelectionChange (val) {
+      this.selectionVal = val
+    },
+    onAdd () {
+      this.cameras.push(...this.selectionVal.map(({ identifier, remark }) => {
+        return { identifier, remark }
+      }))
+      this.selectionVal = []
+      this.$refs.table?.getInst().clearSelection()
+    },
+    onDel (index) {
+      this.cameras.splice(index, 1)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.o-transfer-button {
+  padding: 0 $spacing;
+  margin: 0 $spacing;
+  border-left: 1px solid $border;
+  border-right: 1px solid $border;
+}
+
+.o-draggable-item {
+  border: 1px solid $gray;
+  border-radius: $radius--sm;
+
+  &.sortable-chosen {
+    border-color: #c6e2ff;
+    background-color: $blue--light;
+  }
+
+  &__mover {
+    cursor: move;
+  }
+}
+</style>

+ 162 - 0
src/views/dashboard/v1/TransferDeviceDialog.vue

@@ -0,0 +1,162 @@
+<template>
+  <confirm-dialog
+    ref="dialog"
+    title="关注排序"
+    size="lg fixed"
+    append-to-body
+    @confirm="save"
+  >
+    <template #default>
+      <div class="l-flex__fill l-flex">
+        <department-tree
+          class="c-sibling-item c-sidebar u-width--md"
+          @change="onGroupChanged"
+        />
+        <div class="l-flex__fill l-flex--col c-sibling-item far">
+          <schema-table
+            ref="table"
+            :schema="schema"
+            @row-click="onRowClick"
+            @selection-change="onSelectionChange"
+          />
+        </div>
+        <div class="l-flex__none l-flex--row o-transfer-button">
+          <el-button
+            type="primary"
+            size="mini"
+            :disabled="!selectionVal.length"
+            @click="onAdd"
+          >
+            <i class="el-icon-arrow-right" />
+          </el-button>
+        </div>
+        <div class="l-flex__fill l-flex--col u-overflow-x--auto">
+          <draggable
+            v-model="devices"
+            class="l-flex__auto l-flex--col u-font-size--sm u-overflow--auto"
+            handle=".mover"
+            animation="300"
+          >
+            <div
+              v-for="(item, index) in devices"
+              :key="item.id"
+              class="l-flex--row c-sibling-item--v o-draggable-item"
+            >
+              <div class="l-flex__auto l-flex--row c-sibling-item mover">
+                <i class="l-flex__none o-draggable-item__mover el-icon-sort has-padding--h u-color--info u-font-size--md has-active" />
+                <div class="l-flex__auto has-padding--v u-ellipsis">
+                  {{ item.name }}
+                </div>
+              </div>
+              <i
+                class="l-flex__none el-icon-delete has-padding--h u-color--info u-font-size--md has-active"
+                @click="onDel(index)"
+              />
+            </div>
+          </draggable>
+        </div>
+      </div>
+    </template>
+  </confirm-dialog>
+</template>
+
+<script>
+import { getDevicesByQuery } from '@/api/device'
+import Draggable from 'vuedraggable'
+
+export default {
+  name: 'TransferDeviceDialog',
+  components: {
+    Draggable
+  },
+  data () {
+    return {
+      selectionVal: [],
+      devices: [],
+      schema: {
+        list: this.getDevicesByQuery,
+        condition: { activate: 1 },
+        pagination: {
+          layout: 'prev,pager,next',
+          small: true
+        },
+        filters: [
+          { key: 'name', type: 'search', placeholder: '设备名称' }
+        ],
+        cols: [
+          { type: 'selection', selectable: this.selectable },
+          { prop: 'name', label: '设备名称' }
+        ]
+      }
+    }
+  },
+  methods: {
+    onGroupChanged (group) {
+      this.$group = group
+      this.$refs.table?.pageTo(1)
+    },
+    getDevicesByQuery (params) {
+      if (!this.$group) {
+        return Promise.resolve({ data: [] })
+      }
+      return getDevicesByQuery({
+        org: this.$group.path,
+        ...params
+      })
+    },
+    show () {
+      this.devices = JSON.parse(window.localStorage.getItem('MSR_DASHBOARD_DEVICES') || '[]')
+      this.$refs.dialog.show()
+    },
+    save (done) {
+      window.localStorage.setItem('MSR_DASHBOARD_DEVICES', JSON.stringify(this.devices))
+      this.$emit('change', this.devices)
+      done()
+    },
+    selectable ({ id }) {
+      return !this.devices.some(device => device.id === id)
+    },
+    onRowClick (row) {
+      if (this.selectable(row)) {
+        this.$refs.table.getInst().toggleRowSelection(row)
+      }
+    },
+    onSelectionChange (val) {
+      this.selectionVal = val
+    },
+    onAdd () {
+      this.devices.push(...this.selectionVal.map(({ id, name }) => {
+        return { id, name }
+      }))
+      this.selectionVal = []
+      this.$refs.table?.getInst().clearSelection()
+    },
+    onDel (index) {
+      this.devices.splice(index, 1)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.o-transfer-button {
+  padding: 0 $spacing;
+  margin: 0 $spacing;
+  border-left: 1px solid $border;
+  border-right: 1px solid $border;
+}
+
+.o-draggable-item {
+  border: 1px solid $gray;
+  border-radius: $radius--sm;
+
+  &.sortable-chosen {
+    border-color: #c6e2ff;
+    background-color: $blue--light;
+  }
+
+  &__mover {
+    cursor: move;
+  }
+}
+</style>

+ 7 - 1
src/views/dashboard/v1/index.vue

@@ -42,6 +42,7 @@
               style="width: 1040px; height: 450px"
             >
               <MessageNotice
+                ref="MessageNotice"
                 @new-alarm="onNewAlarm"
               />
             </div>
@@ -83,6 +84,7 @@
                 ref="map"
                 :status-data="statusData"
                 :device-list="deviceList"
+                @closeAlarm="onCloseAlarm"
               />
             </div>
           </div>
@@ -161,7 +163,8 @@
 <script>
 import { Index } from './config'
 import {
-  getDevicesByQuery, getDeviceStatisticsByPath
+  getDevicesByQuery,
+  getDeviceStatisticsByPath
 } from '@/api/device'
 import DeviceCalender from './DeviceCalender'
 import Map from './Map'
@@ -255,6 +258,9 @@ export default {
       }
       audio.play()
     },
+    onCloseAlarm (alarm) {
+      this.$refs.MessageNotice.closeNewAlarm(alarm)
+    },
     onNewAlarm (alarm) {
       const onlineMap = [32, 9]
       const offlineMap = [1, 15, 16, 17]

+ 2 - 1
src/views/dashboard/v2/index.vue

@@ -100,7 +100,8 @@
 <script>
 import { Index } from './config'
 import {
-  getDevicesByQuery, getDeviceStatisticsByPath
+  getDevicesByQuery,
+  getDeviceStatisticsByPath
 } from '@/api/device'
 import DeviceCalender from './DeviceCalender'
 import DeviceStatus from './DeviceStatus'

+ 5 - 1
src/views/device/detail/index.vue

@@ -5,7 +5,7 @@
     fill
   >
     <div class="l-flex__none c-detail__header">
-      <div class="l-flex--row has-padding">
+      <div class="l-flex--row c-detail__info">
         <i
           class="l-flex__none c-sibling-item o-icon o-icon--hover el-icon-arrow-left u-bold u-pointer"
           @click="onBack"
@@ -263,6 +263,10 @@ export default {
     background-color: #fff;
   }
 
+  &__info {
+    padding: $spacing $spacing $spacing--xs;
+  }
+
   &__wrapper {
     border-radius: $radius;
     background-color: #fff;