Bläddra i källkod

增加实时事件,增加长者预警服务列表

xiongxing 2 veckor sedan
förälder
incheckning
d15fa0726c

+ 13 - 81
src/views/Home/components/ElderWarningServiceDialog.vue

@@ -21,20 +21,21 @@
       <el-table
         :data="warningList"
         style="width: 100%; margin-bottom: 20px"
-        :default-sort="{ prop: 'createdTime', order: 'descending' }"
+        :default-sort="{ prop: 'triggeredAt', order: 'descending' }"
       >
-        <el-table-column prop="name" label="长者姓名" width="120" />
-        <el-table-column prop="eventType" label="预警类型" width="150">
+        <el-table-column prop="elderName" label="长者姓名" width="120" />
+        <el-table-column prop="alertType" label="预警类型" width="150">
           <template #default="{ row }">
-            <el-tag :type="getEventTypeTag(row.eventType)">
-              {{ row.eventType }}
+            <el-tag>
+              {{ row.alertType }}
             </el-tag>
           </template>
         </el-table-column>
         <el-table-column prop="message" label="预警信息" show-overflow-tooltip />
-        <el-table-column prop="createdTime" label="创建时间" width="180">
+        <el-table-column prop="status" label="状态" show-overflow-tooltip />
+        <el-table-column prop="triggeredAt" label="创建时间" width="180">
           <template #default="{ row }">
-            {{ formatTime(row.createdTime) }}
+            {{ formatTime(row.triggeredAt) }}
           </template>
         </el-table-column>
       </el-table>
@@ -65,15 +66,11 @@ import { formatToDateTime } from '@/utils/dateUtil'
 
 // 类型定义
 interface WarningRecord {
-  name: string
-  eventType: string
+  elderName: string
+  alertType: string
   message: string
-  createdTime: string
-}
-
-interface WarningServiceResponse {
-  total: number
-  list: WarningRecord[]
+  triggeredAt: string
+  status: string
 }
 
 // Props 和 Emits
@@ -132,69 +129,13 @@ const formatTime = (time: string | number) => {
   return formatToDateTime(time)
 }
 
