!601 同步商城的客服优化、门店自提优化

Merge pull request !601 from 芋道源码/dev
pull/597/MERGE
芋道源码 2024-11-25 12:35:35 +00:00 committed by Gitee
commit 6eb013b2a6
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
17 changed files with 793 additions and 466 deletions

View File

@ -21,6 +21,10 @@ export const KeFuConversationApi = {
getConversationList: async () => { getConversationList: async () => {
return await request.get({ url: '/promotion/kefu-conversation/list' }) 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) => { updateConversationPinned: async (data: any) => {
return await request.put({ return await request.put({
@ -30,6 +34,6 @@ export const KeFuConversationApi = {
}, },
// 删除客服会话 // 删除客服会话
deleteConversation: async (id: number) => { 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}` })
} }
} }

View File

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

View File

@ -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)
}

View File

@ -115,7 +115,7 @@ import { getPropertyList, PropertyAndValues, SkuList } from '@/views/mall/produc
import { ElTable } from 'element-plus' import { ElTable } from 'element-plus'
import { dateFormatter } from '@/utils/formatTime' import { dateFormatter } from '@/utils/formatTime'
import { createImageViewer } from '@/components/ImageViewer' import { createImageViewer } from '@/components/ImageViewer'
import { formatToFraction } from '@/utils' import { floatToFixed2, formatToFraction } from '@/utils'
import { defaultProps, handleTree } from '@/utils/tree' import { defaultProps, handleTree } from '@/utils/tree'
import * as ProductCategoryApi from '@/api/mall/product/category' import * as ProductCategoryApi from '@/api/mall/product/category'
@ -228,6 +228,13 @@ const expandChange = async (row: ProductSpuApi.Spu, expandedRows?: ProductSpuApi
} }
// SPU // SPU
const res = (await ProductSpuApi.getSpu(row.id as number)) as 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) propertyList.value = getPropertyList(res)
spuData.value = res spuData.value = res
isExpand.value = true isExpand.value = true

View File

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

View File

@ -1,11 +1,11 @@
<template> <template>
<el-container v-if="showKeFuMessageList" class="kefu"> <el-container v-if="showKeFuMessageList" class="kefu">
<el-header> <el-header class="kefu-header">
<div class="kefu-title">{{ conversation.userNickname }}</div> <div class="kefu-title">{{ conversation.userNickname }}</div>
</el-header> </el-header>
<el-main class="kefu-content overflow-visible"> <el-main class="kefu-content overflow-visible">
<el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)" @scroll="handleScroll"> <el-scrollbar ref="scrollbarRef" always @scroll="handleScroll">
<div v-if="refreshContent" ref="innerRef" class="w-[100%] pb-3px"> <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 v-for="(item, index) in getMessageList0" :key="item.id" class="w-[100%]">
<div class="flex justify-center items-center mb-20px"> <div class="flex justify-center items-center mb-20px">
@ -43,15 +43,16 @@
class="w-60px h-60px" class="w-60px h-60px"
/> />
<div <div
:class="{ 'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType }" :class="{
class="p-10px" 'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType
}"
> >
<!-- 文本消息 --> <!-- 文本消息 -->
<MessageItem :message="item"> <MessageItem :message="item">
<template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType"> <template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType">
<div <div
v-dompurify-html="replaceEmoji(item.content)" v-dompurify-html="replaceEmoji(getMessageContent(item).text || item.content)"
class="flex items-center" class="line-height-normal text-justify h-1/1 w-full"
></div> ></div>
</template> </template>
</MessageItem> </MessageItem>
@ -60,9 +61,9 @@
<el-image <el-image
v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType" v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
:initial-index="0" :initial-index="0"
:preview-src-list="[item.content]" :preview-src-list="[getMessageContent(item).picUrl || item.content]"
:src="item.content" :src="getMessageContent(item).picUrl || item.content"
class="w-200px" class="w-200px mx-10px"
fit="contain" fit="contain"
preview-teleported preview-teleported
/> />
@ -71,14 +72,13 @@
<MessageItem :message="item"> <MessageItem :message="item">
<ProductItem <ProductItem
v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType" v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType"
:spuId="getMessageContent(item).spuId"
:picUrl="getMessageContent(item).picUrl" :picUrl="getMessageContent(item).picUrl"
:price="getMessageContent(item).price" :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" :title="getMessageContent(item).spuName"
:titleWidth="400" class="max-w-300px mx-10px"
class="max-w-70%"
priceColor="#FF3000"
/> />
</MessageItem> </MessageItem>
<!-- 订单消息 --> <!-- 订单消息 -->
@ -86,7 +86,7 @@
<OrderItem <OrderItem
v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType" v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
:message="item" :message="item"
class="max-w-100%" class="max-w-100% mx-10px"
/> />
</MessageItem> </MessageItem>
</div> </div>
@ -108,23 +108,29 @@
<Icon class="ml-5px" icon="ep:bottom" /> <Icon class="ml-5px" icon="ep:bottom" />
</div> </div>
</el-main> </el-main>
<el-footer height="230px"> <el-footer class="kefu-footer">
<div class="h-[100%]"> <div class="chat-tools flex items-center">
<div class="chat-tools flex items-center"> <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
<EmojiSelectPopover @select-emoji="handleEmojiSelect" /> <PictureSelectUpload
<PictureSelectUpload class="ml-15px mt-3px cursor-pointer"
class="ml-15px mt-3px cursor-pointer" @send-picture="handleSendPicture"
@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>
</div> </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-footer>
</el-container> </el-container>
<el-empty v-else description="请选择左侧的一个会话后开始" /> <el-container v-else class="kefu">
<el-main>
<el-empty description="请选择左侧的一个会话后开始" />
</el-main>
</el-container>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -144,6 +150,7 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { debounce } from 'lodash-es' import { debounce } from 'lodash-es'
import { jsonParse } from '@/utils' import { jsonParse } from '@/utils'
import { useMallKefuStore } from '@/store/modules/mall/kefu'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
@ -156,25 +163,31 @@ const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表
const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) //
const showNewMessageTip = ref(false) // const showNewMessageTip = ref(false) //
const queryParams = reactive({ const queryParams = reactive({
pageNo: 1, conversationId: 0,
pageSize: 10, createTime: undefined
conversationId: 0
}) })
const total = ref(0) // const total = ref(0) //
const refreshContent = ref(false) // , const refreshContent = ref(false) // ,
const kefuStore = useMallKefuStore() //
/** 获悉消息内容 */ /** 获悉消息内容 */
const getMessageContent = computed(() => (item: any) => jsonParse(item.content)) const getMessageContent = computed(() => (item: any) => jsonParse(item.content))
/** 获得消息列表 */ /** 获得消息列表 */
const getMessageList = async () => { const getMessageList = async () => {
const res = await KeFuMessageApi.getKeFuMessagePage(queryParams) const res = await KeFuMessageApi.getKeFuMessageList(queryParams)
total.value = res.total if (isEmpty(res)) {
//
skipGetMessageList.value = true
return
}
queryParams.createTime = formatDate(res.at(-1).createTime) as any
// //
if (queryParams.pageNo === 1) { if (!queryParams.createTime) {
messageList.value = res.list messageList.value = res
} else { } else {
// //
for (const item of res.list) { for (const item of res) {
pushMessage(item) pushMessage(item)
} }
} }
@ -208,8 +221,7 @@ const refreshMessageList = async (message?: any) => {
} }
pushMessage(message) pushMessage(message)
} else { } else {
// TODO @puhui999 page createTime queryParams.createTime = undefined
queryParams.pageNo = 1
await getMessageList() await getMessageList()
} }
@ -222,28 +234,27 @@ const refreshMessageList = async (message?: any) => {
} }
} }
/** 获得新会话的消息列表 */ /** 获得新会话的消息列表, 点击切换时读取缓存然后异步获取新消息merge 下; */
// TODO @puhui999 list merge
const getNewMessageList = async (val: KeFuConversationRespVO) => { const getNewMessageList = async (val: KeFuConversationRespVO) => {
// , // 1.
queryParams.pageNo = 1 kefuStore.saveMessageList(conversation.value.id, messageList.value)
messageList.value = [] // 2.1 ,
total.value = 0 messageList.value = kefuStore.getConversationMessageList(val.id) || []
total.value = messageList.value.length || 0
loadHistory.value = false loadHistory.value = false
refreshContent.value = false refreshContent.value = false
// skipGetMessageList.value = false
// 2.2
conversation.value = val conversation.value = val
queryParams.conversationId = val.id queryParams.conversationId = val.id
// queryParams.createTime = undefined
// 3.
await refreshMessageList() await refreshMessageList()
} }
defineExpose({ getNewMessageList, refreshMessageList }) defineExpose({ getNewMessageList, refreshMessageList })
const showKeFuMessageList = computed(() => !isEmpty(conversation.value)) // const showKeFuMessageList = computed(() => !isEmpty(conversation.value)) //
const skipGetMessageList = computed(() => { const skipGetMessageList = ref(false) //
//
return total.value > 0 && Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo
}) //
/** 处理表情选择 */ /** 处理表情选择 */
const handleEmojiSelect = (item: Emoji) => { const handleEmojiSelect = (item: Emoji) => {
@ -256,13 +267,17 @@ const handleSendPicture = async (picUrl: string) => {
const msg = { const msg = {
conversationId: conversation.value.id, conversationId: conversation.value.id,
contentType: KeFuMessageContentTypeEnum.IMAGE, contentType: KeFuMessageContentTypeEnum.IMAGE,
content: picUrl content: JSON.stringify({ picUrl })
} }
await sendMessage(msg) await sendMessage(msg)
} }
/** 发送文本消息 */ /** 发送文本消息 */
const handleSendMessage = async () => { const handleSendMessage = async (event: any) => {
// shift
if (event.shiftKey) {
return
}
// 1. // 1.
if (isEmpty(unref(message.value))) { if (isEmpty(unref(message.value))) {
messageTool.notifyWarning('请输入消息后再发送哦!') messageTool.notifyWarning('请输入消息后再发送哦!')
@ -272,7 +287,7 @@ const handleSendMessage = async () => {
const msg = { const msg = {
conversationId: conversation.value.id, conversationId: conversation.value.id,
contentType: KeFuMessageContentTypeEnum.TEXT, contentType: KeFuMessageContentTypeEnum.TEXT,
content: message.value content: JSON.stringify({ text: message.value })
} }
await sendMessage(msg) await sendMessage(msg)
} }
@ -284,6 +299,8 @@ const sendMessage = async (msg: any) => {
message.value = '' message.value = ''
// //
await refreshMessageList() await refreshMessageList()
//
await kefuStore.updateConversation(conversation.value.id)
} }
/** 滚动到底部 */ /** 滚动到底部 */
@ -333,8 +350,6 @@ const handleOldMessage = async () => {
return return
} }
loadHistory.value = true loadHistory.value = true
//
queryParams.pageNo += 1
await getMessageList() await getMessageList()
// //
scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight - oldPageHeight) scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight - oldPageHeight)
@ -357,14 +372,51 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
<style lang="scss" scoped> <style lang="scss" scoped>
.kefu { .kefu {
&-title { background-color: #f5f5f5;
border-bottom: #e4e0e0 solid 1px; position: relative;
height: 60px; width: calc(100% - 300px - 260px);
line-height: 60px;
&::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 { &-content {
margin: 0;
padding: 10px;
position: relative; position: relative;
height: 100%;
width: 100%;
.newMessageTip { .newMessageTip {
position: absolute; position: absolute;
@ -381,21 +433,12 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
justify-content: flex-start; justify-content: flex-start;
.kefu-message { .kefu-message {
margin-left: 20px; background-color: #fff;
position: relative; margin-left: 10px;
margin-top: 3px;
&::before { border-top-right-radius: 10px;
content: ''; border-bottom-right-radius: 10px;
width: 10px; border-bottom-left-radius: 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);
}
} }
} }
@ -403,37 +446,25 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
justify-content: flex-end; justify-content: flex-end;
.kefu-message { .kefu-message {
margin-right: 20px; background-color: rgb(206, 223, 255);
position: relative; margin-right: 10px;
margin-top: 3px;
&::after { border-top-left-radius: 10px;
content: ''; border-bottom-right-radius: 10px;
width: 10px; border-bottom-left-radius: 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;
}
} }
} }
// //
.kefu-message { .kefu-message {
color: #a9a9a9; color: #414141;
border-radius: 5px; font-weight: 500;
box-shadow: 3px 3px 5px rgba(220, 220, 220, 0.1);
padding: 5px 10px; padding: 5px 10px;
width: auto; width: auto;
max-width: 50%; max-width: 50%;
text-align: left; //text-align: left;
display: inline-block !important; //display: inline-block !important;
position: relative; //word-break: break-all;
word-break: break-all;
background-color: var(--app-content-bg-color);
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
@ -444,24 +475,51 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
.date-message, .date-message,
.system-message { .system-message {
width: fit-content; width: fit-content;
border-radius: 12rpx; background-color: rgba(0, 0, 0, 0.1);
padding: 8rpx 16rpx; border-radius: 8px;
margin-bottom: 16rpx; padding: 0 5px;
//background-color: #e8e8e8; color: #fff;
color: #999; font-size: 10px;
font-size: 24rpx;
} }
} }
.chat-tools { .kefu-footer {
width: 100%; position: relative;
border: var(--el-border-color) solid 1px; display: flex;
border-radius: 10px; flex-direction: column;
height: 44px; 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) { ::v-deep(textarea) {
resize: none; 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> </style>

View File

@ -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 ab
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>

View File

@ -1,5 +1,5 @@
import KeFuConversationList from './KeFuConversationList.vue' import KeFuConversationList from './KeFuConversationList.vue'
import KeFuMessageList from './KeFuMessageList.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 }

View File

@ -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>

View File

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

View File

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

View File

@ -1,51 +1,27 @@
<template> <template>
<div @click.stop="openDetail(props.spuId)" style="cursor: pointer;"> <div class="product-warp" style="cursor: pointer" @click.stop="openDetail(spuId)">
<div> <!-- 左侧商品图片-->
<slot name="top"></slot> <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>
<div <!-- 右侧商品信息 -->
:style="[{ borderRadius: radius + 'px', marginBottom: marginBottom + 'px' }]" <div class="product-warp-right">
class="ss-order-card-warp flex items-stretch justify-between bg-white" <div class="description">{{ title }}</div>
> <div class="my-5px">
<div class="img-box mr-24px"> <span class="mr-20px">库存: {{ stock || 0 }}</span>
<el-image <span>销量: {{ salesCount || 0 }}</span>
:initial-index="0"
:preview-src-list="[picUrl]"
:src="picUrl"
class="order-img"
fit="contain"
preview-teleported
@click.stop
/>
</div> </div>
<div <div class="flex justify-between items-center">
:style="[{ width: titleWidth ? titleWidth + 'px' : '' }]" <span class="price">{{ fenToYuan(price) }}</span>
class="box-right flex flex-col justify-between" <el-button size="small" text type="primary">详情</el-button>
>
<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> </div>
</div> </div>
</div> </div>
@ -57,7 +33,7 @@ import { fenToYuan } from '@/utils'
const { push } = useRouter() const { push } = useRouter()
defineOptions({ name: 'ProductItem' }) defineOptions({ name: 'ProductItem' })
const props = defineProps({ defineProps({
spuId: { spuId: {
type: Number, type: Number,
default: 0 default: 0
@ -70,134 +46,70 @@ const props = defineProps({
type: String, type: String,
default: '' default: ''
}, },
titleWidth: {
type: Number,
default: 0
},
skuText: {
type: [String, Array],
default: ''
},
price: { price: {
type: [String, Number], type: [String, Number],
default: '' default: ''
}, },
priceColor: { salesCount: {
type: [String],
default: ''
},
num: {
type: [String, Number],
default: 0
},
score: {
type: [String, Number], type: [String, Number],
default: '' default: ''
}, },
radius: { stock: {
type: [String], type: [String, Number],
default: ''
},
marginBottom: {
type: [String],
default: '' 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) => { const openDetail = (spuId: number) => {
console.log(props.spuId)
push({ name: 'ProductSpuDetail', params: { id: spuId } }) push({ name: 'ProductSpuDetail', params: { id: spuId } })
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.ss-order-card-warp { .button {
padding: 20px; background-color: #007bff;
border-radius: 10px; color: white;
border: 1px var(--el-border-color) solid; border: none;
background-color: var(--app-content-bg-color); padding: 5px 10px;
cursor: pointer;
.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;
}
} }
.ss-line { .product-warp {
min-width: 0; width: 100%;
overflow: hidden; background-color: #fff;
text-overflow: ellipsis; border-radius: 8px;
display: -webkit-box; display: flex;
-webkit-box-orient: vertical; align-items: center;
padding: 10px;
&-1 { &-left {
-webkit-line-clamp: 1; width: 70px;
&-img {
width: 100%;
height: 100%;
border-radius: 8px;
}
} }
&-2 { &-right {
-webkit-line-clamp: 2; 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> </style>

View File

@ -93,7 +93,7 @@ export const useEmoji = () => {
const emojiFile = getEmojiFileByName(item) const emojiFile = getEmojiFileByName(item)
newData = newData.replace( newData = newData.replace(
item, 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=""/>`
) )
}) })
} }

View File

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

View File

@ -1,5 +1,5 @@
<template> <template>
<el-descriptions :column="2"> <el-descriptions :class="{ 'kefu-descriptions': column === 1 }" :column="column">
<el-descriptions-item> <el-descriptions-item>
<template #label> <template #label>
<descriptions-item-label icon="svg-icon:member_level" 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 * as WalletApi from '@/api/pay/wallet/balance'
import { fenToYuan } from '@/utils' import { fenToYuan } from '@/utils'
defineProps<{ user: UserApi.UserVO; wallet: WalletApi.WalletVO }>() // withDefaults(defineProps<{ user: UserApi.UserVO; wallet: WalletApi.WalletVO; column?: number }>(), {
column: 2
}) //
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.cell-item { .cell-item {
@ -60,4 +62,23 @@ defineProps<{ user: UserApi.UserVO; wallet: WalletApi.WalletVO }>() // 用户信
.cell-item::after { .cell-item::after {
content: ':'; 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> </style>

View File

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