Browse Source

feat: dashboard v1

Casper Dai 3 years ago
parent
commit
f7eeb933b5
37 changed files with 3029 additions and 22 deletions
  1. 1 0
      .env
  2. BIN
      src/assets/v1/bg_alarm.png
  3. 14 0
      src/assets/v1/icon_position1.svg
  4. 14 0
      src/assets/v1/icon_position2.svg
  5. BIN
      src/assets/v1/monitor_bg.png
  6. 4 0
      src/main.js
  7. 7 1
      src/router/index.js
  8. 6 0
      src/store/modules/user.js
  9. 12 10
      src/views/bigscreen/ast/core/utils.js
  10. 4 4
      src/views/dashboard/v0/DeviceCalender.vue
  11. 0 1
      src/views/dashboard/v0/MessageNotice.vue
  12. 3 0
      src/views/dashboard/v0/Record.vue
  13. 8 4
      src/views/dashboard/v0/api.js
  14. 154 0
      src/views/dashboard/v1/AlarmInfo.vue
  15. 55 0
      src/views/dashboard/v1/AlarmLevel.vue
  16. 102 0
      src/views/dashboard/v1/AlarmRate.vue
  17. 178 0
      src/views/dashboard/v1/AlarmType.vue
  18. 79 0
      src/views/dashboard/v1/AssetStatistic.vue
  19. 154 0
      src/views/dashboard/v1/Box.vue
  20. 84 0
      src/views/dashboard/v1/Cards.vue
  21. 204 0
      src/views/dashboard/v1/DeviceCalender.vue
  22. 36 0
      src/views/dashboard/v1/DeviceStatus.vue
  23. 105 0
      src/views/dashboard/v1/DonutChart.vue
  24. 76 0
      src/views/dashboard/v1/Header.vue
  25. 109 0
      src/views/dashboard/v1/LineChart.vue
  26. 295 0
      src/views/dashboard/v1/Map.vue
  27. 295 0
      src/views/dashboard/v1/MessageNotice.vue
  28. 58 0
      src/views/dashboard/v1/ProgramRate.vue
  29. 77 0
      src/views/dashboard/v1/ProgramStatistic.vue
  30. 119 0
      src/views/dashboard/v1/ProgramTop.vue
  31. 65 0
      src/views/dashboard/v1/Record.vue
  32. 125 0
      src/views/dashboard/v1/StatisticCard.vue
  33. 232 0
      src/views/dashboard/v1/SystemLoad.vue
  34. 105 0
      src/views/dashboard/v1/api.js
  35. 239 0
      src/views/dashboard/v1/index.vue
  36. 6 0
      src/views/realm/debug/simulator/index.vue
  37. 4 2
      src/views/realm/debug/simulator/simulate.js

+ 1 - 0
.env

@@ -55,3 +55,4 @@ VUE_APP_SALT = '42857cfddb33f3fddb27fff9773683f3'
 
 # gaode
 VUE_APP_GAODE_MAP_KEY = '9c499e7000d066c05de9af8556a890f7'
+VUE_APP_GAODE_MAP_JSCODE = 'e7b3c29a5112657edcc688a3c589bd15'

BIN
src/assets/v1/bg_alarm.png


+ 14 - 0
src/assets/v1/icon_position1.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="14px" height="19px" viewBox="0 0 14 19" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon_position备份 2</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="大屏设备监控系统-单屏" transform="translate(-300.000000, -661.000000)">
+            <g id="地图位置" transform="translate(80.000000, 437.000000)">
+                <g id="icon_position备份-2" transform="translate(220.000000, 224.000000)">
+                    <ellipse id="椭圆形" fill="#333333" cx="7" cy="17" rx="4" ry="2"></ellipse>
+                    <path d="M11.9497475,2.05025253 C14.6212886,4.72179371 14.6820055,9.01549316 12.131898,11.7607688 L11.9497475,11.9497475 L7.70710678,16.1923882 C7.31658249,16.5829124 6.68341751,16.5829124 6.29289322,16.1923882 L2.05025253,11.9497475 L2.05025253,11.9497475 C-0.683417511,9.21607743 -0.683417511,4.78392257 2.05025253,2.05025253 C4.78392257,-0.683417511 9.21607743,-0.683417511 11.9497475,2.05025253 Z" id="路径" fill="#04A681"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 14 - 0
src/assets/v1/icon_position2.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="14px" height="19px" viewBox="0 0 14 19" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>icon_position备份 4</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="大屏设备监控系统-单屏" transform="translate(-314.000000, -552.000000)">
+            <g id="地图位置" transform="translate(80.000000, 437.000000)">
+                <g id="icon_position备份-4" transform="translate(234.000000, 115.000000)">
+                    <ellipse id="椭圆形" fill="#333333" cx="7" cy="17" rx="4" ry="2"></ellipse>
+                    <path d="M11.9497475,2.05025253 C14.6212886,4.72179371 14.6820055,9.01549316 12.131898,11.7607688 L11.9497475,11.9497475 L7.70710678,16.1923882 C7.31658249,16.5829124 6.68341751,16.5829124 6.29289322,16.1923882 L2.05025253,11.9497475 L2.05025253,11.9497475 C-0.683417511,9.21607743 -0.683417511,4.78392257 2.05025253,2.05025253 C4.78392257,-0.683417511 9.21607743,-0.683417511 11.9497475,2.05025253 Z" id="路径" fill="#F90C0C"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

BIN
src/assets/v1/monitor_bg.png


+ 4 - 0
src/main.js

