feat(im): 优化 ConversationItem.vue 逻辑

im
YunaiV 2026-04-24 21:54:20 +08:00
parent 68d3ad10d4
commit d6f96a56a2
1 changed files with 606 additions and 0 deletions

View File

@ -0,0 +1,606 @@
import { defineStore } from 'pinia'
import { store } from '@/store'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import {
ImConversationType,
ImMessageType,
ImMessageStatus,
TIME_TIP_GAP_MS
} from '../../utils/constants'
import { StorageKeys } from '../../utils/storage'
import { parseMessage, buildRecallTip, type TextMessage } from '../../utils/message'
import type { Conversation, Message, ConversationsData } from '../types'
const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识atUserIds 中包含 -1 表示 @all
/** 获取当前登录用户编号 */
function getCurrentUserId(): number {
const { wsCache } = useCache()
const user = wsCache.get(CACHE_KEY.USER)?.user
return Number(user?.id) || 0
}
/** 当前登录用户的会话列表 localStorage key */
function currentConversationsKey(): string {
return StorageKeys.conversations(getCurrentUserId())
}
export const useConversationStore = defineStore('imConversationStore', {
state: () => ({
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊)
activeConversation: null as Conversation | null, // 当前激活的会话
privateMessageMaxId: 0, // 私聊最大消息 id作为 pull 的游标
groupMessageMaxId: 0, // 群聊最大消息 id作为 pull 的游标
loading: false // 是否正在批量加载(例如离线消息拉取期间),避免频繁写 localStorage
}),
getters: {
/**
*
* 1. top=true
* 2. lastSendTime
*/
sortedConversations(state): Conversation[] {
return [...state.conversations]
.filter((c) => !c.deleted)
.sort((a, b) => {
const aTop = a.top ? 1 : 0
const bTop = b.top ? 1 : 0
if (aTop !== bTop) {
return bTop - aTop
}
return b.lastSendTime - a.lastSendTime
})
},
/** 当前会话的消息列表 */
activeMessages(state): Message[] {
return state.activeConversation?.messages || []
},
/** 未读总数(免打扰会话不计入)—— 用于 ToolBar 红点 */
totalUnread(state): number {
return state.conversations
.filter((c) => !c.deleted && !c.muted)
.reduce((sum, c) => sum + (c.unreadCount || 0), 0)
}
},
actions: {
// ==================== 本地存储 ====================
/** 从 localStorage 恢复会话数据 */
loadConversations() {
const item = localStorage.getItem(currentConversationsKey())
if (!item) {
return
}
try {
// 反序列化缓存数据恢复消息游标privateMessageMaxId / groupMessageMaxId
const storageData = JSON.parse(item) as ConversationsData
this.privateMessageMaxId = Number(storageData.privateMessageMaxId) || 0
this.groupMessageMaxId = Number(storageData.groupMessageMaxId) || 0
// 回放会话列表,同时修正重启前遗留的"发送中"状态
if (storageData.conversations && storageData.conversations.length > 0) {
for (const conversation of storageData.conversations) {
if (conversation.messages) {
conversation.messages.forEach((message) => {
// 发送中状态的消息标记为失败:重启后不可能仍处在发送中
if (message.status === ImMessageStatus.SENDING) {
message.status = ImMessageStatus.FAILED
}
})
}
}
this.conversations = storageData.conversations
}
} catch (e) {
console.error('[IM] 本地消息缓存读取失败', e)
}
},
/** 持久化到 localStorage */
saveToStorage() {
// loading 期间跳过,避免大量写入阻塞主线程
if (this.loading) {
return
}
// TODO @AI这个方案可能存不下太多数据需要调整
const storageData: ConversationsData = {
privateMessageMaxId: this.privateMessageMaxId,
groupMessageMaxId: this.groupMessageMaxId,
conversations: this.conversations.filter((c) => !c.deleted)
}
try {
localStorage.setItem(currentConversationsKey(), JSON.stringify(storageData))
} catch (e) {
console.error('[IM] 本地消息缓存存储失败', e)
}
},
// ==================== 会话查找 / 打开 ====================
/** 查找会话:按 (type, targetId) 组合主键 */
findConversation(type: number, targetId: number): Conversation | undefined {
return this.conversations.find((c) => c.type === type && c.targetId === targetId)
},
/**
*
*
* friendStore / groupStore muted
* options /
* conversationStore import friendStore/groupStore
*/
openConversation(
targetId: number,
type: number,
showName: string,
showImage: string,
options?: { muted?: boolean }
): Conversation {
// 按 (type, targetId) 查找已有会话,不存在则新建并插到列表头部
let conversation = this.findConversation(type, targetId)
if (!conversation) {
conversation = this.createEmptyConversation(type, targetId, showName, showImage)
if (options?.muted !== undefined) {
conversation.muted = options.muted
}
this.conversations.unshift(conversation)
} else {
// 已存在会话:用最新元数据刷新 showName / showImage / muted
if (showName) {
conversation.showName = showName
}
if (showImage) {
conversation.showImage = showImage
}
if (options?.muted !== undefined) {
conversation.muted = options.muted
}
}
this.setActiveConversation(conversation)
return conversation
},
/** 设置当前会话,同时清零未读数 + 清除 @ 标记 */
setActiveConversation(conversation: Conversation | null) {
this.activeConversation = conversation
if (conversation) {
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
this.saveToStorage()
}
},
/** 创建空会话(抽取公共逻辑,供 insertMessage / openConversation 复用) */
createEmptyConversation(type: number, targetId: number, showName: string, showImage: string): Conversation {
return {
targetId,
type,
showName,
showImage,
lastContent: '',
lastSendTime: 0,
unreadCount: 0,
messages: [],
deleted: false,
top: false,
muted: false,
atMe: false,
atAll: false,
senderNickName: '',
lastTimeTip: 0
}
},
// ==================== 置顶 / 免打扰 / 删除会话 ====================
/** 将某个会话置顶态切换 */
setTop(type: number, targetId: number, top: boolean) {
const conversation = this.findConversation(type, targetId)
if (!conversation) {
return
}
conversation.top = top
this.saveToStorage()
},
/** 设置会话免打扰(本地状态;后端同步由 friendStore / groupStore + /muted API 负责) */
setMuted(type: number, targetId: number, muted: boolean) {
const conversation = this.findConversation(type, targetId)
if (!conversation) {
return
}
conversation.muted = muted
this.saveToStorage()
},
/** 删除会话(软删:标记 deleted=true持久化时过滤*/
removeConversation(type: number, targetId: number) {
const conversation = this.findConversation(type, targetId)
if (!conversation) {
return
}
if (this.activeConversation === conversation) {
this.activeConversation = null
}
conversation.deleted = true
this.saveToStorage()
},
removePrivateConversation(friendId: number) {
this.removeConversation(ImConversationType.PRIVATE, friendId)
},
removeGroupConversation(groupId: number) {
this.removeConversation(ImConversationType.GROUP, groupId)
},
// ==================== 消息插入 / 更新 ====================
/**
*
*
* // x.y 注释):
* 1. +
* 2. @
* 3. 线 + id
* 4. +
*/
insertMessage(
conversationInfo: { type: number; targetId: number; showName: string; showImage: string },
messageInfo: Message
) {
// 1.1 查找或自动创建会话
let conversation = this.findConversation(conversationInfo.type, conversationInfo.targetId)
if (!conversation) {
conversation = this.createEmptyConversation(
conversationInfo.type,
conversationInfo.targetId,
conversationInfo.showName,
conversationInfo.showImage
)
this.conversations.unshift(conversation)
}
// 1.2 去重合并:优先按 id其次按 clientMessageId。命中则覆盖更新并返回
const existingIndex = conversation.messages.findIndex((message) => {
if (messageInfo.id && message.id && message.id === messageInfo.id) {
return true
}
return !!(messageInfo.clientMessageId && message.clientMessageId && message.clientMessageId === messageInfo.clientMessageId)
})
if (existingIndex >= 0) {
// 覆盖更新,保留本地已有但服务端未带的字段(如 senderNickName
conversation.messages[existingIndex] = { ...conversation.messages[existingIndex], ...messageInfo }
conversation.lastSendTime = messageInfo.sendTime || conversation.lastSendTime
this.updateMaxId(conversationInfo.type, messageInfo.id)
this.saveToStorage()
return
}
// 2.1 更新会话摘要lastContent / lastSendTime / senderNickName
conversation.lastContent = this.resolveLastContent(messageInfo)
conversation.lastSendTime = messageInfo.sendTime || Date.now()
conversation.senderNickName = messageInfo.senderNickName || ''
// 2.2 群聊 @ 标记(仅对方消息 + 未读态有效)
if (
!messageInfo.selfSend &&
conversation.type === ImConversationType.GROUP &&
messageInfo.atUserIds &&
messageInfo.atUserIds.length > 0 &&
messageInfo.status !== ImMessageStatus.READ
) {
const currentUserId = getCurrentUserId()
if (currentUserId && messageInfo.atUserIds.includes(currentUserId)) {
conversation.atMe = true
}
if (messageInfo.atUserIds.includes(AT_ALL_FLAG)) {
conversation.atAll = true
}
}
// 2.3 未读数:非当前会话 + 非自己发送 + 非系统 tip + 非已读 => +1
const isActive =
this.activeConversation?.type === conversationInfo.type &&
this.activeConversation?.targetId === conversationInfo.targetId
const isTipMessage =
messageInfo.type === ImMessageType.TIP_TEXT || messageInfo.type === ImMessageType.TIP_TIME
if (
!messageInfo.selfSend &&
!isActive &&
!isTipMessage &&
messageInfo.status !== ImMessageStatus.READ &&
messageInfo.status !== ImMessageStatus.RECALL
) {
conversation.unreadCount++
}
// 3.1 时间分隔线:距上条 TIP_TIME 超过 10 分钟则插入一条
const sendTime = messageInfo.sendTime || Date.now()
if (!conversation.lastTimeTip || conversation.lastTimeTip < sendTime - TIME_TIP_GAP_MS) {
conversation.messages.push({
id: 0,
clientMessageId: `tip-${sendTime}`,
type: ImMessageType.TIP_TIME,
content: '',
status: ImMessageStatus.UNREAD,
sendTime,
senderId: 0,
senderNickName: '',
targetId: conversationInfo.targetId,
selfSend: false
})
conversation.lastTimeTip = sendTime
}
// 3.2 根据 id 插入到正确位置防止乱序tip 消息 / 本地临时消息直接追加末尾
let insertIndex = conversation.messages.length
if (messageInfo.id) {
for (let index = 0; index < conversation.messages.length; index++) {
const existing = conversation.messages[index]
// TIP_TIME 没有 id不参与排序
if (existing.type === ImMessageType.TIP_TIME) {
continue
}
if (existing.id && messageInfo.id < existing.id) {
insertIndex = index
break
}
}
}
conversation.messages.splice(insertIndex, 0, messageInfo)
// 4.1 更新游标
this.updateMaxId(conversationInfo.type, messageInfo.id)
// 4.2 持久化到 localStorage
this.saveToStorage()
},
/** 根据消息类型计算会话列表最后一条摘要 */
resolveLastContent(messageInfo: Message): string {
switch (messageInfo.type) {
case ImMessageType.IMAGE:
return '[图片]'
case ImMessageType.FILE:
return '[文件]'
case ImMessageType.VOICE:
return '[语音]'
case ImMessageType.VIDEO:
return '[视频]'
case ImMessageType.RECALL:
return buildRecallTip(messageInfo.senderNickName, messageInfo.selfSend)
case ImMessageType.TEXT:
case ImMessageType.TIP_TEXT:
return parseMessage<TextMessage>(messageInfo.content)?.content ?? ''
default:
return parseMessage<TextMessage>(messageInfo.content)?.content ?? ''
}
},
/**
* clientMessageId
*
* SENDING id=0 + clientMessageId
* idsendTimestatus
*/
updateMessageState(
conversationType: number,
targetId: number,
clientMessageId: string,
updates: Partial<Message>
) {
const conversation = this.findConversation(conversationType, targetId)
if (!conversation) {
return
}
const message = conversation.messages.find((item) => item.clientMessageId === clientMessageId)
if (!message) {
return
}
Object.assign(message, updates)
if (updates.id) {
this.updateMaxId(conversationType, updates.id)
}
this.saveToStorage()
},
/**
* type RECALL
* RECALL messageId
*/
applyRecall(
conversationType: number,
targetId: number,
messageId: number,
senderNickName: string,
selfSend: boolean
) {
const conversation = this.findConversation(conversationType, targetId)
if (!conversation) {
return
}
const message = conversation.messages.find((item) => item.id === messageId)
if (!message) {
return
}
message.type = ImMessageType.RECALL
message.status = ImMessageStatus.RECALL
message.content = JSON.stringify({
content: buildRecallTip(senderNickName, selfSend)
})
// 最后一条消息是刚撤回的,才更新会话摘要
if (conversation.messages[conversation.messages.length - 1]?.id === messageId) {
conversation.lastContent = buildRecallTip(senderNickName, selfSend)
}
this.saveToStorage()
},
/** 处理对方已读 / 群回执:更新发送方自己消息的 status / readCount / receiptStatus */
applyReadReceipt(options: {
conversationType: number
targetId: number
// 私聊:把和该好友的「自己发送的」消息标为已读
markPrivateRead?: boolean
// 群聊:针对单条消息的回执刷新
groupMessageId?: number
readCount?: number
receiptStatus?: number
}) {
const conversation = this.findConversation(options.conversationType, options.targetId)
if (!conversation) {
return
}
if (options.conversationType === ImConversationType.PRIVATE && options.markPrivateRead) {
conversation.messages.forEach((message) => {
if (message.selfSend && message.status !== ImMessageStatus.RECALL) {
message.status = ImMessageStatus.READ
}
})
} else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
const message = conversation.messages.find((item) => item.id === options.groupMessageId)
if (message) {
if (options.readCount !== undefined) {
message.readCount = options.readCount
}
if (options.receiptStatus !== undefined) {
message.receiptStatus = options.receiptStatus
}
}
}
this.saveToStorage()
},
/**
* "删除"
* id id 0 clientMessageId
*/
removeLocalMessage(
conversationType: number,
targetId: number,
key: { id?: number; clientMessageId?: string }
) {
const conversation = this.findConversation(conversationType, targetId)
if (!conversation) {
return
}
const index = conversation.messages.findIndex((message) => {
if (key.id && message.id && message.id === key.id) {
return true
}
return !!(key.clientMessageId && message.clientMessageId && message.clientMessageId === key.clientMessageId)
})
if (index < 0) {
return
}
conversation.messages.splice(index, 1)
// 如果删的是最后一条,刷新摘要
if (index === conversation.messages.length) {
const last = conversation.messages[conversation.messages.length - 1]
conversation.lastContent = last ? this.resolveLastContent(last) : ''
conversation.lastSendTime = last?.sendTime || conversation.lastSendTime
conversation.senderNickName = last?.senderNickName || ''
}
this.saveToStorage()
},
/**
* /
*
*/
markActiveAsRead() {
if (!this.activeConversation) {
return
}
this.activeConversation.unreadCount = 0
this.activeConversation.atMe = false
this.activeConversation.atAll = false
this.activeConversation.messages.forEach((message) => {
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
message.status = ImMessageStatus.READ
}
})
this.saveToStorage()
},
/** 更新 privateMessageMaxId / groupMessageMaxId 游标 */
updateMaxId(conversationType: number, messageId?: number) {
if (!messageId) {
return
}
if (conversationType === ImConversationType.PRIVATE) {
if (messageId > this.privateMessageMaxId) {
this.privateMessageMaxId = messageId
}
} else if (conversationType === ImConversationType.GROUP) {
if (messageId > this.groupMessageMaxId) {
this.groupMessageMaxId = messageId
}
}
},
/** 离线消息加载完后重排:按 lastSendTime 倒序并持久化 */
refreshConversations() {
this.conversations.sort((a, b) => b.lastSendTime - a.lastSendTime)
this.saveToStorage()
},
/** 根据 friendStore 最新的好友信息同步对应私聊会话的展示名 / 头像 / 免打扰 */
updateConversationFromFriend(friendId: number, info: { nickName?: string; showImage?: string; muted?: boolean }) {
const conversation = this.findConversation(ImConversationType.PRIVATE, friendId)
if (!conversation) {
return
}
let changed = false
if (info.nickName && conversation.showName !== info.nickName) {
conversation.showName = info.nickName
changed = true
}
if (info.showImage !== undefined && conversation.showImage !== info.showImage) {
conversation.showImage = info.showImage || ''
changed = true
}
if (info.muted !== undefined && conversation.muted !== info.muted) {
conversation.muted = info.muted
changed = true
}
if (changed) {
this.saveToStorage()
}
},
/** 根据 groupStore 最新的群信息同步对应群聊会话的展示名 / 头像 / 免打扰 */
updateConversationFromGroup(groupId: number, info: { name?: string; showImage?: string; muted?: boolean }) {
const conversation = this.findConversation(ImConversationType.GROUP, groupId)
if (!conversation) {
return
}
let changed = false
if (info.name && conversation.showName !== info.name) {
conversation.showName = info.name
changed = true
}
if (info.showImage !== undefined && conversation.showImage !== info.showImage) {
conversation.showImage = info.showImage || ''
changed = true
}
if (info.muted !== undefined && conversation.muted !== info.muted) {
conversation.muted = info.muted
changed = true
}
if (changed) {
this.saveToStorage()
}
}
}
})
export const useConversationStoreWithOut = () => {
return useConversationStore(store)
}