Browse Source

feat(camera): playback

Casper Dai 2 years ago
parent
commit
e820c58639

+ 1 - 0
.env

@@ -45,6 +45,7 @@ VUE_APP_MQTT_PASSWORD = 'inspur-frontend'
 # camera
 # {protocol}://{gateway | host}{proxy}
 VUE_APP_CAMERA_PROXY = '/prod-api/websocket'
+VUE_APP_CAMERA_RECORD_PROXY = '/prod-api/playBackWebSocket'
 
 # user default password
 # MD5('123456a?' + MD5('123456a?'))

+ 15 - 0
src/assets/icon_camera.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
+<g>
+	<path d="M14.6,16.3c-0.3,0-0.7-0.1-1-0.2L2.7,12.1C2,11.9,1.4,11.3,1,10.6C0.7,9.9,0.7,9.1,0.9,8.3L3,2.7C3.3,1.9,3.8,1.3,4.5,1
+		c0.7-0.3,1.5-0.4,2.3-0.1L20,5.7c0.8,0.3,1.3,0.9,1.5,1.6c0.2,0.8,0.1,1.6-0.4,2.2L17,15.1C16.4,15.8,15.5,16.3,14.6,16.3z
+		 M14.2,14.2c0.4,0.2,0.9,0,1.1-0.3l4.1-5.6c0.1-0.2,0.1-0.4,0.1-0.4c0-0.1-0.1-0.3-0.3-0.3L6.1,2.7c-0.3-0.1-0.5-0.1-0.8,0
+		C5.1,2.9,5,3.1,4.9,3.3L2.8,9c-0.1,0.3-0.1,0.5,0,0.8C3,10,3.2,10.2,3.4,10.3L14.2,14.2z"/>
+	<polygon points="13.1,17 11.9,18.7 8.7,17.5 6,20.3 2.5,20.3 2.5,22.3 0.5,22.3 0.5,16.3 2.5,16.3 2.5,18.3 5.1,18.3 6.7,16.8 
+		2.6,15.3 3.3,13.4 	"/>
+	<polygon points="23.5,11.2 19.7,16.2 17.7,15.5 21.5,10.5 	"/>
+	<circle cx="6.4" cy="5" r="1"/>
+</g>
+</svg>

+ 15 - 0
src/assets/icon_camera_white.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
+<g style='fill: #fff'>
+	<path d="M14.6,16.3c-0.3,0-0.7-0.1-1-0.2L2.7,12.1C2,11.9,1.4,11.3,1,10.6C0.7,9.9,0.7,9.1,0.9,8.3L3,2.7C3.3,1.9,3.8,1.3,4.5,1
+		c0.7-0.3,1.5-0.4,2.3-0.1L20,5.7c0.8,0.3,1.3,0.9,1.5,1.6c0.2,0.8,0.1,1.6-0.4,2.2L17,15.1C16.4,15.8,15.5,16.3,14.6,16.3z
+		 M14.2,14.2c0.4,0.2,0.9,0,1.1-0.3l4.1-5.6c0.1-0.2,0.1-0.4,0.1-0.4c0-0.1-0.1-0.3-0.3-0.3L6.1,2.7c-0.3-0.1-0.5-0.1-0.8,0
+		C5.1,2.9,5,3.1,4.9,3.3L2.8,9c-0.1,0.3-0.1,0.5,0,0.8C3,10,3.2,10.2,3.4,10.3L14.2,14.2z"/>
+	<polygon points="13.1,17 11.9,18.7 8.7,17.5 6,20.3 2.5,20.3 2.5,22.3 0.5,22.3 0.5,16.3 2.5,16.3 2.5,18.3 5.1,18.3 6.7,16.8 
+		2.6,15.3 3.3,13.4 	"/>
+	<polygon points="23.5,11.2 19.7,16.2 17.7,15.5 21.5,10.5 	"/>
+	<circle cx="6.4" cy="5" r="1"/>
+</g>
+</svg>

+ 9 - 3
src/constant.js

@@ -7,6 +7,8 @@ export const GATEWAY_WS = `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}`
 
 export const GATEWAY_CAMERA = `${GATEWAY_WS}${process.env.VUE_APP_CAMERA_PROXY}`
 
