!468 【新增】:mall 客服

Merge pull request !468 from puhui999/dev-crm
pull/470/head
芋道源码 2024-07-02 12:40:46 +00:00 committed by Gitee
commit 8d759b2a0b
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
12 changed files with 740 additions and 2 deletions

View File

@ -29,5 +29,8 @@ VITE_BASE_PATH=/
# 商城H5会员端域名
VITE_MALL_H5_DOMAIN='http://localhost:3000'
# 客户端静态资源地址 空=默认使用服务端指定的CDN资源地址前缀 | local=本地 | http(s)://xxx.xxx=自定义静态资源地址前缀
VITE_STATIC_URL = https://file.sheepjs.com
# 验证码的开关
VITE_APP_CAPTCHA_ENABLE=false

View File

@ -0,0 +1,71 @@
import request from '@/config/axios'
export interface KeFuConversationRespVO {
/**
*
*/
id: number
/**
*
*/
userId: number
/**
*
*/
userAvatar: string
/**
*
*/
userNickname: string
/**
*
*/
lastMessageTime: Date
/**
*
*/
lastMessageContent: string
/**
*
*/
lastMessageContentType: number
/**
*
*/
adminPinned: boolean
/**
*
*/
userDeleted: boolean
/**
*
*/
adminDeleted: boolean
/**
*
*/
adminUnreadMessageCount: number
/**
*
*/
createTime?: string
}
// 客服会话 API
export const KeFuConversationApi = {
// 获得客服会话列表
getConversationList: async () => {
return await request.get({ url: '/promotion/kefu-conversation/list' })
},
// 客服会话置顶
updateConversationPinned: async (data: any) => {
return await request.put({
url: '/promotion/kefu-conversation/update-conversation-pinned',
data
})
},
// 删除客服会话
deleteConversation: async (id: number) => {
return await request.get({ url: '/promotion/kefu-conversation/delete?id' + id })
}
}

View File

@ -0,0 +1,70 @@
import request from '@/config/axios'
export interface KeFuMessageRespVO {
/**
*
*/
id: number
/**
*
*/
conversationId: number
/**
*
*/
senderId: number
/**
*
*/
senderAvatar: string
/**
*
*/
senderType: number
/**
*
*/
receiverId: number
/**
*
*/
receiverType: number
/**
*
*/
contentType: number
/**
*
*/
content: string
/**
*
*/
readStatus: boolean
/**
*
*/
createTime: Date
}
// 客服会话 API
export const KeFuMessageApi = {
// 发送客服消息
sendKeFuMessage: async (data: any) => {
return await request.post({
url: '/promotion/kefu-message/send',
data
})
},
// 更新客服消息已读状态
updateKeFuMessageReadStatus: async (data: any) => {
return await request.put({
url: '/promotion/kefu-message/update-read-status',
data
})
},
// 获得消息分页数据
getKeFuMessagePage: async (params: any) => {
return await request.get({ url: '/promotion/kefu-message/page', params })
}
}

View File

@ -29,8 +29,8 @@
:autosize="{ minRows: 2, maxRows: 4 }"
:disabled="!getIsOpen"
clearable
type="textarea"
placeholder="请输入你要发送的消息"
type="textarea"
/>
<el-select v-model="sendUserId" class="mt-4" placeholder="请选择发送人">
<el-option key="" label="所有人" value="" />
@ -71,7 +71,7 @@
<script lang="ts" setup>
import { formatDate } from '@/utils/formatTime'
import { useWebSocket } from '@vueuse/core'
import { getAccessToken } from '@/utils/auth'
// import { getAccessToken } from '@/utils/auth'
import * as UserApi from '@/api/system/user'
defineOptions({ name: 'InfraWebSocket' })

View File

