Files
front-pc/components/kl-quick-menu/components/kl-service.vue
2025-08-26 16:25:58 +08:00

735 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- 弄成一个聊天弹窗 仿照微信聊天弹窗 -->
<template>
<div class="chat-dialog">
<el-dialog v-model="dialogVisible" lock-scroll draggable :show-close="true" :close-on-click-modal="false" :close-on-press-escape="false" width="800px">
<template #header>
<div class="dialog-header">
<div class="title">在线客服</div>
</div>
</template>
<div class="chat-layout">
<!-- 右侧聊天区域 -->
<div class="chat-container">
<!-- 消息列表区域 -->
<div ref="messageList" class="message-list" @scroll="handleScroll">
<!-- 加载更多提示 -->
<div v-if="chatMessageQuery.hasMore" class="load-more">
<el-icon v-if="chatMessageQuery.loading"><Loading /></el-icon>
<span v-else>上拉加载更多</span>
</div>
<div
v-for="(msg, index) in chatMessages"
:key="index"
:class="['message-item', msg.fromId === userStore.userInfoRes.id ? 'message-sent' : 'message-received']"
>
<div class="avatar">
<el-avatar :size="40" :src="msg.fromId === userStore.userInfoRes.id ? userStore.userInfoRes.avatar : userAvatar" />
</div>
<div class="message-content">
<div v-if="msg.msgType === 0" class="message-bubble whitespace-pre-wrap">{{ msg.content }}</div>
<div v-else-if="msg.msgType === 1" class="message-bubble max-w-[50%]">
<img :src="msg.content" alt="图片" class="w-[100%]" />
</div>
<div v-else class="message-bubble max-w-[50%]">
{{ msg.content.split('/').pop() }}
</div>
<div class="message-time">{{ dayjs(msg.createTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<div class="toolbar">
<div class="tool-left">
<el-tooltip content="发送图片" placement="top">
<el-icon class="tool-icon" @click="triggerImageUpload"><Picture /></el-icon>
</el-tooltip>
<input ref="imageInputRef" type="file" style="display: none" @change="handleImageUpload" />
<el-popover :visible="showEmoji" placement="top" :width="450" trigger="click">
<template #reference>
<el-icon class="tool-icon emoji-trigger" @click="showEmoji = !showEmoji"><Sunrise /></el-icon>
</template>
<div class="emoji-list">
<span v-for="emoji in emojiList" :key="emoji" class="emoji-item" @click="insertEmoji(emoji)">
{{ emoji }}
</span>
</div>
</el-popover>
</div>
</div>
<div class="input-box">
<el-input
v-model="inputMessage"
type="textarea"
:rows="3"
placeholder="请输入消息Enter 发送"
resize="none"
maxlength="255"
@keydown.enter.prevent="(e: any) => (e.shiftKey ? (inputMessage += '\n') : handleSend(0))"
/>
</div>
<div class="send-btn">
<el-button type="primary" :disabled="!inputMessage.trim()" @click="handleSend(0)">
<el-icon class="send-icon"><Position /></el-icon>
发送
</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { throttle } from 'lodash'
import { ref, onMounted, nextTick } from 'vue'
import { Picture, Position, Sunrise, Loading } from '@element-plus/icons-vue'
import { upload } from '~/api/common'
import { sendKefuMessage, getMessagePage } from '~/api/channel/index'
import type { msgType, PageResultMessageRespVO } from '~/api/channel/types'
import useUserStore from '~/stores/user'
const userStore = useUserStore()
import dayjs from 'dayjs'
const inputMessage = ref('')
const messageList = ref()
// const searchText = ref('')
const currentUserId = ref()
const dialogVisible = defineModel<boolean>('dialogVisible', { required: true })
// 获取传过来的数据
const props = defineProps({
sessionType: {
// 会话类型0:单聊 1:群聊,示例值(2)
type: String,
default: '1',
},
})
console.log('会话类型', props.sessionType)
// 模拟数据
const userAvatar = 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
// const userList = ref<PageResultSessionRespVO[]>([])
// 聊天记录
const chatMessages = ref<PageResultMessageRespVO['list']>([])
// 聊天消息查询
const chatMessageQuery = ref({
pageNo: 1,
pageSize: 10,
hasMore: true,
loading: false,
sessionId: 0,
toId: 0,
isPending: false,
})
const switchUser = async () => {
// 切换用户
// chatMessageQuery.value.toId = user.fromId === userStore.userInfoRes.id ? user.toId : user.fromId
// chatMessageQuery.value.sessionId = currentUserId.value = user.sessionId
// user.unreadCount = 0 // 清除未读消息
// 切换用户时,清空聊天记录
chatMessageQuery.value.loading = true
chatMessageQuery.value.pageNo = 1
chatMessageQuery.value.hasMore = true
chatMessages.value = []
await getChatDetailList()
nextTick(() => {
scrollToBottom()
})
// 清空未读消息
// clearUnreadMessage({ id: user.sessionId })
}
const getChatDetailList = async () => {
const res = await getMessagePage({
pageNo: chatMessageQuery.value.pageNo,
pageSize: chatMessageQuery.value.pageSize,
// fromId: userStore.userInfoRes.id, // 当前用户id
// msgType: 2,
topic: `zbjk_message_kefu/${userStore.userInfoRes.id}`,
})
if (res.code === 0) {
chatMessageQuery.value.isPending = false
// 数据反过来
if (res.data.list.length > 0) {
// 保存当前滚动位置和内容高度
const scrollElement = messageList.value
const oldScrollHeight = scrollElement.scrollHeight
const oldScrollTop = scrollElement.scrollTop
chatMessages.value = [...res.data.list.reverse(), ...chatMessages.value]
// 在下一个 tick 后调整滚动位置
nextTick(() => {
const newScrollHeight = scrollElement.scrollHeight
const heightDiff = newScrollHeight - oldScrollHeight
scrollElement.scrollTop = oldScrollTop + heightDiff
})
}
chatMessageQuery.value.loading = false
chatMessageQuery.value.pageNo++
// 判断是否还有更多数据
if (res.data.list.length < chatMessageQuery.value.pageSize) {
chatMessageQuery.value.hasMore = false
}
}
}
// 滚动处理函数
const handleScroll = async (e: Event) => {
const target = e.target as HTMLElement
// 当滚动到顶部附近时触发加载更多
if (target.scrollTop < 50 && !chatMessageQuery.value.loading && chatMessageQuery.value.hasMore && !chatMessageQuery.value.isPending) {
// await loadMoreMessages()
chatMessageQuery.value.isPending = true
await throttledGetChatList()
}
}
// 创建节流函数,设置 500ms 的间隔
const throttledGetChatList = throttle(async () => {
await getChatDetailList()
}, 1500)
const handleSend = (msgType: msgType = 0) => {
if (!inputMessage.value.trim()) return
const newMessage = {
msgType: msgType,
content: inputMessage.value,
toId: 0, // 对方userId
userId: userStore.userInfoRes.id, // 当前用户id
fromId: userStore.userInfoRes.id, // 当前用户id
createTime: dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss'), // 当前时间戳
}
chatMessages.value.push(newMessage)
userStore.mqttClient?.publish(`zbjk_message_kefu/`, JSON.stringify(newMessage))
// 更新用户列表中的最后一条消息
// userLastMessage(newMessage)
// 清空输入框
inputMessage.value = ''
// 滚动到底部
scrollToBottom()
// 发送单聊信息存储到服务器
sendKefuMessage(newMessage)
}
const scrollToBottom = () => {
if (messageList.value) {
nextTick(() => {
messageList.value.scrollTop = messageList.value.scrollHeight
})
}
}
switchUser()
onMounted(() => {
userStore.mqttClient?.onMessage((topic: string, message: any) => {
if (topic.indexOf(`zbjk_message_kefu`) === -1) return
console.log('接收消息---------', topic, message)
console.log('当前用户id', userStore.userInfoRes.id)
console.log('当前聊天用户id', currentUserId.value)
// 将消息添加到聊天记录中
const msg = JSON.parse(message)
if (msg.userId === userStore.userInfoRes.id) return
msg.msgType = Number(msg.msgType)
// 当前用户发送的消息
chatMessages.value.push(msg)
// 更新当前用户列表中的最后一条消息
// userLastMessage(msg)
// 清空未读消息
// clearUnreadMessage({ id: msg.sessionId })
if (msg.msgType === 1) {
const img = new Image()
img.src = msg.content
img.onload = () => {
// 滚动到底部
scrollToBottom()
}
} else {
// 滚动到底部
scrollToBottom()
}
})
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement
if (!target.closest('.emoji-popover') && !target.closest('.emoji-trigger')) {
showEmoji.value = false
}
})
})
// 触发图片上传
const imageInputRef = ref()
const triggerImageUpload = (): void => {
if (imageInputRef.value) {
imageInputRef.value.click()
}
}
// 处理图片上传
const handleImageUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
const files = target.files
if (files && files.length > 0) {
const file = files[0]
// 检查文件类型 0:文本 1:图片 2:视频 3:文件
const msgType = file.type.startsWith('image/') ? 1 : file.type.startsWith('video/') ? 2 : 3
const formData = new FormData()
formData.append('fieldName', file?.name)
formData.append('file', file as Blob)
const res = await upload('/prod-api/app-api/infra/file/upload', formData)
if (res.code === 0) {
const imageUrl = res.data
if (msgType === 1) {
// 预加载图片
const img = new Image()
img.src = imageUrl
img.onload = () => {
inputMessage.value = imageUrl
handleSend(msgType)
}
img.onerror = () => {
ElMessage.error('图片加载失败')
}
} else {
inputMessage.value = imageUrl
handleSend(msgType)
}
}
// 重置文件输入以允许重复选择同一文件
if (imageInputRef.value) {
imageInputRef.value.value = ''
}
}
}
const showEmoji = ref(false)
const emojiList = [
'😀',
'😃',
'😄',
'😁',
'😆',
'😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'🙃',
'😉',
'😌',
'😍',
'🥰',
'😘',
'😗',
'😙',
'😚',
'😋',
'😛',
'😝',
'😜',
'🤪',
'🤨',
'🧐',
'🤓',
'😎',
'🤩',
'🥳',
'😏',
'😒',
'😞',
'😔',
'😟',
'😕',
'🙁',
'☹️',
'😣',
]
const insertEmoji = (emoji: string) => {
inputMessage.value += emoji
showEmoji.value = false
}
</script>
<style lang="scss" scoped>
.emoji-list {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 8px;
.emoji-item {
cursor: pointer;
font-size: 20px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-radius: 4px;
transform: scale(1.2);
}
}
}
.chat-dialog {
:deep(.el-dialog__header) {
margin: 0;
padding: 0;
border-radius: 8px 8px 0 0;
overflow: hidden;
}
:deep(.el-dialog__body) {
padding: 0px !important;
overflow: hidden;
display: flex;
}
:deep(.el-dialog) {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 12px 32px 4px rgba(0, 0, 0, 0.1);
}
:deep(.el-dialog__headerbtn) {
top: 24px;
right: 12px;
z-index: 10;
.el-dialog__close {
font-size: 16px;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
}
}
}
.dialog-header {
display: flex;
align-items: center;
padding: 0 16px;
// height: 40px;
.title {
font-size: 14px;
font-weight: normal;
}
}
.chat-layout {
display: flex;
flex: 1;
}
.user-list {
width: 280px;
border-right: 1px solid #eee;
display: flex;
flex-direction: column;
background-color: #34353a;
.search-box {
padding: 12px 12px 12px 12px;
:deep(.el-input__wrapper) {
background-color: #4f5054 !important;
box-shadow: none !important;
}
:deep(.el-input__inner) {
color: #fff;
border-radius: 16px;
border: none;
&:focus {
box-shadow: none;
}
}
}
.user-items {
flex: 1;
overflow-y: auto;
.user-item {
display: flex;
align-items: flex-start;
padding: 12px;
cursor: pointer;
transition: all 0.3s;
&:hover {
// background-color: #f5f5f5;
}
&.active {
background-color: rgba(255, 255, 255, 0.12) !important;
}
.user-info {
flex: 1;
margin: 0 12px;
overflow: hidden;
.user-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
color: #fff;
}
.last-message {
font-size: 14px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.message-status {
text-align: right;
.time {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.unread-badge {
:deep(.el-badge__content) {
background-color: #c561f9;
}
}
}
}
}
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
.chat-header {
padding: 16px 20px;
background: #fff;
border-bottom: 1px solid #eee;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.title-info {
.title {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.subtitle {
font-size: 12px;
color: #999;
}
}
}
}
}
.message-list {
flex: 1;
padding: 24px;
box-sizing: border-box;
overflow-y: auto;
background-color: #f7f8fa;
min-height: 300px;
.load-more {
text-align: center;
padding: 10px 0;
color: #999;
font-size: 12px;
}
.message-item {
display: flex;
margin-bottom: 24px;
&.message-sent {
flex-direction: row-reverse;
.message-content {
margin-right: 12px;
margin-left: 80px;
align-items: flex-end;
.message-bubble {
background: #1a65ff;
color: #fff;
border-radius: 12px 2px 12px 12px;
box-shadow: 0 4px 12px rgba(197, 97, 249, 0.2);
}
}
}
&.message-received {
.message-content {
margin-left: 12px;
margin-right: 80px;
.message-bubble {
background-color: #fff;
border-radius: 2px 12px 12px 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
}
}
}
.message-content {
display: flex;
flex-direction: column;
.message-bubble {
padding: 12px 16px;
font-size: 14px;
max-width: 400px;
word-break: break-word;
line-height: 1.6;
}
.message-time {
font-size: 12px;
color: #999;
margin-top: 6px;
}
}
}
.input-area {
background-color: #fff;
border-top: 1px solid #eee;
padding: 16px 24px;
box-sizing: border-box;
.toolbar {
display: flex;
justify-content: space-between;
padding: 0 0 12px;
.tool-left {
display: flex;
gap: 20px;
}
.tool-icon {
font-size: 20px;
color: #666;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: #c561f9;
transform: scale(1.1);
}
}
}
.input-box {
margin-bottom: 12px;
:deep(.el-textarea__inner) {
border: 1px solid #fff !important;
border-radius: 8px;
padding: 12px;
font-size: 14px;
transition: all 0.3s;
outline: none !important;
box-shadow: none !important;
&:focus {
border-color: #c561f9;
box-shadow: none !important;
// box-shadow: 0 0 0 2px rgba(197, 97, 249, 0.1);
}
&::placeholder {
color: #999;
}
}
}
.send-btn {
display: flex;
justify-content: flex-end;
.el-button {
background: #1a65ff;
border: none;
padding: 10px 24px;
font-size: 14px;
border-radius: 8px;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 6px;
.send-icon {
font-size: 16px;
}
&:hover {
transform: translateY(-1px);
// box-shadow: 0 4px 12px rgba(197, 97, 249, 0.3);
}
&:active {
transform: translateY(0);
}
&.is-disabled {
background: #e0e0e0;
opacity: 0.6;
}
}
}
}
// 自定义滚动条样式
.message-list {
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.1);
border-radius: 3px;
&:hover {
background-color: rgba(0, 0, 0, 0.2);
}
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
</style>