|
|
@@ -4,73 +4,80 @@
|
|
|
<div class="left-action" @click="goBack">
|
|
|
<i class="el-icon-arrow-left" />
|
|
|
</div>
|
|
|
- <span class="chat-title">{{ currentContact.nickname }}</span>
|
|
|
+ <span class="chat-title">{{ chatReceiver.screenName }}</span>
|
|
|
<i class="el-icon-more right-icon" />
|
|
|
</header>
|
|
|
|
|
|
<div ref="messageBox" class="message-container" @scroll="handleScroll">
|
|
|
|
|
|
- <div v-if="hasOlder" class="loading-tips">
|
|
|
+ <div v-if="hasOlder && isLoading && isPageLoadingOlder" class="loading-tips">
|
|
|
<i class="el-icon-loading" /> 正在加载历史消息...
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
- v-for="msg in messageList"
|
|
|
- :id="'msg-' + msg.messageId"
|
|
|
+ v-for="(msg, index) in formattedMessageList"
|
|
|
:key="msg.messageId"
|
|
|
- :class="['message-row', isMe(msg.sender.userId) ? 'msg-me' : 'msg-other']"
|
|
|
>
|
|
|
- <el-avatar
|
|
|
- :size="38"
|
|
|
- shape="square"
|
|
|
- :src="isMe(msg.sender.userId) ? myAvatar : (msg.sender.avatarUrl || currentContact.avatar)"
|
|
|
- />
|
|
|
-
|
|
|
- <div class="message-content">
|
|
|
- <div v-if="msg.msgType === 1" class="msg-text">{{ msg.content }}</div>
|
|
|
-
|
|
|
- <div v-else-if="msg.msgType === 2" class="msg-image" @click="onPreview(msg.objectUrl)">
|
|
|
- <el-image :src="msg.objectUrl" fit="cover" />
|
|
|
- </div>
|
|
|
+ <div v-if="msg._showTimeText" class="chat-time-node">
|
|
|
+ <span>{{ msg._showTimeText }}</span>
|
|
|
+ </div>
|
|
|
|
|
|
- <div
|
|
|
- v-else-if="msg.msgType === 3"
|
|
|
- class="msg-audio"
|
|
|
- :style="{ width: 60 + (msg.duration || 5) * 4 + 'px' }"
|
|
|
- @click="toggleAudio(msg, msg.messageId)"
|
|
|
- >
|
|
|
- <i :class="['audio-icon', currentPlayingId === msg.messageId ? 'el-icon-loading' : 'el-icon-phone-outline']" />
|
|
|
- <span class="audio-duration">{{ msg.duration || 5 }}"</span>
|
|
|
- <div v-if="msg.isUnread" class="audio-unread-dot" />
|
|
|
- </div>
|
|
|
+ <div
|
|
|
+ :id="'msg-' + msg.messageId"
|
|
|
+ :class="['message-row', isMe(msg.sender.userId) ? 'msg-me' : 'msg-other']"
|
|
|
+ >
|
|
|
+ <el-avatar
|
|
|
+ :size="38"
|
|
|
+ shape="square"
|
|
|
+ :src="isMe(msg.sender.userId) ? my.avatarUrl : (msg.sender.avatarUrl || chatReceiver.avatarUrl)"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="message-content">
|
|
|
+ <div v-if="msg.msgType === 1" class="msg-text">{{ msg.content }}</div>
|
|
|
+
|
|
|
+ <div v-else-if="msg.msgType === 2" class="msg-image" @click="onPreview(msg.objectUrl)">
|
|
|
+ <el-image :src="msg.objectUrl" fit="cover" />
|
|
|
+ </div>
|
|
|
|
|
|
- <div v-else-if="msg.msgType === 4" class="msg-video" @click="viewVideo(msg.objectUrl)">
|
|
|
- <el-image :src="require('@/assets/img/video.jpg')" fit="cover" class="video-cover" />
|
|
|
- <div class="video-play-mask">
|
|
|
- <i class="el-icon-video-play play-icon" />
|
|
|
+ <div
|
|
|
+ v-else-if="msg.msgType === 3"
|
|
|
+ class="msg-audio"
|
|
|
+ :style="{ width: 60 + (msg.duration || 5) * 4 + 'px' }"
|
|
|
+ @click="toggleAudio(msg, msg.messageId)"
|
|
|
+ >
|
|
|
+ <i :class="['audio-icon', currentPlayingId === msg.messageId ? 'el-icon-loading' : 'el-icon-phone-outline']" />
|
|
|
+ <span class="audio-duration">{{ msg.duration || 5 }}"</span>
|
|
|
+ <div v-if="msg.isUnread" class="audio-unread-dot" />
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- <div v-else-if="[5, 6, 7, 99].includes(msg.msgType)" class="msg-file">
|
|
|
- <div class="file-info">
|
|
|
- <div class="file-name">{{ msg.fileName || '未知文件' }}</div>
|
|
|
- <div class="file-size">{{ msg.fileSize || '0 KB' }}</div>
|
|
|
+ <div v-else-if="msg.msgType === 4" class="msg-video" @click="viewVideo(msg.objectUrl)">
|
|
|
+ <el-image :src="require('@/assets/img/video.jpg')" fit="cover" class="video-cover" />
|
|
|
+ <div class="video-play-mask">
|
|
|
+ <i class="el-icon-video-play play-icon" />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class="file-icon-box">
|
|
|
- <i :class="getFileIcon(msg.msgType)" />
|
|
|
+
|
|
|
+ <div v-else-if="[5, 6, 7, 99].includes(msg.msgType)" class="msg-file">
|
|
|
+ <div class="file-info">
|
|
|
+ <div class="file-name">{{ msg.fileName || '未知文件' }}</div>
|
|
|
+ <div class="file-size">{{ msg.fileSize || '0 KB' }}</div>
|
|
|
+ </div>
|
|
|
+ <div class="file-icon-box">
|
|
|
+ <i :class="getFileIcon(msg.msgType)" />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- <div v-else-if="msg.msgType === 8" class="msg-record">
|
|
|
- <div class="record-title">聊天记录</div>
|
|
|
- <div v-for="(line, idx) in msg.previewLines" :key="idx" class="record-preview">
|
|
|
- {{ line }}
|
|
|
+ <div v-else-if="msg.msgType === 8" class="msg-record">
|
|
|
+ <div class="record-title">聊天记录</div>
|
|
|
+ <div v-for="(line, idx) in msg.previewLines" :key="idx" class="record-preview">
|
|
|
+ {{ line }}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div v-if="hasNewer" class="loading-tips">
|
|
|
+ <div v-if="hasNewer && isLoading && isPageLoadingNewer" class="loading-tips">
|
|
|
<i class="el-icon-loading" /> 正在加载新消息...
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -90,7 +97,8 @@
|
|
|
:src="currentAudioUrl"
|
|
|
style="display: none;"
|
|
|
@ended="onAudioEnded"
|
|
|
- @error="onAudioError" />
|
|
|
+ @error="onAudioError"
|
|
|
+ />
|
|
|
|
|
|
<el-dialog
|
|
|
:visible.sync="videoDialogVisible"
|
|
|
@@ -115,24 +123,26 @@
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
-import { getChatRecords } from '@/api/chat'
|
|
|
+import { getChatDialogue, getChatRecords, updateChatDialogue, getChatUser } from '@/api/chat'
|
|
|
+import { getAuthedUser } from '@/utils/auth'
|
|
|
|
|
|
export default {
|
|
|
name: 'ChatDialogue',
|
|
|
data() {
|
|
|
return {
|
|
|
- // 1. 基本配置与当前登入用户静态声明
|
|
|
- myUserId: 10001, // 对应后端 receiver/sender 中的当前用户 ID,用于判断消息左右定位
|
|
|
- myAvatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
|
|
|
+ my: null,
|
|
|
inputText: '',
|
|
|
currentAudioUrl: '',
|
|
|
currentPlayingId: null,
|
|
|
currentContact: {
|
|
|
- chatId: 65422888961, // 当前会话的唯一分布式 ID
|
|
|
- nickname: '1000条分页压力测试',
|
|
|
- avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
|
|
|
+ chatId: 0,
|
|
|
+ receiverId: 0
|
|
|
+ },
|
|
|
+ chatReceiver: {
|
|
|
+ userId: 0,
|
|
|
+ screenName: '',
|
|
|
+ avatarUrl: ''
|
|
|
},
|
|
|
-
|
|
|
// 2. 状态控制与渲染层解耦变量
|
|
|
messageList: [], // 视图层真正循环渲染的数组
|
|
|
lastReadMessageId: 0, // 后端持久化记录的用户上次查看的 Anchor ID
|
|
|
@@ -141,18 +151,132 @@ export default {
|
|
|
hasOlder: false, // 初始化默认为 true,若请求触顶且后端返回数据为空或判断到头则置为 false
|
|
|
hasNewer: false, // 初始化默认为 true,向下滚动加载更新的标记
|
|
|
isLoading: false, // 节流阀锁
|
|
|
+ isPageLoadingOlder: false, // 是否是“主动向上拉取历史”引发的加载
|
|
|
+ isPageLoadingNewer: false, // 是否是“主动向下拉取新消息”引发的加载
|
|
|
videoDialogVisible: false,
|
|
|
currentVideoUrl: ''
|
|
|
}
|
|
|
},
|
|
|
+ computed: {
|
|
|
+ /**
|
|
|
+ * 带有时间聚合标记的渲染消息列表
|
|
|
+ */
|
|
|
+ formattedMessageList() {
|
|
|
+ // const TIME_THRESHOLD = 5 * 60 * 1000 // 时间差阈值:5分钟(毫秒)
|
|
|
+ const TIME_THRESHOLD = 3 * 1000 // 时间差阈值:5分钟(毫秒)
|
|
|
+
|
|
|
+ return this.messageList.map((msg, index) => {
|
|
|
+ // 深拷贝或浅拷贝出一个用于渲染的新对象,避免直接污染原数组
|
|
|
+ const rMsg = { ...msg }
|
|
|
+
|
|
|
+ // 1. 第一条消息无条件显示时间
|
|
|
+ if (index === 0) {
|
|
|
+ rMsg._showTimeText = this.formatChatTime(rMsg.createdAt)
|
|
|
+ return rMsg
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 计算当前消息与上一条消息的时间差
|
|
|
+ const prevMsg = this.messageList[index - 1]
|
|
|
+ const prevTime = new Date(prevMsg.createdAt).getTime()
|
|
|
+ const currTime = new Date(rMsg.createdAt).getTime()
|
|
|
+
|
|
|
+ if (currTime - prevTime > TIME_THRESHOLD) {
|
|
|
+ rMsg._showTimeText = this.formatChatTime(rMsg.createdAt)
|
|
|
+ } else {
|
|
|
+ rMsg._showTimeText = null
|
|
|
+ }
|
|
|
+
|
|
|
+ return rMsg
|
|
|
+ })
|
|
|
+ }
|
|
|
+ },
|
|
|
created() {
|
|
|
- // 页面创建时执行核心链条:先通过锚点初始化首屏切片
|
|
|
- this.initFirstScreen()
|
|
|
+ this.my = getAuthedUser()
|
|
|
+ this.parseRouteParams()
|
|
|
},
|
|
|
methods: {
|
|
|
+ /**
|
|
|
+ * 仿微信聊天时间精细化格式化引擎
|
|
|
+ * @param {String|Date} timeStr 后端返回的 ISO 或标准时间字符串 (例如 msg.createdAt)
|
|
|
+ */
|
|
|
+ formatChatTime(timeStr) {
|
|
|
+ if (!timeStr) return ''
|
|
|
+ const date = new Date(timeStr)
|
|
|
+ const now = new Date()
|
|
|
+
|
|
|
+ const diffMs = now.getTime() - date.getTime()
|
|
|
+ const diffDays = Math.floor(diffMs / (24 * 3600 * 1000))
|
|
|
+
|
|
|
+ // 提取时分补零
|
|
|
+ const hours = String(date.getHours()).padStart(2, '0')
|
|
|
+ const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
|
+ const timePart = `${hours}:${minutes}`
|
|
|
+
|
|
|
+ // 1. 同一天
|
|
|
+ if (date.toDateString() === now.toDateString()) {
|
|
|
+ return timePart
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 昨天
|
|
|
+ const yesterday = new Date(now)
|
|
|
+ yesterday.setDate(now.getDate() - 1)
|
|
|
+ if (date.toDateString() === yesterday.toDateString()) {
|
|
|
+ return `昨天 ${timePart}`
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 同一年内但超过昨天
|
|
|
+ if (date.getFullYear() === now.getFullYear()) {
|
|
|
+ return `${date.getMonth() + 1}月${date.getDate()}日 ${timePart}`
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 跨年
|
|
|
+ return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${timePart}`
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 精准解析并转换路由传入的 Long 型参数
|
|
|
+ */
|
|
|
+ parseRouteParams() {
|
|
|
+ const query = this.$route.query
|
|
|
+
|
|
|
+ // 1. 防御性拦截:如果关键参数缺失,进行报错提示或重定向
|
|
|
+ if (!query.chatId || !query.receiverId) {
|
|
|
+ this.$message.error('会话参数缺失,正在返回列表...')
|
|
|
+ setTimeout(() => {
|
|
|
+ this.goBack()
|
|
|
+ }, 1500)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 类型转换 (将 String 转为 JavaScript 的标准 Number,对应 Java 的 Long)
|
|
|
+ // 注意:如果你的分布式雪花 ID 超过了 16 位(大于 9007199254740991),
|
|
|
+ // 请不要转成 Number,保持字符串进行传输,否则 JS 会丢失后三位精度!
|
|
|
+ this.currentContact.chatId = Number(query.chatId)
|
|
|
+ this.currentContact.receiverId = Number(query.receiverId)
|
|
|
+
|
|
|
+ getChatUser({ 'userId': this.currentContact.receiverId }).then(resp => {
|
|
|
+ if (resp.code === 0) {
|
|
|
+ this.chatReceiver = resp.data
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 3. 联动逻辑:如果路由中没有传 lastMessageId,则强制补 0
|
|
|
+ // this.lastReadMessageId = query.lastMessageId ? Number(query.lastMessageId) : 0
|
|
|
+ this.lastReadMessageId = 0
|
|
|
+ const queryParams = {}
|
|
|
+ queryParams.chatId = this.currentContact.chatId
|
|
|
+ getChatDialogue(queryParams).then(resp => {
|
|
|
+ if (resp.code === 0) {
|
|
|
+ this.lastReadMessageId = resp.data.lastMessageId
|
|
|
+ }
|
|
|
+ }).finally(() => {
|
|
|
+ // 4. 参数解析完成后,正式向后端发起切片请求
|
|
|
+ // 页面创建时执行核心链条:先通过锚点初始化首屏切片
|
|
|
+ this.initFirstScreen()
|
|
|
+ })
|
|
|
+ },
|
|
|
// 助手函数:比对 userId 判断是否是“我”发送的
|
|
|
isMe(senderId) {
|
|
|
- return Number(senderId) === Number(this.myUserId)
|
|
|
+ return Number(senderId) === Number(this.my.userId)
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
@@ -188,13 +312,44 @@ export default {
|
|
|
this.hasNewer = resp.data.hasNext
|
|
|
|
|
|
// 精准复位将该消息钉在可视区域正中央
|
|
|
- this.scrollToMessage(this.lastReadMessageId)
|
|
|
+ // this.scrollToMessage(this.lastReadMessageId)
|
|
|
+ this.$nextTick(() => {
|
|
|
+ if (Number(this.lastReadMessageId) === 0) {
|
|
|
+ this.scrollToBottom()
|
|
|
+
|
|
|
+ if (this.messageList.length > 0) {
|
|
|
+ this.lastReadMessageId = this.messageList[this.messageList.length - 1].messageId
|
|
|
+
|
|
|
+ // 可选:在这里调用后端接口,异步上报已读回执/更新数据库中的 last_link_id
|
|
|
+ // this.reportLastReadId(this.lastReadMessageId);
|
|
|
+ const payload = {}
|
|
|
+ payload.chatId = this.currentContact.chatId
|
|
|
+ payload.lastMessageId = this.lastReadMessageId
|
|
|
+ updateChatDialogue(payload).then(resp => {
|
|
|
+ if (resp.code === 0) {
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 精准定位到特定的某条历史消息
|
|
|
+ this.scrollToMessage(this.lastReadMessageId)
|
|
|
+ }
|
|
|
+ })
|
|
|
}
|
|
|
}).finally(() => {
|
|
|
this.isLoading = false
|
|
|
})
|
|
|
},
|
|
|
-
|
|
|
+ scrollToBottom() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ const box = this.$refs.messageBox
|
|
|
+ if (box) {
|
|
|
+ // 将滚动条拉到最底部:
|
|
|
+ // 盒子的总高度(包含溢出隐藏的部分)直接赋值给滚动偏移量
|
|
|
+ box.scrollTop = box.scrollHeight
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
/**
|
|
|
* 核心监听:判定触顶/触底边界
|
|
|
*/
|
|
|
@@ -215,14 +370,55 @@ export default {
|
|
|
if (scrollBottom <= threshold && this.hasNewer) {
|
|
|
this.loadMoreData('newer')
|
|
|
}
|
|
|
+
|
|
|
+ // ==== 2. 【新增】视窗滚动时,动态捕捉当前屏幕顶部的消息作为新锚点 ====
|
|
|
+ // 采用防抖(Debounce)优化性能,避免滚动时频繁计算
|
|
|
+ clearTimeout(this.scrollTimer)
|
|
|
+ this.scrollTimer = setTimeout(() => {
|
|
|
+ this.updateCurrentAnchor()
|
|
|
+ }, 200)
|
|
|
},
|
|
|
+ updateCurrentAnchor() {
|
|
|
+ if (this.messageList.length === 0) return
|
|
|
|
|
|
+ const box = this.$refs.messageBox
|
|
|
+ const containerTop = box.getBoundingClientRect().top
|
|
|
+
|
|
|
+ // 遍历当前渲染的所有消息 DOM 节点
|
|
|
+ for (const msg of this.messageList) {
|
|
|
+ const el = document.getElementById(`msg-${msg.messageId}`)
|
|
|
+ if (el) {
|
|
|
+ const rect = el.getBoundingClientRect()
|
|
|
+ // 当某个消息气泡的底部穿过了容器顶部,且其头部在可见区附近时
|
|
|
+ if (rect.bottom > containerTop + 10) {
|
|
|
+ // 这条消息就是用户当前正在看的第一条消息
|
|
|
+ if (this.lastReadMessageId !== msg.messageId) {
|
|
|
+ this.lastReadMessageId = msg.messageId
|
|
|
+
|
|
|
+ const payload = {}
|
|
|
+ payload.chatId = this.currentContact.chatId
|
|
|
+ payload.lastMessageId = this.lastReadMessageId
|
|
|
+ updateChatDialogue(payload).then(resp => {
|
|
|
+ if (resp.code === 0) {
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
/**
|
|
|
* 核心异步翻页:双向动态加载器
|
|
|
*/
|
|
|
loadMoreData(direction) {
|
|
|
if (this.messageList.length === 0) return
|
|
|
this.isLoading = true
|
|
|
+ if (direction === 'older') {
|
|
|
+ this.isPageLoadingOlder = true // 此时顶部的“正在加载历史消息...”才会真正显示出来!
|
|
|
+ } else {
|
|
|
+ this.isPageLoadingNewer = true // 此时底部的“正在加载新消息...”才会真正显示出来!
|
|
|
+ }
|
|
|
|
|
|
const box = this.$refs.messageBox
|
|
|
const queryParams = {
|
|
|
@@ -251,6 +447,7 @@ export default {
|
|
|
}
|
|
|
}).finally(() => {
|
|
|
this.isLoading = false
|
|
|
+ this.isPageLoadingOlder = false
|
|
|
})
|
|
|
} else {
|
|
|
// 向下:边界锚点为当前渲染队列的最后一条
|
|
|
@@ -267,6 +464,7 @@ export default {
|
|
|
}
|
|
|
}).finally(() => {
|
|
|
this.isLoading = false
|
|
|
+ this.isPageLoadingNewer = false
|
|
|
})
|
|
|
}
|
|
|
},
|
|
|
@@ -283,6 +481,7 @@ export default {
|
|
|
|
|
|
// 返回列表页
|
|
|
goBack() {
|
|
|
+ // this.messageList = []
|
|
|
this.$router.push('/chat/list')
|
|
|
},
|
|
|
|
|
|
@@ -298,7 +497,7 @@ export default {
|
|
|
createdAt: new Date().toISOString(),
|
|
|
msgType: 1,
|
|
|
content: this.inputText,
|
|
|
- sender: { userId: this.myUserId },
|
|
|
+ sender: { userId: this.my.userId },
|
|
|
receiver: { userId: 0 }
|
|
|
}
|
|
|
|
|
|
@@ -311,12 +510,15 @@ export default {
|
|
|
box.scrollTop = box.scrollHeight // 强制置底
|
|
|
})
|
|
|
},
|
|
|
-
|
|
|
getFileIcon(type) {
|
|
|
- const iconMap = { 5: 'el-icon-document text-word', 6: 'el-icon-s-order text-excel', 7: 'el-icon-document-checked text-pdf', 99: 'el-icon-box text-zip' }
|
|
|
+ const iconMap = {
|
|
|
+ 5: 'el-icon-document text-word',
|
|
|
+ 6: 'el-icon-s-order text-excel',
|
|
|
+ 7: 'el-icon-document-checked text-pdf',
|
|
|
+ 99: 'el-icon-box text-zip'
|
|
|
+ }
|
|
|
return iconMap[type] || 'el-icon-document'
|
|
|
},
|
|
|
-
|
|
|
toggleAudio(msg, id) {
|
|
|
const player = this.$refs.audioPlayer
|
|
|
if (!player) return
|
|
|
@@ -600,4 +802,21 @@ export default {
|
|
|
display: block;
|
|
|
object-fit: contain; /* 保持视频原本的宽高比 */
|
|
|
}
|
|
|
+
|
|
|
+/* 聊天会话精细化时间节点样式 */
|
|
|
+.chat-time-node {
|
|
|
+ text-align: center;
|
|
|
+ margin: 14px 0 10px 0;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-time-node span {
|
|
|
+ display: inline-block;
|
|
|
+ background-color: rgba(0, 0, 0, 0.05); /* 淡淡的灰色胶囊背景 */
|
|
|
+ color: #b0b0b0; /* 柔和的文字颜色 */
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 2px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ letter-spacing: 0.3px;
|
|
|
+}
|
|
|
</style>
|