@ -0,0 +1,40 @@
<!-- emoji 表情选择组件 -->
<template>
<el-popover :width="500" placement="top" trigger="click">
<template #reference>
<Icon :size="30" class="ml-10px" icon="twemoji:grinning-face" />
</template>
<ElScrollbar height="300px">
<ul class="ml-2 flex flex-wrap px-2">
<li
v-for="(item, index) in emojiList"
:key="index"
:style="{
borderColor: 'var(--el-color-primary)',
color: 'var(--el-color-primary)'
}"
:title="item.name"
class="icon-item mr-2 mt-1 w-1/10 flex cursor-pointer items-center justify-center border border-solid p-2"
@click="handleSelect(item)"
>
<img :src="item.url" style="width: 24px; height: 24px" />
</li>
</ul>
</ElScrollbar>
</el-popover>
</template>
<script lang="ts" setup>
defineOptions({ name: 'EmojiSelectPopover' })
import { Emoji, getEmojiList } from './emoji'
const emojiList = computed(() => getEmojiList())
const emits = defineEmits<{
(e: 'select-emoji', v: Emoji)
}>()
const handleSelect = (item: Emoji) => {
// emoji 便
emits('select-emoji', item)
}
</script>

View File

@ -0,0 +1,258 @@
<template>
<el-container v-if="showChatBox" class="kefu">
<el-header>
<div class="kefu-title">{{ keFuConversation.userNickname }}</div>
</el-header>
<el-main class="kefu-content" style="overflow: visible">
<el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)">
<div ref="innerRef" class="w-[100%] pb-3px">
<div
v-for="item in messageList"
:key="item.id"
:class="[
item.senderType === UserTypeEnum.MEMBER
? `ss-row-left`
: item.senderType === UserTypeEnum.ADMIN
? `ss-row-right`
: ''
]"
class="flex mb-20px w-[100%]"
>
<el-avatar
v-show="item.senderType === UserTypeEnum.MEMBER"
:src="keFuConversation.userAvatar"
alt="avatar"
/>
<div class="kefu-message p-10px">
<!-- TODO puhui999: 消息相关等后续完成后统一抽离封装 -->
<!-- 文本消息 -->
<template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType">
<div
v-dompurify-html="replaceEmoji(item.content)"
:class="[
item.senderType === UserTypeEnum.MEMBER
? `ml-10px`
: item.senderType === UserTypeEnum.ADMIN
? `mr-10px`
: ''
]"
class="flex items-center"
></div>
</template>
<!-- 图片消息 -->
<template v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType">
<div
:class="[
item.senderType === UserTypeEnum.MEMBER
? `ml-10px`
: item.senderType === UserTypeEnum.ADMIN
? `mr-10px`
: ''
]"
class="flex items-center"
>
<el-image
:src="item.content"
fit="contain"
style="width: 200px; height: 200px"
@click="imagePreview(item.content)"
/>
</div>
</template>
</div>
<el-avatar
v-show="item.senderType === UserTypeEnum.ADMIN"
:src="item.senderAvatar"
alt="avatar"
/>
</div>
</div>
</el-scrollbar>
</el-main>
<el-footer height="230px">
<div class="h-[100%]">
<div class="chat-tools">
<EmojiSelectPopover @select-emoji="handleEmojiSelect" />
</div>
<el-input v-model="message" :rows="6" type="textarea" />
<div class="h-45px flex justify-end">
<el-button class="mt-10px" type="primary" @click="handleSendMessage"></el-button>
</div>
</div>
</el-footer>
</el-container>
<el-empty v-else description="请选择左侧的一个会话后开始" />
</template>
<script lang="ts" setup>
import { ElScrollbar as ElScrollbarType } from 'element-plus'
import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import EmojiSelectPopover from './EmojiSelectPopover.vue'
import { Emoji, replaceEmoji } from './emoji'
import { KeFuMessageContentTypeEnum } from './constants'
import { isEmpty } from '@/utils/is'
import { UserTypeEnum } from '@/utils/constants'
import { createImageViewer } from '@/components/ImageViewer'
defineOptions({ name: 'KeFuMessageBox' })
const messageTool = useMessage()
const message = ref('') //
const messageList = ref<KeFuMessageRespVO[]>([]) //
const keFuConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) //
const poller = ref<any>(null) // TODO puhui999: websocket
// TODO puhui999:
const getMessageList = async (conversation: KeFuConversationRespVO) => {
keFuConversation.value = conversation
const { list } = await KeFuMessageApi.getKeFuMessagePage({
pageNo: 1,
conversationId: conversation.id
})
messageList.value = list.reverse()
// TODO puhui999:
await scrollToBottom()
// TODO puhui999:
if (!poller.value) {
poller.value = setInterval(() => {
getMessageList(conversation)
}, 1000)
}
}
defineExpose({ getMessageList })
//
const showChatBox = computed(() => !isEmpty(keFuConversation.value))
//
const handleEmojiSelect = (item: Emoji) => {
message.value += item.name
}
//
const handleSendMessage = async () => {
// 1.
if (isEmpty(unref(message.value))) {
messageTool.warning('请输入消息后再发送哦!')
}
// 2.
const msg = {
conversationId: keFuConversation.value.id,
contentType: KeFuMessageContentTypeEnum.TEXT,
content: message.value
}
await KeFuMessageApi.sendKeFuMessage(msg)
message.value = ''
// 3.
await getMessageList(keFuConversation.value)
//
await scrollToBottom()
}
const innerRef = ref<HTMLDivElement>()
const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
//
const scrollToBottom = async () => {
await nextTick()
scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
}
/** 图预览 */
const imagePreview = (imgUrl: string) => {
createImageViewer({
urlList: [imgUrl]
})
}
// TODO puhui999:
onBeforeUnmount(() => {
if (!poller.value) {
return
}
clearInterval(poller.value)
})
</script>
<style lang="scss" scoped>
.kefu {
&-title {
border-bottom: #e4e0e0 solid 1px;
height: 60px;
line-height: 60px;
}
&-content {
.ss-row-left {
justify-content: flex-start;
.kefu-message {
margin-left: 20px;
position: relative;
&::before {
content: '';
width: 10px;
height: 10px;
left: -19px;
top: calc(50% - 10px);
position: absolute;
border-left: 5px solid transparent;
border-bottom: 5px solid transparent;
border-top: 5px solid transparent;
border-right: 5px solid #ffffff;
}
}
}
.ss-row-right {
justify-content: flex-end;
.kefu-message {
margin-right: 20px;
position: relative;
&::after {
content: '';
width: 10px;
height: 10px;
right: -19px;
top: calc(50% - 10px);
position: absolute;
border-left: 5px solid #ffffff;
border-bottom: 5px solid transparent;
border-top: 5px solid transparent;
border-right: 5px solid transparent;
}
}
}
//
.kefu-message {
color: #333;
border-radius: 5px;
box-shadow: 3px 5px 15px rgba(0, 0, 0, 0.2);
padding: 5px 10px;
width: auto;
max-width: 50%;
text-align: left;
display: inline-block !important;
position: relative;
word-break: break-all;
background-color: #ffffff;
transition: all 0.2s;
&:hover {
transform: scale(1.03);
}
}
}
.chat-tools {
width: 100%;
border: #e4e0e0 solid 1px;
height: 44px;
display: flex;
align-items: center;
}
::v-deep(textarea) {
resize: none;
}
}
</style>

