Explorar el Código

Merge branch 'dev' of https://gitee.com/yudaocode/yudao-ui-admin-vue3

YunaiV hace 8 meses
padre
commit
a4dd4773ca

+ 5 - 1
src/api/mall/promotion/kefu/conversation/index.ts

@@ -21,6 +21,10 @@ export const KeFuConversationApi = {
   getConversationList: async () => {
     return await request.get({ url: '/promotion/kefu-conversation/list' })
   },
+  // 获得客服会话
+  getConversation: async (id: number) => {
+    return await request.get({ url: `/promotion/kefu-conversation/get?id=` + id })
+  },
   // 客服会话置顶
   updateConversationPinned: async (data: any) => {
     return await request.put({
@@ -30,6 +34,6 @@ export const KeFuConversationApi = {
   },
   // 删除客服会话
   deleteConversation: async (id: number) => {
-    return await request.delete({ url: `/promotion/kefu-conversation/delete?id=${id}`})
+    return await request.delete({ url: `/promotion/kefu-conversation/delete?id=${id}` })
   }
 }

+ 3 - 3
src/api/mall/promotion/kefu/message/index.ts

@@ -29,8 +29,8 @@ export const KeFuMessageApi = {
       url: '/promotion/kefu-message/update-read-status?conversationId=' + conversationId
     })
   },
-  // 获得消息分页数据
-  getKeFuMessagePage: async (params: any) => {
-    return await request.get({ url: '/promotion/kefu-message/page', params })
+  // 获得消息列表(流式加载)
+  getKeFuMessageList: async (params: any) => {
+    return await request.get({ url: '/promotion/kefu-message/list', params })
   }
 }

+ 81 - 0
src/store/modules/mall/kefu.ts

@@ -0,0 +1,81 @@
+import { store } from '@/store'
+import { defineStore } from 'pinia'
+import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
+import { isEmpty } from '@/utils/is'
+
+interface MallKefuInfoVO {
+  conversationList: KeFuConversationRespVO[] // 会话列表
+  conversationMessageList: Map<number, KeFuMessageRespVO[]> // 会话消息
+}
+
+export const useMallKefuStore = defineStore('mall-kefu', {
+  state: (): MallKefuInfoVO => ({
+    conversationList: [],
+    conversationMessageList: new Map<number, KeFuMessageRespVO[]>() // key 会话,value 会话消息列表
+  }),
+  getters: {
+    getConversationList(): KeFuConversationRespVO[] {
+      return this.conversationList
+    },
+    getConversationMessageList(): (conversationId: number) => KeFuMessageRespVO[] | undefined {
+      return (conversationId: number) => this.conversationMessageList.get(conversationId)
+    }
+  },
+  actions: {
+    // ======================= 会话消息相关 =======================
+    /** 缓存历史消息 */
+    saveMessageList(conversationId: number, messageList: KeFuMessageRespVO[]) {
+      this.conversationMessageList.set(conversationId, messageList)
+    },
+
+    // ======================= 会话相关 =======================
+    /** 加载会话缓存列表 */
+    async setConversationList() {
+      this.conversationList = await KeFuConversationApi.getConversationList()
+      this.conversationSort()
+    },
+    /** 更新会话缓存已读 */
+    async updateConversationStatus(conversationId: number) {
+      if (isEmpty(this.conversationList)) {
+        return
+      }
+      const conversation = this.conversationList.find((item) => item.id === conversationId)
+      conversation && (conversation.adminUnreadMessageCount = 0)
+    },
+    /** 更新会话缓存 */
+    async updateConversation(conversationId: number) {
+      if (isEmpty(this.conversationList)) {
+        return
+      }
+
+      const conversation = await KeFuConversationApi.getConversation(conversationId)
+      this.deleteConversation(conversationId)
+      conversation && this.conversationList.push(conversation)
+      this.conversationSort()
+    },
+    /** 删除会话缓存 */
+    deleteConversation(conversationId: number) {
+      const index = this.conversationList.findIndex((item) => item.id === conversationId)
+      // 存在则删除
+      if (index > -1) {
+        this.conversationList.splice(index, 1)
+      }
+    },
+    conversationSort() {
+      // 按置顶属性和最后消息时间排序
+      this.conversationList.sort((a, b) => {
+        // 按照置顶排序,置顶的会在前面
+        if (a.adminPinned !== b.adminPinned) {
+          return a.adminPinned ? -1 : 1
+        }
+        // 按照最后消息时间排序,最近的会在前面
+        return (b.lastMessageTime as unknown as number) - (a.lastMessageTime as unknown as number)
+      })
+    }
+  }
+})
+
+export const useMallKefuStoreWithOut = () => {
+  return useMallKefuStore(store)
+}

+ 8 - 1
src/views/mall/promotion/components/SpuSelect.vue

@@ -115,7 +115,7 @@ import { getPropertyList, PropertyAndValues, SkuList } from '@/views/mall/produc
 import { ElTable } from 'element-plus'
 import { dateFormatter } from '@/utils/formatTime'
 import { createImageViewer } from '@/components/ImageViewer'
-import { formatToFraction } from '@/utils'
+import { floatToFixed2, formatToFraction } from '@/utils'
 import { defaultProps, handleTree } from '@/utils/tree'
 
 import * as ProductCategoryApi from '@/api/mall/product/category'
@@ -228,6 +228,13 @@ const expandChange = async (row: ProductSpuApi.Spu, expandedRows?: ProductSpuApi
   }
   // 获取 SPU 详情
   const res = (await ProductSpuApi.getSpu(row.id as number)) as ProductSpuApi.Spu
+  res.skus?.forEach((item) => {
+    item.price = floatToFixed2(item.price)
+    item.marketPrice = floatToFixed2(item.marketPrice)
+    item.costPrice = floatToFixed2(item.costPrice)
+    item.firstBrokeragePrice = floatToFixed2(item.firstBrokeragePrice)
+    item.secondBrokeragePrice = floatToFixed2(item.secondBrokeragePrice)
+  })
   propertyList.value = getPropertyList(res)
   spuData.value = res
   isExpand.value = true

+ 49 - 33
src/views/mall/promotion/kefu/components/KeFuConversationList.vue

@@ -1,10 +1,13 @@
 <template>
-  <div class="kefu">
+  <el-aside class="kefu pt-5px h-100%" width="260px">
+    <div class="color-[#999] font-bold my-10px">
+      会话记录({{ kefuStore.getConversationList.length }})
+    </div>
     <div
-      v-for="item in conversationList"
+      v-for="item in kefuStore.getConversationList"
       :key="item.id"
       :class="{ active: item.id === activeConversationId, pinned: item.adminPinned }"
-      class="kefu-conversation flex items-center"
+      class="kefu-conversation px-10px flex items-center"
       @click="openRightMessage(item)"
       @contextmenu.prevent="rightClick($event as PointerEvent, item)"
     >
@@ -22,8 +25,8 @@
         <div class="ml-10px w-100%">
           <div class="flex justify-between items-center w-100%">
             <span class="username">{{ item.userNickname }}</span>
-            <span class="color-[var(--left-menu-text-color)]" style="font-size: 13px">
-              {{ formatPast(item.lastMessageTime, 'YYYY-MM-DD') }}
+            <span class="color-[#999]" style="font-size: 13px">
+              {{ lastMessageTimeMap.get(item.id) ?? '计算中' }}
             </span>
           </div>
           <!-- 最后聊天内容 -->
@@ -31,7 +34,7 @@
             v-dompurify-html="
               getConversationDisplayText(item.lastMessageContentType, item.lastMessageContent)
             "
-            class="last-message flex items-center color-[var(--left-menu-text-color)]"
+            class="last-message flex items-center color-[#999]"
           >
           </div>
         </div>
@@ -65,7 +68,7 @@
         取消
       </li>
     </ul>
-  </div>
+  </el-aside>
 </template>
 
 <script lang="ts" setup>
@@ -74,29 +77,36 @@ import { useEmoji } from './tools/emoji'
 import { formatPast } from '@/utils/formatTime'
 import { KeFuMessageContentTypeEnum } from './tools/constants'
 import { useAppStore } from '@/store/modules/app'
+import { useMallKefuStore } from '@/store/modules/mall/kefu'
+import { jsonParse } from '@/utils'
 
 defineOptions({ name: 'KeFuConversationList' })
 
 const message = useMessage() // 消息弹窗
 const appStore = useAppStore()
+const kefuStore = useMallKefuStore() // 客服缓存
 const { replaceEmoji } = useEmoji()
-const conversationList = ref<KeFuConversationRespVO[]>([]) // 会话列表
 const activeConversationId = ref(-1) // 选中的会话
 const collapse = computed(() => appStore.getCollapse) // 折叠菜单
 
-/** 加载会话列表 */
-const getConversationList = async () => {
-  const list = await KeFuConversationApi.getConversationList()
-  list.sort((a: KeFuConversationRespVO, _) => (a.adminPinned ? -1 : 1))
-  conversationList.value = list
+/** 计算消息最后发送时间距离现在过去了多久 */
+const lastMessageTimeMap = ref<Map<number, string>>(new Map<number, string>())
+const calculationLastMessageTime = () => {
+  kefuStore.getConversationList?.forEach((item) => {
+    lastMessageTimeMap.value.set(item.id, formatPast(item.lastMessageTime, 'YYYY-MM-DD'))
+  })
 }
-defineExpose({ getConversationList })
+defineExpose({ calculationLastMessageTime })
 
 /** 打开右侧的消息列表 */
 const emits = defineEmits<{
   (e: 'change', v: KeFuConversationRespVO): void
 }>()
 const openRightMessage = (item: KeFuConversationRespVO) => {
+  // 同一个会话则不处理
+  if (activeConversationId.value === item.id) {
+    return
+  }
   activeConversationId.value = item.id
   emits('change', item)
 }
@@ -118,7 +128,7 @@ const getConversationDisplayText = computed(
       case KeFuMessageContentTypeEnum.VOICE:
         return '[语音消息]'
       case KeFuMessageContentTypeEnum.TEXT:
-        return replaceEmoji(lastMessageContent)
+        return replaceEmoji(jsonParse(lastMessageContent).text || lastMessageContent)
       default:
         return ''
     }
@@ -155,7 +165,7 @@ const updateConversationPinned = async (adminPinned: boolean) => {
   message.notifySuccess(adminPinned ? '置顶成功' : '取消置顶成功')
   // 2. 关闭右键菜单,更新会话列表
   closeRightMenu()
-  await getConversationList()
+  await kefuStore.updateConversation(rightClickConversation.value.id)
 }
 
 /** 删除会话 */
@@ -165,7 +175,7 @@ const deleteConversation = async () => {
   await KeFuConversationApi.deleteConversation(rightClickConversation.value.id)
   // 2. 关闭右键菜单,更新会话列表
   closeRightMenu()
-  await getConversationList()
+  kefuStore.deleteConversation(rightClickConversation.value.id)
 }
 
 /** 监听右键菜单的显示状态,添加点击事件监听器 */
@@ -176,42 +186,48 @@ watch(showRightMenu, (val) => {
     document.body.removeEventListener('click', closeRightMenu)
   }
 })
+
+const timer = ref<any>()
+/** 初始化 */
+onMounted(() => {
+  timer.value = setInterval(calculationLastMessageTime, 1000 * 10) // 十秒计算一次
+})
+/** 组件卸载前 */
+onBeforeUnmount(() => {
+  clearInterval(timer.value)
+})
 </script>
 
 <style lang="scss" scoped>
 .kefu {
+  background-color: #e5e4e4;
+
   &-conversation {
     height: 60px;
-    padding: 10px;
     //background-color: #fff;
-    transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
+    //transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
 
     .username {
       min-width: 0;
       max-width: 60%;
+    }
+
+    .last-message {
+      font-size: 13px;
+    }
+
+    .last-message,
+    .username {
       overflow: hidden;
       text-overflow: ellipsis;
       display: -webkit-box;
       -webkit-box-orient: vertical;
       -webkit-line-clamp: 1;
     }
-
-    .last-message {
-      font-size: 13px;
-      width: 200px;
-      overflow: hidden; // 隐藏超出的文本
-      white-space: nowrap; // 禁止换行
-      text-overflow: ellipsis; // 添加省略号
-    }
   }
 
   .active {
-    border-left: 5px #3271ff solid;
-    background-color: var(--login-bg-color);
-  }
-
-  .pinned {
-    background-color: var(--left-menu-bg-active-color);
+    background-color: rgba(128, 128, 128, 0.5); // 透明色,暗黑模式下也能体现
   }
 
   .right-menu-ul {

+ 168 - 110
src/views/mall/promotion/kefu/components/KeFuMessageList.vue

@@ -1,11 +1,11 @@
 <template>
   <el-container v-if="showKeFuMessageList" class="kefu">
-    <el-header>
+    <el-header class="kefu-header">
       <div class="kefu-title">{{ conversation.userNickname }}</div>
     </el-header>
     <el-main class="kefu-content overflow-visible">
-      <el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)" @scroll="handleScroll">
-        <div v-if="refreshContent" ref="innerRef" class="w-[100%] pb-3px">
+      <el-scrollbar ref="scrollbarRef" always @scroll="handleScroll">
+        <div v-if="refreshContent" ref="innerRef" class="w-[100%] px-10px">
           <!-- 消息列表 -->
           <div v-for="(item, index) in getMessageList0" :key="item.id" class="w-[100%]">
             <div class="flex justify-center items-center mb-20px">
@@ -43,15 +43,16 @@
                 class="w-60px h-60px"
               />
               <div
-                :class="{ 'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType }"
-                class="p-10px"
+                :class="{
+                  'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType
+                }"
               >
                 <!-- 文本消息 -->
                 <MessageItem :message="item">
                   <template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType">
                     <div
-                      v-dompurify-html="replaceEmoji(item.content)"
-                      class="flex items-center"
+                      v-dompurify-html="replaceEmoji(getMessageContent(item).text || item.content)"
+                      class="line-height-normal text-justify h-1/1 w-full"
                     ></div>
                   </template>
                 </MessageItem>
@@ -60,9 +61,9 @@
                   <el-image
                     v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
                     :initial-index="0"
-                    :preview-src-list="[item.content]"
-                    :src="item.content"
-                    class="w-200px"
+                    :preview-src-list="[getMessageContent(item).picUrl || item.content]"
+                    :src="getMessageContent(item).picUrl || item.content"
+                    class="w-200px mx-10px"
                     fit="contain"
                     preview-teleported
                   />
@@ -71,14 +72,13 @@
                 <MessageItem :message="item">
                   <ProductItem
                     v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType"
-                    :spuId="getMessageContent(item).spuId"
                     :picUrl="getMessageContent(item).picUrl"
                     :price="getMessageContent(item).price"
-                    :skuText="getMessageContent(item).introduction"
+                    :sales-count="getMessageContent(item).salesCount"
+                    :spuId="getMessageContent(item).spuId"
+                    :stock="getMessageContent(item).stock"
                     :title="getMessageContent(item).spuName"
-                    :titleWidth="400"
-                    class="max-w-70%"
-                    priceColor="#FF3000"
+                    class="max-w-300px mx-10px"
                   />
                 </MessageItem>
                 <!-- 订单消息 -->
@@ -86,7 +86,7 @@
                   <OrderItem
                     v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
                     :message="item"
-                    class="max-w-100%"
+                    class="max-w-100% mx-10px"
                   />
                 </MessageItem>
               </div>
@@ -108,23 +108,29 @@
         <Icon class="ml-5px" icon="ep:bottom" />
       </div>
     </el-main>
-    <el-footer height="230px">
-      <div class="h-[100%]">
-        <div class="chat-tools flex items-center">
-          <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
-          <PictureSelectUpload
-            class="ml-15px mt-3px cursor-pointer"
-            @send-picture="handleSendPicture"
-          />
-        </div>
-        <el-input v-model="message" :rows="6" style="border-style: none" type="textarea" />
-        <div class="h-45px flex justify-end">
-          <el-button class="mt-10px" type="primary" @click="handleSendMessage">发送</el-button>
-        </div>
+    <el-footer class="kefu-footer">
+      <div class="chat-tools flex items-center">
+        <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
+        <PictureSelectUpload
+          class="ml-15px mt-3px cursor-pointer"
+          @send-picture="handleSendPicture"
+        />
       </div>
+      <el-input
+        v-model="message"
+        :rows="6"
+        placeholder="输入消息,Enter发送,Shift+Enter换行"
+        style="border-style: none"
+        type="textarea"
+        @keyup.enter.prevent="handleSendMessage"
+      />
     </el-footer>
   </el-container>
-  <el-empty v-else description="请选择左侧的一个会话后开始" />
+  <el-container v-else class="kefu">
+    <el-main>
+      <el-empty description="请选择左侧的一个会话后开始" />
+    </el-main>
+  </el-container>
 </template>
 
 <script lang="ts" setup>
@@ -144,6 +150,7 @@ import dayjs from 'dayjs'
 import relativeTime from 'dayjs/plugin/relativeTime'
 import { debounce } from 'lodash-es'
 import { jsonParse } from '@/utils'
+import { useMallKefuStore } from '@/store/modules/mall/kefu'
 
 dayjs.extend(relativeTime)
 
@@ -156,25 +163,31 @@ const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表
 const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话
 const showNewMessageTip = ref(false) // 显示有新消息提示
 const queryParams = reactive({
-  pageNo: 1,
-  pageSize: 10,
-  conversationId: 0
+  conversationId: 0,
+  createTime: undefined
 })
 const total = ref(0) // 消息总条数
 const refreshContent = ref(false) // 内容刷新,主要解决会话消息页面高度不一致导致的滚动功能精度失效
+const kefuStore = useMallKefuStore() // 客服缓存
 
 /** 获悉消息内容 */
 const getMessageContent = computed(() => (item: any) => jsonParse(item.content))
 /** 获得消息列表 */
 const getMessageList = async () => {
-  const res = await KeFuMessageApi.getKeFuMessagePage(queryParams)
-  total.value = res.total
+  const res = await KeFuMessageApi.getKeFuMessageList(queryParams)
+  if (isEmpty(res)) {
+    // 当返回的是空列表说明没有消息或者已经查询完了历史消息
+    skipGetMessageList.value = true
+    return
+  }
+  queryParams.createTime = formatDate(res.at(-1).createTime) as any
+
   // 情况一:加载最新消息
-  if (queryParams.pageNo === 1) {
-    messageList.value = res.list
+  if (!queryParams.createTime) {
+    messageList.value = res
   } else {
     // 情况二:加载历史消息
-    for (const item of res.list) {
+    for (const item of res) {
       pushMessage(item)
     }
   }
@@ -208,8 +221,7 @@ const refreshMessageList = async (message?: any) => {
     }
     pushMessage(message)
   } else {
-    // TODO @puhui999:不基于 page 做。而是流式分页;通过 createTime 排序查询;
-    queryParams.pageNo = 1
+    queryParams.createTime = undefined
     await getMessageList()
   }
 
@@ -222,28 +234,27 @@ const refreshMessageList = async (message?: any) => {
   }
 }
 
-/** 获得新会话的消息列表 */
-// TODO @puhui999:可优化:可以考虑本地做每个会话的消息 list 缓存;然后点击切换时,读取缓存;然后异步获取新消息,merge 下;
+/** 获得新会话的消息列表, 点击切换时,读取缓存;然后异步获取新消息,merge 下; */
 const getNewMessageList = async (val: KeFuConversationRespVO) => {
-  // 会话切换,重置相关参数
-  queryParams.pageNo = 1
-  messageList.value = []
-  total.value = 0
+  // 1. 缓存当前会话消息列表
+  kefuStore.saveMessageList(conversation.value.id, messageList.value)
+  // 2.1 会话切换,重置相关参数
+  messageList.value = kefuStore.getConversationMessageList(val.id) || []
+  total.value = messageList.value.length || 0
   loadHistory.value = false
   refreshContent.value = false
-  // 设置会话相关属性
+  skipGetMessageList.value = false
+  // 2.2 设置会话相关属性
   conversation.value = val
   queryParams.conversationId = val.id
-  // 获取消息
+  queryParams.createTime = undefined
+  // 3. 获取消息
   await refreshMessageList()
 }
 defineExpose({ getNewMessageList, refreshMessageList })
 
 const showKeFuMessageList = computed(() => !isEmpty(conversation.value)) // 是否显示聊天区域
-const skipGetMessageList = computed(() => {
-  // 已加载到最后一页的话则不触发新的消息获取
-  return total.value > 0 && Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo
-}) // 跳过消息获取
+const skipGetMessageList = ref(false) // 跳过消息获取
 
 /** 处理表情选择 */
 const handleEmojiSelect = (item: Emoji) => {
@@ -256,13 +267,17 @@ const handleSendPicture = async (picUrl: string) => {
   const msg = {
     conversationId: conversation.value.id,
     contentType: KeFuMessageContentTypeEnum.IMAGE,
-    content: picUrl
+    content: JSON.stringify({ picUrl })
   }
   await sendMessage(msg)
 }
 
 /** 发送文本消息 */
-const handleSendMessage = async () => {
+const handleSendMessage = async (event: any) => {
+  // shift 不发送
+  if (event.shiftKey) {
+    return
+  }
   // 1. 校验消息是否为空
   if (isEmpty(unref(message.value))) {
     messageTool.notifyWarning('请输入消息后再发送哦!')
@@ -272,7 +287,7 @@ const handleSendMessage = async () => {
   const msg = {
     conversationId: conversation.value.id,
     contentType: KeFuMessageContentTypeEnum.TEXT,
-    content: message.value
+    content: JSON.stringify({ text: message.value })
   }
   await sendMessage(msg)
 }
@@ -284,6 +299,8 @@ const sendMessage = async (msg: any) => {
   message.value = ''
   // 加载消息列表
   await refreshMessageList()
+  // 更新会话缓存
+  await kefuStore.updateConversation(conversation.value.id)
 }
 
 /** 滚动到底部 */
@@ -333,8 +350,6 @@ const handleOldMessage = async () => {
     return
   }
   loadHistory.value = true
-  // 加载消息列表
-  queryParams.pageNo += 1
   await getMessageList()
   // 等页面加载完后,获得上一页最后一条消息的位置,控制滚动到它所在位置
   scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight - oldPageHeight)
@@ -357,14 +372,51 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
 
 <style lang="scss" scoped>
 .kefu {
-  &-title {
-    border-bottom: #e4e0e0 solid 1px;
-    height: 60px;
-    line-height: 60px;
+  background-color: #f5f5f5;
+  position: relative;
+  width: calc(100% - 300px - 260px);
+
+  &::after {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 1px; /* 实际宽度 */
+    height: 100%;
+    background-color: var(--el-border-color);
+    transform: scaleX(0.3); /* 缩小宽度 */
+  }
+
+  .kefu-header {
+    background-color: #f5f5f5;
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    &::before {
+      content: '';
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      width: 100%;
+      height: 1px; /* 初始宽度 */
+      background-color: var(--el-border-color);
+      transform: scaleY(0.3); /* 缩小视觉高度 */
+    }
+
+    &-title {
+      font-size: 18px;
+      font-weight: bold;
+    }
   }
 
   &-content {
+    margin: 0;
+    padding: 10px;
     position: relative;
+    height: 100%;
+    width: 100%;
 
     .newMessageTip {
       position: absolute;
@@ -381,21 +433,12 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
       justify-content: flex-start;
 
       .kefu-message {
-        margin-left: 20px;
-        position: relative;
-
-        &::before {
-          content: '';
-          width: 10px;
-          height: 10px;
-          left: -19px;
-          top: calc(50% - 10px);
-          position: absolute;
-          border-left: 5px solid transparent;
-          border-bottom: 5px solid transparent;
-          border-top: 5px solid transparent;
-          border-right: 5px solid var(--app-content-bg-color);
-        }
+        background-color: #fff;
+        margin-left: 10px;
+        margin-top: 3px;
+        border-top-right-radius: 10px;
+        border-bottom-right-radius: 10px;
+        border-bottom-left-radius: 10px;
       }
     }
 
@@ -403,37 +446,25 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
       justify-content: flex-end;
 
       .kefu-message {
-        margin-right: 20px;
-        position: relative;
-
-        &::after {
-          content: '';
-          width: 10px;
-          height: 10px;
-          right: -19px;
-          top: calc(50% - 10px);
-          position: absolute;
-          border-left: 5px solid var(--app-content-bg-color);
-          border-bottom: 5px solid transparent;
-          border-top: 5px solid transparent;
-          border-right: 5px solid transparent;
-        }
+        background-color: rgb(206, 223, 255);
+        margin-right: 10px;
+        margin-top: 3px;
+        border-top-left-radius: 10px;
+        border-bottom-right-radius: 10px;
+        border-bottom-left-radius: 10px;
       }
     }
 
     // 消息气泡
     .kefu-message {
-      color: #a9a9a9;
-      border-radius: 5px;
-      box-shadow: 3px 3px 5px rgba(220, 220, 220, 0.1);
+      color: #414141;
+      font-weight: 500;
       padding: 5px 10px;
       width: auto;
       max-width: 50%;
-      text-align: left;
-      display: inline-block !important;
-      position: relative;
-      word-break: break-all;
-      background-color: var(--app-content-bg-color);
+      //text-align: left;
+      //display: inline-block !important;
+      //word-break: break-all;
       transition: all 0.2s;
 
       &:hover {
@@ -444,24 +475,51 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
     .date-message,
     .system-message {
       width: fit-content;
-      border-radius: 12rpx;
-      padding: 8rpx 16rpx;
-      margin-bottom: 16rpx;
-      //background-color: #e8e8e8;
-      color: #999;
-      font-size: 24rpx;
+      background-color: rgba(0, 0, 0, 0.1);
+      border-radius: 8px;
+      padding: 0 5px;
+      color: #fff;
+      font-size: 10px;
     }
   }
 
-  .chat-tools {
-    width: 100%;
-    border: var(--el-border-color) solid 1px;
-    border-radius: 10px;
-    height: 44px;
+  .kefu-footer {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    height: auto;
+    margin: 0;
+    padding: 0;
+
+    &::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 1px; /* 初始宽度 */
+      background-color: var(--el-border-color);
+      transform: scaleY(0.3); /* 缩小视觉高度 */
+    }
+
+    .chat-tools {
+      width: 100%;
+      height: 44px;
+    }
   }
 
   ::v-deep(textarea) {
     resize: none;
+    background-color: #f5f5f5;
+  }
+
+  :deep(.el-input__wrapper) {
+    box-shadow: none !important;
+    border-radius: 0;
+  }
+
+  ::v-deep(.el-textarea__inner) {
+    box-shadow: none !important;
   }
 }
 </style>

+ 0 - 97
src/views/mall/promotion/kefu/components/history/MemberBrowsingHistory.vue

@@ -1,97 +0,0 @@
-<!-- 目录是不是叫 member 好点。然后这个组件是 MemberInfo,里面有浏览足迹 -->
-<template>
-  <div v-show="!isEmpty(conversation)" class="kefu">
-    <div class="header-title h-60px flex justify-center items-center">他的足迹</div>
-    <el-tabs v-model="activeName" class="demo-tabs" @tab-click="handleClick">
-      <el-tab-pane label="最近浏览" name="a" />
-      <el-tab-pane label="订单列表" name="b" />
-    </el-tabs>
-    <div>
-      <el-scrollbar ref="scrollbarRef" always height="calc(115vh - 400px)" @scroll="handleScroll">
-        <!-- 最近浏览 -->
-        <ProductBrowsingHistory v-if="activeName === 'a'" ref="productBrowsingHistoryRef" />
-        <!-- 订单列表 -->
-        <OrderBrowsingHistory v-if="activeName === 'b'" ref="orderBrowsingHistoryRef" />
-      </el-scrollbar>
-    </div>
-  </div>
-  <el-empty v-show="isEmpty(conversation)" description="请选择左侧的一个会话后开始" />
-</template>
-
-<script lang="ts" setup>
-import type { TabsPaneContext } from 'element-plus'
-import ProductBrowsingHistory from './ProductBrowsingHistory.vue'
-import OrderBrowsingHistory from './OrderBrowsingHistory.vue'
-import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
-import { isEmpty } from '@/utils/is'
-import { debounce } from 'lodash-es'
-import { ElScrollbar as ElScrollbarType } from 'element-plus/es/components/scrollbar/index'
-
-defineOptions({ name: 'MemberBrowsingHistory' })
-
-const activeName = ref('a')
-
-/** tab 切换 */
-const productBrowsingHistoryRef = ref<InstanceType<typeof ProductBrowsingHistory>>()
-const orderBrowsingHistoryRef = ref<InstanceType<typeof OrderBrowsingHistory>>()
-const handleClick = async (tab: TabsPaneContext) => {
-  activeName.value = tab.paneName as string
-  await nextTick()
-  await getHistoryList()
-}
-
-/** 获得历史数据 */
-// TODO @puhui:不要用 a、b 哈。就订单列表、浏览列表这种噶
-const getHistoryList = async () => {
-  switch (activeName.value) {
-    case 'a':
-      await productBrowsingHistoryRef.value?.getHistoryList(conversation.value)
-      break
-    case 'b':
-      await orderBrowsingHistoryRef.value?.getHistoryList(conversation.value)
-      break
-    default:
-      break
-  }
-}
-
-/** 加载下一页数据 */
-const loadMore = async () => {
-  switch (activeName.value) {
-    case 'a':
-      await productBrowsingHistoryRef.value?.loadMore()
-      break
-    case 'b':
-      await orderBrowsingHistoryRef.value?.loadMore()
-      break
-    default:
-      break
-  }
-}
-
-/** 浏览历史初始化 */
-const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话
-const initHistory = async (val: KeFuConversationRespVO) => {
-  activeName.value = 'a'
-  conversation.value = val
-  await nextTick()
-  await getHistoryList()
-}
-defineExpose({ initHistory })
-
-/** 处理消息列表滚动事件(debounce 限流) */
-const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
-const handleScroll = debounce(() => {
-  const wrap = scrollbarRef.value?.wrapRef
-  // 触底重置
-  if (Math.abs(wrap!.scrollHeight - wrap!.clientHeight - wrap!.scrollTop) < 1) {
-    loadMore()
-  }
-}, 200)
-</script>
-
-<style lang="scss" scoped>
-.header-title {
-  border-bottom: #e4e0e0 solid 1px;
-}
-</style>

+ 2 - 2
src/views/mall/promotion/kefu/components/index.ts

@@ -1,5 +1,5 @@
 import KeFuConversationList from './KeFuConversationList.vue'
 import KeFuMessageList from './KeFuMessageList.vue'
-import MemberBrowsingHistory from './history/MemberBrowsingHistory.vue'
+import MemberInfo from './member/MemberInfo.vue'
 
-export { KeFuConversationList, KeFuMessageList, MemberBrowsingHistory }
+export { KeFuConversationList, KeFuMessageList, MemberInfo }

+ 252 - 0
src/views/mall/promotion/kefu/components/member/MemberInfo.vue

@@ -0,0 +1,252 @@
+<!-- 右侧信息:会员信息 + 最近浏览 + 交易订单 -->
+<template>
+  <el-container class="kefu">
+    <el-header class="kefu-header">
+      <div
+        :class="{ 'kefu-header-item-activation': tabActivation('会员信息') }"
+        class="kefu-header-item cursor-pointer flex items-center justify-center"
+        @click="handleClick('会员信息')"
+      >
+        会员信息
+      </div>
+      <div
+        :class="{ 'kefu-header-item-activation': tabActivation('最近浏览') }"
+        class="kefu-header-item cursor-pointer flex items-center justify-center"
+        @click="handleClick('最近浏览')"
+      >
+        最近浏览
+      </div>
+      <div
+        :class="{ 'kefu-header-item-activation': tabActivation('交易订单') }"
+        class="kefu-header-item cursor-pointer flex items-center justify-center"
+        @click="handleClick('交易订单')"
+      >
+        交易订单
+      </div>
+    </el-header>
+    <el-main class="kefu-content p-10px!">
+      <div v-if="!isEmpty(conversation)" v-loading="loading">
+        <!-- 基本信息 -->
+        <UserBasicInfo v-if="activeTab === '会员信息'" :user="user" mode="kefu">
+          <template #header>
+            <CardTitle title="基本信息" />
+          </template>
+        </UserBasicInfo>
+        <!-- 账户信息 -->
+        <el-card v-if="activeTab === '会员信息'" class="h-full mt-10px" shadow="never">
+          <template #header>
+            <CardTitle title="账户信息" />
+          </template>
+          <UserAccountInfo :column="1" :user="user" :wallet="wallet" />
+        </el-card>
+      </div>
+      <div v-show="!isEmpty(conversation)">
+        <el-scrollbar ref="scrollbarRef" always @scroll="handleScroll">
+          <!-- 最近浏览 -->
+          <ProductBrowsingHistory v-if="activeTab === '最近浏览'" ref="productBrowsingHistoryRef" />
+          <!-- 交易订单 -->
+          <OrderBrowsingHistory v-if="activeTab === '交易订单'" ref="orderBrowsingHistoryRef" />
+        </el-scrollbar>
+      </div>
+      <el-empty v-show="isEmpty(conversation)" description="请选择左侧的一个会话后开始" />
+    </el-main>
+  </el-container>
+</template>
+
+<script lang="ts" setup>
+import ProductBrowsingHistory from './ProductBrowsingHistory.vue'
+import OrderBrowsingHistory from './OrderBrowsingHistory.vue'
+import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import { isEmpty } from '@/utils/is'
+import { debounce } from 'lodash-es'
+import { ElScrollbar as ElScrollbarType } from 'element-plus/es/components/scrollbar/index'
+import { CardTitle } from '@/components/Card'
+import UserBasicInfo from '@/views/member/user/detail/UserBasicInfo.vue'
+import UserAccountInfo from '@/views/member/user/detail/UserAccountInfo.vue'
+import * as UserApi from '@/api/member/user'
+import * as WalletApi from '@/api/pay/wallet/balance'
+
+defineOptions({ name: 'MemberBrowsingHistory' })
+
+const activeTab = ref('会员信息')
+const tabActivation = computed(() => (tab: string) => activeTab.value === tab)
+
+/** tab 切换 */
+const productBrowsingHistoryRef = ref<InstanceType<typeof ProductBrowsingHistory>>()
+const orderBrowsingHistoryRef = ref<InstanceType<typeof OrderBrowsingHistory>>()
+const handleClick = async (tab: string) => {
+  activeTab.value = tab
+  await nextTick()
+  await getHistoryList()
+}
+
+/** 获得历史数据 */
+const getHistoryList = async () => {
+  switch (activeTab.value) {
+    case '会员信息':
+      await getUserData()
+      await getUserWallet()
+      break
+    case '最近浏览':
+      await productBrowsingHistoryRef.value?.getHistoryList(conversation.value)
+      break
+    case '交易订单':
+      await orderBrowsingHistoryRef.value?.getHistoryList(conversation.value)
+      break
+    default:
+      break
+  }
+}
+
+/** 加载下一页数据 */
+const loadMore = async () => {
+  switch (activeTab.value) {
+    case '会员信息':
+      break
+    case '最近浏览':
+      await productBrowsingHistoryRef.value?.loadMore()
+      break
+    case '交易订单':
+      await orderBrowsingHistoryRef.value?.loadMore()
+      break
+    default:
+      break
+  }
+}
+
+/** 浏览历史初始化 */
+const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话
+const initHistory = async (val: KeFuConversationRespVO) => {
+  activeTab.value = '会员信息'
+  conversation.value = val
+  await nextTick()
+  await getHistoryList()
+}
+defineExpose({ initHistory })
+
+/** 处理消息列表滚动事件(debounce 限流) */
+const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
+const handleScroll = debounce(() => {
+  const wrap = scrollbarRef.value?.wrapRef
+  // 触底重置
+  if (Math.abs(wrap!.scrollHeight - wrap!.clientHeight - wrap!.scrollTop) < 1) {
+    loadMore()
+  }
+}, 200)
+
+/** 查询用户钱包信息 */
+const WALLET_INIT_DATA = {
+  balance: 0,
+  totalExpense: 0,
+  totalRecharge: 0
+} as WalletApi.WalletVO // 钱包初始化数据
+const wallet = ref<WalletApi.WalletVO>(WALLET_INIT_DATA) // 钱包信息
+const getUserWallet = async () => {
+  if (!conversation.value.userId) {
+    wallet.value = WALLET_INIT_DATA
+    return
+  }
+  wallet.value =
+    (await WalletApi.getWallet({ userId: conversation.value.userId })) || WALLET_INIT_DATA
+}
+
+/** 获得用户 */
+const loading = ref(true) // 加载中
+const user = ref<UserApi.UserVO>({} as UserApi.UserVO)
+const getUserData = async () => {
+  loading.value = true
+  try {
+    user.value = await UserApi.getUser(conversation.value.userId)
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.kefu {
+  position: relative;
+  width: 300px !important;
+  background-color: #f5f5f5;
+
+  &::after {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 1px; /* 实际宽度 */
+    height: 100%;
+    background-color: var(--el-border-color);
+    transform: scaleX(0.3); /* 缩小宽度 */
+  }
+
+  &-header {
+    background-color: #f5f5f5;
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-around;
+
+    &::before {
+      content: '';
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      width: 100%;
+      height: 1px; /* 初始宽度 */
+      background-color: var(--el-border-color);
+      transform: scaleY(0.3); /* 缩小视觉高度 */
+    }
+
+    &-title {
+      font-size: 18px;
+      font-weight: bold;
+    }
+
+    &-item {
+      height: 100%;
+      width: 100%;
+      position: relative;
+
+      &-activation::before {
+        content: '';
+        position: absolute; /* 绝对定位 */
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0; /* 覆盖整个元素 */
+        border-bottom: 2px solid rgba(128, 128, 128, 0.5); /* 边框样式 */
+        pointer-events: none; /* 确保点击事件不会被伪元素拦截 */
+      }
+
+      &:hover::before {
+        content: '';
+        position: absolute; /* 绝对定位 */
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0; /* 覆盖整个元素 */
+        border-bottom: 2px solid rgba(128, 128, 128, 0.5); /* 边框样式 */
+        pointer-events: none; /* 确保点击事件不会被伪元素拦截 */
+      }
+    }
+  }
+
+  &-content {
+    margin: 0;
+    padding: 0;
+    position: relative;
+    height: 100%;
+    width: 100%;
+  }
+
+  &-tabs {
+    height: 100%;
+    width: 100%;
+  }
+}
+
+.header-title {
+  border-bottom: #e4e0e0 solid 1px;
+}
+</style>

+ 0 - 0
src/views/mall/promotion/kefu/components/history/OrderBrowsingHistory.vue → src/views/mall/promotion/kefu/components/member/OrderBrowsingHistory.vue


+ 3 - 4
src/views/mall/promotion/kefu/components/history/ProductBrowsingHistory.vue → src/views/mall/promotion/kefu/components/member/ProductBrowsingHistory.vue

@@ -1,15 +1,14 @@
 <template>
   <ProductItem
     v-for="item in list"
-    :spu-id="item.spuId"
     :key="item.id"
     :picUrl="item.picUrl"
     :price="item.price"
-    :skuText="item.introduction"
+    :sales-count="item.salesCount"
+    :spu-id="item.spuId"
+    :stock="item.stock"
     :title="item.spuName"
-    :titleWidth="400"
     class="mb-10px"
-    priceColor="#FF3000"
   />
 </template>
 

+ 9 - 7
src/views/mall/promotion/kefu/components/message/OrderItem.vue

@@ -14,11 +14,11 @@
       </div>
       <div v-for="item in getMessageContent.items" :key="item.id" class="border-bottom">
         <ProductItem
-          :spu-id="item.spuId"
           :num="item.count"
           :picUrl="item.picUrl"
           :price="item.price"
           :skuText="item.properties.map((property: any) => property.valueName).join(' ')"
+          :spu-id="item.spuId"
           :title="item.spuName"
         />
       </div>
@@ -112,14 +112,14 @@ function formatOrderStatus(order: any) {
   border-radius: 10px;
   padding: 10px;
   border: 1px var(--el-border-color) solid;
-  background-color: var(--app-content-bg-color);
+  background-color: #fff; // 透明色,暗黑模式下也能体现
 
   .order-card-header {
     height: 28px;
+    font-weight: bold;
 
     .order-no {
-      font-size: 12px;
-      font-weight: 500;
+      font-size: 13px;
 
       span {
         &:hover {
@@ -128,27 +128,29 @@ function formatOrderStatus(order: any) {
         }
       }
     }
+
+    .order-state {
+      font-size: 13px;
+    }
   }
 
   .pay-box {
     padding-top: 10px;
+    font-weight: bold;
 
     .discounts-title {
       font-size: 16px;
       line-height: normal;
-      color: #999999;
     }
 
     .discounts-money {
       font-size: 16px;
       line-height: normal;
-      color: #999;
       font-family: OPPOSANS;
     }
 
     .pay-color {
       font-size: 13px;
-      color: var(--left-menu-text-color);
     }
   }
 }

+ 60 - 148
src/views/mall/promotion/kefu/components/message/ProductItem.vue

@@ -1,51 +1,27 @@
 <template>
-  <div @click.stop="openDetail(props.spuId)" style="cursor: pointer;">
-    <div>
-      <slot name="top"></slot>
+  <div class="product-warp" style="cursor: pointer" @click.stop="openDetail(spuId)">
+    <!-- 左侧商品图片-->
+    <div class="product-warp-left mr-24px">
+      <el-image
+        :initial-index="0"
+        :preview-src-list="[picUrl]"
+        :src="picUrl"
+        class="product-warp-left-img"
+        fit="contain"
+        preview-teleported
+        @click.stop
+      />
     </div>
-    <div
-      :style="[{ borderRadius: radius + 'px', marginBottom: marginBottom + 'px' }]"
-      class="ss-order-card-warp flex items-stretch justify-between bg-white"
-    >
-      <div class="img-box mr-24px">
-        <el-image
-          :initial-index="0"
-          :preview-src-list="[picUrl]"
-          :src="picUrl"
-          class="order-img"
-          fit="contain"
-          preview-teleported
-          @click.stop
-        />
+    <!-- 右侧商品信息 -->
+    <div class="product-warp-right">
+      <div class="description">{{ title }}</div>
+      <div class="my-5px">
+        <span class="mr-20px">库存: {{ stock || 0 }}</span>
+        <span>销量: {{ salesCount || 0 }}</span>
       </div>
-      <div
-        :style="[{ width: titleWidth ? titleWidth + 'px' : '' }]"
-        class="box-right flex flex-col justify-between"
-      >
-        <div v-if="title" class="title-text ss-line-2">{{ title }}</div>
-        <div v-if="skuString" class="spec-text mt-8px mb-12px">{{ skuString }}</div>
-        <div class="groupon-box">
-          <slot name="groupon"></slot>
-        </div>
-        <div class="flex">
-          <div class="flex items-center">
-            <div
-              v-if="price && Number(price) > 0"
-              :style="[{ color: priceColor }]"
-              class="price-text flex items-center"
-            >
-              ¥{{ fenToYuan(price) }}
-            </div>
-            <div v-if="num" class="total-text flex items-center">x {{ num }}</div>
-            <slot name="priceSuffix"></slot>
-          </div>
-        </div>
-        <div class="tool-box">
-          <slot name="tool"></slot>
-        </div>
-        <div>
-          <slot name="rightBottom"></slot>
-        </div>
+      <div class="flex justify-between items-center">
+        <span class="price">¥{{ fenToYuan(price) }}</span>
+        <el-button size="small" text type="primary">详情</el-button>
       </div>
     </div>
   </div>
@@ -57,7 +33,7 @@ import { fenToYuan } from '@/utils'
 const { push } = useRouter()
 
 defineOptions({ name: 'ProductItem' })
-const props = defineProps({
+defineProps({
   spuId: {
     type: Number,
     default: 0
@@ -70,134 +46,70 @@ const props = defineProps({
     type: String,
     default: ''
   },
-  titleWidth: {
-    type: Number,
-    default: 0
-  },
-  skuText: {
-    type: [String, Array],
-    default: ''
-  },
   price: {
     type: [String, Number],
     default: ''
   },
-  priceColor: {
-    type: [String],
-    default: ''
-  },
-  num: {
+  salesCount: {
     type: [String, Number],
-    default: 0
-  },
-  score: {
-    type: [String, Number],
-    default: ''
-  },
-  radius: {
-    type: [String],
     default: ''
   },
-  marginBottom: {
-    type: [String],
+  stock: {
+    type: [String, Number],
     default: ''
   }
 })
 
-/** SKU 展示字符串 */
-const skuString = computed(() => {
-  if (!props.skuText) {
-    return ''
-  }
-  if (typeof props.skuText === 'object') {
-    return props.skuText.join(',')
-  }
-  return props.skuText
-})
-
 /** 查看商品详情 */
 const openDetail = (spuId: number) => {
-  console.log(props.spuId)
   push({ name: 'ProductSpuDetail', params: { id: spuId } })
 }
 </script>
 
 <style lang="scss" scoped>
-.ss-order-card-warp {
-  padding: 20px;
-  border-radius: 10px;
-  border: 1px var(--el-border-color) solid;
-  background-color: var(--app-content-bg-color);
-
-  .img-box {
-    width: 80px;
-    height: 80px;
-    border-radius: 10px;
-    overflow: hidden;
+.button {
+  background-color: #007bff;
+  color: white;
+  border: none;
+  padding: 5px 10px;
+  cursor: pointer;
+}
 
-    .order-img {
-      width: 80px;
-      height: 80px;
+.product-warp {
+  width: 100%;
+  background-color: #fff;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  padding: 10px;
+
+  &-left {
+    width: 70px;
+
+    &-img {
+      width: 100%;
+      height: 100%;
+      border-radius: 8px;
     }
   }
 
-  .box-right {
+  &-right {
     flex: 1;
-    position: relative;
 
-    .tool-box {
-      position: absolute;
-      right: 0;
-      bottom: -10px;
+    .description {
+      width: 100%;
+      font-size: 16px;
+      font-weight: bold;
+      display: -webkit-box;
+      -webkit-line-clamp: 1; /* 显示一行 */
+      -webkit-box-orient: vertical;
+      overflow: hidden;
+      text-overflow: ellipsis;
     }
-  }
-
-  .title-text {
-    font-size: 13px;
-    font-weight: 500;
-    line-height: 20px;
-  }
-
-  .spec-text {
-    font-size: 10px;
-    font-weight: 400;
-    color: #999999;
-    min-width: 0;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    display: -webkit-box;
-    -webkit-line-clamp: 1;
-    -webkit-box-orient: vertical;
-  }
-
-  .price-text {
-    font-size: 11px;
-    font-weight: 500;
-    font-family: OPPOSANS;
-  }
-
-  .total-text {
-    font-size: 10px;
-    font-weight: 400;
-    line-height: 16px;
-    color: #999999;
-    margin-left: 8px;
-  }
-}
 
-.ss-line {
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: -webkit-box;
-  -webkit-box-orient: vertical;
-
-  &-1 {
-    -webkit-line-clamp: 1;
-  }
-
-  &-2 {
-    -webkit-line-clamp: 2;
+    .price {
+      color: #ff3000;
+    }
   }
 }
 </style>

+ 1 - 1
src/views/mall/promotion/kefu/components/tools/emoji.ts

@@ -93,7 +93,7 @@ export const useEmoji = () => {
           const emojiFile = getEmojiFileByName(item)
           newData = newData.replace(
             item,
-            `<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${emojiFile}" alt=""/>`
+            `<img style="width: 20px;height: 20px;margin:0 1px 3px 1px;vertical-align: middle;" src="${emojiFile}" alt=""/>`
           )
         })
       }

+ 29 - 37
src/views/mall/promotion/kefu/index.vue

@@ -1,36 +1,27 @@
 <template>
-  <el-row :gutter="10">
+  <el-container class="kefu-layout">
     <!-- 会话列表 -->
-    <el-col :span="6">
-      <ContentWrap>
-        <KeFuConversationList ref="keFuConversationRef" @change="handleChange" />
-      </ContentWrap>
-    </el-col>
+    <KeFuConversationList ref="keFuConversationRef" @change="handleChange" />
     <!-- 会话详情(选中会话的消息列表) -->
-    <el-col :span="12">
-      <ContentWrap>
-        <KeFuMessageList ref="keFuChatBoxRef" @change="getConversationList" />
-      </ContentWrap>
-    </el-col>
-    <!-- 会员足迹(选中会话的会员足迹) -->
-    <el-col :span="6">
-      <ContentWrap>
-        <MemberBrowsingHistory ref="memberBrowsingHistoryRef" />
-      </ContentWrap>
-    </el-col>
-  </el-row>
+    <KeFuMessageList ref="keFuChatBoxRef" />
+    <!-- 会员信息(选中会话的会员信息) -->
+    <MemberInfo ref="memberInfoRef" />
+  </el-container>
 </template>
 
 <script lang="ts" setup>
-import { KeFuConversationList, KeFuMessageList, MemberBrowsingHistory } from './components'
+import { KeFuConversationList, KeFuMessageList, MemberInfo } from './components'
 import { WebSocketMessageTypeConstants } from './components/tools/constants'
 import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
 import { getRefreshToken } from '@/utils/auth'
 import { useWebSocket } from '@vueuse/core'
+import { useMallKefuStore } from '@/store/modules/mall/kefu'
+import { jsonParse } from '@/utils'
 
 defineOptions({ name: 'KeFu' })
 
 const message = useMessage() // 消息弹窗
+const kefuStore = useMallKefuStore() // 客服缓存
 
 // ======================= WebSocket start =======================
 const server = ref(
@@ -55,7 +46,6 @@ watchEffect(() => {
     if (data.value === 'pong') {
       return
     }
-
     // 2.1 解析 type 消息类型
     const jsonMessage = JSON.parse(data.value)
     const type = jsonMessage.type
@@ -65,41 +55,39 @@ watchEffect(() => {
     }
     // 2.2 消息类型:KEFU_MESSAGE_TYPE
     if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
+      const message = JSON.parse(jsonMessage.content)
       // 刷新会话列表
-      // TODO @puhui999:不应该刷新列表,而是根据消息,本地 update 列表的数据;
-      getConversationList()
+      kefuStore.updateConversation(message.conversationId)
       // 刷新消息列表
-      keFuChatBoxRef.value?.refreshMessageList(JSON.parse(jsonMessage.content))
+      keFuChatBoxRef.value?.refreshMessageList(message)
       return
     }
     // 2.3 消息类型:KEFU_MESSAGE_ADMIN_READ
     if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_ADMIN_READ) {
-      // 刷新会话列表
-      // TODO @puhui999:不应该刷新列表,而是根据消息,本地 update 列表的数据;
-      getConversationList()
+      // 更新会话已读
+      kefuStore.updateConversationStatus(jsonParse(jsonMessage.content))
     }
   } catch (error) {
     console.error(error)
   }
 })
 // ======================= WebSocket end =======================
-/** 加载会话列表 */
-const keFuConversationRef = ref<InstanceType<typeof KeFuConversationList>>()
-const getConversationList = () => {
-  keFuConversationRef.value?.getConversationList()
-}
 
 /** 加载指定会话的消息列表 */
 const keFuChatBoxRef = ref<InstanceType<typeof KeFuMessageList>>()
-const memberBrowsingHistoryRef = ref<InstanceType<typeof MemberBrowsingHistory>>()
+const memberInfoRef = ref<InstanceType<typeof MemberInfo>>()
 const handleChange = (conversation: KeFuConversationRespVO) => {
   keFuChatBoxRef.value?.getNewMessageList(conversation)
-  memberBrowsingHistoryRef.value?.initHistory(conversation)
+  memberInfoRef.value?.initHistory(conversation)
 }
 
+const keFuConversationRef = ref<InstanceType<typeof KeFuConversationList>>()
 /** 初始化 */
 onMounted(() => {
-  getConversationList()
+  /** 加载会话列表 */
+  kefuStore.setConversationList().then(() => {
+    keFuConversationRef.value?.calculationLastMessageTime()
+  })
   // 打开 websocket 连接
   open()
 })
@@ -112,9 +100,13 @@ onBeforeUnmount(() => {
 </script>
 
 <style lang="scss">
-.kefu {
-  height: calc(100vh - 165px);
-  overflow: auto; /* 确保内容可滚动 */
+.kefu-layout {
+  position: absolute;
+  flex: 1;
+  top: 0;
+  left: 0;
+  height: 100%;
+  width: 100%;
 }
 
 /* 定义滚动条样式 */

+ 23 - 2
src/views/member/user/detail/UserAccountInfo.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-descriptions :column="2">
+  <el-descriptions :class="{ 'kefu-descriptions': column === 1 }" :column="column">
     <el-descriptions-item>
       <template #label>
         <descriptions-item-label icon="svg-icon:member_level" label=" 等级 " />
@@ -50,7 +50,9 @@ import * as UserApi from '@/api/member/user'
 import * as WalletApi from '@/api/pay/wallet/balance'
 import { fenToYuan } from '@/utils'
 
-defineProps<{ user: UserApi.UserVO; wallet: WalletApi.WalletVO }>() // 用户信息
+withDefaults(defineProps<{ user: UserApi.UserVO; wallet: WalletApi.WalletVO; column?: number }>(), {
+  column: 2
+}) // 用户信息
 </script>
 <style lang="scss" scoped>
 .cell-item {
@@ -60,4 +62,23 @@ defineProps<{ user: UserApi.UserVO; wallet: WalletApi.WalletVO }>() // 用户信
 .cell-item::after {
   content: ':';
 }
+
+.kefu-descriptions {
+  ::v-deep(.el-descriptions__cell) {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    .el-descriptions__label {
+      width: 120px;
+      display: block;
+      text-align: left;
+    }
+
+    .el-descriptions__content {
+      flex: 1;
+      text-align: end;
+    }
+  }
+}
 </style>

+ 97 - 17
src/views/member/user/detail/UserBasicInfo.vue

@@ -3,83 +3,163 @@
     <template #header>
       <slot name="header"></slot>
     </template>
-    <el-row>
+    <el-row v-if="mode === 'member'">
       <el-col :span="4">
-        <ElAvatar shape="square" :size="140" :src="user.avatar || undefined" />
+        <ElAvatar :size="140" :src="user.avatar || undefined" shape="square" />
       </el-col>
       <el-col :span="20">
         <el-descriptions :column="2">
           <el-descriptions-item>
             <template #label>
-              <descriptions-item-label label="用户名" icon="ep:user" />
+              <descriptions-item-label icon="ep:user" label="用户名" />
             </template>
             {{ user.name || '空' }}
           </el-descriptions-item>
           <el-descriptions-item>
             <template #label>
-              <descriptions-item-label label="昵称" icon="ep:user" />
+              <descriptions-item-label icon="ep:user" label="昵称" />
             </template>
             {{ user.nickname }}
           </el-descriptions-item>
           <el-descriptions-item label="手机号">
             <template #label>
-              <descriptions-item-label label="手机号" icon="ep:phone" />
+              <descriptions-item-label icon="ep:phone" label="手机号" />
             </template>
             {{ user.mobile }}
           </el-descriptions-item>
           <el-descriptions-item>
             <template #label>
-              <descriptions-item-label label="性别" icon="fa:mars-double" />
+              <descriptions-item-label icon="fa:mars-double" label="性别" />
             </template>
             <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="user.sex" />
           </el-descriptions-item>
           <el-descriptions-item>
             <template #label>
-              <descriptions-item-label label="所在地" icon="ep:location" />
+              <descriptions-item-label icon="ep:location" label="所在地" />
             </template>
             {{ user.areaName }}
           </el-descriptions-item>
           <el-descriptions-item>
             <template #label>
-              <descriptions-item-label label="注册 IP" icon="ep:position" />
+              <descriptions-item-label icon="ep:position" label="注册 IP" />
             </template>
             {{ user.registerIp }}
           </el-descriptions-item>
           <el-descriptions-item>
             <template #label>
-              <descriptions-item-label label="生日" icon="fa:birthday-cake" />
+              <descriptions-item-label icon="fa:birthday-cake" label="生日" />
             </template>
-            {{ user.birthday ? formatDate(user.birthday) : '空' }}
+            {{ user.birthday ? formatDate(user.birthday as any) : '空' }}
           </el-descriptions-item>
           <el-descriptions-item>
             <template #label>
-              <descriptions-item-label label="注册时间" icon="ep:calendar" />
+              <descriptions-item-label icon="ep:calendar" label="注册时间" />
             </template>
-            {{ user.createTime ? formatDate(user.createTime) : '空' }}
+            {{ user.createTime ? formatDate(user.createTime as any) : '空' }}
           </el-descriptions-item>
           <el-descriptions-item>
             <template #label>
-              <descriptions-item-label label="最后登录时间" icon="ep:calendar" />
+              <descriptions-item-label icon="ep:calendar" label="最后登录时间" />
             </template>
-            {{ user.loginDate ? formatDate(user.loginDate) : '空' }}
+            {{ user.loginDate ? formatDate(user.loginDate as any) : '空' }}
           </el-descriptions-item>
         </el-descriptions>
       </el-col>
     </el-row>
+    <template v-if="mode === 'kefu'">
+      <ElAvatar :size="140" :src="user.avatar || undefined" shape="square" />
+      <el-descriptions :column="1" class="kefu-descriptions">
+        <el-descriptions-item>
+          <template #label>
+            <descriptions-item-label icon="ep:user" label="用户名" />
+          </template>
+          {{ user.name || '空' }}
+        </el-descriptions-item>
+        <el-descriptions-item>
+          <template #label>
+            <descriptions-item-label icon="ep:user" label="昵称" />
+          </template>
+          {{ user.nickname }}
+        </el-descriptions-item>
+        <el-descriptions-item>
+          <template #label>
+            <descriptions-item-label icon="ep:phone" label="手机号" />
+          </template>
+          {{ user.mobile }}
+        </el-descriptions-item>
+        <el-descriptions-item>
+          <template #label>
+            <descriptions-item-label icon="fa:mars-double" label="性别" />
+          </template>
+          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="user.sex" />
+        </el-descriptions-item>
+        <el-descriptions-item>
+          <template #label>
+            <descriptions-item-label icon="ep:location" label="所在地" />
+          </template>
+          {{ user.areaName }}
+        </el-descriptions-item>
+        <el-descriptions-item>
+          <template #label>
+            <descriptions-item-label icon="ep:position" label="注册 IP" />
+          </template>
+          {{ user.registerIp }}
+        </el-descriptions-item>
+        <el-descriptions-item>
+          <template #label>
+            <descriptions-item-label icon="fa:birthday-cake" label="生日" />
+          </template>
+          {{ user.birthday ? formatDate(user.birthday as any) : '空' }}
+        </el-descriptions-item>
+        <el-descriptions-item>
+          <template #label>
+            <descriptions-item-label icon="ep:calendar" label="注册时间" />
+          </template>
+          {{ user.createTime ? formatDate(user.createTime as any) : '空' }}
+        </el-descriptions-item>
+        <el-descriptions-item>
+          <template #label>
+            <descriptions-item-label icon="ep:calendar" label="最后登录时间" />
+          </template>
+          {{ user.loginDate ? formatDate(user.loginDate as any) : '空' }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </template>
   </el-card>
 </template>
-<script setup lang="ts">
+<script lang="ts" setup>
 import { DICT_TYPE } from '@/utils/dict'
 import { formatDate } from '@/utils/formatTime'
 import * as UserApi from '@/api/member/user'
 import { DescriptionsItemLabel } from '@/components/Descriptions/index'
 
-const { user } = defineProps<{ user: UserApi.UserVO }>()
+withDefaults(defineProps<{ user: UserApi.UserVO; mode?: string }>(), {
+  mode: 'member'
+})
 </script>
-<style scoped lang="scss">
+<style lang="scss" scoped>
 .card-header {
   display: flex;
   justify-content: space-between;
   align-items: center;
 }
+
+::v-deep(.kefu-descriptions) {
+  .el-descriptions__cell {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    .el-descriptions__label {
+      width: 120px;
+      display: block;
+      text-align: left;
+    }
+
+    .el-descriptions__content {
+      flex: 1;
+      text-align: end;
+    }
+  }
+}
 </style>