Przeglądaj źródła

【功能完善】商城:客服消息改为游标查询,消息 JSON 化

Xiao_123 9 miesięcy temu
rodzic
commit
72946af120

+ 72 - 29
pages/chat/components/messageList.vue

@@ -1,20 +1,35 @@
 <template>
   <!--  聊天虚拟列表  -->
-  <z-paging ref="pagingRef" v-model="messageList" use-chat-record-mode use-virtual-list
-            cell-height-mode="dynamic" default-page-size="20" :auto-clean-list-when-reload="false"
-            safe-area-inset-bottom bottom-bg-color="#f8f8f8" :back-to-top-style="backToTopStyle"
-            :auto-show-back-to-top="showNewMessageTip" @backToTopClick="onBackToTopClick"
-            @scrolltoupper="onScrollToUpper" @query="queryList">
+  <z-paging
+    ref="pagingRef"
+    v-model="messageList"
+    use-chat-record-mode
+    use-virtual-list
+    cell-height-mode="dynamic"
+    default-page-size="20"
+    :auto-clean-list-when-reload="false"
+    safe-area-inset-bottom
+    bottom-bg-color="#f8f8f8"
+    :back-to-top-style="backToTopStyle"
+    :auto-show-back-to-top="showNewMessageTip"
+    @backToTopClick="onBackToTopClick"
+    @scrolltoupper="onScrollToUpper"
+    @query="queryList"
+  >
     <template #top>
       <!-- 撑一下顶部导航 -->
       <view :style="{ height: sys_navBar + 'px' }"></view>
     </template>
     <!-- style="transform: scaleY(-1)"必须写,否则会导致列表倒置!!! -->
     <!-- 注意不要直接在chat-item组件标签上设置style,因为在微信小程序中是无效的,请包一层view -->
-    <template #cell="{item,index}">
+    <template #cell="{ item, index }">
       <view style="transform: scaleY(-1)">
         <!--  消息渲染  -->
-        <MessageListItem :message="item" :message-index="index" :message-list="messageList"></MessageListItem>
+        <MessageListItem
+          :message="item"
+          :message-index="index"
+          :message-list="messageList"
+        ></MessageListItem>
       </view>
     </template>
     <!-- 底部聊天输入框 -->
@@ -34,49 +49,79 @@
   import KeFuApi from '@/sheep/api/promotion/kefu';
   import { isEmpty } from '@/sheep/helper/utils';
   import sheep from '@/sheep';
-  
+  import { formatDate } from '@/sheep/util';
+
   const sys_navBar = sheep.$platform.navbar;
   const messageList = ref([]); // 消息列表
   const showNewMessageTip = ref(false); // 显示有新消息提示
+  const refreshMessage = ref(false); // 更新消息列表
   const backToTopStyle = reactive({
-    'width': '100px',
+    width: '100px',
     'background-color': '#fff',
     'border-radius': '30px',
     'box-shadow': '0 2px 4px rgba(0, 0, 0, 0.1)',
-    'display': 'flex',
-    'justifyContent': 'center',
-    'alignItems': 'center',
+    display: 'flex',
+    justifyContent: 'center',
+    alignItems: 'center',
   }); // 返回顶部样式
   const queryParams = reactive({
-    pageNo: 1,
-    pageSize: 10,
+    no: 1, // 查询次数,只用于触底计算
+    limit: 20,
+    createTime: undefined,
   });
   const pagingRef = ref(null); // 虚拟列表