View File

@ -0,0 +1,114 @@
<template>
<div class="kefu">
<div
v-for="(item, index) in conversationList"
:key="item.id"
:class="{ active: index === activeConversationIndex }"
class="kefu-conversation flex justify-between items-center"
@click="openRightMessage(item, index)"
>
<div class="flex justify-center items-center w-100%">
<el-avatar :src="item.userAvatar" alt="avatar" />
<div class="ml-10px w-100%">
<div class="flex justify-between items-center w-100%">
<span>{{ item.userNickname }}</span>
<span class="color-[#989EA6]">
{{ formatDate(item.lastMessageTime) }}
</span>
</div>
<!-- 文本消息 -->
<template v-if="KeFuMessageContentTypeEnum.TEXT === item.lastMessageContentType">
<div
v-dompurify-html="replaceEmoji(item.lastMessageContent)"
class="last-message flex items-center color-[#989EA6]"
></div>
</template>
<!-- 图片消息 -->
<template v-if="KeFuMessageContentTypeEnum.IMAGE === item.lastMessageContentType">
<div class="last-message flex items-center color-[#989EA6]">图片消息</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import { replaceEmoji } from '@/views/mall/promotion/kefu/components/emoji'
import { formatDate, getNowDateTime } from '@/utils/formatTime'
import { KeFuMessageContentTypeEnum } from '@/views/mall/promotion/kefu/components/constants'
defineOptions({ name: 'KeFuConversationBox' })
const activeConversationIndex = ref(-1) //
const conversationList = ref<KeFuConversationRespVO[]>([]) //
const getConversationList = async () => {
conversationList.value = await KeFuConversationApi.getConversationList()
//
for (let i = 0; i < 5; i++) {
conversationList.value.push({
id: 1,
userId: 283,
userAvatar:
'https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTKMezSxtOImrC9lbhwHiazYwck3xwrEcO7VJfG6WQo260whaeVNoByE5RreiaGsGfOMlIiaDhSaA991w/132',
userNickname: '辉辉鸭' + i,
lastMessageTime: getNowDateTime(),
lastMessageContent:
'[爱心][爱心]你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇',
lastMessageContentType: 1,
adminPinned: false,
userDeleted: false,
adminDeleted: false,
adminUnreadMessageCount: 19
})
}
}
defineExpose({ getConversationList })
const emits = defineEmits<{
(e: 'change', v: KeFuConversationRespVO): void
}>()
//
const openRightMessage = (item: KeFuConversationRespVO, index: number) => {
activeConversationIndex.value = index
emits('change', item)
}
const poller = ref<any>(null) // TODO puhui999: websocket
onMounted(() => {
// TODO puhui999:
if (!poller.value) {
poller.value = setInterval(() => {
getConversationList()
}, 1000)
}
})
// TODO puhui999:
onBeforeUnmount(() => {
if (!poller.value) {
return
}
clearInterval(poller.value)
})
</script>
<style lang="scss" scoped>
.kefu {
&-conversation {
height: 60px;
padding: 10px;
background-color: #fff;
transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
.last-message {
width: 200px;
overflow: hidden; //
white-space: nowrap; //
text-overflow: ellipsis; //
}
}
.active {
border-left: 5px #3271ff solid;
background-color: #eff0f1;
}
}
</style>