@@ -72,6 +72,10 @@ async function startApp () {
 
   injectGlobalApi(Vue, store, router)
 
+  window._AMapSecurityConfig = {
+    securityJsCode: process.env.VUE_APP_GAODE_MAP_JSCODE
+  }
+
   new Vue({
     router,
     store,

+ 7 - 1
src/router/index.js

@@ -225,7 +225,7 @@ export const asyncRoutes = [
   {
     path: '/dashboard',
     component: Solo,
-    meta: { title: '大屏展示', icon: 'dm' },
+    meta: { title: '大数据', icon: 'dm' },
     access: [Access.MANAGE_TENANTS, Access.MANAGE_TENANT],
     children: [
       {
@@ -233,6 +233,12 @@ export const asyncRoutes = [
         path: 'v0',
         component: () => import('@/views/dashboard/v0/index'),
         meta: { title: 'V0' }
+      },
+      {
+        name: 'dashboard-v1',
+        path: 'v1',
+        component: () => import('@/views/dashboard/v1/index'),
+        meta: { title: 'V1' }
       }
     ]
   },

+ 6 - 0
src/store/modules/user.js

@@ -5,6 +5,10 @@ import {
   RoleAccess,
   Access
 } from '@/constant'
+import {
+  inst,
+  close
+} from '@/utils/mqtt'
 
 const state = {
   token: '',
@@ -84,6 +88,7 @@ function parseAccess (realmAccess, resourceAccess) {
 const actions = {
   async login ({ commit }, keycloak) {
     if (keycloak.authenticated) {
+      inst()
       const { tenant, sub, preferred_username, family_name, given_name, avatar } = keycloak.tokenParsed
       const { roleSet, accessSet } = parseAccess(keycloak.realmAccess, keycloak.resourceAccess)
       commit('SET_TOKEN', keycloak.token)
@@ -119,6 +124,7 @@ const actions = {
     Vue.prototype.$keycloak.logout()
   },
   clearToken ({ commit }) {
+    close()
     commit('SET_TOKEN', '')
     commit('SET_TENANT_ID', '')
     Vue.prototype.$keycloak.refreshToken = null

+ 12 - 10
src/views/bigscreen/ast/core/utils.js

@@ -55,7 +55,7 @@ export function create (node) {
     width: Math.max(1, canvas.width / canvasDefaults.width),
     height: Math.max(1, canvas.height / canvasDefaults.height)
   }
-  canvas.widgets = canvas.widgets.map(widget => normalize(compatibleProcessing(widget)))
+  canvas.widgets = canvas.widgets.map(widget => normalize(compatibleProcessing(widget))).filter(Boolean)
   return canvas
 }
 
@@ -91,7 +91,7 @@ function compatibleProcessing (widget) {
 export function toJSON (node) {
   const transformOptions = { color: transformColorToAndroid }
   const canvas = transform({ ...node }, widgetCanvas.transform, transformOptions)
-  canvas.widgets = canvas.widgets.map(({ id, ...widget }) => transform(widget, getWidget(widget.type).transform, transformOptions))
+  canvas.widgets = canvas.widgets.map(({ id, ...widget }) => transform(widget, getWidget(widget.type)?.transform, transformOptions))
   return canvas
 }
 
@@ -103,15 +103,15 @@ export function copy ({ id, ...node }) {
 }
 
 function getWidget (type) {
-  return type && supportWidgets.find(widget => widget.type === type) || {}
+  return type && supportWidgets.find(widget => widget.type === type)
 }
 
 export function getIcon (type) {
-  return getWidget(type).icon
+  return getWidget(type)?.icon
 }
 
 export function getOptions (type) {
-  return (type && getWidget(type) || widgetCanvas).options
+  return (getWidget(type) || widgetCanvas).options
 }
 
 function createID (type) {
@@ -121,11 +121,13 @@ function createID (type) {
 export function normalize (data) {
   const { type } = data
   const widgetOptions = getWidget(type)
-  return {
-    id: createID(type),
-    ...widgetOptions.defaults(scale),
-    ...transform(data, widgetOptions.transform, { color: transformColorToWeb })
-  }
+  return widgetOptions
+    ? {
+      id: createID(type),
+      ...widgetOptions.defaults(scale),
+      ...transform(data, widgetOptions.transform, { color: transformColorToWeb })
+    }
+    : null
 }
 
 function transform10To16 (num) {

+ 4 - 4
src/views/dashboard/v0/DeviceCalender.vue

@@ -1,6 +1,6 @@
 <template>
   <box title="各大屏当前播放节目">
-    <div class="l-flex__fill l-flex--col c-calender u-text-center">
+    <div class="l-flex__fill l-flex--col c-calendar u-text-center">
       <div class="l-flex__none l-flex--row c-calendar__header">
         <div class="col__deviceName">设备名称</div>
         <div class="col__programName">节目名称</div>
@@ -8,7 +8,7 @@
         <div class="col__createTime">更新时间</div>
       </div>
       <div class="l-flex__fill u-relative">
-        <div class="c-calender__list">
+        <div class="c-calendar__list">
           <vue-seamless-scroll
             :data="tableData"
             :class-option="classOption"
@@ -16,7 +16,7 @@
             <div
               v-for="item in tableData"
               :key="item.id"
-              class="l-flex--row c-calender__item"
+              class="l-flex--row c-calendar__item"
             >
               <div class="col__deviceName">
                 <auto-text
@@ -135,7 +135,7 @@ export default {
 </script>
 
 <style lang="scss" scoped>
-.c-calender {
+.c-calendar {
   font-size: 12px;
   line-height: 36px;
 

+ 0 - 1
src/views/dashboard/v0/MessageNotice.vue

@@ -75,7 +75,6 @@ export default {
     Box
   },
   data () {
-    this.$num = 0
     return {
       classOption: {
         step: 0.4

+ 3 - 0
src/views/dashboard/v0/Record.vue

@@ -192,6 +192,7 @@ export default {
     color: $blue;
   }
 }
+
 .control__icon {
   width: 24px;
   height: 24px;
@@ -227,6 +228,7 @@ export default {
     grid-template-columns: 1fr 1fr 1fr;
   }
 }
+
 .c-tab {
   min-width: 140px;
   height: 32px;
@@ -240,6 +242,7 @@ export default {
     color: #fff;
   }
 }
+
 .o-sort {
   width: 20px;
   height: 20px;

+ 8 - 4
src/views/dashboard/v0/api.js

@@ -6,7 +6,8 @@ export function getTimelines (deviceIdList, options) {
     url: `/content/deviceCalender`,
     method: 'POST',
     ...options,
-    data: { deviceIdList }
+    data: { deviceIdList },
+    custom: true
   }).then(({ data }) => data.map(i => { return { ...i, eventDetail: JSON.parse(i.eventDetail) } }) || [])
 }
 
@@ -14,7 +15,8 @@ export function getDeviceExceptionRanking () {
   return tenantRequest({
     url: '/deviceException/ranking',
     method: 'GET',
-    params: addTenant({})
+    params: addTenant({}),
+    custom: true
   })
 }
 
@@ -22,7 +24,8 @@ export function getDeviceExceptionLevelStatistic () {
   return tenantRequest({
     url: '/deviceException/levelStatistic',
     method: 'GET',
-    params: addTenant({})
+    params: addTenant({}),
+    custom: true
   })
 }
 
@@ -30,7 +33,8 @@ export function getDeviceExceptionTypeStatistics (statisticDate) {
   return tenantRequest({
     url: '/deviceException/typeStatistics',
     method: 'GET',
-    params: addTenant({ statisticDate })
+    params: addTenant({ statisticDate }),
+    custom: true
   })
 }
 

+ 154 - 0
src/views/dashboard/v1/AlarmInfo.vue

@@ -0,0 +1,154 @@
+<template>
+  <div class="info__bg l-flex--col  center jcenter ">
+    <div class="info">
+      <div class="l-flex--row center info__deviceName">
+        {{ alarm.deviceName }}
+      </div>
+      <div
+        class="l-flex--row center"
+        :style="colorStyle"
+      >
+        <svg-icon
+          class="alarm__icon"
+          icon-class="v0-alarm"
+        />
+        <div class="alarm__name">
+          {{ levelMap[alarm.level] }}
+        </div>
+      </div>
+      <div class="l-flex--row center alarm__type">
+        {{ alarm.type }}
+      </div>
+      <div
+        v-for="(row, index) in rows"
+        :key="index"
+        class="l-flex--row info__row c-sibling-item--v far"
+      >
+        <div
+          v-for="item in row"
+          :key="item.key"
+          class="l-flex--row l-flex__fill"
+        >
+          <div class="l-flex__none l-flex--row center row__label">
+            {{ item.label }}
+          </div>
+          <div class="l-flex__fill row__value">
+            {{ alarm[item.key] }}
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'AlarmInfo',
+  components: {},
+  props: {
+    alarm: {
+      type: Object,
+      required: true
+    }
+  },
+  data () {
+    return {
+      levelMap: ['普通等级', '提示等级', '紧急等级'],
+      rows: [
+        [
+          {
+            label: '发生时间',
+            key: 'happenTime'
+          },
+          {
+            label: 'MAC',
+            key: 'mac'
+          }
+        ],
+        [
+          {
+            label: '系统配置预处理方式',
+            key: 'handle'
+          },
+          {
+            label: '执行结果',
+            key: 'status'
+          }
+        ],
+        [
+          {
+            label: '位置',
+            key: 'address'
+          }
+        ]
+      ]
+    }
+  },
+  methods: {
+    colorStyle () {
+      return this.alarm
+        ? {
+          color: ['#04A681', '#FFA000', '#F40645'][this.alarm.level]
+        }
+        : null
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.info {
+  color: #f40645;
+  width: 640px;
+  height: 360px;
+  &__bg {
+    width: 720px;
+    height: 440px;
+    background: url("~@/assets/v1/bg_alarm.png");
+    background-size: 100%;
+    background-position: center;
+    background-repeat: no-repeat;
+  }
+  &__row {
+    height: 40px;
+    opacity: 0.5;
+    font-size: 16px;
+    background: rgba(#7b102c, 0.5);
+    margin: 0 16px;
+    .row__label {
+      color: #9ea9cd;
+      width: 150px;
+    }
+    .row__value {
+      color: #fff;
+    }
+  }
+
+  &__deviceName {
+    line-height: 48px;
+    font-size: 32px;
+    font-weight: bold;
+    margin: 20px 0 15px 0;
+  }
+  .alarm {
+    &__icon {
+      width: 24px;
+      height: 24px;
+      background-repeat: no-repeat;
+      background-size: 100%;
+    }
+    &__name {
+      font-size: 24px;
+      line-height: 36px;
+      font-weight: 500;
+    }
+    &__type {
+      font-size: 32px;
+
+      line-height: 48px;
+      font-weight: bold;
+      margin: 15px 0 15px 0;
+    }
+  }
+}
+</style>

+ 55 - 0
src/views/dashboard/v1/AlarmLevel.vue

@@ -0,0 +1,55 @@
+<template>
+  <box title="预警等级情况">
+    <cards :items="items" />
+  </box>
+</template>
+
+<script>
+import { getDeviceExceptionLevelStatistic } from './api'
+import Box from './Box'
+import Cards from './Cards'
+
+export default {
+  name: 'AlarmLevel',
+  components: {
+    Box,
+    Cards
+  },
+  data () {
+    return {
+      itemValue: ['-', '-', '-', '-']
+    }
+  },
+  computed: {
+    items () {
+      const colors = ['#0AB4FF', '#F40645', '#FFA000', '#04A681']
+      const value = this.itemValue
+
+      return ['预警等级总数', '紧急等级', '提示等级', '普通等级'].map((item, index) => {
+        return {
+          icon: 'v0-alarm',
+          label: item,
+          value: value[index] === void 0 ? '-' : value[index],
+          style: { color: colors[index] }
+        }
+      })
+    }
+  },
+  created () {
+    this.getDeviceExceptionLevelStatistic()
+    this.$timer = setInterval(this.getDeviceExceptionLevelStatistic, 10000)
+  },
+  beforeDestroy () {
+    clearInterval(this.$timer)
+  },
+  methods: {
+    getDeviceExceptionLevelStatistic () {
+      getDeviceExceptionLevelStatistic().then(({ data }) => {
+        const { total, urgency, hint, common } = data
+        this.itemValue = [total, urgency, hint, common]
+      })
+    }
+  }
+}
+</script>
+

+ 102 - 0
src/views/dashboard/v1/AlarmRate.vue

@@ -0,0 +1,102 @@
+<template>
+  <box title="各大屏预警率占比">
+    <div class="l-flex__fill l-flex--col">
+      <div
+        ref="canvas"
+        class="l-flex__fill"
+      />
+    </div>
+  </box>
+</template>
+
+<script>
+import { getDeviceExceptionRanking } from './api'
+import * as echarts from 'echarts'
+import Box from './Box'
+
+export default {
+  components: {
+    Box
+  },
+  mounted () {
+    this.getDeviceExceptionRanking()
+    this.$timer = setInterval(this.getDeviceExceptionRanking, 30000)
+  },
+  beforeDestroy () {
+    clearInterval(this.$timer)
+  },
+  methods: {
+    getDeviceExceptionRanking () {
+      getDeviceExceptionRanking().then(({ data: { rankItemList } }) => {
+        rankItemList = rankItemList.map(i => {
+          return { name: i.deviceName, value: Number(i.errorCount) }
+        })
+        rankItemList.sort((a, b) => b.value - a.value)
+        if (rankItemList.length > 7) {
+          const otherArray = rankItemList.splice(7)
+          rankItemList.push({
+            name: '其它',
+            value: otherArray.reduce((total, cur) => cur.value + total, 0)
+          })
+        }
+        this.initEchart(rankItemList.length ? rankItemList : [{ name: '其它', value: 0 }])
+      })
+    },
+    initEchart (rankItemList) {
+      if (!this.$echarts) {
+        this.$echarts = echarts.init(this.$refs.canvas)
+      }
+      const option = {
+        textStyle: {
+          fontSize: 16
+        },
+        color: ['#2956F0', '#00D1FF', '#F30DFF', '#22C170', '#FACF21', '#F5761A', '#E01010', '#C4C4C4'],
+        legend: {
+          right: 0,
+          top: 'center',
+          orient: 'vertical',
+          textStyle: {
+            color: '#ffffff'
+          },
+          formatter (name) {
+            return name.length > 10 ? `${name.slice(0, 10)}...` : name
+          },
+          tooltip: {
+            show: true
+          },
+          itemWidth: 25,
+          itemHeight: 25,
+          icon: 'rect',
+          itemGap: 15
+
+        },
+        series: [
+          {
+            name: 'Chart',
+            type: 'pie',
+            radius: [25, 115],
+            center: [230, '50%'],
+            minAngle: 20,
+            roseType: 'radius',
+            label: {
+              show: true,
+              color: '#ffffff',
+              formatter: '{d}%\n\n',
+              padding: [0, -30]
+            },
+            labelLine: {
+              length: 15,
+              length2: 35
+            },
+            data: rankItemList
+          }
+        ],
+        tooltip: {
+          formatter: '名称:{b}<br />占比:{d}%'
+        }
+      }
+      this.$echarts.setOption(option)
+    }
+  }
+}
+</script>

+ 178 - 0
src/views/dashboard/v1/AlarmType.vue

@@ -0,0 +1,178 @@
+<template>
+  <box title="设备告警类型统计">
+    <div class="l-flex__fill l-flex--col">
+      <div
+        ref="canvas"
+        class="l-flex__fill"
+      />
+    </div>
+  </box>
+</template>
+
+<script>
+import { getDeviceExceptionTypeStatistics } from './api'
+import * as echarts from 'echarts'
+import Box from './Box'
+
+export default {
+  name: 'AlarmType',
+  components: {
+    Box
+  },
+  mounted () {
+    this.getDeviceExceptionTypeStatistics()
+    this.$timer = setInterval(this.getDeviceExceptionTypeStatistics, 30000)
+  },
+  beforeDestroy () {
+    clearInterval(this.$timer)
+  },
+  methods: {
+    getDeviceExceptionTypeStatistics () {
+      getDeviceExceptionTypeStatistics()
+        .then(({ data: typeList }) => {
+          typeList = typeList.map(i => {
+            return { name: i.typeName, value: Number(i.count) }
+          })
+          typeList.sort((a, b) => b.value - a.value)
+          if (typeList.length > 5) {
+            const otherArray = typeList.splice(5)
+            typeList.push({
+              name: '其它',
+              value: otherArray.reduce((total, cur) => cur.value + total, 0)
+            })
+          }
+          this.$nextTick(() => {
+            this.initEchart(typeList.length ? typeList : [{ name: '告警', value: 0 }])
+          })
+        })
+    },
+    initEchart (typeList) {
+      const xdata = typeList.map(i => `${i.name}`)
+      const ydata = typeList.map(i => i.value)
+      if (!this.$echarts) {
+        this.$echarts = echarts.init(this.$refs.canvas)
+      }
+      this.$echarts.setOption({
+        title: {
+          text: '',
+          textStyle: {
+            color: '#fff',
+            fontWeight: 'bold'
+          }
+        },
+        xAxis: {
+          name: '(类型)',
+          type: 'category',
+          data: xdata,
+          nameLocation: 'middle',
+          nameTextStyle: {
+            fontSize: 18,
+            color: '#9EA9CD',
+            padding: [-8, 0, 0, 850]
+          },
+          splitLine: {
+            show: false
+          },
+          axisLine: {
+            lineStyle: {
+              color: '#313A5A'
+            }
+          },
+          axisLabel: {
+            interval: 0,
+            color: '#9EA9CD',
+            fontSize: 18
+          },
+          axisTick: {
+            show: false
+          }
+        },
+        yAxis: {
+          name: '(次数)',
+          type: 'value',
+          minInterval: 1,
+          splitLine: {
+            lineStyle: {
+              color: '#313A5A'
+            }
+          },
+          nameTextStyle: {
+            fontSize: 18,
+            color: '#9EA9CD',
+            padding: [0, 50, 20, 0]
+          },
+          axisLine: {
+            show: false,
+            lineStyle: {
+              color: '#4779BC'
+            }
+          },
+          axisLabel: {
+            color: '#9EA9CD',
+            fontSize: 18
+          }
+        },
+        grid: {
+        },
+        series: [
+          {
+            data: ydata,
+            type: 'bar',
+            barWidth: '50%',
+            itemStyle: {
+              color: '#12A3FF'
+            },
+            select: {
+              itemStyle: {
+                color: 'rgb(0, 234, 255)'
+              }
+            },
+            label: {
+              show: true,
+              color: '#fff',
+              position: 'top',
+              fontSize: '20'
+            }
+          }
+        ],
+        emphasis: {
+          itemStyle: {
+            color: '#FFCA1A',
+            borderWidth: 1,
+            borderColor: '#FF2222',
+            borderType: 'solid'
+          }
+        },
+        tooltip: {
+          formatter: '类型:{b}<br />次数:{c}'
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.date__picker {
+  width: 110px;
+
+  ::v-deep .el-input__inner {
+    color: #fff;
+    border-color: #353c59;
+    background-color: transparent;
+    padding-left: 5px;
+    &:hover {
+      border-color: #fff;
+    }
+  }
+  ::v-deep .el-input__prefix {
+    transition: none;
+    left: auto;
+    right: 5px;
+  }
+
+  ::v-deep .el-input__icon {
+    transition: none;
+  }
+}
+</style>

+ 79 - 0
src/views/dashboard/v1/AssetStatistic.vue

@@ -0,0 +1,79 @@
+<template>
+  <Box title="素材统计">
+    <StatisticCard
+      :colors="colors"
+      unit="个"
+      total-color="#00FFF0"
+      title="素材"
+      :items="items"
+    />
+  </Box>
+</template>
+<script>
+import { getAssetAnalysis } from './api.js'
+import { AssetType } from '@/constant'
+import Box from './Box'
+import StatisticCard from './StatisticCard'
+
+export default {
+  name: 'AssetStatistic',
+  components: {
+    StatisticCard,
+    Box
+  },
+  data () {
+    this.statusMap = {
+      [AssetType.IMAGE]: '图片',
+      [AssetType.VIDEO]: '视频',
+      [AssetType.AUDIO]: '音频',
+      [AssetType.PPT]: 'PPT',
+      [AssetType.PDF]: 'PDF',
+      [AssetType.DOC]: 'DOC'
+    }
+    return {
+      colors: [
+        '#FACF21',
+        '#FF6000',
+        '#04FF98',
+        '#F40645',
+        '#00D1FF',
+        '#F30DFF'
+      ],
+      items: []
+    }
+  },
+  created () {
+    this.getColor(this.transform())
+    this.getAssetAnalysis()
+  },
+  methods: {
+    transform (data = {}) {
+      const result = []
+      for (const key in this.statusMap) {
+        if (Object.hasOwnProperty.call(this.statusMap, key)) {
+          const element = data[key]
+          result.push({
+            label: this.statusMap[key],
+            value: element || 0
+          })
+        }
+      }
+      return result
+    },
+    getAssetAnalysis () {
+      getAssetAnalysis().then(({ data }) => {
+        this.getColor(this.transform(data))
+      })
+    },
+    getColor (data) {
+      this.items = data.map((i, index) => {
+        return {
+          ...i,
+          color: this.colors[index]
+        }
+      })
+    }
+  }
+}
+</script>
+<style lang="scss" scoped></style>

+ 154 - 0
src/views/dashboard/v1/Box.vue

@@ -0,0 +1,154 @@
+<template>
+  <div class="l-flex--col c-box">
+    <div class="c-box__border--left" />
+    <div class="c-box__border--right" />
+    <div class="l-flex__fill l-flex--col c-box__main has-bg">
+      <div
+        v-if="title"
+        class="l-flex__none c-box__header l-flex--row"
+      >
+        <div class="u-bold l-flex__fill">{{ title }}</div>
+        <div class="header__decoration">
+          <div class="decoration__bg" />
+          <div class="decoration__bg--under" />
+        </div>
+        <div class="header__rects l-flex--row">
+          <div class="header__rect l-flex__fill" />
+          <div class="header__rect l-flex__fill" />
+          <div class="header__rect l-flex__fill" />
+        </div>
+      </div>
+      <div class="l-flex__fill l-flex--col has-content-padding">
+        <slot />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'Box',
+  props: {
+    title: {
+      type: String,
+      default: ''
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.c-box {
+  position: relative;
+  height: 100%;
+  width: 100%;
+  color: #fff;
+  border: 1px solid rgba(#fff, 0.3);
+
+  &__border {
+    &--left {
+      left: 0;
+      position: absolute;
+      height: 100%;
+      width: 4px;
+      background-image: linear-gradient(
+        to bottom,
+        #fff 0,
+        #fff 16px,
+        transparent 16px,
+        transparent 24px,
+        #2956f0 24px,
+        #2956f0 40px,
+        #2956f0 56px,
+        transparent 56px,
+        transparent 64px,
+        #fff 64px,
+        #fff 80px,
+        transparent 80px,
+        transparent calc(100% - 16px),
+        #fff calc(100% - 16px),
+        #fff 100%
+      );
+    }
+
+    &--right {
+      right: 0;
+      position: absolute;
+      height: 100%;
+      width: 4px;
+      background-image: linear-gradient(
+        to bottom,
+        #fff 0,
+        #fff 16px,
+        transparent 16px,
+        transparent calc(100% - 16px),
+        #fff calc(100% - 16px),
+        #fff 100%
+      );
+    }
+  }
+
+  &__main {
+    background-color: rgba(#1d274b, 0.85);
+    overflow: hidden;
+  }
+
+  &__header {
+    margin: 0 24px 20px;
+    position: relative;
+    height: 64px;
+    line-height: 64px;
+    font-size: 32px;
+    border-bottom: 1px solid rgba(#fff, 0.1);
+  }
+  .header__decoration {
+    position: relative;
+    width: 160px;
+    height: 8px;
+
+    .decoration__bg {
+      width: 100%;
+      height: 100%;
+      background: linear-gradient(
+        270deg,
+        rgba(34, 51, 108, 0) 0%,
+        #22336c 100%
+      );
+    }
+
+    .decoration__bg--under {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      top: 0;
+      background: repeating-linear-gradient(
+        120deg,
+        rgba(#1d274b, 0.85),
+        rgba(#1d274b, 0.85) 4px,
+        transparent 4px,
+        transparent 8px
+      );
+    }
+  }
+
+  .header__rect {
+    width: 16px;
+    height: 8px;
+    &:nth-child(1) {
+      background-color: #8ca6ff;
+    }
+    &:nth-child(2) {
+      margin-left: 4px;
+    }
+    background-color: #5279ff;
+    &:nth-child(3) {
+      margin-left: 4px;
+      background-color: #2556ff;
+    }
+  }
+}
+
+.has-content-padding {
+  padding: 0 24px 24px;
+}
+</style>

+ 84 - 0
src/views/dashboard/v1/Cards.vue

@@ -0,0 +1,84 @@
+<template>
+  <div
+    class="c-dashoboard-cards--v1"
+    :class="{ lg }"
+  >
+    <div
+      v-for="(item, index) in items"
+      :key="index"
+      class="c-dashoboard-cards--v1__item"
+      :style="item.style"
+    >
+      <div class="l-flex--row c-dashoboard-cards--v0__header">
+        <svg-icon
+          class="c-dashoboard-cards--v1__icon"
+          :icon-class="item.icon"
+        />
+        {{ item.label }}
+      </div>
+      <div class="c-dashoboard-cards--v1__value u-bold u-ellipsis u-text-center">{{ item.value }}</div>
+    </div>
+  </div>
+</template>
+
+<script>
+
+export default {
+  name: 'Cards',
+  props: {
+    items: {
+      type: Array,
+      required: true
+    },
+    lg: {
+      type: [Boolean, String],
+      default: false
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.c-dashoboard-cards--v1 {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-gap: 20px 20px;
+
+  &__item {
+    min-width: 0;
+    height: 160px;
+    padding: 24px;
+    background-color: rgba(#060920, 0.3);
+  }
+
+  &__header {
+    height: 36px;
+    font-size: 24px;
+    line-height: 1;
+  }
+
+  &__icon {
+    margin: 0 8px 0 0;
+    font-size: 30px;
+  }
+
+  &__value {
+    max-width: 100%;
+    margin-top: 24px;
+    font-size: 56px;
+  }
+
+  &.lg {
+    grid-template-columns: 1fr 1fr 1fr 1fr;
+    grid-gap: 40px 40px;
+
+    .c-dashoboard-cards--v1__item {
+      height: 180px;
+    }
+
+    .c-dashoboard-cards--v1__value {
+      font-size: 72px;
+    }
+  }
+}
+</style>

+ 204 - 0
src/views/dashboard/v1/DeviceCalender.vue

@@ -0,0 +1,204 @@
+<template>
+  <box title="各大屏当前播放节目">
+    <div class="l-flex__fill l-flex--col c-calendar u-text-center">
+      <div class="l-flex__none l-flex--row c-calendar__header">
+        <div class="col__deviceName">设备名称</div>
+        <div class="col__programName">节目名称</div>
+        <div class="col__calender">时间排期</div>
+        <div class="col__createTime">更新时间</div>
+        <div class="col__status">状态</div>
+      </div>
+      <div class="l-flex__fill u-relative">
+        <div class="c-calendar__list">
+          <vue-seamless-scroll
+            :data="tableData"
+            :class-option="classOption"
+          >
+            <div
+              v-for="item in tableData"
+              :key="item.id"
+              class="l-flex--row c-calendar__item"
+            >
+              <div class="col__deviceName">
+                <auto-text
+                  class="u-text-center"
+                  :text="item.name"
+                />
+              </div>
+              <div class="col__programName">
+                <auto-text :text="item.programName" />
+              </div>
+              <div class="col__calender">
+                <auto-text :text="item.programDesc" />
+              </div>
+              <div class="col__createTime">
+                <auto-text :text="item.updateTime" />
+              </div>
+              <div class="col__status o-red">{{ item.status }}</div>
+            </div>
+          </vue-seamless-scroll>
+        </div>
+      </div>
+    </div>
+  </box>
+</template>
+
+<script>
+import { getTimelines } from './api'
+import {
+  isHit,
+  toDate,
+  getEventDescription
+} from '@/utils/event'
+import VueSeamlessScroll from 'vue-seamless-scroll'
+import Box from './Box'
+
+export default {
+  name: 'DeviceCalender',
+  components: {
+    VueSeamlessScroll,
+    Box
+  },
+  props: {
+    deviceList: {
+      type: Array,
+      required: true
+    }
+  },
+  data () {
+    return {
+      classOption: {
+        step: 0.5,
+        hoverStop: false
+      },
+      programMap: null
+    }
+  },
+  computed: {
+    tableData () {
+      const map = this.programMap
+      return this.deviceList.map(({ id, name, onlineStatus }) => {
+        const data = map ? map[id] : null
+        return {
+          id, name,
+          updateTime: data ? data.updateTime : '-',
+          programName: map
+            ? data?.event?.target.name || '暂无节目'
+            : '查询中...',
+          programDesc: map && data?.event ? getEventDescription(data.event) : '-',
+          status: onlineStatus === 1 && map && data?.event ? '在播' : ''
+        }
+      })
+    }
+  },
+  watch: {
+    deviceList: {
+      handler () {
+        if (this.deviceList.length) {
+          this.getTimelines()
+        }
+      },
+      immediate: true
+    }
+  },
+  methods: {
+    getTimelines () {
+      getTimelines(this.deviceList.map(i => i.id), { custom: true }).then(
+        data => {
+          const map = {}
+          data.forEach(({ deviceId, updateTime, eventDetail }) => {
+            map[deviceId] = {
+              updateTime,
+              event: this.getCurrentEvent(eventDetail)
+            }
+          })
+          this.programMap = map
+        }
+      )
+    },
+    getCurrentEvent (events) {
+      if (events.length) {
+        const now = new Date()
+        const timeline = events.filter(({ until }) => !until || now < new Date(until)).sort((a, b) => {
+          if (b.priority === a.priority) {
+            return toDate(a.start) - toDate(b.start)
+          }
+          return b.priority - a.priority
+        })
+        for (let i = 0; i < timeline.length; i++) {
+          const event = timeline[i]
+          if (isHit(event, now)) {
+            return event
+          }
+        }
+      }
+      return null
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.c-calendar {
+  &__header {
+    color: #9ea9cd;
+    background-color: #313a5a;
+  }
+
+  &__list {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+  }
+
+  &__item {
+    color: #ffffff;
+    border-bottom: 1px solid #313a5a;
+  }
+
+  &__header,
+  &__item {
+    font-size: 18px;
+    line-height: 60px;
+    text-align: center;
+  }
+  .alarm__icon {
+    width: 19px;
+    height: 19px;
+    transform: translateY(-2px);
+  }
+
+  .col {
+    &__deviceName {
+      flex: 1;
+      min-width: 0;
+    }
+
+    &__programName {
+      flex: 1;
+      min-width: 0;
+    }
+
+    &__calender {
+      flex: 2;
+      min-width: 0;
+    }
+
+    &__createTime {
+      flex: 1;
+      min-width: 0;
+    }
+
+    &__status {
+      width: 50px;
+
+      &.o-red {
+        color: #f40645;
+      }
+    }
+  }
+}
+</style>

+ 36 - 0
src/views/dashboard/v1/DeviceStatus.vue

@@ -0,0 +1,36 @@
+<template>
+  <cards
+    :items="itemList"
+    lg
+  />
+</template>
+
+<script>
+import Cards from './Cards'
+
+export default {
+  name: 'DeviceStatus',
+  components: {
+    Cards
+  },
+  props: {
+    items: {
+      type: Array,
+      required: true
+    }
+  },
+  computed: {
+    itemList () {
+      const colors = ['#2956F0', '#04A681', '#F40645', '#C4C4C4']
+
+      return this.items.map((item, index) => {
+        return {
+          ...item,
+          icon: 'menu-four',
+          style: { color: colors[index] }
+        }
+      })
+    }
+  }
+}
+</script>

+ 105 - 0
src/views/dashboard/v1/DonutChart.vue

@@ -0,0 +1,105 @@
+<template>
+  <div ref="canvas" />
+</template>
+
+<script>
+import * as echarts from 'echarts'
+
+export default {
+  name: 'DonutChart',
+  props: {
+    item: {
+      type: Object,
+      default: () => null
+    }
+  },
+  data () {
+    return {}
+  },
+  watch: {
+    item () {
+      this.init()
+    }
+  },
+  mounted () {
+    this.init()
+  },
+  methods: {
+    init () {
+      if (!this.item) { return }
+      const { value, name, color } = this.item
+      if (!this.$echarts) {
+        this.$echarts = echarts.init(this.$refs.canvas)
+      }
+      this.$echarts.setOption({
+        tooltip: {
+          show: false
+        },
+        title: [{
+          text: `${value}%`,
+          left: 'center',
+          top: '35%',
+          textStyle: {
+            color: '#ffff',
+            fontSize: 24
+          }
+        }, {
+          text: name,
+          left: 'center',
+          bottom: '28%',
+          textStyle: {
+            color: '#ffff',
+            fontSize: 24
+          }
+        }],
+        series: [
+          {
+            type: 'pie',
+            name: '名称',
+            radius: ['80%', '100%'],
+            center: ['50%', '40%'],
+            hoverAnimation: false, // 鼠标hover高亮隐藏
+            label: {
+              normal: {
+                position: 'inner',
+                show: false
+              }
+            },
+            data: [
+              {
+                value,
+                itemStyle: {
+                  normal: {
+                    color
+                  },
+                  label: {
+                    show: false // 去掉指示线
+                  },
+                  labelLine: {
+                    show: false // 去掉指示线
+                  }
+                }
+              },
+              {
+                value: 100 - value,
+                itemStyle: {
+                  normal: {
+                    color: '#141C3B'
+                  },
+                  label: {
+                    show: false // 去掉指示线
+                  },
+                  labelLine: {
+                    show: false // 去掉指示线
+                  }
+                }
+              }
+            ]
+          }
+        ]
+      })
+    }
+  }
+}
+</script>
+<style lang="scss" scoped></style>

+ 76 - 0
src/views/dashboard/v1/Header.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="c-device-dashboard-header">
+    <div class="c-device-dashboard-header__name u-bold">{{ title }}</div>
+    <div class="c-device-dashboard-header__time">
+      <span>{{ date }}</span>
+      <span class="time-week">{{ week }} </span>
+      <span class="time-detail">{{ time }}</span>
+    </div>
+  </div>
+</template>
+
+<script>
+import { parseTime } from '@/utils'
+
+export default {
+  data () {
+    return {
+      title: process.env.VUE_APP_NAME,
+      date: '',
+      week: '',
+      time: ''
+    }
+  },
+  created () {
+    this.getCurrentTime()
+    this.$timer = setInterval(this.getCurrentTime, 500)
+  },
+  beforeDestroy () {
+    clearInterval(this.$timer)
+  },
+  methods: {
+    getCurrentTime () {
+      const now = parseTime(new Date(), '{y}/{m}/{d} {h}:{i}:{s}').split(' ')
+      this.date = now[0]
+      this.week = `星期${['日', '一', '二', '三', '四', '五', '六'][new Date().getDay()]}`
+      this.time = now[1]
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.c-device-dashboard-header {
+  position: relative;
+  height: 218px;
+  padding: 73px 0;
+  color: #fff;
+  font-size: 84px;
+  text-align: center;
+  line-height: 96px;
+
+  &__name {
+    transform: translateY(-25px);
+    letter-spacing: 2rem;
+  }
+
+  &__time {
+    display: flex;
+    align-items: flex-end;
+    position: absolute;
+    right: 120px;
+    bottom: 40px;
+    font-size: 28px;
+    line-height: 1;
+  }
+
+  .time-week {
+    margin: 0 72px;
+  }
+
+  .time-detail {
+    font-size: 48px;
+    font-weight: bold;
+  }
+}
+</style>

+ 109 - 0
src/views/dashboard/v1/LineChart.vue

@@ -0,0 +1,109 @@
+<template>
+  <div ref="canvas" />
+</template>
+
+<script>
+import * as echarts from 'echarts'
+
+export default {
+  name: 'LineChart',
+  props: {
+    list: {
+      type: Array,
+      default: () => []
+    },
+    colorType: {
+      type: Number,
+      default: 0
+    },
+    unit: {
+      type: String,
+      default: '%'
+    }
+  },
+  data () {
+    return {
+      colorMap: [
+        ['rgb(0, 209, 255)', 'rgba(0,209,255,0.5)'],
+        [' rgb(243,13,255)', ' rgba(243,13,255,0.5)'],
+        ['rgba(255,96,0)', 'rgba(255,96,0,0.5)'],
+        ['rgba(4,255,152)', 'rgba(4,255,152,0.5)']
+      ]
+    }
+  },
+  watch: {
+    list () {
+      this.renderChart(true)
+    }
+  },
+  mounted () {
+    this.init()
+  },
+  methods: {
+    renderChart (type) {
+      if (!this.$echarts || !this.list.length) {
+        return
+      }
+
+      this.$echarts.setOption(
+        {
+          xAxis: {
+            type: 'category',
+            show: false,
+            boundaryGap: false
+          },
+          yAxis: {
+            type: 'value',
+            show: false
+          },
+          title: {
+            text: `${Math.floor(+this.list[this.list.length - 1][1])}${this.unit}`,
+            top: 'center',
+            right: '0',
+            textStyle: {
+              color: '#fff',
+              fontSize: 18
+            }
+          },
+
+          grid: {
+            right: this.unit === '%' ? '35%' : '50%'
+          },
+
+          series: [
+            {
+              data: this.list,
+              type: 'line',
+              smooth: true,
+              symbol: 'none',
+              lineStyle: {
+                color: this.colorMap[this.colorType][0]
+              },
+              areaStyle: {
+                color: echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                  {
+                    offset: 0,
+                    color: this.colorMap[this.colorType][1]
+                  },
+                  {
+                    offset: 1,
+                    color: 'rgba(33,190,230,0)'
+                  }
+                ])
+              }
+            }
+          ]
+        },
+        type
+      )
+    },
+    init () {
+      if (!this.$echarts) {
+        this.$echarts = echarts.init(this.$refs.canvas)
+      }
+      this.renderChart()
+    }
+  }
+}
+</script>
+<style lang="scss" scoped></style>

+ 295 - 0
src/views/dashboard/v1/Map.vue

@@ -0,0 +1,295 @@
+<template>
+  <box class="map-box">
+    <div class="l-flex__fill l-flex--col device-map">
+      <DeviceStatus
+        :items="statusData"
+        style="margin: 70px 0 40px"
+      />
+      <div
+        ref="map"
+        class="l-flex__fill"
+      >
+        <AlarmInfo
+          v-if="isShowAlarm"
+          :alarm="alarm"
+          :style="alarmPositionStyle"
+          class="c-alarm"
+        />
+      </div>
+    </div>
+  </box>
+</template>
+
+<script>
+import Box from './Box'
+import DeviceStatus from './DeviceStatus'
+import AlarmInfo from './AlarmInfo'
+import AMapLoader from '@amap/amap-jsapi-loader'
+const onlineIcon = require('@/assets/v1/icon_position1.svg')
+const offlineIcon = require('@/assets/v1/icon_position2.svg')
+
+export default {
+  name: 'Map',
+  components: {
+    Box,
+    DeviceStatus,
+    AlarmInfo
+  },
+  props: {
+    deviceList: {
+      type: Array,
+      required: true
+    },
+    statusData: {
+      type: Array,
+      required: true
+    },
+    city: {
+      type: String,
+      default: '深圳市'
+    }
+  },
+  data () {
+    this.curMarker = null
+    this.polylines = null
+    return {
+      marks: [],
+      alarmPosition: [],
+      isShowAlarm: false,
+      alarm: {}
+    }
+  },
+  computed: {
+    alarmPositionStyle () {
+      if (!this.alarmPosition.length) {
+        return {}
+      }
+      return {
+        left: this.alarmPosition[0],
+        top: this.alarmPosition[1]
+      }
+    }
+  },
+  watch: {
+    deviceList () {
+      if (this.map) {
+        this.refreshMarkers()
+      }
+    }
+  },
+  mounted () {
+    this.initMap()
+  },
+  beforeDestroy () {
+    this.map?.destroy()
+  },
+  methods: {
+    initMap () {
+      AMapLoader.load({
+        key: process.env.VUE_APP_GAODE_MAP_KEY,
+        version: '2.0',
+        plugins: ['AMap.DistrictSearch']
+      }).then(AMap => {
+        this.$AMap = AMap
+
+        const options = {
+          subdistrict: 2,
+          extensions: 'all',
+          level: 'province'
+        }
+
+        const district = new AMap.DistrictSearch(options)
+        // 查询区域
+        district.search(this.city, (status, result) => {
+          const bounds = result.districtList[0]['boundaries']
+          const mask = []
+          console.log(result)
+          for (let i = 0; i < bounds.length; i++) {
+            mask.push([bounds[i]])
+          }
+
+          // 实例化地图
+          const map = new AMap.Map(this.$refs.map, {
+            mask,
+            // expandZoomRange: true, // 开启显示范围设置
+            // zooms: [7, 20], // 最小显示级别为7,最大显示级别为20
+            // viewMode: '3D', // 这里特别注意,设置为3D则其它地区不显示
+            zoomEnable: true, // 是否可以缩放地图
+            // resizeEnable: true
+            mapStyle: 'amap://styles/darkblue'
+          })
+          this.map = map
+
+          this.marks = []
+          this.$deviceMap = {}
+
+          this.$onlineIcon = new AMap.Icon({
+            image: onlineIcon
+          })
+          this.$offlineIcon = new AMap.Icon({
+            image: offlineIcon
+          })
+
+          this.deviceList.forEach(({ id, longitude, latitude, name, onlineStatus }) => {
+            if (longitude && latitude) {
+              const markObj = new AMap.Marker({
+                position: [Number(longitude), Number(latitude)],
+                title: name,
+                icon: onlineStatus === 1 ? this.$onlineIcon : this.$offlineIcon
+              })
+              this.marks.push(markObj)
+              this.$deviceMap[id] = {
+                onlineStatus,
+                markObj
+              }
+            }
+          })
+          map.add(this.marks)
+
+          const polylines = []
+          // 添加描边
+          for (let i = 0; i < bounds.length; i += 1) {
+            polylines.push(
+              new AMap.Polyline({
+                path: bounds[i],
+                strokeColor: '#182C65',
+                strokeWeight: 0,
+                map
+              })
+            )
+          }
+          this.polylines = polylines
+          this.resetView()
+          map.on('complete', () => {
+            map.on('zoomstart', () => {
+              this.isShowAlarm = false
+            })
+            map.on('movestart', () => {
+              this.isShowAlarm = false
+            })
+          })
+
+          // 区级线条
+          for (const item of result.districtList[0].districtList) {
+            district.search(item.adcode, (status, result) => {
+              const bounds = result.districtList[0]['boundaries']
+              for (let i = 0; i < bounds.length; i++) {
+                new AMap.Polyline({
+                  path: bounds[i],
+                  strokeColor: '#182C65',
+                  strokeWeight: 4,
+                  map
+                })
+              }
+            })
+          }
+        })
+      })
+    },
+    setNewAlarm (alarm) {
+      let marker
+      for (const item of this.marks) {
+        if (item._originOpts.id === alarm.deviceId) {
+          marker = item
+          break
+        }
+      }
+      if (!marker) {
+        return
+      }
+      this.alarm = alarm
+      alarm.status = alarm.status?.label
+      alarm.mac = marker._originOpts.mac
+      alarm.address = marker._originOpts.address
+      //
+      this.map.on('zoomend', this.onSetNewAlarmFitView)
+      this.map.on('moveend', this.onSetNewAlarmFitView)
+
+      this.map.setFitView(marker)
+      this.curMarker = marker
+    },
+    resetView () {
+      this.map.setFitView(this.polylines)
+      this.isShowAlarm = false
+    },
+    onSetNewAlarmFitView () {
+      this.showAlarm(this.curMarker._points)
+    },
+    showAlarm (position) {
+      this.map.off('zoomend', this.onSetNewAlarmFitView)
+      this.map.off('moveend', this.onSetNewAlarmFitView)
+      const x = this.$refs.map.offsetWidth
+      // const y = this.$refs.map.offsetHeight
+      const padding = 40
+      const width = 700 / 2
+      const height = 420
+      let left = position[0]
+      let top = position[1]
+      if (left < width) {
+        left = 0
+      } else if (left + width > x) {
+        left = x - width * 2
+      } else {
+        left -= width
+      }
+      if (left < padding) {
+        left = padding
+      }
+      if (top - height - padding > 0) {
+        top = top - height - padding
+      } else {
+        top += padding
+      }
+
+      this.alarmPosition = [`${left}px`, `${top}px`]
+      this.isShowAlarm = true
+    },
+    refreshMarkers () {
+      const map = {}
+      const arr = []
+      this.deviceList.forEach(({ id, longitude, latitude, name, onlineStatus }) => {
+        if (longitude && latitude) {
+          const device = this.$deviceMap[id]
+          if (device) {
+            device.onlineStatus = onlineStatus
+            device.markObj.setPosition([Number(longitude), Number(latitude)])
+            device.markObj.setIcon(onlineStatus === 1 ? this.$onlineIcon : this.$offlineIcon)
+            map[id] = device
+            delete this.$deviceMap[id]
+          } else {
+            const markObj = new this.$AMap.Marker({
+              position: [Number(longitude), Number(latitude)],
+              title: name,
+              icon: onlineStatus === 1 ? this.$onlineIcon : this.$offlineIcon
+            })
+            map[id] = {
+              onlineStatus,
+              markObj
+            }
+            arr.push(markObj)
+          }
+        }
+      })
+      if (arr.length) {
+        this.map.add(arr)
+      }
+      const marks = Object.values(this.$deviceMap).filter(Boolean).map(({ markObj }) => markObj)
+      if (marks.length) {
+        this.map.remove(marks)
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.map-box {
+  .amap-container {
+    background: none !important;
+  }
+  .c-alarm {
+    position: absolute;
+    z-index: 9;
+  }
+}
+</style>

+ 295 - 0
src/views/dashboard/v1/MessageNotice.vue

@@ -0,0 +1,295 @@
+<template>
+  <Box title="消息通知">
+    <div class="l-flex__fill l-flex--col c-messageNotice">
+      <div class="l-flex__none l-flex--row c-messageNotice__header">
+        <div class="l-flex__none col__time">时间</div>
+        <div class="l-flex__fill col__deviceName">设备名称</div>
+        <div class="l-flex__none col__type">类型</div>
+        <div class="l-flex__none col__handle">处理方式</div>
+        <div class="l-flex__none col__status">处理结果</div>
+      </div>
+      <status-wrapper
+        v-if="listData.length === 0"
+        class="l-flex__fill l-flex--row center"
+      />
+      <template v-else>
+        <div class="l-flex__fill u-relative">
+          <div class="c-messageNotice__newList">
+            <div
+              v-for="item in newAlarmList.slice(0, 4)"
+              :key="item.id"
+              class="l-flex--row c-messageNotice__item new"
+            >
+              <div class="l-flex__none col__time l-flex--row center">
+                <svg-icon
+                  class="alarm__icon"
+                  icon-class="v0-alarm"
+                  :style="{ color: colors[item.level] }"
+                />
+                <div>{{ item.happenTime }}</div>
+              </div>
+              <div class="l-flex__fill col__deviceName">
+                <auto-text
+                  class="u-text-center"
+                  :text="item.deviceName"
+                />
+              </div>
+              <div class="l-flex__none col__type">
+                <auto-text
+                  class="u-text-center"
+                  :text="item.type"
+                />
+              </div>
+              <div class="l-flex__none col__handle">
+                <auto-text
+                  class="u-text-center"
+                  :text="item.handle"
+                />
+              </div>
+              <div class="l-flex__none col__status">
+                <auto-text
+                  class="u-text-center"
+                  :text="item.status.label"
+                />
+              </div>
+            </div>
+          </div>
+          <div class="c-messageNotice__list">
+            <vue-seamless-scroll
+              ref="seamlessScroll"
+              :data="listData"
+              :class-option="classOption"
+            >
+              <div
+                v-for="item in listData"
+                :key="item.id"
+                class="l-flex--row c-messageNotice__item"
+              >
+                <div class="l-flex__none col__time l-flex--row center">
+                  <svg-icon
+                    class="alarm__icon"
+                    icon-class="v0-alarm"
+                    :style="{ color: colors[item.level] }"
+                  />
+                  <div>{{ item.happenTime }}</div>
+                </div>
+                <div class="l-flex__fill col__deviceName">
+                  <auto-text
+                    class="u-text-center"
+                    :text="item.deviceName"
+                  />
+                </div>
+                <div class="l-flex__none col__type">
+                  <auto-text
+                    class="u-text-center"
+                    :text="item.type"
+                  />
+                </div>
+                <div class="l-flex__none col__handle">
+                  <auto-text
+                    class="u-text-center"
+                    :text="item.handle"
+                  />
+                </div>
+                <div class="l-flex__none col__status">
+                  <auto-text
+                    class="u-text-center"
+                    :text="item.status.label"
+                  />
+                </div>
+              </div>
+            </vue-seamless-scroll>
+          </div>
+        </div>
+      </template>
+    </div>
+  </Box>
+</template>
+
+<script>
+import { getDeviceAlarms } from '@/api/device'
+import { addTenant } from '@/api/base'
+import { parseTime } from '@/utils/index.js'
+import VueSeamlessScroll from 'vue-seamless-scroll'
+import Box from './Box'
+
+const isTest = false
+
+export default {
+  components: {
+    Box,
+    VueSeamlessScroll
+  },
+  props: {
+    deviceList: {
+      type: Array,
+      required: true
+    }
+  },
+  data () {
+    return {
+      classOption: {
+        step: 0.4,
+        hoverStop: false
+      },
+      listData: [],
+      newAlarmList: [],
+      colors: ['#04A681', '#FFA000', '#F40645']
+    }
+  },
+  created () {
+    this.getDeviceAlarms()
+    this.$timer = setInterval(this.getDeviceAlarms, 10000)
+  },
+  beforeDestroy () {
+    clearTimeout(this.$timer)
+  },
+  methods: {
+    getTag (value) {
+      switch (value) {
+        case 0:
+          return {
+            type: 'danger',
+            label: '否'
+          }
+        case 1:
+          return {
+            type: 'success',
+            label: '是'
+          }
+        default:
+          return {
+            label: '-'
+          }
+      }
+    },
+    getDeviceAlarms () {
+      getDeviceAlarms(
+        addTenant({
+          pageIndex: 1,
+          pageSize: 100
+        })
+      ).then(({ data }) => {
+        if (isTest) {
+          if (this.$rupdate) {
+            if (!this.deviceList.length) { return }
+            if (this.newAlarmList.length > 1) {
+              this.newAlarmList = []
+              return
+            }
+            data = this.listData.slice()
+            const newAlarm = this.deviceList[Math.floor(Math.random() * this.deviceList.length)]
+            data.push({
+              'id': new Date().valueOf(),
+              'deviceName': newAlarm.name,
+              'level': 2,
+              'happenTime': parseTime(new Date()),
+              'deviceId': newAlarm.id,
+              'asset': null,
+              'type': '设备上线',
+              'handle': '未干预',
+              'status': {
+                'label': '-'
+              }
+            })
+          } else {
+            this.$rupdate = true
+          }
+        }
+        if (!data.length) {
+          return
+        }
+        const length = data.length
+        const lastId = this.listData[0]?.id
+        let emit = false
+        for (let i = 0; i < length; i++) {
+          const item = data[i]
+          if (item.id === lastId) {
+            break
+          }
+          if (item.level === 2) {
+            this.newAlarmList.unshift(item)
+            if (!emit) {
+              this.$emit('new-alarm', item)
+              emit = true
+            }
+          }
+        }
+
+        this.listData = data
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.c-messageNotice {
+  &__header {
+    color: #9ea9cd;
+    background-color: #313a5a;
+  }
+
+  &__list {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+  }
+
+  &__item {
+    color: #ffffff;
+    border-bottom: 1px solid #313a5a;
+
+    &.new {
+      color: #f40645;
+      font-weight: bold;
+    }
+  }
+
+  &__header,
+  &__item {
+    font-size: 18px;
+    line-height: 60px;
+    text-align: center;
+  }
+
+  .alarm__icon {
+    width: 19px;
+    height: 19px;
+    transform: translateY(-2px);
+  }
+
+  .col {
+    &__time {
+      width: 200px;
+    }
+    &__type {
+      width: 250px;
+    }
+
+    &__handle {
+      width: 120px;
+    }
+
+    &__status {
+      width: 120px;
+    }
+  }
+
+  &__newList {
+    animation: sparkle 2s linear infinite;
+    &:hover {
+      animation: none;
+    }
+  }
+
+  @keyframes sparkle {
+    0% {
+      opacity: 1;
+    }
+    100% {
+      opacity: 0;
+    }
+  }
+}
+</style>

+ 58 - 0
src/views/dashboard/v1/ProgramRate.vue

@@ -0,0 +1,58 @@
+<template>
+  <Box title="节目类型占比">
+    <div class="l-flex__fill l-flex--row">
+      <DonutChart
+        v-for="item in list"
+        :key="item.key"
+        :item="item"
+        class="l-flex__fill chart"
+      />
+    </div>
+  </Box>
+</template>
+
+<script>
+import Box from './Box'
+import DonutChart from './DonutChart'
+
+export default {
+  name: 'ProgramRate',
+  components: {
+    Box,
+    DonutChart
+  },
+  data () {
+    return {
+      list: [
+        {
+          name: '广告',
+          value: 55,
+          color: '#F30DFF',
+          key: 1
+        },
+        {
+          name: '公益',
+          value: 35,
+          color: '#00D1FF',
+          key: 2
+        },
+        {
+          name: '应急',
+          value: 10,
+          color: '#FACF21',
+          key: 3
+        }
+      ]
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.chart {
+  height: 100%;
+  margin-left:40px ;
+  &:nth-child(1){
+    margin-left: 0;
+  }
+}
+</style>

+ 77 - 0
src/views/dashboard/v1/ProgramStatistic.vue

@@ -0,0 +1,77 @@
+<template>
+  <Box title="节目统计">
+    <StatisticCard
+      :colors="colors"
+      unit="条"
+      total-color="#2956F0"
+      title="节目"
+      :items="items"
+    />
+  </Box>
+</template>
+<script>
+import { getProgramAnalysis } from './api.js'
+import StatisticCard from './StatisticCard'
+import Box from './Box'
+
+export default {
+  name: 'ProgramStatistic',
+  components: {
+    StatisticCard,
+    Box
+  },
+  data () {
+    this.statusMap = {
+      draft: '草稿',
+      subjectToAudit: '待审核',
+      reviewed: '已审核',
+      rejected: '驳回'
+    }
+    return {
+      colors: [
+        '#FF6000',
+        '#04FF98',
+        '#F30DFF',
+        '#C4C4C4',
+        '#F40645'
+      ],
+      items: [
+      ]
+    }
+  },
+  created () {
+    this.getColor(this.transform())
+    this.getProgramAnalysis()
+  },
+  methods: {
+    transform (data = {}) {
+      const result = []
+      for (const key in this.statusMap) {
+        if (Object.hasOwnProperty.call(this.statusMap, key)) {
+          const element = data[key]
+          result.push({
+            label: this.statusMap[key],
+            value: element || 0
+          })
+        }
+      }
+      return result
+    },
+    getProgramAnalysis () {
+      getProgramAnalysis().then(({ data }) => {
+        this.getColor(this.transform(data))
+      })
+    },
+    getColor (data) {
+      this.items = data.map((i, index) => {
+        return {
+          ...i,
+          color: this.colors[index]
+        }
+      })
+    }
+  }
+}
+</script>
+<style lang="scss" scoped
+></style>

+ 119 - 0
src/views/dashboard/v1/ProgramTop.vue

@@ -0,0 +1,119 @@
+<template>
+  <Box title="节目播放TOP5">
+    <div class="l-flex__fill l-flex--col c-top">
+      <div
+        v-for="item in list"
+        :key="item.key"
+        :item="item"
+        class="l-flex__fill block"
+      >
+        <div class="block__intro">
+          <div class="block__name">{{ item.name }}</div>
+          <div class="block__value">{{ item.value }}</div>
+        </div>
+        <div class="block__progress--outer">
+          <div
+            class="block__progress--inner"
+            :style="{width:item.rate}"
+          />
+        </div>
+      </div>
+    </div>
+  </Box>
+</template>
+
+<script>
+import Box from './Box'
+
+export default {
+  name: 'ProgramTop',
+  components: {
+    Box
+  },
+  data () {
+    return {
+      list: [
+        {
+          name: '消防安全教育宣传',
+          value: 200
+        },
+        {
+          name: '城市建设宣传',
+          value: 140
+        },
+        {
+          name: '招商广告宣传',
+          value: 100
+        },
+        {
+          name: '安全生产宣传',
+          value: 95
+        },
+        {
+          name: '广告宣传',
+          value: 87
+        }
+      ]
+    }
+  },
+  mounted () {
+    this.getRate()
+  },
+  methods: {
+    getRate () {
+      if (this.list.length === 1) {
+        this.list[0].rate = '100%'
+        return
+      }
+      let total = this.list.reduce((total, cur) => total + cur.value, 0)
+      total *= 0.6
+      this.list = this.list.map(i => {
+        return {
+          ...i,
+          rate: `${i.value * 100 / total}%`
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.block {
+  height: 20%;
+  padding-top: 20px;
+  &:nth-child(1) {
+    padding-top: 0;
+  }
+  &__intro {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 10px;
+  }
+  &__name {
+    font-size: 18px;
+    color: #9ea9cd;
+    line-height: 27px;
+    height: 18px;
+  }
+  &__value {
+    font-size: 18px;
+    line-height: 21px;
+  }
+  &__progress--outer {
+    height: 20px;
+    position: relative;
+    background-color: #060920;
+    width: 100%;
+    .block__progress--inner {
+      position: absolute;
+      width: 0;
+      top: 0;
+      left: 0;
+      bottom: 0;
+      background-color: #00d1ff;
+      transition: all 1s;
+    }
+  }
+}
+</style>

+ 65 - 0
src/views/dashboard/v1/Record.vue

@@ -0,0 +1,65 @@
+<template>
+  <box title="大屏实时画面">
+    <div
+      v-if="options.list.length"
+      class="l-flex__auto c-record-grid"
+    >
+      <device-player
+        v-for="item in options.list"
+        :key="item.identifier"
+        :device="item"
+        controls
+        autoplay
+        retry
+      />
+    </div>
+    <div
+      v-else
+      class="l-flex__auto l-flex--col jcenter"
+    >
+      <status-wrapper />
+    </div>
+  </Box>
+</template>
+
+<script>
+import { getDevices } from '@/api/device'
+import { createListOptions } from '@/utils'
+import Box from './Box'
+
+export default {
+  name: 'DeviceRecord',
+  components: {
+    Box
+  },
+  data () {
+    return {
+      options: createListOptions({ activate: 2, onlineStatus: 1, pageSize: 9 })
+    }
+  },
+  created () {
+    this.getDevices()
+    this.$timer = setInterval(this.getDevices, 10000)
+  },
+  beforeDestroy () {
+    clearInterval(this.$timer)
+  },
+  methods: {
+    getDevices () {
+      getDevices(this.options.params, { custom: true }).then(({ data }) => {
+        this.options.list = data
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.c-record-grid {
+  display: grid;
+  grid-template-rows: 1fr 1fr 1fr;
+  grid-template-columns: 1fr 1fr 1fr;
+  grid-row-gap: 20px;
+  grid-column-gap: 20px;
+}
+</style>

+ 125 - 0
src/views/dashboard/v1/StatisticCard.vue

@@ -0,0 +1,125 @@
+<template>
+  <div class="l-flex--col">
+    <div class="block__total l-flex--col">
+      <div class="total__title u-bold u-text-center">{{ title }}总数</div>
+      <div
+        class="total__num u-bold u-text-center"
+        :style="{ color: totalColor }"
+      >
+        {{ total }} <span class="total__unit">{{ unit }}</span>
+      </div>
+    </div>
+    <div class="block--secondary l-flex wrap">
+      <div
+        v-for="item in items"
+        :key="item.name"
+        class="block__item"
+      >
+        <div class="item__title u-text-center">
+          {{ item.label }}
+        </div>
+        <div
+          class="item__num u-bold u-text-center"
+          :style="{ color: item.color }"
+        >
+          {{ item.value }} <span class="item__unit">{{ unit }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'StatisticCard',
+  props: {
+    title: {
+      type: String,
+      default: '统计'
+    },
+    unit: {
+      type: String,
+      default: ''
+    },
+    items: {
+      type: Array,
+      default: () => []
+    },
+    totalColor: {
+      type: String,
+      default: '#FFFFFF'
+    }
+  },
+  data () {
+    return {}
+  },
+  computed: {
+    total () {
+      return this.items.reduce((total, cur) => total + cur.value, 0)
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+@mixin unit {
+  transform: translateY(-3px);
+  display: inline-block;
+  color: #ffffff;
+}
+.block__total {
+  .total {
+    &__title {
+      font-size: 24px;
+      height: 24px;
+      line-height: 36px;
+      color: #ffffff;
+      margin:10px 0 20px ;
+    }
+    &__num {
+      font-size: 72px;
+      height: 85px;
+      line-height: 85px;
+    }
+    &__unit {
+      font-size: 18px;
+      @include unit;
+    }
+  }
+}
+.block--secondary {
+  margin-top: 10px;
+  .block__item {
+    width: calc(100% / 3);
+    height: 95px;
+    border: 1px solid #313a5a;
+    border-top: none;
+    border-left: none;
+    &:nth-child(3n) {
+      border-right: none;
+    }
+    &:nth-last-child(-n + 3):nth-child(3n + 1),
+    &:nth-last-child(-n + 3):nth-child(3n + 1) ~ .block__item {
+      border-bottom: none;
+    }
+    .item {
+      &__title {
+        margin-top: 10px;
+        height: 18px;
+        font-size: 18px;
+        color: #ffffff;
+        line-height: 27px;
+      }
+      &__num {
+        height: 38px;
+        font-size: 32px;
+        line-height: 38px;
+        margin-top: 14px;
+      }
+      &__unit {
+        font-size: 12px;
+        @include unit;
+      }
+    }
+  }
+}
+</style>

+ 232 - 0
src/views/dashboard/v1/SystemLoad.vue

@@ -0,0 +1,232 @@
+<template>
+  <Box title="系统实时负载">
+    <div class="l-flex__fill l-flex--col c-load">
+      <div class="l-flex c-duration u-bold">
+        系统运行时长<span class="c-duration__num">{{ diffDay }}</span>天
+      </div>
+      <div class="l-flex__none l-flex--row c-load__header">
+        <div class="l-flex__fill">服务器</div>
+        <div class="l-flex__fill">CPU使用率</div>
+        <div class="l-flex__fill">内存使用率</div>
+        <div class="l-flex__fill">磁盘空间使用率</div>
+        <div class="l-flex__fill col__net">网络</div>
+        <div class="l-flex__fill">状态</div>
+      </div>
+      <status-wrapper v-if="!tableData.length" />
+      <template v-else>
+        <div class="l-flex__fill u-relative">
+          <div class="c-load__list">
+            <vue-seamless-scroll
+              :data="tableData"
+              :class-option="classOption"
+            >
+              <div
+                v-for="item in tableData"
+                :key="item.id"
+                class="l-flex--row c-load__item"
+              >
+                <div class="l-flex__fill">
+                  <auto-text
+                    class="u-text-center"
+                    :text="item.ip"
+                  />
+                </div>
+                <div class="l-flex__fill item__chart l-flex--col">
+                  <LineChart
+                    class="l-flex__fill"
+                    :list="item.cpu"
+                    :color-type="0"
+                  />
+                </div>
+                <div class="l-flex__fill item__chart l-flex--col">
+                  <LineChart
+                    class="l-flex__fill"
+                    :list="item.memory"
+                    :color-type="1"
+                  />
+                </div>
+                <div class="l-flex__fill item__chart l-flex--col">
+                  <LineChart
+                    class="l-flex__fill"
+                    :list="item.disk"
+                    :color-type="2"
+                  />
+                </div>
+                <div class="l-flex__fill item__chart l-flex--col col__net">
+                  <LineChart
+                    class="l-flex__fill"
+                    :list="item.net"
+                    :color-type="3"
+                    unit="Kbps"
+                  />
+                </div>
+                <div class="l-flex__fill">
+                  <auto-text
+                    class="u-text-center"
+                    :text="item.status === 1 ? '正常' : '离线'"
+                    :style="{
+                      color: item.status === 1 ? '#04FF98' : '#F40645',
+                    }"
+                  />
+                </div>
+              </div>
+            </vue-seamless-scroll>
+          </div>
+        </div>
+      </template>
+    </div>
+  </Box>
+</template>
+
+<script>
+import Box from './Box'
+import LineChart from './LineChart'
+import VueSeamlessScroll from 'vue-seamless-scroll'
+
+export default {
+  name: 'SystemLoad',
+  components: {
+    Box,
+    VueSeamlessScroll,
+    LineChart
+  },
+  props: {
+    deviceList: {
+      type: Array,
+      default: () => []
+    }
+  },
+  data () {
+    this.$count = 0
+    return {
+      loaded: false,
+      error: false,
+      classOption: {
+        step: 0.5,
+        hoverStop: false
+      },
+      initDay: '2022-1-1',
+      tableData: []
+    }
+  },
+  computed: {
+    diffDay () {
+      return Math.floor(
+        (Date.parse(new Date()) - Date.parse(this.initDay)) / (1000 * 3600 * 24)
+      )
+    }
+  },
+  created () {
+    this.initData()
+  },
+  methods: {
+    getRandomData (value = Math.floor(50 + Math.random() * 10), limit = true) {
+      let gap = Math.floor(Math.random() * 20 - 10)
+      if (!limit) {
+        gap *= 3
+      }
+      value += gap
+      if (limit) {
+        if (value < 0) {
+          value = 10
+        }
+        if (value > 100) {
+          value = 90
+        }
+      } else if (value < 0) {
+        value = 10
+      }
+      this.$count++
+      return [this.$count, value]
+    },
+    getChartData (num, limit) {
+      const arr = [this.getRandomData(num, limit)]
+      for (let index = 0; index < 30; index++) {
+        arr.push(this.getRandomData(arr[index][1], limit))
+      }
+      return arr
+    },
+    initData () {
+      this.loaded = true
+      this.tableData = Array.from({ length: 2 }, (i, index) => {
+        return {
+          ip: ['主服务器', '副服务器'][index],
+          cpu: this.getChartData(),
+          memory: this.getChartData(),
+          disk: this.getChartData(),
+          net: this.getChartData(700, false),
+          status: 1,
+          id: index
+        }
+      })
+      this.updateValue()
+    },
+    updateValue () {
+      const map = ['cpu', 'memory', 'disk', 'net']
+      setTimeout(() => {
+        this.tableData = this.tableData.map(i => {
+          for (const key of map) {
+            i[key].shift()
+            i[key].push(
+              this.getRandomData(i[key][i[key].length - 1][1], key !== 'net')
+            )
+          }
+          return i
+        })
+        this.updateValue()
+      }, 1000)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.c-load {
+  &__header {
+    color: #9ea9cd;
+    background-color: #313a5a;
+  }
+  .c-duration {
+    font-size: 24px;
+    color: #ffffff;
+    justify-content: center;
+    align-items: flex-end;
+    margin: 5px 0 50px;
+    &__num {
+      margin-left: 60px;
+      margin-right: 15px;
+      transform: translateY(14px);
+      font-size: 72px;
+      color: #00d1ff;
+    }
+  }
+  &__list {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+  }
+
+  &__header,
+  &__item {
+    font-size: 18px;
+    line-height: 60px;
+    height: 60px;
+    text-align: center;
+  }
+  &__item {
+    color: #ffffff;
+    border-bottom: 1px solid #313a5a;
+    height: 72px;
+    line-height: 72px;
+  }
+  .item__chart {
+    height: 100%;
+  }
+  .col__net {
+    flex: 1 1 50px;
+  }
+}
+</style>

+ 105 - 0
src/views/dashboard/v1/api.js

@@ -0,0 +1,105 @@
+import request, { tenantRequest } from '@/utils/request'
+import {
+  addTenant,
+  addOrg
+} from '@/api/base'
+
+export function getTimelines (deviceIdList, options) {
+  return request({
+    url: `/content/deviceCalender`,
+    method: 'POST',
+    ...options,
+    data: { deviceIdList },
+    custom: true
+  }).then(({ data }) => data.map(i => { return { ...i, eventDetail: JSON.parse(i.eventDetail) } }) || [])
+}
+
+export function getDeviceExceptionRanking () {
+  return tenantRequest({
+    url: '/deviceException/ranking',
+    method: 'GET',
+    params: addTenant({}),
+    custom: true
+  })
+}
+
+export function getDeviceExceptionLevelStatistic () {
+  return tenantRequest({
+    url: '/deviceException/levelStatistic',
+    method: 'GET',
+    params: addTenant({}),
+    custom: true
+  })
+}
+
+export function getDeviceExceptionTypeStatistics (statisticDate) {
+  return tenantRequest({
+    url: '/deviceException/typeStatistics',
+    method: 'GET',
+    params: addTenant({ statisticDate }),
+    custom: true
+  })
+}
+
+export function getAssetAnalysis () {
+  return tenantRequest({
+    method: 'GET',
+    url: '/minio-data/type/listSummary',
+    params: addTenant({}),
+    custom: true
+  })
+}
+
+export function getOrgAssetAnalysis () {
+  return tenantRequest({
+    method: 'GET',
+    url: '/minio-data/type/listSummary',
+    params: addOrg({}),
+    custom: true
+  })
+}
+
+export function getAssetStatusAnalysis () {
+  return tenantRequest({
+    method: 'GET',
+    url: '/minio-data/status/listSummary',
+    params: addTenant({}),
+    custom: true
+  })
+}
+
+export function getOrgAssetStatusAnalysis () {
+  return tenantRequest({
+    method: 'GET',
+    url: '/minio-data/status/listSummary',
+    params: addOrg({}),
+    custom: true
+  })
+}
+
+export function getProgramAnalysis () {
+  return tenantRequest({
+    method: 'GET',
+    url: '/item/status/listSummary',
+    params: addTenant({}),
+    custom: true
+  })
+}
+
+export function getCarouselAnalysis () {
+  return tenantRequest({
+    method: 'GET',
+    url: '/content/carousel/status/listSummary',
+    params: addTenant({}),
+    custom: true
+  })
+}
+
+export function getCalendarAnalysis () {
+  return tenantRequest({
+    method: 'GET',
+    url: '/content/calendar/status/listSummary',
+    params: addTenant({}),
+    custom: true
+  })
+}

+ 239 - 0
src/views/dashboard/v1/index.vue

@@ -0,0 +1,239 @@
+<template>
+  <div class="c-monitor-dashboard">
+    <div
+      class="c-monitor-dashboard__main"
+      :style="style"
+    >
+      <Header />
+      <div class="l-flex--col center">
+        <div class="l-flex--row">
+          <div class="l-flex--col l-flex__none">
+            <div
+              class="l-flex__none dashboard-block"
+              style="width: 1040px; height: 450px"
+            >
+              <SystemLoad />
+            </div>
+            <div class="l-flex__none dashboard-block l-flex--row">
+              <div
+                class="l-flex__none l-flex--col block-item"
+                style="width: 510px; height: 450px"
+              >
+                <AssetStatistic />
+              </div>
+              <div
+                class="l-flex__none l-flex--col block-item"
+                style="width: 510px; height: 450px"
+              >
+                <ProgramStatistic />
+              </div>
+            </div>
+            <div class="l-flex__none dashboard-block l-flex--row">
+              <div
+                class="l-flex__none l-flex--col block-item"
+                style="width: 510px; height: 450px"
+              >
+                <ProgramRate />
+              </div>
+              <div
+                class="l-flex__none l-flex--col block-item"
+                style="width: 510px; height: 450px"
+              >
+                <ProgramTop />
+              </div>
+            </div>
+          </div>
+          <div class="l-flex--col l-flex__none">
+            <div
+              class="l-flex__none dashboard-block"
+              style="width: 1480px; height: 1390px"
+            >
+              <Map
+                ref="map"
+                :status-data="statusData"
+                :device-list="mapDeviceList"
+              />
+            </div>
+          </div>
+          <div class="l-flex--col l-flex__none">
+            <div
+              class="l-flex__none dashboard-block"
+              style="width: 1040px; height: 686px"
+            >
+              <Record ref="record" />
+            </div>
+            <div
+              class="l-flex__none dashboard-block"
+              style="width: 1040px; height: 686px"
+            >
+              <MessageNotice
+                :device-list="mapDeviceList"
+                @new-alarm="onNewAlarm"
+              />
+            </div>
+          </div>
+        </div>
+        <div class="l-flex--row">
+          <div class="l-flex--col l-flex__none">
+            <div
+              class="l-flex__none dashboard-block"
+              style="width: 1040px; height: 450px"
+            >
+              <DeviceCalender :device-list="deviceList" />
+            </div>
+          </div>
+
+          <div
+            class="l-flex__none l-flex--col"
+            style="width: 730px; height: 450px"
+          >
+            <AlarmLevel />
+          </div>
+          <div
+            class="l-flex__none l-flex--col"
+            style="width: 730px; height: 450px"
+          >
+            <AlarmRate />
+          </div>
+          <div class="l-flex--col l-flex__none">
+            <div
+              class="l-flex__none dashboard-block"
+              style="width: 1040px; height: 450px"
+            >
+              <AlarmType />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import {
+  getDevices,
+  getDeviceStatistics
+} from '@/api/device'
+import DeviceCalender from './DeviceCalender'
+import Map from './Map'
+import AlarmType from './AlarmType'
+import AlarmRate from './AlarmRate'
+import AlarmLevel from './AlarmLevel'
+import MessageNotice from './MessageNotice'
+import Record from './Record'
+import SystemLoad from './SystemLoad'
+import Header from './Header'
+import ProgramRate from './ProgramRate'
+import ProgramTop from './ProgramTop'
+import AssetStatistic from './AssetStatistic'
+import ProgramStatistic from './ProgramStatistic'
+
+export default {
+  components: {
+    AssetStatistic,
+    ProgramStatistic,
+    Map,
+    AlarmType,
+    AlarmRate,
+    MessageNotice,
+    Record,
+    DeviceCalender,
+    AlarmLevel,
+    Header,
+    ProgramRate,
+    ProgramTop,
+    SystemLoad
+  },
+  data () {
+    return {
+      style: null,
+      statusData: [
+        { label: '设备总数', value: '-' },
+        { label: '设备在线数', value: '-' },
+        { label: '设备离线数', value: '-' },
+        { label: '设备未启用数', value: '-' }
+      ],
+      deviceList: []
+    }
+  },
+  computed: {
+    mapDeviceList () {
+      return this.deviceList.filter(i => i.longitude && i.latitude)
+    }
+  },
+  created () {
+    this.getDeviceStatistics()
+    this.$timer = setInterval(this.getDeviceStatistics, 60000)
+  },
+  mounted () {
+    this.checkScale()
+    window.addEventListener('resize', this.checkScale)
+  },
+  beforeDestroy () {
+    clearInterval(this.$timer)
+    window.removeEventListener('resize', this.checkScale)
+  },
+  methods: {
+    checkScale () {
+      this.style = {
+        transform: `scale(${window.innerWidth / 3840}, ${window.innerHeight / 2160})`
+      }
+    },
+    onNewAlarm (alarm) {
+      this.$refs.map?.setNewAlarm(alarm)
+    },
+    getDeviceStatistics () {
+      getDeviceStatistics().then(({ data }) => {
+        const { notEnabledTotal, offLineTotal, onLineTotal, total } = data
+        this.statusData = [
+          { label: '设备总数', value: total || 0 },
+          { label: '设备在线数', value: onLineTotal || 0 },
+          { label: '设备离线数', value: offLineTotal || 0 },
+          { label: '设备未启用数', value: notEnabledTotal || 0 }
+        ]
+        this.getDevices(total)
+      })
+    },
+    getDevices (total) {
+      getDevices(
+        {
+          pageNum: 1,
+          pageSize: total,
+          activate: 2
+        },
+        { custom: true }
+      ).then(({ data }) => {
+        this.deviceList = data
+      })
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.c-monitor-dashboard {
+  height: 100%;
+  overflow: hidden;
+
+  &__main {
+    width: 3840px;
+    height: 2160px;
+    background: url("~@/assets/v1/monitor_bg.png");
+    transform-origin: left top;
+  }
+}
+.dashboard-block {
+  & ~ & {
+    margin-top: 20px;
+  }
+}
+.l-flex--row {
+  & ~ & {
+    margin-top: 20px;
+  }
+}
+.l-flex--col {
+  & ~ & {
+    margin-left: 20px;
+  }
+}
+</style>

+ 6 - 0
src/views/realm/debug/simulator/index.vue

@@ -45,6 +45,12 @@
         >
           Offline
         </button>
+        <button
+          class="l-flex__none c-sibling-item far o-button"
+          @click="publish('/calendar/pull')"
+        >
+          排期拉取
+        </button>
       </div>
       <div class="l-flex__auto c-sibling-item--v far c-simulator u-overflow-y--auto">
         <div

+ 4 - 2
src/views/realm/debug/simulator/simulate.js

@@ -64,7 +64,8 @@ function createProxyClient ({ productId, deviceId, username, password }, { onMes
   client.on('connect', () => {
     console.log('Simulate MQTT connected')
     client.subscribe([
-      `${productId}/${deviceId}/oss/reply`
+      `${productId}/${deviceId}/oss/reply`,
+      `${productId}/${deviceId}/calendar/pull/reply`
     ])
     resolve({
       productId,
@@ -75,7 +76,8 @@ function createProxyClient ({ productId, deviceId, username, password }, { onMes
         }
         force = true
         client.unsubscribe([
-          `${productId}/${deviceId}/oss/reply`
+          `${productId}/${deviceId}/oss/reply`,
+          `${productId}/${deviceId}/calendar/pull/reply`
         ])
         client.end(true)
       },