557 lines
16 KiB
Vue
557 lines
16 KiB
Vue
<template>
|
||
<div class="chat-page">
|
||
<div class="left-panel">
|
||
<UserCard @load="onLoadUserList" :cardlist="userList" height="calc(100vh - 120px)" @change="onSelectUser" />
|
||
</div>
|
||
<div class="right-panel">
|
||
<div class="chat-body" ref="chatBody" @scroll="handleScroll">
|
||
<!-- 加载更多提示 -->
|
||
<div v-if="loadingMore" class="loading-more">加载中...</div>
|
||
|
||
<ChatRecord :msgList="messages" :sendeInfo="sendeInfo" msgType="user" @retry="handleRetry" />
|
||
</div>
|
||
|
||
<div class="chat-footer">
|
||
<div class="footer-left" style="">
|
||
<div class="upload-row" style="display:flex; align-items:center; gap:12px;margin-left: 6px;position: relative; top: 10px;">
|
||
<el-icon style="font-size:24px;cursor:pointer" @click="triggerImageInput">
|
||
<svg class="icon" aria-hidden="true">
|
||
<use xlink:href="#icon-tupian1"></use>
|
||
</svg>
|
||
</el-icon>
|
||
<input ref="imageInput" type="file" accept="image/*" style="display:none"
|
||
@change="handleImageChange" />
|
||
<V3Emoji @click-emoji="onEmojiSelect" :recent="true"></V3Emoji>
|
||
</div>
|
||
<el-input type="textarea" v-model="draft" placeholder="输入消息..." rows="2" style="flex:1"
|
||
@keydown="handleInputKeydown" />
|
||
</div>
|
||
<!-- 添加发送按钮 -->
|
||
<!-- <el-button type="primary" @click="send">发送</el-button> -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
||
import ChatRecord from '@/components/ChatRecord/index.vue'
|
||
import UserCard from '@/components/UserCard/index.vue'
|
||
import UserMessage from '@/components/UserMessage/index.vue'
|
||
import mihoutai from '@/assets/images/mihoutai.png'
|
||
import V3Emoji from "vue3-emoji";
|
||
import { getUserList, send_message, get_messages } from '@/api/chat'
|
||
import { useRoute } from 'vue-router'
|
||
import { uploadFile } from '@/api/upload'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const route = useRoute()
|
||
|
||
// 好友列表数据
|
||
const userList = ref([
|
||
{ user_id: 'u1', user_name: '张三', user_avatar: '' },
|
||
{ user_id: 'u2', user_name: '李四', user_avatar: '' },
|
||
{ user_id: 'u3', user_name: '王五', user_avatar: '' },
|
||
])
|
||
|
||
const activeUser = ref({})
|
||
const messages = ref([])
|
||
const draft = ref('')
|
||
const imageInput = ref(null)
|
||
const chatBody = ref(null)
|
||
|
||
// 发送者信息
|
||
const sendeInfo = reactive({
|
||
userInfo: { user_id: '', user_name: '', user_avatar: '' },
|
||
robotInfo: { wx_id: '', head_url: '' }
|
||
})
|
||
|
||
// 消息分页查询参数
|
||
const msgQuery = reactive({
|
||
page: 1,
|
||
page_size: 190,
|
||
app_id: route.query.app_id,
|
||
user_id: ''
|
||
})
|
||
|
||
// 状态变量
|
||
const loadingMore = ref(false)
|
||
const noMoreMessages = ref(false)
|
||
const isAutoScroll = ref(true) // 是否自动滚动到底部
|
||
const messageTimer = ref(null) // 定时器实例
|
||
|
||
// 选择用户
|
||
const onSelectUser = (id) => {
|
||
const u = userList.value.find((it) => it.user_id == id)
|
||
if (u) {
|
||
userList.value.forEach((it) => (it.active = it.user_id === id))
|
||
activeUser.value = u
|
||
sendeInfo.userInfo = { user_id: u.user_id, user_name: u.user_name, user_avatar: u.user_avatar }
|
||
sendeInfo.robotInfo = { wx_id: 'robot', head_url: mihoutai }
|
||
|
||
// 重置消息列表和分页
|
||
messages.value = []
|
||
msgQuery.page = 1
|
||
msgQuery.user_id = u.user_id
|
||
noMoreMessages.value = false
|
||
|
||
// 保持原有行为:先滚动到底部再加载(不 await),以免改变用户可感知的交互顺序
|
||
scrollToBottom()
|
||
getMessages()
|
||
|
||
// 重启定时器
|
||
restartMessageTimer()
|
||
}
|
||
}
|
||
|
||
|
||
let msgTotal = 0
|
||
// 获取消息(区分加载历史和最新消息)
|
||
// isHistory: 是否为向上翻页加载历史
|
||
// pageOverride: 可选的临时 page 值(不会修改 msgQuery.page),用于定时拉取最新时使用 page=1
|
||
const getMessages = async (isHistory = true, pageOverride = null) => {
|
||
if (loadingMore.value) return
|
||
if (isHistory && noMoreMessages.value) return
|
||
if (!msgQuery.user_id) return
|
||
|
||
loadingMore.value = true
|
||
const queryPage = pageOverride ?? msgQuery.page
|
||
const query = { ...msgQuery, page: queryPage }
|
||
|
||
try {
|
||
const res = await get_messages({ ...query })
|
||
msgTotal = res.total || 0
|
||
const list = res.list || []
|
||
|
||
if (list.length > 0) {
|
||
const newMessages = list.map(item => {
|
||
try { item.content = JSON.parse(item.content) } catch (e) { /* keep original */ }
|
||
return item
|
||
})
|
||
|
||
if (isHistory) {
|
||
// 在向上加载历史时,需要在 prepend 前记录 scrollHeight 以便恢复视图位置
|
||
const el = chatBody.value
|
||
const prevScrollHeight = el ? el.scrollHeight : 0
|
||
|
||
if (queryPage === 1) {
|
||
// 第一次加载(第一页)直接替换
|
||
messages.value = newMessages
|
||
} else {
|
||
// 向上翻页,prepend
|
||
messages.value = [...newMessages, ...messages.value]
|
||
// 等待 DOM 更新后恢复滚动位置,避免跳动
|
||
await nextTick()
|
||
if (el) {
|
||
// 保持用户看到原来位置(新内容高度增加导致的偏移)
|
||
el.scrollTop = el.scrollHeight - prevScrollHeight
|
||
}
|
||
}
|
||
} else {
|
||
// 拉取最新消息(追加到末尾),避免重复
|
||
const existIds = new Set(messages.value.map(m => m.id || m._id))
|
||
const unique = newMessages.filter(m => !(existIds.has(m.id || m._id)))
|
||
if (unique.length) messages.value = [...messages.value, ...unique]
|
||
}
|
||
|
||
// 使用 total 判断是否还有更多历史消息(仅在已知 total 时判断)
|
||
if (msgTotal && isHistory) {
|
||
noMoreMessages.value = messages.value.length >= msgTotal
|
||
} else if (list.length < msgQuery.page_size && isHistory) {
|
||
// 如果后端没有 total,退回到 page_size 判断
|
||
noMoreMessages.value = true
|
||
}
|
||
|
||
// 非历史加载且处于自动滚动状态时滚到底部
|
||
if (!isHistory && isAutoScroll.value) {
|
||
await scrollToBottom()
|
||
}
|
||
} else if (isHistory) {
|
||
noMoreMessages.value = true
|
||
}
|
||
} catch (err) {
|
||
// 可扩展错误处理
|
||
} finally {
|
||
loadingMore.value = false
|
||
|
||
// 如果是向上翻页(非第一页)并且 prepend 后视图仍然靠近顶部,自动继续翻页
|
||
try {
|
||
if (isHistory && queryPage !== 1) {
|
||
await nextTick()
|
||
const el2 = chatBody.value
|
||
const nearTop = el2 ? el2.scrollTop <= 50 : false
|
||
if (nearTop && !noMoreMessages.value) {
|
||
// 继续加载下一页历史(递增 page)
|
||
msgQuery.page = msgQuery.page + 1
|
||
await getMessages(true)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 忽略自动加载中的错误,保持用户可见性
|
||
}
|
||
}
|
||
}
|
||
|
||
// 滚动事件处理
|
||
const handleScroll = (e) => {
|
||
const { scrollTop, scrollHeight, clientHeight } = e.target
|
||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50 // 距离底部小于50px视为在底部
|
||
|
||
// 更新自动滚动状态
|
||
isAutoScroll.value = isNearBottom
|
||
|
||
// 控制定时器
|
||
if (isNearBottom && !messageTimer.value && msgQuery.user_id) {
|
||
// 在底部且无定时器,启动定时器
|
||
startMessageTimer()
|
||
} else if (!isNearBottom && messageTimer.value) {
|
||
// 不在底部且有定时器,停止定时器
|
||
stopMessageTimer()
|
||
}
|
||
|
||
// 滚动到顶部时加载更多历史消息
|
||
if (scrollTop <= 0 && !loadingMore.value && !noMoreMessages.value) {
|
||
loadMoreMessages()
|
||
}
|
||
}
|
||
|
||
// 加载更多历史消息
|
||
const loadMoreMessages = () => {
|
||
if (msgQuery.user_id && !loadingMore.value && !noMoreMessages.value) {
|
||
msgQuery.page++
|
||
getMessages(true)
|
||
}
|
||
}
|
||
|
||
// 发送消息
|
||
const send = () => {
|
||
if (!activeUser.value.user_id) {
|
||
ElMessage({ type: 'warning', message: '请先选择一个联系人' })
|
||
return
|
||
}
|
||
if (!draft.value) return
|
||
|
||
const content = draft.value
|
||
const id = Date.now() + Math.random()
|
||
const msg = {
|
||
_id: id,
|
||
_sending: true,
|
||
_failed: false,
|
||
sender_name: '机器人',
|
||
msg_type: 0,
|
||
content: { messages: content },
|
||
send_time: '刚刚',
|
||
"sender_id": "888888",
|
||
"sender_name": "平台",
|
||
"msg_type": 1
|
||
}
|
||
messages.value.push(msg)
|
||
draft.value = ''
|
||
|
||
// 发送后强制滚动到底部并设置自动滚动状态
|
||
scrollToBottom()
|
||
isAutoScroll.value = true
|
||
|
||
// 发送到服务器
|
||
send_message({
|
||
app_id: route.query.app_id,
|
||
content: JSON.stringify({
|
||
messages: content
|
||
}),
|
||
msg_type: 1,
|
||
to_user_id: activeUser.value.user_id
|
||
}).then(() => {
|
||
msg._sending = false
|
||
msg._failed = false
|
||
}).catch(() => {
|
||
msg._sending = false
|
||
msg._failed = true
|
||
ElMessage({ type: 'error', message: '消息发送失败' })
|
||
})
|
||
}
|
||
|
||
// 键盘事件处理
|
||
const handleInputKeydown = (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault()
|
||
send()
|
||
}
|
||
}
|
||
|
||
// 图片上传相关
|
||
const triggerImageInput = () => {
|
||
imageInput.value && imageInput.value.click()
|
||
}
|
||
|
||
const handleImageChange = (e) => {
|
||
const file = e.target.files[0]
|
||
if (!file) return
|
||
|
||
// 本地预览
|
||
const url = URL.createObjectURL(file)
|
||
const id = Date.now() + Math.random()
|
||
const msg = {
|
||
_id: id,
|
||
_sending: true,
|
||
_failed: false,
|
||
sender_id: '888888',
|
||
sender_name: '平台',
|
||
msg_type: 2,
|
||
content: { messages: url },
|
||
send_time: '刚刚'
|
||
}
|
||
messages.value.push(msg)
|
||
e.target.value = ''
|
||
|
||
// 滚动到底部
|
||
setTimeout(() => scrollToBottom(), 100)
|
||
isAutoScroll.value = true
|
||
|
||
// 上传文件
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
form.append('app_id', route.query.app_id)
|
||
form.append('msg_type', 2)
|
||
form.append('to_user_id', activeUser.value.user_id)
|
||
uploadFile(form, import.meta.env.VITE_APP_BASE_API + 'admin/upload/image').then((resp) => {
|
||
send_message({
|
||
app_id: route.query.app_id,
|
||
content: JSON.stringify({ messages: import.meta.env.VITE_APP_BASE_API + resp.preview_image_url }),
|
||
msg_type: 2,
|
||
to_user_id: activeUser.value.user_id
|
||
}).then(() => {
|
||
msg._sending = false
|
||
msg._failed = false
|
||
}).catch(() => {
|
||
msg._sending = false
|
||
msg._failed = true
|
||
ElMessage({ type: 'error', message: '图片发送失败' })
|
||
})
|
||
})
|
||
}
|
||
|
||
// 重发消息
|
||
const handleRetry = (id) => {
|
||
const msg = messages.value.find((m) => m._id === id)
|
||
if (!msg) return
|
||
|
||
msg._failed = false
|
||
msg._sending = true
|
||
|
||
if (msg.msg_type === 0) {
|
||
send_message({
|
||
app_id: route.query.app_id,
|
||
content: msg.content.content,
|
||
msg_type: 1,
|
||
to_user_id: activeUser.value.user_id
|
||
}).then(() => {
|
||
msg._sending = false
|
||
}).catch(() => {
|
||
msg._sending = false;
|
||
msg._failed = true;
|
||
ElMessage({ type: 'error', message: '重发失败' })
|
||
})
|
||
} else if (msg.msg_type === 2) {
|
||
const form = new FormData()
|
||
form.append('file', msg.content.file)
|
||
form.append('app_id', route.query.app_id)
|
||
form.append('msg_type', 2)
|
||
form.append('to_user_id', activeUser.value.user_id)
|
||
send_message(form).then(() => {
|
||
msg._sending = false
|
||
}).catch(() => {
|
||
msg._sending = false;
|
||
msg._failed = true;
|
||
ElMessage({ type: 'error', message: '重发失败' })
|
||
})
|
||
}
|
||
}
|
||
|
||
// 滚动到底部
|
||
const scrollToBottom = async () => {
|
||
await nextTick()
|
||
const el = chatBody.value
|
||
if (!el) return
|
||
try {
|
||
el.scrollTop = el.scrollHeight
|
||
} catch (e) {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
|
||
// 表情选择
|
||
const onEmojiSelect = (emoji) => {
|
||
draft.value = (draft.value || '') + emoji
|
||
}
|
||
|
||
// 获取用户列表
|
||
const params = reactive({
|
||
page: 1,
|
||
page_size: 20,
|
||
app_id: route.query.app_id
|
||
})
|
||
|
||
const getUsers = (append = false) => {
|
||
getUserList(params).then((res) => {
|
||
if (append) {
|
||
userList.value = userList.value.concat(res.list || [])
|
||
} else {
|
||
userList.value = res.list || []
|
||
}
|
||
// 首次加载且没有选中用户时,默认选中第一个
|
||
if (!append && userList.value.length && !activeUser.value.user_id) {
|
||
onSelectUser(userList.value[0].user_id)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 加载更多用户
|
||
const onLoadUserList = async () => {
|
||
params.page = params.page + 1
|
||
await getUsers(true)
|
||
await nextTick()
|
||
const el = document.querySelector('.left-panel .infinite-list')
|
||
if (el) {
|
||
el.scrollTop = el.scrollHeight
|
||
}
|
||
}
|
||
|
||
// 定时器相关函数
|
||
const startMessageTimer = () => {
|
||
// 清除现有定时器
|
||
if (messageTimer.value) {
|
||
clearInterval(messageTimer.value)
|
||
}
|
||
|
||
// 每秒获取一次最新消息,不修改 msgQuery.page,使用 pageOverride=1
|
||
messageTimer.value = setInterval(() => {
|
||
if (!msgQuery.user_id) return
|
||
getMessages(false, 1)
|
||
}, 1000)
|
||
}
|
||
|
||
const stopMessageTimer = () => {
|
||
if (messageTimer.value) {
|
||
clearInterval(messageTimer.value)
|
||
messageTimer.value = null
|
||
}
|
||
}
|
||
|
||
const restartMessageTimer = () => {
|
||
stopMessageTimer()
|
||
if (msgQuery.user_id) {
|
||
startMessageTimer()
|
||
}
|
||
}
|
||
|
||
// 组件挂载时
|
||
onMounted(() => {
|
||
getUsers()
|
||
|
||
// 监听窗口关闭,清理定时器
|
||
window.addEventListener('beforeunload', stopMessageTimer)
|
||
})
|
||
|
||
// 组件卸载时清理定时器
|
||
onUnmounted(() => {
|
||
stopMessageTimer()
|
||
})
|
||
|
||
// 监听活跃用户变化,重启定时器
|
||
watch(activeUser, (newVal) => {
|
||
if (newVal.user_id) {
|
||
restartMessageTimer()
|
||
} else {
|
||
stopMessageTimer()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.chat-page {
|
||
display: flex;
|
||
height: calc(100vh - 64px);
|
||
}
|
||
|
||
:deep(.el-textarea__inner) {
|
||
background-color: transparent;
|
||
padding-left: 8px !important;
|
||
}
|
||
|
||
.left-panel {
|
||
width: 280px;
|
||
border-right: 1px solid var(--el-border-color);
|
||
background: var(--el-bg-color);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.left-header {
|
||
height: 60px;
|
||
line-height: 60px;
|
||
padding: 0 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.right-panel {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.chat-header {
|
||
height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 16px;
|
||
border-bottom: 1px solid var(--el-border-color);
|
||
}
|
||
|
||
.chat-header .avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.chat-body {
|
||
flex: 1;
|
||
padding: 16px;
|
||
overflow: auto;
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
// 加载更多样式
|
||
.loading-more {
|
||
text-align: center;
|
||
padding: 10px;
|
||
color: #666;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.chat-footer {
|
||
height: 96px;
|
||
padding: 5px 15px;
|
||
border-top: 1px solid var(--el-border-color);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding-top: 10px;
|
||
}
|
||
|
||
.chat-footer .footer-left {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chat-footer .el-button {
|
||
height: 40px;
|
||
align-self: flex-end;
|
||
}
|
||
|
||
.chat-footer .actions {
|
||
display: flex;
|
||
align-items: center
|
||
}
|
||
</style> |