!470 【新增】:mall 客服选择并发送图片信息

Merge pull request !470 from puhui999/dev-crm
pull/471/head^2
芋道源码 2024-07-04 13:55:32 +00:00 committed by Gitee
commit cabfc5ccc0
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
60 changed files with 410 additions and 180 deletions

View File

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

View File

@ -6,75 +6,69 @@
<el-main class="kefu-content" style="overflow: visible"> <el-main class="kefu-content" style="overflow: visible">
<el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)"> <el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)">
<div ref="innerRef" class="w-[100%] pb-3px"> <div ref="innerRef" class="w-[100%] pb-3px">
<div <div v-for="(item, index) in messageList" :key="item.id" class="w-[100%]">
v-for="item in messageList" <div class="flex justify-center items-center mb-20px">
:key="item.id" <!-- 日期 -->
:class="[ <div
item.senderType === UserTypeEnum.MEMBER v-if="
? `ss-row-left` item.contentType !== KeFuMessageContentTypeEnum.SYSTEM && showTime(item, index)
: item.senderType === UserTypeEnum.ADMIN "
? `ss-row-right` class="date-message"
: '' >
]" {{ formatDate(item.createTime) }}
class="flex mb-20px w-[100%]" </div>
> <!-- 系统消息 -->
<el-avatar <view
v-show="item.senderType === UserTypeEnum.MEMBER" v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
:src="keFuConversation.userAvatar" class="system-message"
alt="avatar" >
/> {{ item.content }}
<div class="kefu-message p-10px"> </view>
<!-- TODO puhui999: 消息相关等后续完成后统一抽离封装 --> </div>
<!-- 文本消息 --> <div
<template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType"> :class="[
<div item.senderType === UserTypeEnum.MEMBER
v-dompurify-html="replaceEmoji(item.content)" ? `ss-row-left`
:class="[ : item.senderType === UserTypeEnum.ADMIN
item.senderType === UserTypeEnum.MEMBER ? `ss-row-right`
? `ml-10px` : ''
: item.senderType === UserTypeEnum.ADMIN ]"
? `mr-10px` class="flex mb-20px w-[100%]"
: '' >
]" <el-avatar
class="flex items-center" v-if="item.senderType === UserTypeEnum.MEMBER"
></div> :src="keFuConversation.userAvatar"
</template> alt="avatar"
<!-- 图片消息 --> />
<template v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"> <div
<div :class="{ 'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType }"
:class="[ class="p-10px"
item.senderType === UserTypeEnum.MEMBER >
? `ml-10px` <!-- 文本消息 -->
: item.senderType === UserTypeEnum.ADMIN <TextMessageItem :message="item" />
? `mr-10px` <!-- 图片消息 -->
: '' <ImageMessageItem :message="item" />
]" </div>
class="flex items-center" <el-avatar
> v-if="item.senderType === UserTypeEnum.ADMIN"
<el-image :src="item.senderAvatar"
:src="item.content" alt="avatar"
fit="contain" />
style="width: 200px; height: 200px"
@click="imagePreview(item.content)"
/>
</div>
</template>
</div> </div>
<el-avatar
v-show="item.senderType === UserTypeEnum.ADMIN"
:src="item.senderAvatar"
alt="avatar"
/>
</div> </div>
</div> </div>
</el-scrollbar> </el-scrollbar>
</el-main> </el-main>
<el-footer height="230px"> <el-footer height="230px">
<div class="h-[100%]"> <div class="h-[100%]">
<div class="chat-tools"> <div class="chat-tools flex items-center">
<EmojiSelectPopover @select-emoji="handleEmojiSelect" /> <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
<PictureSelectUpload
class="ml-15px mt-3px cursor-pointer"
@send-picture="handleSendPicture"
/>
</div> </div>
<el-input v-model="message" :rows="6" type="textarea" /> <el-input v-model="message" :rows="6" style="border-style: none" type="textarea" />
<div class="h-45px flex justify-end"> <div class="h-45px flex justify-end">
<el-button class="mt-10px" type="primary" @click="handleSendMessage"></el-button> <el-button class="mt-10px" type="primary" @click="handleSendMessage"></el-button>
</div> </div>
@ -88,19 +82,25 @@
import { ElScrollbar as ElScrollbarType } from 'element-plus' import { ElScrollbar as ElScrollbarType } from 'element-plus'
import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message' import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import EmojiSelectPopover from './EmojiSelectPopover.vue' import EmojiSelectPopover from './tools/EmojiSelectPopover.vue'
import { Emoji, replaceEmoji } from './emoji' import PictureSelectUpload from './tools/PictureSelectUpload.vue'
import { KeFuMessageContentTypeEnum } from './constants' import TextMessageItem from './message/TextMessageItem.vue'
import ImageMessageItem from './message/ImageMessageItem.vue'
import { Emoji } from './tools/emoji'
import { KeFuMessageContentTypeEnum } from './tools/constants'
import { isEmpty } from '@/utils/is' import { isEmpty } from '@/utils/is'
import { UserTypeEnum } from '@/utils/constants' import { UserTypeEnum } from '@/utils/constants'
import { createImageViewer } from '@/components/ImageViewer' import { formatDate } from '@/utils/formatTime'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
defineOptions({ name: 'KeFuMessageBox' }) defineOptions({ name: 'KeFuMessageBox' })
const messageTool = useMessage() const messageTool = useMessage()
const message = ref('') // const message = ref('') //
const messageList = ref<KeFuMessageRespVO[]>([]) // const messageList = ref<KeFuMessageRespVO[]>([]) //
const keFuConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // const keFuConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) //
const poller = ref<any>(null) // TODO puhui999: websocket
// TODO puhui999: // TODO puhui999:
const getMessageList = async (conversation: KeFuConversationRespVO) => { const getMessageList = async (conversation: KeFuConversationRespVO) => {
keFuConversation.value = conversation keFuConversation.value = conversation
@ -111,25 +111,37 @@ const getMessageList = async (conversation: KeFuConversationRespVO) => {
messageList.value = list.reverse() messageList.value = list.reverse()
// TODO puhui999: // TODO puhui999:
await scrollToBottom() await scrollToBottom()
// TODO puhui999:
if (!poller.value) {
poller.value = setInterval(() => {
getMessageList(conversation)
}, 1000)
}
} }
defineExpose({ getMessageList }) //
const refreshMessageList = () => {
if (!keFuConversation.value) {
return
}
getMessageList(keFuConversation.value)
}
defineExpose({ getMessageList, refreshMessageList })
// //
const showChatBox = computed(() => !isEmpty(keFuConversation.value)) const showChatBox = computed(() => !isEmpty(keFuConversation.value))
// //
const handleEmojiSelect = (item: Emoji) => { const handleEmojiSelect = (item: Emoji) => {
message.value += item.name message.value += item.name
} }
//
const handleSendPicture = async (picUrl: string) => {
//
const msg = {
conversationId: keFuConversation.value.id,
contentType: KeFuMessageContentTypeEnum.IMAGE,
content: picUrl
}
await sendMessage(msg)
}
// //
const handleSendMessage = async () => { const handleSendMessage = async () => {
// 1. // 1.
if (isEmpty(unref(message.value))) { if (isEmpty(unref(message.value))) {
messageTool.warning('请输入消息后再发送哦!') messageTool.warning('请输入消息后再发送哦!')
return
} }
// 2. // 2.
const msg = { const msg = {
@ -137,6 +149,11 @@ const handleSendMessage = async () => {
contentType: KeFuMessageContentTypeEnum.TEXT, contentType: KeFuMessageContentTypeEnum.TEXT,
content: message.value content: message.value
} }
await sendMessage(msg)
}
//
const sendMessage = async (msg: any) => {
await KeFuMessageApi.sendKeFuMessage(msg) await KeFuMessageApi.sendKeFuMessage(msg)
message.value = '' message.value = ''
// 3. // 3.
@ -152,20 +169,17 @@ const scrollToBottom = async () => {
await nextTick() await nextTick()
scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight) scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
} }
/**
/** 图预览 */ * 是否显示时间
const imagePreview = (imgUrl: string) => { * @param {*} item - 数据
createImageViewer({ * @param {*} index - 索引
urlList: [imgUrl] */
}) const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
} if (unref(messageList.value)[index + 1]) {
let dateString = dayjs(unref(messageList.value)[index + 1].createTime).fromNow()
// TODO puhui999: return dateString !== dayjs(unref(item).createTime).fromNow()
onBeforeUnmount(() => {
if (!poller.value) {
return
} }
clearInterval(poller.value) return false
}) })
</script> </script>
@ -241,14 +255,24 @@ onBeforeUnmount(() => {
transform: scale(1.03); transform: scale(1.03);
} }
} }
.date-message,
.system-message {
width: fit-content;
border-radius: 12rpx;
padding: 8rpx 16rpx;
margin-bottom: 16rpx;
background-color: #e8e8e8;
color: #999;
font-size: 24rpx;
}
} }
.chat-tools { .chat-tools {
width: 100%; width: 100%;
border: #e4e0e0 solid 1px; border: #e4e0e0 solid 1px;
border-radius: 10px;
height: 44px; height: 44px;
display: flex;
align-items: center;
} }
::v-deep(textarea) { ::v-deep(textarea) {

View File

@ -35,33 +35,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import { replaceEmoji } from '@/views/mall/promotion/kefu/components/emoji' import { useEmoji } from './tools/emoji'
import { formatDate, getNowDateTime } from '@/utils/formatTime' import { formatDate } from '@/utils/formatTime'
import { KeFuMessageContentTypeEnum } from '@/views/mall/promotion/kefu/components/constants' import { KeFuMessageContentTypeEnum } from './tools/constants'
defineOptions({ name: 'KeFuConversationBox' }) defineOptions({ name: 'KeFuConversationBox' })
const { replaceEmoji } = useEmoji()
const activeConversationIndex = ref(-1) // const activeConversationIndex = ref(-1) //
const conversationList = ref<KeFuConversationRespVO[]>([]) // const conversationList = ref<KeFuConversationRespVO[]>([]) //
const getConversationList = async () => { const getConversationList = async () => {
conversationList.value = await KeFuConversationApi.getConversationList() 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 }) defineExpose({ getConversationList })
const emits = defineEmits<{ const emits = defineEmits<{
@ -72,22 +55,6 @@ const openRightMessage = (item: KeFuConversationRespVO, index: number) => {
activeConversationIndex.value = index activeConversationIndex.value = index
emits('change', item) 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1720063872285" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6895"
width="200" height="200">
<path d="M782.16 880.98c-179.31 23.91-361 23.91-540.32 0C138.89 867.25 62 779.43 62 675.57V348.43c0-103.86 76.89-191.69 179.84-205.41 179.31-23.91 361-23.91 540.31 0C885.11 156.75 962 244.57 962 348.43v327.13c0 103.87-76.89 191.69-179.84 205.42z"
fill="#FF554D" p-id="6896"></path>
<path d="M226.11 596.86c-9.74 47.83 17.26 95.6 63.48 111.3C333.49 723.08 394.55 737 469.53 737c59.25 0 105.46-8.69 140.23-19.7 51.59-16.34 79.94-71.16 63.37-122.68-24.47-76.11-65.57-180.7-106.68-180.7-64.62 0-64.62 96.92-64.62 96.92S437.22 317 372.61 317c-82.11 0-117.85 139.12-146.5 279.86z"
fill="#FFFFFF" p-id="6897"></path>
<path d="M782 347m-60 0a60 60 0 1 0 120 0 60 60 0 1 0-120 0Z" fill="#FFBC55" p-id="6898"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

@ -0,0 +1,39 @@
<template>
<!-- 图片消息 -->
<template v-if="KeFuMessageContentTypeEnum.IMAGE === message.contentType">
<div
:class="[
message.senderType === UserTypeEnum.MEMBER
? `ml-10px`
: message.senderType === UserTypeEnum.ADMIN
? `mr-10px`
: ''
]"
>
<el-image
:src="message.content"
fit="contain"
style="width: 200px"
@click="imagePreview(message.content)"
/>
</div>
</template>
</template>
<script lang="ts" setup>
import { KeFuMessageContentTypeEnum } from '../tools/constants'
import { UserTypeEnum } from '@/utils/constants'
import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
import { createImageViewer } from '@/components/ImageViewer'
defineOptions({ name: 'ImageMessageItem' })
defineProps<{
message: KeFuMessageRespVO
}>()
/** 图预览 */
const imagePreview = (imgUrl: string) => {
createImageViewer({
urlList: [imgUrl]
})
}
</script>

View File

@ -0,0 +1,29 @@
<template>
<!-- 文本消息 -->
<template v-if="KeFuMessageContentTypeEnum.TEXT === message.contentType">
<div
v-dompurify-html="replaceEmoji(message.content)"
:class="[
message.senderType === UserTypeEnum.MEMBER
? `ml-10px`
: message.senderType === UserTypeEnum.ADMIN
? `mr-10px`
: ''
]"
class="flex items-center"
></div>
</template>
</template>
<script lang="ts" setup>
import { KeFuMessageContentTypeEnum } from '../tools/constants'
import { UserTypeEnum } from '@/utils/constants'
import { useEmoji } from '../tools/emoji'
import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
defineOptions({ name: 'TextMessageItem' })
defineProps<{
message: KeFuMessageRespVO
}>()
const { replaceEmoji } = useEmoji()
</script>

View File

@ -2,7 +2,7 @@
<template> <template>
<el-popover :width="500" placement="top" trigger="click"> <el-popover :width="500" placement="top" trigger="click">
<template #reference> <template #reference>
<Icon :size="30" class="ml-10px" icon="twemoji:grinning-face" /> <Icon :size="30" class="ml-10px cursor-pointer" icon="twemoji:grinning-face" />
</template> </template>
<ElScrollbar height="300px"> <ElScrollbar height="300px">
<ul class="ml-2 flex flex-wrap px-2"> <ul class="ml-2 flex flex-wrap px-2">
@ -26,8 +26,9 @@
<script lang="ts" setup> <script lang="ts" setup>
defineOptions({ name: 'EmojiSelectPopover' }) defineOptions({ name: 'EmojiSelectPopover' })
import { Emoji, getEmojiList } from './emoji' import { Emoji, useEmoji } from './emoji'
const { getEmojiList } = useEmoji()
const emojiList = computed(() => getEmojiList()) const emojiList = computed(() => getEmojiList())
const emits = defineEmits<{ const emits = defineEmits<{

View File

@ -0,0 +1,91 @@
<template>
<div>
<img :src="Picture" style="width: 35px; height: 35px" @click="selectAndUpload" />
</div>
</template>
<script lang="ts" setup>
import Picture from '@/views/mall/promotion/kefu/components/images/picture.svg'
import * as FileApi from '@/api/infra/file'
defineOptions({ name: 'PictureSelectUpload' })
const message = useMessage()
const emits = defineEmits<{
(e: 'send-picture', v: string): void
}>()
//
const selectAndUpload = async () => {
const files: any = await getFiles()
message.success('图片发送中请稍等。。。')
const res = await FileApi.updateFile({ file: files[0].file })
emits('send-picture', res.data)
}
/**
* 唤起文件选择窗口并获取选择的文件
* @param {Object} options - 配置选项
* @param {boolean} [options.multiple=true] - 是否支持多选
* @param {string} [options.accept=''] - 文件上传格式限制
* @param {number} [options.limit=1] - 单次上传最大文件数
* @param {number} [options.fileSize=500] - 单个文件大小限制单位MB
* @returns {Promise<Array>} 选择的文件列表每个文件带有一个uid
*/
async function getFiles(options = {}) {
const { multiple, accept, limit, fileSize } = {
multiple: true,
accept: 'image/jpeg, image/png, image/gif', //
limit: 1,
fileSize: 500,
...options
}
//
const input = document.createElement('input')
input.type = 'file'
input.style.display = 'none'
if (multiple) input.multiple = true
if (accept) input.accept = accept
//
document.body.appendChild(input)
//
input.click()
// change
try {
const files = await new Promise((resolve, reject) => {
input.addEventListener('change', (event: any) => {
const filesArray = Array.from(event?.target?.files || [])
//
document.body.removeChild(input)
//
if (filesArray.length > limit) {
reject({ errorType: 'limit', files: filesArray })
return
}
//
const oversizedFiles = filesArray.filter((file: File) => file.size / 1024 ** 2 > fileSize)
if (oversizedFiles.length > 0) {
reject({ errorType: 'fileSize', files: oversizedFiles })
return
}
// uid
const fileList = filesArray.map((file, index) => ({ file, uid: Date.now() + index }))
resolve(fileList)
})
})
return files
} catch (error) {
console.error('选择文件出错:', error)
throw error
}
}
</script>
<style lang="scss" scoped></style>

View File

@ -1,3 +1,4 @@
// 客服消息类型枚举类
export const KeFuMessageContentTypeEnum = { export const KeFuMessageContentTypeEnum = {
TEXT: 1, // 文本消息 TEXT: 1, // 文本消息
IMAGE: 2, // 图片消息 IMAGE: 2, // 图片消息
@ -8,3 +9,8 @@ export const KeFuMessageContentTypeEnum = {
PRODUCT: 10, // 商品消息 PRODUCT: 10, // 商品消息
ORDER: 11 // 订单消息" ORDER: 11 // 订单消息"
} }
// Promotion 的 WebSocket 消息类型枚举类
export const WebSocketMessageTypeConstants = {
KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型
KEFU_MESSAGE_ADMIN_READ: 'kefu_message_read_status_change' // 客服消息管理员已读
}

View File

@ -1,4 +1,6 @@
export const emojiList = [ import { isEmpty } from '@/utils/is'
const emojiList = [
{ name: '[笑掉牙]', file: 'xiaodiaoya.png' }, { name: '[笑掉牙]', file: 'xiaodiaoya.png' },
{ name: '[可爱]', file: 'keai.png' }, { name: '[可爱]', file: 'keai.png' },
{ name: '[冷酷]', file: 'lengku.png' }, { name: '[冷酷]', file: 'lengku.png' },
@ -54,53 +56,60 @@ export interface Emoji {
url: string url: string
} }
export const emojiPage = {} export const useEmoji = () => {
emojiList.forEach((item, index) => { const emojiPathList = ref<any[]>([])
if (!emojiPage[Math.floor(index / 30) + 1]) { // 加载本地图片
emojiPage[Math.floor(index / 30) + 1] = [] const getStaticEmojiPath = async () => {
} const pathList = import.meta.glob(
emojiPage[Math.floor(index / 30) + 1].push(item) '@/views/mall/promotion/kefu/components/images/*.{png,jpg,jpeg,svg}'
}) )
for (const path in pathList) {
// 后端上传地址 const imageModule: any = await pathList[path]()
const staticUrl = import.meta.env.VITE_STATIC_URL emojiPathList.value.push(imageModule.default)
// 后缀
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 // 初始化
} onMounted(async () => {
if (isEmpty(emojiPathList.value)) {
// 获得所有表情 await getStaticEmojiPath()
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
} }
})
// 处理表情
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="${emojiFile}"/>`
)
})
}
}
return newData
} }
return false
// 获得所有表情
function getEmojiList(): Emoji[] {
return emojiList.map((item) => ({
url: selEmojiFile(item.name),
name: item.name
})) as Emoji[]
}
function selEmojiFile(name: string) {
for (const emoji of emojiList) {
if (emoji.name === name) {
return emojiPathList.value.find((item: string) => item.indexOf(emoji.file) > -1)
}
}
return false
}
return { replaceEmoji, getEmojiList }
} }

View File

@ -16,19 +16,77 @@
<script lang="ts" setup> <script lang="ts" setup>
import { KeFuChatBox, KeFuConversationBox } from './components' import { KeFuChatBox, KeFuConversationBox } from './components'
import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import { getAccessToken } from '@/utils/auth'
import { useWebSocket } from '@vueuse/core'
import { WebSocketMessageTypeConstants } from '@/views/mall/promotion/kefu/components/tools/constants'
defineOptions({ name: 'KeFu' }) defineOptions({ name: 'KeFu' })
const message = useMessage()
// //
const keFuChatBoxRef = ref<InstanceType<typeof KeFuChatBox>>() const keFuChatBoxRef = ref<InstanceType<typeof KeFuChatBox>>()
const handleChange = (conversation: KeFuConversationRespVO) => { const handleChange = (conversation: KeFuConversationRespVO) => {
keFuChatBoxRef.value?.getMessageList(conversation) keFuChatBoxRef.value?.getMessageList(conversation)
} }
// //======================= websocket start=======================
const server = ref(
(import.meta.env.VITE_BASE_URL + '/infra/ws/').replace('http', 'ws') +
'?token=' +
getAccessToken()
) // WebSocket
/** 发起 WebSocket 连接 */
const { data, close, open } = useWebSocket(server.value, {
autoReconnect: false,
heartbeat: true
})
watchEffect(() => {
if (!data.value) {
return
}
try {
// 1.
if (data.value === 'pong') {
// state.recordList.push({
// text: '',
// time: new Date().getTime()
// })
return
}
// 2.1 type
const jsonMessage = JSON.parse(data.value)
const type = jsonMessage.type
if (!type) {
message.error('未知的消息类型:' + data.value)
return
}
// 2.2 KEFU_MESSAGE_TYPE
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
//
getConversationList()
//
keFuChatBoxRef.value?.refreshMessageList()
return
}
} catch (error) {
console.error(error)
}
})
//======================= websocket end=======================
//
const keFuConversationRef = ref<InstanceType<typeof KeFuConversationBox>>() const keFuConversationRef = ref<InstanceType<typeof KeFuConversationBox>>()
onMounted(() => { const getConversationList = () => {
keFuConversationRef.value?.getConversationList() keFuConversationRef.value?.getConversationList()
}
onMounted(() => {
getConversationList()
// websocket
open()
})
onBeforeUnmount(() => {
// websocket
close()
}) })
</script> </script>