refactor(im): 拆分会话和消息本地存储

- 新增 IM IndexedDB DB 封装、schema、key helper 和 session guard
- 新增 messageStore,支持消息逐条持久化、分页加载、ack 合并、撤回和回执更新
- 调整 conversationStore 只持久化会话摘要,不再内嵌 messages 数组
- 切换发送、拉取、WebSocket、媒体上传和消息组件到 messageStore
- 增加离开 IM 时的 store 清理和本地存储序列化保护
pull/881/head
YunaiV 2026-05-27 23:46:18 +08:00
parent e1b8370267
commit 811b93d9f1
26 changed files with 1815 additions and 1029 deletions

View File

@ -83,11 +83,7 @@
</ConversationPickerPanel>
<!-- 好友视图选好友建群后发送 -->
<FriendPickerPanel
v-else
v-model:selected-ids="selectedFriendIds"
:friends="friends"
/>
<FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
</div>
<!-- 好友视图的 dialog footer建群并发送 -->
@ -119,11 +115,7 @@ import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { useMessageSender } from '../../composables/useMessageSender'
import {
ImConversationType,
ImMessageType,
isGroupConversation
} from '../../../utils/constants'
import { ImConversationType, ImMessageType, isGroupConversation } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group'
import { serializeMessage, type CardTarget } from '../../../utils/message'
@ -287,7 +279,6 @@ async function handleCreateGroupAndSend() {
name: group.name || name,
avatar: group.avatar || '',
unreadCount: 0,
messages: [],
lastContent: '',
lastSendTime: 0
}

View File