+export const GATEWAY_CAMERA_RECORD = `${GATEWAY_WS}${process.env.VUE_APP_CAMERA_RECORD_PROXY}`
+
 export const AssetType = {
   IMAGE: 1,
   VIDEO: 2,
@@ -280,18 +282,22 @@ export const RoleAccess = {
     Access.MANAGE_TENANT,
     Access.MANAGE_GROUP,
     Access.MANAGE_CALENDAR,
+    Access.MANAGE_DEVICE,
     Access.REVIEW_RELEASE_FIRST,
     Access.REVIEW_RELEASE_SECOND,
     Access.REVIEW_RELEASE_FINAL
   ],
   [Role.ADMIN]: [
-    Access.MANAGE_TENANT
+    Access.MANAGE_TENANT,
+    Access.MANAGE_DEVICE
   ],
   [Role.SUPERVISOR]: [
-    Access.MANAGE_GROUP
+    Access.MANAGE_GROUP,
+    Access.MANAGE_DEVICE
   ],
   [Role.STAFF]: [
-    Access.MANAGE_CALENDAR
+    Access.MANAGE_CALENDAR,
+    Access.MANAGE_DEVICE
   ],
   [Role.FIRST_LEVEL_REVIEWER]: [
     Access.REVIEW_RELEASE_FIRST

+ 0 - 5
src/router/index.js

@@ -273,11 +273,6 @@ export const asyncRoutes = [
         path: 'group',
         component: () => import('@/views/device/group/index'),
         meta: { title: '分组管理' }
-      },
-      {
-        path: 'camera',
-        component: () => import('@/views/device/camera/index'),
-        meta: { title: '监控录像' }
       }
     ]
   },

+ 77 - 18
src/views/device/detail/components/DeviceExternal/components/LED.vue

@@ -1,21 +1,38 @@
 <template>
-  <div class="l-flex--col">
-    <grid-table
-      ref="table"
-      :schema="schema"
-      size="large"
+  <div class="l-flex--col o-tabs">
+    <el-tabs
+      v-model="active"
+      type="card"
+      class="l-flex l-flex--col l-flex__auto"
     >
-      <grid-table-item v-slot="item">
-        <camera-player
-          v-if="isActivated"
-          :key="item.identifier"
-          :camera="item"
-          autoplay
-          controls
-          @fullscreen="onFullScreen(item)"
-        />
-      </grid-table-item>
-    </grid-table>
+      <el-tab-pane
+        label="实时画面"
+        name="real"
+      >
+        <grid-table
+          ref="table"
+          :schema="schema"
+          size="large"
+        >
+          <grid-table-item v-slot="item">
+            <camera-player
+              v-if="isActivated"
+              :key="item.identifier"
+              :camera="item"
+              autoplay
+              controls
+              @fullscreen="onFullScreen(item)"
+            />
+          </grid-table-item>
+        </grid-table>
+      </el-tab-pane>
+      <el-tab-pane
+        label="录像回放"
+        name="record"
+      >
+        <camera-record :cameras="cameras" />
+      </el-tab-pane>
+    </el-tabs>
     <camera-dialog
       ref="cameraDialog"
       @open="onOpen"
@@ -27,9 +44,13 @@
 <script>
 import { ThirdPartyDevice } from '@/constant'
 import { getThirdPartyDevicesByThirdPartyDevice } from '@/api/mesh'
