refactor(im): 移除 TIP_TIME 消息类型,时间分隔条改为渲染时按 prevMessage.sendTime 计算

顺带修复 Bug-Y(删除最后一条消息后孤立时间分隔条)
pull/881/head
YunaiV 2026-05-04 16:05:23 +08:00
parent 63c4dd1096
commit 1b51926b19
7 changed files with 36 additions and 46 deletions

View File

@ -127,7 +127,7 @@ const lastSenderDisplayName = computed(() => {
) )
}) })
/** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(TIP_TIME / TIP_TEXT / RECALL / 草稿态不带前缀) */ /** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(TIP_TEXT / RECALL / 草稿态不带前缀) */
const showSendName = computed(() => { const showSendName = computed(() => {
if (draft.value) { if (draft.value) {
return false return false

View File

@ -529,16 +529,13 @@ function matchesActiveFilter(message: Message): boolean {
} }
/** /**
* 当前列表:先剔除 TIP_TIME(每行已有绝对时间,时间分隔线无意义)、 * 当前列表:先 activeFilter 过滤、再 keyword 模糊命中、最后 reverse(最新在前)
* 再 activeFilter 过滤、再 keyword 模糊命中、最后 reverse(最新在前)
* *
* 关键字命中走 textSnippetOf —— 文本拿原文、媒体拿"[图片]"等占位词、文件拿文件名 * 关键字命中走 textSnippetOf —— 文本拿原文、媒体拿"[图片]"等占位词、文件拿文件名
*/ */
const currentList = computed<Message[]>(() => { const currentList = computed<Message[]>(() => {
const trimmedKeyword = keyword.value.trim() const trimmedKeyword = keyword.value.trim()
let list = allMessages.value let list = allMessages.value.filter(matchesActiveFilter)
.filter((message) => message.type !== ImMessageType.TIP_TIME)
.filter(matchesActiveFilter)
if (trimmedKeyword) { if (trimmedKeyword) {
list = list.filter((message) => textSnippetOf(message).includes(trimmedKeyword)) list = list.filter((message) => textSnippetOf(message).includes(trimmedKeyword))
} }

View File

@ -1,15 +1,15 @@
<template> <template>
<!-- 时间分隔线(TIP_TIME=20):居中灰色时间 --> <!-- 时间分隔条:列表第一条 / 距上一条超过阈值时居中显示灰色时间 -->
<div <div
v-if="isTipTime" v-if="shouldShowTimeTip"
class="flex items-center justify-center px-4 pt-2.5 pb-0.5 text-12px text-[var(--el-text-color-disabled)]" class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-disabled)]"
> >
{{ formatTipTime(message.sendTime) }} {{ formatTipTime(message.sendTime) }}
</div> </div>
<!-- 系统提示文案(TIP_TEXT=21) --> <!-- 系统提示文案(TIP_TEXT=21) -->
<div <div
v-else-if="isTipText" v-if="isTipText"
class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]" class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
> >
{{ tipText }} {{ tipText }}
@ -226,6 +226,7 @@ import {
ImGroupReceiptStatus, ImGroupReceiptStatus,
ImConversationType, ImConversationType,
ImGroupMemberRole, ImGroupMemberRole,
TIME_TIP_GAP_MS,
isGroupNotification, isGroupNotification,
isNormalMessage isNormalMessage
} from '@/views/im/utils/constants' } from '@/views/im/utils/constants'
@ -268,6 +269,8 @@ defineOptions({ name: 'ImMessageItem' })
const props = defineProps<{ const props = defineProps<{
message: Message message: Message
/** 列表中的上一条消息,用于判断是否要在当前消息上方渲染时间分隔条;不传按列表第一条处理 */
prevMessage?: Message
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -292,10 +295,20 @@ const { confirm: confirmDialog, success: successMessage } = useMessage()
/** 是否已撤回:pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL,渲染只需识别 type */ /** 是否已撤回:pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL,渲染只需识别 type */
const isRecall = computed(() => props.message.type === ImMessageType.RECALL) const isRecall = computed(() => props.message.type === ImMessageType.RECALL)
/** 时间分隔线 / 系统提示文案 */ /** 系统提示文案 */
const isTipTime = computed(() => props.message.type === ImMessageType.TIP_TIME)
const isTipText = computed(() => props.message.type === ImMessageType.TIP_TEXT) const isTipText = computed(() => props.message.type === ImMessageType.TIP_TEXT)
/** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染 */
const shouldShowTimeTip = computed(() => {
if (!props.message.sendTime) {
return false
}
if (!props.prevMessage?.sendTime) {
return true
}
return props.message.sendTime - props.prevMessage.sendTime > TIME_TIP_GAP_MS
})
/** 是否文本消息 */ /** 是否文本消息 */
const isText = computed(() => props.message.type === ImMessageType.TEXT) const isText = computed(() => props.message.type === ImMessageType.TEXT)
const isImage = computed(() => props.message.type === ImMessageType.IMAGE) const isImage = computed(() => props.message.type === ImMessageType.IMAGE)
@ -540,10 +553,10 @@ const RECALL_WINDOW_MS = 2 * 60 * 1000
* - 引用:已落库(id≠0)+ 未撤回的消息可引用,引用块写入 draftStore.reply * - 引用:已落库(id≠0)+ 未撤回的消息可引用,引用块写入 draftStore.reply
* - 撤回 / 删除:互斥;自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」(推服务器),其它显示「删除」(仅本地清) * - 撤回 / 删除:互斥;自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」(推服务器),其它显示「删除」(仅本地清)
* *
* TIP_TIME / TIP_TEXT 态不弹菜单 * TIP_TEXT 态不弹菜单
*/ */
async function handleContextMenu(e: MouseEvent) { async function handleContextMenu(e: MouseEvent) {
if (isTipTime.value || isTipText.value) { if (isTipText.value) {
return return
} }

View File

@ -80,12 +80,16 @@
<!-- data-message-id 给 MessageHistory "定位到聊天位置" 用:父级通过 querySelector <!-- data-message-id 给 MessageHistory "定位到聊天位置" 用:父级通过 querySelector
找到这层 wrapper,scrollIntoView + 加高亮 class;id=0 的本地占位消息跳过 --> 找到这层 wrapper,scrollIntoView + 加高亮 class;id=0 的本地占位消息跳过 -->
<div <div
v-for="msg in messages" v-for="(msg, index) in messages"
:key="msg.id || msg.clientMessageId" :key="msg.id || msg.clientMessageId"
:data-message-id="msg.id || ''" :data-message-id="msg.id || ''"
class="message-panel__message-anchor" class="message-panel__message-anchor"
> >
<MessageItem :message="msg" @locate="handleLocate" /> <MessageItem
:message="msg"
:prev-message="messages[index - 1]"
@locate="handleLocate"
/>
</div> </div>
<!-- 回到底部浮动按钮(滚动不在底部时显示) --> <!-- 回到底部浮动按钮(滚动不在底部时显示) -->

View File

@ -7,12 +7,11 @@ import {
ImMessageType, ImMessageType,
ImMessageStatus, ImMessageStatus,
IM_AT_ALL_USER_ID, IM_AT_ALL_USER_ID,
TIME_TIP_GAP_MS,
isGroupNotification, isGroupNotification,
isNormalMessage isNormalMessage
} from '../../utils/constants' } from '../../utils/constants'
import { getCurrentUserId, imStorage, removeQuietly, StorageKeys } from '../../utils/storage' import { getCurrentUserId, imStorage, removeQuietly, StorageKeys } from '../../utils/storage'
import { generateClientMessageId, parseRecallMessageId } from '../../utils/message' import { parseRecallMessageId } from '../../utils/message'
import { resolveConversationLastContent } from '../../utils/conversation' import { resolveConversationLastContent } from '../../utils/conversation'
import { tryGetSenderDisplayName } from '../../utils/user' import { tryGetSenderDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore' import { useGroupStore } from './groupStore'
@ -297,8 +296,7 @@ export const useConversationStore = defineStore('imConversationStore', {
top: false, top: false,
muted: false, muted: false,
atMe: false, atMe: false,
atAll: false, atAll: false
lastTimeTip: 0
} }
}, },
@ -359,7 +357,7 @@ export const useConversationStore = defineStore('imConversationStore', {
* 主要行为(子步骤见函数内 // x.y 注释): * 主要行为(子步骤见函数内 // x.y 注释):
* 1. 会话定位:查找或创建 + 去重合并 * 1. 会话定位:查找或创建 + 去重合并
* 2. 更新会话元数据:摘要、@ 标记、未读数 * 2. 更新会话元数据:摘要、@ 标记、未读数
* 3. 插入消息:时间分隔线 + 按 id 有序插入 * 3. 按 id 有序插入消息
* 4. 收尾:更新游标 + 持久化 * 4. 收尾:更新游标 + 持久化
*/ */
insertMessage( insertMessage(
@ -459,32 +457,12 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.unreadCount++ conversation.unreadCount++
} }
// 3.1 时间分隔线:距上条 TIP_TIME 超过 10 分钟则插入一条 // 3. 按真实 id 升序插入;id=0 的本地占位(SENDING)固定停在末尾,
const sendTime = messageInfo.sendTime || Date.now() // 并发场景下:占位仍在末尾时收到的真实消息会追加在占位之后,列表不严格按 id 升序
if (!conversation.lastTimeTip || conversation.lastTimeTip < sendTime - TIME_TIP_GAP_MS) {
conversation.messages.push({
id: 0,
clientMessageId: generateClientMessageId(),
type: ImMessageType.TIP_TIME,
content: '',
status: ImMessageStatus.UNREAD,
sendTime,
senderId: 0,
targetId: conversationInfo.targetId,
selfSend: false
})
conversation.lastTimeTip = sendTime
}
// 3.2 根据 id 插入到正确位置(防止乱序);tip 消息 / 本地临时消息直接追加末尾
let insertIndex = conversation.messages.length let insertIndex = conversation.messages.length
if (messageInfo.id) { if (messageInfo.id) {
for (let index = 0; index < conversation.messages.length; index++) { for (let index = 0; index < conversation.messages.length; index++) {
const existing = conversation.messages[index] const existing = conversation.messages[index]
// TIP_TIME 没有 id,不参与排序
if (existing.type === ImMessageType.TIP_TIME) {
continue
}
if (existing.id && messageInfo.id < existing.id) { if (existing.id && messageInfo.id < existing.id) {
insertIndex = index insertIndex = index
break break

View File

@ -62,7 +62,6 @@ export interface Conversation {
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我 atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员 atAll?: boolean // 群聊:是否有人 @全体成员
lastTimeTip?: number // 最后一条"时间分隔线"的时间戳,判断是否需要插入下一条 TIP_TIME
} }
// 消息数据结构 // 消息数据结构

View File

@ -8,7 +8,6 @@ export const ImMessageType = {
RECALL: 10, // 撤回 RECALL: 10, // 撤回
READ: 11, // 已读 READ: 11, // 已读
RECEIPT: 12, // 回执 RECEIPT: 12, // 回执
TIP_TIME: 20, // 时间分隔线(前端本地生成,不发送到后端)
TIP_TEXT: 21, // 提示文本(撤回提示等) TIP_TEXT: 21, // 提示文本(撤回提示等)
// 好友通知(1201-1210 复用 OpenIM 段位编号) // 好友通知(1201-1210 复用 OpenIM 段位编号)
FRIEND_REQUEST_APPROVED: 1201, // 好友申请被同意 FRIEND_REQUEST_APPROVED: 1201, // 好友申请被同意
@ -141,7 +140,7 @@ export const PRIVATE_MESSAGE_PULL_SIZE = 100
/** 每次拉取群聊消息的最大条数(后端上限 1000,前端取保守值 100) */ /** 每次拉取群聊消息的最大条数(后端上限 1000,前端取保守值 100) */
export const GROUP_MESSAGE_PULL_SIZE = 100 export const GROUP_MESSAGE_PULL_SIZE = 100
/** 会话之间插入"时间分隔线"的阈值:10 分钟 */ /** 消息之间渲染「时间分隔条」的阈值:10 分钟 */
export const TIME_TIP_GAP_MS = 10 * 60 * 1000 export const TIME_TIP_GAP_MS = 10 * 60 * 1000
/** /**