-const getEventTypeTag = (eventType: string) => {
-  if (eventType === 'SOS报警') {
-    return 'danger'
-  } else if (eventType === '健康指标异常') {
-    return 'warning'
-  }
-  return 'info'
-}
-
-// 生成模拟数据(支持分页)
-const generateMockServiceData = (
-  elderId: number,
-  pageNum: number,
-  pageSize: number
-): WarningServiceResponse => {
-  const names = ['王奶奶', '李爷爷', '张奶奶', '刘爷爷', '陈奶奶', '杨爷爷']
-  const eventTypes = ['SOS报警', '健康指标异常']
-  const healthMsgs = [
-    '心率过高,建议就医检查',
-    '血压偏高,请注意休息',
-    '血氧偏低,请及时关注',
-    '体温异常,建议复测'
-  ]
-  const sosMsgs = [
-    '手表发出SOS呼叫,请立即回拨',
-    '设备触发紧急求助,请尽快联系',
-    '跌倒疑似SOS,请核实情况'
-  ]
-
-  const total = 37 // 模拟总条数
-  const all: WarningRecord[] = Array.from({ length: total }).map((_, idx) => {
-    const type = eventTypes[Math.floor(Math.random() * eventTypes.length)]
-    const msg =
-      type === 'SOS报警'
-        ? sosMsgs[Math.floor(Math.random() * sosMsgs.length)]
-        : healthMsgs[Math.floor(Math.random() * healthMsgs.length)]
-    const daysAgo = Math.floor(Math.random() * 15)
-    const date = new Date(
-      Date.now() - daysAgo * 24 * 60 * 60 * 1000 - Math.floor(Math.random() * 86400000)
-    )
-    return {
-      name: names[elderId % names.length],
-      eventType: type,
-      message: msg,
-      createdTime: date.toISOString()
-    }
-  })
-
-  const start = (pageNum - 1) * pageSize
-  const end = start + pageSize
-  return {
-    total,
-    list: all.slice(start, end)
-  }
-}
-
 const fetchWarningServiceData = async (elderId: number) => {
   if (!elderId) return
 
   loading.value = true
   try {
     const res = await fetchHttp.get(
-      '/api/pc/admin/getElderServer',
+      '/api/pc/admin/getElderAlerts',
       {
         elderId: elderId,
         pageNum: pageNum.value,
@@ -210,18 +151,9 @@ const fetchWarningServiceData = async (elderId: number) => {
     if (res && Array.isArray(res.list) && res.list.length > 0) {
       total.value = res.total || 0
       warningList.value = res.list || []
-    } else {
-      // 接口暂无数据,使用模拟数据
-      const mock = generateMockServiceData(elderId, pageNum.value, pageSize.value)
-      total.value = mock.total
-      warningList.value = mock.list
     }
   } catch (error) {
     console.error('获取预警服务数据失败:', error)
-    // 接口异常,使用模拟数据
-    const mock = generateMockServiceData(elderId, pageNum.value, pageSize.value)
-    total.value = mock.total
-    warningList.value = mock.list
   } finally {
     loading.value = false
   }

+ 341 - 74
src/views/Home/components/RealtimeFeedDrawer.vue

@@ -1,80 +1,157 @@
 <template>
-  <el-drawer
-    v-model="visible"
-    :title="title"
-    direction="rtl"
-    size="420px"
-    append-to-body
-    class="realtime-feed-drawer"
-  >
-    <div style="overflow: hidden; height: 800px">
-      <vue3-seamless-scroll
-        ref="seamlessScrollRef"
-        class="feed-scroll"
-        :list="items"
-        :step="0.5"
-        :visibleCount="1"
-        :hover="true"
-        direction="up"
-        :singleHeight="0"
-        :singleWaitTime="0"
-        style="overflow: hidden; height: auto"
-        v-if="items.length"
+  <!-- 右侧固定面板:自动滚动列表 -->
+  <div class="realtime-feed-panel" v-show="visible">
+    <div class="panel-header">
+      <span class="panel-title">{{ title }}</span>
+      <button class="close-btn" @click="visible = false">×</button>
+    </div>
+
+    <div class="feed-container">
+      <!-- 自定义自动滚动容器(不依赖第三方组件) -->
+      <div
+        class="auto-scroll"
+        ref="autoWrapRef"
+        @mouseenter="paused = true"
+        @mouseleave="paused = false"
       >
-        <!-- v-if="items.length" -->
-        <div class="feed-item" v-for="(it, idx) in items" :key="it._k">
-          <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' ? '定位' : '健康' }}-#{{ idx + 1 }}</span
-              >
-              <span class="time">{{ it.time }}</span>
-            </div>
+        <div
+          class="scroll-inner"
+          ref="innerRef"
+          :style="{ transform: `translateY(-${scrollY}px)` }"
+        >
+          <!-- 第一组(基准循环体) -->
+          <div class="group" ref="group0Ref">
+            <div class="feed-item" v-for="it in items" :key="`g0_${it._k}`">
+              <div class="left-mark" :class="it.type"></div>
+              <div class="item-main">
+                <div class="row top">
+                  <span class="tag" :class="it.type">{{ it.typeText }}</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.elderName }}</span>
-              </div>
-              <div class="row" v-if="it.address">
-                <span class="label">位置:</span>
-                <span class="text">{{ it.address }}</span>
-              </div>
-            </template>
+                <template v-if="it.type === 'location'">
+                  <div class="row">
+                    <span class="label">长者姓名:</span>
+                    <span class="text">{{ it.elderName }}</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>
-                <div>
-                  <div
-                    class="text"
-                    v-for="(indicator, keyIndex) in it.indicatorResults"
-                    :key="keyIndex"
-                  >
-                    <span class="label">{{ indicator.indicatorName }}:</span>
-                    <span class="text">{{ indicator.message }}-{{ indicator.suggestion }}</span>
+                <template v-else-if="it.type === 'health'">
+                  <div class="row">
+                    <span class="label">长者姓名:</span>
+                    <span class="text">{{ it.elderName || '-' }}</span>
+                  </div>
+                  <div class="row">
+                    <span class="label">健康消息:</span>
+                    <div style="flex: 1">
+                      <div
+                        class="text"
+                        v-for="(indicator, keyIndex) in it.indicatorResults"
+                        :key="keyIndex"
+                      >
+                        <span class="label">{{ indicator.indicatorName }}:</span>
+                        <span class="text">{{ indicator.message }}-{{ indicator.suggestion }}</span>
+                      </div>
+                    </div>
+                  </div>
+                </template>
+
+                <template v-else-if="it.type === 'deviceUpdate'">
+                  <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.deviceType }}</span>
                   </div>
