messageList.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. <template>
  2. <view class="chat-box" :style="{ height: pageHeight + 'px' }">
  3. <scroll-view
  4. :style="{ height: pageHeight + 'px' }"
  5. class="scroll"
  6. :scroll-y="true"
  7. :scroll-top="currentTop"
  8. @scroll="handleScroll"
  9. @scrolltolower="handleScrolltolower"
  10. >
  11. <view ref="messageViewRef" class="messageView" v-if="refreshContent" style="width: 100%; padding-bottom: 10rpx">
  12. <!-- 消息渲染 -->
  13. <view class="message-item ss-flex-col scroll-item" v-for="(item, index) in getMessageList0" :key="item.id">
  14. <view class="ss-flex ss-row-center ss-col-center">
  15. <!-- 日期 -->
  16. <view v-if="item.contentType !== KeFuMessageContentTypeEnum.SYSTEM && showTime(item, index)"
  17. class="date-message">
  18. {{ formatDate(item.createTime) }}
  19. </view>
  20. <!-- 系统消息 -->
  21. <view v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM" class="system-message">
  22. {{ item.content }}
  23. </view>
  24. </view>
  25. <!-- 消息体渲染管理员消息和用户消息并左右展示 -->
  26. <view
  27. v-if="item.contentType !== KeFuMessageContentTypeEnum.SYSTEM"
  28. class="ss-flex ss-col-top"
  29. :class="[
  30. item.senderType === UserTypeEnum.ADMIN
  31. ? `ss-row-left`
  32. : item.senderType === UserTypeEnum.MEMBER
  33. ? `ss-row-right`
  34. : '',
  35. ]"
  36. >
  37. <!-- 客服头像 -->
  38. <image
  39. v-show="item.senderType === UserTypeEnum.ADMIN"
  40. class="chat-avatar ss-m-r-24"
  41. :src="
  42. sheep.$url.cdn(item.senderAvatar) ||
  43. sheep.$url.static('/static/img/shop/chat/default.png')
  44. "
  45. mode="aspectFill"
  46. ></image>
  47. <!-- 内容 -->
  48. <template v-if="item.contentType === KeFuMessageContentTypeEnum.TEXT">
  49. <view class="message-box" :class="{'admin': item.senderType === UserTypeEnum.ADMIN}">
  50. <mp-html :content="replaceEmoji(item.content)" />
  51. </view>
  52. </template>
  53. <template v-if="item.contentType === KeFuMessageContentTypeEnum.IMAGE">
  54. <view class="message-box" :class="{'admin': item.senderType === UserTypeEnum.ADMIN}"
  55. :style="{ width: '200rpx' }">
  56. <su-image
  57. class="message-img"
  58. isPreview
  59. :previewList="[sheep.$url.cdn(item.content)]"
  60. :current="0"
  61. :src="sheep.$url.cdn(item.content)"
  62. :height="200"
  63. :width="200"
  64. mode="aspectFill"
  65. ></su-image>
  66. </view>
  67. </template>
  68. <template v-if="item.contentType === KeFuMessageContentTypeEnum.PRODUCT">
  69. <GoodsItem
  70. :goodsData="getMessageContent(item)"
  71. @tap="
  72. sheep.$router.go('/pages/goods/index', {
  73. id: getMessageContent(item).id,
  74. })
  75. "
  76. />
  77. </template>
  78. <template v-if="item.contentType === KeFuMessageContentTypeEnum.ORDER">
  79. <OrderItem
  80. :orderData="getMessageContent(item)"
  81. @tap="
  82. sheep.$router.go('/pages/order/detail', {
  83. id: getMessageContent(item).id,
  84. })
  85. "
  86. />
  87. </template>
  88. <!-- user头像 -->
  89. <image
  90. v-if="item.senderType === UserTypeEnum.MEMBER"
  91. class="chat-avatar ss-m-l-24"
  92. :src="sheep.$url.cdn(item.senderAvatar) ||
  93. sheep.$url.static('/static/img/shop/chat/default.png')"
  94. mode="aspectFill"
  95. >
  96. </image>
  97. </view>
  98. </view>
  99. </view>
  100. </scroll-view>
  101. <!-- 查看最新消息 -->
  102. <view
  103. v-if="showNewMessageTip"
  104. class="newMessageTip"
  105. @click="handleToNewMessage"
  106. >
  107. <text>有新消息</text>
  108. </view>
  109. </view>
  110. </template>
  111. <script setup>
  112. import { computed, getCurrentInstance, nextTick, reactive, ref, unref } from 'vue';
  113. import dayjs from 'dayjs';
  114. import _ from 'lodash'
  115. import { KeFuMessageContentTypeEnum, UserTypeEnum } from '@/pages/chat/util/constants';
  116. import { emojiList } from '@/pages/chat/util/emoji';
  117. import { isEmpty } from '@/sheep/helper/utils';
  118. import sheep from '@/sheep';
  119. import KeFuApi from '@/sheep/api/promotion/kefu';
  120. import { formatDate } from '@/sheep/util';
  121. import GoodsItem from '@/pages/chat/components/goods.vue';
  122. import OrderItem from '@/pages/chat/components/order.vue';
  123. const { safeArea } = sheep.$platform.device;
  124. const pageHeight = safeArea.height - 44 - 35 - 50;
  125. const vm = getCurrentInstance();
  126. const getMessageContent = computed(() => (item) => JSON.parse(item.content)); // 解析消息内容
  127. const messageList = ref([]); // 消息列表
  128. const currentTop = ref(0); // 当前距顶位置
  129. const showNewMessageTip = ref(false); // 显示有新消息提示
  130. const queryParams = reactive({
  131. pageNo: 1,
  132. pageSize: 10,
  133. });
  134. const total = ref(0); // 消息总条数
  135. const refreshContent = ref(false); // 内容刷新,主要解决会话消息页面高度不一致导致的滚动功能精度失效
  136. const skipGetMessageList = computed(() => {
  137. // 已加载到最后一页的话则不触发新的消息获取
  138. return total.value > 0 && Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo;
  139. }); // 跳过消息获取
  140. /** 按照时间倒序,获取消息列表 */
  141. const getMessageList0 = computed(() => {
  142. messageList.value.sort((a, b) => a.createTime - b.createTime);
  143. return messageList.value;
  144. });
  145. // 获得消息分页列表
  146. const getMessageList = async () => {
  147. const { data } = await KeFuApi.getKefuMessagePage({
  148. pageNo: queryParams.pageNo,
  149. });
  150. if (isEmpty(data.list)) {
  151. return;
  152. }
  153. total.value = data.total;
  154. // 情况一:加载最新消息
  155. if (queryParams.pageNo === 1 && !loadHistory.value) {
  156. messageList.value = data.list;
  157. } else {
  158. // 情况二:加载历史消息
  159. for (const item of data.list) {
  160. if (messageList.value.some((val) => val.id === item.id)) {
  161. continue;
  162. }
  163. messageList.value.push(item);
  164. }
  165. }
  166. refreshContent.value = true;
  167. await scrollToBottom();
  168. };
  169. /** 刷新消息列表 */
  170. const refreshMessageList = async () => {
  171. queryParams.pageNo = 1;
  172. await getMessageList();
  173. if (loadHistory.value) {
  174. // 右下角显示有新消息提示
  175. showNewMessageTip.value = true;
  176. }
  177. };
  178. defineExpose({ getMessageList, refreshMessageList });
  179. /** 滚动到底部 */
  180. const messageViewRef = ref();
  181. const scrollToBottom = async () => {
  182. // 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
  183. if (loadHistory.value) {
  184. return;
  185. }
  186. // 2. 滚动到最新消息,关闭新消息提示
  187. await nextTick();
  188. // #ifdef MP
  189. currentTop.value = await getMessageViewHeight();
  190. // #endif
  191. // #ifdef H5
  192. currentTop.value = messageViewRef.value.$el.clientHeight;
  193. // #endif
  194. showNewMessageTip.value = false;
  195. };
  196. /** 查看新消息 */
  197. const handleToNewMessage = async () => {
  198. loadHistory.value = false;
  199. await scrollToBottom();
  200. };
  201. /** 加载历史消息 */
  202. const loadHistory = ref(false); // 加载历史消息
  203. const handleScroll = async (event) => {
  204. if (skipGetMessageList.value) {
  205. return;
  206. }
  207. // 触顶自动加载下一页数据
  208. console.log(event.detail.scrollTop);
  209. if (event.detail.scrollTop === 0) {
  210. await handleOldMessage();
  211. // 防抖
  212. // _.debounce(handleOldMessage, 200)
  213. }
  214. };
  215. const handleOldMessage = async () => {
  216. // 记录已有页面高度
  217. let oldPageHeight = 0;
  218. // #ifdef MP
  219. oldPageHeight = await getMessageViewHeight();
  220. // #endif
  221. // #ifdef H5
  222. oldPageHeight = messageViewRef.value.$el.clientHeight;
  223. // #endif
  224. if (!oldPageHeight) {
  225. return;
  226. }
  227. loadHistory.value = true;
  228. // 加载消息列表
  229. queryParams.pageNo += 1;
  230. await getMessageList();
  231. // 等页面加载完后,获得上一页最后一条消息的位置,控制滚动到它所在位置
  232. // #ifdef MP
  233. // TODO puhui999: 微信滚动条定位还是有点问题,页面会闪烁
  234. currentTop.value = (await getMessageViewHeight()) - oldPageHeight - 127;
  235. // #endif
  236. // #ifdef H5
  237. currentTop.value = messageViewRef.value.$el.clientHeight - oldPageHeight;
  238. // #endif
  239. };
  240. // 触底事件
  241. const handleScrolltolower = () => {
  242. // refreshContent.value = false;
  243. // loadHistory.value = false;
  244. // // messageList.value = messageList.value.slice(0, 10)
  245. };
  246. /**
  247. * 获得消息列表高度
  248. */
  249. const getMessageViewHeight = () => {
  250. return new Promise((resolve, reject) => {
  251. uni.createSelectorQuery().in(vm).select('.messageView').boundingClientRect((rect) => {
  252. console.log(rect);
  253. resolve(rect.height);
  254. }).exec();
  255. });
  256. };
  257. //======================= 工具 =======================
  258. const showTime = computed(() => (item, index) => {
  259. if (unref(messageList.value)[index + 1]) {
  260. let dateString = dayjs(unref(messageList.value)[index + 1].createTime).fromNow();
  261. return dateString !== dayjs(unref(item).createTime).fromNow();
  262. }
  263. return false;
  264. });
  265. // 处理表情
  266. function replaceEmoji(data) {
  267. let newData = data;
  268. if (typeof newData !== 'object') {
  269. let reg = /\[(.+?)]/g; // [] 中括号
  270. let zhEmojiName = newData.match(reg);
  271. if (zhEmojiName) {
  272. zhEmojiName.forEach((item) => {
  273. let emojiFile = selEmojiFile(item);
  274. newData = newData.replace(
  275. item,
  276. `<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${sheep.$url.cdn(
  277. '/static/img/chat/emoji/' + emojiFile,
  278. )}"/>`,
  279. );
  280. });
  281. }
  282. }
  283. return newData;
  284. }
  285. function selEmojiFile(name) {
  286. for (let index in emojiList) {
  287. if (emojiList[index].name === name) {
  288. return emojiList[index].file;
  289. }
  290. }
  291. return false;
  292. }
  293. </script>
  294. <style scoped lang="scss">
  295. .newMessageTip {
  296. position: absolute;
  297. bottom: 35rpx;
  298. right: 35rpx;
  299. background-color: #fff;
  300. padding: 10rpx;
  301. border-radius: 30rpx;
  302. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
  303. }
  304. .chat-box {
  305. padding: 0 20rpx 0;
  306. position: relative;
  307. .message-item {
  308. margin-bottom: 33rpx;
  309. }
  310. .date-message,
  311. .system-message {
  312. width: fit-content;
  313. border-radius: 12rpx;
  314. padding: 8rpx 16rpx;
  315. margin-bottom: 16rpx;
  316. background-color: var(--ui-BG-3);
  317. color: #999;
  318. font-size: 24rpx;
  319. }
  320. .chat-avatar {
  321. width: 70rpx;
  322. height: 70rpx;
  323. border-radius: 50%;
  324. }
  325. .send-status {
  326. color: #333;
  327. height: 80rpx;
  328. margin-right: 8rpx;
  329. display: flex;
  330. align-items: center;
  331. .loading {
  332. width: 32rpx;
  333. height: 32rpx;
  334. -webkit-animation: rotating 2s linear infinite;
  335. animation: rotating 2s linear infinite;
  336. @-webkit-keyframes rotating {
  337. 0% {
  338. transform: rotateZ(0);
  339. }
  340. 100% {
  341. transform: rotateZ(360deg);
  342. }
  343. }
  344. @keyframes rotating {
  345. 0% {
  346. transform: rotateZ(0);
  347. }
  348. 100% {
  349. transform: rotateZ(360deg);
  350. }
  351. }
  352. }
  353. .warning {
  354. width: 32rpx;
  355. height: 32rpx;
  356. color: #ff3000;
  357. }
  358. }
  359. .message-box {
  360. max-width: 50%;
  361. font-size: 16px;
  362. line-height: 20px;
  363. white-space: normal;
  364. word-break: break-all;
  365. word-wrap: break-word;
  366. padding: 20rpx;
  367. border-radius: 10rpx;
  368. color: #fff;
  369. background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
  370. &.admin {
  371. background: #fff;
  372. color: #333;
  373. }
  374. :deep() {
  375. .imgred {
  376. width: 100%;
  377. }
  378. .imgred,
  379. img {
  380. width: 100%;
  381. }
  382. }
  383. }
  384. :deep() {
  385. .goods,
  386. .order {
  387. max-width: 500rpx;
  388. }
  389. }
  390. .message-img {
  391. width: 100px;
  392. height: 100px;
  393. border-radius: 6rpx;
  394. }
  395. .template-wrap {
  396. // width: 100%;
  397. padding: 20rpx 24rpx;
  398. background: #fff;
  399. border-radius: 10rpx;
  400. .title {
  401. font-size: 26rpx;
  402. font-weight: 500;
  403. color: #333;
  404. margin-bottom: 29rpx;
  405. }
  406. .item {
  407. font-size: 24rpx;
  408. color: var(--ui-BG-Main);
  409. margin-bottom: 16rpx;
  410. &:last-of-type {
  411. margin-bottom: 0;
  412. }
  413. }
  414. }
  415. .error-img {
  416. width: 400rpx;
  417. height: 400rpx;
  418. }
  419. #scrollBottom {
  420. height: 120rpx;
  421. }
  422. }
  423. </style>