2025-10-19 21:58:43 +08:00

557 lines
16 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">
<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>