|
|
@@ -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;
|
|
|
}
|