messageList.vue 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. <template>
  2. <!-- 聊天列表使用scroll-view原生组件,整体倒置 -->
  3. <scroll-view
  4. :scroll-top="scroll.top"
  5. class="chat-scroll-view"
  6. scroll-y
  7. :refresher-enabled="false"
  8. @scroll="onScroll"
  9. @scrolltolower="loadMoreHistory"
  10. style="transform: scaleY(-1)"
  11. >
  12. <!-- 消息列表容器 -->
  13. <view class="message-container">
  14. <!-- 加载更多提示 -->
  15. <view v-if="isLoading" class="loading-more" style="transform: scaleY(-1)">
  16. <text>加载中...</text>
  17. </view>
  18. <!-- 消息列表 -->
  19. <view class="message-list">
  20. <view
  21. v-for="(item, index) in messageList"
  22. :key="item.id"
  23. class="message-item"
  24. style="transform: scaleY(-1)"
  25. >
  26. <!-- 消息渲染 -->
  27. <MessageListItem
  28. :message="item"
  29. :message-index="index"
  30. :message-list="messageList"
  31. ></MessageListItem>
  32. </view>
  33. </view>
  34. </view>
  35. </scroll-view>
  36. <!-- 底部聊天输入框 -->
  37. <su-fixed bottom>
  38. <view v-if="showTip" class="back-top ss-flex ss-row-center ss-m-b-10" @tap="scrollToTop">
  39. <text class="back-top-item ss-flex ss-row-center">
  40. {{ showNewMessageTip ? '有新消息' : '回到底部' }}
  41. </text>
  42. </view>
  43. <slot name="bottom"></slot>
  44. </su-fixed>
  45. </template>
  46. <script setup>
  47. import MessageListItem from '@/pages/chat/components/messageListItem.vue';
  48. import { onMounted, reactive, ref, computed } from 'vue';
  49. import KeFuApi from '@/sheep/api/promotion/kefu';
  50. import { isEmpty } from '@/sheep/helper/utils';
  51. import { formatDate } from '@/sheep/helper/utils';
  52. import sheep from '@/sheep';
  53. const { safeAreaInsets } = sheep.$platform.device;
  54. const safeAreaInsetsBottom = safeAreaInsets.bottom + 'px'; // 底部安全区域
  55. const messageList = ref([]); // 消息列表
  56. const showTip = ref(false); // 显示提示
  57. const showNewMessageTip = ref(false); // 显示有新消息提示
  58. const refreshMessage = ref(false); // 更新消息列表
  59. const isLoading = ref(false); // 是否正在加载更多
  60. const hasMore = ref(true); // 是否还有更多数据
  61. const keyboardHeight = ref(0); // 键盘高度
  62. const scroll = ref({
  63. top: 0,
  64. oldTop: 0,
  65. }); // 滚动位置记录
  66. const queryParams = reactive({
  67. no: 1,
  68. limit: 20,
  69. createTime: undefined,
  70. }); // 查询参数
  71. // 计算聊天窗口高度
  72. const chatScrollHeight = computed(() => {
  73. const baseHeight = 'calc(100vh - 150px - ' + safeAreaInsetsBottom + ')';
  74. if (keyboardHeight.value > 0) {
  75. // 键盘弹起状态,减去键盘高度
  76. return `calc(${baseHeight} - ${keyboardHeight.value}px)`;
  77. }
  78. return baseHeight;
  79. });
  80. // 获得消息分页列表
  81. const getMessageList = async () => {
  82. isLoading.value = true;
  83. try {
  84. const { data } = await KeFuApi.getKefuMessageList(queryParams);
  85. if (isEmpty(data)) {
  86. hasMore.value = false;
  87. return;
  88. }
  89. if (queryParams.no > 1 && refreshMessage.value) {
  90. const newMessageList = [];
  91. for (const message of data) {
  92. if (messageList.value.some((val) => val.id === message.id)) {
  93. continue;
  94. }
  95. newMessageList.push(message);
  96. }
  97. // 新消息追加到开头
  98. messageList.value = [...newMessageList, ...messageList.value];
  99. refreshMessage.value = false; // 更新好后重置状态
  100. return;
  101. }
  102. if (queryParams.no > 1) {
  103. // 加载更多历史消息,追加到现有列表末尾(因为是倒置的,所以旧消息在底部/列表末尾)
  104. if (data.length < queryParams.limit) {
  105. hasMore.value = false; // 如果返回的数据少于请求的数量,说明没有更多数据了
  106. }
  107. // 过滤掉已存在的消息
  108. const historyMessages = data.filter(
  109. (msg) => !messageList.value.some((existing) => existing.id === msg.id),
  110. );
  111. if (historyMessages.length > 0) {
  112. messageList.value = [...messageList.value, ...historyMessages];
  113. }
  114. } else {
  115. // 首次加载
  116. messageList.value = data;
  117. if (data.length < queryParams.limit) {
  118. hasMore.value = false;
  119. }
  120. }
  121. if (data.slice(-1).length > 0) {
  122. // 设置最后一次历史查询的最后一条消息的 createTime
  123. queryParams.createTime = formatDate(data.slice(-1)[0].createTime);
  124. }
  125. } finally {
  126. isLoading.value = false;
  127. }
  128. };
  129. /** 加载更多历史数据 */
  130. const loadMoreHistory = async () => {
  131. if (isLoading.value || !hasMore.value) return;
  132. // 增加页码
  133. queryParams.no += 1;
  134. await getMessageList();
  135. };
  136. /** 刷新消息列表 */
  137. const refreshMessageList = async (message = undefined) => {
  138. if (typeof message !== 'undefined') {
  139. // 追加数据到列表开头(因为是倒置的,所以新消息在顶部/列表开头)
  140. messageList.value.unshift(message);
  141. showNewMessageTip.value = true;
  142. } else {
  143. queryParams.createTime = undefined;
  144. refreshMessage.value = true;
  145. await getMessageList();
  146. }
  147. // 若已是第一页则不做处理
  148. if (queryParams.no > 1) {
  149. showTip.value = true;
  150. } else {
  151. scrollToTop();
  152. }
  153. };
  154. /** 滚动到顶部(倒置后相当于滚动到最新消息) */
  155. const scrollToTop = () => {
  156. scroll.value.top = scroll.value.oldTop;
  157. setTimeout(() => {
  158. scroll.value.top = 0;
  159. }, 200); // 等待 view 层同步
  160. showTip.value = false;
  161. };
  162. /** 设置键盘高度 */
  163. const setKeyboardHeight = (height) => {
  164. keyboardHeight.value = height;
  165. // 键盘弹起时,滚动到最新消息
  166. if (height > 0) {
  167. scrollToTop();
  168. }
  169. };
  170. defineExpose({ getMessageList, refreshMessageList });
  171. /** 监听消息列表滚动 */
  172. const onScroll = (e) => {
  173. const { scrollTop } = e.detail;
  174. scroll.value.oldTop = scrollTop;
  175. // 当滚动位置超过一定值时,显示"新消息"提示
  176. if (scrollTop > 100) {
  177. showTip.value = true;
  178. } else {
  179. showTip.value = false;
  180. }
  181. };
  182. // 监听键盘弹起和收起事件
  183. const setupKeyboardListeners = () => {
  184. // #ifdef H5
  185. // H5环境
  186. window.addEventListener('resize', () => {
  187. // 窗口大小变化可能是由键盘引起的
  188. if (
  189. document.activeElement &&
  190. (document.activeElement.tagName === 'INPUT' ||
  191. document.activeElement.tagName === 'TEXTAREA')
  192. ) {
  193. // 估算键盘高度,实际上是窗口高度变化
  194. const currentHeight = window.innerHeight;
  195. const viewportHeight = window.visualViewport
  196. ? window.visualViewport.height
  197. : window.innerHeight;
  198. const keyboardHeight = currentHeight - viewportHeight;
  199. setKeyboardHeight(keyboardHeight > 0 ? keyboardHeight : 0);
  200. } else {
  201. setKeyboardHeight(0);
  202. }
  203. });
  204. // #endif
  205. // #ifdef MP-WEIXIN
  206. // TODO puhui999: 小程序键盘弹起还有点问题,看看怎么适配
  207. // 微信小程序环境
  208. uni.onKeyboardHeightChange((res) => {
  209. setKeyboardHeight(res.height);
  210. });
  211. // #endif
  212. };
  213. onMounted(() => {
  214. queryParams.no = 1; // 确保首次加载是第一页
  215. scroll.value = {
  216. top: 0,
  217. oldTop: 0,
  218. };
  219. getMessageList();
  220. setupKeyboardListeners();
  221. });
  222. </script>
  223. <style lang="scss" scoped>
  224. .chat-scroll-view {
  225. height: v-bind(chatScrollHeight);
  226. width: 100%;
  227. position: relative;
  228. background-color: #f8f8f8;
  229. z-index: 1;
  230. }
  231. .message-container {
  232. width: 100%;
  233. /* 确保容器至少有一屏高度 */
  234. min-height: 100vh;
  235. display: flex;
  236. flex-direction: column;
  237. justify-content: flex-end;
  238. }
  239. .message-list {
  240. width: 100%;
  241. display: flex;
  242. flex-direction: column;
  243. padding-bottom: 20px;
  244. }
  245. .message-item {
  246. margin-bottom: 10px;
  247. }
  248. .loading-more {
  249. width: 100%;
  250. height: 40px;
  251. display: flex;
  252. justify-content: center;
  253. align-items: center;
  254. color: #999;
  255. font-size: 14px;
  256. }
  257. .back-top {
  258. .back-top-item {
  259. height: 30px;
  260. width: 100px;
  261. background-color: #fff;
  262. border-radius: 30px;
  263. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  264. }
  265. }
  266. </style>