@ -4,6 +4,7 @@ import { useMessage } from '@/hooks/web/useMessage'
import { isOpenableUrl } from '@/utils/url'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore'
import { useMessageSender } from './useMessageSender'
import { useMuteOverlay } from './useMuteOverlay'
import { ImMessageStatus, ImMessageType } from '../../utils/constants'
@ -67,8 +68,7 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
},
[ImMessageType.VOICE]: {
kind: '语音',
build: (_file, url, context) =>
({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
extractResendContext: (oldContent) => {
const old = parseMessage<AudioMessage>(oldContent)
return { voiceDuration: old?.duration ?? 0 }
@ -88,7 +88,8 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
extractResendContext: (oldContent) => {
const old = parseMessage<VideoMessage>(oldContent)
// 旧 coverUrl 是 blob 说明上传期失败cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传
const reuseCover = old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined
const reuseCover =
old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined
return {
videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
videoCoverUrl: reuseCover
@ -155,6 +156,7 @@ export function ensureMediaSizeWithinLimit(
export const useMediaUploader = () => {
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const userStore = useUserStore()
const muteOverlay = useMuteOverlay()
const message = useMessage()
@ -177,7 +179,7 @@ export const useMediaUploader = () => {
const blobUrl = URL.createObjectURL(opts.file)
const clientMessageId = opts.existingClientMessageId || generateClientMessageId()
if (opts.existingClientMessageId) {
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
content: opts.buildContent(blobUrl),
status: ImMessageStatus.SENDING,
uploadProgress: 0,
@ -186,7 +188,6 @@ export const useMediaUploader = () => {
return { clientMessageId, blobUrl }
}
const placeholder: Message = {
id: 0,
clientMessageId,
type: opts.type,
content: opts.buildContent(blobUrl),
@ -198,7 +199,7 @@ export const useMediaUploader = () => {
uploadProgress: 0,
_localFile: opts.file
}
conversationStore.insertMessage(
messageStore.insertMessage(
{
type: conversation.type,
targetId: conversation.targetId,
@ -221,7 +222,7 @@ export const useMediaUploader = () => {
targetId: number,
clientMessageId: string
): void => {
conversationStore.patchMessage(conversationType, targetId, clientMessageId, {
messageStore.patchMessage(conversationType, targetId, clientMessageId, {
status: ImMessageStatus.FAILED,
uploadProgress: undefined
})
@ -244,7 +245,7 @@ export const useMediaUploader = () => {
return
}
lastPercent = percent
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
uploadProgress: percent
})
}
@ -303,7 +304,7 @@ export const useMediaUploader = () => {
clientMessageId: string
realContent: string
}): Promise<void> => {
conversationStore.patchMessage(
messageStore.patchMessage(
opts.conversation.type,
opts.conversation.targetId,
opts.clientMessageId,

View File

@ -1,5 +1,6 @@
import { watch } from 'vue'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore, type PulledMessageBatchItem } from '../store/messageStore'
import { useImWebSocketStore } from '../store/websocketStore'
import { useFriendStore } from '../store/friendStore'
import { getFriendDisplayName } from '../../utils/user'
@ -30,7 +31,7 @@ import {
MESSAGE_PRIVATE_READ_ENABLED
} from '../../utils/config'
import { buildChannelConversationStub } from '../../utils/channel'
import { getPrivateMessagePeerId } from '../../utils/message'
import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message'
import { getCurrentUserId } from '../../utils/storage'
import type { Message } from '../types'
@ -47,6 +48,7 @@ import type { Message } from '../types'
*/
export const useMessagePuller = () => {
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const wsStore = useImWebSocketStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
@ -55,7 +57,11 @@ export const useMessagePuller = () => {
/** 判断请求是否被主动取消 */
const isAbortError = (e: unknown): boolean => {
const error = e as { name?: string; code?: string; message?: string }
return error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED' || error?.message === 'canceled'
return (
error?.name === 'CanceledError' ||
error?.code === 'ERR_CANCELED' ||
error?.message === 'canceled'
)
}
/** 私聊会话归属:自己发的算"发给 receiverId 的会话",否则算"发送方的会话"curry currentUserId 进闭包减少 3 处调用方的样板 */
@ -66,7 +72,7 @@ export const useMessagePuller = () => {
const convertPrivateMessage = (message: ImPrivateMessageRespVO): Message => {
return {
id: message.id,
clientMessageId: message.clientMessageId || '',
clientMessageId: message.clientMessageId || generateClientMessageId(),
type: message.type,
content: message.content,
status: message.status,
@ -81,7 +87,7 @@ export const useMessagePuller = () => {
const convertGroupMessage = (message: ImGroupMessageRespVO): Message => {
return {
id: message.id,
clientMessageId: message.clientMessageId || '',
clientMessageId: message.clientMessageId || generateClientMessageId(),
type: message.type,
content: message.content,
status: message.status,
@ -100,7 +106,8 @@ export const useMessagePuller = () => {
const convertChannelMessage = (message: ImChannelMessageRespVO): Message => {
return {
id: message.id,
clientMessageId: '',
// TODO @AI是不是都需要使用 message 的 clientMessageId注意后端也需要有 clientMessageId
clientMessageId: generateClientMessageId(),
type: message.type,
content: message.content,
status: message.status ?? ImMessageStatus.UNREAD,
@ -161,7 +168,8 @@ export const useMessagePuller = () => {
const isPrivate = conversationType === ImConversationType.PRIVATE
const isChannel = conversationType === ImConversationType.CHANNEL
const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE
const isStillValid = () => !signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId
const isStillValid = () =>
!signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId
while (true) {
if (!isStillValid()) {
return
@ -182,26 +190,30 @@ export const useMessagePuller = () => {
break
}
// 逐条 dispatch原消息走 insertMessageRECALL 信号走 recallMessage 把同批内已 insert 的原消息更新为撤回提示。
// TODO @AI感觉这个 PulledMessageBatchItem 类名batchItems 变量名insertPulledBatch 方法名,不够能体现出 message
const batchItems: PulledMessageBatchItem[] = []
// 逐条 dispatch原消息走批量 insertRECALL 信号走批量 recall 把同批内已 insert 的原消息更新为撤回提示。
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id先更新 status 再插信号所以原消息一定先到、recallMessage 找得到
for (const raw of list) {
if (isChannel) {
const message = raw as ImChannelMessageRespVO
conversationStore.insertMessage(
convertChannelConversation(message),
convertChannelMessage(message)
)
batchItems.push({
kind: 'insert',
conversationInfo: convertChannelConversation(message),
message: convertChannelMessage(message)
})
continue
}
if (isPrivate) {
const message = raw as ImPrivateMessageRespVO
// 特殊:撤回消息的处理
if (message.type === ImMessageType.RECALL) {
conversationStore.recallMessage(
ImConversationType.PRIVATE,
getPrivatePeerId(message),
message.content
)
batchItems.push({
kind: 'recall',
conversationType: ImConversationType.PRIVATE,
targetId: getPrivatePeerId(message),
recallSignalContent: message.content
})
continue
}
// 特殊:离线 pull 期间入库的 FRIEND_* 帧(目前仅 FRIEND_ADD persistent=true也要走好友数据分发
@ -214,35 +226,40 @@ export const useMessagePuller = () => {
}
}
// 其它消息正常入会话消息列表
conversationStore.insertMessage(
convertPrivateConversation(message),
convertPrivateMessage(message)
)
batchItems.push({
kind: 'insert',
conversationInfo: convertPrivateConversation(message),
message: convertPrivateMessage(message)
})
} else {
const message = raw as ImGroupMessageRespVO
// 特殊:撤回消息的处理
if (message.type === ImMessageType.RECALL) {
conversationStore.recallMessage(
ImConversationType.GROUP,
message.groupId,
message.content
)
batchItems.push({
kind: 'recall',
conversationType: ImConversationType.GROUP,
targetId: message.groupId,
recallSignalContent: message.content
})
continue
}
// 其它消息正常入会话消息列表
conversationStore.insertMessage(
convertGroupConversation(message),
convertGroupMessage(message)
)
batchItems.push({
kind: 'insert',
conversationInfo: convertGroupConversation(message),
message: convertGroupMessage(message)
})
}
}
// 游标推进到本批最大 id与后端返回顺序无关无有效 id 直接 break 避免死翻同一批
const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
if (validIds.length === 0) {
await messageStore.insertPulledBatch(batchItems, conversationType)
break
}
const nextMinId = Math.max(...validIds)
await messageStore.insertPulledBatch(batchItems, conversationType, nextMinId)
// 游标没前进就停:当前后端契约是 id > minId理论不会出现防御后端契约变更或边界数据死翻
if (nextMinId <= minId) {
break
@ -311,21 +328,21 @@ export const useMessagePuller = () => {
await Promise.all([
pullByType(
ImConversationType.PRIVATE,
conversationStore.privateMessageMaxId,
messageStore.privateMessageMaxId,
startEpoch,
startUserId,
abortController.signal
),
pullByType(
ImConversationType.GROUP,
conversationStore.groupMessageMaxId,
messageStore.groupMessageMaxId,
startEpoch,
startUserId,
abortController.signal
),
pullByType(
ImConversationType.CHANNEL,
conversationStore.channelMessageMaxId,
messageStore.channelMessageMaxId,
startEpoch,
startUserId,
abortController.signal
@ -369,12 +386,15 @@ export const useMessagePuller = () => {
const active = conversationStore.activeConversation
if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) {
try {
const maxReadId = await apiGetPrivateMaxReadMessageId(active.targetId, abortController.signal)
const maxReadId = await apiGetPrivateMaxReadMessageId(
active.targetId,
abortController.signal
)
if (!isCurrentPull()) {
return
}
if (maxReadId) {
conversationStore.applyReadReceipt({
messageStore.applyReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: active.targetId,
privateReadMaxId: maxReadId

View File

@ -1,4 +1,5 @@
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore'
import {
sendPrivateMessage as apiSendPrivateMessage,
readPrivateMessages as apiReadPrivateMessages,
@ -20,6 +21,7 @@ import {
} from '../../utils/message'
import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants'
import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config'
import { getClientConversationId } from '../../utils/db'
import type { Conversation, Message } from '../types'
import { useUserStore } from '@/store/modules/user'
@ -57,9 +59,10 @@ interface SendExtOptions {
*/
export const useMessageSender = () => {
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const userStore = useUserStore()
/**构造本地乐观消息对象id=0 表示尚未拿到服务端消息 id */
/** 构造本地乐观消息对象 */
const buildLocalMessage = (opts: {
clientMessageId: string
content: string
@ -68,7 +71,6 @@ export const useMessageSender = () => {
atUserIds?: number[]
}): Message => {
return {
id: 0,
clientMessageId: opts.clientMessageId,
type: opts.type,
content: opts.content,
@ -109,10 +111,10 @@ export const useMessageSender = () => {
clientMessageId = options.existingClientMessageId
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
const targetConversation = conversationStore.getConversation(conversation.type, realTarget)
const stillExists = targetConversation?.messages.some(
(m) => m.clientMessageId === clientMessageId
)
// TODO @AI尽量不要 m 缩写,全称
const stillExists = messageStore
.getMessageList(conversation.type, realTarget)
.some((m) => m.clientMessageId === clientMessageId && !m._ackMerging)
if (!stillExists) {
return false
}
@ -131,7 +133,7 @@ export const useMessageSender = () => {
name: conversation.name || String(realTarget),
avatar: conversation.avatar || ''
}
conversationStore.insertMessage(conversationInfo, message)
messageStore.insertMessage(conversationInfo, message)
}
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD失败更新为 FAILED
@ -143,7 +145,7 @@ export const useMessageSender = () => {
type,
content
})
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, {
id: data.id,
sendTime: new Date(data.sendTime).getTime(),
status: data.status,
@ -158,7 +160,7 @@ export const useMessageSender = () => {
atUserIds: options?.atUserIds,
receipt: options?.receipt
})
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, {
id: data.id,
sendTime: new Date(data.sendTime).getTime(),
status: data.status,
@ -170,7 +172,7 @@ export const useMessageSender = () => {
return true
} catch (e) {
console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, e)
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, {
status: ImMessageStatus.FAILED
})
return false
@ -195,7 +197,7 @@ export const useMessageSender = () => {
* 2. 退
*/
const recall = async (message: Message) => {
// 参数校验:本地占位消息id=0不能撤回
// 参数校验:本地占位消息不能撤回
if (!message.id) {
return
}
@ -215,7 +217,7 @@ export const useMessageSender = () => {
/**
* /
* 1.
* 2. idid=0
* 2. id
*/
const readActive = async () => {
const conversation = conversationStore.activeConversation
@ -223,11 +225,12 @@ export const useMessageSender = () => {
return
}
// 本地标记已读:未读数清零 + 消息状态更新为 READUI 立刻响应)
conversationStore.markActiveAsRead()
const maxMessageId = conversationStore.getActiveMessages.reduce<number>(
(max, m) => (m.id > max ? m.id : max),
0
)
conversationStore.markConversationAsRead(conversation.type, conversation.targetId)
messageStore.markConversationMessagesRead(conversation)
// TODO @AImessage不要用 m
const maxMessageId = messageStore
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
.reduce<number>((max, m) => (m.id && m.id > max ? m.id : max), 0)
if (!maxMessageId) {
return
}
@ -283,7 +286,7 @@ export const useMessageSender = () => {
return
}
// applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
conversationStore.applyReadReceipt({
messageStore.applyReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: peerId,
privateReadMaxId: maxReadId

View File

@ -13,7 +13,7 @@ export type MuteOverlayInfo = { text: string; icon: string }
* setInterval timer
*/
const sharedNow = ref(Date.now())
let sharedTickTimer: ReturnType<typeof setInterval> | null = null
let sharedTickTimer: number | null = null
let subscriberCount = 0
function subscribeNowTick(): void {

View File

@ -34,6 +34,7 @@ import { useRoute } from 'vue-router'
import { useAppStore } from '@/store/modules/app'
import { useConversationStore } from './store/conversationStore'
import { useMessageStore } from './store/messageStore'
import { useImWebSocketStore } from './store/websocketStore'
import { useFriendStore } from './store/friendStore'
import { useGroupStore } from './store/groupStore'
@ -46,6 +47,7 @@ import { useMessageSender } from './composables/useMessageSender'
import { useVoicePlayer } from './composables/useVoicePlayer'
import { ImConversationType } from '../utils/constants'
import { StorageKeys } from '../utils/storage'
import { initDb, stopRequests } from '../utils/db'
import type { Conversation } from './types'
import ToolBar from './components/ToolBar.vue'
import UserInfoCard from './components/user/UserInfoCard.vue'
@ -58,6 +60,7 @@ defineOptions({ name: 'ImIndex' })
const route = useRoute()
const appStore = useAppStore()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const webSocketStore = useImWebSocketStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
@ -81,9 +84,12 @@ onMounted(async () => {
// 1.1 loading=true saveConversations + WebSocket connect pullOnce maxId pull 线
conversationStore.loading = true
try {
// TODO @AI
await initDb()
// 1.2 store IDB loadConversations / loadDrafts voidload{Friends,Groups,Channels}
const [, hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([
const [, , hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([
conversationStore.loadConversations(),
messageStore.loadCursors(),
friendStore.loadFriends(),
groupStore.loadGroups(),
draftStore.loadDrafts(),
@ -162,6 +168,8 @@ onUnmounted(() => {
// audio
voicePlayer.stop()
window.removeEventListener('beforeunload', onBeforeUnload)
// TODO @AI
void stopRequests()
})
/**

View File

@ -157,13 +157,14 @@ const groups = computed<GroupLite[]>(() =>
watch(
friends,
(list) => {
if (selection.value?.type !== 'friend') {
const selected = selection.value
if (selected?.type !== 'friend') {
return
}
const fresh = list.find((friend) => friend.id === selection.value!.friend.id)
const fresh = list.find((friend) => friend.id === selected.friend.id)
if (!fresh) {
selection.value = null
} else if (fresh !== selection.value.friend) {
} else if (fresh !== selected.friend) {
selection.value = { type: 'friend', friend: fresh }
}
},
@ -172,13 +173,14 @@ watch(
watch(
groups,
(list) => {
if (selection.value?.type !== 'group') {
const selected = selection.value
if (selected?.type !== 'group') {
return
}
const fresh = list.find((group) => group.id === selection.value!.group.id)
const fresh = list.find((group) => group.id === selected.group.id)
if (!fresh) {
selection.value = null
} else if (fresh !== selection.value.group) {
} else if (fresh !== selected.group) {
selection.value = { type: 'group', group: fresh }
}
},
@ -187,13 +189,14 @@ watch(
watch(
friendRequests,
(list) => {
if (selection.value?.type !== 'request') {
const selected = selection.value
if (selected?.type !== 'request') {
return
}
const fresh = list.find((request) => request.id === selection.value!.request.id)
const fresh = list.find((request) => request.id === selected.request.id)
if (!fresh) {
selection.value = null
} else if (fresh !== selection.value.request) {
} else if (fresh !== selected.request) {
selection.value = { type: 'request', request: fresh }
}
},

View File

@ -51,14 +51,17 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useMessageStore } from '@/views/im/home/store/messageStore'
import { useMessageMultiSelect } from '@/views/im/home/composables/useMessageMultiSelect'
import { ImForwardMode, isNormalMessage } from '@/views/im/utils/constants'
import { getClientConversationId } from '@/views/im/utils/db'
import type { Message } from '@/views/im/home/types'
import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys'
defineOptions({ name: 'ImMessageMultiSelectBar' })
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const message = useMessage()
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY)
const multiSelect = useMessageMultiSelect()
@ -66,16 +69,16 @@ const multiSelect = useMessageMultiSelect()
/** 选中条数 */
const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length)
/** 当前会话内已选消息conversation.messages 已按 sendTime 升序filter 保序无需再 sortisNormalMessage 过滤掉 RECALL / 系统事件,与 MessageItem.canForward 对齐 */
/** 当前会话内已选消息 */
function getSelectedMessages(): Message[] {
const conversation = conversationStore.activeConversation
if (!conversation) {
return []
}
const ids = multiSelect.selectedIdSet.value
return conversation.messages.filter(
(message) => ids.has(message.clientMessageId) && isNormalMessage(message.type)
)
return messageStore
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
.filter((message) => ids.has(message.clientMessageId) && isNormalMessage(message.type))
}
/** 逐条转发:开 ForwardDialog 单条模式 */
@ -128,7 +131,7 @@ async function handleDelete() {
return
}
for (const m of messages) {
conversationStore.removeMessage(conversation.type, conversation.targetId, {
messageStore.removeMessage(conversation.type, conversation.targetId, {
id: m.id,
clientMessageId: m.clientMessageId
})
@ -141,4 +144,3 @@ function handleCancel() {
multiSelect.exit()
}
</script>

View File

@ -9,8 +9,14 @@
class="flex items-center gap-1.5 w-[360px] px-3 py-1.5 rounded-[10px] text-13px text-[var(--el-text-color-primary)] bg-[var(--el-bg-color)] shadow-[0_1px_2px_rgba(0,0,0,0.04)] cursor-pointer hover:bg-[var(--el-fill-color-lighter)]"
@click="handleTopClick"
>
<Icon icon="ant-design:pushpin-outlined" :size="14" class="flex-shrink-0 text-[var(--el-color-warning)]" />
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">{{ getSenderName(latest) }}</span>
<Icon
icon="ant-design:pushpin-outlined"
:size="14"
class="flex-shrink-0 text-[var(--el-color-warning)]"
/>
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]"
>{{ getSenderName(latest) }}</span
>
<span class="flex-1 min-w-0 truncate">{{ getPreview(latest) }}</span>
<!-- 单条移除按钮多条折叠 N 多条展开收起箭头 -->
<span
@ -22,7 +28,9 @@
移除
</span>
<template v-else-if="pinnedMessages.length > 1">
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)] text-12px"> {{ pinnedMessages.length }} </span>
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)] text-12px">
{{ pinnedMessages.length }}
</span>
<Icon
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
:size="11"
@ -35,7 +43,7 @@
<div
v-if="pinnedMessages.length > 1 && expanded"
class="im-group-pinned-message__list absolute top-full left-1.5 z-10 flex flex-col gap-2.5 w-[380px] p-3 rounded-xl bg-[var(--el-bg-color)] shadow-[0_6px_16px_rgba(0,0,0,0.12)]"
style="margin-top: -1px;"
style="margin-top: -1px"
>
<div
v-for="msg in pinnedMessages"
@ -43,8 +51,14 @@
class="flex items-center gap-1.5 w-full px-3 py-1.5 rounded-[10px] text-13px text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)] cursor-pointer hover:bg-[var(--el-bg-color)]"
@click="handleLocate(msg)"
>
<Icon icon="ant-design:pushpin-outlined" :size="14" class="flex-shrink-0 text-[var(--el-color-warning)]" />
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">{{ getSenderName(msg) }}</span>
<Icon
icon="ant-design:pushpin-outlined"
:size="14"
class="flex-shrink-0 text-[var(--el-color-warning)]"
/>
<span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">
{{ getSenderName(msg) }}
</span>
<span class="flex-1 min-w-0 truncate">{{ getPreview(msg) }}</span>
<span
v-if="canManage"
@ -126,23 +140,31 @@ function handleTopClick() {
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
function handleLocate(msg: Message) {
if (!msg.id) {
return
}
emit('locate', msg.id)
expanded.value = false
}
// TODO @AI message msg
/** 置顶消息发送人显示名 */
function getSenderName(msg: Message): string {
return group.value ? getSenderDisplayName(msg.senderId, ImConversationType.GROUP, group.value.id) : ''
return group.value
? getSenderDisplayName(msg.senderId, ImConversationType.GROUP, group.value.id)
: ''
}
/** 置顶消息预览文本:复用会话最后一条摘要逻辑([图片] / [文件] / 文本等) */
function getPreview(msg: Message): string {
return group.value ? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id) : ''
return group.value
? resolveConversationLastContent(msg, ImConversationType.GROUP, group.value.id)
: ''
}
/** 移除置顶:调后端 APIloading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
async function handleRemove(msg: Message) {
if (!group.value || removingId.value !== null) {
if (!group.value || !msg.id || removingId.value !== null) {
return
}
removingId.value = msg.id

View File

@ -153,7 +153,9 @@
v-else-if="isGroupNotification(message.type)"
class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-b-solid border-[var(--el-border-color-lighter)]"
>
<TipSegments :segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))" />
<TipSegments
:segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))"
/>
</div>
<!-- 普通消息行 -->
@ -178,11 +180,11 @@
<span class="block text-right">{{ formatHistoryTime(message.sendTime) }}</span>
<!-- 定位到聊天位置absolute 浮在时间下方 hover 才显示
不参与右侧栏 flex 排版避免隐藏时占位让"我"和内容之间留空
仅有真实 id 的消息才支持本地占位消息 id=0 不行 -->
仅有真实 id 的消息才支持本地占位消息不行 -->
<span
v-if="message.id > 0"
v-if="message.id && message.id > 0"
class="im-message-history__locate"
@click="locateMessage(message.id)"
@click="locateMessage(message.id!)"
>
定位到聊天位置
</span>
@ -248,6 +250,7 @@ import { useUserStore } from '@/store/modules/user'
import { getPrivateMessageList as apiGetPrivateMessageList } from '@/api/im/message/private'
import { getGroupMessageList as apiGetGroupMessageList } from '@/api/im/message/group'
import { useConversationStore } from '../../../../store/conversationStore'
import { useMessageStore } from '../../../../store/messageStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useFriendStore } from '../../../../store/friendStore'
import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
@ -285,6 +288,7 @@ import {
type MergeMessage
} from '@/views/im/utils/message'
import type { Message } from '@/views/im/home/types'
import { getClientConversationId } from '@/views/im/utils/db'
import UserAvatar from '../../../../components/user/UserAvatar.vue'
import MessageBubble from './MessageBubble.vue'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
@ -299,6 +303,7 @@ const emit = defineEmits<{
const userStore = useUserStore()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
@ -316,7 +321,13 @@ defineExpose({
const conversation = computed(() => conversationStore.activeConversation)
const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP)
const allMessages = computed<Message[]>(() => conversation.value?.messages || [])
const allMessages = computed<Message[]>(() =>
conversation.value
? messageStore.getMessages(
getClientConversationId(conversation.value.type, conversation.value.targetId)
)
: []
)
/** 单条消息的发送人显示名:渲染时按 conversation 上下文走 WeChat 优先级实时算 */
function senderDisplayNameOf(message: Message): string {
@ -329,8 +340,7 @@ function senderDisplayNameOf(message: Message): string {
/** 群广播事件 segments 的成员名解析器;按当前会话 targetId 走 getSenderDisplayName */
function resolveGroupMemberName(message: Message): (userId: number) => string {
return (id: number) =>
getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
return (id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
}
/** 单条消息的发送人真实昵称:给 UserAvatar 色卡 / alt 用,永远是 nickname 不掺备注 */
@ -535,7 +545,7 @@ const hasMore = ref(true)
*
* - 未对接 list 接口的 type / keyword / sender 过滤参数后端只支持 maxId + limit 游标分页
* tab 筛选在前端做数据来回到本地后过滤
* - id=0本地占位跳过后端没法按 messageId
* - 本地占位跳过后端没法按 messageId
* - 返回数量 < limit 视为到顶
*/
async function loadEarlier() {
@ -545,10 +555,7 @@ async function loadEarlier() {
}
// PRIVATE / GROUP CHANNEL 广 list else receiverId channelId
const requestedType = conversation.value.type
if (
requestedType !== ImConversationType.PRIVATE &&
requestedType !== ImConversationType.GROUP
) {
if (requestedType !== ImConversationType.PRIVATE && requestedType !== ImConversationType.GROUP) {
return
}
// await / prepend
@ -559,11 +566,11 @@ async function loadEarlier() {
loadingMore.value = true
try {
// maxId id
// id=0 id
// id
// / reduce POSITIVE_INFINITY undefined
const earliestId = allMessages.value
.filter((message) => message.id > 0)
.reduce((min, message) => Math.min(min, message.id), Number.POSITIVE_INFINITY)
.filter((message) => !!message.id && message.id > 0)
.reduce((min, message) => Math.min(min, message.id || min), Number.POSITIVE_INFINITY)
const maxId = Number.isFinite(earliestId) ? earliestId : undefined
// list / useMessagePuller
@ -597,9 +604,9 @@ async function loadEarlier() {
if (pageLength < HISTORY_PAGE_SIZE) {
hasMore.value = false
}
// conversationStoreprependMessages + + IndexedDB
// messageStoreprependMessages + + IndexedDB
// messages
conversationStore.prependMessages(requestedType, requestedTargetId, earlier)
messageStore.prependMessages(requestedType, requestedTargetId, earlier)
} finally {
loadingMore.value = false
}

View File

@ -251,6 +251,7 @@ import {
parseRtcCallPayload
} from '@/views/im/utils/message'
import { useImUiStore } from '../../../../store/uiStore'
import { useMessageStore } from '../../../../store/messageStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
@ -289,6 +290,7 @@ const emit = defineEmits<{
const userStore = useUserStore()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
@ -600,7 +602,7 @@ type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS]
/**
* 右键菜单项
* - 引用已落库id0+ 未撤回的消息可引用引用块写入 draftStore.reply
* - 引用已落库 + 未撤回的消息可引用引用块写入 draftStore.reply
* - 撤回 / 删除互斥自己发送 + 已落库 + 未撤回 + 2 分钟内显示撤回推服务器其它显示删除仅本地清
*
* 好友事件气泡态不弹菜单
@ -661,7 +663,7 @@ async function handleContextMenu(e: MouseEvent) {
icon: 'ant-design:copy-outlined'
})
}
// id0+ + MERGE
// + + MERGE
if (!!props.message.id && !isRecall.value && !isMerge.value) {
items.push({
key: MENU_KEYS.REPLY,
@ -669,7 +671,7 @@ async function handleContextMenu(e: MouseEvent) {
icon: 'bxs:quote-alt-left'
})
}
// id0+ + ForwardDialog /
// + + ForwardDialog /
if (canForward.value) {
items.push({
key: MENU_KEYS.FORWARD,
@ -728,7 +730,7 @@ async function handleContextMenu(e: MouseEvent) {
})
}
// /
// - + id0+ + RECALL
// - + + + RECALL
// - / /
// divided danger
const canRecall =
@ -822,7 +824,7 @@ const canManageSender = computed(() => {
return myGroupRole.value < senderMember.role
})
/** 是否允许转发 / 多选:普通消息 + 已落库id≠0+ 未撤回;本地占位 / 撤回 / 事件消息一律不可 */
/** 是否允许转发 / 多选:普通消息 + 已落库 + 未撤回;本地占位 / 撤回 / 事件消息一律不可 */
const canForward = computed(
() => isNormalMessage(props.message.type) && !!props.message.id && !isRecall.value
)
@ -866,7 +868,7 @@ const canPin = computed(
/** 置顶消息:二次确认 → 调后端 pin-message后端广播 GROUP_MESSAGE_PIN本端 dispatcher 拉最新 pinnedMessages */
async function handlePin() {
const group = currentGroup.value
if (!group) {
if (!group || !props.message.id) {
return
}
try {
@ -976,7 +978,7 @@ async function handleResend() {
}
// / _localFile FAILED SENDING clientMessageId cmid
conversationStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, {
messageStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, {
status: ImMessageStatus.SENDING
})
await sendRaw(message.type, message.content, {
@ -986,7 +988,7 @@ async function handleResend() {
}
/**
* 删除消息本地软删仅从 conversationStore.messages 移除不调后端
* 删除消息本地软删仅从 messageStore 移除不调后端
* 区别于"撤回"服务端没动多端登录时其它客户端 / 群里其他人依然能看到这条
*/
/** 禁言emit 给父组件打开时长选择弹窗(避免 MessageItem 过重) */
@ -1033,7 +1035,7 @@ function handleDelete() {
if (!conversation) {
return
}
conversationStore.removeMessage(conversation.type, conversation.targetId, {
messageStore.removeMessage(conversation.type, conversation.targetId, {
id: props.message.id,
clientMessageId: props.message.clientMessageId
})

View File

@ -2,7 +2,9 @@
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
<template v-if="conversationStore.activeConversation">
<!-- 顶部 header第一行群名 + 右侧图标第二行嵌入置顶气泡仅群聊 + 有置顶 -->
<div class="flex flex-shrink-0 flex-col bg-[var(--el-fill-color-light)] border-b border-b-solid border-[var(--el-border-color-light)]">
<div
class="flex flex-shrink-0 flex-col bg-[var(--el-fill-color-light)] border-b border-b-solid border-[var(--el-border-color-light)]"
>
<div class="flex items-center justify-between h-14 px-5">
<span class="flex flex-col min-w-0">
<span class="flex items-baseline gap-1.5 min-w-0">
@ -119,7 +121,9 @@
class="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-13px cursor-pointer text-[var(--el-text-color-primary)] bg-[var(--el-color-warning-light-9)] transition-colors hover:bg-[var(--el-color-warning-light-8)]"
@click="handleNotFriendClick"
>
<span class="inline-flex items-center justify-center w-4 h-4 rounded-full text-white bg-[var(--el-color-warning)] flex-shrink-0">
<span
class="inline-flex items-center justify-center w-4 h-4 rounded-full text-white bg-[var(--el-color-warning)] flex-shrink-0"
>
<Icon icon="ant-design:user-outlined" :size="11" />
</span>
<span>对方还不是你的朋友</span>
@ -141,7 +145,7 @@
暂无消息
</div>
<!-- data-message-id MessageHistory "定位到聊天位置" 父级通过 querySelector
找到这层 wrapperscrollIntoView + 加高亮 classid=0 本地占位消息跳过 -->
找到这层 wrapperscrollIntoView + 加高亮 class本地占位消息跳过 -->
<div
v-for="(msg, index) in messages"
:key="msg.id || msg.clientMessageId"
@ -254,11 +258,14 @@ import RtcGroupCallBanner from '../../../../components/rtc/RtcGroupCallBanner.vu
import { createCall } from '@/api/im/rtc'
import { ImRtcCallMediaType, ImRtcCallStatus, ImConversationType } from '@/views/im/utils/constants'
import { resolveCallEndReasonText } from '@/views/im/utils/message'
import { getClientConversationId } from '@/views/im/utils/db'
import { useRtcStore } from '../../../../store/rtcStore'
import { useMessageStore } from '../../../../store/messageStore'
defineOptions({ name: 'ImMessagePanel' })
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
const groupStore = useGroupStore()
@ -298,7 +305,12 @@ watch(
}
)
const messages = computed(() => conversationStore.getActiveMessages)
const messages = computed(() => {
const conversation = conversationStore.activeConversation
return conversation
? messageStore.getMessages(getClientConversationId(conversation.type, conversation.targetId))
: []
})
const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)

View File

@ -59,7 +59,7 @@ import { getGroupReadUsers as apiGetGroupReadUsers } from '@/api/im/message/grou
import { CommonStatusEnum } from '@/utils/constants'
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
import type { Message } from '../../../../types'
import { useConversationStore } from '../../../../store/conversationStore'
import { useMessageStore } from '../../../../store/messageStore'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
import PagedScroller from '../../../../components/PagedScroller.vue'
@ -73,7 +73,7 @@ const props = defineProps<{
groupId: number
}>()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
// popover show readUserIds
const popVisible = ref(false)
@ -132,7 +132,7 @@ const unreadMembers = computed(() =>
* 跳过本地占位消息id = 0还没拿到服务端 id后端没法按 messageId
* 失败仅控制台告警readUserIds 保持空数组 label readCount 兜底不阻塞 UI
*
* 拉到名单后顺手把 readCount / receiptStatus 回写到 conversationStore popover 外面的
* 拉到名单后顺手把 readCount / receiptStatus 回写到 messageStore popover 外面的
* label 也跟着走最新数离线 / 漏收 RECEIPT 事件时本地 readCount 会偏旧弹层里看到"已读 5"
* 但外面仍是"未读"或旧人数这里以服务端返回为准矫正回去
*/
@ -150,7 +150,7 @@ async function loadReadUsers() {
// flip DONE label ""
// readCountreceiptStatus PENDING / READING
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
conversationStore.applyReadReceipt({
messageStore.applyReadReceipt({
conversationType: ImConversationType.GROUP,
targetId: props.groupId,
groupMessageId: props.message.id,

View File

@ -14,7 +14,9 @@
<div
class="flex w-fit gap-1.5 items-center min-w-0 py-0.5 text-12px text-[var(--el-text-color-secondary)] rounded transition-colors"
:class="[
mirrored ? 'pl-1 pr-2 border-r-2 border-r-solid border-r-[var(--el-border-color)]' : 'pl-2 pr-1 border-l-2 border-l-solid border-l-[var(--el-border-color)]',
mirrored
? 'pl-1 pr-2 border-r-2 border-r-solid border-r-[var(--el-border-color)]'
: 'pl-2 pr-1 border-l-2 border-l-solid border-l-[var(--el-border-color)]',
{
'cursor-pointer hover:text-[var(--el-text-color-primary)]': clickable && !isRecalled,
'hover:bg-[var(--el-fill-color-light)]': (clickable && !isRecalled) || closable
@ -32,12 +34,7 @@
<!-- 文件icon + 文件名 + 大小 -->
<template v-else-if="isFile">
<Icon
:icon="fileIcon.icon"
:color="fileIcon.color"
:size="14"
class="flex-shrink-0"
/>
<Icon :icon="fileIcon.icon" :color="fileIcon.color" :size="14" class="flex-shrink-0" />
<span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
{{ parsedPayload.name }}
</span>
@ -58,7 +55,11 @@
</template>
<!-- 名片 -->
<CardLineLabel v-else-if="isCard" :card="parsedPayload" class="min-w-0 line-clamp-2 break-words" />
<CardLineLabel
v-else-if="isCard"
:card="parsedPayload"
class="min-w-0 line-clamp-2 break-words"
/>
<!-- 表情贴图缩略图 + name name 仅显示 [表情] -->
<template v-else-if="isFace">
@ -103,8 +104,10 @@ import { formatSeconds } from '@/utils/formatTime'
import { formatFileSize } from '@/utils/file'
import { useConversationStore } from '../../../../store/conversationStore'
import { useMessageStore } from '../../../../store/messageStore'
import { getSenderDisplayName } from '@/views/im/utils/user'
import { ImMessageType } from '@/views/im/utils/constants'
import { getClientConversationId } from '@/views/im/utils/db'
import CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue'
import {
parseMessage,
@ -148,6 +151,7 @@ const emit = defineEmits<{
}>()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
/** 在当前会话消息列表里查找原消息,仅用于实时判断是否已撤回;摘要 / 缩略图都从 quote.content 直接派生 */
const liveMessage = computed(() => {
@ -155,7 +159,9 @@ const liveMessage = computed(() => {
if (!conversation || !props.quote.messageId) {
return undefined
}
return conversation.messages.find((message) => message.id === props.quote.messageId)
return messageStore
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
.find((message) => message.id === props.quote.messageId)
})
/** 命中本地缓存且 type === RECALL 才判定为已撤回;不在缓存的当快照仍有效 */
@ -193,9 +199,7 @@ const isMaterial = computed(() => props.quote.type === ImMessageType.MATERIAL)
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
const textPreview = computed(() => {
const text = parsedPayload.value?.content ?? ''
return text.length <= MAX_TEXT_PREVIEW_LEN
? text
: `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}`
return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}`
})
/** 文件 icon按扩展名挑色跟主气泡渲染同源 */
@ -227,4 +231,3 @@ function onClick() {
emit('locate', props.quote.messageId)
}
</script>

View File

@ -402,7 +402,6 @@ async function handleCreateGroupAndSend() {
name: group.name || name,
avatar: group.avatar || '',
unreadCount: 0,
messages: [],
lastContent: '',
lastSendTime: 0
}

View File

@ -90,6 +90,12 @@ export const useChannelStore = defineStore('imChannelStore', {
conversation.avatar = channel.avatar
}
})
},
/** 清空频道内存 */
clear() {
this.channels = []
this.loaded = false
}
}
})
@ -98,4 +104,6 @@ if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot))
}
export const useImChannelStore = () => useChannelStore(store)
export const useChannelStoreWithOut = () => useChannelStore(store)
// TODO @AI这里重名名是不是没必要问问。
export const useImChannelStore = useChannelStoreWithOut

File diff suppressed because it is too large Load Diff

View File

@ -124,6 +124,13 @@ export const useDraftStore = defineStore('imDraft', {
/** 立即落盘待写的草稿beforeunload 时调,避免最后一次输入卡在 debounce 队列里丢失 */
flushPersist(): void {
persistBucket.flush()
},
/** 清空草稿内存 */
// TODO @AI写草稿是不是融合到 conversationStore 里。
clear(): void {
persistBucket.cancel()
this.drafts = {}
}
}
})

View File

@ -514,7 +514,7 @@ export const useGroupStore = defineStore('imGroupStore', {
/**
* GROUP_* 广 type action
*
* WebSocket + useMessagePuller 线 pull conversationStore.insertMessage
* WebSocket + useMessagePuller 线 pull messageStore.insertMessage
* store fetchGroups
*/
applyGroupNotification(groupId: number, type: number, content?: string) {
@ -567,7 +567,9 @@ export const useGroupStore = defineStore('imGroupStore', {
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN)
// 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
if (isSelfInPayloadMembers(payload)) {
useGroupRequestStore().fetchUnhandledList().catch(() => undefined)
useGroupRequestStore()
.fetchUnhandledList()
.catch(() => undefined)
}
break
case ImMessageType.GROUP_ADMIN_REMOVE:
@ -701,7 +703,9 @@ export const useGroupStore = defineStore('imGroupStore', {
// 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
const selfUserId = getCurrentUserId()
if (selfUserId && payload.newOwnerUserId === selfUserId) {
useGroupRequestStore().fetchUnhandledList().catch(() => undefined)
useGroupRequestStore()
.fetchUnhandledList()
.catch(() => undefined)
}
},
@ -802,7 +806,9 @@ function convertGroup(group: ImGroupRespVO): Group {
}
/** 后端 ImGroupMessageRespVO -> 前端 Message补 targetId / selfSend / sendTime 等派生字段 */
function convertGroupMessageVO(message: NonNullable<ImGroupRespVO['pinnedMessages']>[number]): Message {
function convertGroupMessageVO(
message: NonNullable<ImGroupRespVO['pinnedMessages']>[number]
): Message {
const currentUserId = getCurrentUserId()
return {
id: message.id,

View File

@ -0,0 +1,839 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
import { store } from '@/store'
import {
IM_AT_ALL_USER_ID,
ImConversationType,
ImMessageStatus,
ImMessageType,
isGroupNotification,
isNormalMessage
} from '../../utils/constants'
import {
getClientConversationId,
getClientMessageKey,
getDb,
getServerMessageKey,
parseClientConversationId,
setMessageMaxId,
type DbTx
} from '../../utils/db'
import {
generateClientMessageId,
parseRecallMessageId,
revokeBlobUrlsInContent
} from '../../utils/message'
import { resolveConversationLastContent } from '../../utils/conversation'
import { getCurrentUserId } from '../../utils/storage'
import { tryGetSenderDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
import { useConversationStore } from './conversationStore'
import type { Conversation, Message, MessageDO } from '../types'
const MESSAGE_CACHE_CONVERSATION_LIMIT = 5
const ackMergingPromises = new Map<string, Promise<void>>()
interface MessageConversationInfo {
type: number
targetId: number
name: string
avatar: string
silent?: boolean
}
// TODO @AI叫这个 type 有点奇怪,可能需要再考虑下。
export type PulledMessageBatchItem =
| {
kind: 'insert'
conversationInfo: MessageConversationInfo
message: Message
}
| {
kind: 'recall'
conversationType: number
targetId: number
recallSignalContent: string
}
/** 获取会话的消息缓存 key */
function getMessageCacheKey(type: number, targetId: number): string {
return getClientConversationId(type, targetId)
}
/** 生成消息本地主键 */
function getMessageKey(
message: Pick<Message, 'id' | 'clientMessageId'>,
conversationType: number
): string {
return message.id
? getServerMessageKey(conversationType, message.id)
: getClientMessageKey(message.clientMessageId)
}
/** 补齐客户端消息编号 */
function ensureClientMessageId(message: Message): Message {
if (!message.clientMessageId) {
message.clientMessageId = generateClientMessageId()
}
if (!message.id) {
message.id = undefined
}
return message
}
/** 转换为 IndexedDB 消息记录 */
// TODO @AIbuildXXX 更合理。
function toMessageDO(message: Message, conversationType: number): MessageDO {
const {
uploadProgress: _uploadProgress,
_localFile: _localFile,
_ackMerging: _ackMerging,
...rest
} = message
return {
...rest,
messageKey: getMessageKey(message, conversationType),
conversationType,
clientConversationId: getClientConversationId(conversationType, message.targetId)
}
}
/** IndexedDB 消息记录转前端消息 */
// TODO @AIbuildXXX 更合理。
function fromMessageDO(message: MessageDO): Message {
const {
messageKey: _messageKey,
conversationType: _conversationType,
clientConversationId: _clientConversationId,
...rest
} = message
return rest
}
/** 算出末条消息的发送人快照 */
// TODO @AI里面的代码注释最好写下
function deriveLastSenderDisplayName(
conversation: Conversation,
senderId: number
): string | undefined {
const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
if (liveSenderName) {
return liveSenderName
}
if (conversation.type === ImConversationType.GROUP) {
const groupStore = useGroupStore()
const group = groupStore.getGroup(conversation.targetId)
const fetchPromise = group?.membersLoaded
? groupStore.fetchGroupMember(conversation.targetId, senderId)
: groupStore.fetchGroupMembers(conversation.targetId)
fetchPromise.catch((e) =>
console.warn(
'[IM messageStore] 兜底拉群成员失败',
{ groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
e
)
)
}
return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
}
/** 按消息更新会话摘要 */
function applyConversationSummary(conversation: Conversation, message: Message): void {
const senderDisplayName = deriveLastSenderDisplayName(conversation, message.senderId)
conversation.lastContent = resolveConversationLastContent(
message,
conversation.type,
conversation.targetId,
senderDisplayName
)
conversation.lastSendTime = message.sendTime || Date.now()
conversation.lastSenderId = message.senderId
conversation.lastMessageType = message.type
conversation.lastMessageId = message.id
conversation.lastClientMessageId = message.clientMessageId
conversation.lastMessageStatus = message.status
conversation.lastReceiptStatus = message.receiptStatus
conversation.lastSelfSend = message.selfSend
conversation.lastSenderDisplayName = senderDisplayName
}
/** 按末条消息重算会话摘要 */
function recomputeConversationLast(conversation: Conversation, messages: Message[]): void {
const last = messages[messages.length - 1]
if (last) {
applyConversationSummary(conversation, last)
return
}
conversation.lastContent = ''
conversation.lastSendTime = 0
conversation.lastSenderId = undefined
conversation.lastMessageType = undefined
conversation.lastMessageId = undefined
conversation.lastClientMessageId = undefined
conversation.lastMessageStatus = undefined
conversation.lastReceiptStatus = undefined
conversation.lastSelfSend = undefined
conversation.lastSenderDisplayName = undefined
}
/** 同步群 @ 状态 */
function syncConversationAtFlags(conversation: Conversation, message: Message): void {
if (
message.selfSend ||
conversation.type !== ImConversationType.GROUP ||
!message.atUserIds ||
message.atUserIds.length === 0 ||
message.status === ImMessageStatus.READ
) {
return
}
const currentUserId = getCurrentUserId()
if (currentUserId && message.atUserIds.includes(currentUserId)) {
conversation.atMe = true
}
if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
conversation.atAll = true
}
}
/** 应用服务端消息更新 */
function applyServerMessageUpdate(message: Message, updates: Partial<Message>): void {
if (updates.content && updates.content !== message.content) {
revokeBlobUrlsInContent(message.content)
}
Object.assign(message, updates)
if (updates.id === 0) {
message.id = undefined
}
if (updates.status !== undefined && updates.status !== ImMessageStatus.SENDING) {
message.uploadProgress = undefined
if (updates.status !== ImMessageStatus.FAILED) {
message._localFile = undefined
}
}
}
/** 判断是否为同一条消息 */
function isSameMessage(left: Message, right: Message): boolean {
if (left.id && right.id && left.id === right.id) {
return true
}
return !!left.clientMessageId && left.clientMessageId === right.clientMessageId
}
export const useMessageStore = defineStore('imMessageStore', {
state: () => ({
messagesByConversation: {} as Record<string, Message[]>,
loadedConversationKeys: [] as string[],
privateMessageMaxId: 0,
groupMessageMaxId: 0,
channelMessageMaxId: 0
}),
getters: {
/** 获取会话已加载消息 */
getMessages:
(state) =>
(clientConversationId: string): Message[] =>
state.messagesByConversation[clientConversationId] || []
},
actions: {
/** 清空消息内存 */
clear() {
Object.values(this.messagesByConversation).forEach((messages) => {
messages.forEach((message) => {
revokeBlobUrlsInContent(message.content)
message._localFile = undefined
})
})
this.messagesByConversation = {}
this.loadedConversationKeys = []
this.privateMessageMaxId = 0
this.groupMessageMaxId = 0
this.channelMessageMaxId = 0
ackMergingPromises.clear()
},
/** 从 settings 加载消息游标 */
async loadCursors() {
const db = getDb()
// TODO @AI可以通过 message 表去算么?不通过这个。
const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
db.getSetting<number>('privateMessageMaxId'),
db.getSetting<number>('groupMessageMaxId'),
db.getSetting<number>('channelMessageMaxId')
])
this.privateMessageMaxId = privateMaxId || 0
this.groupMessageMaxId = groupMaxId || 0
this.channelMessageMaxId = channelMaxId || 0
},
/** 更新内存游标 */
updateMaxId(conversationType: number, messageId?: number) {
if (!messageId) {
return
}
if (conversationType === ImConversationType.PRIVATE && messageId > this.privateMessageMaxId) {
this.privateMessageMaxId = messageId
} else if (
conversationType === ImConversationType.GROUP &&
messageId > this.groupMessageMaxId
) {
this.groupMessageMaxId = messageId
} else if (
conversationType === ImConversationType.CHANNEL &&
messageId > this.channelMessageMaxId
) {
this.channelMessageMaxId = messageId
}
},
/** 标记会话近期使用 */
touchConversation(clientConversationId: string) {
this.loadedConversationKeys = [
clientConversationId,
...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
]
// 保留当前活跃会话 + 最近打开过的 5 个会话。
const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
this.loadedConversationKeys = retained
removed.forEach((key) => {
delete this.messagesByConversation[key]
})
},
/** 加载当前会话最近消息 */
async loadMore(
clientConversationId: string,
beforeSendTime?: number,
limit = 50
): Promise<Message[]> {
// TODO @AI代码段的注释
const list = await getDb().getMessagesByConversation(clientConversationId, {
beforeSendTime,
limit
})
const parsed = parseClientConversationId(clientConversationId)
if (!parsed) {
return []
}
const messages = list.map(fromMessageDO)
const existing = this.messagesByConversation[clientConversationId] || []
const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
const fresh = messages.filter(
(message) => !existingKeys.has(getMessageKey(message, parsed.type))
)
// TODO @AImessageA、messageB
this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort(
(a, b) => (a.sendTime || 0) - (b.sendTime || 0)
)
this.touchConversation(clientConversationId)
return fresh
},
/** 确保会话消息已加载 */
async ensureLoaded(conversation: Conversation) {
// TODO @AI代码段的注释
const key = getMessageCacheKey(conversation.type, conversation.targetId)
if (this.messagesByConversation[key]) {
this.touchConversation(key)
return
}
await this.loadMore(key)
},
/** 获取内存消息数组 */
getMessageList(conversationType: number, targetId: number): Message[] {
// TODO @AI代码段的注释
const key = getMessageCacheKey(conversationType, targetId)
if (!this.messagesByConversation[key]) {
this.messagesByConversation[key] = []
}
this.touchConversation(key)
return this.messagesByConversation[key]
},
/** 持久化单条消息 */
async persistMessage(message: Message, conversationType: number, tx?: DbTx) {
// TODO @AI代码段的注释
const db = getDb()
const next = toMessageDO(message, conversationType)
if (message.id && message.clientMessageId) {
const existing = await db.getByIndex<MessageDO>(
'messages',
'clientMessageId',
message.clientMessageId,
tx
)
if (existing && existing.messageKey !== next.messageKey) {
await db.delete('messages', existing.messageKey, tx)
}
}
await db.put('messages', next, tx)
},
/** 持久化消息游标 */
async persistMaxId(conversationType: number, messageId?: number, tx?: DbTx) {
this.updateMaxId(conversationType, messageId)
await setMessageMaxId(conversationType, messageId, tx)
},
/** 应用撤回到内存 */
applyRecallInMemory(conversationType: number, targetId: number, recallSignalContent: string) {
// TODO @AI代码段的注释
const messageId = parseRecallMessageId(recallSignalContent)
if (!messageId) {
return null
}
const conversationStore = useConversationStore()
const conversation = conversationStore.getConversation(conversationType, targetId)
if (!conversation) {
return null
}
const messages = this.getMessageList(conversationType, targetId)
const message = messages.find((item) => item.id === messageId)
if (!message) {
return null
}
message.type = ImMessageType.RECALL
message.status = ImMessageStatus.RECALL
message.content = ''
if (messages[messages.length - 1]?.id === messageId) {
recomputeConversationLast(conversation, messages)
}
return { conversation, message }
},
/** 批量写入拉取消息 */
async insertPulledBatch(
items: PulledMessageBatchItem[],
conversationType: number,
maxMessageId?: number
) {
// TODO @AI代码段的注释
if (items.length === 0) {
await this.persistMaxId(conversationType, maxMessageId)
return
}
const conversationStore = useConversationStore()
const persistedMessages = new Map<string, { message: Message; conversationType: number }>()
const changedConversations = new Map<string, Conversation>()
const addChanged = (conversation: Conversation, message: Message) => {
const clientConversationId = getClientConversationId(
conversation.type,
conversation.targetId
)
changedConversations.set(clientConversationId, conversation)
persistedMessages.set(getMessageKey(message, conversation.type), {
message,
conversationType: conversation.type
})
}
// TODO @AI是不是最好 mesages
for (const item of items) {
if (item.kind === 'recall') {
const changed = this.applyRecallInMemory(
item.conversationType,
item.targetId,
item.recallSignalContent
)
if (changed) {
addChanged(changed.conversation, changed.message)
}
continue
}
const { conversationInfo } = item
const message = ensureClientMessageId(item.message)
if (
conversationInfo.type === ImConversationType.GROUP &&
isGroupNotification(message.type)
) {
useGroupStore().applyGroupNotification(
conversationInfo.targetId,
message.type,
message.content
)
}
const conversation = conversationStore.ensureConversation(conversationInfo)
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
if (existingIndex >= 0) {
applyServerMessageUpdate(messages[existingIndex], message)
if (existingIndex === messages.length - 1) {
recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message)
}
this.updateMaxId(conversationInfo.type, message.id)
addChanged(conversation, messages[existingIndex])
continue
}
// TODO @AIapplyConversationSummary 要 await 么?不然会有报错;
applyConversationSummary(conversation, message)
syncConversationAtFlags(conversation, message)
const isActive =
conversationStore.activeConversation?.type === conversationInfo.type &&
conversationStore.activeConversation?.targetId === conversationInfo.targetId
if (
!message.selfSend &&
!isActive &&
isNormalMessage(message.type) &&
message.status !== ImMessageStatus.READ &&
message.status !== ImMessageStatus.RECALL
) {
conversation.unreadCount++
}
let insertIndex = messages.length
if (message.id) {
for (let index = 0; index < messages.length; index++) {
const existing = messages[index]
if (existing.id && message.id < existing.id) {
insertIndex = index
break
}
}
}
messages.splice(insertIndex, 0, message)
this.updateMaxId(conversationInfo.type, message.id)
addChanged(conversation, message)
}
this.updateMaxId(conversationType, maxMessageId)
await getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
for (const item of persistedMessages.values()) {
await this.persistMessage(item.message, item.conversationType, tx)
}
await conversationStore.persistConversations([...changedConversations.values()], tx)
await setMessageMaxId(conversationType, maxMessageId, tx)
})
.catch((e) => console.error('[IM messageStore] 批量消息写入失败', e))
},
/** 插入消息 */
insertMessage(
conversationInfo: MessageConversationInfo,
messageInfo: Message,
options?: { persistMaxId?: boolean }
) {
// TODO @AI代码段的注释类似上面的问题
const conversationStore = useConversationStore()
const message = ensureClientMessageId(messageInfo)
if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
useGroupStore().applyGroupNotification(
conversationInfo.targetId,
message.type,
message.content
)
}
const conversation = conversationStore.ensureConversation(conversationInfo)
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
if (existingIndex >= 0) {
applyServerMessageUpdate(messages[existingIndex], message)
if (existingIndex === messages.length - 1) {
recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message)
}
this.updateMaxId(conversationInfo.type, message.id)
void getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessage(messages[existingIndex], conversationInfo.type, tx)
await conversationStore.persistConversations(conversation, tx)
if (options?.persistMaxId !== false) {
await setMessageMaxId(conversationInfo.type, message.id, tx)
}
})
.catch((e) => console.error('[IM messageStore] 消息写入失败', e))
return
}
applyConversationSummary(conversation, message)
syncConversationAtFlags(conversation, message)
const isActive =
conversationStore.activeConversation?.type === conversationInfo.type &&
conversationStore.activeConversation?.targetId === conversationInfo.targetId
if (
!message.selfSend &&
!isActive &&
isNormalMessage(message.type) &&
message.status !== ImMessageStatus.READ &&
message.status !== ImMessageStatus.RECALL
) {
conversation.unreadCount++
}
let insertIndex = messages.length
if (message.id) {
for (let index = 0; index < messages.length; index++) {
const existing = messages[index]
if (existing.id && message.id < existing.id) {
insertIndex = index
break
}
}
}
messages.splice(insertIndex, 0, message)
this.updateMaxId(conversationInfo.type, message.id)
void getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessage(message, conversationInfo.type, tx)
await conversationStore.persistConversations(conversation, tx)
if (options?.persistMaxId !== false) {
await setMessageMaxId(conversationInfo.type, message.id, tx)
}
})
.catch((e) => console.error('[IM messageStore] 消息写入失败', e))
},
/** ack 合并 */
ackMessage(
conversationType: number,
targetId: number,
clientMessageId: string,
updates: Partial<Message>
) {
const mergeKey = `${conversationType}:${targetId}:${clientMessageId}`
const existingPromise = ackMergingPromises.get(mergeKey)
if (existingPromise) {
return existingPromise
}
const promise = this.doAckMessage(
conversationType,
targetId,
clientMessageId,
updates
).finally(() => {
ackMergingPromises.delete(mergeKey)
})
ackMergingPromises.set(mergeKey, promise)
return promise
},
/** 执行 ack 合并 */
async doAckMessage(
conversationType: number,
targetId: number,
clientMessageId: string,
updates: Partial<Message>
) {
const conversationStore = useConversationStore()
const conversation = conversationStore.getConversation(conversationType, targetId)
if (!conversation) {
return
}
const messages = this.getMessageList(conversationType, targetId)
const message = messages.find((item) => item.clientMessageId === clientMessageId)
if (!message) {
return
}
message._ackMerging = true
try {
applyServerMessageUpdate(message, updates)
if (messages[messages.length - 1] === message) {
recomputeConversationLast(conversation, messages)
}
this.updateMaxId(conversationType, message.id)
await getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessage(message, conversationType, tx)
await conversationStore.persistConversations(conversation, tx)
await setMessageMaxId(conversationType, message.id, tx)
})
.catch((e) => console.error('[IM messageStore] ack 写入失败', e))
} finally {
message._ackMerging = false
}
},
/** 局部更新消息 */
patchMessage(
conversationType: number,
targetId: number,
clientMessageId: string,
patch: Partial<Message>
) {
// TODO @AI代码段的注释
const message = this.getMessageList(conversationType, targetId).find(
(item) => item.clientMessageId === clientMessageId
)
if (!message) {
return
}
let changed = false
for (const key in patch) {
if (
Object.prototype.hasOwnProperty.call(patch, key) &&
(patch as Record<string, unknown>)[key] !==
(message as unknown as Record<string, unknown>)[key]
) {
changed = true
break
}
}
if (changed) {
applyServerMessageUpdate(message, patch)
}
},
/** 撤回消息 */
recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
const conversationStore = useConversationStore()
const changed = this.applyRecallInMemory(conversationType, targetId, recallSignalContent)
if (!changed) {
return
}
this.persistMessage(changed.message, conversationType).catch((e) =>
console.error('[IM messageStore] 撤回消息写入失败', e)
)
conversationStore.saveConversations(changed.conversation)
},
/** 应用已读回执 */
applyReadReceipt(options: {
conversationType: number
targetId: number
privateReadMaxId?: number
groupMessageId?: number
readCount?: number
receiptStatus?: number
}) {
// TODO @AI代码段的注释
const messages = this.getMessageList(options.conversationType, options.targetId)
const changed: Message[] = []
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
messages.forEach((message) => {
if (
message.selfSend &&
message.id &&
message.id <= options.privateReadMaxId! &&
message.status !== ImMessageStatus.RECALL
) {
message.status = ImMessageStatus.READ
changed.push(message)
}
})
} else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
const message = 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
}
changed.push(message)
}
}
changed.forEach((message) => {
this.persistMessage(message, options.conversationType).catch((e) =>
console.warn('[IM messageStore] 回执写入失败', e)
)
})
},
/** 前置历史消息 */
prependMessages(conversationType: number, targetId: number, earlierMessages: Message[]) {
// TODO @AI代码段的注释
if (earlierMessages.length === 0) {
return
}
const messages = this.getMessageList(conversationType, targetId)
const existingIds = new Set(messages.map((message) => message.id).filter(Boolean))
const fresh = earlierMessages
.map(ensureClientMessageId)
.filter((message) => message.id && !existingIds.has(message.id))
.sort((a, b) => (a.id || 0) - (b.id || 0))
if (fresh.length === 0) {
return
}
const key = getMessageCacheKey(conversationType, targetId)
this.messagesByConversation[key] = [...fresh, ...messages]
fresh.forEach((message) => {
this.persistMessage(message, conversationType).catch((e) =>
console.warn('[IM messageStore] 历史消息写入失败', e)
)
})
},
/** 删除单条消息 */
removeMessage(
conversationType: number,
targetId: number,
key: { id?: number; clientMessageId?: string }
) {
// TODO @AI代码段的注释
const conversationStore = useConversationStore()
const conversation = conversationStore.getConversation(conversationType, targetId)
if (!conversation) {
return
}
const messages = this.getMessageList(conversationType, targetId)
const index = messages.findIndex((message) => {
if (key.id && message.id && message.id === key.id) {
return true
}
return !!key.clientMessageId && message.clientMessageId === key.clientMessageId
})
if (index < 0) {
return
}
const [removed] = messages.splice(index, 1)
revokeBlobUrlsInContent(removed.content)
if (index === messages.length) {
recomputeConversationLast(conversation, messages)
}
getDb()
.delete('messages', getMessageKey(removed, conversationType))
.catch((e) => console.warn('[IM messageStore] 消息删除失败', e))
conversationStore.saveConversations()
},
/** 当前会话标记已读 */
markConversationMessagesRead(conversation: Conversation) {
// TODO @AI代码段的注释
const messages = this.getMessageList(conversation.type, conversation.targetId)
messages.forEach((message) => {
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
message.status = ImMessageStatus.READ
this.persistMessage(message, conversation.type).catch((e) =>
console.warn('[IM messageStore] 已读状态写入失败', e)
)
}
})
},
/** 删除会话全部消息 */
deleteConversationMessages(conversationType: number, targetId: number) {
// TODO @AI代码段的注释
const clientConversationId = getClientConversationId(conversationType, targetId)
const messages = this.messagesByConversation[clientConversationId] || []
messages.forEach((message) => {
revokeBlobUrlsInContent(message.content)
message._localFile = undefined
})
delete this.messagesByConversation[clientConversationId]
this.loadedConversationKeys = this.loadedConversationKeys.filter(
(key) => key !== clientConversationId
)
getDb()
.deleteByIndex('messages', 'clientConversationId', clientConversationId)
.catch((e) => console.warn('[IM messageStore] 会话消息删除失败', e))
}
}
})
export const useMessageStoreWithOut = () => useMessageStore(store)
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot))
}

View File

@ -28,6 +28,7 @@ import {
WS_RECONNECT_JITTER_MS
} from '../../utils/config'
import { useConversationStore } from './conversationStore'
import { useMessageStore } from './messageStore'
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
import { getFriendDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
@ -317,10 +318,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
/**
* + insertMessage
* pull WS id conversationStore.insertMessage id
* pull WS id messageStore.insertMessage id
*/
handleChannelMessage(websocketMessage: ImChannelMessageRespVO) {
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
// 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱
if (conversationStore.loading) {
this.messageBuffer.push({
@ -333,7 +335,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
typeof websocketMessage.sendTime === 'number'
? websocketMessage.sendTime
: new Date(websocketMessage.sendTime).getTime()
conversationStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
messageStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
id: websocketMessage.id,
clientMessageId: '',
type: websocketMessage.type,
@ -513,7 +515,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage
// 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态),不让它作为新消息进列表
if (websocketMessage.type === ImMessageType.RECALL) {
conversationStore.recallMessage(
useMessageStore().recallMessage(
ImConversationType.PRIVATE,
peerId,
websocketMessage.content
@ -523,7 +525,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 4. 后端 DTO → 前端 Message发送人名渲染时实时算不写入消息字段
const message = convertPrivateMessage(websocketMessage, currentUserId)
conversationStore.insertMessage(
useMessageStore().insertMessage(
{
type: ImConversationType.PRIVATE,
targetId: peerId,
@ -543,7 +545,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id
conversationStore.markActiveAsRead()
conversationStore.markConversationAsRead(ImConversationType.PRIVATE, peerId)
if (conversation) {
useMessageStore().markConversationMessagesRead(conversation)
}
if (MESSAGE_PRIVATE_READ_ENABLED) {
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)
@ -580,8 +585,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!websocketMessage.id) {
return
}
const conversationStore = useConversationStore()
conversationStore.applyReadReceipt({
useMessageStore().applyReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: websocketMessage.senderId,
privateReadMaxId: websocketMessage.id
@ -637,7 +641,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`
// 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态)
if (websocketMessage.type === ImMessageType.RECALL) {
conversationStore.recallMessage(
useMessageStore().recallMessage(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.content
@ -647,7 +651,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 4. 后端 DTO → 前端 Message发送人名渲染时实时算不写入消息字段
const message = convertGroupMessage(websocketMessage, currentUserId)
conversationStore.insertMessage(
useMessageStore().insertMessage(
{
type: ImConversationType.GROUP,
targetId: websocketMessage.groupId,
@ -669,7 +673,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
conversationStore.markActiveAsRead()
conversationStore.markConversationAsRead(
ImConversationType.GROUP,
websocketMessage.groupId
)
if (conversation) {
useMessageStore().markConversationMessagesRead(conversation)
}
if (MESSAGE_GROUP_READ_ENABLED) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)
@ -698,8 +708,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!MESSAGE_GROUP_READ_ENABLED) {
return
}
const conversationStore = useConversationStore()
conversationStore.applyReadReceipt({
useMessageStore().applyReadReceipt({
conversationType: ImConversationType.GROUP,
targetId: websocketMessage.groupId,
groupMessageId: websocketMessage.id,

View File

@ -47,13 +47,16 @@ export interface Conversation {
name: string // 展示名称(私聊=好友昵称;群聊=群名)
avatar: string // 头像
unreadCount: number // 未读数
messages: Message[] // 消息列表
// ========== 最后一条消息事实索引 ==========
lastContent: string // 会话列表展示的最后一条消息摘要
lastSendTime: number // 最后一条消息时间,用于排序
lastSenderId?: number // 发送人编号
lastMessageType?: number // 消息类型,对齐 ImMessageType
lastMessageId?: number // 最后一条服务端消息编号
lastClientMessageId?: string // 最后一条客户端消息编号
lastMessageStatus?: number // 最后一条消息状态
lastReceiptStatus?: number // 最后一条群回执状态
lastSelfSend?: boolean // 是否自己发的
lastSenderDisplayName?: string // 发送人显示名快照——仅作 utils/user.getSenderDisplayName 实时算不出真名时的 fallback
@ -68,7 +71,8 @@ export interface Conversation {
// 消息数据结构
export interface Message {
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO ==========
id: number // 服务端消息编号,发送中为 0
// TODO @AI全局的 id 占位 0是不是枚举下
id?: number // 服务端消息编号,发送中为空
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
type: number // 消息类型,对齐 ImMessageType
content: string // 消息内容JSON 字符串
@ -90,23 +94,25 @@ export interface Message {
// 媒体消息内存中保留的原始 File下划线前缀表示不进 JSON / 不持久化IDB 恢复后必为 undefined
// 失败重试时按它重走上传;页面刷新后该字段丢失,恢复阶段直接 drop 整条消息
_localFile?: File
_ackMerging?: boolean // ack 合并中标记,不持久化
}
/**
* Conversation messages messages key
*
* Omit<T, K> TS T K
* `Omit<Conversation, 'messages'>` "Conversation 去掉 messages 字段后的版本"
* "Conversation 派生但少一个 messages 字段"
*/
export type ConversationMeta = Omit<Conversation, 'messages'>
// ==================== IndexedDB 本地存储结构 ====================
// 持久化的会话索引:游标 + 会话元数据列表,按用户 ID 分桶
export interface ConversationStoreMeta {
privateMessageMaxId: number // 私聊消息最大编号
groupMessageMaxId: number // 群聊消息最大编号
channelMessageMaxId?: number // 频道消息最大编号
conversations: ConversationMeta[] // 会话索引(不含 messages
export interface ConversationDO extends Conversation {
clientConversationId: string // `${type}:${targetId}`
}
export interface MessageDO extends Omit<Message, 'uploadProgress' | '_localFile' | '_ackMerging'> {
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number // 会话类型,对齐 ImConversationType
clientConversationId: string // ConversationDO.clientConversationId
}
export interface SettingDO<T = unknown> {
key: string
value: T
updateTime?: number
}
// ==================== 群 / 群成员 ====================

View File

@ -74,6 +74,7 @@ import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import * as ManagerFacePackItemApi from '@/api/im/manager/face/item'
import { probeImageSize } from '@/views/im/utils/image'
import type { FormRules } from 'element-plus'
defineOptions({ name: 'ImManagerFacePackItemForm' })
@ -99,7 +100,7 @@ const formData = ref({
sort: 0,
status: CommonStatusEnum.ENABLE
})
const formRules = reactive({
const formRules = reactive<FormRules>({
url: [{ required: true, message: '表情图不能为空', trigger: 'change' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
// 1-2048 submit

482
src/views/im/utils/db.ts Normal file
View File

@ -0,0 +1,482 @@
import { toRaw } from 'vue'
import { getCurrentUserId } from './storage'
import { ImConversationType } from './constants'
import type { MessageDO, SettingDO } from '../home/types'
export const DB_SCHEMA_VERSION = 1
export type DbStoreName =
| 'conversations'
| 'messages'
| 'friends'
| 'friendRequests'
| 'groups'
| 'groupMembers'
| 'groupRequests'
| 'channels'
| 'settings'
// TODO @AI是不是继续使用 IDBTransaction不用新的类型定义
export type DbTx = IDBTransaction
let currentDb: IDBDatabase | null = null
let currentUserId: number | null = null
let currentSession = 0
/** 校验当前 IM IndexedDB session 仍有效 */
export function isCurrentDbSession(session: number): boolean {
return session === currentSession
}
/** 获取当前 IM IndexedDB session */
export function getDbSession(): number {
return currentSession
}
/** 拼接当前用户 IM DB 名称 */
function getDbName(userId: number): string {
return `im:${userId}`
}
/** 包装 IndexedDB request */
function requestToPromise<T = unknown>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
/** 等待事务完成 */
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
transaction.onabort = () => reject(transaction.error)
})
}
/** 创建索引 */
function createIndex(
store: IDBObjectStore,
name: string,
keyPath: string | string[],
options?: IDBIndexParameters
) {
if (!store.indexNames.contains(name)) {
store.createIndex(name, keyPath, options)
}
}
/** 初始化 schema */
function upgradeSchema(db: IDBDatabase) {
if (!db.objectStoreNames.contains('conversations')) {
const store = db.createObjectStore('conversations', { keyPath: 'clientConversationId' })
createIndex(store, 'lastSendTime', 'lastSendTime')
}
if (!db.objectStoreNames.contains('messages')) {
const store = db.createObjectStore('messages', { keyPath: 'messageKey' })
createIndex(store, 'clientConversationId', 'clientConversationId')
createIndex(store, 'clientConversationId+sendTime', ['clientConversationId', 'sendTime'])
createIndex(store, 'clientMessageId', 'clientMessageId', { unique: true })
}
if (!db.objectStoreNames.contains('friends')) {
const store = db.createObjectStore('friends', { keyPath: 'id' })
createIndex(store, 'friendUserId', 'friendUserId', { unique: true })
createIndex(store, 'status', 'status')
}
if (!db.objectStoreNames.contains('friendRequests')) {
const store = db.createObjectStore('friendRequests', { keyPath: 'id' })
createIndex(store, 'status', 'status')
createIndex(store, 'createTime', 'createTime')
}
if (!db.objectStoreNames.contains('groups')) {
const store = db.createObjectStore('groups', { keyPath: 'id' })
createIndex(store, 'name', 'name')
createIndex(store, 'status', 'status')
}
if (!db.objectStoreNames.contains('groupMembers')) {
const store = db.createObjectStore('groupMembers', { keyPath: 'id' })
createIndex(store, 'groupId', 'groupId')
createIndex(store, 'groupId+userId', ['groupId', 'userId'], { unique: true })
}
if (!db.objectStoreNames.contains('groupRequests')) {
const store = db.createObjectStore('groupRequests', { keyPath: 'id' })
createIndex(store, 'status', 'status')
createIndex(store, 'createTime', 'createTime')
}
if (!db.objectStoreNames.contains('channels')) {
const store = db.createObjectStore('channels', { keyPath: 'id' })
createIndex(store, 'status', 'status')
createIndex(store, 'sort', 'sort')
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' })
}
}
/** 打开 IM IndexedDB */
// TODO @AI是不是方法里代码段的注释是不是要增加下
function openDb(name: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, DB_SCHEMA_VERSION)
request.onupgradeneeded = () => upgradeSchema(request.result)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
/** 初始化当前用户 IM DB */
// TODO @AI是不是方法里代码段的注释是不是要增加下
export async function initDb(): Promise<void> {
const userId = getCurrentUserId()
if (!Number.isFinite(userId) || userId <= 0) {
throw new Error('当前用户不存在,无法初始化 IM DB')
}
if (currentDb && currentUserId === userId) {
return
}
currentDb?.close()
currentSession++
currentUserId = userId
currentDb = await openDb(getDbName(userId))
}
/** 关闭当前 IM DB session */
// TODO @AI是不是需要被调用下
export function closeDbSession() {
currentSession++
currentDb?.close()
currentDb = null
currentUserId = null
}
/** 获取当前 IM DB */
function getRawDb(): IDBDatabase {
if (!currentDb) {
throw new Error('IM DB 未初始化')
}
return currentDb
}
/** 校验单次写入 session */
function guardSession(session: number) {
if (!isCurrentDbSession(session)) {
throw new Error('IM DB session 已失效')
}
}
/** 克隆可入库对象 */
function toDbValue<T>(value: T): T {
return toRaw(value) as T
}
// TODO @AI我们讨论下DbWrapper 会不会有点怪?
// TODO @AI是不是改成 selectOne、selectAll、selectList、insert、update、delete、save 这种。
class DbWrapper {
/** 获取单条记录 */
// TODO @AI是不是不用缩写tx 改成 transaction 更好理解;
async get<T>(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<T | undefined> {
if (tx) {
return requestToPromise<T | undefined>(tx.objectStore(storeName).get(key))
}
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
this.get<T>(storeName, key, innerTx)
)
}
/** 获取 store 全量记录 */
async getAll<T>(storeName: DbStoreName, tx?: DbTx): Promise<T[]> {
if (tx) {
return requestToPromise<T[]>(tx.objectStore(storeName).getAll())
}
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
this.getAll<T>(storeName, innerTx)
)
}
/** 按唯一索引获取单条记录 */
async getByIndex<T>(
storeName: DbStoreName,
indexName: string,
query: IDBValidKey | IDBKeyRange,
tx?: DbTx
): Promise<T | undefined> {
if (tx) {
return requestToPromise<T | undefined>(tx.objectStore(storeName).index(indexName).get(query))
}
return this.transaction<T | undefined>([storeName], 'readonly', (innerTx) =>
this.getByIndex<T>(storeName, indexName, query, innerTx)
)
}
/** 按索引获取记录列表 */
async getAllByIndex<T>(
storeName: DbStoreName,
indexName: string,
query?: IDBValidKey | IDBKeyRange,
tx?: DbTx
): Promise<T[]> {
if (tx) {
return requestToPromise<T[]>(tx.objectStore(storeName).index(indexName).getAll(query))
}
return this.transaction<T[]>([storeName], 'readonly', (innerTx) =>
this.getAllByIndex<T>(storeName, indexName, query, innerTx)
)
}
/** 写入记录 */
async put<T>(storeName: DbStoreName, value: T, tx?: DbTx): Promise<void> {
if (tx) {
await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)))
return
}
await this.transaction([storeName], 'readwrite', (innerTx) =>
this.put(storeName, value, innerTx)
)
}
/** 删除记录 */
async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise<void> {
if (tx) {
await requestToPromise(tx.objectStore(storeName).delete(key))
return
}
await this.transaction([storeName], 'readwrite', (innerTx) =>
this.delete(storeName, key, innerTx)
)
}
/** 按索引删除记录 */
async deleteByIndex(
storeName: DbStoreName,
indexName: string,
query: IDBValidKey | IDBKeyRange,
tx?: DbTx
): Promise<void> {
if (!tx) {
await this.transaction([storeName], 'readwrite', (innerTx) =>
this.deleteByIndex(storeName, indexName, query, innerTx)
)
return
}
const index = tx.objectStore(storeName).index(indexName)
await new Promise<void>((resolve, reject) => {
const request = index.openCursor(query)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const cursor = request.result
if (!cursor) {
resolve()
return
}
cursor.delete()
cursor.continue()
}
})
}
/** 执行事务 */
// TODO @AI方法里的方法段的注释需要写么
async transaction<T>(
storeNames: DbStoreName[],
mode: IDBTransactionMode,
runner: (tx: DbTx) => Promise<T>
): Promise<T> {
const session = getDbSession()
guardSession(session)
const tx = getRawDb().transaction(storeNames, mode)
const done = transactionDone(tx)
let result: T
try {
result = await runner(tx)
} catch (e) {
// TODO @AI这种 logger error 要打印么?
try {
tx.abort()
} catch {}
await done.catch(() => undefined)
throw e
}
await done
guardSession(session)
return result
}
/** 按会话分页获取消息 */
// TODO @AI这个方法里代码段的注释是不是要增加下比如说分页的逻辑游标的逻辑等等
// TODO @AI项目里一般方法名是使用 getListByXXXX
async getMessagesByConversation(
clientConversationId: string,
options?: { beforeSendTime?: number; limit?: number },
tx?: DbTx
): Promise<MessageDO[]> {
const limit = options?.limit ?? 50
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER
const range = IDBKeyRange.bound(
[clientConversationId, 0],
[clientConversationId, upper],
false,
true
)
const read = async (innerTx: DbTx): Promise<MessageDO[]> => {
const index = innerTx.objectStore('messages').index('clientConversationId+sendTime')
const out: MessageDO[] = []
await new Promise<void>((resolve, reject) => {
const request = index.openCursor(range, 'prev')
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const cursor = request.result
if (!cursor || out.length >= limit) {
resolve()
return
}
out.push(cursor.value as MessageDO)
cursor.continue()
}
})
return out.reverse()
}
if (tx) {
return read(tx)
}
return this.transaction<MessageDO[]>(['messages'], 'readonly', read)
}
/** 读取设置 */
async getSetting<T>(key: string, tx?: DbTx): Promise<T | undefined> {
const item = await this.get<SettingDO<T>>('settings', key, tx)
return item?.value
}
/** 写入设置 */
async setSetting<T>(key: string, value: T, tx?: DbTx): Promise<void> {
await this.put<SettingDO<T>>('settings', { key, value, updateTime: Date.now() }, tx)
}
}
const dbWrapper = new DbWrapper()
/** 获取当前 IM DB wrapper */
export function getDb(): DbWrapper {
return dbWrapper
}
/** 当前用户会话主键 */
export function getClientConversationId(type: number, targetId: number): string {
return `${type}:${targetId}`
}
/** 解析当前用户会话主键 */
export function parseClientConversationId(
clientConversationId: string
): { type: number; targetId: number } | null {
const [typeText, targetIdText] = clientConversationId.split(':')
const type = Number(typeText)
const targetId = Number(targetIdText)
if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) {
// TODO @AIlogger info
return null
}
return { type, targetId }
}
/** 服务端消息主键 */
export function getServerMessageKey(conversationType: number, id: number): string {
return `${conversationType}:${id}`
}
/** 客户端临时消息主键 */
export function getClientMessageKey(clientMessageId: string): string {
return `client:${clientMessageId}`
}
/** 解析本地消息主键 */
// TODO @AI这个方法貌似没调用
export function parseMessageKey(
messageKey: string
):
| { kind: 'client'; clientMessageId: string }
| { kind: 'server'; conversationType: number; id: number }
| null {
if (!messageKey) {
return null
}
if (messageKey.startsWith('client:')) {
const clientMessageId = messageKey.slice('client:'.length)
return clientMessageId ? { kind: 'client', clientMessageId } : null
}
const [conversationTypeText, idText] = messageKey.split(':')
const conversationType = Number(conversationTypeText)
const id = Number(idText)
if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) {
return null
}
return { kind: 'server', conversationType, id }
}
/** 更新消息拉取游标 */
export async function setMessageMaxId(
conversationType: number,
maxId: number | undefined,
tx?: DbTx
): Promise<void> {
if (!maxId) {
return
}
let key: string
switch (conversationType) {
case ImConversationType.PRIVATE:
key = 'privateMessageMaxId'
break
case ImConversationType.GROUP:
key = 'groupMessageMaxId'
break
case ImConversationType.CHANNEL:
key = 'channelMessageMaxId'
break
default:
throw new Error(`未知 IM 会话类型:${conversationType}`)
}
const db = getDb()
const current = (await db.getSetting<number>(key, tx)) || 0
if (maxId > current) {
await db.setSetting(key, maxId, tx)
}
}
/** 停止当前 IM DB session */
// TODO @AI这里的注释要写下方法注释
export async function stopRequests(): Promise<void> {
const [
{ useMessageStoreWithOut },
{ useConversationStoreWithOut },
{ useFriendStoreWithOut },
{ useGroupStoreWithOut },
{ useChannelStoreWithOut },
{ useGroupRequestStoreWithOut },
{ useDraftStoreWithOut },
{ useFaceStoreWithOut }
] = await Promise.all([
import('../home/store/messageStore'),
import('../home/store/conversationStore'),
import('../home/store/friendStore'),
import('../home/store/groupStore'),
import('../home/store/channelStore'),
import('../home/store/groupRequestStore'),
import('../home/store/draftStore'),
import('../home/store/faceStore')
])
currentSession++
useMessageStoreWithOut().clear()
useConversationStoreWithOut().clear()
useFriendStoreWithOut().clear()
useGroupStoreWithOut().clear()
useChannelStoreWithOut().clear()
useGroupRequestStoreWithOut().reset()
useDraftStoreWithOut().clear()
useFaceStoreWithOut().reset()
currentDb?.close()
currentDb = null
currentUserId = null
}

View File

@ -414,7 +414,7 @@ function mapMessageToMergeItem(
): MergeMessageItem {
const snapshot = senderSnapshots.get(message.senderId)
return {
messageId: message.id,
messageId: message.id || 0,
senderId: message.senderId,
senderNickname: snapshot?.nickname ?? String(message.senderId),
senderAvatar: snapshot?.avatar ?? '',
@ -555,7 +555,7 @@ export const removeQuotePayload = (content: string): string => {
/** 由 Message 派生 QuoteMessage 用于乐观渲染;ack 后会被服务端权威版本覆盖 */
export const buildQuoteFromMessage = (message: Message): QuoteMessage => {
return {
messageId: message.id,
messageId: message.id || 0,
senderId: message.senderId,
type: message.type,
content: removeQuotePayload(message.content)
@ -882,7 +882,10 @@ export function resolveRtcCallTipSegments(message: {
}
if (message.type === ImMessageType.RTC_CALL_START) {
return payload.inviterUserId
? [tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)), tipText(' 发起了语音通话')]
? [
tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)),
tipText(' 发起了语音通话')
]
: []
}
if (message.type === ImMessageType.RTC_CALL_END) {

View File

@ -4,47 +4,23 @@ import { toRaw } from 'vue'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
/**
* IM IndexedDB localforage IndexedDB WebSQL / localStorage
*
* localStorage
* 1. localStorage 5~10MB
* 2. localStorage key MB 线
*
* key StorageKeys key
* IndexedDB ~50% localStorage
* IM localforage IndexedDB WebSQL / localStorage
*/
export const imStorage = localforage.createInstance({
name: 'im',
storeName: 'conversation',
description: 'IM 会话索引与消息缓存'
description: 'IM 本地缓存'
})
/**
* key
*
* - / / imStorageIndexedDBkey userId
* - / imStorageIndexedDBkey userId
* - UI localStorage Tab IndexedDB
*
* key userId in-memoryIDB / /
*/
export const StorageKeys = {
/**
* + messages ConversationStoreMeta
*
* top / silent / unread / / key messages key KB
*/
conversationMeta: (userId: number | string) => `conversation:meta:${userId}`,
/**
* (type, targetId) Message[]
*
* - type / ImConversationType
* - targetId userId / groupId
*
* key"全量写所有会话所有消息" conversationStore.removeConversationMessages key orphan
*/
conversationMessages: (userId: number | string, type: number, targetId: number) =>
`conversation:messages:${userId}:${type}:${targetId}`,
/**
* 稿Record<`${type}:${targetId}`, DraftSnapshot>
*
@ -59,8 +35,7 @@ export const StorageKeys = {
/** 频道列表整桶;频道量级很小,整桶整写够用 */
channels: (userId: number | string) => `channels:${userId}`,
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
groupMembers: (userId: number | string, groupId: number) =>
`groupMembers:${userId}:${groupId}`,
groupMembers: (userId: number | string, groupId: number) => `groupMembers:${userId}:${groupId}`,
/** 最近转发会话 key 列表(按 userId 分桶ConversationPickerPanel 左栏顶部头像区使用 */
recentForwardConversationKeys: (userId: number | string) =>
@ -81,11 +56,40 @@ export function getCurrentUserId(): number {
/** IDB 写入fire-and-forget */
export function setQuietly(key: string, value: unknown, errorLabel: string): void {
// toRaw 拆 Vue / Pinia reactive Proxy——structuredClone 不接 Proxy 会抛 DataCloneError 静默丢盘
const raw = value && typeof value === 'object' ? toRaw(value) : value
const raw = toStorageValue(value)
void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e))
}
export function removeQuietly(key: string, errorLabel: string): void {
void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e))
}
/** 转换为 IndexedDB 可存储的数据 */
// TODO @AI后续是不是可以删除掉尽量使用 db.ts 对不对哈?
function toStorageValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
const raw = value && typeof value === 'object' ? toRaw(value) : value
if (!raw || typeof raw !== 'object') {
return raw
}
if (raw instanceof Date || raw instanceof Blob || raw instanceof ArrayBuffer) {
return raw
}
if (seen.has(raw)) {
return seen.get(raw) as T
}
if (Array.isArray(raw)) {
const array: unknown[] = []
seen.set(raw, array)
raw.forEach((item) => array.push(toStorageValue(item, seen)))
return array as T
}
const out: Record<string, unknown> = {}
seen.set(raw, out)
Object.entries(raw).forEach(([key, item]) => {
if (typeof item === 'function' || typeof item === 'symbol') {
return
}
out[key] = toStorageValue(item, seen)
})
return out as T
}