+                  <div class="row">
+                    <span class="label">设备状态:</span>
+                    <span class="text">{{ it.eventType }}</span>
+                  </div>
+                </template>
+              </div>
+            </div>
+          </div>
+
+          <!-- 第二组(用于无缝循环,仅在达到门槛时渲染) -->
+          <div class="group" v-if="shouldAutoScroll">
+            <div class="feed-item" v-for="it in items" :key="`g1_${it._k}`">
+              <div class="left-mark" :class="it.type"></div>
+              <div class="item-main">
+                <div class="row top">
+                  <span class="tag" :class="it.type">{{ it.typeText }}</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.elderName }}</span>
+                  </div>
+                  <div class="row" v-if="it.address">
+                    <span class="label">位置:</span>
+                    <span class="text">{{ it.address }}</span>
+                  </div>
+                </template>
+
+                <template v-else-if="it.type === 'health'">
+                  <div class="row">
+                    <span class="label">长者姓名:</span>
+                    <span class="text">{{ it.elderName || '-' }}</span>
+                  </div>
+                  <div class="row">
+                    <span class="label">健康消息:</span>
+                    <div style="flex: 1">
+                      <div
+                        class="text"
+                        v-for="(indicator, keyIndex) in it.indicatorResults"
+                        :key="keyIndex"
+                      >
+                        <span class="label">{{ indicator.indicatorName }}:</span>
+                        <span class="text">{{ indicator.message }}-{{ indicator.suggestion }}</span>
+                      </div>
+                    </div>
+                  </div>
+                </template>
+
+                <template v-else-if="it.type === 'deviceUpdate'">
+                  <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.deviceType }}</span>
+                  </div>
+                  <div class="row">
+                    <span class="label">设备状态:</span>
+                    <span class="text">{{ it.eventType }}</span>
+                  </div>
+                </template>
               </div>
-            </template>
+            </div>
           </div>
         </div>
-      </vue3-seamless-scroll>
+
+        <!-- 空状态 -->
+        <div class="empty" v-if="!items.length"> 暂无数据 </div>
+      </div>
     </div>
-  </el-drawer>
+  </div>
 </template>
 
 <script lang="ts" setup>
