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

View File

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

View File

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

View File

@@ -1,27 +1,17 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { UseScrollReturn } from '@vueuse/core';
import type { Emoji } from './tools/emoji'; import type { Emoji } from './tools/emoji';
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation'; import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message'; 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 { UserTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { formatDate, isEmpty, jsonParse } from '@vben/utils'; import { formatDate, isEmpty, jsonParse } from '@vben/utils';
import { vScroll } from '@vueuse/components'; import { useScroll } from '@vueuse/core';
import { useDebounceFn, useScroll } from '@vueuse/core'; import { Avatar, Empty, Image, notification, Textarea } from 'ant-design-vue';
import {
Avatar,
Empty,
Image,
Layout,
notification,
Textarea,
} from 'ant-design-vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
@@ -206,7 +196,9 @@ async function sendMessage(msg: MallKefuMessageApi.MessageSend) {
/** 滚动到底部 */ /** 滚动到底部 */
const scrollbarRef = ref<HTMLElement | null>(null); const scrollbarRef = ref<HTMLElement | null>(null);
const { y } = useScroll(scrollbarRef); const { y, arrivedState } = useScroll(scrollbarRef);
const { bottom } = toRefs(arrivedState);
async function scrollToBottom() { async function scrollToBottom() {
if (!scrollbarRef.value) return; if (!scrollbarRef.value) return;
@@ -228,14 +220,15 @@ async function handleToNewMessage() {
await scrollToBottom(); await scrollToBottom();
} }
/** 处理消息列表滚动事件(debounce 限流) */ watch(
const handleScroll = useDebounceFn((state: UseScrollReturn) => { () => bottom.value,
const { arrivedState } = state; (newVal) => {
if (newVal) {
if (skipGetMessageList.value) { if (skipGetMessageList.value) {
return; return;
} }
// 滚动到底部了 // 滚动到底部了
if (arrivedState.bottom) { if (newVal) {
loadHistory.value = false; loadHistory.value = false;
refreshMessageList(); refreshMessageList();
} }
@@ -244,7 +237,9 @@ const handleScroll = useDebounceFn((state: UseScrollReturn) => {
if (arrivedState.top) { if (arrivedState.top) {
handleOldMessage(); handleOldMessage();
} }
}, 200); }
},
);
/** 加载历史消息 */ /** 加载历史消息 */
async function handleOldMessage() { async function handleOldMessage() {
loadHistory.value = true; loadHistory.value = true;
@@ -264,22 +259,19 @@ function showTime(item: MallKefuMessageApi.Message, index: number) {
</script> </script>
<template> <template>
<Layout <div
v-if="showMessageList()" 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 <div class="flex h-full flex-auto flex-shrink-0 flex-col">
class="!bg-card border-border flex items-center justify-between border-b" <div class="flex h-12 w-full flex-row items-center justify-between">
> <span class="text-lg font-bold">{{ conversation.userNickname }}</span>
<div class="text-lg font-bold">{{ conversation.userNickname }}</div> </div>
</Layout.Header>
<Layout.Content class="relative m-0 h-full w-full p-0">
<div <div
ref="scrollbarRef" ref="scrollbarRef"
class="absolute inset-0 m-0 overflow-y-auto overflow-x-hidden p-0" class="mb-4 flex flex-col overflow-x-auto rounded-lg bg-gray-100 p-2"
v-scroll="handleScroll"
> >
<!-- <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 class="flex flex-col">
<!-- 消息列表 --> <!-- 消息列表 -->
<div <div
v-for="(item, index) in getMessageList0" v-for="(item, index) in getMessageList0"
@@ -293,14 +285,14 @@ function showTime(item: MallKefuMessageApi.Message, index: number) {
item.contentType !== KeFuMessageContentTypeEnum.SYSTEM && item.contentType !== KeFuMessageContentTypeEnum.SYSTEM &&
showTime(item, index) showTime(item, index)
" "
class="w-fit rounded-lg bg-black/10 px-2 text-xs text-white" class="w-fit rounded-lg bg-black/10 px-2 text-xs"
> >
{{ formatDate(item.createTime) }} {{ formatDate(item.createTime) }}
</div> </div>
<!-- 系统消息 --> <!-- 系统消息 -->
<div <div
v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM" v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
class="w-fit rounded-lg bg-black/10 px-2 text-xs text-white" class="w-fit rounded-lg bg-black/10 px-2 text-xs"
> >
{{ item.content }} {{ item.content }}
</div> </div>
@@ -340,7 +332,9 @@ function showTime(item: MallKefuMessageApi.Message, index: number) {
> >
<div <div
v-dompurify-html=" v-dompurify-html="
replaceEmoji(getMessageContent(item).text || item.content) replaceEmoji(
getMessageContent(item).text || item.content,
)
" "
class="h-full w-full text-justify" class="h-full w-full text-justify"
></div> ></div>
@@ -363,7 +357,9 @@ function showTime(item: MallKefuMessageApi.Message, index: number) {
<!-- 商品消息 --> <!-- 商品消息 -->
<MessageItem :message="item"> <MessageItem :message="item">
<ProductItem <ProductItem
v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType" v-if="
KeFuMessageContentTypeEnum.PRODUCT === item.contentType
"
:pic-url="getMessageContent(item).picUrl" :pic-url="getMessageContent(item).picUrl"
:price="getMessageContent(item).price" :price="getMessageContent(item).price"
:sales-count="getMessageContent(item).salesCount" :sales-count="getMessageContent(item).salesCount"
@@ -389,20 +385,19 @@ function showTime(item: MallKefuMessageApi.Message, index: number) {
/> />
</div> </div>
</div> </div>
<!-- </div> --> </div>
</div> </div>
<div <div
v-show="showNewMessageTip" 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" @click="handleToNewMessage"
> >
<span>有新消息</span> <span>有新消息</span>
<IconifyIcon class="ml-1" icon="lucide:arrow-down-from-line" /> <IconifyIcon class="ml-1" icon="lucide:arrow-down-from-line" />
</div> </div>
</Layout.Content> <div class="flex flex-col">
<Layout.Footer class="!bg-card m-0 flex flex-col p-0"> <div class="border-border -mt-3 flex flex-col rounded-xl border">
<div class="border-border flex flex-col rounded-xl border p-2"> <div class="flex h-10 w-full items-center">
<div class="flex h-11 w-full items-center">
<EmojiSelectPopover @select-emoji="handleEmojiSelect" /> <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
<PictureSelectUpload <PictureSelectUpload
class="ml-4 mt-1 cursor-pointer" class="ml-4 mt-1 cursor-pointer"
@@ -411,17 +406,16 @@ function showTime(item: MallKefuMessageApi.Message, index: number) {
</div> </div>
<Textarea <Textarea
v-model:value="message" v-model:value="message"
:rows="6" :rows="4"
class="border-none" class="border-none p-2"
placeholder="输入消息Enter发送Shift+Enter换行" placeholder="输入消息Enter发送Shift+Enter换行"
@press-enter="handleSendMessage" @press-enter="handleSendMessage"
/> />
</div> </div>
</Layout.Footer> </div>
</Layout> </div>
<Layout v-else class="bg-card relative w-[calc(100vw-300px-260px)]"> </div>
<Layout.Content> <div v-else class="bg-background relative">
<Empty description="请选择左侧的一个会话后开始" class="mt-[20%]" /> <Empty description="请选择左侧的一个会话后开始" class="mt-[20%]" />
</Layout.Content> </div>
</Layout>
</template> </template>