@zuopngfei 884870d9b7 dwew
2025-10-23 18:28:19 +08:00

631 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-page">
<div class="left-panel">
<WxUserCard @load="onLoadUserList" :cardlist="userList" height="calc(100vh - 120px)" @change="onSelectUser" />
</div>
<div class="right-panel">
<div :class="['chat-body', { 'chat-body--loading': isLoadingMessages }]" ref="chatBody" @scroll="handleScroll">
<!-- 加载更多提示 -->
<div v-if="loadingMore" class="loading-more">加载中...</div>
<WxChatRecord :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 WxChatRecord from '@/components/ChatRecord/index.vue'
import WxUserCard 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 isLoadingMessages = ref(false)
// 发送者信息
const sendeInfo = reactive({
userInfo: { user_id: '', user_name: '', user_avatar: '' },
robotInfo: { wx_id: '', head_url: '' }
})
// 消息分页查询参数
const msgQuery = reactive({
page: 1,
page_size: 100,
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 = async (id) => {
const u = userList.value.find((it) => it.sender_id == id)
if (u) {
userList.value.forEach((it) => (it.active = it.sender_id === id))
activeUser.value = u
sendeInfo.userInfo = { user_id: u.sender_id, user_name: u.sender_name, user_avatar: u.sender_avatar }
sendeInfo.robotInfo = { wx_id: 'robot', head_url: mihoutai }
// 重置消息列表和分页
messages.value = []
msgQuery.page = 1
msgQuery.user_id = u.sender_id
noMoreMessages.value = false
// 为避免切换用户时看到明显的滚动过程:
// - 在开始加载消息前将 chat 区域设置为不可见(但依然可滚动),
// - 加载完成后恢复显示并滚到底部(保持原有的交互顺序)。
isLoadingMessages.value = true
// 保持原有行为:先滚动到底部再加载(不 await以免改变用户可感知的交互顺序
scrollToBottom()
// 启动加载(不 await使界面更流畅
getMessages().finally(() => {
// 在消息加载结束后恢复可见性并尝试滚到底部
isLoadingMessages.value = false
// 确保滚到底部以避免残留偏移
scrollToBottom()
})
// 重启定时器
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
// 在第一次加载page=1 且 isHistory=true期间也将 isLoadingMessages 保持为 true
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
isLoadingMessages.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
// 如果这是第一页历史加载,确保 loading 标志被清理(页面外也会在调用者 finally 中清理)
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
isLoadingMessages.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.sender_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.sender_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.sender_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.sender_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.sender_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.sender_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.sender_id) {
onSelectUser(userList.value[0].sender_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.sender_id) {
restartMessageTimer()
} else {
stopMessageTimer()
}
})
// 当 messages 变化时,尝试可靠地滚动到底部:
// - 等待 DOM 更新nextTick
// - 立即滚动并在短延迟后再滚一次以应对图片等资源加载导致的高度变化,
// - 对未加载的图片注册 load 事件,加载完成后再滚动。
watch(messages, async (newVal, oldVal) => {
// 只有在自动滚动模式下才触发
if (!isAutoScroll.value) return
await nextTick()
// 立即尝试滚动到底部
scrollToBottom()
// 在短延迟后再尝试一次,以容忍布局抖动或图片加载
setTimeout(() => scrollToBottom(), 120)
// 如果存在未完成加载的图片,注册 load 事件,加载后滚到底部
const el = chatBody.value
if (el) {
const imgs = el.querySelectorAll('img')
imgs.forEach(img => {
if (!img.complete) {
img.addEventListener('load', () => {
if (isAutoScroll.value) scrollToBottom()
}, { once: true })
}
})
}
})
</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;
}
/* Hide scrollbar but keep scrolling functionality */
.chat-body {
/* For WebKit-based browsers (Chrome, Safari, Edge Chromium) */
-webkit-overflow-scrolling: touch;
}
.chat-body::-webkit-scrollbar {
width: 0px;
height: 0px;
}
/* For Firefox */
.chat-body {
scrollbar-width: none; /* Firefox */
}
/* For IE and older Edge */
.chat-body {
-ms-overflow-style: none; /* IE 10+ */
}
/* When loading messages during user switch, hide visual content to avoid jump/flicker */
.chat-body--loading {
opacity: 0;
transition: opacity 180ms ease-in-out;
pointer-events: none; /* prevent interaction during transient loading */
}
// 加载更多样式
.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>