View File

@ -0,0 +1,10 @@
export const KeFuMessageContentTypeEnum = {
TEXT: 1, // 文本消息
IMAGE: 2, // 图片消息
VOICE: 3, // 语音消息
VIDEO: 4, // 视频消息
SYSTEM: 5, // 系统消息
// ========== 商城特殊消息 ==========
PRODUCT: 10, // 商品消息
ORDER: 11 // 订单消息"
}

View File

@ -0,0 +1,106 @@
export const emojiList = [
{ name: '[笑掉牙]', file: 'xiaodiaoya.png' },
{ name: '[可爱]', file: 'keai.png' },
{ name: '[冷酷]', file: 'lengku.png' },
{ name: '[闭嘴]', file: 'bizui.png' },
{ name: '[生气]', file: 'shengqi.png' },
{ name: '[惊恐]', file: 'jingkong.png' },
{ name: '[瞌睡]', file: 'keshui.png' },
{ name: '[大笑]', file: 'daxiao.png' },
{ name: '[爱心]', file: 'aixin.png' },
{ name: '[坏笑]', file: 'huaixiao.png' },
{ name: '[飞吻]', file: 'feiwen.png' },
{ name: '[疑问]', file: 'yiwen.png' },
{ name: '[开心]', file: 'kaixin.png' },
{ name: '[发呆]', file: 'fadai.png' },
{ name: '[流泪]', file: 'liulei.png' },
{ name: '[汗颜]', file: 'hanyan.png' },
{ name: '[惊悚]', file: 'jingshu.png' },
{ name: '[困~]', file: 'kun.png' },
{ name: '[心碎]', file: 'xinsui.png' },
{ name: '[天使]', file: 'tianshi.png' },
{ name: '[晕]', file: 'yun.png' },
{ name: '[啊]', file: 'a.png' },
{ name: '[愤怒]', file: 'fennu.png' },
{ name: '[睡着]', file: 'shuizhuo.png' },
{ name: '[面无表情]', file: 'mianwubiaoqing.png' },
{ name: '[难过]', file: 'nanguo.png' },
{ name: '[犯困]', file: 'fankun.png' },
{ name: '[好吃]', file: 'haochi.png' },
{ name: '[呕吐]', file: 'outu.png' },
{ name: '[龇牙]', file: 'ziya.png' },
{ name: '[懵比]', file: 'mengbi.png' },
{ name: '[白眼]', file: 'baiyan.png' },
{ name: '[饿死]', file: 'esi.png' },
{ name: '[凶]', file: 'xiong.png' },
{ name: '[感冒]', file: 'ganmao.png' },
{ name: '[流汗]', file: 'liuhan.png' },
{ name: '[笑哭]', file: 'xiaoku.png' },
{ name: '[流口水]', file: 'liukoushui.png' },
{ name: '[尴尬]', file: 'ganga.png' },
{ name: '[惊讶]', file: 'jingya.png' },
{ name: '[大惊]', file: 'dajing.png' },
{ name: '[不好意思]', file: 'buhaoyisi.png' },
{ name: '[大闹]', file: 'danao.png' },
{ name: '[不可思议]', file: 'bukesiyi.png' },
{ name: '[爱你]', file: 'aini.png' },
{ name: '[红心]', file: 'hongxin.png' },
{ name: '[点赞]', file: 'dianzan.png' },
{ name: '[恶魔]', file: 'emo.png' }
]
export interface Emoji {
name: string
url: string
}
export const emojiPage = {}
emojiList.forEach((item, index) => {
if (!emojiPage[Math.floor(index / 30) + 1]) {
emojiPage[Math.floor(index / 30) + 1] = []
}
emojiPage[Math.floor(index / 30) + 1].push(item)
})
// 后端上传地址
const staticUrl = import.meta.env.VITE_STATIC_URL
// 后缀
const suffix = '/static/img/chat/emoji/'
// 处理表情
export function replaceEmoji(data: string) {
let newData = data
if (typeof newData !== 'object') {
const reg = /\[(.+?)\]/g // [] 中括号
const zhEmojiName = newData.match(reg)
if (zhEmojiName) {
zhEmojiName.forEach((item) => {
const emojiFile = selEmojiFile(item)
newData = newData.replace(
item,
`<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${
staticUrl + suffix + emojiFile
}"/>`
)
})
}
}
return newData
}
// 获得所有表情
export function getEmojiList(): Emoji[] {
return emojiList.map((item) => ({
url: staticUrl + suffix + item.file,
name: item.name
})) as Emoji[]
}
function selEmojiFile(name: string) {
for (const index in emojiList) {
if (emojiList[index].name === name) {
return emojiList[index].file
}
}
return false
}

