Browse Source

feat: dashboard ui

Casper Dai 3 years ago
parent
commit
e48e5b63ba

+ 2 - 2
.env.development

@@ -5,10 +5,10 @@ ENV = 'development'
 VUE_APP_BASE_API = '/dev-api'
 
 # minio
-VUE_APP_MINIO = '/dev-api/minio'
+VUE_APP_MINIO = '/dev-api/oss-api'
 
 # thumbnail
-VUE_APP_THUMBNAIL = '/dev-api/thumbnail'
+VUE_APP_THUMBNAIL = '/dev-api/api/imageproxy'
 
 # mqtt
 VUE_APP_MQTT_URL = 'ws://10.180.88.10:8083/mqtt'

+ 1 - 1
.env.production

@@ -8,7 +8,7 @@ VUE_APP_BASE_API = '/prod-api'
 VUE_APP_MINIO = '/oss-api'
 
 # thumbnail
-VUE_APP_THUMBNAIL = 'http://isoc.artaplay.com:8082'
+VUE_APP_THUMBNAIL = '/api/imageproxy'
 
 # mqtt
 VUE_APP_MQTT_URL = 'ws://10.180.88.10:8083/mqtt'

+ 20 - 34
mock/proxy.js

@@ -1,43 +1,40 @@
 const proxy = require('express-http-proxy')
 
-const use = false
+const use = true
 
 const base_url = process.env.VUE_APP_BASE_API
 const minioKey = process.env.VUE_APP_MINIO.replace(base_url, '')
 const thumbnailKey = process.env.VUE_APP_THUMBNAIL.replace(base_url, '')
 
-const minio_url = 'http://10.180.88.84:9000'
-const thumbnail_url = 'http://isoc.artaplay.com:8082'
+const ui = 'http://10.180.88.84:8093'
 const gate = 'http://10.180.88.84:8081'
 
 module.exports = {
   register (router) {
     if (use) {
       router.use('/auth', createProxy(process.env.VUE_APP_KEYCLOAK_OPTIONS_URL))
-      router.use(minioKey, createProxy(minio_url))
-      router.use(thumbnailKey, createThumbnailProxy(thumbnail_url))
-      // router.use('/minio-data', createProxy('http://10.180.90.6:18888', true))
-      // router.use('/item', createProxy('http://10.180.90.6:18887', true))
-      // router.use('/scheduling', createProxy('http://10.180.90.6:18887', true))
-      // router.use('/scheduling-config', createProxy('http://10.180.90.6:18887', true))
-      // router.use('/scheduling-plugin-device', createProxy('http://10.180.90.6:18887', true))
-      // router.use('/release-history', createProxy('http://10.180.90.6:18887', true))
-      // router.use('/apkUpgradeFile', createProxy('http://10.180.90.6:8889', true))
-      // router.use('/apkUpgradePolicy', createProxy('http://10.180.90.6:8889', true))
-      // router.use('/device', createProxy('http://10.180.90.27:8891', true))
-      // router.use('/sysLog', createProxy('http://10.180.91.61:8890', true))
-      // router.use('/minio-data', createProdProxy('http://10.180.88.84:8094'))
+      router.use(minioKey, createProxy(`${ui}`))
+      router.use(thumbnailKey, createThumbnailProxy(`${ui}`))
+      // router.use('/minio-data', createProxy('http://10.180.90.6:18888'))
+      // router.use('/item', createProxy('http://10.180.90.6:18887'))
+      // router.use('/scheduling', createProxy('http://10.180.90.6:18887'))
+      // router.use('/scheduling-config', createProxy('http://10.180.90.6:18887'))
+      // router.use('/scheduling-plugin-device', createProxy('http://10.180.90.6:18887'))
+      // router.use('/release-history', createProxy('http://10.180.90.6:18887'))
+      // router.use('/apkUpgradeFile', createProxy('http://10.180.90.6:8889'))
+      // router.use('/apkUpgradePolicy', createProxy('http://10.180.90.6:8889'))
+      // router.use('/device', createProxy('http://10.180.90.27:8891'))
+      // router.use('/sysLog', createProxy('http://10.180.91.61:8890'))
       // router.use('/content', createProxy('http://liangke00.home.langchao.com:8081'))
-      // router.use('/content', createProxy('http://10.180.90.13:8887', true))
-      // router.use('/', createProxy('http://liangke00.home.langchao.com:8081'))
-      // router.use('/content', createProxy('http://liangke00.home.langchao.com:8081', true))
-      // router.use('/orchestration', createProxy('http://liangke00.home.langchao.com:8081', true))
-      router.use('/', createProxy(gate))
+      // router.use('/content', createProxy('http://10.180.90.13:8887'))
+      // router.use('/content', createProxy('http://liangke00.home.langchao.com:8081'))
+      // router.use('/orchestration', createProxy('http://liangke00.home.langchao.com:8081'))
+      router.use('/', createProxy(gate, false))
     }
   }
 }
 