-  const queryList = async (pageNo, pageSize) => {
+  const queryList = async (no, limit) => {
     // 组件加载时会自动触发此方法,因此默认页面加载时会自动触发,无需手动调用
-    // 这里的pageNo和pageSize会自动计算好,直接传给服务器即可
-    queryParams.pageNo = pageNo;
-    queryParams.pageSize = pageSize;
+    queryParams.no = no;
+    queryParams.limit = limit;
     await getMessageList();
   };
   // 获得消息分页列表
   const getMessageList = async () => {
-    const { data } = await KeFuApi.getKefuMessagePage(queryParams);
-    if (isEmpty(data.list)) {
+    const { data } = await KeFuApi.getKefuMessageList(queryParams);
+    if (isEmpty(data)) {
+      pagingRef.value.completeByNoMore([], true);
+      return;
+    }
+    if (queryParams.no > 1 && refreshMessage.value) {
+      const newMessageList = [];
+      for (const message of data) {
+        if (messageList.value.some((val) => val.id === message.id)) {
+          continue;
+        }
+        newMessageList.push(message);
+      }
+      // 新消息追加到开头
+      messageList.value = [...newMessageList, ...messageList.value];
+      pagingRef.value.updateCache(); // 更新缓存
+      refreshMessage.value = false; // 更新好后重置状态
       return;
     }
-    pagingRef.value.completeByTotal(data.list, data.total);
+    if (data.slice(-1).length > 0) {
+      // 设置最后一次历史查询的最后一条消息的 createTime
+      queryParams.createTime = formatDate(data.slice(-1)[0].createTime);
+    }
+    pagingRef.value.completeByNoMore(data, false);
   };
   /** 刷新消息列表 */
-  const refreshMessageList = (message = undefined) => {
-    if (message !== undefined) {
-      showNewMessageTip.value = true;
+  const refreshMessageList = async (message = undefined) => {
+    if (typeof message !== 'undefined') {
       // 追加数据
       pagingRef.value.addChatRecordData([message], false);
-      return;
+    } else {
+      queryParams.createTime = undefined;
+      refreshMessage.value = true;
+      await getMessageList();
+    }
+
+    // 若已是第一页则不做处理
+    if (queryParams.no > 1) {
+      showNewMessageTip.value = true;
+    } else {
+      onScrollToUpper();
     }
-    pagingRef.value.reload();
   };
+
   /** 滚动到最新消息 */
   const onBackToTopClick = (event) => {
     event(false); // 禁用默认操作
@@ -85,12 +130,10 @@
   /** 监听滚动到底部事件(因为 scroll 翻转了顶就是底) */
   const onScrollToUpper = () => {
     // 若已是第一页则不做处理
-    if (queryParams.pageNo === 1) {
+    if (queryParams.no === 1) {
       return;
     }
     showNewMessageTip.value = false;
-    // 到底重置消息列表
-    // refreshMessageList();
   };
   defineExpose({ getMessageList, refreshMessageList });
 </script>

+ 35 - 48
pages/chat/components/messageListItem.vue

@@ -26,27 +26,27 @@
         v-if="message.contentType !== KeFuMessageContentTypeEnum.SYSTEM"
         class="ss-flex ss-col-top"
         :class="[
-              message.senderType === UserTypeEnum.ADMIN
-                ? `ss-row-left`
-                : message.senderType === UserTypeEnum.MEMBER
-                ? `ss-row-right`
-                : '',
-            ]"
+          message.senderType === UserTypeEnum.ADMIN
+            ? `ss-row-left`
+            : message.senderType === UserTypeEnum.MEMBER
+            ? `ss-row-right`
+            : '',
+        ]"
       >
         <!-- 客服头像 -->
         <image
           v-show="message.senderType === UserTypeEnum.ADMIN"
           class="chat-avatar ss-m-r-24"
           :src="
-                sheep.$url.cdn(message.senderAvatar) ||
-                sheep.$url.static('/static/img/shop/chat/default.png')
-              "
+            sheep.$url.cdn(message.senderAvatar) ||
+            sheep.$url.static('/static/img/shop/chat/default.png')
+          "
           mode="aspectFill"
         ></image>
         <!-- 内容 -->
         <template v-if="message.contentType === KeFuMessageContentTypeEnum.TEXT">
           <view class="message-box" :class="{ admin: message.senderType === UserTypeEnum.ADMIN }">
-            <mp-html :content="replaceEmoji(message.content)" />
+            <mp-html :content="replaceEmoji(getMessageContent(message).text || message.content)" />
           </view>
         </template>
         <template v-if="message.contentType === KeFuMessageContentTypeEnum.IMAGE">
@@ -58,9 +58,9 @@
             <su-image
               class="message-img"
               isPreview
-              :previewList="[sheep.$url.cdn(message.content)]"
+              :previewList="[sheep.$url.cdn(getMessageContent(message).picUrl || message.content)]"
               :current="0"
-              :src="sheep.$url.cdn(message.content)"
+              :src="sheep.$url.cdn(getMessageContent(message).picUrl || message.content)"
               :height="200"
               :width="200"
               mode="aspectFill"
@@ -68,10 +68,12 @@
           </view>
         </template>
         <template v-if="message.contentType === KeFuMessageContentTypeEnum.PRODUCT">
+          <div class="ss-m-b-10">
           <GoodsItem
             :goodsData="getMessageContent(message)"
             @tap="sheep.$router.go('/pages/goods/index', { id: getMessageContent(message).spuId })"
           />
+          </div>
         </template>
         <template v-if="message.contentType === KeFuMessageContentTypeEnum.ORDER">
           <OrderItem
@@ -83,8 +85,10 @@
         <image
           v-if="message.senderType === UserTypeEnum.MEMBER"
           class="chat-avatar ss-m-l-24"
-          :src="sheep.$url.cdn(message.senderAvatar) ||
-                sheep.$url.static('/static/img/shop/chat/default.png')"
+          :src="
+            sheep.$url.cdn(userInfo.avatar) ||
+            sheep.$url.static('/static/img/shop/chat/default.png')
+          "
           mode="aspectFill"
         >
         </image>
@@ -99,7 +103,7 @@
   import { KeFuMessageContentTypeEnum, UserTypeEnum } from '@/pages/chat/util/constants';
   import { emojiList } from '@/pages/chat/util/emoji';
   import sheep from '@/sheep';
-  import { formatDate } from '@/sheep/util';
+  import { formatDate, jsonParse } from '@/sheep/util';
   import GoodsItem from '@/pages/chat/components/goods.vue';
   import OrderItem from '@/pages/chat/components/order.vue';
 
@@ -107,7 +111,7 @@
     // 消息
     message: {
       type: Object,
-      default: ()=>({}),
+      default: () => ({}),
     },
     // 消息索引
     messageIndex: {
@@ -115,12 +119,14 @@
       default: 0,
     },
     // 消息列表
-    messageList:{
+    messageList: {
       type: Array,
       default: () => [],
-    }
+    },
   });
-  const getMessageContent = computed(() => (item) => JSON.parse(item.content)); // 解析消息内容
+
+  const getMessageContent = computed(() => (item) => jsonParse(item.content)); // 解析消息内容
+  const userInfo = computed(() => sheep.$store('user').userInfo);
 
   //======================= 工具 =======================
 
@@ -143,7 +149,7 @@
           let emojiFile = selEmojiFile(item);
           newData = newData.replace(
             item,
-            `<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${sheep.$url.cdn(
+            `<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;vertical-align: middle;" src="${sheep.$url.cdn(
               '/static/img/chat/emoji/' + emojiFile,
             )}"/>`,
           );
@@ -165,7 +171,7 @@
 
 <style scoped lang="scss">
   .message-item {
-    margin-bottom: 33rpx;
+    margin-bottom: 10rpx;
   }
 
   .date-message,
@@ -229,18 +235,23 @@
   .message-box {
     max-width: 50%;
     font-size: 16px;
-    line-height: 20px;
     white-space: normal;
     word-break: break-all;
     word-wrap: break-word;
     padding: 20rpx;
-    border-radius: 10rpx;
     color: #fff;
     background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
-
+    margin-top: 18px;
+    margin-bottom: 9px;
+    border-top-left-radius: 10px;
+    border-bottom-right-radius: 10px;
+    border-bottom-left-radius: 10px;
     &.admin {
       background: #fff;
       color: #333;
+      margin-top: 18px;
+      margin-bottom: 9px;
+      border-radius: 0 10px 10px 10px;
     }
 
     :deep() {
@@ -268,30 +279,6 @@
     border-radius: 6rpx;
   }
 
-  .template-wrap {
-    // width: 100%;
-    padding: 20rpx 24rpx;
-    background: #fff;
-    border-radius: 10rpx;
-
-    .title {
-      font-size: 26rpx;
-      font-weight: 500;
-      color: #333;
-      margin-bottom: 29rpx;
-    }
-
-    .item {
-      font-size: 24rpx;
-      color: var(--ui-BG-Main);
-      margin-bottom: 16rpx;
-
-      &:last-of-type {
-        margin-bottom: 0;
-      }
-    }
-  }
-
   .error-img {
     width: 400rpx;
     height: 400rpx;

+ 33 - 13
pages/chat/index.vue

@@ -1,17 +1,35 @@
 <template>
-  <s-layout class="chat-wrap" :title="!isReconnecting ? '连接客服成功' : '会话重连中'" navbar="inner">
+  <s-layout
+    class="chat-wrap"
+    :title="!isReconnecting ? '连接客服成功' : '会话重连中'"
+    navbar="inner"
+  >
     <!--  覆盖头部导航栏背景颜色  -->
     <div class="page-bg" :style="{ height: sys_navBar + 'px' }"></div>
     <!--  聊天区域  -->
     <MessageList ref="messageListRef">
       <template #bottom>
-        <message-input v-model="chat.msg" @on-tools="onTools" @send-message="onSendMessage"></message-input>
+        <message-input
+          v-model="chat.msg"
+          @on-tools="onTools"
+          @send-message="onSendMessage"
+        ></message-input>
       </template>
     </MessageList>
     <!--  聊天工具  -->
-    <tools-popup :show-tools="chat.showTools" :tools-mode="chat.toolsMode" @close="handleToolsClose"
-                 @on-emoji="onEmoji" @image-select="onSelect" @on-show-select="onShowSelect">
-      <message-input v-model="chat.msg" @on-tools="onTools" @send-message="onSendMessage"></message-input>
+    <tools-popup
+      :show-tools="chat.showTools"
+      :tools-mode="chat.toolsMode"
+      @close="handleToolsClose"
+      @on-emoji="onEmoji"
+      @image-select="onSelect"
+      @on-show-select="onShowSelect"
+    >
+      <message-input
+        v-model="chat.msg"
+        @on-tools="onTools"
+        @send-message="onSendMessage"
+      ></message-input>
     </tools-popup>
     <!--  商品订单选择  -->
     <SelectPopup
@@ -30,10 +48,14 @@
   import ToolsPopup from '@/pages/chat/components/toolsPopup.vue';
   import MessageInput from '@/pages/chat/components/messageInput.vue';
   import SelectPopup from '@/pages/chat/components/select-popup.vue';
-  import { KeFuMessageContentTypeEnum, WebSocketMessageTypeConstants } from '@/pages/chat/util/constants';
+  import {
+    KeFuMessageContentTypeEnum,
+    WebSocketMessageTypeConstants,
+  } from '@/pages/chat/util/constants';
   import FileApi from '@/sheep/api/infra/file';
   import KeFuApi from '@/sheep/api/promotion/kefu';
   import { useWebSocket } from '@/sheep/hooks/useWebSocket';
+  import { jsonParse } from '@/sheep/util';
 
   const sys_navBar = sheep.$platform.navbar;
 
@@ -52,7 +74,7 @@
     try {
       const data = {
         contentType: KeFuMessageContentTypeEnum.TEXT,
-        content: chat.msg,
+        content: JSON.stringify({ text: chat.msg }),
       };
       await KeFuApi.sendKefuMessage(data);
       await messageListRef.value.refreshMessageList();
@@ -104,7 +126,7 @@
         const res = await FileApi.uploadFile(data.tempFiles[0].path);
         msg = {
           contentType: KeFuMessageContentTypeEnum.IMAGE,
-          content: res.data,
+          content: JSON.stringify({ picUrl: res.data }),
         };
         break;
       case 'goods':
@@ -134,19 +156,18 @@
   //======================= 聊天工具相关 end =======================
   const { options } = useWebSocket({
     // 连接成功
-    onConnected: async () => {
-    },
+    onConnected: async () => {},
     // 收到消息
     onMessage: async (data) => {
       const type = data.type;
       if (!type) {
-        console.error('未知的消息类型:' + data.value);
+        console.error('未知的消息类型:' + data);
         return;
       }
       // 2.2 消息类型:KEFU_MESSAGE_TYPE
       if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
         // 刷新消息列表
-        await messageListRef.value.refreshMessageList(JSON.parse(data.content));
+        await messageListRef.value.refreshMessageList(jsonParse(data.content));
         return;
       }
       // 2.3 消息类型:KEFU_MESSAGE_ADMIN_READ
@@ -160,7 +181,6 @@
 
 <style scoped lang="scss">
   .chat-wrap {
-
     .page-bg {
       width: 100%;
       position: absolute;

+ 2 - 2
sheep/api/promotion/kefu.js

@@ -15,9 +15,9 @@ const KeFuApi = {
       },
     });
   },
-  getKefuMessagePage: (params) => {
+  getKefuMessageList: (params) => {
     return request({
-      url: '/promotion/kefu-message/page',
+      url: '/promotion/kefu-message/list',
       method: 'GET',
       params,
       custom: {

+ 57 - 37
sheep/util/index.js

@@ -1,4 +1,4 @@
-import dayjs from "dayjs";
+import dayjs from 'dayjs';
 
 /**
  * 将一个整数转换为分数保留两位小数
@@ -6,10 +6,10 @@ import dayjs from "dayjs";
  * @return {number} 分数
  */
 export const formatToFraction = (num) => {
-  if (typeof num === 'undefined') return 0
-  const parsedNumber = typeof num === 'string' ? parseFloat(num) : num
-  return parseFloat((parsedNumber / 100).toFixed(2))
-}
+  if (typeof num === 'undefined') return 0;
+  const parsedNumber = typeof num === 'string' ? parseFloat(num) : num;
+  return parseFloat((parsedNumber / 100).toFixed(2));
+};
 
 /**
  * 将一个数转换为 1.00 这样
@@ -19,26 +19,26 @@ export const formatToFraction = (num) => {
  * @return {string} 分数
  */
 export const floatToFixed2 = (num) => {
-  let str = '0.00'
+  let str = '0.00';
   if (typeof num === 'undefined') {
-    return str
+    return str;
   }
-  const f = formatToFraction(num)
-  const decimalPart = f.toString().split('.')[1]
-  const len = decimalPart ? decimalPart.length : 0
+  const f = formatToFraction(num);
+  const decimalPart = f.toString().split('.')[1];
+  const len = decimalPart ? decimalPart.length : 0;
   switch (len) {
     case 0:
-      str = f.toString() + '.00'
-      break
+      str = f.toString() + '.00';
+      break;
     case 1:
-      str = f.toString() + '.0'
-      break
+      str = f.toString() + '.0';
+      break;
     case 2:
-      str = f.toString()
-      break
+      str = f.toString();
+      break;
   }
-  return str
-}
+  return str;
+};
 
 /**
  * 将一个分数转换为整数
@@ -47,11 +47,11 @@ export const floatToFixed2 = (num) => {
  * @return {number} 整数
  */
 export const convertToInteger = (num) => {
-  if (typeof num === 'undefined') return 0
-  const parsedNumber = typeof num === 'string' ? parseFloat(num) : num
+  if (typeof num === 'undefined') return 0;
+  const parsedNumber = typeof num === 'string' ? parseFloat(num) : num;
   // TODO 分转元后还有小数则四舍五入
-  return Math.round(parsedNumber * 100)
-}
+  return Math.round(parsedNumber * 100);
+};
 
 /**
  * 时间日期转换
@@ -64,16 +64,16 @@ export const convertToInteger = (num) => {
  * @description format 季度 + 星期 + 几周:"YYYY-mm-dd HH:MM:SS WWW QQQQ ZZZ"
  * @returns {string} 返回拼接后的时间字符串
  */
-export function formatDate(date, format= 'YYYY-MM-DD HH:mm:ss') {
+export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
   // 日期不存在,则返回空
   if (!date) {
-    return ''
+    return '';
   }
   // 日期存在,则进行格式化
   if (format === undefined) {
-    format = 'YYYY-MM-DD HH:mm:ss'
+    format = 'YYYY-MM-DD HH:mm:ss';
   }
-  return dayjs(date).format(format)
+  return dayjs(date).format(format);
 }
 
 /**
@@ -85,18 +85,24 @@ export function formatDate(date, format= 'YYYY-MM-DD HH:mm:ss') {
  * @param {*} children 孩子节点字段 默认 'children'
  * @param {*} rootId 根Id 默认 0
  */
-export function handleTree(data, id = 'id', parentId = 'parentId', children = 'children', rootId = 0) {
+export function handleTree(
+  data,
+  id = 'id',
+  parentId = 'parentId',
+  children = 'children',
+  rootId = 0,
+) {
   // 对源数据深度克隆
-  const cloneData = JSON.parse(JSON.stringify(data))
+  const cloneData = JSON.parse(JSON.stringify(data));
   // 循环所有项
-  const treeData = cloneData.filter(father => {
-    let branchArr = cloneData.filter(child => {
+  const treeData = cloneData.filter((father) => {
+    let branchArr = cloneData.filter((child) => {
       //返回每一项的子级数组
-      return father[id] === child[parentId]
+      return father[id] === child[parentId];
     });
-    branchArr.length > 0 ? father.children = branchArr : '';
+    branchArr.length > 0 ? (father.children = branchArr) : '';
     //返回第一层
-    return +father[parentId] === +rootId;
+    return father[parentId] === rootId;
   });
   return treeData !== '' ? treeData : data;
 }
@@ -120,14 +126,28 @@ export function resetPagination(pagination) {
  * @param source 源对象
  */
 export const copyValueToTarget = (target, source) => {
-  const newObj = Object.assign({}, target, source)
+  const newObj = Object.assign({}, target, source);
   // 删除多余属性
   Object.keys(newObj).forEach((key) => {
     // 如果不是target中的属性则删除
     if (Object.keys(target).indexOf(key) === -1) {
-      delete newObj[key]
+      delete newObj[key];
     }
-  })
+  });
   // 更新目标对象值
-  Object.assign(target, newObj)
+  Object.assign(target, newObj);
+};
+
+/**
+ * 解析 JSON 字符串
+ *
+ * @param str
+ */
+export function jsonParse(str) {
+  try {
+    return JSON.parse(str);
+  } catch (e) {
+    console.error(`str[${str}] 不是一个 JSON 字符串`);
+    return '';
+  }
 }