NotificationMessageCard.vue 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. <script setup lang="ts">
  2. import EventCard from './components/EventCard.vue'
  3. import PasswordCard from './components/PasswordCard.vue'
  4. import FeatureUpdateCard from './components/FeatureUpdateCard.vue'
  5. import { useNotificationMessage } from '@/stores/modules/notificationMessage'
  6. import { cloneDeep } from 'lodash'
  7. // import { DynamicScroller, DynamicScrollerItem } from 'vue3-virtual-scroller'
  8. import 'vue3-virtual-scroller/dist/vue3-virtual-scroller.css'
  9. import { formatTimezone } from '@/utils/tools'
  10. const notificationMsgStore = useNotificationMessage()
  11. const props = withDefaults(
  12. defineProps<{
  13. data: any
  14. isObserver?: boolean // 是否开启监听
  15. isScrollPadding?: boolean // 是否有padding
  16. updateReadCardsOnChange?: boolean // 是否在数据变化时请求接口更新已读卡片
  17. topOffset?: number // 应减去高度
  18. isShowInsertionTime?: boolean // 是否显示插入时间
  19. }>(),
  20. {
  21. isObserver: true,
  22. isScrollPadding: false,
  23. updateReadCardsOnChange: true,
  24. topOffset: 220
  25. }
  26. )
  27. const pageData = ref<any[]>([])
  28. const emit = defineEmits<{
  29. seeAll: []
  30. hasCardRead: []
  31. viewMore: []
  32. jumpTracking: []
  33. loading: []
  34. }>()
  35. const handleSeeAll = () => {
  36. emit('seeAll')
  37. }
  38. // 创建一个新的 IntersectionObserver 实例
  39. const observer = new IntersectionObserver(
  40. (entries) => {
  41. entries.forEach((entry) => {
  42. const cardId = entry.target?.dataset?.cardId
  43. if (entry.isIntersecting) {
  44. // 将卡片设置为已经展示
  45. notificationMsgStore.setReadCardMap(cardId)
  46. const index = pageData.value.findIndex((item) => item.info.id === cardId)
  47. if (index > -1) {
  48. // 在1秒钟后将消息卡片设置为已读
  49. setTimeout(() => {
  50. pageData.value[index].info.isRead = true
  51. emit('hasCardRead')
  52. }, 400)
  53. }
  54. }
  55. })
  56. },
  57. {
  58. // 配置选项
  59. root: null, // 使用视窗作为根元素
  60. threshold: 0.1 // 当至少10%的元素进入视图时触发回调
  61. }
  62. )
  63. const curPageAllCards = ref<any>([])
  64. // 监听元素是否在可视区域内
  65. const watchCards = () => {
  66. curPageAllCards.value = document?.querySelectorAll('.notification-message-card')
  67. curPageAllCards.value.forEach((card: any) => {
  68. // const index = notificationMsgStore.notificationMsgList.indexOf(card.dataset.cardId)
  69. // index < 0 &&
  70. if (card.dataset.cardIsread === 'false' && card.dataset.cardId) {
  71. notificationMsgStore.concatNotificationMsgList([card.dataset.cardId])
  72. observer.observe(card)
  73. }
  74. })
  75. }
  76. const clearReadData = (data) => {
  77. const curData = data || []
  78. // 将当前页面剩余未读消息id从监听列表中移除
  79. const idsToRemove = curData.map((item: any) => item.info.id)
  80. notificationMsgStore.removeNotificationMsgList(idsToRemove)
  81. // 清除当前页面的所有卡片的监听
  82. curPageAllCards.value.forEach((card: any) => {
  83. observer.unobserve(card)
  84. })
  85. }
  86. const compareIdsInArrays = (arr1, arr2) => {
  87. // 如果两个数组长度不同,则它们不可能有相同的id集合
  88. if (arr1.length !== arr2.length) {
  89. return false
  90. }
  91. // 提取两个数组中的所有有效id并转换为Set
  92. const ids1 = new Set(arr1.filter((item) => item.info && item.info.id).map((item) => item.info.id))
  93. const ids2 = new Set(arr2.filter((item) => item.info && item.info.id).map((item) => item.info.id))
  94. // 比较两个Set的大小是否相同
  95. if (ids1.size !== ids2.size) {
  96. return false
  97. }
  98. // 检查ids1中的每个id是否都存在于ids2中
  99. for (let id of ids1) {
  100. if (!ids2.has(id)) {
  101. return false
  102. }
  103. }
  104. return true
  105. }
  106. const handleData = (data) => {
  107. data.map((item) => {
  108. item.id = String(Math.random()).slice(-10)
  109. })
  110. return data
  111. }
  112. const initData = () => {
  113. const watchOptions = { deep: true, immediate: true }
  114. const handleDataChange = (newData, oldData) => {
  115. // 如果id相等,则不需要更新页面数据
  116. if (compareIdsInArrays(oldData || [], newData || [])) return
  117. pageData.value = handleData(cloneDeep(newData))
  118. // 清除旧数据中的卡片监听
  119. clearReadData(oldData)
  120. // 请求接口将旧数据中的新已读卡片上传服务器
  121. if (props.data.updateReadCardsOnChange) {
  122. notificationMsgStore.markMessageAsRead()
  123. }
  124. // 重新监听新数据中的卡片
  125. nextTick(() => {
  126. setTimeout(() => {
  127. watchCards()
  128. }, 500)
  129. })
  130. }
  131. // 需要监听卡片已读未读状态
  132. if (props.isObserver) {
  133. watch(
  134. () => props.data,
  135. (newVal, oldVal) => {
  136. handleDataChange(newVal, oldVal)
  137. },
  138. watchOptions
  139. )
  140. } else {
  141. // 不需要监听
  142. watch(
  143. () => props.data,
  144. (newVal) => {
  145. pageData.value = handleData(cloneDeep(newVal))
  146. },
  147. watchOptions
  148. )
  149. }
  150. }
  151. initData()
  152. // 将数据中的消息置为已读
  153. const setAllMessageRead = () => {
  154. pageData.value.forEach((item) => {
  155. item.info.isRead = true
  156. })
  157. }
  158. // 定时将消息卡片未读的置为已读,五分钟一次
  159. let timer = null
  160. onMounted(() => {
  161. if (props.isObserver) {
  162. timer = setInterval(() => {
  163. notificationMsgStore.markMessageAsRead()
  164. }, 300000)
  165. }
  166. })
  167. onUnmounted(() => {
  168. if (props.isObserver) {
  169. notificationMsgStore.markMessageAsRead()
  170. clearReadData(pageData.value)
  171. clearInterval(timer)
  172. }
  173. })
  174. const handleViewMore = () => {
  175. emit('viewMore')
  176. }
  177. const parentHeight = computed(() => {
  178. return (window.innerHeight || document.documentElement.clientHeight) - props.topOffset
  179. })
  180. const scrollParentBoxStyle = computed(() => {
  181. return props.topOffset ? { height: `${parentHeight.value}px` } : {}
  182. })
  183. const scrollContainerRef = ref<HTMLElement | null>(null)
  184. const loading = ref(false)
  185. const finished = ref(false)
  186. const prevScrollTop = ref(0)
  187. const loadMore = async () => {
  188. if (loading.value || finished.value) return
  189. loading.value = true
  190. emit('loading')
  191. }
  192. const onScroll = () => {
  193. const el = scrollContainerRef.value
  194. if (!el || loading.value || finished.value) return
  195. prevScrollTop.value = el.scrollTop + 50
  196. const threshold = 50 // 提前50px触发
  197. if (el.scrollHeight - el.scrollTop - el.clientHeight <= threshold) {
  198. loadMore()
  199. }
  200. }
  201. const sentinel = ref<HTMLElement | null>(null)
  202. const adjustScrollTop = () => {
  203. const el = scrollContainerRef.value
  204. if (el) {
  205. // 如果滚动容器存在,调整其 scrollTop
  206. el.scrollTop = prevScrollTop.value
  207. }
  208. }
  209. defineExpose({
  210. loading,
  211. finished,
  212. scrollContainerRef,
  213. adjustScrollTop,
  214. setAllMessageRead
  215. })
  216. </script>
  217. <template>
  218. <div class="scroller" :style="scrollParentBoxStyle" ref="scrollContainerRef" @scroll="onScroll">
  219. <template v-for="item in pageData" :key="item.id">
  220. <div
  221. :class="{ 'scroll-padding': props.isScrollPadding }"
  222. :item="item"
  223. :size-dependencies="[item.info.isRead]"
  224. >
  225. <div
  226. class="notification-message-card"
  227. :data-card-id="item.info.id"
  228. :data-card-isread="item.info.isRead"
  229. >
  230. <EventCard
  231. @seeAll="handleSeeAll"
  232. @jump-tracking="emit('jumpTracking')"
  233. v-if="item.notificationType === 'event'"
  234. :data="item.info"
  235. />
  236. <PasswordCard v-else-if="item.notificationType === 'password'" :data="item.info" />
  237. <FeatureUpdateCard
  238. @view-more="handleViewMore"
  239. v-else-if="item.notificationType === 'feature'"
  240. :data="item.info"
  241. />
  242. <slot></slot>
  243. <div class="insertion-time" v-if="isShowInsertionTime">
  244. {{ formatTimezone(item.info.first_notifiation_date) }}
  245. </div>
  246. </div>
  247. </div>
  248. </template>
  249. <div
  250. class="footer"
  251. v-if="pageData?.[0]?.notificationType !== 'feature' && pageData?.length > 0"
  252. >
  253. <el-divider v-if="loading"> loading... </el-divider>
  254. <el-divider v-if="finished && pageData.length > 0">
  255. Only display the message data within three months
  256. </el-divider>
  257. </div>
  258. </div>
  259. </template>
  260. <style lang="scss" scoped>
  261. .scroller {
  262. width: 100%;
  263. overflow-y: auto;
  264. .scroll-padding {
  265. padding: 0px 140px 0px 16px;
  266. }
  267. .footer {
  268. padding: 0 16px;
  269. text-align: center;
  270. }
  271. }
  272. .notification-message-card {
  273. position: relative;
  274. .insertion-time {
  275. position: absolute;
  276. right: 0;
  277. top: 4px;
  278. font-size: 12px;
  279. color: var(--color-neutral-2);
  280. }
  281. }
  282. </style>