-function createProxy (to, replace) {
+function createProxy (to, replace = true) {
   return proxy(to, {
     parseReqBody: false,
     proxyReqPathResolver (req) {
@@ -48,22 +45,11 @@ function createProxy (to, replace) {
   })
 }
 
-function createProdProxy (to) {
-  return proxy(to, {
-    parseReqBody: false,
-    proxyReqPathResolver (req) {
-      const url = `/prod-api${req.baseUrl.replace(base_url, '')}${req.url}`
-      console.log(`proxy ${url} to ${to}`)
-      return url
-    }
-  })
-}
-
 function createThumbnailProxy (to) {
   return proxy(to, {
     parseReqBody: false,
     proxyReqPathResolver (req) {
-      const url = req.url.replace(new RegExp(`http.*${minioKey}`), minio_url)
+      const url = `${thumbnailKey}${req.url.replace(new RegExp(`http.*${minioKey}`), `${ui}${minioKey}`)}`
       console.log(`thumbnail ${url} to ${to}`)
       return url
     }

+ 8 - 0
src/api/device.js

@@ -244,3 +244,11 @@ export function deleteDeviceFromGroup (id, { id: deviceId, name }) {
     params: { deviceId }
   })
 }
+
+export function getDeviceStatistics (productId) {
+  return request({
+    url: '/device/listDeviceTotal',
+    method: 'post',
+    data: { productId }
+  })
+}

BIN
src/assets/dashboard.png


BIN
src/assets/icon_date.png


BIN
src/assets/icon_info.png


BIN
src/assets/icon_inform.png


BIN
src/assets/icon_log.png


BIN
src/assets/icon_performance.png


BIN
src/assets/icon_safety.png


+ 52 - 0
src/components/AutoText/index.vue

@@ -0,0 +1,52 @@
+<template>
+  <el-tooltip
+    :disabled="!overflow"
+    :content="text"
+    :open-delay="500"
+  >
+    <div
+      ref="target"
+      class="u-ellipsis u-readonly"
+    >
+      {{ text }}
+    </div>
+  </el-tooltip>
+</template>
+
+<script>
+export default {
+  name: 'AutoText',
+  props: {
+    text: {
+      type: String,
+      default: ''
+    }
+  },
+  data () {
+    return {
+      overflow: false
+    }
+  },
+  watch: {
+    text () {
+      this.$nextTick(this.checkSize)
+    }
+  },
+  mounted () {
+    this.checkSize()
+
+    window.addEventListener('resize', this.checkSize)
+  },
+  beforeDestroy () {
+    window.removeEventListener('resize', this.checkSize)
+  },
+  methods: {
+    checkSize () {
+      const target = this.$refs.target
+      if (target) {
+        this.overflow = this.text.length ? target.scrollWidth > target.offsetWidth : false
+      }
+    }
+  }
+}
+</script>

+ 7 - 0
src/components/EditInput/index.vue

@@ -52,6 +52,13 @@ export default {
       manual: false
     }
   },
+  watch: {
+    value () {
+      if (!this.manual) {
+        this.$nextTick(this.checkSize)
+      }
+    }
+  },
   mounted () {
     this.checkSize()
 

+ 2 - 2
src/layout/components/Navbar/Breadcrumb.vue

@@ -2,7 +2,7 @@
   <el-breadcrumb class="c-breadcrumb" separator="/">
     <transition-group name="breadcrumb">
       <el-breadcrumb-item
-        v-for="(item,index) in levelList"
+        v-for="(item, index) in levelList"
         :key="item.path"
       >
         <span
@@ -39,7 +39,7 @@ export default {
     }
   },
   watch: {
-    $route (route) {
+    $route () {
       this.getBreadcrumb()
     }
   },

+ 1 - 1
src/layout/components/Navbar/UploadDashboard/index.vue

@@ -98,7 +98,7 @@ export default {
 .c-upload-dashboard {
   &__icon {
     color: $blue;
-    font-size: 24px;
+    font-size: 28px;
   }
 
   &__item + &__item {

+ 33 - 2
src/layout/components/Navbar/index.vue

@@ -1,6 +1,13 @@
 <template>
   <div class="l-flex--row c-navbar">
     <breadcrumb class="l-flex__auto c-navbar__item" />
+    <i class="l-flex__none c-navbar__item c-navbar__info u-pointer">
+      <i
+        class="c-navbar__count"
+      >
+        1
+      </i>
+    </i>
     <upload-dashboard class="l-flex__none c-navbar__item" />
     <el-dropdown
       class="l-flex__none c-navbar__item c-navbar__user u-pointer"
@@ -65,7 +72,31 @@ export default {
   overflow: hidden;
 
   &__item + &__item {
-    margin-left: $spacing;
+    margin-left: 32px;
+  }
+
+  &__info {
+    position: relative;
+    display: inline-block;
+    width: 24px;
+    height: 24px;
+    background: url('~@/assets/icon_inform.png') 0 0 / 100% 100% no-repeat;
+  }
+
+  &__count {
+    position: absolute;
+    top: 4px;
+    right: 4px;
+    min-width: 16px;
+    padding: 0 2px;
+    color: #fff;
+    font-size: 12px;
+    font-style: normal;
+    line-height: 16px;
+    text-align: center;
+    border-radius: 10px;
+    background-color: $error--dark;
+    transform: translate(50%, -50%);
   }
 
   &__user {
@@ -90,7 +121,7 @@ export default {
   }
 
   &__name {
-    padding: 0 10px;
+    padding-right: 10px;
   }
 
   .el-icon-arrow-down {

+ 11 - 13
src/main.js

@@ -10,8 +10,8 @@ import App from './App'
 import store from './store'
 import router from './router'
 
-import './icons' // icon
-import './permission' // permission control
+import './icons'
+import './permission'
 
 import Permission from './components/Permission'
 import StatusWrapper from './components/StatusWrapper'
@@ -20,11 +20,12 @@ import CTable from './components/CTable'
 import EditInput from './components/EditInput'
 import SearchInput from './components/SearchInput'
 
-import { showLoading, closeLoading } from './utils/pop'
+import {
+  showLoading,
+  closeLoading
+} from './utils/pop'
 
 async function startApp () {
-  console.log(keycloak)
-
   Vue.use(Element)
 
   Vue.component('Permission', Permission)
@@ -38,7 +39,7 @@ async function startApp () {
   Vue.prototype.$closeLoading = closeLoading
 
   Vue.config.productionTip = false
-  Vue.config.errorHandler = (err, vm, info) => {
+  Vue.config.errorHandler = err => {
     closeLoading()
     throw err
   }
@@ -63,7 +64,8 @@ const initOptions = {
 
 const keycloak = Keycloak(initOptions)
 
-keycloak.init({ onLoad: initOptions.onLoad })
+keycloak
+  .init({ onLoad: initOptions.onLoad })
   .then(auth => {
     if (!auth) {
       console.error('Authenticated Failed[403]')
@@ -79,12 +81,8 @@ keycloak.init({ onLoad: initOptions.onLoad })
         } else {
           console.warn(`Token not refreshed, valid for ${Math.round(keycloak.tokenParsed.exp + keycloak.timeSkew - new Date().getTime() / 1000)} seconds`)
         }
-      }).catch(() => {
-        console.error('Failed to refresh token')
-      })
+      }).catch(() => console.error('Failed to refresh token'))
     }, 6000)
   })
-  .catch(() => {
-    console.error('Authenticated Failed')
-  })
+  .catch(() => console.error('Authenticated Failed'))
   .finally(startApp)

+ 16 - 1
src/router/index.js

@@ -49,12 +49,27 @@ export const asyncRoutes = [
     children: [
       {
         path: 'dashboard',
-        name: 'dashboard',
+        name: 'home',
         component: () => import('@/views/dashboard/index'),
         meta: { title: '首页', icon: 'el-icon-s-home' }
       }
     ]
   },
+  {
+    hidden: true,
+    path: '/abnormal',
+    redirect: '/dashboard',
+    component: Layout,
+    meta: { title: '首页' },
+    children: [
+      {
+        path: ':type',
+        name: 'abnormal',
+        component: () => import('@/views/abnormal/index'),
+        meta: { title: '异常', activeMenu: '/dashboard' }
+      }
+    ]
+  },
   {
     name: 'design',
     path: '/design/:id',

+ 4 - 5
src/scss/bem/_component.scss

@@ -87,13 +87,12 @@
 }
 
 .c-grid {
-  flex: 0 1 auto;
-  min-height: 0;
-  min-width: 0;
   display: grid;
   grid-template-columns: repeat(4, minmax(100px, 1fr));
-  grid-row-gap: 20px;
-  grid-column-gap: 20px;
+  grid-row-gap: $spacing;
+  grid-column-gap: $spacing;
+  min-height: 0;
+  min-width: 0;
   overflow-y: auto;
 }
 

+ 20 - 0
src/scss/bem/_utility.scss

@@ -21,10 +21,30 @@
   color: $success;
 }
 
+.u-color--success.dark {
+  color: $success--dark;
+}
+
 .u-color--error {
   color: $error;
 }
 
+.u-color--error.dark {
+  color: #fb8885;
+}
+
 .u-color--warning {
   color: $warning;
 }
+
+.u-color--black {
+  color: $black;
+}
+
+.u-color--info {
+  color: $gray--dark;
+}
+
+.u-text-center {
+  text-align: center;
+}

+ 2 - 0
src/scss/helpers/_variables.scss

@@ -8,10 +8,12 @@ $gray--dark: #969696;
 $black: #333333;
 
 $success: #67c23a;
+$success--dark: #04a681;
 
 $warning: #e6a23c;
 $warning--light: #ebb563;
 
 $error: #f56c6c;
+$error--dark: #e51414;
 
 $spacing: 16px;

+ 3 - 0
src/views/abnormal/index.vue

@@ -0,0 +1,3 @@
+<template>
+  <div>abnormal</div>
+</template>

+ 143 - 0
src/views/dashboard/components/Card.vue

@@ -0,0 +1,143 @@
+<template>
+  <div
+    class="o-card has-padding u-pointer"
+    @click="go"
+  >
+    <div class="o-card__title">
+      <i
+        class="l-flex__none o-card__icon"
+        :class="type"
+      />
+      <div class="l-flex__fill u-text-center">
+        <div class="u-relative">
+          <auto-text :text="title" />
+          <i
+            v-if="count"
+            class="o-card__count"
+          >
+            {{ count }}
+          </i>
+        </div>
+        <auto-text
+          v-if="tip"
+          :text="tip"
+        />
+      </div>
+    </div>
+    <auto-text
+      v-if="desc"
+      class="o-card__desc"
+      :text="desc"
+    />
+  </div>
+</template>
+
+<script>
+import AutoText from '@/components/AutoText'
+
+export default {
+  name: 'Card',
+  components: {
+    AutoText
+  },
+  props: {
+    type: {
+      type: String
+    },
+    title: {
+      type: String
+    },
+    tip: {
+      type: String
+    },
+    desc: {
+      type: String
+    },
+    count: {
+      type: [String, Number],
+      default: null
+    }
+  },
+  methods: {
+    go () {
+      // this.$router.push({
+      //   name: 'abnormal',
+      //   params: { type: 'a' }
+      // })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.o-card {
+  display: inline-flex;
+  flex-direction: column;
+  align-items: center;
+  height: 100px;
+  color: $black;
+  font-size: 16px;
+  font-weight: bold;
+  border-radius: 8px;
+  background-color: #fff;
+
+  &__title {
+    display: inline-flex;
+    align-items: flex-start;
+    max-width: 100%;
+    line-height: 36px;
+  }
+
+  &__count {
+    position: absolute;
+    top: -4px;
+    right: -10px;
+    min-width: 20px;
+    padding: 2px 4px;
+    color: #fff;
+    font-size: 12px;
+    font-style: normal;
+    line-height: 1;
+    border: 2px solid #fff;
+    border-radius: 10px;
+    background-color: $error--dark;
+  }
+
+  &__icon {
+    display: inline-block;
+    width: 36px;
+    height: 36px;
+    margin-right: $spacing;
+    background-position: 0 0;
+    background-size: 100% 100%;
+    background-repeat: no-repeat;
+
+    &.info {
+      background-image: url('~@/assets/icon_info.png');
+    }
+
+    &.safety {
+      background-image: url('~@/assets/icon_safety.png');
+    }
+
+    &.performance {
+      background-image: url('~@/assets/icon_performance.png');
+    }
+
+    &.log {
+      background-image: url('~@/assets/icon_log.png');
+    }
+
+    &.date {
+      background-image: url('~@/assets/icon_date.png');
+    }
+  }
+
+  &__desc {
+    margin-top: $spacing;
+    color: $gray;
+    font-size: 12px;
+    font-weight: normal;
+  }
+}
+</style>

+ 136 - 0
src/views/dashboard/components/Device.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="o-device has-padding">
+    <div class="l-flex__none l-flex--row o-device__header">
+      <i class="l-flex__none o-device__status u-color--success dark" />
+      <auto-text
+        class="l-flex__fill"
+        text="深圳市宝安区海天路15号卓越宝中时代广场"
+      />
+      <span class="l-flex__none o-device__tip">在线</span>
+    </div>
+    <div class="l-flex__fill l-flex--col u-text-center">
+      <auto-text
+        class="l-flex__none o-device__current"
+        text="深圳市宝安区海天路15号卓越宝中时代广场"
+      />
+      <div class="l-flex__fill o-device__time">
+        <span class="o-device__hms">
+          10:00
+          <span class="o-device__ymd">2021.10.27</span>
+        </span>
+        <span class="o-device__line" />
+        <span class="o-device__hms">
+          10:00
+          <span class="o-device__ymd">2021.10.27</span>
+        </span>
+      </div>
+      <auto-text
+        class="l-flex__none o-device__next"
+        text="下一场:深圳市宝安区海天路15号卓越宝中时代广场"
+      />
+    </div>
+    <auto-text
+      class="l-flex__none o-device__footer"
+      text="位置:深圳市宝安区海天路15号卓越宝中时代广场"
+    />
+  </div>
+</template>
+
+<script>
+import AutoText from '@/components/AutoText'
+
+export default {
+  name: 'DeviceCard',
+  components: {
+    AutoText
+  },
+  props: {
+    type: {
+      type: String
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.o-device {
+  display: inline-flex;
+  flex-direction: column;
+  height: 220px;
+  color: $black;
+  line-height: 1;
+  border-radius: 8px;
+  background-color: #fff;
+
+  &__header {
+    margin-bottom: 24px;
+    font-size: 16px;
+    font-weight: bold;
+  }
+
+  &__status {
+    display: inline-block;
+    width: 12px;
+    height: 12px;
+    margin-right: 6px;
+    border-radius: 50%;
+    background-color: currentColor;
+  }
+
+  &__tip {
+    display: inline-block;
+    position: relative;
+    left: 16px;
+    padding: 2px 8px 2px 10px;
+    margin-left: -10px;
+    color: #fff;
+    font-size: 12px;
+    line-height: 1;
+    border-radius: 9px 0 0 9px;
+    background-color: $success--dark;
+  }
+
+  &__current {
+    font-size: 20px;
+    font-weight: bold;
+  }
+
+  &__time {
+    margin-top: $spacing;
+    font-size: 20px;
+  }
+
+  &__line {
+    display: inline-block;
+    width: 20px;
+    margin: 0 10px;
+    border-bottom: 1px solid currentColor;
+  }
+
+  &__hms {
+    position: relative;
+    font-weight: bold;
+  }
+
+  &__ymd {
+    position: absolute;
+    top: 100%;
+    left: 50%;
+    color: $gray;
+    font-size: 12px;
+    font-weight: normal;
+    transform: translate(-50%, 4px);
+  }
+
+  &__next {
+    color: $gray;
+    font-size: 12px;
+  }
+
+  &__footer {
+    margin-top: 24px;
+    font-size: 12px;
+    font-weight: bold;
+  }
+}
+</style>

+ 212 - 4
src/views/dashboard/index.vue

@@ -1,17 +1,225 @@
 <template>
-  <div class="dashboard">
-    <img src="@/assets/dashboard.png">
+  <div class="c-dashboard">
+    <div class="l-flex--row c-dashboard__block c-count">
+      <div class="l-flex__none l-flex--row">
+        设备分类:
+        <el-select
+          v-model="product"
+          placeholder="请选择产品"
+          :loading="productOptions.fetching"
+          @visible-change="getProducts"
+          @change="onProductChange"
+        >
+          <el-option
+            v-for="item in productOptions.list"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+      </div>
+      <div class="l-flex__none c-count__item u-color--black u-text-center">
+        <div>全部设备</div>
+        <i
+          v-if="device.loading"
+          class="el-icon-loading"
+        />
+        <div v-else>{{ device.total }}</div>
+      </div>
+      <div class="l-flex__none c-count__item u-color--success dark u-text-center">
+        <div>● 在线</div>
+        <i
+          v-if="device.loading"
+          class="el-icon-loading"
+        />
+        <div v-else>{{ device.online }}</div>
+      </div>
+      <div class="l-flex__none c-count__item u-color--error dark u-text-center">
+        <div>● 离线</div>
+        <i
+          v-if="device.loading"
+          class="el-icon-loading"
+        />
+        <div v-else>{{ device.offline }}</div>
+      </div>
+      <div class="l-flex__none c-count__item u-color--info u-text-center">
+        <div>● 未启用</div>
+        <i
+          v-if="device.loading"
+          class="el-icon-loading"
+        />
+        <div v-else>{{ device.inactive }}</div>
+      </div>
+      <i
+        class="c-count__refresh el-icon-refresh u-pointer"
+        @click="refreshDevices"
+      />
+    </div>
+    <div class="c-dashboard__block c-cards">
+      <card
+        class="c-cards__item"
+        type="info"
+        title="总次数"
+        tip="100"
+      />
+      <card
+        class="c-cards__item"
+        type="safety"
+        title="安全异常"
+        desc="累计异常数:30"
+      />
+      <card
+        class="c-cards__item"
+        type="performance"
+        title="性能异常"
+        desc="累计异常数:30"
+        count="3"
+      />
+      <card
+        class="c-cards__item"
+        type="log"
+        title="日志异常"
+        desc="累计异常数:30"
+        count="10"
+      />
+      <card
+        class="c-cards__item"
+        type="date"
+        title="即将空闲排期"
+        count="100"
+      />
+    </div>
+    <div class="c-dashboard__block c-devices">
+      <device />
+    </div>
   </div>
 </template>
 
 <script>
+import {
+  getProducts,
+  getDeviceStatistics
+} from '@/api/device'
+import Card from './components/Card'
+import Device from './components/Device'
+
 export default {
-  name: 'Dashboard'
+  name: 'Dashboard',
+  components: {
+    Card,
+    Device
+  },
+  data () {
+    return {
+      productOptions: {
+        list: [
+          { value: '', label: '全部设备' }
+        ],
+        loaded: false,
+        fetching: false,
+        firstLoadSize: 999
+      },
+      product: '',
+      device: { loading: false }
+    }
+  },
+  created () {
+    this.refreshDevices()
+  },
+  methods: {
+    _getProducts () {
+      const options = this.productOptions
+      options.fetching = true
+      getProducts({ pageNum: 1, pageSize: options.firstLoadSize }).then(({ data, totalCount }) => {
+        if (totalCount > options.firstLoadSize) {
+          options.firstLoadSize = totalCount
+          this._getProducts()
+        } else {
+          options.list = options.list.concat(data.map(({ id, name }) => {
+            return { value: id, label: name }
+          }))
+          options.fetching = false
+          options.loaded = true
+        }
+      }, () => {
+        options.fetching = false
+      })
+    },
+    getProducts (visible) {
+      if (visible && !this.productOptions.fetching && !this.productOptions.loaded) {
+        this._getProducts()
+      }
+    },
+    onProductChange () {
+      this.refreshDevices(true)
+    },
+    refreshDevices (force) {
+      if (!force && this.device.loading) {
+        return
+      }
+      const deviceOption = {
+        loading: true,
+        total: '-',
+        online: '-',
+        offline: '-',
+        inactive: '-'
+      }
+      this.device = deviceOption
+      getDeviceStatistics(this.product).then(({ data }) => {
+        const { notEnabledTotal, offLineTotal, onLineTotal, total } = data
+        deviceOption.total = !total && total !== 0 ? '-' : total
+        deviceOption.online = !onLineTotal && onLineTotal !== 0 ? '-' : onLineTotal
+        deviceOption.offline = !offLineTotal && offLineTotal !== 0 ? '-' : offLineTotal
+        deviceOption.inactive = !notEnabledTotal && notEnabledTotal !== 0 ? '-' : notEnabledTotal
+      }).finally(() => {
+        deviceOption.loading = false
+      })
+    }
+  }
 }
 </script>
 
 <style lang="scss" scoped>
-.dashboard {
+.c-dashboard {
+  flex: none;
   background-color: transparent;
+
+  &__block + &__block {
+    margin-top: $spacing;
+  }
+}
+
+.c-count {
+  justify-content: space-between;
+  padding: 8px 16px;
+  font-weight: bold;
+  border-radius: 8px;
+  background-color: #fff;
+
+  &__item > div:first-child {
+    margin-bottom: 10px;
+  }
+
+  &__refresh {
+    font-size: 24px;
+    color: $blue;
+  }
+}
+
+.c-cards {
+  display: grid;
+  grid-template-columns: repeat(5, 1fr);
+  grid-column-gap: $spacing;
+
+  &__item {
+    min-width: 0;
+  }
+}
+
+.c-devices {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(100px, 1fr));
+  grid-row-gap: $spacing;
+  grid-column-gap: $spacing;
 }
 </style>