-import { reactive, ref, computed, watch, nextTick } from 'vue'
+import { reactive, ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
 import { amapReverseGeocode } from '@/utils/amapService'
 
 interface FeedLocation {
   type: 'location'
+  typeText: string
   time: string
   longitude: number
   latitude: number
@@ -93,22 +170,39 @@ interface IndicatorResultsOv {
 
 interface FeedHealth {
   type: 'health'
+  typeText: string
   time: string
   elderName?: string
   indicatorResults: IndicatorResultsOv[]
   _k?: string
 }
 
-type FeedItem = FeedLocation | FeedHealth
+interface FeedDeviceUpdate {
+  type: 'deviceUpdate'
+  typeText: string
+  time: string
+  elderName?: string
+  _k?: string
+  eventType: string
+  deviceType: string
+}
+
+type FeedItem = FeedLocation | FeedHealth | FeedDeviceUpdate
 
 const props = withDefaults(
   defineProps<{
     modelValue?: boolean
     title?: string
+    // 自动滚动速度(px/秒)
+    speed?: number
+    // 达到多少条开始自动滚动
+    minCount?: number
   }>(),
   {
     modelValue: false,
-    title: '实时事件'
+    title: '实时事件',
+    speed: 30,
+    minCount: 6
   }
 )
 
@@ -128,6 +222,68 @@ const visible = computed({
   }
 })
 
+// 列表与控制
+const items = ref<FeedItem[]>([])
+const MAX = 15
+
+// 自动滚动相关
+const autoWrapRef = ref<HTMLElement | null>(null)
+const innerRef = ref<HTMLElement | null>(null)
+const group0Ref = ref<HTMLElement | null>(null)
+const scrollY = ref(0)
+const cycleHeight = ref(0)
+const paused = ref(false)
+let rafId: number | null = null
+let lastTs = 0
+
+const shouldAutoScroll = computed(() => items.value.length >= props.minCount)
+
+const stopLoop = () => {
+  if (rafId != null) {
+    cancelAnimationFrame(rafId)
+    rafId = null
+  }
+}
+
+const stepLoop = (ts: number) => {
+  const canScroll = visible.value && !paused.value && shouldAutoScroll.value && !!cycleHeight.value
+
+  if (!canScroll) {
+    lastTs = ts
+    // 不满足门槛时停在顶部
+    if (!shouldAutoScroll.value && scrollY.value !== 0) scrollY.value = 0
+    rafId = requestAnimationFrame(stepLoop)
+    return
+  }
+
+  const dt = lastTs ? (ts - lastTs) / 1000 : 0
+  lastTs = ts
+  const dy = props.speed * dt
+  scrollY.value += dy
+  if (scrollY.value >= cycleHeight.value) {
+    scrollY.value -= cycleHeight.value
+  }
+  rafId = requestAnimationFrame(stepLoop)
+}
+
+const startLoop = () => {
+  if (rafId == null) {
+    lastTs = 0
+    rafId = requestAnimationFrame(stepLoop)
+  }
+}
+
+const measure = () => {
+  // 重新计算单个循环体高度
+  cycleHeight.value = group0Ref.value?.offsetHeight || 0
+  // 防止高度为 0 的情况,稍后再测一次
+  if (!cycleHeight.value) {
+    setTimeout(() => {
+      cycleHeight.value = group0Ref.value?.offsetHeight || 0
+    }, 50)
+  }
+}
+
 watch(
   () => props.modelValue,
   (v) => {
@@ -135,9 +291,27 @@ watch(
   }
 )
 
-const items = ref<FeedItem[]>([])
-const MAX = 15
-const seamlessScrollRef = ref()
+watch(
+  () => visible.value,
+  async (v) => {
+    if (v) {
+      await nextTick()
+      measure()
+      startLoop()
+    } else {
+      stopLoop()
+    }
+  }
+)
+
+watch(
+  () => items.value.length,
+  async () => {
+    await nextTick()
+    scrollY.value = 0
+    measure()
+  }
+)
 
 let _seq = 0
 const genKey = () => `${Date.now()}_${++_seq}_${Math.random().toString(36).slice(2, 8)}`
@@ -149,10 +323,6 @@ const ensureMax = () => {
   if (items.value.length > MAX) {
     items.value.splice(0, items.value.length - MAX)
   }
-  // nextTick(() => {
-  //   console.log('seamlessScrollRef.value', seamlessScrollRef.value)
-  //   seamlessScrollRef.value?.reset()
-  // })
 }
 
 const toTimeText = (t?: any) => {
@@ -184,6 +354,7 @@ const pushLocation = async (payload: {
   items.value.push({
     _k: genKey(),
     type: 'location',
+    typeText: '定位信息更新',
     time: toTimeText(payload.locationTime),
     longitude: lng,
     latitude: lat,
@@ -198,6 +369,7 @@ const pushHealth = (payload: FeedHealth) => {
   items.value.push({
     _k: genKey(),
     type: 'health',
+    typeText: '健康信息更新',
     time: toTimeText(payload.time),
     elderName: payload.elderName,
     indicatorResults: payload.indicatorResults
@@ -205,16 +377,107 @@ const pushHealth = (payload: FeedHealth) => {
   ensureMax()
 }
 
-defineExpose({ pushLocation, pushHealth })
+// 对外方法:推入设备更新信息
+const pushDeviceUpdate = (payload: FeedDeviceUpdate) => {
+  items.value.push({
+    _k: genKey(),
+    type: 'deviceUpdate',
+    typeText: '设备信息更新',
+    time: toTimeText(payload.time),
+    elderName: payload.elderName,
+    eventType: payload.eventType,
+    deviceType: payload.deviceType
+  })
+  ensureMax()
+}
+
+defineExpose({ pushLocation, pushHealth, pushDeviceUpdate })
+
+onMounted(async () => {
+  if (visible.value) {
+    await nextTick()
+    measure()
+    startLoop()
+  }
+})
+
+onBeforeUnmount(() => {
+  stopLoop()
+})
 </script>
 
 <style lang="scss" scoped>
-.realtime-feed-drawer {
+/* 右侧固定面板样式 */
+.realtime-feed-panel {
+  position: fixed;
+  top: 100px;
+  right: 20px;
+  width: 420px;
+  height: 90vh;
+  background: rgba(18, 18, 18, 0.9);
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  border-radius: 10px;
   color: #fff;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  z-index: 1000;
+}
+
+.panel-header {
+  height: 48px;
+  padding: 0 12px 0 16px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.panel-title {
+  font-size: 20px;
+}
+
+.close-btn {
+  appearance: none;
+  border: none;
+  background: transparent;
+  color: #cfd3dc;
+  font-size: 30px;
+  line-height: 1;
+  cursor: pointer;
+}
+.close-btn:hover {
+  color: #ffffff;
+}
+
+.feed-container {
+  height: calc(100% - 48px);
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.auto-scroll {
+  position: relative;
+  flex: 1;
+  height: 100%;
+  overflow: hidden;
+}
+
+.scroll-inner {
+  will-change: transform;
 }
-// .feed-container {
-//   height: 800px;
-// }
+
+.empty {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #a5b1c2;
+}
+
 .feed-item {
   display: flex;
   padding: 10px 12px;
@@ -256,6 +519,10 @@ defineExpose({ pushLocation, pushHealth })
   color: #26de81;
   background: rgb(38 222 129 / 20%);
 }
+.tag.deviceUpdate {
+  color: #ff9800;
+  background: rgb(255 152 0 / 20%);
+}
 .label {
   color: #a5b1c2;
 }

+ 1 - 1
src/views/Home/composables/useWebSocket.ts

@@ -225,7 +225,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
       case 'HEALTH_UPDATE':
         config.onHealthUpdateAlert?.(data)
         break
-      case 'DEVICE_DATA_UPDATE':
+      case 'DEVICE_ONLINE_STATUS_UPDATE':
         config.onDeviceDataUpdate?.(data)
         break
       case 'SYSTEM_STATS_UPDATE':

+ 27 - 2
src/views/Home/home-refactored.vue

@@ -882,6 +882,30 @@ const handleHealthUpdateAlert = async (healthUpdateAlert: any) => {
   })
 }
 
+const handleDeviceDataUpdate = async (deviceDataUpdate: any) => {
+  ElNotification({
+    title: `设备状态更新`,
+    customClass: 'my-warning-notification',
+    message: h('div', {
+      style: { color: '#fff' },
+      innerHTML: `
+        <div>设备名称: ${deviceDataUpdate.data.deviceType || '未知'}</div>
+        <div>设备状态: ${deviceDataUpdate.data.eventType || '未知'}</div>
+        <div>绑定长者: ${deviceDataUpdate.data.elderName || '未知'}</div>
+        <div>时间: ${new Date(deviceDataUpdate.timestamp).toLocaleString()}</div>
+      `
+    }),
+    type: 'warning',
+    duration: 10000
+  })
+  realtimeDrawerRef.value?.pushDeviceUpdate?.({
+    time: deviceDataUpdate.timestamp,
+    elderName: deviceDataUpdate.data.elderName,
+    eventType: deviceDataUpdate.data.eventType,
+    deviceType: deviceDataUpdate.data.deviceType
+  })
+}
+
 // WebSocket 连接
 const wsUrl = import.meta.env.VITE_API_WSS_URL
 const { connect, disconnect, sendMessage } = useWebSocket({
@@ -889,7 +913,8 @@ const { connect, disconnect, sendMessage } = useWebSocket({
   onSOSAlert: handleSOSAlert,
   onHealthAlert: handleHealthAlert,
   onLocationAlert: handleLocationAlert,
-  onHealthUpdateAlert: handleHealthUpdateAlert
+  onHealthUpdateAlert: handleHealthUpdateAlert,
+  onDeviceDataUpdate: handleDeviceDataUpdate
 })
 
 // 生命周期
@@ -1184,7 +1209,7 @@ $bg-accent-2: rgb(123 97 255 / 25%);
   background: rgb(26 31 46 / 90%);
   box-shadow: 0 6px 18px rgb(0 0 0 / 35%);
   backdrop-filter: blur(6px);
-  z-index: 2000;
+  z-index: 999;
   transition: all 0.2s ease;
 }