|
@@ -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>
|
|
|
|
|
+
|