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

顺带修复 Bug-Y(删除最后一条消息后孤立时间分隔条)
im
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
* - 引用已落库id0+ 未撤回的消息可引用引用块写入 draftStore.reply * - 引用已落库id0+ 未撤回的消息可引用引用块写入 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
找到这层 wrapperscrollIntoView + 加高亮 classid=0 的本地占位消息跳过 --> 找到这层 wrapperscrollIntoView + 加高亮 classid=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
/** /**