+import CameraRecord from './Record'
 
 export default {
   name: 'DeviceLEDCamera',
+  components: {
+    CameraRecord
+  },
   props: {
     device: {
       type: Object,
@@ -42,13 +63,17 @@ export default {
         nonPagination: true,
         list: this.getThirdPartyDevicesByThirdPartyDevice
       },
-      isActivated: true
+      isActivated: true,
+      active: 'real',
+      cameras: []
     }
   },
   methods: {
     getThirdPartyDevicesByThirdPartyDevice () {
       return getThirdPartyDevicesByThirdPartyDevice(this.device.id, [ThirdPartyDevice.LED_CAMERA]).then(({ data }) => {
-        return { data: data.filter(({ instance }) => instance).map(({ instance }) => instance) }
+        data = data.filter(({ instance }) => instance).map(({ instance }) => instance)
+        this.cameras = data
+        return { data }
       })
     },
     onFullScreen (camera) {
@@ -63,3 +88,37 @@ export default {
   }
 }
 </script>
+
+<style lang="scss">
+.o-tabs {
+  .el-tabs--card > .el-tabs__header {
+    border: none;
+  }
+  .el-tabs--card > .el-tabs__header .el-tabs__nav {
+    background-color: #f4f7fb;
+    border: none;
+    padding: 4px;
+    border-radius: 5px;
+  }
+  .el-tabs__item.is-active {
+    color: #1c5cb0;
+    background-color: #fff;
+  }
+  .el-tabs--card > .el-tabs__header .el-tabs__item {
+    font-weight: bold;
+    color: #8e929c;
+    border: none;
+    height: 24px;
+    line-height: 24px;
+  }
+  .el-tabs__content {
+    flex: 1 1 auto;
+    display: flex;
+  }
+  .el-tab-pane {
+    flex: 1 1 auto;
+    display: flex;
+    min-height: 400px;
+  }
+}
+</style>

+ 77 - 0
src/views/device/detail/components/DeviceExternal/components/Record/components/PlayaxisDialog.vue

@@ -0,0 +1,77 @@
+<template>
+  <el-dialog
+    :visible.sync="dialogVisible"
+    custom-class="c-dialog--transparent lg"
+    :close-on-click-modal="false"
+    append-to-body
+    @open="onOpen"
+    @closed="onClosed"
+  >
+    <template v-if="contentRender">
+      <PlayAxis
+        :key="identifier"
+        :camera="camera"
+        autoplay
+        controls
+      />
+      <i
+        class="o-close el-icon-close has-active u-bold"
+        @click="hide"
+      />
+    </template>
+  </el-dialog>
+</template>
+<script>
+import { getAssetUrl } from '@/api/asset'
+import dialogMixin from '@/mixins/dialog'
+import PlayAxis from './components/PlayAxis'
+
+export default {
+  name: 'PlayaxisDialog',
+  components: {
+    PlayAxis
+  },
+  mixins: [dialogMixin],
+  props: {
+    camera: {
+      type: Object,
+      required: true
+    },
+    identifier: {
+      type: String,
+      required: true
+    }
+  },
+  data () {
+    return {
+      source: null
+    }
+  },
+  computed: {
+    assetUrl () {
+      return this.source && getAssetUrl(this.source.url)
+    }
+  },
+  methods: {
+    show () {
+      this.dialogVisible = true
+    },
+    onOpen () {
+      this.contentRender = true
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+
+.o-close {
+  position: absolute;
+  top: 0;
+  right: 0;
+  padding: $padding--md $padding--lg;
+  color: #fff;
+  font-size: $font-size--xl;
+  transform: translateY(-100%);
+}
+</style>

+ 293 - 0
src/views/device/detail/components/DeviceExternal/components/Record/components/components/PlayAxis.vue

@@ -0,0 +1,293 @@
+<template>
+  <div
+    class="o-video o-time-axis"
+    :class="{ offline: !online, mask: !isPlaying, controls }"
+  >
+    <template v-if="online">
+      <video
+        ref="video"
+        class="o-video__player o-simple-video"
+        :poster="poster"
+        autoplay
+        muted
+        @play="onVideoPlay"
+        @pause="onVideoPause"
+        @waiting="onVideoWaiting"
+        @playing="onVideoPlaying"
+        @error="onVideoError"
+        @ended="onVideoEnded"
+      />
+      <div class="o-video__mask">
+        <div
+          class="l-flex--row center o-video__btn u-pointer"
+          @click.stop="onPlayOrPause"
+        >
+          <i :class="statusIconClass" />
+        </div>
+      </div>
+    </template>
+    <!-- <div
+      v-if="controls"
+      class="l-flex--row c-footer"
+    >
+      <div class="l-flex__auto c-sibling-item u-ellipsis">{{ camera.remark }}</div>
+      <slot />
+      <i
+        v-if="online && !loading"
+        class="c-sibling-item el-icon-full-screen has-active"
+        @click="onFullScreen"
+      />
+    </div> -->
+    <div class="o-time-axis__bottom">
+      <div
+        class="l-flex o-time-axis__box"
+        @click.stop="(e) => play(e)"
+      >
+        <div
+          v-for="time in times"
+          :key="time.value"
+          class="o-time-axis__drop"
+          :style="{left: time.left}"
+        >
+          {{ time.value }}
+        </div>
+        <div
+          class="o-time-axis__shift"
+          :style="{left: leftRate + '%'}"
+        />
+      </div>
+      <div class="c-table__filters o-time-axis__slecetime">
+        <el-date-picker
+          v-model="dateValue"
+          type="date"
+          :picker-options="datePickerOptions"
+          placeholder="选择日期"
+          value-format="yyyy-MM-dd"
+          @change="onChange"
+        />
+        <el-time-select
+          v-model="timeValue"
+          class="u-width--xs u-pointer"
+          :picker-options="timePickerOptions"
+          :editable="false"
+          :clearable="false"
+          @change="onChange"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import { GATEWAY_CAMERA_RECORD } from '@/constant'
+import { isOnline } from '@/api/camera'
+import playerMixin from '@/components/service/external/player'
+import { parseTime } from '@/utils'
+
+export default {
+  name: 'PlayAxis',
+  mixins: [playerMixin],
+  props: {
+    camera: {
+      type: Object,
+      required: true
+    }
+  },
+  data () {
+    return {
+      online: true,
+      timeNum: 5,
+      // times: [],
+      timeValue: parseTime(new Date(), '{h}:00'),
+      datePickerOptions: {
+        disabledDate (time) {
+          return time.getTime() > Date.now()
+        }
+      },
+      timePickerOptions: {
+        start: '00:00',
+        step: '01:00',
+        end: '23:00'
+      },
+      dateValue: parseTime(new Date(), '{y}-{m}-{d}'),
+      leftRate: 0
+    }
+  },
+  computed: {
+    ...mapGetters(['token']),
+    times () {
+      return this.getTimeSep()
+    }
+  },
+  watch: {
+    isPlaying () {
+      clearInterval(this.$playTimer)
+      if (this.isPlaying) {
+        this.$playTimer = setInterval(() => {
+          if (this.leftRate >= 100) {
+            this.leftRate = 100
+            clearInterval(this.$playTimer)
+          } else {
+            this.leftRate += (100 / 3600)
+          }
+        }, 1000)
+      }
+    }
+  },
+  mounted () {
+    this.createPlayer()
+  },
+  methods: {
+    onChange () {
+      this.leftRate = 0
+      this.destroyPlayer()
+      this.createPlayer()
+      if (Date.parse(`${this.dateValue} ${this.timeValue.split(':')[0]}:00:00`) > Date.parse(new Date())) {
+        this.$message({
+          type: 'warning',
+          message: '不能超过当前时间'
+        })
+      }
+    },
+    getTimeSep () {
+      const arr = []
+      for (let i = 0; i < this.timeNum; i++) {
+        if (i === (this.timeNum - 1)) {
+          arr.push({
+            value: `${(Number(this.timeValue.split(':')[0]) + 1).toString().padStart(2, '0')}:00`,
+            left: `${parseInt(100 / (this.timeNum - 1)) * i}%`
+          })
+        } else {
+          arr.push({
+            value: `${this.timeValue.split(':')[0]}:${(parseInt(60 / (this.timeNum - 1)) * i).toString().padStart(2, '0')}`,
+            left: `${parseInt(100 / (this.timeNum - 1)) * i}%`
+          })
+        }
+      }
+      return arr
+    },
+    play (value) {
+      const leftRate = (value.offsetX / value.target.offsetWidth) * 100
+      const min = parseInt(leftRate * 60 / 100).toString().padStart(2, '0')
+      const startTime = `${this.dateValue} ${this.timeValue.split(':')[0]}:${min}:00`
+      // const endTime = parseTime(new Date(Date.parse(startTime) + 1 * 60 * 1000), '{y}-{m}-{d} {h}:{i}:{s}')
+      if (Date.parse(startTime) > Date.parse(new Date())) {
+        this.$message({
+          type: 'warning',
+          message: '不能超过当前时间'
+        })
+      } else {
+        this.leftRate = leftRate
+        this.destroyPlayer()
+        this.createPlayer(startTime)
+      }
+    },
+    createPlayer (stime) {
+      this.loading = true
+      const startTime = stime || `${this.dateValue} ${this.timeValue.split(':')[0]}:00:00`
+      const endTime = `${this.dateValue} ${(Number(this.timeValue.split(':')[0]) + 1).toString().padStart(2, '0')}:00:00`
+      isOnline(this.camera.identifier, { custom: true }).then(
+        ({ data }) => {
+          if (!this.loading) {
+            return
+          }
+          this.online = data
+          if (data) {
+            this.$nextTick(() => {
+              this.playUrl(`${GATEWAY_CAMERA_RECORD}/${this.camera.identifier}/${startTime.replace(/-|:| /g, '')}/${endTime.replace(/-|:| /g, '')}`)
+            })
+          } else {
+            this.destroyPlayer()
+            this.checkOnline(5000)
+          }
+        },
+        () => {
+          if (!this.loading) {
+            return
+          }
+          this.checkOnline(2000)
+        }
+      )
+    },
+    checkOnline (delay = 5000) {
+      clearTimeout(this.$timer)
+      this.$timer = setTimeout(this.createPlayer, delay + Math.random() * delay | 0)
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.o-video{
+  overflow: unset;
+  padding-top: 0;
+  background-color: transparent;
+}
+.o-time-axis{
+  width: 900px;
+  max-height: 100%;
+  max-width: 100%;
+  &__box{
+    position: relative;
+    width: 100%;
+    height: 3px;
+    background-color: #fff;
+    border-radius: 3px;
+    cursor: pointer;
+  }
+  &__drop{
+    position: absolute;
+    top: 6px;
+    white-space: nowrap;
+    left: 50%;
+    transform: translateX(-50%);
+    color: #fff;
+    pointer-events: none;
+    &::after{
+      content: '';
+      position: absolute;
+      width: 5px;
+      height: 5px;
+      border-radius: 100%;
+      background-color: #1c5cb0;
+      top: -7px;
+      left: 50%;
+      transform: translateX(-50%);
+    }
+  }
+  &__shift{
+    position: absolute;
+    top: -7px;
+    width: 15px;
+    height: 15px;
+    border-radius: 15px;
+    background-color: #fff;
+    transform: translateX(-50%);
+  }
+  &__video{
+    position: relative;
+  }
+  &__loading{
+    position: absolute;
+    left: 50%;
+    top: 50%;
+    transform: translate(-50%, -50%);
+    font-size: 20px;
+    color: #fff;
+  }
+  &__slecetime{
+    margin-top: 20px;
+  }
+  &__bottom{
+    position: absolute;
+    width: 100%;
+  }
+}
+.o-video__player{
+  position: relative;
+  // height: auto;
+  // width: auto;
+  // max-width: 100%;
+  // max-height: 100%;
+}
+</style>

+ 171 - 0
src/views/device/detail/components/DeviceExternal/components/Record/components/index.vue

@@ -0,0 +1,171 @@
+<template>
+  <div
+    class="o-video controls"
+    :class="{mask: paused || loading}"
+  >
+    <video
+      ref="video"
+      class="o-video__player o-simple-video"
+      muted
+      :src="assetUrl"
+    />
+    <div
+      class="o-video__mask"
+    >
+      <div
+        class="l-flex--row center o-video__btn u-pointer"
+        @click.stop="onPlayOrPause"
+      >
+        <i :class="statusIconClass" />
+      </div>
+    </div>
+    <div
+      class="l-flex--row c-footer"
+    >
+      <div class="l-flex__auto c-sibling-item u-ellipsis">{{ camera.startTime }}</div>
+      <slot />
+      <i
+        class="c-sibling-item el-icon-full-screen has-active"
+        @click="onFullScreen"
+      />
+      <i
+        class="c-sibling-item el-icon-delete has-active"
+        @click="onDeleteRecord"
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+import { getAssetUrl } from '@/api/asset'
+import { deleteRecord } from '@/api/external'
+
+export default {
+  name: 'RecordPlayer',
+  props: {
+    camera: {
+      type: Object,
+      required: true
+    }
+  },
+  data () {
+    return {
+      paused: true,
+      checked: false,
+      isFullscreen: false,
+      loading: false,
+      isdownload: false
+    }
+  },
+  computed: {
+    statusIconClass () {
+      switch (true) {
+        case this.camera.status === 0:
+          return 'el-icon-loading'
+        case this.loading:
+          return 'el-icon-loading'
+        case this.isdownload:
+          return 'el-icon-download has-active'
+        case this.paused:
+          return 'el-icon-video-play has-active'
+        default:
+          return 'el-icon-video-pause has-active'
+      }
+    },
+    assetUrl () {
+      return this.camera.url && getAssetUrl(this.camera.url)
+    }
+  },
+  watch: {
+    camera (newValue, oldValue) {
+      this.paused = newValue.id === oldValue.id ? this.paused : true
+    }
+  },
+  created () {
+    this.isdownload = !this.camera.url
+    if (this.camera.status === 0) {
+      this.loading = true
+    }
+  },
+  mounted () {
+    this.$refs.video.addEventListener('ended', () => {
+      this.paused = true
+    })
+    this.$refs.video.addEventListener('waiting', () => {
+      this.loading = true
+    })
+    this.$refs.video.addEventListener('playing', () => {
+      this.loading = false
+      this.paused = false
+    })
+  },
+  methods: {
+    onDeleteRecord () {
+      deleteRecord(this.camera.id).then(() => {
+        this.$emit('delRecord')
+      })
+    },
+    onPlayOrPause () {
+      switch (true) {
+        case this.isdownload:
+          this.$emit('onResumeDownloadRecord', this.camera.id)
+          break
+        case this.paused:
+          this.paused = false
+          this.$refs.video.play()
+          break
+        default:
+          this.paused = true
+          this.$refs.video.pause()
+          break
+      }
+    },
+    onFullScreen () {
+      const video = this.$refs.video
+      if (!video) {
+        return
+      }
+      if (this.isFullscreen) {
+        if (document.exitFullscreen) {
+          document.exitFullscreen()
+        } else if (document.webkitCancelFullScreen) {
+          document.webkitCancelFullScreen()
+        } else if (document.mozCancelFullScreen) {
+          document.mozCancelFullScreen()
+        } else if (document.msExitFullscreen) {
+          document.msExitFullscreen()
+        }
+        this.isFullscreen = false
+      } else {
+        const elm = video.parentNode
+        if (elm.requestFullscreen) {
+          elm.requestFullscreen()
+        } else if (elm.webkitRequestFullScreen) {
+          elm.webkitRequestFullScreen()
+        } else if (elm.mozRequestFullScreen) {
+          elm.mozRequestFullScreen()
+        } else if (elm.msRequestFullscreen) {
+          elm.msRequestFullscreen()
+        } else {
+          this.$message({
+            type: 'warning',
+            message: '暂不支持全屏观看'
+          })
+          return
+        }
+        this.isFullscreen = true
+      }
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.c-camera__checkbox{
+  position: absolute;
+  top: 10px;
+  left: 10px;
+}
+.mask .o-video__mask{
+  display: inline-block;
+}
+</style>

+ 333 - 0
src/views/device/detail/components/DeviceExternal/components/Record/index.vue

@@ -0,0 +1,333 @@
+<template>
+  <div class="l-flex l-flex__fill c-camera">
+    <div class="l-flex--col c-camera__left">
+      <!-- <div class="c-camera__padding">
+        <search-input
+          v-model.trim="searchName"
+          class="c-camera__input"
+          placeholder="搜索设备"
+          @change="onChange"
+        />
+      </div> -->
+      <div
+        v-if="cameras.length===0"
+        class="u-color--info l-flex--col center"
+      >暂无数据</div>
+      <div v-else>
+        <div
+          v-for="(camera, index) in cameras"
+          :key="camera.id"
+          class="l-flex c-camera__item"
+          :class="{'c-camera__active': index === active }"
+          @click="() => onChangeCamera(camera, index)"
+        >
+          <img :src="index === active ? require('@/assets/icon_camera_white.svg') : require('@/assets/icon_camera.svg')">
+          <div class="l-flex__fill">
+            <div class="c-camera__name">{{ camera.identifier }}</div>
+            <div
+              class="c-camera__bound"
+              :style="{color: index === active ? '#fff':'#4293FE'}"
+            ><div
+              class="c-camera__dian"
+              :style="{backgroundColor: index === active ? '#fff':'#4293FE'}"
+            />{{ camera.bound ? '已使用' : '未使用' }}</div>
+            <!-- <div :style="{color: index === 2 ? '#E51414':'#8E929C'}">已下载{{ camera.totalCount }}个录像</div> -->
+          </div>
+          <div
+            class="c-camera__tip"
+            :style="{backgroundColor: camera.onlineStatus===1?'#04A681':'#E51414'}"
+          >{{ camera.onlineStatus===1?'在线':'离线' }}</div>
+        </div>
+      </div>
+    </div>
+    <div class="l-flex l-flex__auto c-camera__right">
+      <grid-table
+        ref="table"
+        :schema="recordSchema"
+        size="large"
+      >
+        <grid-table-item v-slot="item">
+          <record-player
+            :camera="item"
+            @delRecord="onDeleteRecord"
+            @onResumeDownloadRecord="onResumeDownloadRecord"
+          />
+        </grid-table-item>
+      </grid-table>
+    </div>
+    <table-dialog
+      ref="sdRecordTableDialog"
+      title="SD卡数据"
+      :schema="sdRecordSchema"
+      append-to-body
+    >
+      <template #header>
+        <div class="l-flex--row u-color--info">
+          展示的为结束时间往前&nbsp;<span class="u-color--blue u-font-size--lg">6</span>&nbsp;小时内的录像
+        </div>
+      </template>
+    </table-dialog>
+    <PlayaxisDialog
+      ref="PlayaxisDialog"
+      :camera="cameraObj"
+      :identifier="identifier"
+    />
+  </div>
+</template>
+
+<script>
+import { AssetType } from '@/constant'
+import { parseTime } from '@/utils'
+import {
+  getRecords,
+  getSDRecords,
+  downloadRecord,
+  stopDownloadRecord,
+  resumeDownloadRecord
+} from '@/api/external'
+import PlayaxisDialog from './components/PlayaxisDialog'
+import RecordPlayer from './components/index'
+
+export default {
+  components: {
+    RecordPlayer,
+    PlayaxisDialog
+  },
+  props: {
+    cameras: {
+      type: Array,
+      default: null
+    }
+  },
+  data () {
+    return {
+      identifier: '',
+      searchName: '',
+      active: 0,
+      recordSchema: {
+        pageSize: 6,
+        list: this.getRecords,
+        buttons: [
+          { type: 'add', label: '新增录像', on: this.onViewSD },
+          { type: '', label: '实时回放', on: this.onPlayBack }
+        ]
+      },
+      checked: false,
+      // SD卡数据
+      sdRecordSchema: {
+        nonPagination: true,
+        list: this.getSDRecords,
+        filters: [
+          { key: 'time', type: 'datepicker', options: {
+            type: 'datetime',
+            placeholder: '结束时间',
+            'picker-options': {
+              disabledDate: this.isDisableDate
+            }
+          } }
+        ],
+        cols: [
+          { type: 'refresh' },
+          { prop: 'recordTypeName', label: '名称' },
+          { prop: 'startTime', label: '开始时间' },
+          { prop: 'endTime', label: '结束时间' },
+          { type: 'invoke', render: [
+            { label: '下载', on: this.onStartDownload }
+          ] }
+        ]
+      },
+      cameraObj: {}
+    }
+  },
+  computed: {
+    pickerOptions () {
+      return {
+        disabledDate: this.isDisableDate
+      }
+    }
+  },
+  watch: {
+    cameras () {
+      this.cameraObj = this.cameras[this.active]
+      this.cameraId = this.cameras[this.active].id
+      this.identifier = this.cameras[this.active].identifier
+      this.$refs.table?.pageTo(1)
+    }
+  },
+  created () {
+    this.$timer = -1
+  },
+  beforeDestroy () {
+    clearTimeout(this.$timer)
+  },
+  methods: {
+    onPlayBack () {
+      if (this.cameras[this.active].onlineStatus === 1) {
+        this.$refs.PlayaxisDialog.show()
+      } else {
+        this.$message({
+          type: 'warning',
+          message: '摄像头离线'
+        })
+      }
+    },
+    onChangeCamera (camera, index) {
+      this.cameraId = camera.id
+      this.identifier = camera.identifier
+      this.active = index
+      this.$refs.table?.pageTo(1)
+    },
+    onPlayRecords ({ identifier }) {
+      this.identifier = identifier
+      this.$refs.PlayaxisDialog.show()
+    },
+    isDisableDate (date) {
+      return date > Date.now()
+    },
+    onView (camera) {
+      this.$refs.cameraDialog.show(camera)
+    },
+    onViewRecords ({ identifier }) {
+      this.identifier = identifier
+      this.$refs.recordTableDialog.show()
+    },
+    // 获取录像
+    getRecords (params) {
+      clearTimeout(this.$timer)
+      if (params.pageNum === 1) {
+        this.$timer = setTimeout(() => {
+          this.$refs.table?.refreshCurrentPageOnBackground()
+        }, 5000)
+      }
+      return getRecords({
+        cameraId: this.cameraId,
+        ...params
+      })
+    },
+    onDeleteRecord () {
+      this.$refs.table?.pageTo(1)
+    },
+    onResumeDownloadRecord (id) {
+      resumeDownloadRecord(id).then(() => {
+        this.$refs.table?.pageTo(1)
+      })
+    },
+    onStopDownloadRecord () {
+      stopDownloadRecord(this.identifier).then(() => {
+        this.$refs.recordTableDialog.getTable().pageTo(1)
+      })
+    },
+    onPlayRecord ({ url }) {
+      this.$refs.previewDialog.show({ type: AssetType.VIDEO, url })
+    },
+    onViewSD () {
+      if (this.cameras[this.active].onlineStatus === 1) {
+        this.$refs.sdRecordTableDialog.show()
+      } else {
+        this.$message({
+          type: 'warning',
+          message: '摄像头离线'
+        })
+      }
+    },
+    getSDRecords ({ time }) {
+      time = time ? new Date(time) : new Date()
+      const startTime = new Date(time.getTime())
+      startTime.setHours(startTime.getHours() - 1)
+      return getSDRecords({
+        identifier: this.identifier,
+        startTime: parseTime(startTime, '{y}-{m}-{d} {h}:{i}:{s}'),
+        endTime: parseTime(time, '{y}-{m}-{d} {h}:{i}:{s}')
+      })
+    },
+    onStartDownload ({ startTime, endTime }) {
+      downloadRecord({
+        identifier: this.identifier,
+        startTime,
+        endTime
+      }).then(() => {
+        this.$refs.sdRecordTableDialog.hide()
+        this.$refs.table?.pageTo(1)
+      })
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.c-camera{
+  border-top: 1px solid #EDF0F6;
+  padding: 15px 0;
+  &__left{
+    width: 224px;
+    border-right: 1px solid #EDF0F6;
+  }
+  &__padding{
+    padding: 16px;
+  }
+  &__input{
+    width: 100%;
+  }
+  &__tip{
+    width: 32px;
+    height: 16px;
+    text-align: center;
+    border-radius: 2px;
+    color: #fff;
+    margin-left: 10px;
+  }
+  &__bound{
+    color: #4293FE;
+    margin: 5px 0;
+  }
+  &__dian{
+    width: 4px;
+    height: 4px;
+    border-radius: 4px;
+    display: inline-block;
+    position: relative;
+    top: -2px;
+    margin-right: 7px;
+  }
+  &__item{
+    padding: 16px;
+    cursor: pointer;
+    border-bottom: 1px solid #EDF0F6;
+    font-size: 12px;
+    img{
+      width: 23px;
+      height: 22px;
+      margin-right: 10px;
+    }
+  }
+  &__name{
+    font-size: 16px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+  &__active{
+    background-color: #1C5CB0;
+    color: #fff;
+  }
+  &__right{
+    padding-left: 17px;
+    position: relative;
+  }
+  &__button {
+    position: absolute;
+    left: 128px;
+  }
+  &__checkbox {
+    display: flex;
+    align-items: center;
+  }
+}
+</style>
+<style>
+.c-camera .el-checkbox__input.is-checked+.el-checkbox__label{
+  color: #1c5cb0;
+}
+.c-camera .el-checkbox__input.is-checked .el-checkbox__inner{
+  background-color: #1c5cb0;
+  border-color: #1c5cb0;
+}
+</style>

+ 4 - 0
src/views/device/detail/components/DeviceRuntime/OnlineDuration.vue

@@ -97,8 +97,12 @@ export default {
             this.duration = this.transformDuration(data[0].powerSeconds)
             this.timestamp = data[0].powerSecondsUpdateTime
           }
+          if (!this.duration) {
+            this.duration = '暂无统计数据'
+          }
         } else {
           this.duration = '暂无统计数据'
+          this.timestamp = ''
         }
       }).finally(() => {
         if (this.$running) {