Merge remote-tracking branch 'origin/dev' into dev

pull/471/head^2
cherishsince 2024-07-05 09:09:49 +08:00
commit ca64760dd0
68 changed files with 465 additions and 242 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

@ -43,8 +43,8 @@ export const ChatConversationApi = {
}, },
// 删除【我的】所有对话,置顶除外 // 删除【我的】所有对话,置顶除外
deleteMyAllExceptPinned: async () => { deleteChatConversationMyByUnpinned: async () => {
return await request.delete({ url: `/ai/chat/conversation/delete-my-all-except-pinned` }) return await request.delete({ url: `/ai/chat/conversation/delete-by-unpinned` })
}, },
// 获得【我的】聊天对话列表 // 获得【我的】聊天对话列表

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -29,7 +29,7 @@ const download = {
html: (data: Blob, fileName: string) => { html: (data: Blob, fileName: string) => {
download0(data, fileName, 'text/html') download0(data, fileName, 'text/html')
}, },
// 下载 MarkdownView 方法 // 下载 Markdown 方法
markdown: (data: Blob, fileName: string) => { markdown: (data: Blob, fileName: string) => {
download0(data, fileName, 'text/markdown') download0(data, fileName, 'text/markdown')
} }

View File

@ -1,9 +1,8 @@
<!-- AI 对话 --> <!-- AI 对话 -->
<template> <template>
<el-aside width="260px" class="conversation-container" style="height: 100%;"> <el-aside width="260px" class="conversation-container" style="height: 100%">
<!-- 左顶部对话 --> <!-- 左顶部对话 -->
<div style="height: 100%;"> <div style="height: 100%">
<el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation"> <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
<Icon icon="ep:plus" class="mr-5px" /> <Icon icon="ep:plus" class="mr-5px" />
新建对话 新建对话
@ -24,11 +23,13 @@
<!-- 左中间对话列表 --> <!-- 左中间对话列表 -->
<div class="conversation-list"> <div class="conversation-list">
<el-empty v-if="loading" description="." :v-loading="loading" /> <el-empty v-if="loading" description="." :v-loading="loading" />
<div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey"> <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
<div class="conversation-item classify-title" v-if="conversationMap[conversationKey].length"> <div
class="conversation-item classify-title"
v-if="conversationMap[conversationKey].length"
>
<el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text> <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
</div> </div>
<div <div
@ -40,7 +41,9 @@
@mouseout="hoverConversationId = ''" @mouseout="hoverConversationId = ''"
> >
<div <div
:class="conversation.id === activeConversationId ? 'conversation active' : 'conversation'" :class="
conversation.id === activeConversationId ? 'conversation active' : 'conversation'
"
> >
<div class="title-wrapper"> <div class="title-wrapper">
<img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" /> <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" />
@ -66,9 +69,8 @@
</div> </div>
</div> </div>
<!-- 底部站位 --> <!-- 底部站位 -->
<div style="height: 160px; width: 100%;"></div> <div style="height: 160px; width: 100%"></div>
</div> </div>
</div> </div>
<!-- 左底部工具栏 --> <!-- 左底部工具栏 -->
@ -90,15 +92,14 @@
<el-drawer v-model="drawer" title="角色仓库" size="754px"> <el-drawer v-model="drawer" title="角色仓库" size="754px">
<Role /> <Role />
</el-drawer> </el-drawer>
</el-aside> </el-aside>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
import {ref} from "vue"; import { ref } from 'vue'
import Role from "@/views/ai/chat/role/index.vue"; import Role from '@/views/ai/chat/role/index.vue'
import {Bottom, Top} from "@element-plus/icons-vue"; import { Bottom, Top } from '@element-plus/icons-vue'
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg' import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
const message = useMessage() // const message = useMessage() //
@ -138,7 +139,7 @@ const searchConversation = async (e) => {
conversationMap.value = await conversationTimeGroup(conversationList.value) conversationMap.value = await conversationTimeGroup(conversationList.value)
} else { } else {
// //
const filterValues = conversationList.value.filter(item => { const filterValues = conversationList.value.filter((item) => {
return item.title.includes(searchName.value.trim()) return item.title.includes(searchName.value.trim())
}) })
conversationMap.value = await conversationTimeGroup(filterValues) conversationMap.value = await conversationTimeGroup(filterValues)
@ -150,7 +151,7 @@ const searchConversation = async (e) => {
*/ */
const handleConversationClick = async (id: string) => { const handleConversationClick = async (id: string) => {
// //
const filterConversation = conversationList.value.filter(item => { const filterConversation = conversationList.value.filter((item) => {
return item.id === id return item.id === id
}) })
// onConversationClick // onConversationClick
@ -211,20 +212,20 @@ const getChatConversationList = async () => {
const conversationTimeGroup = async (list: ChatConversationVO[]) => { const conversationTimeGroup = async (list: ChatConversationVO[]) => {
// (30) // (30)
const groupMap = { const groupMap = {
'置顶': [], 置顶: [],
'今天': [], 今天: [],
'一天前': [], 一天前: [],
'三天前': [], 三天前: [],
'七天前': [], 七天前: [],
'三十天前': [] 三十天前: []
} }
// //
const now = Date.now(); const now = Date.now()
// //
const oneDay = 24 * 60 * 60 * 1000; const oneDay = 24 * 60 * 60 * 1000
const threeDays = 3 * oneDay; const threeDays = 3 * oneDay
const sevenDays = 7 * oneDay; const sevenDays = 7 * oneDay
const thirtyDays = 30 * oneDay; const thirtyDays = 30 * oneDay
for (const conversation: ChatConversationVO of list) { for (const conversation: ChatConversationVO of list) {
// //
if (conversation.pinned) { if (conversation.pinned) {
@ -232,7 +233,7 @@ const conversationTimeGroup = async (list: ChatConversationVO[]) => {
continue continue
} }
// //
const diff = now - conversation.updateTime; const diff = now - conversation.updateTime
// //
if (diff < oneDay) { if (diff < oneDay) {
groupMap['今天'].push(conversation) groupMap['今天'].push(conversation)
@ -285,7 +286,7 @@ const updateConversationTitle = async (conversation: ChatConversationVO) => {
// 3. // 3.
await getChatConversationList() await getChatConversationList()
// 4. // 4.
const filterConversationList = conversationList.value.filter(item => { const filterConversationList = conversationList.value.filter((item) => {
return item.id === conversation.id return item.id === conversation.id
}) })
if (filterConversationList.length > 0) { if (filterConversationList.length > 0) {
@ -310,8 +311,7 @@ const deleteChatConversation = async (conversation: ChatConversationVO) => {
await getChatConversationList() await getChatConversationList()
// //
emits('onConversationDelete', conversation) emits('onConversationDelete', conversation)
} catch { } catch {}
}
} }
/** /**
@ -343,16 +343,13 @@ const handleRoleRepository = async () => {
*/ */
const handleClearConversation = async () => { const handleClearConversation = async () => {
// TODO @fan使 await message.confirm( 使 await // TODO @fan使 await message.confirm( 使 await
ElMessageBox.confirm( ElMessageBox.confirm('确认后对话会全部清空,置顶的对话除外。', '确认提示', {
'确认后对话会全部清空,置顶的对话除外。',
'确认提示',
{
confirmButtonText: '确认', confirmButtonText: '确认',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning', type: 'warning'
}) })
.then(async () => { .then(async () => {
await ChatConversationApi.deleteMyAllExceptPinned() await ChatConversationApi.deleteChatConversationMyByUnpinned()
ElMessage({ ElMessage({
message: '操作成功!', message: '操作成功!',
type: 'success' type: 'success'
@ -364,8 +361,7 @@ const handleClearConversation = async () => {
// //
emits('onConversationClear') emits('onConversationClear')
}) })
.catch(() => { .catch(() => {})
})
} }
// ============ onMounted // ============ onMounted
@ -394,11 +390,9 @@ onMounted(async () => {
} }
} }
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.conversation-container { .conversation-container {
position: relative; position: relative;
display: flex; display: flex;

View File

@ -6,9 +6,26 @@
<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 v-for="(item, index) in messageList" :key="item.id" class="w-[100%]">
<div class="flex justify-center items-center mb-20px">
<!-- 日期 -->
<div
v-if="
item.contentType !== KeFuMessageContentTypeEnum.SYSTEM && showTime(item, index)
"
class="date-message"
>
{{ formatDate(item.createTime) }}
</div>
<!-- 系统消息 -->
<view
v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
class="system-message"
>
{{ item.content }}
</view>
</div>
<div <div
v-for="item in messageList"
:key="item.id"
:class="[ :class="[
item.senderType === UserTypeEnum.MEMBER item.senderType === UserTypeEnum.MEMBER
? `ss-row-left` ? `ss-row-left`
@ -19,62 +36,39 @@
class="flex mb-20px w-[100%]" class="flex mb-20px w-[100%]"
> >
<el-avatar <el-avatar
v-show="item.senderType === UserTypeEnum.MEMBER" v-if="item.senderType === UserTypeEnum.MEMBER"
:src="keFuConversation.userAvatar" :src="keFuConversation.userAvatar"
alt="avatar" alt="avatar"
/> />
<div class="kefu-message p-10px">
<!-- TODO puhui999: 消息相关等后续完成后统一抽离封装 -->
<!-- 文本消息 -->
<template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType">
<div <div
v-dompurify-html="replaceEmoji(item.content)" :class="{ 'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType }"
:class="[ class="p-10px"
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" <TextMessageItem :message="item" />
fit="contain" <!-- 图片消息 -->
style="width: 200px; height: 200px" <ImageMessageItem :message="item" />
@click="imagePreview(item.content)"
/>
</div>
</template>
</div> </div>
<el-avatar <el-avatar
v-show="item.senderType === UserTypeEnum.ADMIN" v-if="item.senderType === UserTypeEnum.ADMIN"
:src="item.senderAvatar" :src="item.senderAvatar"
alt="avatar" alt="avatar"
/> />
</div> </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)
} }
//
const refreshMessageList = () => {
if (!keFuConversation.value) {
return
} }
defineExpose({ getMessageList }) 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()
return dateString !== dayjs(unref(item).createTime).fromNow()
} }
return false
// TODO puhui999:
onBeforeUnmount(() => {
if (!poller.value) {
return
}
clearInterval(poller.value)
}) })
</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,21 +56,27 @@ 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(
'@/views/mall/promotion/kefu/components/images/*.{png,jpg,jpeg,svg}'
)
for (const path in pathList) {
const imageModule: any = await pathList[path]()
emojiPathList.value.push(imageModule.default)
}
}
// 初始化
onMounted(async () => {
if (isEmpty(emojiPathList.value)) {
await getStaticEmojiPath()
} }
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) { function replaceEmoji(data: string) {
let newData = data let newData = data
if (typeof newData !== 'object') { if (typeof newData !== 'object') {
const reg = /\[(.+?)\]/g // [] 中括号 const reg = /\[(.+?)\]/g // [] 中括号
@ -78,9 +86,7 @@ export function replaceEmoji(data: string) {
const emojiFile = selEmojiFile(item) const emojiFile = selEmojiFile(item)
newData = newData.replace( newData = newData.replace(
item, item,
`<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${ `<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${emojiFile}"/>`
staticUrl + suffix + emojiFile
}"/>`
) )
}) })
} }
@ -89,18 +95,21 @@ export function replaceEmoji(data: string) {
} }
// 获得所有表情 // 获得所有表情
export function getEmojiList(): Emoji[] { function getEmojiList(): Emoji[] {
return emojiList.map((item) => ({ return emojiList.map((item) => ({
url: staticUrl + suffix + item.file, url: selEmojiFile(item.name),
name: item.name name: item.name
})) as Emoji[] })) as Emoji[]
} }
function selEmojiFile(name: string) { function selEmojiFile(name: string) {
for (const index in emojiList) { for (const emoji of emojiList) {
if (emojiList[index].name === name) { if (emoji.name === name) {
return emojiList[index].file return emojiPathList.value.find((item: string) => item.indexOf(emoji.file) > -1)
} }
} }
return false 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>

1
types/env.d.ts vendored
View File

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