소스 검색

增加设备实时事件查看,自动滚动

xiongxing 2 주 전
부모
커밋
2d5e5bb5c2
3개의 변경된 파일338개의 추가작업 그리고 4개의 파일을 삭제
  1. 1 1
      src/views/Home/components/ElderLocationMap.vue
  2. 278 0
      src/views/Home/components/RealtimeFeedDrawer.vue
  3. 59 3
      src/views/Home/home-refactored.vue

+ 1 - 1
src/views/Home/components/ElderLocationMap.vue

@@ -6,7 +6,7 @@
         <vue3-seamless-scroll
           class="list-body"
           :list="list"
-          :step="2"
+          :step="0.5"
           :visibleCount="10"
           :hover="true"
           direction="up"

+ 278 - 0
src/views/Home/components/RealtimeFeedDrawer.vue

@@ -0,0 +1,278 @@
+<template>
+  <el-drawer
+    v-model="visible"
+    :title="title"
+    direction="rtl"
+    size="420px"
+    append-to-body
+    class="realtime-feed-drawer"
+  >
+    <div class="feed-container">
+      <div class="feed-header">
+        <div class="summary">
+          <span>实时事件(最多100条)</span>
+          <span class="count">{{ items.length }}</span>
+        </div>
+        <div class="actions">
+          <el-button size="small" @click="clear">清空</el-button>
+        </div>
+      </div>
+
+      <vue3-seamless-scroll class="feed-scroll" :list="items" :class-option="seamlessOption">
+        <div class="feed-item" v-for="(it, idx) in items" :key="it.id || idx">
+          <div class="left-mark" :class="it.type"></div>
+          <div class="item-main">
+            <div class="row top">
+              <span class="tag" :class="it.type">{{ it.type === 'location' ? '定位' : '健康' }}</span>
+              <span class="time">{{ it.time }}</span>
+            </div>
+
+            <template v-if="it.type === 'location'">
+              <div class="row">
+                <span class="label">经纬度:</span>
+                <span class="text">{{ it.longitude.toFixed(6) }}, {{ it.latitude.toFixed(6) }}</span>
+              </div>
+              <div class="row" v-if="it.address">
+                <span class="label">位置:</span>
+                <span class="text">{{ it.address }}</span>
+              </div>
+            </template>
+
+            <template v-else>
+              <div class="row">
+                <span class="label">长者:</span>
+                <span class="text">{{ it.elderName || '-' }}</span>
+              </div>
+              <div class="row">
+                <span class="label">消息:</span>
+                <span class="text">{{ it.message }}</span>
+              </div>
+            </template>
+          </div>
+        </div>
+      </vue3-seamless-scroll>
+    </div>
+  </el-drawer>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, computed, watch } from 'vue'
+import { amapReverseGeocode } from '@/utils/amapService'
+
+interface FeedLocation {
+  type: 'location'
+  time: string
+  longitude: number
+  latitude: number
+  address?: string
+  id?: string
+}
+
+interface FeedHealth {
+  type: 'health'
+  time: string
+  message: string
+  elderName?: string
+  id?: string
+}
+
+type FeedItem = FeedLocation | FeedHealth
+
+const props = withDefaults(
+  defineProps<{
+    modelValue?: boolean
+    title?: string
+  }>(),
+  {
+    modelValue: false,
+    title: '实时事件'
+  }
+)
+
+const emit = defineEmits<{
+  (e: 'update:visible', v: boolean): void
+  (e: 'update:modelValue', v: boolean): void
+}>()
+
+// 兼容 v-model:visible 和 v-model
+const _visible = ref(!!props.modelValue)
+const visible = computed({
+  get: () => _visible.value,
+  set: (v: boolean) => {
+    _visible.value = v
+    emit('update:visible', v)
+    emit('update:modelValue', v)
+  }
+})
+
+watch(
+  () => props.modelValue,
+  (v) => {
+    if (typeof v === 'boolean') _visible.value = v
+  }
+)
+
+const items = ref<FeedItem[]>([])
+const MAX = 100
+
+// 滚动配置
+const seamlessOption = reactive({
+  step: 0.5,
+  limitMoveNum: 1,
+  hoverStop: true,
+  direction: 'up',
+  singleHeight: 72,
+  waitTime: 800
+})
+
+// 地址缓存,key: "lng.toFixed(6),lat.toFixed(6)"
+const addrCache: Record<string, string> = {}
+
+const ensureMax = () => {
+  if (items.value.length > MAX) {
+    items.value.splice(0, items.value.length - MAX)
+  }
+}
+
+const toTimeText = (t?: any) => {
+  if (!t) return new Date().toLocaleString()
+  if (typeof t === 'number') return new Date(t).toLocaleString()
+  return String(t)
+}
+
+// 对外方法:推入定位
+const pushLocation = async (payload: {
+  longitude: number | string
+  latitude: number | string
+  locationTime?: string | number
+  id?: string
+}) => {
+  const lng = Number(payload.longitude)
+  const lat = Number(payload.latitude)
+  if (!Number.isFinite(lng) || !Number.isFinite(lat)) return
+
+  const key = `${lng.toFixed(6)},${lat.toFixed(6)}`
+  if (!addrCache[key]) {
+    try {
+      addrCache[key] = await amapReverseGeocode(lng, lat)
+    } catch {
+      addrCache[key] = ''
+    }
+  }
+
+  items.value.push({
+    type: 'location',
+    time: toTimeText(payload.locationTime),
+    longitude: lng,
+    latitude: lat,
+    address: addrCache[key],
+    id: payload.id
+  })
+  ensureMax()
+}
+
+// 对外方法:推入健康
+const pushHealth = (payload: { time?: string | number; message: string; elderName?: string; id?: string }) => {
+  items.value.push({
+    type: 'health',
+    time: toTimeText(payload.time),
+    message: payload.message,
+    elderName: payload.elderName,
+    id: payload.id
+  })
+  ensureMax()
+}
+
+const clear = () => {
+  items.value = []
+}
+
+defineExpose({ pushLocation, pushHealth })
+</script>
+
+<style lang="scss" scoped>
+.realtime-feed-drawer {
+  color: #fff;
+}
+.feed-container {
+  display: flex;
+  height: 100%;
+  flex-direction: column;
+}
+.feed-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-bottom: 8px;
+  margin-bottom: 8px;
+  border-bottom: 1px solid rgb(255 255 255 / 10%);
+  .summary {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    .count {
+      padding: 2px 8px;
+      font-size: 12px;
+      color: #fff;
+      background: rgb(255 255 255 / 20%);
+      border-radius: 12px;
+    }
+  }
+}
+.feed-scroll {
+  flex: 1;
+  overflow: hidden;
+}
+.feed-item {
+  display: flex;
+  padding: 10px 12px;
+  border-bottom: 1px solid rgb(255 255 255 / 6%);
+  gap: 10px;
+}
+.left-mark {
+  width: 6px;
+  border-radius: 3px;
+}
+.left-mark.location {
+  background: #409eff;
+}
+.left-mark.health {
+  background: #26de81;
+}
+.item-main {
+  flex: 1;
+}
+.row {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  line-height: 1.6;
+}
+.row.top {
+  justify-content: space-between;
+}
+.tag {
+  padding: 2px 8px;
+  font-size: 12px;
+  border-radius: 10px;
+}
+.tag.location {
+  color: #409eff;
+  background: rgb(64 158 255 / 20%);
+}
+.tag.health {
+  color: #26de81;
+  background: rgb(38 222 129 / 20%);
+}
+.label {
+  color: #a5b1c2;
+}
+.text {
+  color: #fff;
+}
+.time {
+  color: #cfd3dc;
+  font-size: 12px;
+}
+</style>
+

