commit
						6eb013b2a6
					
				|  | @ -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}` }) | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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 }) | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -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 } | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -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> | ||||
| 
 | ||||
|  | @ -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); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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: { | ||||
|     type: [String, Number], | ||||
|     default: 0 | ||||
|   }, | ||||
|   score: { | ||||
|   salesCount: { | ||||
|     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; | ||||
| 
 | ||||
|     .order-img { | ||||
|       width: 80px; | ||||
|       height: 80px; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .box-right { | ||||
|     flex: 1; | ||||
|     position: relative; | ||||
| 
 | ||||
|     .tool-box { | ||||
|       position: absolute; | ||||
|       right: 0; | ||||
|       bottom: -10px; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .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; | ||||
|   } | ||||
| .button { | ||||
|   background-color: #007bff; | ||||
|   color: white; | ||||
|   border: none; | ||||
|   padding: 5px 10px; | ||||
|   cursor: pointer; | ||||
| } | ||||
| 
 | ||||
| .ss-line { | ||||
|   min-width: 0; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   display: -webkit-box; | ||||
|   -webkit-box-orient: vertical; | ||||
| .product-warp { | ||||
|   width: 100%; | ||||
|   background-color: #fff; | ||||
|   border-radius: 8px; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   padding: 10px; | ||||
| 
 | ||||
|   &-1 { | ||||
|     -webkit-line-clamp: 1; | ||||
|   &-left { | ||||
|     width: 70px; | ||||
| 
 | ||||
|     &-img { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       border-radius: 8px; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &-2 { | ||||
|     -webkit-line-clamp: 2; | ||||
|   &-right { | ||||
|     flex: 1; | ||||
| 
 | ||||
|     .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; | ||||
|     } | ||||
| 
 | ||||
|     .price { | ||||
|       color: #ff3000; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -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=""/>` | ||||
|           ) | ||||
|         }) | ||||
|       } | ||||
|  |  | |||
|  | @ -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%; | ||||
| } | ||||
| 
 | ||||
| /* 定义滚动条样式 */ | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 芋道源码
						芋道源码