refactor(im): 移除 TIP_TIME 消息类型,时间分隔条改为渲染时按 prevMessage.sendTime 计算
顺带修复 Bug-Y(删除最后一条消息后孤立时间分隔条)im
parent
63c4dd1096
commit
1b51926b19
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
<!-- 回到底部浮动按钮(滚动不在底部时显示) -->
|
<!-- 回到底部浮动按钮(滚动不在底部时显示) -->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ export interface Conversation {
|
||||||
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||||
atMe?: boolean // 群聊:是否有人 @我
|
atMe?: boolean // 群聊:是否有人 @我
|
||||||
atAll?: boolean // 群聊:是否有人 @全体成员
|
atAll?: boolean // 群聊:是否有人 @全体成员
|
||||||
lastTimeTip?: number // 最后一条"时间分隔线"的时间戳,判断是否需要插入下一条 TIP_TIME
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息数据结构
|
// 消息数据结构
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue