refactor: kefu

This commit is contained in:
xingyu4j
2025-11-06 15:04:27 +08:00
parent 103f633344
commit af60802840
4 changed files with 238 additions and 236 deletions

View File

@@ -7,7 +7,7 @@ import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { useWebSocket } from '@vueuse/core';
import { Layout, message } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { useMallKefuStore } from '#/store/mall/kefu';
@@ -104,13 +104,19 @@ onBeforeUnmount(() => {
<template>
<Page auto-content-height>
<Layout.Content class="absolute left-0 top-0 m-4 flex h-full w-full flex-1">
<!-- 会话列表 -->
<ConversationList ref="conversationListRef" @change="handleChange" />
<!-- 会话详情选中会话的消息列表 -->
<MessageList ref="messageListRef" />
<!-- 会员信息选中会话的会员信息 -->
<MemberInfo ref="memberInfoRef" />
</Layout.Content>
<div class="flex h-full antialiased">
<div class="flex h-full w-full flex-row overflow-x-hidden">
<!-- 会话列表 -->
<ConversationList
class="w-1/6"
ref="conversationListRef"
@change="handleChange"
/>
<!-- 会话详情选中会话的消息列表 -->
<MessageList class="w-4/6" ref="messageListRef" />
<!-- 会员信息选中会话的会员信息 -->
<MemberInfo class="w-1/6" ref="memberInfoRef" />
</div>
</div>
</Page>
</template>

View File

@@ -7,7 +7,7 @@ import { confirm } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatPast, jsonParse } from '@vben/utils';
import { Avatar, Badge, Layout, message } from 'ant-design-vue';
import { Avatar, message } from 'ant-design-vue';
import {
deleteConversation,
@@ -158,38 +158,36 @@ onBeforeUnmount(() => {
</script>
<template>
<Layout.Sider
class="bg-card relative flex h-full flex-col justify-between overflow-hidden p-4"
width="260px"
<div
class="bg-background flex flex-shrink-0 flex-col border-r border-gray-200 p-4"
>
<div class="m-4 border-b border-gray-200 pb-2 font-bold">
会话记录({{ kefuStore.getConversationList.length }})
<div class="flex h-12 w-full flex-row items-center justify-between">
<span class="text-lg font-bold">会话记录</span>
<span
class="flex h-4 w-4 items-center justify-center rounded-full bg-indigo-100 text-indigo-600"
>
{{ kefuStore.getConversationList.length }}
</span>
</div>
<div
v-for="item in kefuStore.getConversationList"
:key="item.id"
:class="{
'bg-gray-500/50': item.id === activeConversationId,
}"
class="flex h-14 cursor-pointer items-center px-3"
@click="openRightMessage(item)"
@contextmenu.prevent="rightClick($event as PointerEvent, item)"
>
<div class="flex w-full items-center justify-center">
<div class="flex h-12 w-12 items-center justify-center">
<!-- 头像 + 未读 -->
<Badge :max="99" :value="item.adminUnreadMessageCount">
<div class="mt-2 flex flex-col">
<div class="-mx-2 mt-4 flex h-48 flex-col space-y-1 overflow-y-auto">
<button
v-for="(item, index) in kefuStore.getConversationList"
:key="index"
class="flex flex-row items-center rounded-xl p-2 hover:bg-gray-100"
:class="{
'bg-gray-500/50': item.id === activeConversationId,
}"
@click="openRightMessage(item)"
@contextmenu.prevent="rightClick($event as PointerEvent, item)"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"
>
<Avatar :src="item.userAvatar" alt="avatar" />
</Badge>
</div>
<div class="ml-3 w-full">
<div class="flex w-full items-center justify-between">
<span class="line-clamp-1 min-w-0 max-w-[60%]">
{{ item.userNickname || 'null' }}
</span>
<span class="text-sm text-gray-500">
{{ lastMessageTimeMap.get(item.id) ?? '计算中' }}
</span>
</div>
<div class="ml-2 text-sm font-semibold">
{{ item.userNickname || 'null' }}
</div>
<!-- 最后聊天内容 -->
<div
@@ -201,7 +199,13 @@ onBeforeUnmount(() => {
"
class="line-clamp-1 flex items-center text-sm text-gray-500"
></div>
</div>
<div
v-if="item.adminUnreadMessageCount > 0"
class="ml-auto flex h-4 w-4 items-center justify-center rounded bg-red-500 text-xs leading-none text-white"
>
{{ item.adminUnreadMessageCount }}
</div>
</button>
</div>
</div>
@@ -242,5 +246,5 @@ onBeforeUnmount(() => {
取消
</li>
</ul>
</Layout.Sider>
</div>
</template>

View File

@@ -1,18 +1,15 @@
<!-- 右侧信息会员信息 + 最近浏览 + 交易订单 -->
<script lang="ts" setup>
import type { UseScrollReturn } from '@vueuse/core';
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
import type { MemberUserApi } from '#/api/member/user';
import type { PayWalletApi } from '#/api/pay/wallet/balance';
import { computed, nextTick, ref } from 'vue';
import { computed, nextTick, ref, toRefs, watch } from 'vue';
import { isEmpty } from '@vben/utils';
import { vScroll } from '@vueuse/components';
import { useDebounceFn } from '@vueuse/core';
import { Empty, Layout, message } from 'ant-design-vue';
import { useScroll } from '@vueuse/core';
import { Empty, message } from 'ant-design-vue';
import { getUser } from '#/api/member/user';
import { getWallet } from '#/api/pay/wallet/balance';
@@ -93,12 +90,16 @@ defineExpose({ initHistory });
/** 处理消息列表滚动事件(debounce 限流) */
const scrollbarRef = ref<InstanceType<any>>();
const handleScroll = useDebounceFn(async (state: UseScrollReturn) => {
const { arrivedState } = state;
if (arrivedState.bottom) {
await loadMore();
}
}, 200);
const { arrivedState } = useScroll(scrollbarRef);
const { bottom } = toRefs(arrivedState);
watch(
() => bottom.value,
async (newVal) => {
if (newVal) {
await loadMore();
}
},
);
/** 查询用户钱包信息 */
// TODO @jaweidea 的导入报错;需要看下;
@@ -139,18 +140,16 @@ async function getUserData() {
</script>
<template>
<Layout
class="bg-card relative w-72 after:absolute after:left-0 after:top-0 after:h-full after:w-[1px] after:scale-x-[0.3] after:bg-gray-200 after:content-['']"
>
<Layout.Header
class="!bg-card relative flex items-center justify-around before:absolute before:bottom-0 before:left-0 before:h-[1px] before:w-full before:scale-y-[0.3] before:bg-gray-200 before:content-['']"
<div class="bg-background relative">
<div
class="relative flex h-12 items-center justify-around before:absolute before:bottom-0 before:left-0 before:h-[1px] before:w-full before:scale-y-[0.3] before:bg-gray-200 before:content-['']"
>
<div
:class="{
'before:border-b-2 before:border-gray-500/50':
tabActivation('会员信息'),
}"
class="relative flex h-full w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
class="relative flex w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
@click="handleClick('会员信息')"
>
会员信息
@@ -160,7 +159,7 @@ async function getUserData() {
'before:border-b-2 before:border-gray-500/50':
tabActivation('最近浏览'),
}"
class="relative flex h-full w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
class="relative flex w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
@click="handleClick('最近浏览')"
>
最近浏览
@@ -170,18 +169,18 @@ async function getUserData() {
'before:border-b-2 before:border-gray-500/50':
tabActivation('交易订单'),
}"
class="relative flex h-full w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
class="relative flex w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
@click="handleClick('交易订单')"
>
交易订单
</div>
</Layout.Header>
<Layout.Content class="relative m-0 h-full w-full p-2">
</div>
<div class="relative m-0 w-full p-2">
<template v-if="!isEmpty(conversation)">
<div
v-loading="loading"
v-if="activeTab === '会员信息'"
class="relative h-full overflow-y-auto overflow-x-hidden"
class="relative overflow-y-auto overflow-x-hidden"
>
<!-- 基本信息 -->
<BasicInfo :user="user" mode="kefu">
@@ -205,7 +204,6 @@ async function getUserData() {
<div
v-show="activeTab !== '会员信息'"
ref="scrollbarRef"
v-scroll="handleScroll"
class="relative h-full overflow-y-auto overflow-x-hidden"
>
<!-- 最近浏览 -->
@@ -221,6 +219,6 @@ async function getUserData() {
</div>
</template>
<Empty v-else description="请选择左侧的一个会话后开始" class="mt-[20%]" />
</Layout.Content>
</Layout>
</div>
</div>
</template>

View File

@@ -1,27 +1,17 @@
<script lang="ts" setup>
import type { UseScrollReturn } from '@vueuse/core';
import type { Emoji } from './tools/emoji';
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
import { computed, reactive, ref, unref } from 'vue';
import { computed, reactive, ref, toRefs, unref, watch } from 'vue';
import { UserTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDate, isEmpty, jsonParse } from '@vben/utils';
import { vScroll } from '@vueuse/components';
import { useDebounceFn, useScroll } from '@vueuse/core';
import {
Avatar,
Empty,
Image,
Layout,
notification,
Textarea,
} from 'ant-design-vue';
import { useScroll } from '@vueuse/core';
import { Avatar, Empty, Image, notification, Textarea } from 'ant-design-vue';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@@ -206,7 +196,9 @@ async function sendMessage(msg: MallKefuMessageApi.MessageSend) {
/** 滚动到底部 */
const scrollbarRef = ref<HTMLElement | null>(null);
const { y } = useScroll(scrollbarRef);
const { y, arrivedState } = useScroll(scrollbarRef);
const { bottom } = toRefs(arrivedState);
async function scrollToBottom() {
if (!scrollbarRef.value) return;
@@ -228,23 +220,26 @@ async function handleToNewMessage() {
await scrollToBottom();
}
/** 处理消息列表滚动事件(debounce 限流) */
const handleScroll = useDebounceFn((state: UseScrollReturn) => {
const { arrivedState } = state;
if (skipGetMessageList.value) {
return;
}
// 滚动到底部了
if (arrivedState.bottom) {
loadHistory.value = false;
refreshMessageList();
}
watch(
() => bottom.value,
(newVal) => {
if (newVal) {
if (skipGetMessageList.value) {
return;
}
// 滚动到底部了
if (newVal) {
loadHistory.value = false;
refreshMessageList();
}
// 触顶自动加载下一页数据
if (arrivedState.top) {
handleOldMessage();
}
}, 200);
// 触顶自动加载下一页数据
if (arrivedState.top) {
handleOldMessage();
}
}
},
);
/** 加载历史消息 */
async function handleOldMessage() {
loadHistory.value = true;
@@ -264,164 +259,163 @@ function showTime(item: MallKefuMessageApi.Message, index: number) {
</script>
<template>
<Layout
<div
v-if="showMessageList()"
class="bg-card relative w-[calc(100%-300px-260px)]"
class="bg-background flex h-full flex-auto flex-col p-4"
>
<Layout.Header
class="!bg-card border-border flex items-center justify-between border-b"
>
<div class="text-lg font-bold">{{ conversation.userNickname }}</div>
</Layout.Header>
<Layout.Content class="relative m-0 h-full w-full p-0">
<div class="flex h-full flex-auto flex-shrink-0 flex-col">
<div class="flex h-12 w-full flex-row items-center justify-between">
<span class="text-lg font-bold">{{ conversation.userNickname }}</span>
</div>
<div
ref="scrollbarRef"
class="absolute inset-0 m-0 overflow-y-auto overflow-x-hidden p-0"
v-scroll="handleScroll"
class="mb-4 flex flex-col overflow-x-auto rounded-lg bg-gray-100 p-2"
>
<!-- <div v-if="refreshContent" ref="innerRef" class="w-full px-[10px] absolute inset-0 m-0 overflow-x-hidden p-0 overflow-y-auto"> -->
<!-- 消息列表 -->
<div
v-for="(item, index) in getMessageList0"
:key="item.id"
class="w-full"
>
<div class="mb-5 flex items-center justify-center">
<!-- 日期 -->
<div
v-if="
item.contentType !== KeFuMessageContentTypeEnum.SYSTEM &&
showTime(item, index)
"
class="w-fit rounded-lg bg-black/10 px-2 text-xs text-white"
>
{{ formatDate(item.createTime) }}
</div>
<!-- 系统消息 -->
<div
v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
class="w-fit rounded-lg bg-black/10 px-2 text-xs text-white"
>
{{ item.content }}
</div>
</div>
<div class="flex flex-col">
<!-- 消息列表 -->
<div
:class="[
item.senderType === UserTypeEnum.MEMBER
? 'justify-start'
: item.senderType === UserTypeEnum.ADMIN
? 'justify-end'
: '',
]"
class="mb-5 flex w-full"
v-for="(item, index) in getMessageList0"
:key="item.id"
class="w-full"
>
<Avatar
v-if="item.senderType === UserTypeEnum.MEMBER"
:src="conversation.userAvatar"
alt="avatar"
class="size-8"
/>
<div
:class="{
'w-auto max-w-[50%] p-1 font-medium text-gray-500 transition-all duration-200 hover:scale-105':
KeFuMessageContentTypeEnum.TEXT === item.contentType,
'm-1 break-words rounded-lg bg-gray-100':
KeFuMessageContentTypeEnum.TEXT === item.contentType &&
item.senderType === UserTypeEnum.MEMBER,
'm-1 break-words rounded-lg bg-blue-50':
KeFuMessageContentTypeEnum.TEXT === item.contentType &&
item.senderType === UserTypeEnum.ADMIN,
}"
>
<!-- 文本消息 -->
<MessageItem :message="item">
<template
v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType"
>
<div
v-dompurify-html="
replaceEmoji(getMessageContent(item).text || item.content)
"
class="h-full w-full text-justify"
></div>
</template>
</MessageItem>
<!-- 图片消息 -->
<MessageItem :message="item">
<Image
v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
:initial-index="0"
:preview-src-list="[
getMessageContent(item).picUrl || item.content,
]"
:src="getMessageContent(item).picUrl || item.content"
class="mx-2 !w-52"
fit="contain"
preview-teleported
/>
</MessageItem>
<!-- 商品消息 -->
<MessageItem :message="item">
<ProductItem
v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType"
:pic-url="getMessageContent(item).picUrl"
:price="getMessageContent(item).price"
:sales-count="getMessageContent(item).salesCount"
:spu-id="getMessageContent(item).spuId"
:stock="getMessageContent(item).stock"
:title="getMessageContent(item).spuName"
class="mx-2 max-w-80"
/>
</MessageItem>
<!-- 订单消息 -->
<MessageItem :message="item">
<OrderItem
v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
:message="item"
class="mx-2 max-w-full"
/>
</MessageItem>
<div class="mb-5 flex items-center justify-center">
<!-- 日期 -->
<div
v-if="
item.contentType !== KeFuMessageContentTypeEnum.SYSTEM &&
showTime(item, index)
"
class="w-fit rounded-lg bg-black/10 px-2 text-xs"
>
{{ formatDate(item.createTime) }}
</div>
<!-- 系统消息 -->
<div
v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
class="w-fit rounded-lg bg-black/10 px-2 text-xs"
>
{{ item.content }}
</div>
</div>
<div
:class="[
item.senderType === UserTypeEnum.MEMBER
? 'justify-start'
: item.senderType === UserTypeEnum.ADMIN
? 'justify-end'
: '',
]"
class="mb-5 flex w-full"
>
<Avatar
v-if="item.senderType === UserTypeEnum.MEMBER"
:src="conversation.userAvatar"
alt="avatar"
class="size-8"
/>
<div
:class="{
'w-auto max-w-[50%] p-1 font-medium text-gray-500 transition-all duration-200 hover:scale-105':
KeFuMessageContentTypeEnum.TEXT === item.contentType,
'm-1 break-words rounded-lg bg-gray-100':
KeFuMessageContentTypeEnum.TEXT === item.contentType &&
item.senderType === UserTypeEnum.MEMBER,
'm-1 break-words rounded-lg bg-blue-50':
KeFuMessageContentTypeEnum.TEXT === item.contentType &&
item.senderType === UserTypeEnum.ADMIN,
}"
>
<!-- 文本消息 -->
<MessageItem :message="item">
<template
v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType"
>
<div
v-dompurify-html="
replaceEmoji(
getMessageContent(item).text || item.content,
)
"
class="h-full w-full text-justify"
></div>
</template>
</MessageItem>
<!-- 图片消息 -->
<MessageItem :message="item">
<Image
v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
:initial-index="0"
:preview-src-list="[
getMessageContent(item).picUrl || item.content,
]"
:src="getMessageContent(item).picUrl || item.content"
class="mx-2 !w-52"
fit="contain"
preview-teleported
/>
</MessageItem>
<!-- 商品消息 -->
<MessageItem :message="item">
<ProductItem
v-if="
KeFuMessageContentTypeEnum.PRODUCT === item.contentType
"
:pic-url="getMessageContent(item).picUrl"
:price="getMessageContent(item).price"
:sales-count="getMessageContent(item).salesCount"
:spu-id="getMessageContent(item).spuId"
:stock="getMessageContent(item).stock"
:title="getMessageContent(item).spuName"
class="mx-2 max-w-80"
/>
</MessageItem>
<!-- 订单消息 -->
<MessageItem :message="item">
<OrderItem
v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
:message="item"
class="mx-2 max-w-full"
/>
</MessageItem>
</div>
<Avatar
v-if="item.senderType === UserTypeEnum.ADMIN"
:src="item.senderAvatar"
alt="avatar"
/>
</div>
<Avatar
v-if="item.senderType === UserTypeEnum.ADMIN"
:src="item.senderAvatar"
alt="avatar"
/>
</div>
</div>
<!-- </div> -->
</div>
<div
v-show="showNewMessageTip"
class="bg-card absolute bottom-9 right-9 z-10 flex cursor-pointer items-center rounded-full p-2.5 text-xs shadow-md"
class="absolute bottom-9 right-9 z-10 flex cursor-pointer items-center rounded-lg p-2 text-xs shadow-md"
@click="handleToNewMessage"
>
<span>有新消息</span>
<IconifyIcon class="ml-1" icon="lucide:arrow-down-from-line" />
</div>
</Layout.Content>
<Layout.Footer class="!bg-card m-0 flex flex-col p-0">
<div class="border-border flex flex-col rounded-xl border p-2">
<div class="flex h-11 w-full items-center">
<EmojiSelectPopover @select-emoji="handleEmojiSelect" />
<PictureSelectUpload
class="ml-4 mt-1 cursor-pointer"
@send-picture="handleSendPicture"
<div class="flex flex-col">
<div class="border-border -mt-3 flex flex-col rounded-xl border">
<div class="flex h-10 w-full items-center">
<EmojiSelectPopover @select-emoji="handleEmojiSelect" />
<PictureSelectUpload
class="ml-4 mt-1 cursor-pointer"
@send-picture="handleSendPicture"
/>
</div>
<Textarea
v-model:value="message"
:rows="4"
class="border-none p-2"
placeholder="输入消息Enter发送Shift+Enter换行"
@press-enter="handleSendMessage"
/>
</div>
<Textarea
v-model:value="message"
:rows="6"
class="border-none"
placeholder="输入消息Enter发送Shift+Enter换行"
@press-enter="handleSendMessage"
/>
</div>
</Layout.Footer>
</Layout>
<Layout v-else class="bg-card relative w-[calc(100vw-300px-260px)]">
<Layout.Content>
<Empty description="请选择左侧的一个会话后开始" class="mt-[20%]" />
</Layout.Content>
</Layout>
</div>
</div>
<div v-else class="bg-background relative">
<Empty description="请选择左侧的一个会话后开始" class="mt-[20%]" />
</div>
</template>