+ 59 - 3
src/views/Home/home-refactored.vue

@@ -7,6 +7,13 @@
       <!-- 顶部信息栏 -->
       <TopInfoBar :tenant-name="getTenantName()" />
 
+      <!-- 右上角实时事件抽屉开关 -->
+      <el-tooltip class="box-item" effect="dark" content="设备实时事件" placement="top-start">
+        <button class="realtime-fab" @click="realtimeDrawerVisible = true" title="实时事件">
+          <Icon icon="mdi:radar" :size="30" />
+        </button>
+      </el-tooltip>
+
       <!-- 统计信息卡片 -->
       <StatsCard :stats="largeScreenStats" @stat-card-click="handleStatCardClick" />
 
@@ -96,6 +103,9 @@
       v-model="elderWarningServiceDialogVisible"
       :elder-id="currentWarningServiceElderId"
     />
+
+    <!-- 实时事件抽屉 -->
+    <RealtimeFeedDrawer ref="realtimeDrawerRef" v-model="realtimeDrawerVisible" />
   </div>
 </template>
 
@@ -135,6 +145,10 @@ const ElderProfileDialog = defineAsyncComponent(() => import('./components/Elder
 const ElderWarningServiceDialog = defineAsyncComponent(
   () => import('./components/ElderWarningServiceDialog.vue')
 )
+const RealtimeFeedDrawer = defineAsyncComponent(() => import('./components/RealtimeFeedDrawer.vue'))
+
+const realtimeDrawerVisible = ref(false)
+const realtimeDrawerRef = ref<any>(null)
 
 // 类型定义
 interface WarningHistory {
@@ -833,13 +847,19 @@ const handleLocationAlert = async (locationAlert: any) => {
     const elderId = Number(payload.elderId || payload.elderID || payload.elder_id || 0)
     const lng = Number(payload.longitude)
     const lat = Number(payload.latitude)
-    const ts = payload.timestamp || payload.time || Date.now()
+    const ts = payload.locationTime || payload.timestamp || payload.time || Date.now()
+
+    // 推入右上角实时抽屉
+    realtimeDrawerRef.value?.pushLocation?.({
+      longitude: lng,
+      latitude: lat,
+      locationTime: ts
+    })
 
-    // 只处理当前选中长者
+    // 地图联动:只对当前选中长者联动地图
     if (!elderId || elderId !== selectedElderly.value.id) return
     if (!Number.isFinite(lng) || !Number.isFinite(lat)) return
 
-    // 仅当右侧详情处于“长者位置”地图模式时,实时追加
     const isMapShown = detailSectionRef.value?.isMapShown?.()
     if (!isMapShown) return
 
@@ -1139,6 +1159,42 @@ $bg-accent-2: rgb(123 97 255 / 25%);
     }
   }
 }
+/* 右上角悬浮按钮 */
+.realtime-fab {
+  position: fixed;
+  top: 230px;
+  right: 16px;
+  width: 80px;
+  height: 80px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  border: none;
+  border-radius: 50%;
+  color: #fff;
+  background: rgb(26 31 46 / 90%);
+  box-shadow: 0 6px 18px rgb(0 0 0 / 35%);
+  backdrop-filter: blur(6px);
+  z-index: 2000;
+  transition: all 0.2s ease;
+}
+
+.realtime-fab:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 10px 26px rgb(0 0 0 / 45%);
+  background: rgb(26 115 232 / 30%);
+}
+
+.realtime-fab:active {
+  transform: translateY(0);
+}
+
+// .realtime-fab :deep(svg),
+// .realtime-fab :deep(span) {
+//   width: 22px !important;
+//   height: 22px !important;
+// }
 </style>
 
 <style lang="scss">