2323
This commit is contained in:
parent
279c2074db
commit
c5b305d4ff
@ -1,5 +1,5 @@
|
||||
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
||||
NODE_ENV = 'production'
|
||||
VITE_APP_BASE_API = 'http://scrm.1024tool.vip/'
|
||||
VITE_SERVE = "http://scrm.1024tool.vip/"
|
||||
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/'
|
||||
VITE_SERVE = "https://mini-chat.1024tool.vip/"
|
||||
|
||||
|
||||
12
components.d.ts
vendored
12
components.d.ts
vendored
@ -12,13 +12,10 @@ declare module 'vue' {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
@ -26,30 +23,21 @@ declare module 'vue' {
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTour: typeof import('element-plus/es')['ElTour']
|
||||
ElTourStep: typeof import('element-plus/es')['ElTourStep']
|
||||
IndexUser: typeof import('./src/components/TagClass/indexUser.vue')['default']
|
||||
KeyWords: typeof import('./src/components/KeyWords/index.vue')['default']
|
||||
MaterialPublic: typeof import('./src/components/MaterialPublic/index.vue')['default']
|
||||
|
||||
@ -31,7 +31,7 @@ const constantRoutes = [
|
||||
path: '/',
|
||||
name: 'Layout',
|
||||
component: () => import('@/layout/index.vue'),
|
||||
redirect: '/robot',
|
||||
redirect: '/miniProgram',
|
||||
meta: {
|
||||
title: 'Layout',
|
||||
},
|
||||
|
||||
398
src/views/chat/index copy.vue
Normal file
398
src/views/chat/index copy.vue
Normal file
@ -0,0 +1,398 @@
|
||||
<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">
|
||||
<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;">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, nextTick, watch } 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'
|
||||
const route = useRoute()
|
||||
|
||||
// mock data for now — integrate with store/api as needed
|
||||
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: 100,
|
||||
app_id: route.query.app_id,
|
||||
user_id: ''
|
||||
})
|
||||
|
||||
// 添加加载状态
|
||||
const loadingMore = ref(false)
|
||||
const noMoreMessages = ref(false)
|
||||
|
||||
const onSelectUser = (id) => {
|
||||
// id may be user_id or array when multiple; handle only single selection here
|
||||
const u = userList.value.find((it) => it.user_id == id)
|
||||
if (u) {
|
||||
// mark selected and clear others
|
||||
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 }
|
||||
// set robot identity used by ChatRecord to determine right-side messages
|
||||
sendeInfo.robotInfo = { wx_id: 'robot', head_url: mihoutai }
|
||||
// load messages for user (placeholder)
|
||||
messages.value = []
|
||||
// 重置分页参数
|
||||
msgQuery.page = 1
|
||||
msgQuery.user_id = u.user_id
|
||||
noMoreMessages.value = false
|
||||
// scroll after messages rendered
|
||||
scrollToBottom()
|
||||
getMessages()
|
||||
}
|
||||
}
|
||||
|
||||
// 修改获取消息的方法
|
||||
const getMessages = () => {
|
||||
if (loadingMore.value || noMoreMessages.value) return
|
||||
|
||||
loadingMore.value = true
|
||||
get_messages({ ...msgQuery }).then(res => {
|
||||
if (res.list && res.list.length > 0) {
|
||||
// 解析消息内容
|
||||
const newMessages = res.list.map(item => {
|
||||
item.content = JSON.parse(item.content)
|
||||
return item
|
||||
})
|
||||
|
||||
if (msgQuery.page === 1) {
|
||||
// 首次加载
|
||||
messages.value = newMessages
|
||||
} else {
|
||||
// 加载更多,将新消息添加到列表开头
|
||||
messages.value = [...newMessages, ...messages.value]
|
||||
}
|
||||
|
||||
// 如果返回的消息少于请求的数量,说明没有更多消息了
|
||||
if (res.list.length < msgQuery.page_size) {
|
||||
noMoreMessages.value = true
|
||||
}
|
||||
} else {
|
||||
// 没有更多消息
|
||||
noMoreMessages.value = true
|
||||
}
|
||||
|
||||
loadingMore.value = false
|
||||
}).catch(() => {
|
||||
loadingMore.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 添加滚动事件处理函数
|
||||
const handleScroll = (e) => {
|
||||
const { scrollTop } = e.target
|
||||
|
||||
// 当滚动到顶部时加载更多消息
|
||||
if (scrollTop <= 0 && !loadingMore.value && !noMoreMessages.value) {
|
||||
loadMoreMessages()
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多消息
|
||||
const loadMoreMessages = () => {
|
||||
if (msgQuery.user_id) {
|
||||
msgQuery.page++
|
||||
getMessages()
|
||||
}
|
||||
}
|
||||
|
||||
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 = ''
|
||||
// ensure scroll
|
||||
scrollToBottom()
|
||||
// send to server
|
||||
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
|
||||
// 直接用本地URL预览和发送
|
||||
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)
|
||||
// 清空input,允许重复选择同一图片
|
||||
e.target.value = ''
|
||||
// 图片可能增加高度,延迟滚动以保证图片渲染
|
||||
setTimeout(() => scrollToBottom(), 100)
|
||||
// 上传 file to server using FormData
|
||||
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) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 当 messages 变化时自动滚动(仅在首次加载或发送新消息时)
|
||||
// 移除原来的watcher,因为现在需要更精确地控制滚动行为
|
||||
|
||||
const onEmojiSelect = (emoji) => {
|
||||
if (draft.value) {
|
||||
draft.value += emoji
|
||||
} else {
|
||||
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) {
|
||||
// append new items
|
||||
userList.value = userList.value.concat(res.list || [])
|
||||
} else {
|
||||
userList.value = res.list || []
|
||||
}
|
||||
// if first load and no active user, select first
|
||||
if (!append && userList.value.length && !activeUser.value.user_id) {
|
||||
onSelectUser(userList.value[0].user_id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 当 UserCard 触发 load(分页)时调用,加载下一页并滚动列表到底部以显示新数据
|
||||
const onLoadUserList = async () => {
|
||||
params.page = params.page + 1
|
||||
await getUsers(true)
|
||||
// 等待 DOM 更新后滚动
|
||||
await nextTick()
|
||||
const el = document.querySelector('.left-panel .infinite-list')
|
||||
if (el) {
|
||||
// 滚动到底部以展示新加载的用户
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async (message) => {
|
||||
send_message(message).then((res) => {
|
||||
}).catch((err) => {
|
||||
ElMessage({ type: 'error', message: '消息发送失败' })
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-page {
|
||||
display: flex;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
: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
|
||||
}
|
||||
|
||||
.chat-footer {
|
||||
height: 96px;
|
||||
padding: 5px 5px;
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.chat-footer .actions {
|
||||
display: flex;
|
||||
align-items: center
|
||||
}
|
||||
</style>
|
||||
@ -5,12 +5,15 @@
|
||||
</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;">
|
||||
<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>
|
||||
@ -23,13 +26,15 @@
|
||||
<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 } from 'vue'
|
||||
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'
|
||||
@ -38,9 +43,11 @@ 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()
|
||||
|
||||
// mock data for now — integrate with store/api as needed
|
||||
// 好友列表数据
|
||||
const userList = ref([
|
||||
{ user_id: 'u1', user_name: '张三', user_avatar: '' },
|
||||
{ user_id: 'u2', user_name: '李四', user_avatar: '' },
|
||||
@ -53,105 +60,177 @@ 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: 100,
|
||||
page_size: 20,
|
||||
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) => {
|
||||
// id may be user_id or array when multiple; handle only single selection here
|
||||
const u = userList.value.find((it) => it.user_id == id)
|
||||
if (u) {
|
||||
// mark selected and clear others
|
||||
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 }
|
||||
// set robot identity used by ChatRecord to determine right-side messages
|
||||
sendeInfo.robotInfo = { wx_id: 'robot', head_url: mihoutai }
|
||||
// load messages for user (placeholder)
|
||||
|
||||
// 重置消息列表和分页
|
||||
messages.value = []
|
||||
// 重置分页参数
|
||||
msgQuery.page = 1
|
||||
msgQuery.user_id = u.user_id
|
||||
noMoreMessages.value = false
|
||||
// scroll after messages rendered
|
||||
|
||||
// 保持原有行为:先滚动到底部再加载(不 await),以免改变用户可感知的交互顺序
|
||||
scrollToBottom()
|
||||
getMessages()
|
||||
|
||||
// 重启定时器
|
||||
restartMessageTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// 修改获取消息的方法
|
||||
const getMessages = () => {
|
||||
if (loadingMore.value || noMoreMessages.value) return
|
||||
|
||||
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
|
||||
get_messages({ ...msgQuery }).then(res => {
|
||||
if (res.list && res.list.length > 0) {
|
||||
// 解析消息内容
|
||||
const newMessages = res.list.map(item => {
|
||||
item.content = JSON.parse(item.content)
|
||||
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 (msgQuery.page === 1) {
|
||||
// 首次加载
|
||||
messages.value = newMessages
|
||||
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 {
|
||||
// 加载更多,将新消息添加到列表开头
|
||||
messages.value = [...newMessages, ...messages.value]
|
||||
// 拉取最新消息(追加到末尾),避免重复
|
||||
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]
|
||||
}
|
||||
|
||||
// 如果返回的消息少于请求的数量,说明没有更多消息了
|
||||
if (res.list.length < msgQuery.page_size) {
|
||||
// 使用 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
|
||||
}
|
||||
} else {
|
||||
// 没有更多消息
|
||||
|
||||
// 非历史加载且处于自动滚动状态时滚到底部
|
||||
if (!isHistory && isAutoScroll.value) {
|
||||
await scrollToBottom()
|
||||
}
|
||||
} else if (isHistory) {
|
||||
noMoreMessages.value = true
|
||||
}
|
||||
} catch (err) {
|
||||
// 可扩展错误处理
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
|
||||
loadingMore.value = false
|
||||
}).catch(() => {
|
||||
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 } = e.target
|
||||
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) {
|
||||
if (msgQuery.user_id && !loadingMore.value && !noMoreMessages.value) {
|
||||
msgQuery.page++
|
||||
getMessages()
|
||||
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 = {
|
||||
@ -168,9 +247,12 @@ const send = () => {
|
||||
}
|
||||
messages.value.push(msg)
|
||||
draft.value = ''
|
||||
// ensure scroll
|
||||
|
||||
// 发送后强制滚动到底部并设置自动滚动状态
|
||||
scrollToBottom()
|
||||
// send to server
|
||||
isAutoScroll.value = true
|
||||
|
||||
// 发送到服务器
|
||||
send_message({
|
||||
app_id: route.query.app_id,
|
||||
content: JSON.stringify({
|
||||
@ -188,6 +270,7 @@ const send = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 键盘事件处理
|
||||
const handleInputKeydown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
@ -195,6 +278,7 @@ const handleInputKeydown = (e) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 图片上传相关
|
||||
const triggerImageInput = () => {
|
||||
imageInput.value && imageInput.value.click()
|
||||
}
|
||||
@ -202,7 +286,8 @@ const triggerImageInput = () => {
|
||||
const handleImageChange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
// 直接用本地URL预览和发送
|
||||
|
||||
// 本地预览
|
||||
const url = URL.createObjectURL(file)
|
||||
const id = Date.now() + Math.random()
|
||||
const msg = {
|
||||
@ -216,11 +301,13 @@ const handleImageChange = (e) => {
|
||||
send_time: '刚刚'
|
||||
}
|
||||
messages.value.push(msg)
|
||||
// 清空input,允许重复选择同一图片
|
||||
e.target.value = ''
|
||||
// 图片可能增加高度,延迟滚动以保证图片渲染
|
||||
|
||||
// 滚动到底部
|
||||
setTimeout(() => scrollToBottom(), 100)
|
||||
// 上传 file to server using FormData
|
||||
isAutoScroll.value = true
|
||||
|
||||
// 上传文件
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('app_id', route.query.app_id)
|
||||
@ -241,29 +328,46 @@ const handleImageChange = (e) => {
|
||||
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: '重发失败' }) })
|
||||
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: '重发失败' }) })
|
||||
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
|
||||
@ -271,21 +375,16 @@ const scrollToBottom = async () => {
|
||||
try {
|
||||
el.scrollTop = el.scrollHeight
|
||||
} catch (e) {
|
||||
// ignore
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// 当 messages 变化时自动滚动(仅在首次加载或发送新消息时)
|
||||
// 移除原来的watcher,因为现在需要更精确地控制滚动行为
|
||||
|
||||
// 表情选择
|
||||
const onEmojiSelect = (emoji) => {
|
||||
if (draft.value) {
|
||||
draft.value += emoji
|
||||
} else {
|
||||
draft.value = emoji
|
||||
}
|
||||
draft.value = (draft.value || '') + emoji
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
const params = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
@ -295,47 +394,83 @@ const params = reactive({
|
||||
const getUsers = (append = false) => {
|
||||
getUserList(params).then((res) => {
|
||||
if (append) {
|
||||
// append new items
|
||||
userList.value = userList.value.concat(res.list || [])
|
||||
} else {
|
||||
userList.value = res.list || []
|
||||
}
|
||||
// if first load and no active user, select first
|
||||
// 首次加载且没有选中用户时,默认选中第一个
|
||||
if (!append && userList.value.length && !activeUser.value.user_id) {
|
||||
onSelectUser(userList.value[0].user_id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 当 UserCard 触发 load(分页)时调用,加载下一页并滚动列表到底部以显示新数据
|
||||
// 加载更多用户
|
||||
const onLoadUserList = async () => {
|
||||
params.page = params.page + 1
|
||||
await getUsers(true)
|
||||
// 等待 DOM 更新后滚动
|
||||
await nextTick()
|
||||
const el = document.querySelector('.left-panel .infinite-list')
|
||||
if (el) {
|
||||
// 滚动到底部以展示新加载的用户
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async (message) => {
|
||||
send_message(message).then((res) => {
|
||||
}).catch((err) => {
|
||||
ElMessage({ type: 'error', message: '消息发送失败' })
|
||||
})
|
||||
// 定时器相关函数
|
||||
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 - 60px);
|
||||
height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
@ -382,13 +517,37 @@ onMounted(() => {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
background: #f5f7fa
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
// 加载更多样式
|
||||
.loading-more {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-footer {
|
||||
height: 96px;
|
||||
padding: 5px 5px;
|
||||
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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user