View File

@ -0,0 +1,5 @@
import KeFuConversationBox from './KeFuConversationBox.vue'
import KeFuChatBox from './KeFuChatBox.vue'
import * as Constants from './constants'
export { KeFuConversationBox, KeFuChatBox, Constants }

View File

@ -0,0 +1,60 @@
<template>
<el-row :gutter="10">
<el-col :span="8">
<ContentWrap>
<KeFuConversationBox ref="keFuConversationRef" @change="handleChange" />
</ContentWrap>
</el-col>
<el-col :span="16">
<ContentWrap>
<KeFuChatBox ref="keFuChatBoxRef" />
</ContentWrap>
</el-col>
</el-row>
</template>
<script lang="ts" setup>
import { KeFuChatBox, KeFuConversationBox } from './components'
import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
defineOptions({ name: 'KeFu' })
//
const keFuChatBoxRef = ref<InstanceType<typeof KeFuChatBox>>()
const handleChange = (conversation: KeFuConversationRespVO) => {
keFuChatBoxRef.value?.getMessageList(conversation)
}
//
const keFuConversationRef = ref<InstanceType<typeof KeFuConversationBox>>()
onMounted(() => {
keFuConversationRef.value?.getConversationList()
})
</script>
<style lang="scss">
.kefu {
height: calc(100vh - 165px);
overflow: auto; /* 确保内容可滚动 */
}
/* 定义滚动条样式 */
::-webkit-scrollbar {
width: 10px;
height: 6px;
}
/*定义滚动条轨道 内阴影+圆角*/
::-webkit-scrollbar-track {
box-shadow: inset 0 0 0px rgba(240, 240, 240, 0.5);
border-radius: 10px;
background-color: #fff;
}
/*定义滑块 内阴影+圆角*/
::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 0px rgba(240, 240, 240, 0.5);
background-color: rgba(240, 240, 240, 0.5);
}
</style>

1
types/env.d.ts vendored
View File

@ -19,6 +19,7 @@ interface ImportMetaEnv {
readonly VITE_UPLOAD_URL: string
readonly VITE_API_URL: string
readonly VITE_BASE_PATH: string
readonly VITE_STATIC_URL: string
readonly VITE_DROP_DEBUGGER: string
readonly VITE_DROP_CONSOLE: string
readonly VITE_SOURCEMAP: string