From 7aee52a47ecada50d9f118b7217d01c890aa9aeb Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 21 Jul 2026 18:41:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(im):=20=E5=90=8C=E6=AD=A5=E5=B9=B6?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E4=B8=89=E7=AB=AF=E5=B9=B6=E5=8F=91=E4=B8=8E?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=95=B0=E6=8D=AE=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/rtc/rtc-call-container.vue | 451 +++- .../im/home/composables/useLiveKitRoom.ts | 342 +-- .../home/composables/useMediaStreamElement.ts | 35 +- .../im/home/composables/useMediaUploader.ts | 469 ++-- .../im/home/composables/useMessagePuller.ts | 495 ++--- .../im/home/composables/useMessageSender.ts | 393 ++-- apps/web-antd/src/views/im/home/index.vue | 114 +- .../conversation/conversation-group-side.vue | 39 +- .../conversation-private-side.vue | 5 +- .../components/input/message-input.vue | 50 +- .../components/input/voice-recorder.vue | 57 +- .../components/message/message-history.vue | 60 +- .../components/message/message-item.vue | 117 +- .../src/views/im/home/store/channelStore.ts | 160 +- .../views/im/home/store/conversationStore.ts | 1221 +++++----- .../src/views/im/home/store/faceStore.ts | 155 +- .../src/views/im/home/store/friendStore.ts | 896 +++----- .../views/im/home/store/groupRequestStore.ts | 263 +-- .../src/views/im/home/store/groupStore.ts | 1558 +++++++------ .../src/views/im/home/store/messageStore.ts | 1974 +++++++++++------ .../src/views/im/home/store/rtcStore.ts | 432 ++-- .../src/views/im/home/store/websocketStore.ts | 1146 +++++----- .../web-antd/src/views/im/home/types/index.ts | 18 + apps/web-antd/src/views/im/utils/auth.ts | 2 - apps/web-antd/src/views/im/utils/config.ts | 59 +- apps/web-antd/src/views/im/utils/constants.ts | 135 +- .../src/views/im/utils/conversation.ts | 155 +- apps/web-antd/src/views/im/utils/db.ts | 623 +++--- apps/web-antd/src/views/im/utils/emoji.ts | 4 +- apps/web-antd/src/views/im/utils/group.ts | 350 ++- apps/web-antd/src/views/im/utils/message.ts | 956 ++++---- apps/web-antd/src/views/im/utils/pull.ts | 138 +- .../components/friend/friend-add-dialog.vue | 31 +- .../home/components/group/group-info-card.vue | 17 +- .../im/home/components/group/group-info.vue | 15 +- .../group/group-owner-transfer-dialog.vue | 13 +- .../components/rtc/rtc-call-container.vue | 451 +++- .../components/rtc/rtc-group-call-banner.vue | 43 +- .../im/home/components/user/user-info.vue | 25 +- .../im/home/composables/useLiveKitRoom.ts | 342 +-- .../home/composables/useMediaStreamElement.ts | 35 +- .../im/home/composables/useMediaUploader.ts | 469 ++-- .../im/home/composables/useMessagePuller.ts | 495 ++--- .../im/home/composables/useMessageSender.ts | 393 ++-- .../src/views/im/home/index.vue | 114 +- .../pages/contact/friend-request-detail.vue | 10 +- .../src/views/im/home/pages/contact/index.vue | 26 +- .../conversation/conversation-group-side.vue | 39 +- .../conversation/conversation-item.vue | 26 +- .../conversation-private-side.vue | 5 +- .../components/input/face-picker.vue | 3 + .../components/input/message-input.vue | 52 +- .../input/message-multi-select-bar.vue | 23 +- .../components/input/voice-recorder.vue | 57 +- .../components/message/message-history.vue | 60 +- .../components/message/message-item.vue | 117 +- .../components/message/message-panel.vue | 45 +- .../message/message-read-status.vue | 2 +- .../src/views/im/home/store/channelStore.ts | 160 +- .../views/im/home/store/conversationStore.ts | 1221 +++++----- .../src/views/im/home/store/faceStore.ts | 155 +- .../src/views/im/home/store/friendStore.ts | 896 +++----- .../views/im/home/store/groupRequestStore.ts | 263 +-- .../src/views/im/home/store/groupStore.ts | 1558 +++++++------ .../src/views/im/home/store/messageStore.ts | 1974 +++++++++++------ .../src/views/im/home/store/rtcStore.ts | 432 ++-- .../src/views/im/home/store/websocketStore.ts | 1146 +++++----- .../src/views/im/home/types/index.ts | 18 + .../web-antdv-next/src/views/im/utils/auth.ts | 2 - .../src/views/im/utils/config.ts | 59 +- .../src/views/im/utils/constants.ts | 135 +- .../src/views/im/utils/conversation.ts | 155 +- apps/web-antdv-next/src/views/im/utils/db.ts | 623 +++--- .../src/views/im/utils/emoji.ts | 4 +- .../src/views/im/utils/group.ts | 350 ++- .../src/views/im/utils/message.ts | 956 ++++---- .../web-antdv-next/src/views/im/utils/pull.ts | 138 +- .../components/friend/friend-add-dialog.vue | 31 +- .../home/components/group/group-info-card.vue | 17 +- .../im/home/components/group/group-info.vue | 15 +- .../group/group-owner-transfer-dialog.vue | 13 +- .../components/rtc/rtc-call-container.vue | 451 +++- .../components/rtc/rtc-group-call-banner.vue | 43 +- .../im/home/components/user/user-info.vue | 16 +- .../im/home/composables/useLiveKitRoom.ts | 342 +-- .../home/composables/useMediaStreamElement.ts | 35 +- .../im/home/composables/useMediaUploader.ts | 471 ++-- .../im/home/composables/useMessagePuller.ts | 495 ++--- .../im/home/composables/useMessageSender.ts | 393 ++-- apps/web-ele/src/views/im/home/index.vue | 114 +- .../pages/contact/friend-request-detail.vue | 10 +- .../src/views/im/home/pages/contact/index.vue | 26 +- .../conversation/conversation-group-side.vue | 39 +- .../conversation/conversation-item.vue | 26 +- .../conversation-private-side.vue | 5 +- .../components/input/face-picker.vue | 3 + .../components/input/message-input.vue | 52 +- .../input/message-multi-select-bar.vue | 23 +- .../components/input/voice-recorder.vue | 57 +- .../components/message/message-history.vue | 51 +- .../components/message/message-item.vue | 115 +- .../components/message/message-panel.vue | 45 +- .../message/message-read-status.vue | 2 +- .../src/views/im/home/store/channelStore.ts | 160 +- .../views/im/home/store/conversationStore.ts | 1221 +++++----- .../src/views/im/home/store/faceStore.ts | 155 +- .../src/views/im/home/store/friendStore.ts | 896 +++----- .../views/im/home/store/groupRequestStore.ts | 263 +-- .../src/views/im/home/store/groupStore.ts | 1558 +++++++------ .../src/views/im/home/store/messageStore.ts | 1974 +++++++++++------ .../src/views/im/home/store/rtcStore.ts | 432 ++-- .../src/views/im/home/store/websocketStore.ts | 1146 +++++----- apps/web-ele/src/views/im/home/types/index.ts | 18 + apps/web-ele/src/views/im/utils/auth.ts | 2 - apps/web-ele/src/views/im/utils/config.ts | 59 +- apps/web-ele/src/views/im/utils/constants.ts | 135 +- .../src/views/im/utils/conversation.ts | 155 +- apps/web-ele/src/views/im/utils/db.ts | 623 +++--- apps/web-ele/src/views/im/utils/emoji.ts | 4 +- apps/web-ele/src/views/im/utils/group.ts | 350 ++- apps/web-ele/src/views/im/utils/message.ts | 956 ++++---- apps/web-ele/src/views/im/utils/pull.ts | 138 +- 122 files changed, 19710 insertions(+), 19450 deletions(-) diff --git a/apps/web-antd/src/views/im/home/components/rtc/rtc-call-container.vue b/apps/web-antd/src/views/im/home/components/rtc/rtc-call-container.vue index 490bfeb31..8e09d2fe2 100644 --- a/apps/web-antd/src/views/im/home/components/rtc/rtc-call-container.vue +++ b/apps/web-antd/src/views/im/home/components/rtc/rtc-call-container.vue @@ -1,11 +1,13 @@ + diff --git a/apps/web-antd/src/views/im/home/store/channelStore.ts b/apps/web-antd/src/views/im/home/store/channelStore.ts index 628f57f38..cf6b90bfe 100644 --- a/apps/web-antd/src/views/im/home/store/channelStore.ts +++ b/apps/web-antd/src/views/im/home/store/channelStore.ts @@ -1,15 +1,21 @@ -import type { ImManagerChannelApi } from '#/api/im/manager/channel'; +import type { ChannelDO } from '../types' -import { acceptHMRUpdate, defineStore } from 'pinia'; +import type { ImManagerChannelApi } from '#/api/im/manager/channel' -import { getSimpleChannelList } from '#/api/im/manager/channel'; -import { getCurrentUserId } from '#/views/im/utils/auth'; +import { acceptHMRUpdate, defineStore } from 'pinia' -import { ImConversationType } from '../../utils/constants'; -import { getDb } from '../../utils/db'; -import { useConversationStore } from './conversationStore'; +import { getSimpleChannelList } from '#/api/im/manager/channel' -let storeEpoch = 0; // clear 时递增;旧账号请求返回后不得写入新账号状态 +import { ImConversationType } from '../../utils/constants' +import { type DbClient, getDb, initDb } from '../../utils/db' +import { + ResourceRequestKey, + ResourceRequestMode, + runResourceRequest +} from '../../utils/resourceRequest' +import { useConversationStore } from './conversationStore' + +type ImManagerChannelVO = ImManagerChannelApi.Channel /** * IM 频道 Store @@ -19,14 +25,14 @@ let storeEpoch = 0; // clear 时递增;旧账号请求返回后不得写入新 */ export const useChannelStore = defineStore('imChannelStore', { state: () => ({ - channels: [] as ImManagerChannelApi.Channel[], - loaded: false, + channels: [] as ImManagerChannelVO[], + loaded: false }), getters: { - getChannel(state): (id: number) => ImManagerChannelApi.Channel | undefined { - return (id: number) => state.channels.find((c) => c.id === id); - }, + getChannel(state): (id: number) => ImManagerChannelVO | undefined { + return (id: number) => state.channels.find((c) => c.id === id) + } }, actions: { @@ -34,54 +40,27 @@ export const useChannelStore = defineStore('imChannelStore', { /** 从 IndexedDB 恢复频道列表 */ async loadChannelList(): Promise { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); try { - const cached = - await getDb().getAll('channels'); - if ( - requestEpoch !== storeEpoch || - getCurrentUserId() !== requestUserId - ) { - return false; - } + const cached = await getDb().getAll('channels') if (!cached || cached.length === 0) { - return false; + return false } - this.channels = cached; - return true; - } catch (error) { - if ( - requestEpoch === storeEpoch && - getCurrentUserId() === requestUserId - ) { - console.warn('[IM channelStore] 本地频道缓存读取失败', error); - } - return false; + this.channels = cached + return true + } catch (e) { + console.warn('[IM channelStore] 本地频道缓存读取失败', e) + return false } }, /** 保存频道列表 */ - saveChannelList(): void { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - const channels = [...this.channels]; - void getDb() - .transaction(['channels'], 'readwrite', async (tx) => { - const db = getDb(); - await db.clearStore('channels', tx); - for (const channel of channels) { - await db.put('channels', channel, tx); - } - }) - .catch((error) => { - if ( - requestEpoch === storeEpoch && - getCurrentUserId() === requestUserId - ) { - console.warn('[IM channelStore] 本地频道缓存写入失败', error); - } - }); + async saveChannelList(channels: ImManagerChannelVO[], db: DbClient = getDb()): Promise { + await db.transaction(['channels'], 'readwrite', async (tx) => { + await db.clearStore('channels', tx) + for (const channel of channels) { + await db.put('channels', channel, tx) + } + }) }, // ==================== 远端拉取 ==================== @@ -89,66 +68,57 @@ export const useChannelStore = defineStore('imChannelStore', { /** 拉取启用的频道精简列表;成功后回填会话列表已有的频道 name / avatar,覆盖 IDB 旧占位 */ async fetchChannelList(force = false) { if (this.loaded && !force) { - return; - } - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - try { - const channels = (await getSimpleChannelList()) || []; - if ( - requestEpoch !== storeEpoch || - getCurrentUserId() !== requestUserId - ) { - return; - } - this.channels = channels; - this.loaded = true; - this.syncChannelConversationMetadata(); - this.saveChannelList(); - } catch (error) { - if ( - requestEpoch === storeEpoch && - getCurrentUserId() === requestUserId - ) { - console.warn('[IM channelStore] fetchChannelList 失败', error); - } + return this.channels } + return runResourceRequest( + ResourceRequestKey.CHANNEL_LIST, + async () => { + const db = await initDb() + const channels = (await getSimpleChannelList()) || [] + this.channels = channels + this.loaded = true + this.syncChannelConversationMetadata(db) + await this.saveChannelList(channels, db).catch((e) => + console.warn('[IM channelStore] 本地频道缓存写入失败', e) + ) + return channels + }, + { mode: ResourceRequestMode.SINGLE_FLIGHT, refreshAfterPending: force } + ) }, /** 用最新的频道信息覆盖已有 CHANNEL 会话的 name / avatar */ - syncChannelConversationMetadata() { - const conversationStore = useConversationStore(); - const indexed = new Map(this.channels.map((c) => [c.id, c])); + syncChannelConversationMetadata(db: DbClient = getDb()) { + const conversationStore = useConversationStore() + const indexed = new Map(this.channels.map((c) => [c.id, c])) conversationStore.conversations.forEach((conversation) => { if (conversation.type !== ImConversationType.CHANNEL) { - return; + return } - const channel = indexed.get(conversation.targetId); + const channel = indexed.get(conversation.targetId) if (!channel) { - return; + return } conversationStore.updateConversation( ImConversationType.CHANNEL, conversation.targetId, { name: channel.name, - avatar: channel.avatar, + avatar: channel.avatar }, - ); - }); + db + ) + }) }, /** 清空频道内存 */ clear() { - storeEpoch++; - this.channels = []; - this.loaded = false; - }, - }, -}); + this.channels = [] + this.loaded = false + } + } +}) if (import.meta.hot) { - import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot)); + import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot)) } - -export const useChannelStoreWithOut = () => useChannelStore(); diff --git a/apps/web-antd/src/views/im/home/store/conversationStore.ts b/apps/web-antd/src/views/im/home/store/conversationStore.ts index 6264dee1b..83379beaa 100644 --- a/apps/web-antd/src/views/im/home/store/conversationStore.ts +++ b/apps/web-antd/src/views/im/home/store/conversationStore.ts @@ -1,78 +1,70 @@ -import type { DbTransaction } from '../../utils/db'; import type { Conversation, ConversationDO, ConversationRead, ConversationReadDO, - MessageDO, -} from '../types'; + Message, + MessageDO +} from '../types' -import type { ImConversationReadApi } from '#/api/im/conversation/read'; +import type { ImConversationReadApi } from '#/api/im/conversation/read' -import { acceptHMRUpdate, defineStore } from 'pinia'; +import { debounce } from '@vben/utils' -import { pullMyConversationReadList as apiPullMyConversationReadList } from '#/api/im/conversation/read'; -import { getCurrentUserId } from '#/views/im/utils/auth'; +import { acceptHMRUpdate, defineStore } from 'pinia' -import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'; +import { + pullMyConversationReadList as apiPullMyConversationReadList +} from '#/api/im/conversation/read' + +import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config' import { IM_AT_ALL_USER_ID, ImConversationType, ImMessageReceiptStatus, ImMessageStatus, - isNormalMessage, -} from '../../utils/constants'; -import { getClientConversationId, getDb, StorageKeys } from '../../utils/db'; -import { runIncrementalPull } from '../../utils/pull'; -import { useMessageStore } from './messageStore'; + isNormalMessage +} from '../../utils/constants' +import { + type DbClient, + type DbTransaction, + getClientConversationId, + getDb, + initDb, + StorageKeys +} from '../../utils/db' +import { + enqueueConversationBarrier, + enqueueConversationWrite, + enqueueConversationWrites, + isRelationTerminated +} from '../../utils/messageSync' +import { runIncrementalPull } from '../../utils/pull' +import { useMessageStore } from './messageStore' -const PERSIST_DRAFT_DEBOUNCE_MS = 500; -const pendingDraftConversations = new Set(); +type ImConversationReadRespVO = ImConversationReadApi.ConversationReadRespVO + +const PERSIST_DRAFT_DEBOUNCE_MS = 500 +const pendingDraftConversations = new Map() +const conversationProjectionBases = new WeakMap() // 记录投影构建基线,仅保留构建后发生的并发字段变更 /** 创建会话读位置记录 */ function createConversationRead( type: number, targetId: number, - messageId: number, + messageId: number ): ConversationRead { return { conversationType: type, targetId, messageId, - updateTime: Date.now(), - }; -} - -/** 创建草稿保存防抖函数 */ -function createDraftDebounce(fn: () => void, wait: number) { - let timer: ReturnType | undefined; - - const run = () => { - if (timer) { - clearTimeout(timer); - timer = undefined; - } - fn(); - }; - const debounced = () => { - if (timer) { - clearTimeout(timer); - } - timer = setTimeout(run, wait); - }; - debounced.cancel = () => { - if (timer) { - clearTimeout(timer); - timer = undefined; - } - }; - debounced.flush = run; - return debounced; + updateTime: Date.now() + } } /** 会话转 IndexedDB 记录 */ function toConversationDO(conversation: Conversation): ConversationDO { - const draft = conversation.draft; + const draft = conversation.draft return { targetId: conversation.targetId, type: conversation.type, @@ -97,20 +89,15 @@ function toConversationDO(conversation: Conversation): ConversationDO { atAll: conversation.atAll, atMessageId: conversation.atMessageId, atAllMessageId: conversation.atAllMessageId, - draft: draft - ? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined } - : undefined, - clientConversationId: getClientConversationId( - conversation.type, - conversation.targetId, - ), - }; + draft: draft ? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined } : undefined, + clientConversationId: getClientConversationId(conversation.type, conversation.targetId) + } } /** IndexedDB 记录转会话 */ function fromConversationDO(conversation: ConversationDO): Conversation { - const { clientConversationId: _clientConversationId, ...rest } = conversation; - return rest; + const { clientConversationId: _clientConversationId, ...rest } = conversation + return rest } /** 会话读位置转 IndexedDB 记录 */ @@ -120,24 +107,19 @@ function toConversationReadDO(record: ConversationRead): ConversationReadDO { targetId: record.targetId, messageId: record.messageId, updateTime: record.updateTime, - clientConversationId: getClientConversationId( - record.conversationType, - record.targetId, - ), - }; + clientConversationId: getClientConversationId(record.conversationType, record.targetId) + } } /** IndexedDB 记录转会话读位置 */ function fromConversationReadDO(record: ConversationReadDO): ConversationRead { - const { clientConversationId: _clientConversationId, ...rest } = record; - return rest; + const { clientConversationId: _clientConversationId, ...rest } = record + return rest } /** 是否为有效会话读位置 */ -function isValidConversationReadRecord( - record: ImConversationReadApi.ConversationReadRespVO, -): boolean { - return !!record.conversationType && !!record.targetId && !!record.messageId; +function isValidConversationReadRecord(record: ImConversationReadRespVO): boolean { + return !!record.conversationType && !!record.targetId && !!record.messageId } /** 按读位置重算会话未读与 @ 状态 */ @@ -145,11 +127,11 @@ function applyConversationUnreadState( conversation: Conversation, messages: MessageDO[], readMessageId: number, + userId: number ): boolean { - const currentUserId = getCurrentUserId(); - let unreadCount = 0; - let atMessageId: number | undefined; - let atAllMessageId: number | undefined; + let unreadCount = 0 + let atMessageId: number | undefined + let atAllMessageId: number | undefined for (const message of messages) { if ( !message.id || @@ -158,21 +140,14 @@ function applyConversationUnreadState( !isNormalMessage(message.type) || message.status === ImMessageStatus.RECALL ) { - continue; + continue } - unreadCount++; - if ( - currentUserId && - message.atUserIds?.includes(currentUserId) && - message.id > (atMessageId || 0) - ) { - atMessageId = message.id; + unreadCount++ + if (message.atUserIds?.includes(userId) && message.id > (atMessageId || 0)) { + atMessageId = message.id } - if ( - message.atUserIds?.includes(IM_AT_ALL_USER_ID) && - message.id > (atAllMessageId || 0) - ) { - atAllMessageId = message.id; + if (message.atUserIds?.includes(IM_AT_ALL_USER_ID) && message.id > (atAllMessageId || 0)) { + atAllMessageId = message.id } } const changed = @@ -180,13 +155,13 @@ function applyConversationUnreadState( conversation.atMe !== !!atMessageId || conversation.atAll !== !!atAllMessageId || conversation.atMessageId !== atMessageId || - conversation.atAllMessageId !== atAllMessageId; - conversation.unreadCount = unreadCount; - conversation.atMe = !!atMessageId; - conversation.atAll = !!atAllMessageId; - conversation.atMessageId = atMessageId; - conversation.atAllMessageId = atAllMessageId; - return changed; + conversation.atAllMessageId !== atAllMessageId + conversation.unreadCount = unreadCount + conversation.atMe = !!atMessageId + conversation.atAll = !!atAllMessageId + conversation.atMessageId = atMessageId + conversation.atAllMessageId = atAllMessageId + return changed } /** 无读位置时按原未读窗口应用撤回状态 */ @@ -194,51 +169,39 @@ function applyConversationRecallStateWithoutRead( conversation: Conversation, messages: MessageDO[], originalMessage: MessageDO, + userId: number ): boolean { - const previousUnreadCount = conversation.unreadCount; + const previousUnreadCount = conversation.unreadCount const originalWasIncomingNormal = !!originalMessage.id && !originalMessage.selfSend && isNormalMessage(originalMessage.type) && - originalMessage.status !== ImMessageStatus.RECALL; + originalMessage.status !== ImMessageStatus.RECALL const incomingNormalMessages = messages .filter( (message) => !!message.id && !message.selfSend && isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL, + message.status !== ImMessageStatus.RECALL ) - .toSorted((left, right) => (right.id || 0) - (left.id || 0)); + .sort((left, right) => (right.id || 0) - (left.id || 0)) const previousUnreadMessages = [ - ...incomingNormalMessages.filter( - (message) => message.id !== originalMessage.id, - ), - ...(originalWasIncomingNormal ? [originalMessage] : []), + ...incomingNormalMessages.filter((message) => message.id !== originalMessage.id), + ...(originalWasIncomingNormal ? [originalMessage] : []) ] - .toSorted((left, right) => (right.id || 0) - (left.id || 0)) - .slice(0, previousUnreadCount); - const recalledUnread = previousUnreadMessages.some( - (message) => message.id === originalMessage.id, - ); - const unreadCount = Math.max( - 0, - previousUnreadCount - (recalledUnread ? 1 : 0), - ); - const unreadMessages = incomingNormalMessages.slice(0, unreadCount); - const currentUserId = getCurrentUserId(); - let atMessageId = conversation.atMessageId; - let atAllMessageId = conversation.atAllMessageId; + .sort((left, right) => (right.id || 0) - (left.id || 0)) + .slice(0, previousUnreadCount) + const recalledUnread = previousUnreadMessages.some((message) => message.id === originalMessage.id) + const unreadCount = Math.max(0, previousUnreadCount - (recalledUnread ? 1 : 0)) + const unreadMessages = incomingNormalMessages.slice(0, unreadCount) + let atMessageId = conversation.atMessageId + let atAllMessageId = conversation.atAllMessageId if ( conversation.atMessageId === originalMessage.id || - (!conversation.atMessageId && - conversation.atMe && - !!currentUserId && - originalMessage.atUserIds?.includes(currentUserId)) + (!conversation.atMessageId && conversation.atMe && originalMessage.atUserIds?.includes(userId)) ) { - atMessageId = unreadMessages.find((message) => - message.atUserIds?.includes(currentUserId), - )?.id; + atMessageId = unreadMessages.find((message) => message.atUserIds?.includes(userId))?.id } if ( conversation.atAllMessageId === originalMessage.id || @@ -247,59 +210,54 @@ function applyConversationRecallStateWithoutRead( originalMessage.atUserIds?.includes(IM_AT_ALL_USER_ID)) ) { atAllMessageId = unreadMessages.find((message) => - message.atUserIds?.includes(IM_AT_ALL_USER_ID), - )?.id; + message.atUserIds?.includes(IM_AT_ALL_USER_ID) + )?.id } const changed = conversation.unreadCount !== unreadCount || conversation.atMe !== !!atMessageId || conversation.atAll !== !!atAllMessageId || conversation.atMessageId !== atMessageId || - conversation.atAllMessageId !== atAllMessageId; - conversation.unreadCount = unreadCount; - conversation.atMe = !!atMessageId; - conversation.atAll = !!atAllMessageId; - conversation.atMessageId = atMessageId; - conversation.atAllMessageId = atAllMessageId; - return changed; + conversation.atAllMessageId !== atAllMessageId + conversation.unreadCount = unreadCount + conversation.atMe = !!atMessageId + conversation.atAll = !!atAllMessageId + conversation.atMessageId = atMessageId + conversation.atAllMessageId = atAllMessageId + return changed } /** 为旧会话回填未读 @ 消息编号 */ function backfillConversationMentionIds( conversation: Conversation, messages: MessageDO[], + userId: number ): boolean { - const currentUserId = getCurrentUserId(); const unreadMessages = messages .filter( (message) => !!message.id && !message.selfSend && isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL, + message.status !== ImMessageStatus.RECALL ) - .toSorted((left, right) => (right.id || 0) - (left.id || 0)) - .slice(0, conversation.unreadCount); + .sort((left, right) => (right.id || 0) - (left.id || 0)) + .slice(0, conversation.unreadCount) const atMessageId = conversation.atMessageId || - (conversation.atMe && currentUserId - ? unreadMessages.find((message) => - message.atUserIds?.includes(currentUserId), - )?.id - : undefined); + (conversation.atMe + ? unreadMessages.find((message) => message.atUserIds?.includes(userId))?.id + : undefined) const atAllMessageId = conversation.atAllMessageId || (conversation.atAll - ? unreadMessages.find((message) => - message.atUserIds?.includes(IM_AT_ALL_USER_ID), - )?.id - : undefined); + ? unreadMessages.find((message) => message.atUserIds?.includes(IM_AT_ALL_USER_ID))?.id + : undefined) const changed = - conversation.atMessageId !== atMessageId || - conversation.atAllMessageId !== atAllMessageId; - conversation.atMessageId = atMessageId; - conversation.atAllMessageId = atAllMessageId; - return changed; + conversation.atMessageId !== atMessageId || conversation.atAllMessageId !== atAllMessageId + conversation.atMessageId = atMessageId + conversation.atAllMessageId = atAllMessageId + return changed } export const useConversationStore = defineStore('imConversationStore', { @@ -309,32 +267,29 @@ export const useConversationStore = defineStore('imConversationStore', { activeConversation: null as Conversation | null, // 当前激活的会话 activeMentionMessageId: undefined as number | undefined, // 当前会话待定位的未读 @ 消息编号 loading: false, // 是否正在批量加载 - recentForwardConversationKeys: [] as string[], // 最近转发会话 key 列表 + recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表 }), getters: { /** 排序后的会话列表 */ getSortedConversationList(state): Conversation[] { - return state.conversations + return [...state.conversations] .filter((conversation) => !conversation.deleted) - .toSorted((a, b) => { - const aTop = a.top ? 1 : 0; - const bTop = b.top ? 1 : 0; + .sort((a, b) => { + const aTop = a.top ? 1 : 0 + const bTop = b.top ? 1 : 0 if (aTop !== bTop) { - return bTop - aTop; + return bTop - aTop } - return (b.lastSendTime || 0) - (a.lastSendTime || 0); - }); + return (b.lastSendTime || 0) - (a.lastSendTime || 0) + }) }, /** 未读总数 */ getTotalUnreadCount(state): number { return state.conversations .filter((conversation) => !conversation.deleted && !conversation.silent) - .reduce( - (sum, conversation) => sum + (conversation.unreadCount || 0), - 0, - ); + .reduce((sum, conversation) => sum + (conversation.unreadCount || 0), 0) }, /** 查找会话 */ @@ -342,190 +297,145 @@ export const useConversationStore = defineStore('imConversationStore', { (state) => (type: number, targetId: number): Conversation | undefined => state.conversations.find( - (conversation) => - conversation.type === type && conversation.targetId === targetId, + (conversation) => conversation.type === type && conversation.targetId === targetId ), /** 查找会话读位置 */ getConversationRead: (state) => (type: number, targetId: number): ConversationRead | undefined => - state.conversationReads[getClientConversationId(type, targetId)], + state.conversationReads[getClientConversationId(type, targetId)] }, actions: { /** 加载会话 */ async loadConversationList() { // 1. 清理旧账号内存 - const userId = getCurrentUserId(); - if (!userId) { - this.clear(); - return; - } const previousActiveKey = this.activeConversation - ? getClientConversationId( - this.activeConversation.type, - this.activeConversation.targetId, - ) - : null; - this.clear(); - // 2. 从 IndexedDB 读取会话和轻量设置 - const db = getDb(); - const [conversations, conversationReads, recent] = await Promise.all([ - db.getAll('conversations'), - db.getAll('conversationReads'), - db.getSetting( - StorageKeys.settings.recentForwardConversationKeys, - ), - ]); - const nextConversationReads: Record = {}; - for (const record of conversationReads) { - const item = fromConversationReadDO(record); - nextConversationReads[ - getClientConversationId(item.conversationType, item.targetId) - ] = item; - } - const nextConversations = conversations.map((conversation) => - fromConversationDO(conversation), - ); - this.conversationReads = nextConversationReads; - await this.applyLocalConversationReads(nextConversations); - this.conversations = nextConversations; - if (Array.isArray(recent)) { - this.recentForwardConversationKeys = recent.slice( - 0, - CONVERSATION_RECENT_FORWARD_MAX, - ); - } - // 3. 恢复当前激活会话 - if (previousActiveKey) { - this.activeConversation = - this.conversations.find( - (conversation) => - !conversation.deleted && - getClientConversationId( - conversation.type, - conversation.targetId, - ) === previousActiveKey, - ) ?? null; - } + ? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId) + : null + await enqueueConversationBarrier(async () => { + const loading = this.loading + this.clear() + this.loading = loading + // 2. 从 IndexedDB 读取会话和轻量设置 + const db = getDb() + const [conversations, conversationReads, recent] = await Promise.all([ + db.getAll('conversations'), + db.getAll('conversationReads'), + db.getSetting(StorageKeys.settings.recentForwardConversationKeys) + ]) + const nextConversationReads: Record = {} + for (const record of conversationReads) { + const item = fromConversationReadDO(record) + nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = + item + } + const nextConversations = conversations.map(fromConversationDO) + this.conversationReads = nextConversationReads + await this.applyLocalConversationReads(nextConversations, db) + this.conversations = nextConversations + if (Array.isArray(recent)) { + this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX) + } + // 3. 恢复当前激活会话 + if (previousActiveKey) { + this.activeConversation = + this.conversations.find( + (conversation) => + !conversation.deleted && + getClientConversationId(conversation.type, conversation.targetId) === + previousActiveKey + ) ?? null + } + }) }, /** 清空会话内存 */ clear() { - saveDraftConversationListDebounced.cancel(); - pendingDraftConversations.clear(); - this.conversations = []; - this.conversationReads = {}; - this.activeConversation = null; - this.activeMentionMessageId = undefined; - this.recentForwardConversationKeys = []; + saveDraftConversationListDebounced.cancel() + pendingDraftConversations.clear() + this.conversations = [] + this.conversationReads = {} + this.activeConversation = null + this.activeMentionMessageId = undefined + this.recentForwardConversationKeys = [] + this.loading = false }, /** 持久化会话读位置 */ async saveConversationReadRecord( target: ConversationRead | ConversationRead[] | null | undefined, tx?: DbTransaction, + db: DbClient = getDb() ): Promise { - let targets: ConversationRead[] = []; - if (Array.isArray(target)) { - targets = target; - } else if (target) { - targets = [target]; - } - const records = targets.map((record) => toConversationReadDO(record)); + const records = (Array.isArray(target) ? target : target ? [target] : []).map( + toConversationReadDO + ) if (records.length === 0) { - return; + return } - const db = getDb(); if (tx) { for (const record of records) { - await db.put('conversationReads', record, tx); + await db.put('conversationReads', record, tx) } - return; + return } await db.transaction(['conversationReads'], 'readwrite', async (tx) => { for (const record of records) { - await db.put('conversationReads', record, tx); + await db.put('conversationReads', record, tx) } - }); + }) }, /** 应用本地会话读位置 */ - async applyLocalConversationReads(conversations?: Conversation[]) { - const targetConversations = conversations || this.conversations; - const changedConversations: Conversation[] = []; + async applyLocalConversationReads(conversations?: Conversation[], db: DbClient = getDb()) { + const targetConversations = conversations || this.conversations + const changedConversations: Conversation[] = [] for (const conversation of targetConversations) { - const record = this.getConversationRead( - conversation.type, - conversation.targetId, - ); + const record = this.getConversationRead(conversation.type, conversation.targetId) const needsMentionBackfill = (conversation.atMe && !conversation.atMessageId) || - (conversation.atAll && !conversation.atAllMessageId); + (conversation.atAll && !conversation.atAllMessageId) if (!record && !needsMentionBackfill) { - continue; + continue } - const messages = await getDb().getAllByIndex( + const messages = await db.getAllByIndex( 'messages', 'clientConversationId', - getClientConversationId(conversation.type, conversation.targetId), - ); + getClientConversationId(conversation.type, conversation.targetId) + ) const changed = record - ? this.applyReadToConversation( - conversation, - record.messageId, - messages, - ) - : backfillConversationMentionIds(conversation, messages); + ? this.applyReadToConversation(conversation, record.messageId, messages, db.userId) + : backfillConversationMentionIds(conversation, messages, db.userId) if (changed) { - changedConversations.push(conversation); + changedConversations.push(conversation) } } if (changedConversations.length > 0) { - await this.saveConversationRecord(changedConversations); + await this.saveConversationRecord(changedConversations, undefined, db) } }, /** 判断消息是否已被会话读位置覆盖 */ isMessageCoveredByReadPosition( conversation: Pick, - message?: null | { id?: number }, + message?: null | { id?: number } ): boolean { if (!message?.id) { - return false; + return false } - const record = this.getConversationRead( - conversation.type, - conversation.targetId, - ); - return !!record && message.id <= record.messageId; - }, - - /** 判断会话读位置是否覆盖消息编号 */ - isReadPositionCovered( - type: number, - targetId: number, - messageId?: number, - ): boolean { - if (!messageId) { - return false; - } - const record = this.getConversationRead(type, targetId); - return !!record && record.messageId >= messageId; + const record = this.getConversationRead(conversation.type, conversation.targetId) + return !!record && message.id <= record.messageId }, /** 判断服务端已读位置是否覆盖消息编号 */ - isReportedReadPositionCovered( - type: number, - targetId: number, - messageId?: number, - ): boolean { + isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean { if (!messageId) { - return false; + return false } - const conversation = this.getConversation(type, targetId); - return (conversation?.reportedReadMessageId || 0) >= messageId; + const conversation = this.getConversation(type, targetId) + return (conversation?.reportedReadMessageId || 0) >= messageId }, /** 应用读位置到会话 */ @@ -533,8 +443,9 @@ export const useConversationStore = defineStore('imConversationStore', { conversation: Conversation, messageId: number, messages: MessageDO[], + userId: number ): boolean { - return applyConversationUnreadState(conversation, messages, messageId); + return applyConversationUnreadState(conversation, messages, messageId, userId) }, /** 应用撤回后的会话未读与 @ 状态 */ @@ -542,79 +453,80 @@ export const useConversationStore = defineStore('imConversationStore', { conversation: Conversation, messages: MessageDO[], originalMessage: MessageDO, + userId: number ): boolean { - const read = this.getConversationRead( - conversation.type, - conversation.targetId, - ); + const read = this.getConversationRead(conversation.type, conversation.targetId) return read - ? applyConversationUnreadState(conversation, messages, read.messageId) - : applyConversationRecallStateWithoutRead( - conversation, - messages, - originalMessage, - ); + ? applyConversationUnreadState(conversation, messages, read.messageId, userId) + : applyConversationRecallStateWithoutRead(conversation, messages, originalMessage, userId) }, /** 应用会话读位置 */ async applyConversationReadList( - records: ImConversationReadApi.ConversationReadRespVO[], - isActive?: () => boolean, + records: ImConversationReadRespVO[], + db: DbClient = getDb() + ): Promise { + const conversationIds = records + .filter(isValidConversationReadRecord) + .map((record) => getClientConversationId(record.conversationType, record.targetId)) + await enqueueConversationWrites(conversationIds, () => + this.applyConversationReadListNow(records, db) + ) + }, + + /** 实际应用会话读位置;调用方必须持有涉及会话的写 lane */ + async applyConversationReadListNow( + records: ImConversationReadRespVO[], + db: DbClient ): Promise { if (records.length === 0) { - return; + return } - const changedReads = new Map(); - const changedConversations = new Map(); - const changedMessages = new Map(); - const db = getDb(); - const messageStore = useMessageStore(); + const changedReads = new Map() + const changedConversations = new Map() + const changedMessages = new Map() + const changedMemoryMessages = new Map() + const messageStore = useMessageStore() // 1. 按读位置更新会话未读和频道已读态 for (const record of records) { - if (isActive && !isActive()) { - return; - } if (!isValidConversationReadRecord(record)) { - continue; + continue } const clientConversationId = getClientConversationId( record.conversationType, - record.targetId, - ); - let storedMessages: MessageDO[] | undefined; + record.targetId + ) + let storedMessages: MessageDO[] | undefined const getStoredMessages = async () => { if (!storedMessages) { storedMessages = await db.getAllByIndex( 'messages', 'clientConversationId', - clientConversationId, - ); + clientConversationId + ) } - return storedMessages; - }; - const current = this.conversationReads[clientConversationId]; - const messageId = Math.max(record.messageId, current?.messageId || 0); - const conversation = this.getConversation( - record.conversationType, - record.targetId, - ); - if ( - conversation && - record.messageId > (conversation.reportedReadMessageId || 0) - ) { - conversation.reportedReadMessageId = record.messageId; - changedConversations.set(clientConversationId, conversation); + return storedMessages + } + const current = + changedReads.get(clientConversationId) || this.conversationReads[clientConversationId] + const messageId = Math.max(record.messageId, current?.messageId || 0) + const currentConversation = this.getConversation(record.conversationType, record.targetId) + const conversation = + changedConversations.get(clientConversationId) || + (currentConversation ? { ...currentConversation } : undefined) + if (conversation && record.messageId > (conversation.reportedReadMessageId || 0)) { + conversation.reportedReadMessageId = record.messageId + changedConversations.set(clientConversationId, conversation) } if (!current || messageId > current.messageId) { const next = { conversationType: record.conversationType, targetId: record.targetId, messageId, - updateTime: record.updateTime, - }; - this.conversationReads[clientConversationId] = next; - changedReads.set(clientConversationId, next); + updateTime: record.updateTime + } + changedReads.set(clientConversationId, next) } if ( @@ -623,21 +535,25 @@ export const useConversationStore = defineStore('imConversationStore', { conversation, messageId, await getStoredMessages(), + db.userId ) ) { - changedConversations.set(clientConversationId, conversation); + changedConversations.set(clientConversationId, conversation) } if (record.conversationType !== ImConversationType.CHANNEL) { - continue; + continue } - const memoryMessages = messageStore.getMessages(clientConversationId); + const memoryMessages = messageStore.getMessages(clientConversationId) for (const message of memoryMessages) { if ( message.id && message.id <= messageId && message.receiptStatus !== ImMessageReceiptStatus.DONE ) { - message.receiptStatus = ImMessageReceiptStatus.DONE; + changedMemoryMessages.set(message, { + ...message, + receiptStatus: ImMessageReceiptStatus.DONE + }) } } for (const message of await getStoredMessages()) { @@ -646,8 +562,8 @@ export const useConversationStore = defineStore('imConversationStore', { message.id <= messageId && message.receiptStatus !== ImMessageReceiptStatus.DONE ) { - message.receiptStatus = ImMessageReceiptStatus.DONE; - changedMessages.set(message.messageKey, message); + message.receiptStatus = ImMessageReceiptStatus.DONE + changedMessages.set(message.messageKey, message) } } } @@ -656,155 +572,199 @@ export const useConversationStore = defineStore('imConversationStore', { if ( changedReads.size === 0 && changedConversations.size === 0 && - changedMessages.size === 0 + changedMessages.size === 0 && + changedMemoryMessages.size === 0 ) { - return; + return } - if (isActive && !isActive()) { - return; - } - const stores: Array<'conversationReads' | 'conversations' | 'messages'> = - []; + const stores: Array<'conversationReads' | 'conversations' | 'messages'> = [] if (changedReads.size > 0) { - stores.push('conversationReads'); + stores.push('conversationReads') } if (changedConversations.size > 0) { - stores.push('conversations'); + stores.push('conversations') } if (changedMessages.size > 0) { - stores.push('messages'); + stores.push('messages') } - await db.transaction(stores, 'readwrite', async (tx) => { - if (changedReads.size > 0) { - await this.saveConversationReadRecord([...changedReads.values()], tx); - } - if (changedConversations.size > 0) { - await this.saveConversationRecord( - [...changedConversations.values()], - tx, - ); - } - for (const message of changedMessages.values()) { - await db.put('messages', message, tx); - } - }); + if (stores.length > 0) { + await db.transaction(stores, 'readwrite', async (tx) => { + if (changedReads.size > 0) { + await this.saveConversationReadRecord([...changedReads.values()], tx, db) + } + if (changedConversations.size > 0) { + await this.saveConversationRecord([...changedConversations.values()], tx, db) + } + for (const message of changedMessages.values()) { + await db.put('messages', message, tx) + } + }) + } + changedReads.forEach((read, key) => { + this.conversationReads[key] = read + }) + changedConversations.forEach((conversation) => { + this.publishConversationProjection(conversation, true) + }) + changedMemoryMessages.forEach((next, current) => Object.assign(current, next)) }, /** 增量拉取会话读位置 */ - async pullConversationReads(isActive?: () => boolean): Promise { + async pullConversationReads(): Promise { + const db = await initDb() await runIncrementalPull( + db, StorageKeys.settings.conversationReadPullCursor, apiPullMyConversationReadList, async (records) => { - if (isActive && !isActive()) { - return false; - } - await this.applyConversationReadList(records, isActive); - if (isActive && !isActive()) { - return false; - } - return true; - }, - isActive, - ); + await this.applyConversationReadList(records, db) + return true + } + ) }, /** 执行会话记录持久化 */ async saveConversationRecord( target: Conversation | Conversation[] | null | undefined, tx?: DbTransaction, + db: DbClient = getDb() ): Promise { - const db = getDb(); - const conversations = - // oxlint-disable-next-line unicorn/no-nested-ternary - (Array.isArray(target) ? target : target ? [target] : []).map( - (conversation) => toConversationDO(conversation), - ); + const conversations = (Array.isArray(target) ? target : target ? [target] : []).map( + toConversationDO + ) if (conversations.length === 0) { - return; + return } if (tx) { for (const conversation of conversations) { - await db.put('conversations', conversation, tx); + await db.put('conversations', conversation, tx) } - return; + return } await db.transaction(['conversations'], 'readwrite', async (tx) => { for (const conversation of conversations) { - await db.put('conversations', conversation, tx); + await db.put('conversations', conversation, tx) } - }); + }) }, /** 持久化单个会话 */ saveConversation( conversation: Conversation | null | undefined, tx?: DbTransaction, + db: DbClient = getDb() ): void { if (!conversation) { - return; + return } - void this.saveConversationRecord(conversation, tx).catch((error) => - console.warn('[IM conversationStore] 会话写入失败', error), - ); + if (tx) { + void this.saveConversationRecord(conversation, tx, db).catch((e) => + console.warn('[IM conversationStore] 会话写入失败', e) + ) + return + } + void enqueueConversationWrite( + getClientConversationId(conversation.type, conversation.targetId), + () => this.saveConversationRecord(conversation, undefined, db) + ).catch((e) => console.warn('[IM conversationStore] 会话写入失败', e)) }, /** 持久化会话列表 */ saveConversationList( conversations?: Conversation[] | null, tx?: DbTransaction, - ): void { - if (this.loading && !tx) { - return; + db: DbClient = getDb() + ): Promise { + const targets = conversations || this.conversations + if (tx) { + return this.saveConversationRecord(targets, tx, db).catch((e) => + console.warn('[IM conversationStore] 会话列表写入失败', e) + ) } - void this.saveConversationRecord( - conversations || this.conversations, - tx, - ).catch((error) => - console.warn('[IM conversationStore] 会话写入失败', error), - ); + return enqueueConversationWrites( + targets.map((item) => getClientConversationId(item.type, item.targetId)), + () => this.saveConversationRecord(targets, undefined, db) + ) + .then(() => undefined) + .catch((e) => { + console.warn('[IM conversationStore] 会话写入失败', e) + }) + }, + + /** 构建会话的下一份投影,不提前修改响应式状态 */ + buildConversationProjection(info: { + avatar: string + name: string + silent?: boolean + targetId: number + type: number + }): Conversation { + const clientConversationId = getClientConversationId(info.type, info.targetId) + const relationTerminated = + info.type === ImConversationType.GROUP && isRelationTerminated(clientConversationId) + const current = this.getConversation(info.type, info.targetId) + const conversation = current + ? { ...current } + : this.createEmptyConversation( + info.type, + info.targetId, + info.name, + info.avatar, + info.silent + ) + if (conversation.deleted && !relationTerminated) { + conversation.deleted = false + } + if (info.name) { + conversation.name = info.name + } + if (info.avatar) { + conversation.avatar = info.avatar + } + if (info.silent !== undefined) { + conversation.silent = info.silent + } + if (relationTerminated) { + conversation.deleted = true + } + if (current) { + conversationProjectionBases.set(conversation, { ...current }) + } + return conversation + }, + + /** 发布已成功持久化的会话投影 */ + publishConversationProjection( + projection: Conversation, + preserveConcurrentFields = false + ): Conversation { + const current = this.getConversation(projection.type, projection.targetId) + if (current) { + const concurrentFields: Partial = {} + const base = conversationProjectionBases.get(projection) + if (preserveConcurrentFields) { + if (!base || current.name !== base.name) concurrentFields.name = current.name + if (!base || current.avatar !== base.avatar) concurrentFields.avatar = current.avatar + if (!base || current.top !== base.top) concurrentFields.top = current.top + if (!base || current.silent !== base.silent) concurrentFields.silent = current.silent + if (!base || current.draft !== base.draft) concurrentFields.draft = current.draft + } + Object.assign(current, projection, concurrentFields) + return current + } + this.conversations.unshift(projection) + return projection }, /** 确保会话存在 */ ensureConversation(info: { - avatar: string; - name: string; - silent?: boolean; - targetId: number; - type: number; + avatar: string + name: string + silent?: boolean + targetId: number + type: number }): Conversation { - // 1. 创建不存在的会话 - let conversation = this.getConversation(info.type, info.targetId); - if (!conversation) { - conversation = this.createEmptyConversation( - info.type, - info.targetId, - info.name, - info.avatar, - info.silent, - ); - this.conversations.unshift(conversation); - } else if (conversation.deleted) { - // 2. 恢复软删除会话 - conversation.deleted = false; - conversation.name = info.name || conversation.name; - conversation.avatar = info.avatar || conversation.avatar; - if (info.silent !== undefined) { - conversation.silent = info.silent; - } - } else { - // 3. 同步会话展示元数据 - if (info.name) { - conversation.name = info.name; - } - if (info.avatar) { - conversation.avatar = info.avatar; - } - if (info.silent !== undefined) { - conversation.silent = info.silent; - } - } - return conversation; + return this.publishConversationProjection(this.buildConversationProjection(info)) }, /** 打开或创建会话 */ @@ -813,7 +773,7 @@ export const useConversationStore = defineStore('imConversationStore', { type: number, name: string, avatar: string, - options?: { silent?: boolean }, + options?: { silent?: boolean } ): Conversation { // 1. 确保会话在列表中 const conversation = this.ensureConversation({ @@ -821,32 +781,33 @@ export const useConversationStore = defineStore('imConversationStore', { targetId, name, avatar, - silent: options?.silent, - }); + silent: options?.silent + }) // 2. 激活会话并保存 - this.setActiveConversation(conversation); - this.saveConversation(conversation); - return conversation; + this.setActiveConversation(conversation) + this.saveConversation(conversation) + return conversation }, /** 设置当前会话 */ setActiveConversation(conversation: Conversation | null) { - this.activeMentionMessageId = - conversation?.atMessageId || conversation?.atAllMessageId; - this.activeConversation = conversation; + this.activeMentionMessageId = conversation?.atMessageId || conversation?.atAllMessageId + this.activeConversation = conversation if (!conversation) { - return; + return } // 懒加载消息并保存会话摘要 - void useMessageStore().ensureConversationMessageListLoaded(conversation); - this.saveConversation(conversation); + void useMessageStore() + .ensureConversationMessageListLoaded(conversation) + .catch((error) => console.warn('[IM conversationStore] 会话消息加载失败', error)) + this.saveConversation(conversation) }, /** 消费当前会话待定位的未读 @ 消息编号 */ consumeActiveMentionMessageId(): number | undefined { - const messageId = this.activeMentionMessageId; - this.activeMentionMessageId = undefined; - return messageId; + const messageId = this.activeMentionMessageId + this.activeMentionMessageId = undefined + return messageId }, /** 创建空会话 */ @@ -855,7 +816,7 @@ export const useConversationStore = defineStore('imConversationStore', { targetId: number, name: string, avatar: string, - silent = false, + silent = false ): Conversation { return { targetId, @@ -869,56 +830,98 @@ export const useConversationStore = defineStore('imConversationStore', { top: false, silent, atMe: false, - atAll: false, - }; + atAll: false + } }, /** 设置置顶 */ setConversationTop(type: number, targetId: number, top: boolean) { - const conversation = this.getConversation(type, targetId); + const conversation = this.getConversation(type, targetId) if (!conversation) { - return; + return } - conversation.top = top; - this.saveConversation(conversation); + conversation.top = top + this.saveConversation(conversation) }, /** 设置免打扰 */ setConversationSilent(type: number, targetId: number, silent: boolean) { - const conversation = this.getConversation(type, targetId); + const conversation = this.getConversation(type, targetId) if (!conversation) { - return; + return } - conversation.silent = silent; - this.saveConversation(conversation); + conversation.silent = silent + this.saveConversation(conversation) }, /** 删除会话 */ - removeConversation(type: number, targetId: number) { - // 1. 标记会话删除 - const conversation = this.getConversation(type, targetId); + async removeConversation(type: number, targetId: number) { + const db = getDb() + await enqueueConversationWrite(getClientConversationId(type, targetId), async () => { + await this.removeConversationNow(type, targetId, db) + }) + }, + + /** 实际删除会话;调用方必须持有当前会话写 lane */ + async removeConversationNow(type: number, targetId: number, db: DbClient) { + if (!this.getConversation(type, targetId)) { + return + } + // 1. 先持久化消息 clear watermark 并清理消息 + await useMessageStore().deleteConversationMessageListNow(type, targetId, db) + // 2. 保存删除终态,再发布响应式投影 + await this.hideConversationNow(type, targetId, db) + }, + + /** 保存隐藏会话投影但不发布 Store;用于和其它终态写入共用事务 */ + async saveHiddenConversationRecord( + type: number, + targetId: number, + tx?: DbTransaction, + db: DbClient = getDb() + ): Promise { + const clientConversationId = getClientConversationId(type, targetId) + const current = this.getConversation(type, targetId) + const stored = current + ? undefined + : await db.get('conversations', clientConversationId, tx) + const conversation = current || (stored ? fromConversationDO(stored) : undefined) if (!conversation) { - return; + return undefined } + const projection = { ...conversation, deleted: true, draft: undefined } + await this.saveConversationRecord(projection, tx, db) + return projection + }, + + /** 发布已成功持久化的隐藏会话投影 */ + publishHiddenConversationProjection(projection: Conversation): void { + const conversation = this.getConversation(projection.type, projection.targetId) + if (!conversation) { + return + } + pendingDraftConversations.delete(conversation) if (this.activeConversation === conversation) { - this.activeConversation = null; - this.activeMentionMessageId = undefined; + this.activeConversation = null + this.activeMentionMessageId = undefined + } + Object.assign(conversation, projection) + }, + + /** 隐藏会话但保留消息;用于退群、被踢和群解散终态 */ + async hideConversationNow(type: number, targetId: number, db: DbClient) { + const projection = await this.saveHiddenConversationRecord(type, targetId, undefined, db) + if (projection) { + this.publishHiddenConversationProjection(projection) } - conversation.deleted = true; - // 2. 删除会话关联的消息和草稿 - useMessageStore().deleteConversationMessageList(type, targetId); - this.clearConversationDraft(conversation); - this.saveConversation(conversation); }, /** 删除私聊会话 */ - removePrivateConversation(friendId: number) { - this.removeConversation(ImConversationType.PRIVATE, friendId); - }, - - /** 删除群聊会话 */ - removeGroupConversation(groupId: number) { - this.removeConversation(ImConversationType.GROUP, groupId); + removePrivateConversation(friendId: number, db: DbClient = getDb()) { + return enqueueConversationWrite( + getClientConversationId(ImConversationType.PRIVATE, friendId), + () => this.removeConversationNow(ImConversationType.PRIVATE, friendId, db) + ) }, /** 标记会话已读 */ @@ -926,55 +929,55 @@ export const useConversationStore = defineStore('imConversationStore', { type: number, targetId: number, messageId?: number, + db: DbClient = getDb() ): void { - const conversation = this.getConversation(type, targetId); + void enqueueConversationWrite(getClientConversationId(type, targetId), () => + this.markConversationReadNow(type, targetId, messageId, db) + ).catch((e) => console.warn('[IM conversationStore] 会话已读写入失败', e)) + }, + + /** 实际标记会话已读;调用方必须持有当前会话写 lane */ + async markConversationReadNow( + type: number, + targetId: number, + messageId: number | undefined, + db: DbClient + ) { + const conversation = this.getConversation(type, targetId) if (!conversation) { - return; + return } - const key = getClientConversationId(type, targetId); - const current = this.conversationReads[key]; - const readMessageIdAdvanced = - !!messageId && messageId > (current?.messageId || 0); + const key = getClientConversationId(type, targetId) + const current = this.conversationReads[key] + const readMessageIdAdvanced = !!messageId && messageId > (current?.messageId || 0) if ( conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll && !readMessageIdAdvanced ) { - return; + return + } + const nextConversation = { + ...conversation, + unreadCount: 0, + atMe: false, + atAll: false, + atMessageId: undefined, + atAllMessageId: undefined } - conversation.unreadCount = 0; - conversation.atMe = false; - conversation.atAll = false; - conversation.atMessageId = undefined; - conversation.atAllMessageId = undefined; if (readMessageIdAdvanced) { - const record = createConversationRead(type, targetId, messageId); - this.conversationReads[key] = record; - void getDb() - .transaction( - ['conversations', 'conversationReads'], - 'readwrite', - async (tx) => { - await this.saveConversationRecord(conversation, tx); - await this.saveConversationReadRecord(record, tx); - }, - ) - .catch((error) => - console.warn( - '[IM conversationStore] 会话已读写入失败', - { - conversationType: type, - targetId, - messageId, - conversationKey: key, - }, - error, - ), - ); - return; + const record = createConversationRead(type, targetId, messageId) + await db.transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => { + await this.saveConversationRecord(nextConversation, tx, db) + await this.saveConversationReadRecord(record, tx, db) + }) + this.publishConversationProjection(nextConversation, true) + this.conversationReads[key] = record + return } - this.saveConversation(conversation); + await this.saveConversationRecord(nextConversation, undefined, db) + this.publishConversationProjection(nextConversation, true) }, /** 标记会话已上报服务端读位置 */ @@ -982,19 +985,30 @@ export const useConversationStore = defineStore('imConversationStore', { type: number, targetId: number, messageId?: number, + db: DbClient = getDb() ): void { if (!messageId) { - return; + return } - const conversation = this.getConversation(type, targetId); - if ( - !conversation || - messageId <= (conversation.reportedReadMessageId || 0) - ) { - return; + void enqueueConversationWrite(getClientConversationId(type, targetId), () => + this.markConversationReadReportedNow(type, targetId, messageId, db) + ).catch((e) => console.warn('[IM conversationStore] 已上报读位置写入失败', e)) + }, + + /** 实际记录已上报读位置;调用方必须持有当前会话写 lane */ + async markConversationReadReportedNow( + type: number, + targetId: number, + messageId: number, + db: DbClient + ) { + const conversation = this.getConversation(type, targetId) + if (!conversation || messageId <= (conversation.reportedReadMessageId || 0)) { + return } - conversation.reportedReadMessageId = messageId; - this.saveConversation(conversation); + const nextConversation = { ...conversation, reportedReadMessageId: messageId } + await this.saveConversationRecord(nextConversation, undefined, db) + this.publishConversationProjection(nextConversation, true) }, // ==================== 最近转发 ==================== @@ -1002,24 +1016,24 @@ export const useConversationStore = defineStore('imConversationStore', { /** 推送最近转发会话 */ pushRecentForwardConversationKeyList(keys: string[]) { if (!keys || keys.length === 0) { - return; + return } - const merged = [...keys, ...this.recentForwardConversationKeys]; - this.recentForwardConversationKeys = [...new Set(merged)].slice( + const merged = [...keys, ...this.recentForwardConversationKeys] + this.recentForwardConversationKeys = Array.from(new Set(merged)).slice( 0, - CONVERSATION_RECENT_FORWARD_MAX, - ); - this.saveRecentForwardConversationKeyList(); + CONVERSATION_RECENT_FORWARD_MAX + ) + this.saveRecentForwardConversationKeyList() }, /** 移除最近转发会话 */ removeRecentForwardConversationKey(key: string) { - const index = this.recentForwardConversationKeys.indexOf(key); - if (index === -1) { - return; + const index = this.recentForwardConversationKeys.indexOf(key) + if (index < 0) { + return } - this.recentForwardConversationKeys.splice(index, 1); - this.saveRecentForwardConversationKeyList(); + this.recentForwardConversationKeys.splice(index, 1) + this.saveRecentForwardConversationKeyList() }, /** 保存最近转发会话 */ @@ -1027,24 +1041,19 @@ export const useConversationStore = defineStore('imConversationStore', { void getDb() .setSetting( StorageKeys.settings.recentForwardConversationKeys, - this.recentForwardConversationKeys.slice( - 0, - CONVERSATION_RECENT_FORWARD_MAX, - ), + this.recentForwardConversationKeys.slice(0, CONVERSATION_RECENT_FORWARD_MAX) ) - .catch((error) => - console.warn('[IM conversationStore] 最近转发列表写入失败', error), - ); + .catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e)) }, // ==================== 会话维护 ==================== /** 重排会话 */ sortConversationList() { - this.conversations.sort( - (a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0), - ); - this.saveConversationList(this.conversations); + this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0)) + if (!this.loading) { + void this.saveConversationList(this.conversations) + } }, /** 同步会话展示元数据 */ @@ -1052,26 +1061,27 @@ export const useConversationStore = defineStore('imConversationStore', { type: number, targetId: number, info: { avatar?: string; name?: string; silent?: boolean }, + db: DbClient = getDb() ) { - const conversation = this.getConversation(type, targetId); + const conversation = this.getConversation(type, targetId) if (!conversation) { - return; + return } - let changed = false; + let changed = false if (info.name && conversation.name !== info.name) { - conversation.name = info.name; - changed = true; + conversation.name = info.name + changed = true } if (info.avatar !== undefined && conversation.avatar !== info.avatar) { - conversation.avatar = info.avatar || ''; - changed = true; + conversation.avatar = info.avatar || '' + changed = true } if (info.silent !== undefined && conversation.silent !== info.silent) { - conversation.silent = info.silent; - changed = true; + conversation.silent = info.silent + changed = true } if (changed) { - this.saveConversation(conversation); + this.saveConversation(conversation, undefined, db) } }, @@ -1079,111 +1089,92 @@ export const useConversationStore = defineStore('imConversationStore', { /** 获取草稿 */ getConversationDraft(conversation: { - targetId: number; - type: number; + targetId: number + type: number }): Conversation['draft'] | undefined { - return this.getConversation(conversation.type, conversation.targetId) - ?.draft; + return this.getConversation(conversation.type, conversation.targetId)?.draft }, /** 设置草稿 */ setConversationDraft( - conversation: { targetId: number; type: number }, - snapshot: NonNullable, + conversation: { targetId: number; type: number; }, + snapshot: NonNullable ): void { if (!snapshot.plain.trim() && !snapshot.reply) { - this.clearConversationDraft(conversation); - return; + this.clearConversationDraft(conversation) + return } - const target = this.getConversation( - conversation.type, - conversation.targetId, - ); + const target = this.getConversation(conversation.type, conversation.targetId) if (!target) { - return; + return } - target.draft = snapshot; - this.scheduleConversationDraftSave(target); + target.draft = snapshot + this.scheduleConversationDraftSave(target) }, /** 清除草稿 */ - clearConversationDraft(conversation: { - targetId: number; - type: number; - }): void { - const target = this.getConversation( - conversation.type, - conversation.targetId, - ); + clearConversationDraft(conversation: { targetId: number; type: number; }): void { + const target = this.getConversation(conversation.type, conversation.targetId) if (!target?.draft) { - return; + return } - target.draft = undefined; - this.scheduleConversationDraftSave(target); + target.draft = undefined + this.scheduleConversationDraftSave(target) }, /** 设置回复草稿 */ setConversationReplyDraft( - conversation: { targetId: number; type: number }, - quote: NonNullable['reply'], + conversation: { targetId: number; type: number; }, + quote: NonNullable['reply'] ) { if (!quote) { - return; + return } - const existing = this.getConversationDraft(conversation); + const existing = this.getConversationDraft(conversation) this.setConversationDraft(conversation, { html: existing?.html ?? '', plain: existing?.plain ?? '', - reply: quote, - }); + reply: quote + }) }, /** 清除回复草稿 */ - clearConversationReplyDraft(conversation: { - targetId: number; - type: number; - }): void { - const existing = this.getConversationDraft(conversation); + clearConversationReplyDraft(conversation: { targetId: number; type: number; }): void { + const existing = this.getConversationDraft(conversation) if (!existing?.reply) { - return; + return } - this.setConversationDraft(conversation, { - ...existing, - reply: undefined, - }); + this.setConversationDraft(conversation, { ...existing, reply: undefined }) }, /** 调度草稿保存 */ scheduleConversationDraftSave(conversation: Conversation): void { - pendingDraftConversations.add(conversation); - saveDraftConversationListDebounced(); + pendingDraftConversations.set(conversation, getDb()) + saveDraftConversationListDebounced() }, /** 立即保存草稿 */ - flushConversationDraftSave(): void { - saveDraftConversationListDebounced.flush(); - }, - }, -}); - -export const useConversationStoreWithOut = () => useConversationStore(); + flushConversationDraftSave(): Promise { + return saveDraftConversationListDebounced.flush() ?? Promise.resolve() + } + } +}) /** 合并草稿写入 */ -const saveDraftConversationListDebounced = createDraftDebounce(() => { - const conversations = [...pendingDraftConversations]; - pendingDraftConversations.clear(); +const saveDraftConversationListDebounced = debounce(async (): Promise => { + const conversations = Array.from(pendingDraftConversations.entries()) + pendingDraftConversations.clear() if (conversations.length === 0) { - return; + return } - void useConversationStoreWithOut() - .saveConversationRecord(conversations) - .catch((error) => - console.warn('[IM conversationStore] 草稿写入失败', error), - ); -}, PERSIST_DRAFT_DEBOUNCE_MS); + const conversationStore = useConversationStore() + await Promise.all( + conversations.map(([conversation, db]) => + conversationStore.saveConversationList([conversation], undefined, db) + ) + ) +}, PERSIST_DRAFT_DEBOUNCE_MS) if (import.meta.hot) { - import.meta.hot.accept( - acceptHMRUpdate(useConversationStore, import.meta.hot), - ); + import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot)) } diff --git a/apps/web-antd/src/views/im/home/store/faceStore.ts b/apps/web-antd/src/views/im/home/store/faceStore.ts index b27040a56..fd8ffa149 100644 --- a/apps/web-antd/src/views/im/home/store/faceStore.ts +++ b/apps/web-antd/src/views/im/home/store/faceStore.ts @@ -1,16 +1,26 @@ -import type { ImFacePackApi } from '#/api/im/face/pack'; -import type { ImFaceUserItemApi } from '#/api/im/face/userItem'; +import type { ImFacePackApi } from '#/api/im/face/pack' +import type { ImFaceUserItemApi } from '#/api/im/face/userItem' -import { ref } from 'vue'; +import { ref } from 'vue' -import { acceptHMRUpdate, defineStore } from 'pinia'; +import { acceptHMRUpdate, defineStore } from 'pinia' -import { getFacePackList as apiGetFacePackList } from '#/api/im/face/pack'; +import { getFacePackList as apiGetFacePackList } from '#/api/im/face/pack' import { createFaceUserItem as apiCreateFaceUserItem, deleteFaceUserItem as apiDeleteFaceUserItem, - getFaceUserItemList as apiGetFaceUserItemList, -} from '#/api/im/face/userItem'; + getFaceUserItemList as apiGetFaceUserItemList +} from '#/api/im/face/userItem' + +import { + ResourceRequestKey, + ResourceRequestMode, + runResourceRequest +} from '../../utils/resourceRequest' + +type ImFacePackUserVO = ImFacePackApi.FacePackUser +type ImFaceUserItemVO = ImFaceUserItemApi.FaceUserItem +type ImFaceUserItemSaveReqVO = ImFaceUserItemApi.FaceUserItemSaveReqVO /** * IM 表情面板数据 store(系统表情包 + 个人表情) @@ -21,64 +31,32 @@ import { */ export const useFaceStore = defineStore('imFace', () => { /** 系统表情包列表(含每个包的 items);运营管理后台维护 */ - const facePacks = ref([]); + const facePacks = ref([]) /** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */ - const faceUserItems = ref([]); + const faceUserItems = ref([]) - /** clear() 时递增;旧账号请求返回后不写入新账号内存 */ - let storeEpoch = 0; - - /** - * 系统表情包拉取 promise;ensureFacePackList 内 cache: - * - null = 还没拉过,下次调用真发请求 - * - resolve 后保留对象 = 后续调用 await 立即返回,不再发请求 - * - reject 后置回 null,让调用方下次重试 - */ - let facePacksPromise: null | Promise = null; /** 按需拉取系统表情包(已拉过则直接复用 cached promise) */ async function ensureFacePackList(): Promise { - if (!facePacksPromise) { - const requestEpoch = storeEpoch; - facePacksPromise = apiGetFacePackList() - .then((data) => { - if (requestEpoch !== storeEpoch) { - return; - } - facePacks.value = data || []; - }) - .catch((error) => { - console.warn('[IM] 拉取表情包失败', error); - if (requestEpoch === storeEpoch) { - facePacksPromise = null; - } - throw error; - }); - } - return facePacksPromise; + await runResourceRequest( + ResourceRequestKey.FACE_PACKS, + async () => { + const data = await apiGetFacePackList() + facePacks.value = data || [] + }, + { mode: ResourceRequestMode.CACHE_SUCCESS } + ) } - /** 个人表情拉取 promise;语义同上 */ - let faceUserItemsPromise: null | Promise = null; /** 按需拉取个人表情(已拉过则直接复用 cached promise) */ async function ensureFaceUserItemList(): Promise { - if (!faceUserItemsPromise) { - const requestEpoch = storeEpoch; - faceUserItemsPromise = apiGetFaceUserItemList() - .then((data) => { - if (requestEpoch !== storeEpoch) { - return; - } - faceUserItems.value = data || []; - }) - .catch((error) => { - console.warn('[IM] 拉取个人表情失败', error); - if (requestEpoch === storeEpoch) { - faceUserItemsPromise = null; - } - throw error; - }); - } - return faceUserItemsPromise; + await runResourceRequest( + ResourceRequestKey.FACE_USER_ITEMS, + async () => { + const data = await apiGetFaceUserItemList() + faceUserItems.value = data || [] + }, + { mode: ResourceRequestMode.CACHE_SUCCESS } + ) } /** @@ -86,57 +64,45 @@ export const useFaceStore = defineStore('imFace', () => { * * 来源:1. 用户在表情面板「+」上传图片 2. 长按消息「添加到表情」 */ - async function addFaceUserItem( - reqVO: ImFaceUserItemApi.FaceUserItemSaveReqVO, - ): Promise { - const requestEpoch = storeEpoch; - const id = await apiCreateFaceUserItem(reqVO); + async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise { + await ensureFaceUserItemList().catch((error) => { + console.warn('[IM] 个人表情列表初始化失败,继续添加', error) + }) + const id = await apiCreateFaceUserItem(reqVO) if (!id) { - return false; + return false } - // 已切账号时跳过旧请求结果 - if (requestEpoch !== storeEpoch) { - return false; - } - // id 不在缓存里才插入;服务端唯一约束兜底了 race,本地理论上不会拿到重复 id if (!faceUserItems.value.some((item) => item.id === id)) { faceUserItems.value.unshift({ id, url: reqVO.url, name: reqVO.name, width: reqVO.width, - height: reqVO.height, - }); + height: reqVO.height + }) } - return true; + return true } /** 删除个人表情;本地立即移除 */ async function removeFaceUserItem(id: number): Promise { - const requestEpoch = storeEpoch; + await ensureFaceUserItemList().catch((error) => { + console.warn('[IM] 个人表情列表初始化失败,继续删除', error) + }) try { - await apiDeleteFaceUserItem(id); - // 已切账号时跳过旧请求结果 - if (requestEpoch !== storeEpoch) { - return false; - } - faceUserItems.value = faceUserItems.value.filter( - (item) => item.id !== id, - ); - return true; - } catch (error) { - console.warn('[IM] 删除个人表情失败', { id }, error); - return false; + await apiDeleteFaceUserItem(id) + faceUserItems.value = faceUserItems.value.filter((item) => item.id !== id) + return true + } catch (e) { + console.warn('[IM] 删除个人表情失败', { id }, e) + return false } } /** 清空表情缓存 */ function clear(): void { - facePacks.value = []; - faceUserItems.value = []; - facePacksPromise = null; - faceUserItemsPromise = null; - storeEpoch++; + facePacks.value = [] + faceUserItems.value = [] } return { @@ -146,13 +112,10 @@ export const useFaceStore = defineStore('imFace', () => { ensureFaceUserItemList, addFaceUserItem, removeFaceUserItem, - clear, - }; -}); - -/** 在 setup 外(路由守卫等)取 store 实例的工具方法 */ -export const useFaceStoreWithOut = () => useFaceStore(); + clear + } +}) if (import.meta.hot) { - import.meta.hot.accept(acceptHMRUpdate(useFaceStore, import.meta.hot)); + import.meta.hot.accept(acceptHMRUpdate(useFaceStore, import.meta.hot)) } diff --git a/apps/web-antd/src/views/im/home/store/friendStore.ts b/apps/web-antd/src/views/im/home/store/friendStore.ts index dd02b2158..e8fd1e4d4 100644 --- a/apps/web-antd/src/views/im/home/store/friendStore.ts +++ b/apps/web-antd/src/views/im/home/store/friendStore.ts @@ -1,11 +1,11 @@ -import type { Friend, FriendLite, FriendRequest } from '../types'; +import type { Friend, FriendDO, FriendLite, FriendRequest, FriendRequestDO } from '../types' -import type { ImFriendApi } from '#/api/im/friend'; -import type { ImFriendRequestApi } from '#/api/im/friend/request'; +import type { ImFriendApi } from '#/api/im/friend' +import type { ImFriendRequestApi } from '#/api/im/friend/request' -import { CommonStatusEnum } from '@vben/constants'; +import { CommonStatusEnum } from '@vben/constants' -import { acceptHMRUpdate, defineStore } from 'pinia'; +import { acceptHMRUpdate, defineStore } from 'pinia' import { blockFriend as apiBlockFriend, @@ -14,65 +14,65 @@ import { getMyFriendList as apiGetMyFriendList, pullMyFriendList as apiPullMyFriendList, unblockFriend as apiUnblockFriend, - updateFriend as apiUpdateFriend, -} from '#/api/im/friend'; + updateFriend as apiUpdateFriend +} from '#/api/im/friend' import { agreeFriendRequest as apiAgreeFriendRequest, applyFriendRequest as apiApplyFriendRequest, getMyFriendRequest as apiGetMyFriendRequest, getMyFriendRequestList as apiGetMyFriendRequestList, pullMyFriendRequestList as apiPullMyFriendRequestList, - refuseFriendRequest as apiRefuseFriendRequest, -} from '#/api/im/friend/request'; -import { getCurrentUserId } from '#/views/im/utils/auth'; + refuseFriendRequest as apiRefuseFriendRequest +} from '#/api/im/friend/request' +import { getCurrentUserId } from '#/views/im/utils/auth' -import { FRIEND_REQUEST_PAGE_SIZE } from '../../utils/config'; +import { FRIEND_REQUEST_PAGE_SIZE } from '../../utils/config' +import { ImConversationType, ImFriendRequestHandleResult } from '../../utils/constants' +import { type DbClient, getDb, initDb, StorageKeys } from '../../utils/db' +import { runIncrementalPull } from '../../utils/pull' import { - ImConversationType, - ImFriendRequestHandleResult, -} from '../../utils/constants'; -import { getDb, StorageKeys } from '../../utils/db'; -import { runIncrementalPull } from '../../utils/pull'; -import { getFriendDisplayName } from '../../utils/user'; -import { useConversationStore } from './conversationStore'; + isResourceRequestPending, + ResourceRequestKey, + ResourceRequestMode, + runResourceRequest +} from '../../utils/resourceRequest' +import { getFriendDisplayName } from '../../utils/user' +import { useConversationStore } from './conversationStore' -type PendingRequest = { epoch: number; promise: Promise; userId: number }; +type ImFriendRespVO = ImFriendApi.FriendRespVO +type ImFriendRequestApplyReqVO = ImFriendRequestApi.FriendRequestApplyReqVO +type ImFriendRequestRespVO = ImFriendRequestApi.FriendRequestRespVO -/** 当前正在进行的好友列表拉取;多 dispatcher 同时触发时复用同一 Promise,避免雪崩重拉 */ -let pendingFetchFriends: null | PendingRequest = null; -/** 当前正在进行的好友申请列表拉取;多端连续多条申请到达时复用同一 Promise,避免雪崩重拉 */ -let pendingFetchRequests: null | PendingRequest = null; -/** 当前正在进行的「加载更多申请」请求 */ -let pendingLoadMoreRequests: null | PendingRequest = null; -/** 当前正在进行的好友详情请求 */ -const pendingFetchFriendInfos = new Map>(); - -/** clear() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store,防跨账号数据泄漏 */ -let storeEpoch = 0; - -/** 构建好友详情请求去重 key */ -function getPendingFriendInfoKey(userId: number, friendUserId: number): string { - return `${userId}:${friendUserId}`; +/** 在好友列表拉取期间合并一次关系事件尾随刷新 */ +function queueFriendListRefreshAfterPending(fetch: () => Promise): void { + if (isResourceRequestPending(ResourceRequestKey.FRIEND_LIST)) { + void fetch().catch(() => undefined) + } } +/** 当前好友申请分页任务;首页和加载更多互斥执行 */ +let requestTask: null | Promise = null +/** 当前正在进行的好友详情请求 */ +const pendingFetchFriendInfos = new Map>() + /** 好友通知 payload(对齐后端 BaseFriendNotification + 子类裁减后的字段) */ export interface FriendNotificationPayload { - operatorUserId: number; - friendUserId: number; + operatorUserId: number + friendUserId: number // FRIEND_REQUEST_* 系列:申请记录的核心字段(避免 payload 携带完整 DO) - requestId?: number; - applyContent?: string; - handleContent?: string; - addSource?: number; + requestId?: number + applyContent?: string + handleContent?: string + addSource?: number // FRIEND_REQUEST_RECEIVED:申请方聚合字段,供前端直推 push 进列表,无需回拉 - fromNickname?: string; - fromAvatar?: string; + fromNickname?: string + fromAvatar?: string // FRIEND_UPDATE:单边属性变更 - displayName?: string; - silent?: boolean; - pinned?: boolean; + displayName?: string + silent?: boolean + pinned?: boolean // FRIEND_DELETE:是否级联清理本端相关数据(如私聊会话) - clear?: boolean; + clear?: boolean } /** @@ -91,7 +91,7 @@ export const useFriendStore = defineStore('imFriendStore', { /** 我相关的好友申请列表(含我发起的 + 别人加我的;后端按 id 倒序游标分页) */ friendRequests: [] as FriendRequest[], /** 是否还有更早的申请记录可加载;返回不满 page size 即置 false */ - hasMoreFriendRequests: true, + hasMoreFriendRequests: true }), getters: { @@ -102,21 +102,19 @@ export const useFriendStore = defineStore('imFriendStore', { * 直接 find 时 N 条消息 × M 好友 = O(N×M);建索引后单次读 O(1),重建只在写好友(fetchFriendList / upsertFriend 等)时发生 */ getFriendMap: (state): Map => { - const map = new Map(); + const map = new Map() for (const friend of state.friends) { - map.set(friend.friendUserId, friend); + map.set(friend.friendUserId, friend) } - return map; + return map }, /** 按 friendUserId 找好友(含已软删的 DISABLE 记录,调用方自行判定) */ getFriend(): (friendUserId: number) => Friend | undefined { - return (friendUserId: number) => this.getFriendMap.get(friendUserId); + return (friendUserId: number) => this.getFriendMap.get(friendUserId) }, /** 当前生效的好友列表(过滤掉 DISABLE 软删记录) */ getActiveFriendList: (state): Friend[] => { - return state.friends.filter( - (friend) => friend.status !== CommonStatusEnum.DISABLE, - ); + return state.friends.filter((friend) => friend.status !== CommonStatusEnum.DISABLE) }, /** 当前生效好友的 Lite 视图(PickerPanel / 选人弹窗共用,自带拼音字段供分桶 / 搜索) */ getActiveFriendLiteList(): FriendLite[] { @@ -126,32 +124,25 @@ export const useFriendStore = defineStore('imFriendStore', { nicknamePinyin: friend.nicknamePinyin, avatar: friend.avatar, displayName: friend.displayName, - displayNamePinyin: friend.displayNamePinyin, - })); + displayNamePinyin: friend.displayNamePinyin + })) }, /** 判断对方是否是当前用户的有效好友(存在 + 非 DISABLE) */ isActiveFriend() { return (friendUserId: number): boolean => { - const entry = this.getFriend(friendUserId); - return !!entry && entry.status !== CommonStatusEnum.DISABLE; - }; - }, - /** 我的黑名单(blocked=true 且 ENABLE) */ - getBlockedFriendList: (state): Friend[] => { - return state.friends.filter( - (friend) => - friend.status !== CommonStatusEnum.DISABLE && friend.blocked === true, - ); + const entry = this.getFriend(friendUserId) + return !!entry && entry.status !== CommonStatusEnum.DISABLE + } }, /** 未处理申请数(接收方=我)—— 实时派生,「新的朋友」红点用 */ getUnhandledRequestCount: (state): number => { - const currentUserId = getCurrentUserId(); + const currentUserId = getCurrentUserId() return state.friendRequests.filter( (request) => request.handleResult === ImFriendRequestHandleResult.UNHANDLED && - request.toUserId === currentUserId, - ).length; - }, + request.toUserId === currentUserId + ).length + } }, actions: { @@ -161,88 +152,81 @@ export const useFriendStore = defineStore('imFriendStore', { async loadFriendData(): Promise { try { const [friends, friendRequests] = await Promise.all([ - getDb().getAll('friends'), - getDb().getAll('friendRequests'), - ]); + getDb().getAll('friends'), + getDb().getAll('friendRequests') + ]) if (friends.length > 0) { - this.friends = friends; + this.friends = friends } if (friendRequests.length > 0) { - this.friendRequests = friendRequests.toSorted( - (requestA, requestB) => requestB.id - requestA.id, - ); - this.hasMoreFriendRequests = - friendRequests.length >= FRIEND_REQUEST_PAGE_SIZE; + this.friendRequests = friendRequests.sort( + (requestA, requestB) => requestB.id - requestA.id + ) + this.hasMoreFriendRequests = friendRequests.length >= FRIEND_REQUEST_PAGE_SIZE } - return friends.length > 0; - } catch (error) { - console.warn('[IM friendStore] 本地好友缓存读取失败', error); - return false; + return friends.length > 0 + } catch (e) { + console.warn('[IM friendStore] 本地好友缓存读取失败', e) + return false } }, /** 保存好友列表 */ - saveFriendList(): void { - void getDb() - .transaction(['friends'], 'readwrite', async (tx) => { - const db = getDb(); - await db.clearStore('friends', tx); - for (const friend of this.friends) { - if (friend.id) { - await db.put('friends', friend, tx); - } + async saveFriendList(friends: Friend[], db: DbClient = getDb()): Promise { + await db.transaction(['friends'], 'readwrite', async (tx) => { + await db.clearStore('friends', tx) + for (const friend of friends) { + if (friend.id) { + await db.put('friends', friend, tx) } - }) - .catch((error) => - console.warn('[IM friendStore] 本地好友缓存写入失败', error), - ); + } + }) }, /** 保存单个好友 */ - async saveFriendRecord(friend: Friend | undefined): Promise { + async saveFriendRecord(friend: Friend | undefined, db: DbClient = getDb()): Promise { if (!friend?.id) { - return; + return } - await getDb().put('friends', friend); + await db.put('friends', friend) }, /** 保存单个好友 */ - saveFriend(friend: Friend | undefined): void { - void this.saveFriendRecord(friend).catch((error) => - console.warn('[IM friendStore] 本地好友写入失败', error), - ); + saveFriend(friend: Friend | undefined, db: DbClient = getDb()): void { + void this.saveFriendRecord(friend, db).catch((e) => + console.warn('[IM friendStore] 本地好友写入失败', e) + ) }, /** 保存好友申请列表 */ - saveFriendRequestList(): void { - void getDb() + saveFriendRequestList(requests?: FriendRequest[], db: DbClient = getDb()): void { + const snapshot = requests ?? [...this.friendRequests] + void db .transaction(['friendRequests'], 'readwrite', async (tx) => { - const db = getDb(); - await db.clearStore('friendRequests', tx); - for (const request of this.friendRequests) { - await db.put('friendRequests', request, tx); + await db.clearStore('friendRequests', tx) + for (const request of snapshot) { + await db.put('friendRequests', request, tx) } }) - .catch((error) => - console.warn('[IM friendStore] 本地好友申请缓存写入失败', error), - ); + .catch((e) => console.warn('[IM friendStore] 本地好友申请缓存写入失败', e)) }, /** 保存单条好友申请 */ async saveFriendRequestRecord( request: FriendRequest | undefined, + db: DbClient = getDb() ): Promise { if (!request) { - return; + return } - await getDb().put('friendRequests', request); + await db.put('friendRequests', request) }, /** 保存单条好友申请 */ - saveFriendRequest(request: FriendRequest | undefined): void { - void this.saveFriendRequestRecord(request).catch((error) => - console.warn('[IM friendStore] 本地好友申请写入失败', error), - ); + saveFriendRequest(request: FriendRequest | undefined, db: DbClient = getDb()): void { + void this.saveFriendRequestRecord(request, db).catch((e) => + console.warn('[IM friendStore] 本地好友申请写入失败', e) + ) }, // ==================== 远端拉取 ==================== @@ -250,31 +234,19 @@ export const useFriendStore = defineStore('imFriendStore', { /** 从后端拉取并覆盖本地列表(含 DISABLE 历史好友给已删对话兜底);只同步 ENABLE 的会话信息,DISABLE 的不动 —— cascade 清会话由 WS dispatcher 按 payload.clear 处理,避免 fetchFriendList 覆盖用户「不清空聊天记录」的选择 */ async fetchFriendList(force = false) { if (this.loaded && !force) { - return; + return this.friends } - // 快照 epoch;clear() 之后到 .then 之间触发的 epoch++ 表示账号已切,旧结果不能写入新 store - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - if ( - pendingFetchFriends?.epoch === requestEpoch && - pendingFetchFriends.userId === requestUserId - ) { - return pendingFetchFriends.promise; - } - const promise = apiGetMyFriendList() - .then((list) => { - if ( - requestEpoch !== storeEpoch || - getCurrentUserId() !== requestUserId - ) { - return; - } - this.friends = (list || []).map((friend) => convertFriend(friend)); - this.loaded = true; - const conversationStore = useConversationStore(); + return runResourceRequest( + ResourceRequestKey.FRIEND_LIST, + async () => { + const db = await initDb() + const friends = ((await apiGetMyFriendList()) || []).map(convertFriend) + this.friends = friends + this.loaded = true + const conversationStore = useConversationStore() for (const friend of this.friends) { if (friend.status === CommonStatusEnum.DISABLE) { - continue; + continue } conversationStore.updateConversation( ImConversationType.PRIVATE, @@ -282,26 +254,18 @@ export const useFriendStore = defineStore('imFriendStore', { { name: getFriendDisplayName(friend), avatar: friend.avatar, - silent: friend.silent, + silent: friend.silent }, - ); + db + ) } - this.saveFriendList(); - }) - .finally(() => { - if ( - pendingFetchFriends?.epoch === requestEpoch && - pendingFetchFriends.userId === requestUserId - ) { - pendingFetchFriends = null; - } - }); - pendingFetchFriends = { - epoch: requestEpoch, - userId: requestUserId, - promise, - }; - return promise; + await this.saveFriendList(friends, db).catch((e) => + console.warn('[IM friendStore] 本地好友缓存写入失败', e) + ) + return friends + }, + { mode: ResourceRequestMode.SINGLE_FLIGHT, refreshAfterPending: force } + ) }, /** @@ -310,95 +274,70 @@ export const useFriendStore = defineStore('imFriendStore', { * 含已删除好友,按 status 走 upsert */ async pullFriends() { - // 快照 epoch;账号在拉取途中切换(clear() → epoch++)时丢弃旧账号那几页结果,防跨账号数据泄漏 - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - const isActive = () => - requestEpoch === storeEpoch && getCurrentUserId() === requestUserId; + const db = await initDb() await runIncrementalPull( + db, StorageKeys.settings.friendPullCursor, apiPullMyFriendList, async (records) => { - if (!isActive()) { - return false; - } - await Promise.all( - records.map((vo) => this.upsertFriendForPull(convertFriend(vo))), - ); - return true; - }, - isActive, - ); + await Promise.all(records.map((vo) => this.upsertFriendForPull(convertFriend(vo), db))) + return true + } + ) // 置 loaded,供通讯录页 fetchFriendList(force=false) 复用缓存而非重复全量拉 - if (isActive()) { - this.loaded = true; - } + this.loaded = true }, /** 按 friendUserId 获取详情并合并到本地(保证 nickname / avatar 最新) */ async fetchFriendInfo(friendUserId: number) { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - if (!requestUserId) { - return; - } - const key = getPendingFriendInfoKey(requestUserId, friendUserId); - const inflight = pendingFetchFriendInfos.get(key); + const inflight = pendingFetchFriendInfos.get(friendUserId) if (inflight) { - return inflight; + return inflight } const promise = (async () => { try { - const data = await apiGetFriend(friendUserId); + const db = await initDb() + const data = await apiGetFriend(friendUserId) if (!data) { - return; + return } - // clear() 已切账号:旧请求的好友详情不能再 upsert 进新账号的 friends - if ( - requestEpoch !== storeEpoch || - getCurrentUserId() !== requestUserId - ) { - return; - } - this.upsertFriend(convertFriend(data)); - } catch (error) { - console.warn('[IM friendStore] fetchFriendInfo 失败', error); + await this.upsertFriendForPull(convertFriend(data), db) + } catch (e) { + console.warn('[IM friendStore] fetchFriendInfo 失败', e) } })().finally(() => { - if (pendingFetchFriendInfos.get(key) === promise) { - pendingFetchFriendInfos.delete(key); + if (pendingFetchFriendInfos.get(friendUserId) === promise) { + pendingFetchFriendInfos.delete(friendUserId) } - }); - pendingFetchFriendInfos.set(key, promise); - return promise; + }) + pendingFetchFriendInfos.set(friendUserId, promise) + return promise }, // ==================== 申请-审批 ==================== /** 发起好友申请:成功后等待对方同意(不直接落地为好友) */ - async applyFriendRequest( - reqVO: ImFriendRequestApi.FriendRequestApplyReqVO, - ): Promise { - return await apiApplyFriendRequest(reqVO); + async applyFriendRequest(reqVO: ImFriendRequestApplyReqVO): Promise { + return await apiApplyFriendRequest(reqVO) }, /** 同意一条好友申请;后端会双向落库 + 推 FRIEND_ADD,本端等通知到达再 upsertFriend */ async agreeFriendRequest(requestId: number) { - await apiAgreeFriendRequest(requestId); - await this.applyHandleResult( - requestId, - ImFriendRequestHandleResult.AGREED, - ); + const db = await initDb() + await apiAgreeFriendRequest(requestId) + await this.applyHandleResult(requestId, ImFriendRequestHandleResult.AGREED, undefined, db) }, /** 拒绝一条好友申请 */ async refuseFriendRequest(requestId: number, handleContent?: string) { - await apiRefuseFriendRequest(requestId, handleContent); + const db = await initDb() + await apiRefuseFriendRequest(requestId, handleContent) await this.applyHandleResult( requestId, ImFriendRequestHandleResult.REFUSED, handleContent, - ); + db + ) }, /** 把 handleResult 应用到本地申请记录;找不到就按 id 单查兜底 upsert,避免破坏 id 倒序 */ @@ -406,368 +345,259 @@ export const useFriendStore = defineStore('imFriendStore', { requestId: number, result: number, handleContent?: string, + db: DbClient = getDb() ): Promise { - const request = this.getFriendRequest(requestId); + const request = this.getFriendRequest(requestId) if (request) { - request.handleResult = result; + request.handleResult = result if (handleContent !== undefined) { - request.handleContent = handleContent; + request.handleContent = handleContent } - request.handleTime = Date.now(); - this.saveFriendRequest(request); - return; + request.handleTime = Date.now() + this.saveFriendRequest(request, db) + return } - await this.fetchFriendRequest(requestId); + await this.fetchFriendRequest(requestId, db) }, - /** 拉取「我相关」的好友申请列表首页(页面打开 / 收到 FRIEND_REQUEST_RECEIVED 时刷新);pending 期间复用同一 Promise */ + /** 拉取「我相关」的好友申请列表首页;pending 期间复用同一 Promise */ async fetchFriendRequestList() { - if (pendingFetchRequests) { - const currentUserId = getCurrentUserId(); - if ( - pendingFetchRequests.epoch === storeEpoch && - pendingFetchRequests.userId === currentUserId - ) { - return pendingFetchRequests.promise; - } + if (requestTask) { + return requestTask } - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - const promise = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE) - .then((list) => { - if ( - requestEpoch !== storeEpoch || - getCurrentUserId() !== requestUserId - ) { - return; - } - const items = (list || []).map((request) => - convertFriendRequest(request), - ); - this.friendRequests = items; - // 不足一页即没有更多;满页可能还有,等 loadMore 拉到 0 条再确定 - this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE; - this.saveFriendRequestList(); - }) - .finally(() => { - if ( - pendingFetchRequests?.epoch === requestEpoch && - pendingFetchRequests.userId === requestUserId - ) { - pendingFetchRequests = null; - } - }); - pendingFetchRequests = { - epoch: requestEpoch, - userId: requestUserId, - promise, - }; - return promise; + const promise = (async () => { + const db = await initDb() + const list = await apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE) + const items = (list || []).map(convertFriendRequest) + this.friendRequests = items.sort((left, right) => right.id - left.id) + // 不足一页即没有更多;满页可能还有,等 loadMore 拉到 0 条再确定 + this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE + this.saveFriendRequestList(undefined, db) + })().finally(() => { + if (requestTask === promise) { + requestTask = null + } + }) + requestTask = promise + return promise }, /** 加载更多申请(按本地最旧 requestId 游标分页);无更多 / pending 中直接返回 */ async loadMoreFriendRequestList() { - const requestUserId = getCurrentUserId(); - const hasSameFetchPending = - pendingFetchRequests?.epoch === storeEpoch && - pendingFetchRequests.userId === requestUserId; - if (!this.hasMoreFriendRequests || hasSameFetchPending) { - return; + if (!this.hasMoreFriendRequests) { + return } - if ( - pendingLoadMoreRequests?.epoch === storeEpoch && - pendingLoadMoreRequests.userId === requestUserId - ) { - return pendingLoadMoreRequests.promise; + if (requestTask) { + return requestTask } - const oldest = this.friendRequests[this.friendRequests.length - 1]; + const oldest = this.friendRequests[this.friendRequests.length - 1] if (!oldest) { - return this.fetchFriendRequestList(); + return this.fetchFriendRequestList() } - const requestEpoch = storeEpoch; - const promise = apiGetMyFriendRequestList( - FRIEND_REQUEST_PAGE_SIZE, - oldest.id, - ) - .then((list) => { - if ( - requestEpoch !== storeEpoch || - getCurrentUserId() !== requestUserId - ) { - return; - } - const items = (list || []).map((request) => - convertFriendRequest(request), - ); - this.friendRequests.push(...items); - this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE; - this.saveFriendRequestList(); - }) - .finally(() => { - if ( - pendingLoadMoreRequests?.epoch === requestEpoch && - pendingLoadMoreRequests.userId === requestUserId - ) { - pendingLoadMoreRequests = null; - } - }); - pendingLoadMoreRequests = { - epoch: requestEpoch, - userId: requestUserId, - promise, - }; - return promise; + const promise = (async () => { + const db = await initDb() + const list = await apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE, oldest.id) + const items = (list || []).map(convertFriendRequest) + const currentIds = new Set(this.friendRequests.map((request) => request.id)) + const additions = items.filter((request) => !currentIds.has(request.id)) + this.friendRequests.push(...additions) + this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE + this.saveFriendRequestList(undefined, db) + })().finally(() => { + if (requestTask === promise) { + requestTask = null + } + }) + requestTask = promise + return promise }, /** 按 id 查申请记录;列表是按 id 倒序的小列表,O(n) find 即可,不再维护 Map 索引 */ getFriendRequest(requestId: number): FriendRequest | undefined { - return this.friendRequests.find((request) => request.id === requestId); + return this.friendRequests.find((request) => request.id === requestId) }, /** 按 id 从后端单查并 upsert 到本地(dispatcher 兜底用,避免全量重拉);后端带越权过滤 */ - async fetchFriendRequest(requestId: number) { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - const data = await apiGetMyFriendRequest(requestId); + async fetchFriendRequest(requestId: number, db: DbClient = getDb()) { + const data = await apiGetMyFriendRequest(requestId) if (!data) { - return; + return } - // clear() 已切账号:旧请求的申请记录不能再写进新账号的 friendRequests - if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) { - return; - } - this.upsertFriendRequest(convertFriendRequest(data)); - }, - - /** 合并单条好友申请:已有则按 id 覆盖;新记录按 id 倒序插入(比本地最旧还老则跳过,留给 loadMore 带回) */ - upsertFriendRequest(next: FriendRequest) { - void this.upsertFriendRequestForPull(next).catch((error) => - console.warn('[IM friendStore] 本地好友申请写入失败', error), - ); + await this.upsertFriendRequestForPull(convertFriendRequest(data), db) }, /** 合并单条好友申请 */ - async upsertFriendRequestForPull(next: FriendRequest): Promise { - const existing = this.getFriendRequest(next.id); + async upsertFriendRequestForPull(next: FriendRequest, db: DbClient = getDb()): Promise { + const existing = this.getFriendRequest(next.id) if (existing) { - Object.assign(existing, next); - await this.saveFriendRequestRecord(existing); - return; + Object.assign(existing, next) + await this.saveFriendRequestRecord(existing, db) + return } // 比本地最旧 id 还老:不入列表,让 loadMore 自然带回,避免破坏 id 倒序 / 后续 loadMore 重复 push - const oldest = this.friendRequests[this.friendRequests.length - 1]; + const oldest = this.friendRequests[this.friendRequests.length - 1] if (oldest && next.id < oldest.id) { - return; + return } // 按 id 倒序找首个比自己小的位置插入;找不到则追加末尾 - const insertIndex = this.friendRequests.findIndex( - (request) => request.id < next.id, - ); - if (insertIndex === -1) { - this.friendRequests.push(next); + const insertIndex = this.friendRequests.findIndex((request) => request.id < next.id) + if (insertIndex < 0) { + this.friendRequests.push(next) } else { - this.friendRequests.splice(insertIndex, 0, next); + this.friendRequests.splice(insertIndex, 0, next) } - await this.saveFriendRequestRecord(next); + await this.saveFriendRequestRecord(next, db) }, /** 增量拉取好友申请变更并合并(重连 / 离线补偿);按 update_time + id 游标,已处理的按 handleResult 覆盖 */ async pullFriendRequests() { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - const isActive = () => - requestEpoch === storeEpoch && getCurrentUserId() === requestUserId; + const db = await initDb() await runIncrementalPull( + db, StorageKeys.settings.friendRequestPullCursor, - apiPullMyFriendRequestList, + (params) => apiPullMyFriendRequestList(params), async (records) => { - if (!isActive()) { - return false; - } await Promise.all( - records.map((vo) => - this.upsertFriendRequestForPull(convertFriendRequest(vo)), - ), - ); - return true; - }, - isActive, - ); + records.map((vo) => this.upsertFriendRequestForPull(convertFriendRequest(vo), db)) + ) + return true + } + ) }, // ==================== 好友关系操作 ==================== /** 删除好友(单向软删,本端置 DISABLE);clear=true 时级联清理本地相关数据(如私聊会话),并透传后端给多端同步 */ async deleteFriend(friendUserId: number, clear: boolean = true) { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - await apiDeleteFriend(friendUserId, clear); - if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) { - return; - } - this.removeFriend(friendUserId, clear); + const db = await initDb() + await apiDeleteFriend(friendUserId, clear) + this.removeFriend(friendUserId, clear, db) }, /** 切换免打扰:同步会话的 silent 字段,避免会话列表 silent 图标等 1210 推到才更新 */ async setFriendSilent(friendUserId: number, silent: boolean) { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - await apiUpdateFriend({ friendUserId, silent }); - if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) { - return; - } - const friend = this.getFriend(friendUserId); + const db = await initDb() + await apiUpdateFriend({ friendUserId, silent }) + const friend = this.getFriend(friendUserId) if (friend) { - friend.silent = silent; - const conversationStore = useConversationStore(); + friend.silent = silent + const conversationStore = useConversationStore() conversationStore.updateConversation( ImConversationType.PRIVATE, friendUserId, { silent }, - ); - this.saveFriend(friend); - } - }, - - /** 切换联系人置顶 */ - async setFriendPinned(friendUserId: number, pinned: boolean) { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - await apiUpdateFriend({ friendUserId, pinned }); - if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) { - return; - } - const friend = this.getFriend(friendUserId); - if (friend) { - friend.pinned = pinned; - this.saveFriend(friend); + db + ) + this.saveFriend(friend, db) } }, /** 拉黑好友:本端乐观更新 + 调接口;后端 FRIEND_BLOCK 推到时由 dispatcher 兜底同步多端 */ async blockFriend(friendUserId: number) { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - await apiBlockFriend(friendUserId); - if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) { - return; - } - const friend = this.getFriend(friendUserId); + const db = await initDb() + await apiBlockFriend(friendUserId) + const friend = this.getFriend(friendUserId) if (friend) { - friend.blocked = true; - this.saveFriend(friend); + friend.blocked = true + this.saveFriend(friend, db) } }, /** 移出黑名单:本端乐观更新 + 调接口;后端 FRIEND_UNBLOCK 推到时由 dispatcher 兜底同步多端 */ async unblockFriend(friendUserId: number) { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - await apiUnblockFriend(friendUserId); - if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) { - return; - } - const friend = this.getFriend(friendUserId); + const db = await initDb() + await apiUnblockFriend(friendUserId) + const friend = this.getFriend(friendUserId) if (friend) { - friend.blocked = false; - this.saveFriend(friend); + friend.blocked = false + this.saveFriend(friend, db) } }, /** 修改好友展示备注(仅自己可见) */ async setFriendDisplayName(friendUserId: number, displayName: string) { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - const value = displayName.trim(); + const value = displayName.trim() // 后端 displayName 语义:null/undefined = 不改,"" = 清空,所以这里直接传 value(可能是空串) - await apiUpdateFriend({ friendUserId, displayName: value }); - if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) { - return; - } - const friend = this.getFriend(friendUserId); + const db = await initDb() + await apiUpdateFriend({ friendUserId, displayName: value }) + const friend = this.getFriend(friendUserId) if (friend) { - friend.displayName = value; - const conversationStore = useConversationStore(); + friend.displayName = value + const conversationStore = useConversationStore() conversationStore.updateConversation( ImConversationType.PRIVATE, friendUserId, - { - name: getFriendDisplayName(friend), - }, - ); - this.saveFriend(friend); + { name: getFriendDisplayName(friend) }, + db + ) + this.saveFriend(friend, db) } }, /** 本地合并 / 新增某个好友(WebSocket 事件 & 手动刷新都用) */ upsertFriend(friend: Friend) { - void this.upsertFriendForPull(friend).catch((error) => - console.warn('[IM friendStore] 本地好友写入失败', error), - ); + void this.upsertFriendForPull(friend).catch((e) => + console.warn('[IM friendStore] 本地好友写入失败', e) + ) }, /** 本地合并 / 新增某个好友 */ - async upsertFriendForPull(friend: Friend): Promise { + async upsertFriendForPull(friend: Friend, db: DbClient = getDb()): Promise { const index = this.friends.findIndex( - (existing) => existing.friendUserId === friend.friendUserId, - ); - if (index === -1) { - this.friends.push({ - ...friend, - status: friend.status ?? CommonStatusEnum.ENABLE, - }); - } else { + (existing) => existing.friendUserId === friend.friendUserId + ) + if (index >= 0) { this.friends[index] = { ...this.friends[index], ...friend, - status: friend.status ?? CommonStatusEnum.ENABLE, - }; + status: friend.status ?? CommonStatusEnum.ENABLE + } + } else { + this.friends.push({ + ...friend, + status: friend.status ?? CommonStatusEnum.ENABLE + }) } - const conversationStore = useConversationStore(); - const merged = this.getFriend(friend.friendUserId); + const conversationStore = useConversationStore() + const merged = this.getFriend(friend.friendUserId) conversationStore.updateConversation( ImConversationType.PRIVATE, friend.friendUserId, { name: merged ? getFriendDisplayName(merged) : friend.nickname, avatar: friend.avatar, - silent: friend.silent, + silent: friend.silent }, - ); - await this.saveFriendRecord(merged); + db + ) + await this.saveFriendRecord(merged, db) }, /** 本地标记删除(WebSocket FRIEND_DELETE 事件触发;clear=true 时级联清相关数据如私聊会话) */ - removeFriend(friendUserId: number, clear: boolean = true) { - const friend = this.getFriend(friendUserId); + removeFriend(friendUserId: number, clear: boolean = true, db: DbClient = getDb()) { + const friend = this.getFriend(friendUserId) if (friend) { // blocked 不动,跟后端 deleteFriend0「删好友期间保留拉黑状态」对齐 - friend.status = CommonStatusEnum.DISABLE; - friend.deleteTime = Date.now(); + friend.status = CommonStatusEnum.DISABLE + friend.deleteTime = Date.now() } if (clear) { - const conversationStore = useConversationStore(); - conversationStore.removePrivateConversation(friendUserId); + const conversationStore = useConversationStore() + void conversationStore + .removePrivateConversation(friendUserId, db) + .catch((e) => console.warn('[IM friendStore] 私聊会话删除失败', e)) } - this.saveFriend(friend); + this.saveFriend(friend, db) }, // ==================== WebSocket 事件 dispatcher(1201-1210 段) ==================== /** FRIEND_REQUEST_RECEIVED(1203):收到新申请;payload 已带申请方昵称 / 头像,按 requestId 直推 push 进列表 */ applyFriendRequestReceivedNotification(payload: FriendNotificationPayload) { - if (!payload.requestId) { - return; - } - const currentUserId = getCurrentUserId(); - const existingIndex = this.friendRequests.findIndex( - (item) => item.id === payload.requestId, - ); - if (existingIndex !== -1) { - const existing = this.friendRequests.splice(existingIndex, 1)[0]; - if (!existing) { - return; - } + const currentUserId = getCurrentUserId() + const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId) + if (existingIndex >= 0) { + const existing = this.friendRequests.splice(existingIndex, 1)[0]! const next = { ...existing, fromUserId: payload.operatorUserId, @@ -777,14 +607,14 @@ export const useFriendStore = defineStore('imFriendStore', { addSource: payload.addSource, createTime: Date.now(), fromNickname: payload.fromNickname, - fromAvatar: payload.fromAvatar, - }; - this.friendRequests.unshift(next); - this.saveFriendRequest(next); - return; + fromAvatar: payload.fromAvatar + } + this.friendRequests.unshift(next) + this.saveFriendRequest(next) + return } const next = { - id: payload.requestId, + id: payload.requestId!, fromUserId: payload.operatorUserId, toUserId: currentUserId, handleResult: ImFriendRequestHandleResult.UNHANDLED, @@ -792,33 +622,26 @@ export const useFriendStore = defineStore('imFriendStore', { addSource: payload.addSource, createTime: Date.now(), fromNickname: payload.fromNickname, - fromAvatar: payload.fromAvatar, - }; - this.friendRequests.unshift(next); - this.saveFriendRequest(next); + fromAvatar: payload.fromAvatar + } + this.friendRequests.unshift(next) + this.saveFriendRequest(next) }, /** FRIEND_REQUEST_APPROVED(1201):我的申请被同意;按 requestId 更新状态(FRIEND_ADD 会另外推) */ applyFriendRequestApprovedNotification(payload: FriendNotificationPayload) { - if (!payload.requestId) { - return; - } - void this.applyHandleResult( - payload.requestId, - ImFriendRequestHandleResult.AGREED, - ); + void this.applyHandleResult(payload.requestId!, ImFriendRequestHandleResult.AGREED).catch( + (e) => console.warn('[IM friendStore] 好友申请同意状态写入失败', e) + ) }, /** FRIEND_REQUEST_REJECTED(1202):我的申请被拒绝;按 requestId 更新状态 */ applyFriendRequestRejectedNotification(payload: FriendNotificationPayload) { - if (!payload.requestId) { - return; - } void this.applyHandleResult( - payload.requestId, + payload.requestId!, ImFriendRequestHandleResult.REFUSED, - payload.handleContent, - ); + payload.handleContent + ).catch((e) => console.warn('[IM friendStore] 好友申请拒绝状态写入失败', e)) }, /** @@ -826,93 +649,88 @@ export const useFriendStore = defineStore('imFriendStore', { * peerUserId 由 websocketStore 按帧 sender / receiver 算好传入:becomeFriends 单条入库后双方收到同一份 payload, * 本端真正的「对端」是帧上的另一个用户,不是 payload.friendUserId(payload 里固定是 toUserId)。 */ - applyFriendAddNotification( - _payload: FriendNotificationPayload, - peerUserId: number, - ) { + applyFriendAddNotification(_payload: FriendNotificationPayload, peerUserId: number) { + queueFriendListRefreshAfterPending(() => this.fetchFriendList(true)) if (this.isActiveFriend(peerUserId)) { - return; + return } - void this.fetchFriendInfo(peerUserId); + void this.fetchFriendInfo(peerUserId).catch((e) => + console.warn('[IM friendStore] 好友详情补拉失败', e) + ) }, /** * FRIEND_DELETE(1205):好友被删除;本端清理 + 按 payload.clear 决定是否级联清会话(多端跟主操作端一致) * peerUserId 由 websocketStore 按帧 sender / receiver 算好传入;与 FRIEND_ADD 保持一致的 peer 推断 */ - applyFriendDeleteNotification( - payload: FriendNotificationPayload, - peerUserId: number, - ) { - this.removeFriend(peerUserId, payload.clear !== false); + applyFriendDeleteNotification(payload: FriendNotificationPayload, peerUserId: number) { + queueFriendListRefreshAfterPending(() => this.fetchFriendList(true)) + this.removeFriend(peerUserId, payload.clear !== false) }, /** FRIEND_BLOCK(1207):拉黑;多端同步 */ applyFriendBlockNotification(payload: FriendNotificationPayload) { - const friend = this.getFriend(payload.friendUserId); + queueFriendListRefreshAfterPending(() => this.fetchFriendList(true)) + const friend = this.getFriend(payload.friendUserId) if (friend) { - friend.blocked = true; - this.saveFriend(friend); + friend.blocked = true + this.saveFriend(friend) } }, /** FRIEND_UNBLOCK(1208):移出黑名单;多端同步 */ applyFriendUnblockNotification(payload: FriendNotificationPayload) { - const friend = this.getFriend(payload.friendUserId); + queueFriendListRefreshAfterPending(() => this.fetchFriendList(true)) + const friend = this.getFriend(payload.friendUserId) if (friend) { - friend.blocked = false; - this.saveFriend(friend); + friend.blocked = false + this.saveFriend(friend) } }, /** FRIEND_INFO_UPDATED(1209):好友资料变更(昵称 / 头像);重拉详情 */ applyFriendInfoUpdatedNotification(payload: FriendNotificationPayload) { - void this.fetchFriendInfo(payload.friendUserId); + queueFriendListRefreshAfterPending(() => this.fetchFriendList(true)) + void this.fetchFriendInfo(payload.friendUserId) }, /** FRIEND_UPDATE(1210):批量更新(备注 / 免打扰 / 联系人置顶);多端同步 */ applyFriendUpdateNotification(payload: FriendNotificationPayload) { - const friend = this.getFriend(payload.friendUserId); + queueFriendListRefreshAfterPending(() => this.fetchFriendList(true)) + const friend = this.getFriend(payload.friendUserId) if (!friend) { - return; + return } - if (payload.displayName !== null) { - friend.displayName = payload.displayName; + if (payload.displayName != null) { + friend.displayName = payload.displayName } - if (payload.silent !== null) { - friend.silent = payload.silent; + if (payload.silent != null) { + friend.silent = payload.silent } - if (payload.pinned !== null) { - friend.pinned = payload.pinned; + if (payload.pinned != null) { + friend.pinned = payload.pinned } - const conversationStore = useConversationStore(); - conversationStore.updateConversation( - ImConversationType.PRIVATE, - payload.friendUserId, - { - name: getFriendDisplayName(friend), - silent: friend.silent, - }, - ); - this.saveFriend(friend); + const conversationStore = useConversationStore() + conversationStore.updateConversation(ImConversationType.PRIVATE, payload.friendUserId, { + name: getFriendDisplayName(friend), + silent: friend.silent + }) + this.saveFriend(friend) }, - /** 清空好友内存状态,并废弃未返回请求(pending Promise 置空 + storeEpoch++) */ + /** 清空好友内存状态,并废弃未返回请求 */ clear() { - this.friends = []; - this.friendRequests = []; - this.loaded = false; - this.hasMoreFriendRequests = true; - pendingFetchFriends = null; - pendingFetchRequests = null; - pendingLoadMoreRequests = null; - pendingFetchFriendInfos.clear(); - storeEpoch++; - }, - }, -}); + this.friends = [] + this.friendRequests = [] + this.loaded = false + this.hasMoreFriendRequests = true + requestTask = null + pendingFetchFriendInfos.clear() + } + } +}) -function convertFriend(vo: ImFriendApi.FriendRespVO): Friend { +function convertFriend(vo: ImFriendRespVO): Friend { return { id: vo.id, friendUserId: vo.friendUserId, @@ -927,13 +745,11 @@ function convertFriend(vo: ImFriendApi.FriendRespVO): Friend { blocked: !!vo.blocked, status: vo.status, addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined, - deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined, - }; + deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined + } } -function convertFriendRequest( - vo: ImFriendRequestApi.FriendRequestRespVO, -): FriendRequest { +function convertFriendRequest(vo: ImFriendRequestRespVO): FriendRequest { return { id: vo.id, fromUserId: vo.fromUserId, @@ -947,13 +763,11 @@ function convertFriendRequest( fromNickname: vo.fromNickname, fromAvatar: vo.fromAvatar, toNickname: vo.toNickname, - toAvatar: vo.toAvatar, - }; + toAvatar: vo.toAvatar + } } -export const useFriendStoreWithOut = () => useFriendStore(); - // dev: 让 Pinia 的 actions / state 改动支持 HMR,避免每次改 store 都得硬刷 if (import.meta.hot) { - import.meta.hot.accept(acceptHMRUpdate(useFriendStore, import.meta.hot)); + import.meta.hot.accept(acceptHMRUpdate(useFriendStore, import.meta.hot)) } diff --git a/apps/web-antd/src/views/im/home/store/groupRequestStore.ts b/apps/web-antd/src/views/im/home/store/groupRequestStore.ts index 6c4f1fbb8..4bb668c4e 100644 --- a/apps/web-antd/src/views/im/home/store/groupRequestStore.ts +++ b/apps/web-antd/src/views/im/home/store/groupRequestStore.ts @@ -1,31 +1,33 @@ -import type { ImGroupRequestApi } from '#/api/im/group/request'; +import type { GroupRequestDO } from '../types' -import { acceptHMRUpdate, defineStore } from 'pinia'; +import type { ImGroupRequestApi } from '#/api/im/group/request' + +import { acceptHMRUpdate, defineStore } from 'pinia' import { agreeGroupRequest as apiAgreeGroupRequest, getMyGroupRequest as apiGetMyGroupRequest, getUnhandledRequestList as apiGetUnhandledRequestList, pullMyGroupRequestList as apiPullMyGroupRequestList, - refuseGroupRequest as apiRefuseGroupRequest, -} from '#/api/im/group/request'; -import { getCurrentUserId } from '#/views/im/utils/auth'; -import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'; + refuseGroupRequest as apiRefuseGroupRequest +} from '#/api/im/group/request' +import { ImGroupRequestHandleResult } from '#/views/im/utils/constants' -import { getDb, StorageKeys } from '../../utils/db'; -import { runIncrementalPull } from '../../utils/pull'; +import { type DbClient, getDb, initDb, StorageKeys } from '../../utils/db' +import { runIncrementalPull } from '../../utils/pull' +import { + ResourceRequestKey, + ResourceRequestMode, + runResourceRequest +} from '../../utils/resourceRequest' -type PendingRequest = { epoch: number; promise: Promise; userId: number }; - -/** clear() 时递增;旧账号 in-flight 的 pullGroupRequests 结果 resolve 后比对一致才写 store,防跨账号红点污染(与 friendStore 同口径) */ -let storeEpoch = 0; -let pendingUnhandledFetch: null | PendingRequest = null; +type ImGroupRequestRespVO = ImGroupRequestApi.GroupRequestRespVO /** * IM 加群申请 Store * * 仅维护「我管理的所有群」下未处理的申请列表(unhandledList); - * 横幅 / Drawer 都从这里派生 count 和分组列表,避免给 ImGroupApi.GroupRespVO 挂 pendingRequestCount 字段 + * 横幅 / Drawer 都从这里派生 count 和分组列表,避免给 ImGroupRespVO 挂 pendingRequestCount 字段 * * 数据生命周期: * - 进 IM 后调一次 fetchUnhandledGroupRequestList 拉首页全量 @@ -37,9 +39,7 @@ let pendingUnhandledFetch: null | PendingRequest = null; export const useGroupRequestStore = defineStore('imGroupRequestStore', { state: () => ({ /** 我管理的所有群下未处理申请列表(按 id 倒序) */ - unhandledList: [] as ImGroupRequestApi.GroupRequestRespVO[], - /** fetchUnhandledGroupRequestList 是否成功执行过;避免横幅显示 0 然后跳数字的闪烁 */ - loaded: false, + unhandledList: [] as ImGroupRequestRespVO[] }), getters: { @@ -47,115 +47,53 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', { * 各群下未处理申请数的 Map;O(N) 扫一次缓存供 ConversationItem 等 N 处复用,避免 N×M 重复 filter */ getUnhandledGroupRequestCountMap(state): Map { - const map = new Map(); + const map = new Map() for (const request of state.unhandledList) { - map.set(request.groupId, (map.get(request.groupId) ?? 0) + 1); + map.set(request.groupId, (map.get(request.groupId) ?? 0) + 1) } - return map; + return map }, /** 指定群下的未处理申请数 */ getUnhandledGroupRequestCount(): (groupId: number) => number { - return (groupId: number) => - this.getUnhandledGroupRequestCountMap.get(groupId) ?? 0; - }, - /** 指定群下的未处理申请列表 */ - getUnhandledGroupRequestListByGroupId: - (state) => - (groupId: number): ImGroupRequestApi.GroupRequestRespVO[] => - state.unhandledList.filter((r) => r.groupId === groupId), + return (groupId: number) => this.getUnhandledGroupRequestCountMap.get(groupId) ?? 0 + } }, actions: { /** 从 IndexedDB 恢复加群申请 */ - async loadGroupRequestList(): Promise { + async loadGroupRequestList(): Promise { try { - const cached = - await getDb().getAll( - 'groupRequests', - ); - if (!cached || cached.length === 0) { - return false; + const cached = await getDb().getAll('groupRequests') + if (cached.length === 0) { + return } this.unhandledList = cached - .filter( - (request) => - request.handleResult === ImGroupRequestHandleResult.UNHANDLED, - ) - .toSorted((requestA, requestB) => requestB.id - requestA.id); - return true; - } catch (error) { - console.warn('[IM groupRequestStore] 本地加群申请缓存读取失败', error); - return false; + .filter((request) => request.handleResult === ImGroupRequestHandleResult.UNHANDLED) + .sort((requestA, requestB) => requestB.id - requestA.id) + } catch (e) { + console.warn('[IM groupRequestStore] 本地加群申请缓存读取失败', e) } }, - /** 保存加群申请列表 */ - saveGroupRequestList(): void { - void getDb() - .transaction(['groupRequests'], 'readwrite', async (tx) => { - const db = getDb(); - await db.clearStore('groupRequests', tx); - for (const request of this.unhandledList) { - await db.put('groupRequests', request, tx); - } - }) - .catch((error) => - console.warn( - '[IM groupRequestStore] 本地加群申请缓存写入失败', - error, - ), - ); - }, - - /** 保存单条加群申请 */ - async saveGroupRequestRecord( - request: ImGroupRequestApi.GroupRequestRespVO, - ): Promise { - await getDb().put('groupRequests', request); - }, - - /** 保存单条加群申请 */ - saveGroupRequest(request: ImGroupRequestApi.GroupRequestRespVO): void { - void this.saveGroupRequestRecord(request).catch((error) => - console.warn('[IM groupRequestStore] 本地加群申请写入失败', error), - ); - }, - /** 拉取我管理的所有群下未处理申请;进 IM 后 / 升级 admin 后 / WS 推送有冲突时调用 */ async fetchUnhandledGroupRequestList() { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - if ( - pendingUnhandledFetch?.epoch === requestEpoch && - pendingUnhandledFetch.userId === requestUserId - ) { - return pendingUnhandledFetch.promise; - } - const promise = (async () => { - const list = await apiGetUnhandledRequestList(); - if ( - requestEpoch !== storeEpoch || - getCurrentUserId() !== requestUserId - ) { - return; - } - this.unhandledList = list || []; - this.loaded = true; - this.saveGroupRequestList(); - })().finally(() => { - if ( - pendingUnhandledFetch?.epoch === requestEpoch && - pendingUnhandledFetch.userId === requestUserId - ) { - pendingUnhandledFetch = null; - } - }); - pendingUnhandledFetch = { - epoch: requestEpoch, - userId: requestUserId, - promise, - }; - return promise; + return runResourceRequest( + ResourceRequestKey.GROUP_REQUEST_UNHANDLED, + async () => { + const db = await initDb() + this.unhandledList = await apiGetUnhandledRequestList() + const snapshot = [...this.unhandledList] + void db + .transaction(['groupRequests'], 'readwrite', async (tx) => { + await db.clearStore('groupRequests', tx) + for (const request of snapshot) { + await db.put('groupRequests', request, tx) + } + }) + .catch((e) => console.warn('[IM groupRequestStore] 本地加群申请缓存写入失败', e)) + }, + { mode: ResourceRequestMode.SINGLE_FLIGHT } + ) }, /** @@ -164,42 +102,25 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', { * 同一对 group_id, user_id 复用记录时 requestId 不变但 applyContent / inviterUserId 会刷新,所以无条件 fetch + 排到头部 */ async addGroupRequestById(requestId: number) { - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - const request = await apiGetMyGroupRequest(requestId); + const db = await initDb() + const request = await apiGetMyGroupRequest(requestId) if (!request) { - return; + return } - if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) { - return; - } - this.upsertGroupRequest(request); - }, - - /** - * 本地合并 / 新增单条加群申请(WS 推送 & 增量拉取共用) - * - * 未处理的按 id 去重后置顶;已处理的从未处理列表移除,避免补偿时把已同意 / 拒绝的记录塞回红点 - */ - upsertGroupRequest(request: ImGroupRequestApi.GroupRequestRespVO) { - void this.upsertGroupRequestForPull(request).catch((error) => - console.warn('[IM groupRequestStore] 本地加群申请写入失败', error), - ); + await this.upsertGroupRequestForPull(request, db) }, /** 本地合并 / 新增单条加群申请 */ async upsertGroupRequestForPull( - request: ImGroupRequestApi.GroupRequestRespVO, + request: ImGroupRequestRespVO, + db: DbClient = getDb() ): Promise { if (request.handleResult !== ImGroupRequestHandleResult.UNHANDLED) { - await this.removeGroupRequestByIdForPull(request.id); - return; + await this.removeGroupRequestByIdForPull(request.id, db) + return } - this.unhandledList = [ - request, - ...this.unhandledList.filter((r) => r.id !== request.id), - ]; - await this.saveGroupRequestRecord(request); + this.unhandledList = [request, ...this.unhandledList.filter((r) => r.id !== request.id)] + await db.put('groupRequests', request) }, /** @@ -209,73 +130,53 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', { * 故首次重连时游标为空 = 一次性全量走一遍(已处理记录命中 removeGroupRequestById 为 no-op,红点不受影响),之后增量。 */ async pullGroupRequests() { - // 快照 epoch;账号在拉取途中切换(clear() → epoch++)时丢弃旧账号那几页结果,防跨账号红点污染 - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - const isActive = () => - requestEpoch === storeEpoch && getCurrentUserId() === requestUserId; + const db = await initDb() await runIncrementalPull( + db, StorageKeys.settings.groupRequestPullCursor, - apiPullMyGroupRequestList, + (params) => apiPullMyGroupRequestList(params), async (records) => { - if (!isActive()) { - return false; - } - await Promise.all( - records.map((vo) => this.upsertGroupRequestForPull(vo)), - ); - return true; - }, - isActive, - ); - if (isActive()) { - this.loaded = true; - } + await Promise.all(records.map((vo) => this.upsertGroupRequestForPull(vo, db))) + return true + } + ) }, /** WS 收到 1505 / 1506 或本端处理完一条:按 requestId 从列表移除 */ - removeGroupRequestById(requestId: number) { - this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId); - void getDb() + removeGroupRequestById(requestId: number, db: DbClient = getDb()) { + this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId) + void db .delete('groupRequests', requestId) - .catch((error) => - console.warn('[IM groupRequestStore] 本地加群申请删除失败', error), - ); + .catch((e) => console.warn('[IM groupRequestStore] 本地加群申请删除失败', e)) }, /** 删除单条加群申请 */ - async removeGroupRequestByIdForPull(requestId: number): Promise { - this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId); - await getDb().delete('groupRequests', requestId); + async removeGroupRequestByIdForPull(requestId: number, db: DbClient = getDb()): Promise { + this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId) + await db.delete('groupRequests', requestId) }, /** 同意申请;本端处理后立即从列表移除,避免被反复点击 */ async agreeGroupRequest(requestId: number) { - await apiAgreeGroupRequest(requestId); - this.removeGroupRequestById(requestId); + const db = await initDb() + await apiAgreeGroupRequest(requestId) + this.removeGroupRequestById(requestId, db) }, /** 拒绝申请 */ async refuseGroupRequest(requestId: number, handleContent?: string) { - await apiRefuseGroupRequest(requestId, handleContent); - this.removeGroupRequestById(requestId); + const db = await initDb() + await apiRefuseGroupRequest(requestId, handleContent) + this.removeGroupRequestById(requestId, db) }, /** 清空加群申请内存 */ clear() { - this.unhandledList = []; - this.loaded = false; - // 账号切换:递增 epoch 废弃旧账号 in-flight 的 pullGroupRequests 结果,避免写进新账号红点列表 - storeEpoch++; - pendingUnhandledFetch = null; - }, - }, -}); - -export const useGroupRequestStoreWithOut = () => useGroupRequestStore(); + this.unhandledList = [] + } + } +}) if (import.meta.hot) { - import.meta.hot.accept( - acceptHMRUpdate(useGroupRequestStore, import.meta.hot), - ); + import.meta.hot.accept(acceptHMRUpdate(useGroupRequestStore, import.meta.hot)) } diff --git a/apps/web-antd/src/views/im/home/store/groupStore.ts b/apps/web-antd/src/views/im/home/store/groupStore.ts index cc0af8ec3..783ba9fb6 100644 --- a/apps/web-antd/src/views/im/home/store/groupStore.ts +++ b/apps/web-antd/src/views/im/home/store/groupStore.ts @@ -1,62 +1,91 @@ -import type { GroupNotificationPayload } from '../../utils/message'; -import type { Group, GroupDO, GroupMember, Message } from '../types'; +import type { Group, GroupDO, GroupMember, GroupMemberDO, Message } from '../types' -import type { ImGroupApi } from '#/api/im/group'; -import type { ImGroupMemberApi } from '#/api/im/group/member'; +import type { ImGroupApi } from '#/api/im/group' +import type { ImGroupMemberApi } from '#/api/im/group/member' -import { CommonStatusEnum } from '@vben/constants'; +import { CommonStatusEnum } from '@vben/constants' -import { acceptHMRUpdate, defineStore } from 'pinia'; +import { acceptHMRUpdate, defineStore } from 'pinia' import { + dissolveGroup as apiDissolveGroup, getGroup as apiGetGroup, - getMyGroupList as apiGetMyGroupList, -} from '#/api/im/group'; + getMyGroupList as apiGetMyGroupList +} from '#/api/im/group' import { getGroupMember as apiGetGroupMember, getGroupMemberList as apiGetGroupMemberList, - updateGroupMember as apiUpdateGroupMember, -} from '#/api/im/group/member'; -import { getCurrentUserId } from '#/views/im/utils/auth'; + quitGroup as apiQuitGroup, + updateGroupMember as apiUpdateGroupMember +} from '#/api/im/group/member' import { ImContentType, ImConversationType, ImGroupMemberRole, - ImMessageStatus, -} from '../../utils/constants'; -import { getDb } from '../../utils/db'; -import { getGroupDisplayName } from '../../utils/user'; -import { useConversationStore } from './conversationStore'; -import { useGroupRequestStore } from './groupRequestStore'; + ImMessageStatus +} from '../../utils/constants' +import { type DbClient, getClientConversationId, getDb, initDb } from '../../utils/db' +import { type GroupNotificationPayload } from '../../utils/message' +import { + enqueueConversationWrite, + enqueueConversationWrites, + isRelationTerminated, + markRelationTerminated, + reopenRelation +} from '../../utils/messageSync' +import { + isResourceRequestPending, + ResourceRequestKey, + ResourceRequestMode, + runResourceRequest +} from '../../utils/resourceRequest' +import { getGroupDisplayName } from '../../utils/user' +import { useConversationStore } from './conversationStore' +import { useGroupRequestStore } from './groupRequestStore' -/** clear() 时递增;旧账号 in-flight 的成员请求返回后比对一致才写 store */ -let storeEpoch = 0; +type ImGroupRespVO = ImGroupApi.GroupRespVO +type ImGroupMemberRespVO = ImGroupMemberApi.GroupMemberRespVO -/** - * fetchGroupMemberList 并发去重表:同 groupId 同时进的请求共用一个 Promise - * - * key 必须带 userId——账号切换时 A 的请求不能被 B 复用,否则 IIFE 内部的 saveGroupMemberList 会把 A 的成员数据写进 B 的 IDB 桶 - */ -const pendingMemberFetches = new Map>(); -const pendingMemberKey = (userId: number, groupId: number) => - `${userId}:${groupId}`; +/** 在群列表拉取期间合并一次群事件尾随刷新 */ +function queueGroupListRefreshAfterPending(fetch: () => Promise): void { + if (isResourceRequestPending(ResourceRequestKey.GROUP_LIST)) { + void fetch().catch(() => undefined) + } +} + +let groupRelationVersionSequence = 0 +const groupRelationVersions = new Map() + +/** 获取当前群关系代际,首次访问时创建 */ +function ensureGroupRelationVersion(groupId: number): number { + const current = groupRelationVersions.get(groupId) + if (current !== undefined) { + return current + } + const next = ++groupRelationVersionSequence + groupRelationVersions.set(groupId, next) + return next +} + +const pendingGroupInfoFetches = new Map>() +const queuedGroupInfoRefreshes = new Set() + +/** fetchGroupMemberList 并发去重表:同一群关系代际共用一个 Promise */ +const pendingMemberFetches = new Map>() +const queuedMemberRefreshes = new Set() +const pendingMemberKey = (groupId: number, relationVersion: number) => + `${groupId}:${relationVersion}` /** * fetchGroupMember 单成员并发去重表:同 (groupId, memberUserId) 同时进的请求共用一个 Promise * * 跟整群表分开:单成员 fetch 跟整群 fetch 语义不同(单成员不回填 me 的 silent),不能互相代替 */ -const pendingSingleMemberFetches = new Map< - string, - Promise ->(); +const pendingSingleMemberFetches = new Map>() -const pendingSingleMemberKey = ( - userId: number, - groupId: number, - memberUserId: number, -) => `${userId}:${groupId}:${memberUserId}`; +const pendingSingleMemberKey = (groupId: number, relationVersion: number, memberUserId: number) => + `${groupId}:${relationVersion}:${memberUserId}` /** 构建群 IndexedDB 记录 */ function buildGroupDO(group: Group): GroupDO { @@ -68,21 +97,20 @@ function buildGroupDO(group: Group): GroupDO { membersLoaded: _membersLoaded, membersExpired: _membersExpired, ...record - } = group; - return record; + } = group + return record } /** 判断当前用户是否在 payload.memberUserIds 里(GROUP_CREATE / INVITE / KICK 自判用) */ -function isSelfInPayloadMembers(payload: GroupNotificationPayload): boolean { - const selfUserId = getCurrentUserId(); - return !!selfUserId && (payload.memberUserIds || []).includes(selfUserId); +function isSelfInPayloadMembers(payload: GroupNotificationPayload, userId: number): boolean { + return (payload.memberUserIds || []).includes(userId) } /** 刷新我管理的群申请红点 */ function refreshUnhandledGroupRequests(): void { useGroupRequestStore() .fetchUnhandledGroupRequestList() - .catch(() => undefined); + .catch(() => undefined) } /** @@ -97,22 +125,15 @@ export const useGroupStore = defineStore('imGroupStore', { state: () => ({ groups: [] as Group[], loaded: false, // 仅 fetchGroupList 成功后置位;loadGroupList(IDB)不置位,否则后台 SWR 刷新会被缓存命中跳过 - groupMembersExpired: false, // 进入 IM / 重连后置位;IDB 里的成员桶延迟加载到内存时,也要按过期处理 + groupMembersExpired: false // 进入 IM / 重连后置位;IDB 里的成员桶延迟加载到内存时,也要按过期处理 }), getters: { getGroup: (state) => (id: number): Group | undefined => { - return state.groups.find((g) => g.id === id); - }, - /** 群成员 userId → GroupMember 索引;调用方按 userId 反查昵称 / 头像等元信息 */ - getGroupMemberMap: - (state) => - (id: number): Map => { - const group = state.groups.find((g) => g.id === id); - return new Map((group?.members || []).map((m) => [m.userId, m])); - }, + return state.groups.find((g) => g.id === id) + } }, actions: { @@ -121,117 +142,145 @@ export const useGroupStore = defineStore('imGroupStore', { /** 从 IndexedDB 恢复群列表 */ async loadGroupList(): Promise { try { - const cached = await getDb().getAll('groups'); + const cached = await getDb().getAll('groups') if (!cached || cached.length === 0) { - return false; + return false } - this.groups = cached; - return true; - } catch (error) { - console.warn('[IM groupStore] 本地群缓存读取失败', error); - return false; + const conversationIds = cached.map((group) => + getClientConversationId(ImConversationType.GROUP, group.id) + ) + return await enqueueConversationWrites(conversationIds, async () => { + const activeGroups = cached.filter( + (group) => + !isRelationTerminated(getClientConversationId(ImConversationType.GROUP, group.id)) + ) + this.groups = activeGroups + return activeGroups.length > 0 + }) + } catch (e) { + console.warn('[IM groupStore] 本地群缓存读取失败', e) + return false } }, /** 保存群列表 */ - saveGroupList(): void { - void getDb() - .transaction(['groups'], 'readwrite', async (tx) => { - const db = getDb(); - await db.clearStore('groups', tx); - for (const group of this.groups) { - await db.put('groups', buildGroupDO(group), tx); - } - }) - .catch((error) => - console.warn('[IM groupStore] 本地群缓存写入失败', error), - ); + async saveGroupList(groups: Group[], db: DbClient = getDb()): Promise { + await db.transaction(['groups'], 'readwrite', async (tx) => { + await db.clearStore('groups', tx) + for (const group of groups) { + await db.put('groups', buildGroupDO(group), tx) + } + }) }, /** 保存单个群 */ - async saveGroupRecord(group: Group | undefined): Promise { + async saveGroupRecord(group: Group | undefined, db: DbClient = getDb()): Promise { if (!group) { - return; + return } - await getDb().put('groups', buildGroupDO(group)); + const clientConversationId = getClientConversationId(ImConversationType.GROUP, group.id) + if (isRelationTerminated(clientConversationId)) { + return + } + await db.put('groups', buildGroupDO(group)) }, /** 保存单个群 */ - saveGroup(group: Group | undefined): void { - void this.saveGroupRecord(group).catch((error) => - console.warn('[IM groupStore] 本地群写入失败', error), - ); + saveGroup(group: Group | undefined, db: DbClient = getDb()): void { + if (!group) { + return + } + const clientConversationId = getClientConversationId(ImConversationType.GROUP, group.id) + const snapshot = { ...group } + void enqueueConversationWrite(clientConversationId, () => + this.saveGroupRecord(snapshot, db) + ).catch((e) => console.warn('[IM groupStore] 本地群写入失败', e)) }, /** 从 IndexedDB 恢复指定群成员 */ async loadGroupMemberList(groupId: number): Promise { // in-memory 已"完整"加载(fetchGroupMemberList 跑过或上次冷启动从 IDB 整桶恢复过):直接复用; // 单成员补齐(fetchGroupMember)写进的 partial members 不在此返回缓存——其 membersLoaded=false - const cachedGroup = this.getGroup(groupId); + const cachedGroup = this.getGroup(groupId) if (cachedGroup?.members && cachedGroup.membersLoaded) { - return cachedGroup.members; + return cachedGroup.members } try { - const cached = await getDb().getAllByIndex( + const relationVersion = ensureGroupRelationVersion(groupId) + const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId) + const cached = await getDb().getAllByIndex( 'groupMembers', 'groupId', - groupId, - ); + groupId + ) if (!cached || cached.length === 0) { - return null; + return null } - // 把 IDB 拿到的成员落到对应 group - const group = this.getGroup(groupId); - if (group) { - group.members = cached; - group.memberCount = cached.length; - group.membersLoaded = true; - group.membersExpired = this.groupMembersExpired; - } else { - // group 还没就位:仅 in-memory 占位(name='' 表示未知),不调 upsertGroup —— 避免把假名灌进 conversation.name + groups IDB 桶; - // 后续,等 fetchGroupList 浅合并时,被真名覆盖 - this.groups.push({ - id: groupId, - name: '', - members: cached, - memberCount: cached.length, - membersLoaded: true, - membersExpired: this.groupMembersExpired, - }); - } - return cached; - } catch (error) { - console.warn( - '[IM groupStore] 本地群成员缓存读取失败', - { groupId }, - error, - ); - return null; + return await enqueueConversationWrite(clientConversationId, async () => { + if ( + groupRelationVersions.get(groupId) !== relationVersion || + isRelationTerminated(clientConversationId) + ) { + return null + } + // 把 IDB 拿到的成员落到对应 group + const group = this.getGroup(groupId) + if (!group) { + // group 还没就位:仅 in-memory 占位(name='' 表示未知),不调 upsertGroup —— 避免把假名灌进 conversation.name + groups IDB 桶; + // 后续,等 fetchGroupList 浅合并时,被真名覆盖 + this.groups.push({ + id: groupId, + name: '', + members: cached, + memberCount: cached.length, + membersLoaded: true, + membersExpired: this.groupMembersExpired + }) + } else { + group.members = cached + group.memberCount = cached.length + group.membersLoaded = true + group.membersExpired = this.groupMembersExpired + } + return cached + }) + } catch (e) { + console.warn('[IM groupStore] 本地群成员缓存读取失败', { groupId }, e) + return null } }, - /** 保存指定群成员 */ - saveGroupMemberList(groupId: number): void { - const members = this.getGroup(groupId)?.members; - if (!members) { - return; + /** 保存指定群成员;调用方必须持有群会话写 lane */ + async saveGroupMemberListRecord( + groupId: number, + members: GroupMemberDO[], + db: DbClient = getDb() + ): Promise { + const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId) + if (isRelationTerminated(clientConversationId)) { + return } - void getDb() - .transaction(['groupMembers'], 'readwrite', async (tx) => { - const db = getDb(); - await db.deleteByIndex('groupMembers', 'groupId', groupId, tx); - for (const member of members) { - if (member.id) { - await db.put('groupMembers', member, tx); - } + await db.transaction(['groupMembers'], 'readwrite', async (tx) => { + await db.deleteByIndex('groupMembers', 'groupId', groupId, tx) + for (const member of members) { + if (member.id) { + await db.put('groupMembers', member, tx) } - }) - .catch((error) => - console.warn( - `[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`, - error, - ), - ); + } + }) + }, + + /** 串行保存指定群成员 */ + saveGroupMemberList(groupId: number, db: DbClient = getDb()): void { + const members = this.getGroup(groupId)?.members + if (!members) { + return + } + const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId) + const records = JSON.parse(JSON.stringify(members)) as GroupMemberDO[] + void enqueueConversationWrite(clientConversationId, () => + this.saveGroupMemberListRecord(groupId, records, db) + ).catch((e) => console.warn(`[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`, e)) }, // ==================== 远端拉取 ==================== @@ -239,90 +288,100 @@ export const useGroupStore = defineStore('imGroupStore', { /** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 + 落 IDB */ async fetchGroupList(force = false) { if (this.loaded && !force) { - return; + return this.groups } - const requestEpoch = storeEpoch; - const requestUserId = getCurrentUserId(); - // 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMemberList) - const list = await apiGetMyGroupList(); - if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) { - return; - } - const fresh = (list || []).map((group) => convertGroup(group)); - // 合并而非全量替换:成员缓存只在成员列表接口维护,群个人设置以群列表接口为准 - const groupMap = new Map(this.groups.map((group) => [group.id, group])); - this.groups = fresh.map((group) => { - const existing = groupMap.get(group.id); - if (!existing) { - return { ...group, activeCallExpired: true, infoLoaded: true }; - } - return { - ...group, - infoLoaded: true, - activeCallExpired: existing.activeCallExpired, - activeCallLoaded: existing.activeCallLoaded, - members: existing.members, - memberCount: existing.memberCount ?? group.memberCount, - membersLoaded: existing.membersLoaded, - membersExpired: existing.membersExpired, - }; - }); - this.loaded = true; - const conversationStore = useConversationStore(); + return runResourceRequest( + ResourceRequestKey.GROUP_LIST, + async () => { + const db = await initDb() + const fresh = ((await apiGetMyGroupList()) || []).map((group) => + convertGroup(group, db.userId) + ) + const conversationIds = Array.from( + new Set( + [...this.groups, ...fresh].map((group) => + getClientConversationId(ImConversationType.GROUP, group.id) + ) + ) + ) + const committed = await enqueueConversationWrites(conversationIds, async () => { + const visibleGroups = fresh.filter( + (group) => + !isRelationTerminated(getClientConversationId(ImConversationType.GROUP, group.id)) + ) + const groupMap = new Map(this.groups.map((group) => [group.id, group])) + this.groups = visibleGroups.map((group) => { + const existing = groupMap.get(group.id) + if (!existing) { + return { ...group, activeCallExpired: true, infoLoaded: true } + } + return { + ...group, + infoLoaded: true, + activeCallExpired: existing.activeCallExpired, + activeCallLoaded: existing.activeCallLoaded, + members: existing.members, + memberCount: existing.memberCount ?? group.memberCount, + membersLoaded: existing.membersLoaded, + membersExpired: existing.membersExpired + } + }) + this.loaded = true + const conversationStore = useConversationStore() + for (const group of this.groups) { + conversationStore.updateConversation( + ImConversationType.GROUP, + group.id, + { + name: getGroupDisplayName(group), + avatar: group.avatar, + silent: group.silent + }, + db + ) + } + await this.saveGroupList([...this.groups], db).catch((e) => + console.warn('[IM groupStore] 本地群缓存写入失败', e) + ) + return this.groups + }) + this.preloadMembersForEmptyAvatarGroups(db) + return committed + }, + { mode: ResourceRequestMode.SINGLE_FLIGHT, refreshAfterPending: force } + ) + }, + + /** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */ + preloadMembersForEmptyAvatarGroups(db: DbClient = getDb()) { for (const group of this.groups) { - conversationStore.updateConversation( - ImConversationType.GROUP, - group.id, - { - name: getGroupDisplayName(group), - avatar: group.avatar, - silent: group.silent, - }, - ); + if ( + group.avatar || + group.joinStatus === CommonStatusEnum.DISABLE || + (group.membersLoaded && !group.membersExpired && group.members?.length) + ) { + continue + } + const force = !!group.membersLoaded && !group.membersExpired && !group.members?.length + this.fetchGroupMemberList(group.id, force, db).catch((error) => { + console.warn('[IM groupStore] 预加载群头像成员失败', { groupId: group.id }, error) + }) } - this.saveGroupList(); - this.preloadMembersForEmptyAvatarGroups(); }, /** 失效全部群详情缓存 */ markAllGroupInfoExpired() { for (const group of this.groups) { - group.infoLoaded = false; - } - }, - - /** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */ - preloadMembersForEmptyAvatarGroups() { - for (const group of this.groups) { - if ( - group.avatar || - group.joinStatus === CommonStatusEnum.DISABLE || - (group.membersLoaded && - !group.membersExpired && - group.members?.length) - ) { - continue; - } - const force = - !!group.membersLoaded && - !group.membersExpired && - !group.members?.length; - this.fetchGroupMemberList(group.id, force).catch((error) => { - console.warn( - '[IM groupStore] 预加载群头像成员失败', - { groupId: group.id }, - error, - ); - }); + group.infoLoaded = false } }, /** 失效全部群成员缓存 */ markAllGroupMembersExpired() { - this.groupMembersExpired = true; + this.groupMembersExpired = true for (const group of this.groups) { if (group.membersLoaded) { - group.membersExpired = true; + group.membersExpired = true } } }, @@ -330,149 +389,203 @@ export const useGroupStore = defineStore('imGroupStore', { /** 失效全部群通话探测缓存 */ markAllGroupActiveCallsExpired() { for (const group of this.groups) { - group.activeCallExpired = true; + group.activeCallExpired = true } }, /** 标记群通话探测已加载 */ markGroupActiveCallLoaded(groupId: number) { - const group = this.getGroup(groupId); + const group = this.getGroup(groupId) if (!group) { - return; + return } - group.activeCallLoaded = true; - group.activeCallExpired = false; + group.activeCallLoaded = true + group.activeCallExpired = false }, /** 判断群通话是否需要重新探测 */ isGroupActiveCallExpired(groupId: number): boolean { - const group = this.getGroup(groupId); - return !group?.activeCallLoaded || !!group.activeCallExpired; + const group = this.getGroup(groupId) + return !group?.activeCallLoaded || !!group.activeCallExpired }, /** 失效指定群成员缓存 */ markGroupMembersExpired(groupId: number) { - const group = this.getGroup(groupId); + const group = this.getGroup(groupId) if (group?.membersLoaded) { - group.membersExpired = true; + group.membersExpired = true } }, /** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */ - async fetchGroupInfo(groupId: number, force = false) { - const cached = this.getGroup(groupId); + fetchGroupInfo( + groupId: number, + force = false, + db: DbClient = getDb() + ): Promise { + const relationVersion = ensureGroupRelationVersion(groupId) + const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId) + if (isRelationTerminated(clientConversationId)) { + return Promise.resolve(undefined) + } + const cached = this.getGroup(groupId) if (cached?.infoLoaded && !force) { - return; + return Promise.resolve(cached) } - try { - const data = await apiGetGroup(groupId); - if (!data) { - return; + const key = `${groupId}:${relationVersion}` + const pending = pendingGroupInfoFetches.get(key) + if (pending) { + if (force) { + queuedGroupInfoRefreshes.add(key) } - this.upsertGroup({ ...convertGroup(data), infoLoaded: true }); - } catch (error) { - console.warn('[IM groupStore] fetchGroupInfo 失败', error); + return pending } + const isRelationCurrent = () => + groupRelationVersions.get(groupId) === relationVersion && + !isRelationTerminated(clientConversationId) + const promise = (async () => { + try { + const data = await apiGetGroup(groupId) + if (!data) { + return undefined + } + return await enqueueConversationWrite(clientConversationId, async () => { + if (!isRelationCurrent()) { + return undefined + } + await this.upsertGroupAndSave( + { ...convertGroup(data, db.userId), infoLoaded: true }, + db + ) + return this.getGroup(groupId) + }) + } catch (e) { + console.warn('[IM groupStore] fetchGroupInfo 失败', e) + return undefined + } + })().finally(() => { + if (pendingGroupInfoFetches.get(key) !== promise) { + return + } + const shouldRefresh = queuedGroupInfoRefreshes.has(key) && isRelationCurrent() + pendingGroupInfoFetches.delete(key) + queuedGroupInfoRefreshes.delete(key) + if (shouldRefresh) { + void this.fetchGroupInfo(groupId, true, db) + } + }) + pendingGroupInfoFetches.set(key, promise) + return promise }, /** 按群拉取成员(in-memory 缓存 + 并发去重,force=true 强刷)+ 落 IDB */ fetchGroupMemberList( groupId: number, force = false, + db: DbClient = getDb() ): Promise { // in-memory "完整"加载过才命中——单成员补齐写入的 partial members 不在此返回(membersLoaded=false) - const cached = this.getGroup(groupId); - if ( - cached && - cached.members && - cached.membersLoaded && - !cached.membersExpired && - !force - ) { - return Promise.resolve(cached.members); + const cached = this.getGroup(groupId) + if (cached && cached.members && cached.membersLoaded && !cached.membersExpired && !force) { + return Promise.resolve(cached.members) } - // 未登录:不发起请求也不登记 in-flight,避免污染单飞表 - const requestUserId = getCurrentUserId(); - if (!requestUserId) { - return Promise.resolve([]); - } - const requestEpoch = storeEpoch; - // 同 (userId, groupId) 已经有正在飞的请求:直接复用,避免重复打接口 - const key = pendingMemberKey(requestUserId, groupId); - const inflight = pendingMemberFetches.get(key); + const relationVersion = ensureGroupRelationVersion(groupId) + const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId) + const key = pendingMemberKey(groupId, relationVersion) + const inflight = pendingMemberFetches.get(key) if (inflight) { - return inflight; + if (force) { + queuedMemberRefreshes.add(key) + } + return inflight } const promise = (async () => { + const client = db + const userId = db.userId // 拉接口 + 单 pass 转换:同时捕获 me 的原始 VO,给下面回填 user-per-group 字段(silent / groupRemark)用 - const list = await apiGetGroupMemberList(groupId); - if ( - requestEpoch !== storeEpoch || - getCurrentUserId() !== requestUserId - ) { - return []; - } - let meRaw: ImGroupMemberApi.GroupMemberRespVO | undefined; + const list = await apiGetGroupMemberList(groupId) + let meRaw: ImGroupMemberRespVO | undefined const members = (list || []).map((member) => { - if (member.userId === requestUserId) { - meRaw = member; + if (member.userId === userId) { + meRaw = member } - return convertGroupMember(member, groupId); - }); - const silent = !!meRaw?.silent; - const groupRemark = meRaw?.groupRemark || ''; + return convertGroupMember(member, groupId) + }) + const silent = !!meRaw?.silent + const groupRemark = meRaw?.groupRemark || '' - // 必须 await 之后重新 getGroup,避免 fetchGroupList 已并发写入真实 group 的 race - const group = this.getGroup(groupId); - const isPlaceholder = !group; - let groupFieldsChanged = false; - if (group) { - group.members = members; - group.memberCount = members.length; - group.membersLoaded = true; - group.membersExpired = false; - // silent / groupRemark 任一变化才同步到 conversation 和 IDB;groupRemark 变化要顺带刷会话名 - if (group.silent !== silent || group.groupRemark !== groupRemark) { - group.silent = silent; - group.groupRemark = groupRemark; - groupFieldsChanged = true; - const conversationStore = useConversationStore(); - conversationStore.updateConversation( - ImConversationType.GROUP, - groupId, - { - name: getGroupDisplayName(group), - silent, - }, - ); + return await enqueueConversationWrite(clientConversationId, async () => { + if ( + groupRelationVersions.get(groupId) !== relationVersion || + isRelationTerminated(clientConversationId) + ) { + return [] + } + // 必须进入 lane 后重新 getGroup,避免 fetchGroupList 已并发写入真实 group 的 race + const group = this.getGroup(groupId) + const isPlaceholder = !group + let groupFieldsChanged = false + if (!group) { + // group 还没就位:仅 in-memory push 占位(name='' 表示未知),不调 upsertGroup——避免把假名灌进 conversation.name + groups IDB 桶; + // 后续,等 fetchGroupList 浅合并时,被真名覆盖 + this.groups.push({ + id: groupId, + name: '', + members, + memberCount: members.length, + silent, + groupRemark, + membersLoaded: true, + membersExpired: false + }) + } else { + group.members = members + group.memberCount = members.length + group.membersLoaded = true + group.membersExpired = false + // silent / groupRemark 任一变化才同步到 conversation 和 IDB;groupRemark 变化要顺带刷会话名 + if (group.silent !== silent || group.groupRemark !== groupRemark) { + group.silent = silent + group.groupRemark = groupRemark + groupFieldsChanged = true + const conversationStore = useConversationStore() + conversationStore.updateConversation( + ImConversationType.GROUP, + groupId, + { + name: getGroupDisplayName(group), + silent + }, + client + ) + } } - } else { - // group 还没就位:仅 in-memory push 占位(name='' 表示未知),不调 upsertGroup——避免把假名灌进 conversation.name + groups IDB 桶; - // 后续,等 fetchGroupList 浅合并时,被真名覆盖 - this.groups.push({ - id: groupId, - name: '', - members, - memberCount: members.length, - silent, - groupRemark, - membersLoaded: true, - membersExpired: false, - }); - } - // groups 桶仅在 user-per-group 字段实际变化时写——避免一次批量进群引发多次整桶重写 - this.saveGroupMemberList(groupId); - if (!isPlaceholder && groupFieldsChanged) { - this.saveGroup(group); - } - return members; + const records = JSON.parse(JSON.stringify(members)) as GroupMemberDO[] + await this.saveGroupMemberListRecord(groupId, records, client) + if (!isPlaceholder && groupFieldsChanged) { + await this.saveGroupRecord(group, client) + } + return members + }) + })().finally(() => { // 无论成功 / 失败都要从单飞表清掉,否则后续同 group 请求永远拿到这个 stale Promise - })().finally(() => pendingMemberFetches.delete(key)); + if (pendingMemberFetches.get(key) === promise) { + const shouldRefresh = + queuedMemberRefreshes.has(key) && + groupRelationVersions.get(groupId) === relationVersion && + !isRelationTerminated(clientConversationId) + pendingMemberFetches.delete(key) + queuedMemberRefreshes.delete(key) + if (shouldRefresh) { + void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined) + } + } + }) - // 把 Promise 登记进单飞表,让此后短时间内的同 (userId, groupId) 请求复用 - pendingMemberFetches.set(key, promise); - return promise; + // 把 Promise 登记进单飞表,让同一群关系代际的请求复用 + pendingMemberFetches.set(key, promise) + return promise }, /** @@ -481,125 +594,182 @@ export const useGroupStore = defineStore('imGroupStore', { * 跟 fetchGroupMemberList 区别:只拉这一个成员,不动 me 的 silent / groupRemark(不是 me 的话拿不到); * 命中时把成员 upsert 进 group.members 数组并落 IDB,让后续渲染能用 displayUserName */ - fetchGroupMember( - groupId: number, - memberUserId: number, - ): Promise { + fetchGroupMember(groupId: number, memberUserId: number): Promise { // in-memory 命中直接返回,不打接口 - const cached = this.getGroup(groupId)?.members?.find( - (m) => m.userId === memberUserId, - ); + const cached = this.getGroup(groupId)?.members?.find((m) => m.userId === memberUserId) if (cached) { - return Promise.resolve(cached); + return Promise.resolve(cached) } - // 未登录:不发起请求也不登记 in-flight,避免污染单飞表 - const requestUserId = getCurrentUserId(); - if (!requestUserId) { - return Promise.resolve(null); - } - const requestEpoch = storeEpoch; - // 同 (userId, groupId, memberUserId) 已经有正在飞的请求:直接复用 - const key = pendingSingleMemberKey(requestUserId, groupId, memberUserId); - const inflight = pendingSingleMemberFetches.get(key); + const relationVersion = ensureGroupRelationVersion(groupId) + const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId) + const key = pendingSingleMemberKey(groupId, relationVersion, memberUserId) + const inflight = pendingSingleMemberFetches.get(key) if (inflight) { - return inflight; + return inflight } const promise = (async () => { - const data = await apiGetGroupMember(groupId, memberUserId); + const data = await apiGetGroupMember(groupId, memberUserId) if (!data) { - return null; + return null } - if ( - requestEpoch !== storeEpoch || - getCurrentUserId() !== requestUserId - ) { - return null; - } - const member = convertGroupMember(data, groupId); - // 把这一条 upsert 进 group.members 仅供 in-memory 渲染兜底;group 还没就位则用 placeholder - // 注意:不写 IDB——成员桶语义是"全量",存"1 人桶"会污染下次冷启动的 loadGroupMemberList - const group = this.getGroup(groupId); - if (group) { - const memberList = group.members ?? []; - const index = memberList.findIndex((m) => m.userId === memberUserId); - if (index === -1) { - memberList.push(member); - } else { - memberList[index] = member; + const member = convertGroupMember(data, groupId) + + return await enqueueConversationWrite(clientConversationId, async () => { + if ( + groupRelationVersions.get(groupId) !== relationVersion || + isRelationTerminated(clientConversationId) + ) { + return null } - group.members = memberList; - } else { - // memberCount 不设:后续 fetchGroupList 合并 `existing.memberCount ?? fresh.memberCount` 时, - // 占位值会顶替真实值(fresh 不带 memberCount),等 fetchGroupMemberList 跑过才能拿到真实数 - this.groups.push({ - id: groupId, - name: '', - members: [member], - }); + // 把这一条 upsert 进 group.members 仅供 in-memory 渲染兜底;group 还没就位则用 placeholder + // 注意:不写 IDB——成员桶语义是"全量",存"1 人桶"会污染下次冷启动的 loadGroupMemberList + const group = this.getGroup(groupId) + if (!group) { + // memberCount 不设:后续 fetchGroupList 合并 `existing.memberCount ?? fresh.memberCount` 时, + // 占位值会顶替真实值(fresh 不带 memberCount),等 fetchGroupMemberList 跑过才能拿到真实数 + this.groups.push({ + id: groupId, + name: '', + members: [member] + }) + } else { + const memberList = group.members ?? [] + const index = memberList.findIndex((m) => m.userId === memberUserId) + if (index >= 0) { + memberList[index] = member + } else { + memberList.push(member) + } + group.members = memberList + } + return member + }) + })().finally(() => { + if (pendingSingleMemberFetches.get(key) === promise) { + pendingSingleMemberFetches.delete(key) } - return member; - })().finally(() => pendingSingleMemberFetches.delete(key)); - pendingSingleMemberFetches.set(key, promise); - return promise; + }) + pendingSingleMemberFetches.set(key, promise) + return promise }, /** 按 id 插入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把展示名 / 头像 / 免打扰推到对应会话 */ - upsertGroup(group: Group) { - void this.upsertGroupAndSave(group).catch((error) => - console.warn('[IM groupStore] 本地群写入失败', error), - ); + upsertGroup(group: Group, db: DbClient = getDb()) { + const clientConversationId = getClientConversationId(ImConversationType.GROUP, group.id) + void enqueueConversationWrite(clientConversationId, () => + this.upsertGroupAndSave(group, db) + ).catch((e) => console.warn('[IM groupStore] 本地群写入失败', e)) }, /** 按 id 插入或合并群 */ - async upsertGroupAndSave(group: Group): Promise { - const index = this.groups.findIndex((g) => g.id === group.id); - if (index === -1) { - this.groups.push(group); + async upsertGroupAndSave(group: Group, db: DbClient = getDb()): Promise { + const clientConversationId = getClientConversationId(ImConversationType.GROUP, group.id) + if (isRelationTerminated(clientConversationId)) { + return + } + const index = this.groups.findIndex((g) => g.id === group.id) + if (index >= 0) { + this.groups[index] = { ...this.groups[index], ...group } } else { - this.groups[index] = { ...this.groups[index], ...group }; + this.groups.push(group) } // 同步推到 conversation:群名 / 头像 / 免打扰是会话列表展示用的,必须紧随 group 变更 - const merged = this.getGroup(group.id) ?? group; - const conversationStore = useConversationStore(); - conversationStore.updateConversation(ImConversationType.GROUP, group.id, { - name: getGroupDisplayName(merged), - avatar: merged.avatar, - silent: merged.silent, - }); + const merged = this.getGroup(group.id) ?? group + const conversationStore = useConversationStore() + conversationStore.updateConversation( + ImConversationType.GROUP, + group.id, + { + name: getGroupDisplayName(merged), + avatar: merged.avatar, + silent: merged.silent + }, + db + ) // 持久化到 IDB(fire-and-forget) - await this.saveGroupRecord(merged); + await this.saveGroupRecord(merged, db) }, /** 本地移除群缓存和群会话;群解散(GROUP_DEL)、退群、被踢都复用 */ - removeGroup(id: number) { - // 本地硬删(区别于好友删除的软删保留记录);级联清群聊会话避免列表里留死群 - this.groups = this.groups.filter((g) => g.id !== id); - const conversationStore = useConversationStore(); - conversationStore.removeGroupConversation(id); - void getDb() - .transaction(['groups', 'groupMembers'], 'readwrite', async (tx) => { - const db = getDb(); - await db.delete('groups', id, tx); - await db.deleteByIndex('groupMembers', 'groupId', id, tx); - }) - .catch((error) => - console.warn(`[IM groupStore] 群缓存删除失败 (groupId=${id})`, error), - ); + removeGroup( + id: number, + expectedRelationVersion = ensureGroupRelationVersion(id), + db: DbClient = getDb() + ) { + const clientConversationId = getClientConversationId(ImConversationType.GROUP, id) + return enqueueConversationWrite(clientConversationId, async () => { + if (groupRelationVersions.get(id) !== expectedRelationVersion) { + return + } + await this.removeGroupNow(id, undefined, db) + }) + }, + + /** 退出群聊并清理本地数据 */ + async quitGroup(id: number): Promise { + const relationVersion = ensureGroupRelationVersion(id) + const db = getDb() + await apiQuitGroup(id) + await this.removeGroup(id, relationVersion, db) + }, + + /** 解散群聊并清理本地数据 */ + async dissolveGroup(id: number): Promise { + const relationVersion = ensureGroupRelationVersion(id) + const db = getDb() + await apiDissolveGroup(id) + await this.removeGroup(id, relationVersion, db) + }, + + /** 实际移除群关系;调用方必须持有群会话写 lane */ + async removeGroupNow(id: number, messageId: number | undefined, db: DbClient): Promise { + const clientConversationId = getClientConversationId(ImConversationType.GROUP, id) + const conversationStore = useConversationStore() + if (!markRelationTerminated(clientConversationId, messageId)) { + return + } + groupRelationVersions.set(id, ++groupRelationVersionSequence) + const currentGroups = this.groups + const currentConversation = conversationStore.getConversation(ImConversationType.GROUP, id) + // 群、成员和会话终态必须一次提交,避免重启后只恢复其中一侧 + const hiddenConversation = await db.transaction( + ['groups', 'groupMembers', 'conversations'], + 'readwrite', + async (tx) => { + await db.delete('groups', id, tx) + await db.deleteByIndex('groupMembers', 'groupId', id, tx) + return conversationStore.saveHiddenConversationRecord( + ImConversationType.GROUP, + id, + tx, + db + ) + } + ) + // 事务期间若新 Store 投影已接管,则只保留已提交的旧 DB 终态 + if (this.groups === currentGroups) { + this.groups = currentGroups.filter((group) => group.id !== id) + } + if ( + hiddenConversation && + conversationStore.getConversation(ImConversationType.GROUP, id) === currentConversation + ) { + conversationStore.publishHiddenConversationProjection(hiddenConversation) + } }, /** 切换免打扰:推后端 + 落本地 + 同步会话列表的 silent,避免 silent 图标 / 总未读 / 提示音判断与设置漂移;和 friendStore.setFriendSilent 对齐 */ async setGroupSilent(id: number, silent: boolean) { - await apiUpdateGroupMember({ groupId: id, silent }); - const group = this.getGroup(id); + const db = await initDb() + await apiUpdateGroupMember({ groupId: id, silent }) + const group = this.getGroup(id) if (!group) { - return; + return } - group.silent = silent; - const conversationStore = useConversationStore(); - conversationStore.updateConversation(ImConversationType.GROUP, id, { - silent, - }); - this.saveGroup(group); + group.silent = silent + const conversationStore = useConversationStore() + conversationStore.updateConversation(ImConversationType.GROUP, id, { silent }, db) + this.saveGroup(group, db) }, /** 批量更新群成员角色;本地不命中则忽略,等 fetchGroupMemberList 兜底 */ @@ -607,25 +777,26 @@ export const useGroupStore = defineStore('imGroupStore', { groupId: number, userIds: number[], role: number, + db: DbClient = getDb() ) { - const group = this.getGroup(groupId); + const group = this.getGroup(groupId) if (!group?.members?.length) { - return; + return } // 命中目标且角色已变化才标记 changed,避免无变化时整数组重建触发响应式 - const idSet = new Set(userIds); - let changed = false; + const idSet = new Set(userIds) + let changed = false const newMembers = group.members.map((member) => { if (!idSet.has(member.userId) || member.role === role) { - return member; + return member } - changed = true; - return { ...member, role }; - }); + changed = true + return { ...member, role } + }) // 有变化才整组替换,让响应式只在真有更新时通知下游 if (changed) { - group.members = newMembers; - this.saveGroupMemberList(groupId); + group.members = newMembers + this.saveGroupMemberList(groupId, db) } }, @@ -634,52 +805,34 @@ export const useGroupStore = defineStore('imGroupStore', { groupId: number, oldOwnerId: number, newOwnerId: number, + db: DbClient = getDb() ) { - const group = this.getGroup(groupId); + const group = this.getGroup(groupId) if (!group) { - return; + return } if (group.ownerUserId !== newOwnerId) { - group.ownerUserId = newOwnerId; + group.ownerUserId = newOwnerId } - this.updateGroupMemberRoleList( - groupId, - [oldOwnerId], - ImGroupMemberRole.NORMAL, - ); - this.updateGroupMemberRoleList( - groupId, - [newOwnerId], - ImGroupMemberRole.OWNER, - ); - this.saveGroup(group); + this.updateGroupMemberRoleList(groupId, [oldOwnerId], ImGroupMemberRole.NORMAL, db) + this.updateGroupMemberRoleList(groupId, [newOwnerId], ImGroupMemberRole.OWNER, db) + this.saveGroup(group, db) }, /** 本地剔除群成员(GROUP_MEMBER_QUIT / KICK 事件);不命中则等 fetchGroupMemberList 兜底 */ - removeLocalGroupMemberList(groupId: number, userIds: number[]) { - const group = this.getGroup(groupId); - if (!group?.members?.length || userIds.length === 0) { - return; + removeLocalGroupMemberList(groupId: number, userIds: number[], db: DbClient = getDb()) { + const group = this.getGroup(groupId) + if (!group?.members?.length || !userIds.length) { + return } - const idSet = new Set(userIds); - const next = group.members.filter((member) => !idSet.has(member.userId)); + const idSet = new Set(userIds) + const next = group.members.filter((member) => !idSet.has(member.userId)) if (next.length === group.members.length) { - return; + return } - group.members = next; - group.memberCount = next.length; - this.saveGroupMemberList(groupId); - }, - - /** 本地更新群成员的 status(自己退群 / 被踢的本地预置;让 isMember 立即收敛到 stranger,不依赖 removeGroup 的整群移除) */ - updateGroupMemberStatus(groupId: number, userId: number, status: number) { - const group = this.getGroup(groupId); - const member = group?.members?.find((m) => m.userId === userId); - if (!member || member.status === status) { - return; - } - member.status = status; - this.saveGroupMemberList(groupId); + group.members = next + group.memberCount = next.length + this.saveGroupMemberList(groupId, db) }, /** 本地更新群成员的 displayUserName(GROUP_MEMBER_NICKNAME_UPDATE 事件);不命中则等 fetchGroupMemberList 兜底 */ @@ -687,36 +840,40 @@ export const useGroupStore = defineStore('imGroupStore', { groupId: number, userId: number, displayUserName: string, + db: DbClient = getDb() ) { - const group = this.getGroup(groupId); - const member = group?.members?.find((m) => m.userId === userId); + const group = this.getGroup(groupId) + const member = group?.members?.find((m) => m.userId === userId) if (!member || member.displayUserName === displayUserName) { - return; + return } - member.displayUserName = displayUserName; - this.saveGroupMemberList(groupId); + member.displayUserName = displayUserName + this.saveGroupMemberList(groupId, db) }, /** 局部更新群字段(name / notice / avatar 等);未命中本地缓存时静默忽略,等 fetchGroupList 兜底;新值跟旧值都相同时跳过响应式 + IDB 写 */ - updateGroupFields(groupId: number, fields: Partial) { - const group = this.getGroup(groupId); + updateGroupFields(groupId: number, fields: Partial, db: DbClient = getDb()) { + const group = this.getGroup(groupId) if (!group) { - return; + return } - const changed = (Object.keys(fields) as (keyof Group)[]).some( - (k) => group[k] !== fields[k], - ); + const changed = (Object.keys(fields) as (keyof Group)[]).some((k) => group[k] !== fields[k]) if (!changed) { - return; + return } - Object.assign(group, fields); - const conversationStore = useConversationStore(); - conversationStore.updateConversation(ImConversationType.GROUP, groupId, { - name: getGroupDisplayName(group), - avatar: group.avatar, - silent: group.silent, - }); - this.saveGroup(group); + Object.assign(group, fields) + const conversationStore = useConversationStore() + conversationStore.updateConversation( + ImConversationType.GROUP, + groupId, + { + name: getGroupDisplayName(group), + avatar: group.avatar, + silent: group.silent + }, + db + ) + this.saveGroup(group, db) }, /** @@ -725,146 +882,174 @@ export const useGroupStore = defineStore('imGroupStore', { * WebSocket 实时收走 messageStore.insertMessage 旁路调用 * store 里没缓存的群静默忽略,等 fetchGroupList 兜底 */ - applyGroupNotification(groupId: number, type: number, content?: string) { + async applyGroupNotification( + groupId: number, + type: number, + content?: string, + messageId?: number, + db: DbClient = getDb() + ) { if (!groupId) { - return; + return } - let payload: GroupNotificationPayload; + await enqueueConversationWrite( + getClientConversationId(ImConversationType.GROUP, groupId), + () => this.applyGroupNotificationNow(groupId, type, content, messageId, db) + ) + }, + + /** 实际应用群广播;调用方必须持有群会话写 lane */ + async applyGroupNotificationNow( + groupId: number, + type: number, + content: string | undefined, + messageId: number | undefined, + db: DbClient + ) { + let payload: GroupNotificationPayload try { - payload = content ? JSON.parse(content) : {}; + payload = content ? JSON.parse(content) : {} } catch (error) { console.warn( '[IM groupStore] applyGroupNotification 解析 content 失败', { groupId, type, contentLength: content?.length ?? 0 }, - error, - ); - return; + error + ) + return } + queueGroupListRefreshAfterPending(() => this.fetchGroupList(true)) switch (type) { - case ImContentType.GROUP_ADMIN_ADD: { + case ImContentType.GROUP_ADMIN_ADD: this.updateGroupMemberRoleList( groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN, - ); - this.markGroupMembersExpired(groupId); + db + ) + this.markGroupMembersExpired(groupId) // 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList - if (isSelfInPayloadMembers(payload)) { - refreshUnhandledGroupRequests(); + if (isSelfInPayloadMembers(payload, db.userId)) { + refreshUnhandledGroupRequests() } - break; - } - case ImContentType.GROUP_ADMIN_REMOVE: { + break + case ImContentType.GROUP_ADMIN_REMOVE: this.updateGroupMemberRoleList( groupId, payload.memberUserIds || [], ImGroupMemberRole.NORMAL, - ); - this.markGroupMembersExpired(groupId); - if (isSelfInPayloadMembers(payload)) { - refreshUnhandledGroupRequests(); + db + ) + this.markGroupMembersExpired(groupId) + if (isSelfInPayloadMembers(payload, db.userId)) { + refreshUnhandledGroupRequests() } - break; - } - case ImContentType.GROUP_BANNED: { - this.updateGroupFields(groupId, { banned: !!payload.banned }); - break; - } - case ImContentType.GROUP_CANCEL_MUTED: { - this.updateGroupFields(groupId, { mutedAll: false }); - break; - } - case ImContentType.GROUP_CREATE: { - this.applyGroupCreateNotification(groupId, payload); - break; - } - case ImContentType.GROUP_DISSOLVE: { - this.removeGroup(groupId); - break; - } - case ImContentType.GROUP_INFO_UPDATE: { - this.applyGroupInfoUpdateNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MEMBER_CANCEL_MUTED: { - this.applyGroupMemberCancelMutedNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MEMBER_ENTER: { - this.applyGroupMemberEnterNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MEMBER_INVITE: { - this.applyGroupMemberInviteNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MEMBER_KICK: { - this.applyGroupMemberKickNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MEMBER_MUTED: { - this.applyGroupMemberMutedNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: { - this.applyGroupMemberNicknameUpdateNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MEMBER_QUIT: { - this.applyGroupMemberQuitNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MESSAGE_PIN: { - this.applyGroupMessagePinNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MESSAGE_UNPIN: { - this.applyGroupMessageUnpinNotification(groupId, payload); - break; - } - case ImContentType.GROUP_MUTED: { - this.updateGroupFields(groupId, { mutedAll: true }); - break; - } - case ImContentType.GROUP_NAME_UPDATE: { - this.applyGroupNameUpdateNotification(groupId, payload); - break; - } - case ImContentType.GROUP_NOTICE_UPDATE: { - this.applyGroupNoticeUpdateNotification(groupId, payload); - break; - } - case ImContentType.GROUP_OWNER_TRANSFER: { - this.applyGroupOwnerTransferNotification(groupId, payload); - break; - } + break + case ImContentType.GROUP_BANNED: + this.updateGroupFields(groupId, { banned: !!payload.banned }, db) + break + case ImContentType.GROUP_CANCEL_MUTED: + this.updateGroupFields(groupId, { mutedAll: false }, db) + break + case ImContentType.GROUP_CREATE: + await this.applyGroupCreateNotificationNow(groupId, payload, messageId, db) + break + case ImContentType.GROUP_DISSOLVE: + await this.removeGroupNow(groupId, messageId, db) + break + case ImContentType.GROUP_INFO_UPDATE: + this.applyGroupInfoUpdateNotification(groupId, payload, db) + break + case ImContentType.GROUP_MEMBER_CANCEL_MUTED: + this.applyGroupMemberCancelMutedNotification(groupId, payload, db) + break + case ImContentType.GROUP_MEMBER_ENTER: + await this.applyGroupMemberEnterNotificationNow(groupId, payload, messageId, db) + break + case ImContentType.GROUP_MEMBER_INVITE: + await this.applyGroupMemberInviteNotificationNow(groupId, payload, messageId, db) + break + case ImContentType.GROUP_MEMBER_KICK: + await this.applyGroupMemberKickNotificationNow(groupId, payload, messageId, db) + break + case ImContentType.GROUP_MEMBER_MUTED: + this.applyGroupMemberMutedNotification(groupId, payload, db) + break + case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: + this.applyGroupMemberNicknameUpdateNotification(groupId, payload, db) + break + case ImContentType.GROUP_MEMBER_QUIT: + await this.applyGroupMemberQuitNotificationNow(groupId, payload, messageId, db) + break + case ImContentType.GROUP_MESSAGE_PIN: + this.applyGroupMessagePinNotification(groupId, payload, db) + break + case ImContentType.GROUP_MESSAGE_UNPIN: + this.applyGroupMessageUnpinNotification(groupId, payload, db) + break + case ImContentType.GROUP_MUTED: + this.updateGroupFields(groupId, { mutedAll: true }, db) + break + case ImContentType.GROUP_NAME_UPDATE: + this.applyGroupNameUpdateNotification(groupId, payload, db) + break + case ImContentType.GROUP_NOTICE_UPDATE: + this.applyGroupNoticeUpdateNotification(groupId, payload, db) + break + case ImContentType.GROUP_OWNER_TRANSFER: + this.applyGroupOwnerTransferNotification(groupId, payload, db.userId, db) + break } }, + /** 实际恢复群关系;调用方必须持有群会话写 lane */ + async reopenGroupRelationNow( + groupId: number, + messageId: number | undefined, + db: DbClient + ): Promise { + const clientConversationId = getClientConversationId(ImConversationType.GROUP, groupId) + if (!reopenRelation(clientConversationId, messageId)) { + return false + } + groupRelationVersions.set(groupId, ++groupRelationVersionSequence) + const conversation = useConversationStore().getConversation(ImConversationType.GROUP, groupId) + if (!conversation?.deleted) { + return true + } + const nextConversation = { ...conversation, deleted: false } + await useConversationStore().saveConversationRecord(nextConversation, undefined, db) + useConversationStore().publishConversationProjection(nextConversation, true) + return true + }, + /** 创建群广播:群未就位时拉群详情 */ - async applyGroupCreateNotification( + async applyGroupCreateNotificationNow( groupId: number, payload: GroupNotificationPayload, + messageId: number | undefined, + db: DbClient = getDb() ) { - if (!isSelfInPayloadMembers(payload)) { - return; + if (!isSelfInPayloadMembers(payload, db.userId)) { + return } - const selfUserId = getCurrentUserId(); - const selfIsOperator = - !!selfUserId && payload.operatorUserId === selfUserId; + if (!(await this.reopenGroupRelationNow(groupId, messageId, db))) { + return + } + const selfIsOperator = payload.operatorUserId === db.userId if (selfIsOperator && this.getGroup(groupId)) { - return; + return } - await this.fetchGroupInfo(groupId, true); + void this.fetchGroupInfo(groupId, true, db) }, /** 群名变更:按 newName 局部更新本地群名 */ applyGroupNameUpdateNotification( groupId: number, payload: GroupNotificationPayload, + db: DbClient = getDb() ) { if (payload.newName) { - this.updateGroupFields(groupId, { name: payload.newName }); + this.updateGroupFields(groupId, { name: payload.newName }, db) } }, @@ -872,96 +1057,113 @@ export const useGroupStore = defineStore('imGroupStore', { applyGroupNoticeUpdateNotification( groupId: number, payload: GroupNotificationPayload, + db: DbClient = getDb() ) { - this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' }); + this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' }, db) }, /** 群信息变更:同步头像、进群审批 */ applyGroupInfoUpdateNotification( groupId: number, payload: GroupNotificationPayload, + db: DbClient = getDb() ) { - const fields: Partial = {}; + const fields: Partial = {} if (payload.newAvatar) { - fields.avatar = payload.newAvatar; + fields.avatar = payload.newAvatar } - if (payload.newJoinApproval !== null) { - fields.joinApproval = payload.newJoinApproval; + if (payload.newJoinApproval != null) { + fields.joinApproval = payload.newJoinApproval } if (Object.keys(fields).length > 0) { - this.updateGroupFields(groupId, fields); + this.updateGroupFields(groupId, fields, db) } }, /** 成员加入:被邀请者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表(新成员 nickname / avatar 不在 payload) */ - async applyGroupMemberInviteNotification( + async applyGroupMemberInviteNotificationNow( groupId: number, payload: GroupNotificationPayload, + messageId: number | undefined, + db: DbClient = getDb() ) { - // 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空 - if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) { - await this.fetchGroupInfo(groupId, true); + const selfJoined = isSelfInPayloadMembers(payload, db.userId) + if (selfJoined) { + if (!(await this.reopenGroupRelationNow(groupId, messageId, db))) { + return + } } - this.markGroupMembersExpired(groupId); - this.fetchGroupMemberList(groupId, true).catch(() => undefined); + // 当前 lane 结束后再补群详情,避免 fetchGroupInfo 重入同一会话 lane + if (selfJoined && !this.getGroup(groupId)) { + void this.fetchGroupInfo(groupId, true, db).then((group) => { + if (!group) { + return + } + this.markGroupMembersExpired(groupId) + void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined) + }) + return + } + this.markGroupMembersExpired(groupId) + void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined) }, /** 自由进群:进群者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表 */ - async applyGroupMemberEnterNotification( + async applyGroupMemberEnterNotificationNow( groupId: number, payload: GroupNotificationPayload, + messageId: number | undefined, + db: DbClient = getDb() ) { - const selfUserId = getCurrentUserId(); - // 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空 - if ( - selfUserId && - payload.entrantUserId === selfUserId && - !this.getGroup(groupId) - ) { - await this.fetchGroupInfo(groupId, true); + const selfJoined = payload.entrantUserId === db.userId + if (selfJoined) { + if (!(await this.reopenGroupRelationNow(groupId, messageId, db))) { + return + } } - this.markGroupMembersExpired(groupId); - this.fetchGroupMemberList(groupId, true).catch(() => undefined); + // 当前 lane 结束后再补群详情,避免 fetchGroupInfo 重入同一会话 lane + if (selfJoined && !this.getGroup(groupId)) { + void this.fetchGroupInfo(groupId, true, db).then((group) => { + if (!group) { + return + } + this.markGroupMembersExpired(groupId) + void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined) + }) + return + } + this.markGroupMembersExpired(groupId) + void this.fetchGroupMemberList(groupId, true, db).catch(() => undefined) }, /** 成员退群:退群者本人先把 self.status 置 DISABLE 再 removeGroup(保留状态语义 + 维持 groups 列表干净);其他成员从本地列表移除 quitter */ - applyGroupMemberQuitNotification( + async applyGroupMemberQuitNotificationNow( groupId: number, payload: GroupNotificationPayload, + messageId: number | undefined, + db: DbClient = getDb() ) { - const selfUserId = getCurrentUserId(); - if (selfUserId && payload.operatorUserId === selfUserId) { - this.updateGroupMemberStatus( - groupId, - selfUserId, - CommonStatusEnum.DISABLE, - ); - this.removeGroup(groupId); + if (payload.operatorUserId === db.userId) { + await this.removeGroupNow(groupId, messageId, db) } else if (payload.operatorUserId) { - this.removeLocalGroupMemberList(groupId, [payload.operatorUserId]); - this.markGroupMembersExpired(groupId); + this.removeLocalGroupMemberList(groupId, [payload.operatorUserId], db) + this.markGroupMembersExpired(groupId) } }, /** 成员被移出:被踢者本人先把 self.status 置 DISABLE 再 removeGroup;其他成员从本地列表移除被踢者 */ - applyGroupMemberKickNotification( + async applyGroupMemberKickNotificationNow( groupId: number, payload: GroupNotificationPayload, + messageId: number | undefined, + db: DbClient = getDb() ) { - const memberIds = payload.memberUserIds || []; - const selfUserId = getCurrentUserId(); - if (isSelfInPayloadMembers(payload)) { - if (selfUserId) { - this.updateGroupMemberStatus( - groupId, - selfUserId, - CommonStatusEnum.DISABLE, - ); - } - this.removeGroup(groupId); - } else if (memberIds.length > 0) { - this.removeLocalGroupMemberList(groupId, memberIds); - this.markGroupMembersExpired(groupId); + const memberIds = payload.memberUserIds || [] + if (isSelfInPayloadMembers(payload, db.userId)) { + await this.removeGroupNow(groupId, messageId, db) + } else if (memberIds.length) { + this.removeLocalGroupMemberList(groupId, memberIds, db) + this.markGroupMembersExpired(groupId) } }, @@ -969,14 +1171,16 @@ export const useGroupStore = defineStore('imGroupStore', { applyGroupMemberNicknameUpdateNotification( groupId: number, payload: GroupNotificationPayload, + db: DbClient = getDb() ) { if (payload.operatorUserId) { this.updateGroupMemberDisplayUserName( groupId, payload.operatorUserId, payload.displayUserName ?? '', - ); - this.markGroupMembersExpired(groupId); + db + ) + this.markGroupMembersExpired(groupId) } }, @@ -984,21 +1188,18 @@ export const useGroupStore = defineStore('imGroupStore', { applyGroupOwnerTransferNotification( groupId: number, payload: GroupNotificationPayload, + userId: number, + db: DbClient = getDb() ) { if (payload.operatorUserId && payload.newOwnerUserId) { - this.transferGroupOwner( - groupId, - payload.operatorUserId, - payload.newOwnerUserId, - ); - this.markGroupMembersExpired(groupId); + this.transferGroupOwner(groupId, payload.operatorUserId, payload.newOwnerUserId, db) + this.markGroupMembersExpired(groupId) } // 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList - const selfUserId = getCurrentUserId(); - if (selfUserId && payload.newOwnerUserId === selfUserId) { - refreshUnhandledGroupRequests(); - } else if (selfUserId && payload.operatorUserId === selfUserId) { - refreshUnhandledGroupRequests(); + if (payload.newOwnerUserId === userId) { + refreshUnhandledGroupRequests() + } else if (payload.operatorUserId === userId) { + refreshUnhandledGroupRequests() } }, @@ -1006,19 +1207,20 @@ export const useGroupStore = defineStore('imGroupStore', { applyGroupMessagePinNotification( groupId: number, payload: GroupNotificationPayload, + db: DbClient = getDb() ) { - const message = payload.message; + const message = payload.message if (!message) { - return; + return } - const group = this.getGroup(groupId); + const group = this.getGroup(groupId) if (!group) { - return; + return } // 幂等:已存在同 messageId 不重复 push - const existing = group.pinnedMessages || []; + const existing = group.pinnedMessages || [] if (existing.some((msg) => msg.id === message.id)) { - return; + return } group.pinnedMessages = [ ...existing, @@ -1031,51 +1233,47 @@ export const useGroupStore = defineStore('imGroupStore', { status: ImMessageStatus.NORMAL, sendTime: new Date(message.sendTime).getTime(), targetId: message.groupId || groupId, - selfSend: message.senderId === getCurrentUserId(), + selfSend: message.senderId === db.userId, atUserIds: message.atUserIds ? [...message.atUserIds] : [], - receiverUserIds: message.receiverUserIds - ? [...message.receiverUserIds] - : [], - }, - ]; - this.saveGroup(group); + receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : [] + } + ] + this.saveGroup(group, db) }, /** 群消息取消置顶:按 messageId 从本地置顶列表中移除 */ applyGroupMessageUnpinNotification( groupId: number, payload: GroupNotificationPayload, + db: DbClient = getDb() ) { if (!payload.messageId) { - return; + return } - const group = this.getGroup(groupId); + const group = this.getGroup(groupId) if (!group?.pinnedMessages?.length) { - return; + return } - const newPinnedMessages = group.pinnedMessages.filter( - (m) => m.id !== payload.messageId, - ); + const newPinnedMessages = group.pinnedMessages.filter((m) => m.id !== payload.messageId) if (newPinnedMessages.length === group.pinnedMessages.length) { - return; + return } - group.pinnedMessages = newPinnedMessages; - this.saveGroup(group); + group.pinnedMessages = newPinnedMessages + this.saveGroup(group, db) }, /** 单成员禁言:更新目标成员的 muteEndTime */ applyGroupMemberMutedNotification( groupId: number, payload: GroupNotificationPayload, + db: DbClient = getDb() ) { - const group = this.getGroup(groupId); - const member = group?.members?.find( - (m) => m.userId === payload.mutedUserId, - ); + const group = this.getGroup(groupId) + const member = group?.members?.find((m) => m.userId === payload.mutedUserId) if (member && payload.muteEndTime) { - member.muteEndTime = payload.muteEndTime; - this.saveGroupMemberList(groupId); - this.markGroupMembersExpired(groupId); + member.muteEndTime = payload.muteEndTime + this.saveGroupMemberList(groupId, db) + this.markGroupMembersExpired(groupId) } }, @@ -1083,33 +1281,34 @@ export const useGroupStore = defineStore('imGroupStore', { applyGroupMemberCancelMutedNotification( groupId: number, payload: GroupNotificationPayload, + db: DbClient = getDb() ) { - const group = this.getGroup(groupId); - const member = group?.members?.find( - (m) => m.userId === payload.mutedUserId, - ); + const group = this.getGroup(groupId) + const member = group?.members?.find((m) => m.userId === payload.mutedUserId) if (member) { - member.muteEndTime = undefined; - this.saveGroupMemberList(groupId); - this.markGroupMembersExpired(groupId); + member.muteEndTime = undefined + this.saveGroupMemberList(groupId, db) + this.markGroupMembersExpired(groupId) } }, /** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */ clear() { - this.groups = []; - this.loaded = false; - this.groupMembersExpired = false; - // 账号切换:递增 epoch 废弃旧账号 in-flight 的成员请求 - storeEpoch++; + this.groups = [] + this.loaded = false + this.groupMembersExpired = false // 单飞表跟 in-memory state 一起重置;旧账号 in-flight 的请求 finally 也会自己 delete key,提前清空只是更干脆 - pendingMemberFetches.clear(); - pendingSingleMemberFetches.clear(); - }, - }, -}); + pendingMemberFetches.clear() + queuedMemberRefreshes.clear() + pendingSingleMemberFetches.clear() + pendingGroupInfoFetches.clear() + queuedGroupInfoRefreshes.clear() + groupRelationVersions.clear() + } + } +}) -function convertGroup(group: ImGroupApi.GroupRespVO): Group { +function convertGroup(group: ImGroupRespVO, currentUserId: number): Group { return { id: group.id, name: group.name, @@ -1117,22 +1316,22 @@ function convertGroup(group: ImGroupApi.GroupRespVO): Group { notice: group.notice, ownerUserId: group.ownerUserId, pinnedMessages: group.pinnedMessages?.map((message) => - convertGroupMessageVO(message), + convertGroupMessageVO(message, currentUserId) ), mutedAll: group.mutedAll, banned: group.banned, joinApproval: group.joinApproval, joinStatus: group.joinStatus, groupRemark: group.groupRemark, - silent: group.silent, - }; + silent: group.silent + } } -/** 后端 ImGroupMessageApi.GroupMessageRespVO -> 前端 Message:补 targetId / selfSend / sendTime 等派生字段 */ +/** 后端 ImGroupMessageRespVO -> 前端 Message:补 targetId / selfSend / sendTime 等派生字段 */ function convertGroupMessageVO( - message: NonNullable[number], + message: NonNullable[number], + currentUserId: number ): Message { - const currentUserId = getCurrentUserId(); return { id: message.id, clientMessageId: message.clientMessageId || '', @@ -1146,14 +1345,11 @@ function convertGroupMessageVO( atUserIds: message.atUserIds || [], receiverUserIds: message.receiverUserIds || [], receiptStatus: message.receiptStatus, - readCount: message.readCount, - }; + readCount: message.readCount + } } -function convertGroupMember( - member: ImGroupMemberApi.GroupMemberRespVO, - groupId: number, -): GroupMember { +function convertGroupMember(member: ImGroupMemberRespVO, groupId: number): GroupMember { return { id: member.id, userId: member.userId, @@ -1163,13 +1359,11 @@ function convertGroupMember( displayUserName: member.displayUserName, status: member.status, role: member.role, - muteEndTime: member.muteEndTime, - }; + muteEndTime: member.muteEndTime + } } -export const useGroupStoreWithOut = () => useGroupStore(); - // dev: 让 Pinia 的 actions 改动支持 HMR,免去每次改 store 都要硬刷 if (import.meta.hot) { - import.meta.hot.accept(acceptHMRUpdate(useGroupStore, import.meta.hot)); + import.meta.hot.accept(acceptHMRUpdate(useGroupStore, import.meta.hot)) } diff --git a/apps/web-antd/src/views/im/home/store/messageStore.ts b/apps/web-antd/src/views/im/home/store/messageStore.ts index a9a424936..268b56aaa 100644 --- a/apps/web-antd/src/views/im/home/store/messageStore.ts +++ b/apps/web-antd/src/views/im/home/store/messageStore.ts @@ -1,12 +1,6 @@ -import type { - DbTransaction, - MessageDOPageCursor, -} from '../../utils/db'; -import type { Conversation, Message, MessageDO } from '../types'; +import type { Conversation, ConversationDO, Message, MessageDO } from '../types' -import { acceptHMRUpdate, defineStore } from 'pinia'; - -import { getCurrentUserId } from '#/views/im/utils/auth'; +import { acceptHMRUpdate, defineStore } from 'pinia' import { IM_AT_ALL_USER_ID, @@ -15,108 +9,210 @@ import { ImMessageReceiptStatus, ImMessageStatus, isGroupNotification, - isNormalMessage, -} from '../../utils/constants'; -import { resolveConversationLastContent } from '../../utils/conversation'; + isNormalMessage +} from '../../utils/constants' +import { resolveConversationLastContent } from '../../utils/conversation' import { + type DbClient, + type DbTransaction, getClientConversationId, getClientMessageKey, getDb, getServerMessageKey, + type MessageDOPageCursor, parseClientConversationId, setMessageMaxId, - StorageKeys, -} from '../../utils/db'; + StorageKeys +} from '../../utils/db' import { generateClientMessageId, + parseMessage, parseRecallMessageId, revokeBlobUrlsInContent, -} from '../../utils/message'; -import { isGroupQuit, tryGetSenderDisplayName } from '../../utils/user'; -import { useConversationStore } from './conversationStore'; -import { useGroupStore } from './groupStore'; + serializeMessage +} from '../../utils/message' +import { + enqueueConversationWrite, + enqueueConversationWrites, + isRelationTerminated, + MessageTerminalPriority, + reduceMessageState +} from '../../utils/messageSync' +import { isGroupQuit, tryGetSenderDisplayName } from '../../utils/user' +import { useConversationStore } from './conversationStore' +import { useGroupStore } from './groupStore' -const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5; -const MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT = - MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT + 1; -const ackMergingPromises = new Map>(); +const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5 +const MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT = MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT + 1 +const ackMergingPromises = new Map>() interface MessageConversationInfo { - type: number; - targetId: number; - name: string; - avatar: string; - silent?: boolean; + type: number + targetId: number + name: string + avatar: string + silent?: boolean } interface PersistMessageRecordOptions { - mergeClientRecord?: boolean; + mergeClientRecord?: boolean } interface MessagePageResult { - messages: Message[]; - hasMore: boolean; + messages: Message[] + hasMore: boolean +} + +interface ConversationMessageTerminal { + clearBefore: number + deletedKeys: Set + recalledKeys: Set +} + +interface RecallMessageProjection { + conversation: Conversation + message: Message + cachedMessage?: Message } /** 拉取消息批量处理项 */ export type PulledMessage = | { - conversationInfo: MessageConversationInfo; - kind: 'insert'; - message: Message; + conversationInfo: MessageConversationInfo + kind: 'insert' + message: Message } | { - conversationType: number; - kind: 'recall'; - recallSignalContent: string; - targetId: number; - }; - -/** 获取会话的消息缓存 key */ -function getMessageCacheKey(type: number, targetId: number): string { - return getClientConversationId(type, targetId); -} + conversationType: number + kind: 'recall' + recallSignalContent: string + targetId: number + } /** 生成消息本地主键 */ function getMessageKey( message: Pick, - conversationType: number, + conversationType: number ): string { return message.id ? getServerMessageKey(conversationType, message.id) - : getClientMessageKey(message.clientMessageId); + : getClientMessageKey(message.clientMessageId) +} + +/** 判断消息是否已被当前设备清理或删除 */ +function isMessageTerminated(message: Message, terminal: ConversationMessageTerminal): boolean { + return ( + (!!message.id && message.id <= terminal.clearBefore) || + (!!message.id && terminal.deletedKeys.has(`id:${message.id}`)) || + terminal.deletedKeys.has(`client:${message.clientMessageId}`) + ) +} + +/** 把已持久化的撤回 marker 应用到迟到的普通消息 */ +function applyPersistedRecall(message: Message, terminal: ConversationMessageTerminal): Message { + if (!message.id || !terminal.recalledKeys.has(`id:${message.id}`)) { + return message + } + return { + ...message, + type: ImContentType.RECALL, + content: '', + status: ImMessageStatus.RECALL + } +} + +/** 读取会话持久化终态 */ +async function getConversationMessageTerminal( + clientConversationId: string, + db: DbClient +): Promise { + const [clearBefore, deletedKeys, recalledKeys] = await Promise.all([ + db.getSetting( + `${StorageKeys.settings.conversationClearBeforePrefix}${clientConversationId}` + ), + db.getSetting( + `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}` + ), + db.getSetting( + `${StorageKeys.settings.conversationRecalledMessagesPrefix}${clientConversationId}` + ) + ]) + return { + clearBefore: clearBefore || 0, + deletedKeys: new Set(deletedKeys || []), + recalledKeys: new Set(recalledKeys || []) + } +} + +/** 获取普通消息与撤回终态的归约优先级 */ +function getMessageTerminalPriority(message: Pick) { + if (message.type === ImContentType.RECALL || message.status === ImMessageStatus.RECALL) { + return MessageTerminalPriority.RECALL + } + return message.status === ImMessageStatus.NORMAL && !!message.id + ? MessageTerminalPriority.CONFIRMED + : MessageTerminalPriority.NORMAL } /** 获取数据库分页结果的最早游标 */ function getMessageDOPageCursor(message?: MessageDO): MessageDOPageCursor | undefined { return message ? { - messageKey: message.messageKey, sendTime: message.sendTime, + messageKey: message.messageKey } - : undefined; + : undefined } /** 判断两个消息分页游标是否一致 */ function isSameMessageDOPageCursor( left?: MessageDOPageCursor, - right?: MessageDOPageCursor, + right?: MessageDOPageCursor ): boolean { if (!left || !right) { - return left === right; + return left === right } - return left.sendTime === right.sendTime && left.messageKey === right.messageKey; + return left.sendTime === right.sendTime && left.messageKey === right.messageKey } /** 补齐客户端消息编号 */ function ensureClientMessageId(message: Message): Message { if (!message.clientMessageId) { - message.clientMessageId = generateClientMessageId(); + message.clientMessageId = generateClientMessageId() } if (!message.id) { - message.id = undefined; + message.id = undefined + } + return message +} + +/** 媒体占位只持久化恢复标记,不把本次运行的 blob URL 写成可重发内容 */ +function getPersistentMessageContent(message: Message): string { + if (message.status !== ImMessageStatus.SENDING || !message._localFile) { + return message.content + } + const payload = parseMessage>(message.content) + if (!payload) { + return message.content + } + const { url: _localUrl, coverUrl: _localCoverUrl, ...persistedPayload } = payload + return serializeMessage({ ...persistedPayload, _uploadPending: true }) +} + +/** 将重启前未完成的本地消息降级为可识别的失败态 */ +function recoverPendingMessage(message: MessageDO): MessageDO { + if (message.status !== ImMessageStatus.SENDING) { + return message + } + const payload = parseMessage>(message.content) + if (!payload?._uploadPending) { + return { ...message, status: ImMessageStatus.FAILED } + } + return { + ...message, + content: serializeMessage({ ...payload, _uploadPending: false, _uploadFailed: true }), + status: ImMessageStatus.FAILED } - return message; } /** 转换为 IndexedDB 消息记录 */ @@ -125,14 +221,12 @@ function buildMessageDO(message: Message, conversationType: number): MessageDO { id: message.id, clientMessageId: message.clientMessageId, type: message.type, - content: message.content, + content: getPersistentMessageContent(message), status: message.status, sendTime: message.sendTime, senderId: message.senderId, atUserIds: message.atUserIds ? [...message.atUserIds] : undefined, - receiverUserIds: message.receiverUserIds - ? [...message.receiverUserIds] - : undefined, + receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : undefined, receiptStatus: message.receiptStatus, readCount: message.readCount, materialId: message.materialId, @@ -140,11 +234,8 @@ function buildMessageDO(message: Message, conversationType: number): MessageDO { selfSend: message.selfSend, messageKey: getMessageKey(message, conversationType), conversationType, - clientConversationId: getClientConversationId( - conversationType, - message.targetId, - ), - }; + clientConversationId: getClientConversationId(conversationType, message.targetId) + } } /** IndexedDB 消息记录转前端消息 */ @@ -154,106 +245,127 @@ function buildMessageFromDO(message: MessageDO): Message { conversationType: _conversationType, clientConversationId: _clientConversationId, ...rest - } = message; - return rest; + } = message + return rest +} + +/** IndexedDB 会话记录转前端会话 */ +function buildConversationFromDO(conversation: ConversationDO): Conversation { + const { clientConversationId: _clientConversationId, ...rest } = conversation + return rest } /** 算出末条消息的发送人快照 */ function deriveLastSenderDisplayName( conversation: Conversation, senderId: number, + db: DbClient ): string | undefined { // 1. 优先使用当前内存中的好友 / 群成员信息 - const liveSenderName = tryGetSenderDisplayName( - senderId, - conversation.type, - conversation.targetId, - ); + const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId) if (liveSenderName) { - return liveSenderName; + return liveSenderName } // 2. 群成员缓存缺失时异步补齐 if (conversation.type === ImConversationType.GROUP) { - const groupStore = useGroupStore(); - const group = groupStore.getGroup(conversation.targetId); + const groupStore = useGroupStore() + const group = groupStore.getGroup(conversation.targetId) if (!group || isGroupQuit(group)) { - return conversation.lastSenderId === senderId - ? conversation.lastSenderDisplayName - : undefined; + return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined } const fetchPromise = group?.membersLoaded && !group.membersExpired ? groupStore.fetchGroupMember(conversation.targetId, senderId) - : groupStore.fetchGroupMemberList(conversation.targetId); - fetchPromise.catch((error) => + : groupStore.fetchGroupMemberList(conversation.targetId, false, db) + fetchPromise.catch((e) => console.warn( '[IM messageStore] 兜底拉群成员失败', - { - groupId: conversation.targetId, - senderId, - fullFetch: !group?.membersLoaded, - }, - error, - ), - ); + { groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded }, + e + ) + ) } - return conversation.lastSenderId === senderId - ? conversation.lastSenderDisplayName - : undefined; + return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined } /** 按消息更新会话摘要 */ function applyConversationSummary( conversation: Conversation, message: Message, + db: DbClient ): void { - const senderDisplayName = deriveLastSenderDisplayName( - conversation, - message.senderId, - ); + const senderDisplayName = deriveLastSenderDisplayName(conversation, message.senderId, db) 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; + 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 shouldUpdateConversationSummary(conversation: Conversation, message: Message): boolean { + if (message.id !== undefined && conversation.lastMessageId !== undefined) { + return message.id >= conversation.lastMessageId + } + return ( + (!!message.clientMessageId && message.clientMessageId === conversation.lastClientMessageId) || + (message.sendTime || 0) > (conversation.lastSendTime || 0) + ) +} + +/** 会话摘要顺序:服务端消息按 id,混合本地消息时按发送时间 */ +function compareConversationSummaryOrder( + left: Pick, + right: Pick +): number { + if (left.id && right.id) { + return left.id - right.id + } + return ( + (left.sendTime || 0) - (right.sendTime || 0) || + Number(!!left.id) - Number(!!right.id) || + left.clientMessageId.localeCompare(right.clientMessageId) + ) } /** 按末条消息重算会话摘要 */ function recomputeConversationLast( conversation: Conversation, messages: Message[], + db: DbClient ): void { - const last = messages[messages.length - 1]; + const last = messages[messages.length - 1] if (last) { - applyConversationSummary(conversation, last); - return; + applyConversationSummary(conversation, last, db) + 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; + 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, + userId: number ): void { if ( message.selfSend || @@ -261,68 +373,75 @@ function syncConversationAtFlags( !message.atUserIds || message.atUserIds.length === 0 ) { - return; + return } - const currentUserId = getCurrentUserId(); - if (currentUserId && message.atUserIds.includes(currentUserId)) { - conversation.atMe = true; + if (message.atUserIds.includes(userId)) { + conversation.atMe = true if (message.id && message.id > (conversation.atMessageId || 0)) { - conversation.atMessageId = message.id; + conversation.atMessageId = message.id } } if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) { - conversation.atAll = true; + conversation.atAll = true if (message.id && message.id > (conversation.atAllMessageId || 0)) { - conversation.atAllMessageId = message.id; + conversation.atAllMessageId = message.id } } } -/** 应用服务端消息更新 */ -function applyServerMessageUpdate( - message: Message, - updates: Partial, -): void { - if (updates.content && updates.content !== message.content) { - revokeBlobUrlsInContent(message.content); +/** 构建服务端消息更新后的下一份投影 */ +function buildServerMessageProjection(message: Message, updates: Partial): Message { + const next = { ...message, ...updates } + if (message.receiptStatus !== undefined) { + next.receiptStatus = + updates.receiptStatus === undefined + ? message.receiptStatus + : Math.max(message.receiptStatus, updates.receiptStatus) + } + if (message.readCount !== undefined) { + next.readCount = + updates.readCount === undefined + ? message.readCount + : Math.max(message.readCount, updates.readCount) } - Object.assign(message, updates); if (updates.id === 0) { - message.id = undefined; + next.id = undefined } - if ( - updates.status !== undefined && - updates.status !== ImMessageStatus.SENDING - ) { - message.uploadProgress = undefined; + if (updates.status !== undefined && updates.status !== ImMessageStatus.SENDING) { + next.uploadProgress = undefined if (updates.status !== ImMessageStatus.FAILED) { - message._localFile = undefined; + next._localFile = undefined } } + return next +} + +/** 发布已成功持久化的服务端消息更新 */ +function applyServerMessageUpdate(message: Message, updates: Partial): void { + if (updates.content && updates.content !== message.content) { + revokeBlobUrlsInContent(message.content) + } + Object.assign(message, buildServerMessageProjection(message, updates)) } /** 判断是否为同一条消息 */ function isSameMessage(left: Message, right: Message): boolean { if (left.id && right.id && left.id === right.id) { - return true; + return true } - return ( - !!left.clientMessageId && left.clientMessageId === right.clientMessageId - ); + return !!left.clientMessageId && left.clientMessageId === right.clientMessageId } export const useMessageStore = defineStore('imMessageStore', { state: () => ({ messagesByConversation: {} as Record, - messageDOPageCursors: {} as Record< - string, - MessageDOPageCursor | undefined - >, + messageDOPageCursors: {} as Record, loadedConversationKeys: [] as string[], + recoveredConversationKeys: [] as string[], privateReadMaxIds: {} as Partial>, privateMessageMaxId: 0, groupMessageMaxId: 0, - channelMessageMaxId: 0, + channelMessageMaxId: 0 }), getters: { @@ -330,7 +449,7 @@ export const useMessageStore = defineStore('imMessageStore', { getMessages: (state) => (clientConversationId: string): Message[] => - state.messagesByConversation[clientConversationId] || [], + state.messagesByConversation[clientConversationId] || [] }, actions: { @@ -338,212 +457,228 @@ export const useMessageStore = defineStore('imMessageStore', { clear() { Object.values(this.messagesByConversation).forEach((messages) => { messages.forEach((message) => { - revokeBlobUrlsInContent(message.content); - message._localFile = undefined; - }); - }); - this.messagesByConversation = {}; - this.messageDOPageCursors = {}; - this.loadedConversationKeys = []; - this.privateReadMaxIds = {}; - this.privateMessageMaxId = 0; - this.groupMessageMaxId = 0; - this.channelMessageMaxId = 0; - ackMergingPromises.clear(); + revokeBlobUrlsInContent(message.content) + message._localFile = undefined + }) + }) + this.messagesByConversation = {} + this.messageDOPageCursors = {} + this.loadedConversationKeys = [] + this.recoveredConversationKeys = [] + this.privateReadMaxIds = {} + this.privateMessageMaxId = 0 + this.groupMessageMaxId = 0 + this.channelMessageMaxId = 0 + ackMergingPromises.clear() }, /** 从 settings 加载消息游标 */ async loadMessageCursorList() { - const db = getDb(); + const db = getDb() const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([ db.getSetting(StorageKeys.settings.privateMessageMaxId), db.getSetting(StorageKeys.settings.groupMessageMaxId), - db.getSetting(StorageKeys.settings.channelMessageMaxId), - ]); - this.privateMessageMaxId = privateMaxId || 0; - this.groupMessageMaxId = groupMaxId || 0; - this.channelMessageMaxId = channelMaxId || 0; + db.getSetting(StorageKeys.settings.channelMessageMaxId) + ]) + this.privateMessageMaxId = privateMaxId || 0 + this.groupMessageMaxId = groupMaxId || 0 + this.channelMessageMaxId = channelMaxId || 0 }, /** 更新内存游标 */ updateMessageCursor(conversationType: number, messageId?: number) { if (!messageId) { - return; + return } - if ( - conversationType === ImConversationType.PRIVATE && - messageId > this.privateMessageMaxId - ) { - this.privateMessageMaxId = messageId; + if (conversationType === ImConversationType.PRIVATE && messageId > this.privateMessageMaxId) { + this.privateMessageMaxId = messageId } else if ( conversationType === ImConversationType.GROUP && messageId > this.groupMessageMaxId ) { - this.groupMessageMaxId = messageId; + this.groupMessageMaxId = messageId } else if ( conversationType === ImConversationType.CHANNEL && messageId > this.channelMessageMaxId ) { - this.channelMessageMaxId = messageId; + this.channelMessageMaxId = messageId } }, /** 获取私聊对方已读位置缓存 */ getPrivateReadMaxId(peerId: number): number | undefined { - return this.privateReadMaxIds[peerId]; + return this.privateReadMaxIds[peerId] }, /** 更新私聊对方已读位置缓存 */ - updatePrivateReadMaxId( - peerId: number, - maxReadId: null | number = 0, - ): number { + updatePrivateReadMaxId(peerId: number, maxReadId?: null | number): number { if (!peerId) { - return 0; + return 0 } - const nextMaxReadId = maxReadId || 0; - const current = this.getPrivateReadMaxId(peerId); + const nextMaxReadId = maxReadId || 0 + const current = this.getPrivateReadMaxId(peerId) if (current !== undefined && nextMaxReadId <= current) { - return current; + return current } - this.privateReadMaxIds = { - ...this.privateReadMaxIds, - [peerId]: nextMaxReadId, - }; - return nextMaxReadId; + this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId } + return nextMaxReadId }, /** 清空私聊对方已读位置缓存 */ clearPrivateReadMaxIdCache(): void { - this.privateReadMaxIds = {}; + this.privateReadMaxIds = {} }, /** 标记会话近期使用 */ touchConversationMessageCache(clientConversationId: string) { this.loadedConversationKeys = [ clientConversationId, - ...this.loadedConversationKeys.filter( - (key) => key !== clientConversationId, - ), - ]; + ...this.loadedConversationKeys.filter((key) => key !== clientConversationId) + ] // 保留当前活跃会话 + 最近打开过的会话 - const retained = this.loadedConversationKeys.slice( - 0, - MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT, - ); - const removed = this.loadedConversationKeys.slice( - MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT, - ); - this.loadedConversationKeys = retained; + const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT) + const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT) + this.loadedConversationKeys = retained removed.forEach((key) => { - Reflect.deleteProperty(this.messagesByConversation, key); - Reflect.deleteProperty(this.messageDOPageCursors, key); - }); + delete this.messagesByConversation[key] + delete this.messageDOPageCursors[key] + }) }, /** 加载当前会话最近消息 */ async loadMoreMessageList( clientConversationId: string, limit = 50, + db: DbClient = getDb() ): Promise { - const parsed = parseClientConversationId(clientConversationId); + return enqueueConversationWrite(clientConversationId, () => + this.loadMoreMessageListNow(clientConversationId, limit, db) + ) + }, + + /** 实际加载会话消息;调用方必须持有当前会话写 lane */ + async loadMoreMessageListNow( + clientConversationId: string, + limit: number, + db: DbClient + ): Promise { + const parsed = parseClientConversationId(clientConversationId) if (!parsed) { - return { messages: [], hasMore: false }; + return { messages: [], hasMore: false } + } + const before = this.messageDOPageCursors[clientConversationId] + const terminal = await getConversationMessageTerminal(clientConversationId, db) + if (!before && !this.recoveredConversationKeys.includes(clientConversationId)) { + await this.recoverPendingMessageListNow(clientConversationId, terminal, db) } - const before = this.messageDOPageCursors[clientConversationId]; // 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列 - const page = await getDb().getMessageListByConversation( - clientConversationId, - { - before, - limit, - }, - ); - if ( - isSameMessageDOPageCursor( - this.messageDOPageCursors[clientConversationId], - before, - ) - ) { - const nextCursor = getMessageDOPageCursor(page.list[0]); + const page = await db.getMessageListByConversation(clientConversationId, { + before, + limit + }) + if (isSameMessageDOPageCursor(this.messageDOPageCursors[clientConversationId], before)) { + const nextCursor = getMessageDOPageCursor(page.list[0]) if (nextCursor) { - this.messageDOPageCursors[clientConversationId] = nextCursor; + this.messageDOPageCursors[clientConversationId] = nextCursor } } // 2. 合并到内存缓存,过滤已存在的消息 - const messages = page.list.map((message) => - buildMessageFromDO(message), - ); - const existing = this.messagesByConversation[clientConversationId] || []; - const existingKeys = new Set( - existing.map((message) => getMessageKey(message, parsed.type)), - ); + const messages = page.list + .map(buildMessageFromDO) + .filter((message) => !isMessageTerminated(message, terminal)) + .map((message) => applyPersistedRecall(message, terminal)) + 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)), - ); - this.messagesByConversation[clientConversationId] = [ - ...fresh, - ...existing, - ].toSorted( - (messageA, messageB) => - (messageA.sendTime || 0) - (messageB.sendTime || 0), - ); - this.touchConversationMessageCache(clientConversationId); - return { messages: fresh, hasMore: page.hasMore }; + (message) => !existingKeys.has(getMessageKey(message, parsed.type)) + ) + this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort( + (messageA, messageB) => (messageA.sendTime || 0) - (messageB.sendTime || 0) + ) + if (!before && !this.recoveredConversationKeys.includes(clientConversationId)) { + this.recoveredConversationKeys.push(clientConversationId) + } + this.touchConversationMessageCache(clientConversationId) + return { messages: fresh, hasMore: page.hasMore } + }, + + /** 恢复当前会话未完成消息;读取、去重和写回均处于同一写 lane */ + async recoverPendingMessageListNow( + clientConversationId: string, + terminal: ConversationMessageTerminal, + db: DbClient + ): Promise { + await db.transaction(['messages'], 'readwrite', async (tx) => { + const records = await db.getAllByIndex( + 'messages', + 'clientConversationId', + clientConversationId, + tx + ) + const serverClientMessageIds = new Set( + records.filter((message) => !!message.id).map((message) => message.clientMessageId) + ) + for (const record of records) { + const message = buildMessageFromDO(record) + if ( + isMessageTerminated(message, terminal) || + (!record.id && serverClientMessageIds.has(record.clientMessageId)) + ) { + await db.delete('messages', record.messageKey, tx) + continue + } + const recovered = recoverPendingMessage(record) + const recoveredMessage = buildMessageFromDO(recovered) + const recalled = applyPersistedRecall(recoveredMessage, terminal) + if (recovered !== record || recalled !== recoveredMessage) { + await db.put('messages', buildMessageDO(recalled, record.conversationType), tx) + } + } + }) }, /** 确保会话消息已加载 */ async ensureConversationMessageListLoaded(conversation: Conversation) { - const key = getMessageCacheKey(conversation.type, conversation.targetId); - if (this.messagesByConversation[key]) { - this.touchConversationMessageCache(key); - return; + const key = getClientConversationId(conversation.type, conversation.targetId) + if (this.messagesByConversation[key] && this.recoveredConversationKeys.includes(key)) { + this.touchConversationMessageCache(key) + return } - await this.loadMoreMessageList(key); + await this.loadMoreMessageList(key) }, /** 获取内存消息数组 */ getMessageList(conversationType: number, targetId: number): Message[] { - const key = getMessageCacheKey(conversationType, targetId); + const key = getClientConversationId(conversationType, targetId) if (!this.messagesByConversation[key]) { - this.messagesByConversation[key] = []; + this.messagesByConversation[key] = [] } - this.touchConversationMessageCache(key); - return this.messagesByConversation[key]; + this.touchConversationMessageCache(key) + return this.messagesByConversation[key] }, /** 持久化消息记录 */ async saveMessageRecord( message: Message, conversationType: number, - tx?: DbTransaction, - options?: PersistMessageRecordOptions, + tx: DbTransaction | undefined, + options: PersistMessageRecordOptions | undefined, + db: DbClient ) { - const db = getDb(); - const next = buildMessageDO(message, conversationType); + const next = buildMessageDO(message, conversationType) // 服务端 key 替换 client key if (options?.mergeClientRecord && message.id && message.clientMessageId) { const existing = await db.getByIndex( 'messages', 'clientMessageId', message.clientMessageId, - tx, - ); + tx + ) if (existing && existing.messageKey !== next.messageKey) { - await db.delete('messages', existing.messageKey, tx); + await db.delete('messages', existing.messageKey, tx) } } - await db.put('messages', next, tx); - }, - - /** 保存消息游标 */ - async saveMessageCursor( - conversationType: number, - messageId?: number, - tx?: DbTransaction, - ) { - await setMessageMaxId(conversationType, messageId, tx); - this.updateMessageCursor(conversationType, messageId); + await db.put('messages', next, tx) }, /** 应用撤回到本地消息与会话状态 */ @@ -552,360 +687,544 @@ export const useMessageStore = defineStore('imMessageStore', { targetId: number, recallSignalContent: string, tx: DbTransaction, + staged: undefined | { conversation: Conversation; messages: Message[] }, + db: DbClient ) { // 1. 定位被撤回的原消息和会话 - const messageId = parseRecallMessageId(recallSignalContent); + const messageId = parseRecallMessageId(recallSignalContent) if (!messageId) { - return null; + return null } - const conversationStore = useConversationStore(); - const conversation = conversationStore.getConversation( - conversationType, - targetId, - ); - if (!conversation) { - return null; + const clientConversationId = getClientConversationId(conversationType, targetId) + const recallSettingKey = `${StorageKeys.settings.conversationRecalledMessagesPrefix}${clientConversationId}` + const recalledKeys = (await db.getSetting(recallSettingKey, tx)) || [] + await db.setSetting( + recallSettingKey, + Array.from(new Set([...recalledKeys, `id:${messageId}`])), + tx + ) + const conversationStore = useConversationStore() + const currentConversation = + staged?.conversation || conversationStore.getConversation(conversationType, targetId) + if (!currentConversation) { + return null } - const clientConversationId = getClientConversationId( - conversationType, - targetId, - ); - const cachedMessage = this.messagesByConversation[ - clientConversationId - ]?.find((item) => item.id === messageId); - const storedMessage = await getDb().get( + const cachedMessage = ( + staged?.messages || this.messagesByConversation[clientConversationId] + )?.find((item) => item.id === messageId) + const storedMessage = await db.get( 'messages', getServerMessageKey(conversationType, messageId), - tx, - ); + tx + ) const originalMessage = cachedMessage ? buildMessageDO(cachedMessage, conversationType) - : storedMessage; + : storedMessage if (!originalMessage) { - return null; + return null } - // 2. 更新内存消息与数据库记录 - const recalledMessage = - cachedMessage || buildMessageFromDO(originalMessage); - revokeBlobUrlsInContent(recalledMessage.content); - recalledMessage.type = ImContentType.RECALL; - recalledMessage.status = ImMessageStatus.RECALL; - recalledMessage.content = ''; - await this.saveMessageRecord(recalledMessage, conversationType, tx); + // 2. 构建撤回后的消息和会话投影,再写数据库 + const recalledMessage = { + ...(cachedMessage || buildMessageFromDO(originalMessage)), + type: ImContentType.RECALL, + status: ImMessageStatus.RECALL, + content: '' + } + const conversation = { ...currentConversation } + await this.saveMessageRecord(recalledMessage, conversationType, tx, undefined, db) // 3. 按本地完整消息和读位置重算未读与 @ 状态 - const storedMessages = await getDb().getAllByIndex( + const storedMessages = await db.getAllByIndex( 'messages', 'clientConversationId', clientConversationId, - tx, - ); + tx + ) conversationStore.applyRecallToConversation( conversation, storedMessages, originalMessage, - ); + db.userId + ) if (conversation.lastMessageId === messageId) { - applyConversationSummary(conversation, recalledMessage); + applyConversationSummary(conversation, recalledMessage, db) } - return { conversation, message: recalledMessage }; + return { conversation, message: recalledMessage, cachedMessage } as RecallMessageProjection }, - /** 批量写入拉取消息 */ + /** 把拉取批次投入涉及会话的串行写 lane */ async applyPulledMessageList( pulledMessages: PulledMessage[], conversationType: number, - maxMessageId?: number, + maxMessageId: number | undefined, + db: DbClient + ) { + const conversationIds = pulledMessages.map((item) => + item.kind === 'insert' + ? getClientConversationId(item.conversationInfo.type, item.conversationInfo.targetId) + : getClientConversationId(item.conversationType, item.targetId) + ) + await enqueueConversationWrites(conversationIds, () => + this.applyPulledMessageListNow(pulledMessages, conversationType, maxMessageId, db) + ) + }, + + /** 批量写入拉取消息 */ + async applyPulledMessageListNow( + pulledMessages: PulledMessage[], + conversationType: number, + maxMessageId: number | undefined, + db: DbClient ) { if (pulledMessages.length === 0) { // 1. 空批次只推进游标 - await this.saveMessageCursor(conversationType, maxMessageId); - return; + await setMessageMaxId(conversationType, maxMessageId, undefined, db) + this.updateMessageCursor(conversationType, maxMessageId) + return } - const conversationStore = useConversationStore(); + const conversationStore = useConversationStore() + const groupStore = useGroupStore() const persistedMessages = new Map< + string, + { conversationType: number; mergeClientRecord?: boolean; message: Message; } + >() + const changedConversations = new Map() + const conversationProjections = new Map< string, { - conversationType: number; - mergeClientRecord?: boolean; - message: Message; + conversation: Conversation + currentMessages: Message[] + nextMessages: Message[] } - >(); - const changedConversations = new Map(); - const recallMessages: Extract< - PulledMessage, - { kind: 'recall' } - >[] = []; + >() + const recallMessages: Extract[] = [] + const terminalStates = new Map() + + const getTerminal = async (clientConversationId: string) => { + const cached = terminalStates.get(clientConversationId) + if (cached) { + return cached + } + const [clearBefore, deletedKeys, recalledKeys] = await Promise.all([ + db.getSetting( + `${StorageKeys.settings.conversationClearBeforePrefix}${clientConversationId}` + ), + db.getSetting( + `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}` + ), + db.getSetting( + `${StorageKeys.settings.conversationRecalledMessagesPrefix}${clientConversationId}` + ) + ]) + const terminal = { + clearBefore: clearBefore || 0, + deletedKeys: new Set(deletedKeys || []), + recalledKeys: new Set(recalledKeys || []) + } + terminalStates.set(clientConversationId, terminal) + return terminal + } const addChanged = ( conversation: Conversation, message: Message, - options?: PersistMessageRecordOptions, + options?: PersistMessageRecordOptions ) => { const clientConversationId = getClientConversationId( conversation.type, - conversation.targetId, - ); - changedConversations.set(clientConversationId, conversation); + conversation.targetId + ) + changedConversations.set(clientConversationId, conversation) persistedMessages.set(getMessageKey(message, conversation.type), { message, conversationType: conversation.type, - mergeClientRecord: options?.mergeClientRecord, - }); - }; + mergeClientRecord: options?.mergeClientRecord + }) + } - // 1. 先更新内存,收集需要持久化的消息和会话 + // 1. 按消息顺序构建待提交投影 for (const pulledMessage of pulledMessages) { if (pulledMessage.kind === 'recall') { // 1.1 撤回信号在事务内读取原消息后统一处理 - recallMessages.push(pulledMessage); - continue; + recallMessages.push(pulledMessage) + continue } - const { conversationInfo } = pulledMessage; - const hasServerClientMessageId = - !!pulledMessage.message.clientMessageId; - const message = ensureClientMessageId(pulledMessage.message); - // 1.2 确保会话和消息缓存存在 - const conversation = - conversationStore.ensureConversation(conversationInfo); - const messages = this.getMessageList( + const { conversationInfo } = pulledMessage + const hasServerClientMessageId = !!pulledMessage.message.clientMessageId + let message = ensureClientMessageId(pulledMessage.message) + const clientConversationId = getClientConversationId( conversationInfo.type, - conversationInfo.targetId, - ); + conversationInfo.targetId + ) + if (conversationInfo.type === ImConversationType.GROUP) { + const groupNotification = isGroupNotification(message.type) + if (groupNotification) { + await groupStore.applyGroupNotificationNow( + conversationInfo.targetId, + message.type, + message.content, + message.id, + db + ) + } + const staged = conversationProjections.get(clientConversationId) + if (staged) { + if (isRelationTerminated(clientConversationId)) { + staged.conversation.deleted = true + staged.conversation.draft = undefined + } else if (groupNotification) { + staged.conversation.deleted = false + } + } + } + const terminal = await getTerminal(clientConversationId) + if (isMessageTerminated(message, terminal)) { + continue + } + message = applyPersistedRecall(message, terminal) + // 1.2 获取或创建当前批次的待提交投影 + let projection = conversationProjections.get(clientConversationId) + if (!projection) { + const currentMessages = this.messagesByConversation[clientConversationId] || [] + projection = { + conversation: conversationStore.buildConversationProjection(conversationInfo), + currentMessages, + nextMessages: [...currentMessages] + } + conversationProjections.set(clientConversationId, projection) + } + const conversation = projection.conversation + const messages = projection.nextMessages const isActive = - conversationStore.activeConversation?.type === - conversationInfo.type && - conversationStore.activeConversation?.targetId === - conversationInfo.targetId; + conversationStore.activeConversation?.type === conversationInfo.type && + conversationStore.activeConversation?.targetId === conversationInfo.targetId const isUnread = !message.selfSend && !isActive && - !conversationStore.isMessageCoveredByReadPosition( - conversation, - message, - ) && + !conversationStore.isMessageCoveredByReadPosition(conversation, message) && isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL; - const existingIndex = messages.findIndex((existing) => - isSameMessage(existing, message), - ); - if (existingIndex !== -1) { - const existing = messages[existingIndex]; - if (!existing) { - continue; + message.status !== ImMessageStatus.RECALL + let existingIndex = messages.findIndex((existing) => isSameMessage(existing, message)) + if (existingIndex < 0 && message.id) { + const messageId = message.id + const storedMessage = await db.get( + 'messages', + getServerMessageKey(conversationInfo.type, messageId) + ) + if (storedMessage) { + existingIndex = messages.findIndex((existing) => existing.id && messageId < existing.id) + if (existingIndex < 0) { + existingIndex = messages.length + } + messages.splice(existingIndex, 0, buildMessageFromDO(storedMessage)) } + } + if (existingIndex >= 0) { // 1.3 已存在消息合并服务端状态 - applyServerMessageUpdate(existing, message); - if (existingIndex === messages.length - 1) { - recomputeConversationLast(conversation, messages); + const existing = messages[existingIndex]! + const reduced = reduceMessageState( + { priority: getMessageTerminalPriority(existing), value: existing }, + { priority: getMessageTerminalPriority(message), value: message } + ) + if (reduced.value === message) { + messages[existingIndex] = buildServerMessageProjection(existing, message) + } + const mergedMessage = messages[existingIndex]! + if ( + existingIndex === messages.length - 1 && + shouldUpdateConversationSummary(conversation, mergedMessage) + ) { + recomputeConversationLast(conversation, messages, db) } if (isUnread) { - syncConversationAtFlags(conversation, message); + syncConversationAtFlags(conversation, message, db.userId) } - addChanged(conversation, existing, { - mergeClientRecord: hasServerClientMessageId, - }); - continue; + addChanged(conversation, messages[existingIndex]!, { + mergeClientRecord: hasServerClientMessageId + }) + continue } // 1.4 新消息更新会话摘要和未读状态 - applyConversationSummary(conversation, message); + if (shouldUpdateConversationSummary(conversation, message)) { + applyConversationSummary(conversation, message, db) + } if (isUnread) { - syncConversationAtFlags(conversation, message); - conversation.unreadCount++; + syncConversationAtFlags(conversation, message, db.userId) + conversation.unreadCount++ } - // 1.5 新消息按服务端 id 插入内存列表 - let insertIndex = messages.length; + // 1.5 新消息按服务端 id 插入待提交列表 + let insertIndex = messages.length if (message.id) { - for (const [index, existing] of messages.entries()) { + for (let index = 0; index < messages.length; index++) { + const existing = messages[index]! if (existing.id && message.id < existing.id) { - insertIndex = index; - break; + insertIndex = index + break } } } - messages.splice(insertIndex, 0, message); + messages.splice(insertIndex, 0, message) addChanged(conversation, message, { - mergeClientRecord: hasServerClientMessageId && !!message.id, - }); + mergeClientRecord: hasServerClientMessageId && !!message.id + }) } // 2. 单事务写入消息、会话摘要和游标 - await getDb().transaction( - ['messages', 'conversations', 'settings'], - 'readwrite', - async (tx) => { - // 2.1 写入本批变更消息 - for (const item of persistedMessages.values()) { - await this.saveMessageRecord( - item.message, - item.conversationType, - tx, - { - mergeClientRecord: item.mergeClientRecord, - }, - ); - } - // 2.2 应用本批撤回信号 - for (const recallMessage of recallMessages) { - const changed = await this.applyRecallMessageRecord( - recallMessage.conversationType, - recallMessage.targetId, - recallMessage.recallSignalContent, - tx, - ); - if (changed) { - changedConversations.set( - getClientConversationId( - changed.conversation.type, - changed.conversation.targetId, - ), - changed.conversation, - ); - } - } - // 2.3 写入本批变更会话 - await conversationStore.saveConversationRecord( - [...changedConversations.values()], + await db.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => { + // 2.1 写入本批变更消息 + for (const item of persistedMessages.values()) { + await this.saveMessageRecord( + item.message, + item.conversationType, tx, - ); - // 2.4 写入本批游标 - await setMessageMaxId(conversationType, maxMessageId, tx); - }, - ); + { + mergeClientRecord: item.mergeClientRecord + }, + db + ) + } + // 2.2 应用本批撤回信号 + for (const recallMessage of recallMessages) { + const staged = conversationProjections.get( + getClientConversationId(recallMessage.conversationType, recallMessage.targetId) + ) + const changed = await this.applyRecallMessageRecord( + recallMessage.conversationType, + recallMessage.targetId, + recallMessage.recallSignalContent, + tx, + staged + ? { conversation: staged.conversation, messages: staged.nextMessages } + : undefined, + db + ) + if (changed) { + const clientConversationId = getClientConversationId( + changed.conversation.type, + changed.conversation.targetId + ) + let projection = conversationProjections.get(clientConversationId) + if (!projection) { + const currentMessages = this.messagesByConversation[clientConversationId] || [] + projection = { + conversation: changed.conversation, + currentMessages, + nextMessages: [...currentMessages] + } + conversationProjections.set(clientConversationId, projection) + } else { + projection.conversation = changed.conversation + } + const recalledIndex = projection.nextMessages.findIndex( + (message) => message.id === changed.message.id + ) + if (recalledIndex >= 0) { + projection.nextMessages[recalledIndex] = changed.message + } + changedConversations.set(clientConversationId, changed.conversation) + } + } + // 2.3 写入本批变更会话 + await conversationStore.saveConversationRecord([...changedConversations.values()], tx, db) + // 2.4 写入本批游标 + await setMessageMaxId(conversationType, maxMessageId, tx, db) + }) // 3. 持久化成功后推进内存游标 - this.updateMessageCursor(conversationType, maxMessageId); - for (const item of persistedMessages.values()) { - this.updateMessageCursor(item.conversationType, item.message.id); + for (const [clientConversationId, projection] of conversationProjections) { + for (const current of projection.currentMessages) { + const next = projection.nextMessages.find((message) => isSameMessage(message, current)) + if (next && next.content !== current.content) { + revokeBlobUrlsInContent(current.content) + } + } + this.messagesByConversation[clientConversationId] = projection.nextMessages + this.touchConversationMessageCache(clientConversationId) + conversationStore.publishConversationProjection(projection.conversation, true) } + this.updateMessageCursor(conversationType, maxMessageId) }, - /** 插入消息 */ - insertMessage( + /** 把实时或本地消息投入会话串行写 lane */ + async insertMessage( conversationInfo: MessageConversationInfo, messageInfo: Message, - options?: { saveMaxId?: boolean }, + db: DbClient = getDb() + ): Promise { + const clientConversationId = getClientConversationId( + conversationInfo.type, + conversationInfo.targetId + ) + await enqueueConversationWrite(clientConversationId, () => + this.insertMessageNow(conversationInfo, messageInfo, db) + ) + return true + }, + + /** 实际插入消息;调用方必须持有当前会话写 lane */ + async insertMessageNow( + conversationInfo: MessageConversationInfo, + messageInfo: Message, + db: DbClient ): Promise { - const conversationStore = useConversationStore(); - const hasIncomingClientMessageId = !!messageInfo.clientMessageId; - const message = ensureClientMessageId(messageInfo); + const conversationStore = useConversationStore() + const hasIncomingClientMessageId = !!messageInfo.clientMessageId + let message = ensureClientMessageId(messageInfo) + const clientConversationId = getClientConversationId( + conversationInfo.type, + conversationInfo.targetId + ) + const [clearBefore, deletedKeys, recalledKeys] = await Promise.all([ + db.getSetting( + `${StorageKeys.settings.conversationClearBeforePrefix}${clientConversationId}` + ), + db.getSetting( + `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}` + ), + db.getSetting( + `${StorageKeys.settings.conversationRecalledMessagesPrefix}${clientConversationId}` + ) + ]) + const terminal = { + clearBefore: clearBefore || 0, + deletedKeys: new Set(deletedKeys || []), + recalledKeys: new Set(recalledKeys || []) + } + if (isMessageTerminated(message, terminal)) { + return + } + message = applyPersistedRecall(message, terminal) // 1. 先处理消息带来的群资料变更 - if ( - conversationInfo.type === ImConversationType.GROUP && - isGroupNotification(message.type) - ) { - useGroupStore().applyGroupNotification( - conversationInfo.targetId, - message.type, - message.content, - ); + if (conversationInfo.type === ImConversationType.GROUP) { + if (isGroupNotification(message.type)) { + await useGroupStore().applyGroupNotificationNow( + conversationInfo.targetId, + message.type, + message.content, + message.id, + db + ) + } } - // 2. 确保会话和消息缓存存在 - const conversation = - conversationStore.ensureConversation(conversationInfo); - const messages = this.getMessageList( - conversationInfo.type, - conversationInfo.targetId, - ); + // 2. 构建会话和消息的下一份投影,不提前修改响应式状态 + const conversation = conversationStore.buildConversationProjection(conversationInfo) + const currentMessages = this.messagesByConversation[clientConversationId] || [] + const messages = [...currentMessages] + let existingIndex = messages.findIndex((item) => isSameMessage(item, message)) + if (existingIndex < 0 && message.id) { + const messageId = message.id + const storedMessage = await db.get( + 'messages', + getServerMessageKey(conversationInfo.type, messageId) + ) + if (storedMessage) { + existingIndex = messages.findIndex((existing) => existing.id && messageId < existing.id) + if (existingIndex < 0) { + existingIndex = messages.length + } + messages.splice(existingIndex, 0, buildMessageFromDO(storedMessage)) + } + } const isActive = conversationStore.activeConversation?.type === conversationInfo.type && - conversationStore.activeConversation?.targetId === - conversationInfo.targetId; + conversationStore.activeConversation?.targetId === conversationInfo.targetId const isUnread = + existingIndex < 0 && !message.selfSend && !isActive && - !conversationStore.isMessageCoveredByReadPosition( - conversation, - message, - ) && + !conversationStore.isMessageCoveredByReadPosition(conversation, message) && isNormalMessage(message.type) && - message.status !== ImMessageStatus.RECALL; - const existingIndex = messages.findIndex((item) => - isSameMessage(item, message), - ); + message.status !== ImMessageStatus.RECALL // 3. 已存在消息走覆盖更新 - if (existingIndex !== -1) { - const existing = messages[existingIndex]; - if (!existing) { - return Promise.resolve(); + if (existingIndex >= 0) { + const existing = messages[existingIndex]! + const reduced = reduceMessageState( + { priority: getMessageTerminalPriority(existing), value: existing }, + { priority: getMessageTerminalPriority(message), value: message } + ) + if (reduced.value === message) { + messages[existingIndex] = buildServerMessageProjection(existing, message) } - applyServerMessageUpdate(existing, message); - if (existingIndex === messages.length - 1) { - recomputeConversationLast(conversation, messages); + if ( + existingIndex === messages.length - 1 && + shouldUpdateConversationSummary(conversation, messages[existingIndex]!) + ) { + recomputeConversationLast(conversation, messages, db) } if (isUnread) { - syncConversationAtFlags(conversation, message); + syncConversationAtFlags(conversation, message, db.userId) } - return getDb() - .transaction( - ['messages', 'conversations', 'settings'], - 'readwrite', - async (tx) => { - await this.saveMessageRecord( - existing, - conversationInfo.type, - tx, - { - mergeClientRecord: hasIncomingClientMessageId, - }, - ); - await conversationStore.saveConversationRecord(conversation, tx); - if (options?.saveMaxId !== false) { - await setMessageMaxId(conversationInfo.type, message.id, tx); - } - }, - ) - .catch((error) => { - console.error('[IM messageStore] 消息写入失败', error); - throw error; + await db + .transaction(['messages', 'conversations'], 'readwrite', async (tx) => { + await this.saveMessageRecord( + messages[existingIndex]!, + conversationInfo.type, + tx, + { + mergeClientRecord: hasIncomingClientMessageId + }, + db + ) + await conversationStore.saveConversationRecord(conversation, tx, db) }) - .then(() => { - this.updateMessageCursor(conversationInfo.type, message.id); - }); + .catch((e) => { + console.error('[IM messageStore] 消息写入失败', e) + throw e + }) + const currentMessage = currentMessages.find((item) => isSameMessage(item, message)) + if (currentMessage && currentMessage.content !== messages[existingIndex]!.content) { + revokeBlobUrlsInContent(currentMessage.content) + } + this.messagesByConversation[clientConversationId] = messages + this.touchConversationMessageCache(clientConversationId) + conversationStore.publishConversationProjection(conversation, true) + return } // 4. 新消息更新会话摘要和未读状态 - applyConversationSummary(conversation, message); + if (shouldUpdateConversationSummary(conversation, message)) { + applyConversationSummary(conversation, message, db) + } if (isUnread) { - syncConversationAtFlags(conversation, message); - conversation.unreadCount++; + syncConversationAtFlags(conversation, message, db.userId) + conversation.unreadCount++ } - // 5. 新消息按 id 插入到内存数组 - let insertIndex = messages.length; + // 5. 新消息按 id 插入到待发布数组 + let insertIndex = messages.length if (message.id) { - for (const [index, existing] of messages.entries()) { + for (let index = 0; index < messages.length; index++) { + const existing = messages[index]! if (existing.id && message.id < existing.id) { - insertIndex = index; - break; + insertIndex = index + break } } } - messages.splice(insertIndex, 0, message); - // 6. 单事务写入消息、会话摘要和游标 - return getDb() - .transaction( - ['messages', 'conversations', 'settings'], - 'readwrite', - async (tx) => { - await this.saveMessageRecord(message, conversationInfo.type, tx, { - mergeClientRecord: hasIncomingClientMessageId && !!message.id, - }); - await conversationStore.saveConversationRecord(conversation, tx); - if (options?.saveMaxId !== false) { - await setMessageMaxId(conversationInfo.type, message.id, tx); - } - }, - ) - .catch((error) => { - console.error('[IM messageStore] 消息写入失败', error); - throw error; + messages.splice(insertIndex, 0, message) + // 6. 单事务写入消息和会话摘要 + await db + .transaction(['messages', 'conversations'], 'readwrite', async (tx) => { + await this.saveMessageRecord( + message, + conversationInfo.type, + tx, + { + mergeClientRecord: hasIncomingClientMessageId && !!message.id + }, + db + ) + await conversationStore.saveConversationRecord(conversation, tx, db) }) - .then(() => { - this.updateMessageCursor(conversationInfo.type, message.id); - }); + .catch((e) => { + console.error('[IM messageStore] 消息写入失败', e) + throw e + }) + this.messagesByConversation[clientConversationId] = messages + this.touchConversationMessageCache(clientConversationId) + conversationStore.publishConversationProjection(conversation, true) }, /** ack 合并 */ @@ -914,22 +1233,24 @@ export const useMessageStore = defineStore('imMessageStore', { targetId: number, clientMessageId: string, updates: Partial, + db: DbClient = getDb() ) { - const mergeKey = `${conversationType}:${targetId}:${clientMessageId}`; - const existingPromise = ackMergingPromises.get(mergeKey); + const mergeKey = `${conversationType}:${targetId}:${clientMessageId}` + const existingPromise = ackMergingPromises.get(mergeKey) if (existingPromise) { - return existingPromise; + return existingPromise } const promise = this.doAckMessage( conversationType, targetId, clientMessageId, updates, + db ).finally(() => { - ackMergingPromises.delete(mergeKey); - }); - ackMergingPromises.set(mergeKey, promise); - return promise; + ackMergingPromises.delete(mergeKey) + }) + ackMergingPromises.set(mergeKey, promise) + return promise }, /** 执行 ack 合并 */ @@ -938,51 +1259,107 @@ export const useMessageStore = defineStore('imMessageStore', { targetId: number, clientMessageId: string, updates: Partial, + db: DbClient = getDb() ) { - // 1. 定位待合并消息 - const conversationStore = useConversationStore(); - const conversation = conversationStore.getConversation( - conversationType, - targetId, - ); - if (!conversation) { - return; + await enqueueConversationWrite(getClientConversationId(conversationType, targetId), () => + this.doAckMessageNow(conversationType, targetId, clientMessageId, updates, db) + ) + }, + + /** 实际执行 ack 合并;调用方必须持有当前会话写 lane */ + async doAckMessageNow( + conversationType: number, + targetId: number, + clientMessageId: string, + updates: Partial, + db: DbClient + ) { + // 1. 从任务绑定的 DB 读取待合并消息和会话,避免内存窗口淘汰后丢 ACK + const conversationStore = useConversationStore() + const clientConversationId = getClientConversationId(conversationType, targetId) + const currentConversation = conversationStore.getConversation(conversationType, targetId) + const currentMessages = this.messagesByConversation[clientConversationId] + const currentMessage = currentMessages?.find( + (item) => item.clientMessageId === clientMessageId + ) + const [storedMessage, storedConversation] = await Promise.all([ + db.getByIndex('messages', 'clientMessageId', clientMessageId), + db.get('conversations', clientConversationId) + ]) + if (!storedMessage) { + return } - const messages = this.getMessageList(conversationType, targetId); - const message = messages.find( - (item) => item.clientMessageId === clientMessageId, - ); - if (!message) { - return; + const message = buildMessageFromDO(storedMessage) + if (currentMessage) { + currentMessage._ackMerging = true } - message._ackMerging = true; try { - // 2. 合并服务端 ack 到内存 - applyServerMessageUpdate(message, updates); - if (messages[messages.length - 1] === message) { - recomputeConversationLast(conversation, messages); + // 2. 构建服务端 ack 的下一份投影 + const incoming = { ...message, ...updates } + const reduced = reduceMessageState( + { priority: getMessageTerminalPriority(message), value: message }, + { priority: getMessageTerminalPriority(incoming), value: incoming } + ) + let nextMessage: Message + if (reduced.value === incoming) { + nextMessage = buildServerMessageProjection(message, updates) + } else { + const nonTerminalUpdates = { ...updates } + delete nonTerminalUpdates.type + delete nonTerminalUpdates.status + delete nonTerminalUpdates.content + nextMessage = buildServerMessageProjection(message, nonTerminalUpdates) + } + nextMessage._ackMerging = undefined + const nextConversation = storedConversation + ? buildConversationFromDO(storedConversation) + : undefined + if ( + nextConversation && + (nextConversation.lastClientMessageId === clientMessageId || + (!!message.id && nextConversation.lastMessageId === message.id)) + ) { + applyConversationSummary(nextConversation, nextMessage, db) + } + // 3. 单事务写入消息和会话摘要;ACK 不推进 pull cursor + await db + .transaction(['messages', 'conversations'], 'readwrite', async (tx) => { + await this.saveMessageRecord( + nextMessage, + conversationType, + tx, + { + mergeClientRecord: true + }, + db + ) + if (nextConversation) { + await conversationStore.saveConversationRecord(nextConversation, tx, db) + } + }) + .catch((e) => { + console.error('[IM messageStore] ack 写入失败', e) + throw e + }) + // 4. 仅更新仍由当前 Store 持有的旧投影;DB-only ACK 不重新撑开 LRU 窗口 + if ( + currentMessage && + currentMessages && + this.messagesByConversation[clientConversationId] === currentMessages + ) { + applyServerMessageUpdate(currentMessage, nextMessage) + } + if ( + nextConversation && + currentConversation && + conversationStore.getConversation(conversationType, targetId) === currentConversation + ) { + conversationStore.publishConversationProjection(nextConversation, true) } - // 3. 单事务写入消息、会话摘要和游标 - await getDb() - .transaction( - ['messages', 'conversations', 'settings'], - 'readwrite', - async (tx) => { - await this.saveMessageRecord(message, conversationType, tx, { - mergeClientRecord: true, - }); - await conversationStore.saveConversationRecord(conversation, tx); - await setMessageMaxId(conversationType, message.id, tx); - }, - ) - .catch((error) => { - console.error('[IM messageStore] ack 写入失败', error); - throw error; - }); - this.updateMessageCursor(conversationType, message.id); } finally { - // 4. 清理合并标记 - message._ackMerging = false; + if (currentMessage) { + currentMessage._ackMerging = false + } } }, @@ -991,27 +1368,27 @@ export const useMessageStore = defineStore('imMessageStore', { conversationType: number, targetId: number, clientMessageId: string, - patch: Partial, + patch: Partial ) { const message = this.getMessageList(conversationType, targetId).find( - (item) => item.clientMessageId === clientMessageId, - ); + (item) => item.clientMessageId === clientMessageId + ) if (!message) { - return; + return } - let changed = false; + let changed = false for (const key in patch) { if ( Object.prototype.hasOwnProperty.call(patch, key) && (patch as Record)[key] !== (message as unknown as Record)[key] ) { - changed = true; - break; + changed = true + break } } if (changed) { - applyServerMessageUpdate(message, patch); + applyServerMessageUpdate(message, patch) } }, @@ -1020,205 +1397,388 @@ export const useMessageStore = defineStore('imMessageStore', { conversationType: number, targetId: number, recallSignalContent: string, + db: DbClient = getDb() ): Promise { - const conversationStore = useConversationStore(); - await getDb() - .transaction(['messages', 'conversations'], 'readwrite', async (tx) => { + await enqueueConversationWrite(getClientConversationId(conversationType, targetId), () => + this.recallMessageNow(conversationType, targetId, recallSignalContent, db) + ) + }, + + /** 实际应用撤回终态;调用方必须持有当前会话写 lane */ + async recallMessageNow( + conversationType: number, + targetId: number, + recallSignalContent: string, + db: DbClient + ): Promise { + const conversationStore = useConversationStore() + const changed = await db + .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => { const changed = await this.applyRecallMessageRecord( conversationType, targetId, recallSignalContent, tx, - ); + undefined, + db + ) if (!changed) { - return; + return null } - await conversationStore.saveConversationRecord( - changed.conversation, - tx, - ); + await conversationStore.saveConversationRecord(changed.conversation, tx, db) + return changed }) - .catch((error) => { - console.error('[IM messageStore] 撤回消息写入失败', error); - throw error; - }); + .catch((e) => { + console.error('[IM messageStore] 撤回消息写入失败', e) + throw e + }) + if (!changed) { + return + } + if (changed.cachedMessage) { + revokeBlobUrlsInContent(changed.cachedMessage.content) + Object.assign(changed.cachedMessage, changed.message) + } + conversationStore.publishConversationProjection(changed.conversation, true) }, /** 应用已读回执 */ - applyMessageReadReceipt(options: { - conversationType: number; - groupMessageId?: number; - privateReadMaxId?: number; - readCount?: number; - receiptStatus?: number; - targetId: number; - }) { - const messages = this.getMessageList( + async applyMessageReadReceipt( + options: { + conversationType: number + groupMessageId?: number + privateReadMaxId?: number + readCount?: number + receiptStatus?: number + targetId: number + }, + db: DbClient = getDb() + ) { + await enqueueConversationWrite( + getClientConversationId(options.conversationType, options.targetId), + () => this.applyMessageReadReceiptNow(options, db) + ) + }, + + /** 实际应用消息回执;调用方必须持有当前会话写 lane */ + async applyMessageReadReceiptNow( + options: { + conversationType: number + groupMessageId?: number + privateReadMaxId?: number + readCount?: number + receiptStatus?: number + targetId: number + }, + db: DbClient + ) { + const clientConversationId = getClientConversationId( options.conversationType, - options.targetId, - ); - const changed: Message[] = []; + options.targetId + ) + const messages = this.messagesByConversation[clientConversationId] || [] + const changed: Array<{ current: Message; next: Message }> = [] + const durableChanges = new Map() // 1. 私聊回执批量更新自己发送的消息 - if ( - options.conversationType === ImConversationType.PRIVATE && - options.privateReadMaxId - ) { - this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId); - const privateReadMaxId = options.privateReadMaxId; + if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) { messages.forEach((message) => { if ( message.selfSend && message.id && - message.id <= privateReadMaxId && + message.id <= options.privateReadMaxId! && message.receiptStatus === ImMessageReceiptStatus.PENDING ) { - message.receiptStatus = ImMessageReceiptStatus.DONE; - changed.push(message); - } - }); - } else if ( - options.conversationType === ImConversationType.GROUP && - options.groupMessageId - ) { - // 2. 群聊回执更新单条消息 - 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); - } - } - if (changed.length === 0) { - return; - } - // 3. 单事务写入变更消息 - void getDb() - .transaction(['messages'], 'readwrite', async (tx) => { - for (const message of changed) { - await this.saveMessageRecord(message, options.conversationType, tx); + changed.push({ + current: message, + next: { ...message, receiptStatus: ImMessageReceiptStatus.DONE } + }) } }) - .catch((error) => - console.warn('[IM messageStore] 回执写入失败', error), - ); + const storedMessages = await db.getAllByIndex( + 'messages', + 'clientConversationId', + clientConversationId + ) + const storedByKey = new Map(storedMessages.map((message) => [message.messageKey, message])) + storedMessages + .filter( + (message) => + message.selfSend && + !!message.id && + message.id <= options.privateReadMaxId! && + message.receiptStatus === ImMessageReceiptStatus.PENDING + ) + .forEach((message) => { + const next = { ...message, receiptStatus: ImMessageReceiptStatus.DONE } + durableChanges.set(next.messageKey, next) + }) + changed.forEach(({ next }) => { + const record = buildMessageDO(next, options.conversationType) + if (!durableChanges.has(record.messageKey)) { + durableChanges.set(record.messageKey, { + ...storedByKey.get(record.messageKey), + ...record + }) + } + }) + } else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) { + // 2. 群聊回执更新单条消息 + const message = messages.find((item) => item.id === options.groupMessageId) + const storedMessage = await db.get( + 'messages', + getServerMessageKey(options.conversationType, options.groupMessageId) + ) + const nextReadCount = + message?.readCount === undefined && + storedMessage?.readCount === undefined && + options.readCount === undefined + ? undefined + : Math.max( + message?.readCount ?? 0, + storedMessage?.readCount ?? 0, + options.readCount ?? 0 + ) + const nextReceiptStatus = + message?.receiptStatus === undefined && + storedMessage?.receiptStatus === undefined && + options.receiptStatus === undefined + ? undefined + : Math.max( + message?.receiptStatus ?? 0, + storedMessage?.receiptStatus ?? 0, + options.receiptStatus ?? 0 + ) + if (message) { + const next = { + ...message, + readCount: nextReadCount, + receiptStatus: nextReceiptStatus + } + if ( + next.readCount !== message.readCount || + next.receiptStatus !== message.receiptStatus + ) { + changed.push({ current: message, next }) + } + } + if (storedMessage) { + const next = { + ...storedMessage, + readCount: nextReadCount, + receiptStatus: nextReceiptStatus + } + if ( + next.readCount !== storedMessage.readCount || + next.receiptStatus !== storedMessage.receiptStatus + ) { + durableChanges.set(next.messageKey, next) + } + } else if (message && changed.length > 0) { + const record = buildMessageDO(changed[0]!.next, options.conversationType) + durableChanges.set(record.messageKey, record) + } + } + if (changed.length === 0 && durableChanges.size === 0) { + if (options.conversationType === ImConversationType.PRIVATE) { + this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId) + } + return + } + // 3. 单事务写入变更消息 + await db + .transaction(['messages'], 'readwrite', async (tx) => { + for (const message of durableChanges.values()) { + await db.put('messages', message, tx) + } + }) + .catch((e) => { + console.warn('[IM messageStore] 回执写入失败', e) + throw e + }) + changed.forEach((item) => Object.assign(item.current, item.next)) + if (options.conversationType === ImConversationType.PRIVATE) { + this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId) + } }, /** 前置历史消息 */ - prependMessageList( + async prependMessageList( conversationType: number, targetId: number, earlierMessages: Message[], + db: DbClient = getDb() + ): Promise { + await enqueueConversationWrite(getClientConversationId(conversationType, targetId), () => + this.prependMessageListNow(conversationType, targetId, earlierMessages, db) + ) + }, + + /** 实际前置历史消息;调用方必须持有当前会话写 lane */ + async prependMessageListNow( + conversationType: number, + targetId: number, + earlierMessages: Message[], + db: DbClient ) { if (earlierMessages.length === 0) { - return; + return } - const messages = this.getMessageList(conversationType, targetId); - const existingIds = new Set( - messages.map((message) => message.id).filter(Boolean), - ); + const clientConversationId = getClientConversationId(conversationType, targetId) + const terminal = await getConversationMessageTerminal(clientConversationId, db) + const messages = this.messagesByConversation[clientConversationId] || [] + const existingIds = new Set(messages.map((message) => message.id).filter(Boolean)) const fresh = earlierMessages - .map((message) => ensureClientMessageId(message)) - .filter((message) => message.id && !existingIds.has(message.id)) - .toSorted( - (messageA, messageB) => (messageA.id || 0) - (messageB.id || 0), - ); + .map(ensureClientMessageId) + .filter( + (message) => + message.id && !existingIds.has(message.id) && !isMessageTerminated(message, terminal) + ) + .map((message) => applyPersistedRecall(message, terminal)) + .sort((messageA, messageB) => (messageA.id || 0) - (messageB.id || 0)) if (fresh.length === 0) { - return; + return } - const key = getMessageCacheKey(conversationType, targetId); - this.messagesByConversation[key] = [...fresh, ...messages]; - void getDb() + const key = getClientConversationId(conversationType, targetId) + const nextMessages = [...fresh, ...messages] + await db .transaction(['messages'], 'readwrite', async (tx) => { for (const message of fresh) { - await this.saveMessageRecord(message, conversationType, tx); + await this.saveMessageRecord(message, conversationType, tx, undefined, db) } }) - .catch((error) => - console.warn('[IM messageStore] 历史消息写入失败', error), - ); + .catch((e) => { + console.warn('[IM messageStore] 历史消息写入失败', e) + throw e + }) + this.messagesByConversation[key] = nextMessages + this.touchConversationMessageCache(key) }, /** 删除单条消息 */ - removeMessage( + async removeMessage( conversationType: number, targetId: number, - key: { clientMessageId?: string; id?: number }, + key: { clientMessageId?: string; id?: number; }, + db: DbClient = getDb() + ) { + await enqueueConversationWrite(getClientConversationId(conversationType, targetId), () => + this.removeMessageNow(conversationType, targetId, key, db) + ) + }, + + /** 实际持久删除单条消息;调用方必须持有当前会话写 lane */ + async removeMessageNow( + conversationType: number, + targetId: number, + key: { clientMessageId?: string; id?: number; }, + db: DbClient ) { // 1. 定位会话和消息 - const conversationStore = useConversationStore(); - const conversation = conversationStore.getConversation( - conversationType, - targetId, - ); + const conversationStore = useConversationStore() + const conversation = conversationStore.getConversation(conversationType, targetId) if (!conversation) { - return; + return } - const messages = this.getMessageList(conversationType, targetId); + const messages = this.getMessageList(conversationType, targetId) const index = messages.findIndex((message) => { if (key.id && message.id && message.id === key.id) { - return true; + return true } - return ( - !!key.clientMessageId && - message.clientMessageId === key.clientMessageId - ); - }); - if (index === -1) { - return; + return !!key.clientMessageId && message.clientMessageId === key.clientMessageId + }) + if (index < 0) { + return } - // 2. 从内存移除消息 - const [removed] = messages.splice(index, 1); - if (!removed) { - return; - } - revokeBlobUrlsInContent(removed.content); - if (index === messages.length) { - recomputeConversationLast(conversation, messages); - } - // 3. 删除本地记录并保存会话摘要 - getDb() - .delete('messages', getMessageKey(removed, conversationType)) - .catch((error) => - console.warn('[IM messageStore] 消息删除失败', error), - ); - conversationStore.saveConversation(conversation); + const removed = messages[index]! + const clientConversationId = getClientConversationId(conversationType, targetId) + let nextMessages = messages.filter((_, messageIndex) => messageIndex !== index) + const nextConversation = { ...conversation } + // 2. 先持久化 delete key,再删除消息并保存会话摘要 + await db.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => { + const settingKey = `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}` + const oldKeys = (await db.getSetting(settingKey, tx)) || [] + const deletedKeys = [ + ...(removed.id ? [`id:${removed.id}`] : []), + ...(removed.clientMessageId ? [`client:${removed.clientMessageId}`] : []) + ] + await db.setSetting(settingKey, Array.from(new Set([...oldKeys, ...deletedKeys])), tx) + await db.delete('messages', getMessageKey(removed, conversationType), tx) + if (index === nextMessages.length) { + if (nextMessages.length === 0) { + const storedMessages = await db.getAllByIndex( + 'messages', + 'clientConversationId', + clientConversationId, + tx + ) + const latest = storedMessages.sort(compareConversationSummaryOrder).at(-1) + nextMessages = latest ? [buildMessageFromDO(latest)] : [] + } + recomputeConversationLast(nextConversation, nextMessages, db) + } + await conversationStore.saveConversationRecord(nextConversation, tx, db) + }) + // 3. 事务提交后发布内存投影 + revokeBlobUrlsInContent(removed.content) + this.messagesByConversation[clientConversationId] = nextMessages + conversationStore.publishConversationProjection(nextConversation, true) }, - /** 删除会话全部消息 */ - deleteConversationMessageList(conversationType: number, targetId: number) { - // 1. 清理内存消息和媒体资源 - const clientConversationId = getClientConversationId( - conversationType, - targetId, - ); - const messages = this.messagesByConversation[clientConversationId] || []; + /** 实际清空会话消息;调用方必须持有当前会话写 lane */ + async deleteConversationMessageListNow( + conversationType: number, + targetId: number, + db: DbClient + ) { + const clientConversationId = getClientConversationId(conversationType, targetId) + const messages = this.messagesByConversation[clientConversationId] || [] + const conversation = useConversationStore().getConversation(conversationType, targetId) + // 1. 先持久化 clear watermark,再删除当前会话消息 + await db.transaction(['messages', 'settings'], 'readwrite', async (tx) => { + const storedMessages = await db.getAllByIndex( + 'messages', + 'clientConversationId', + clientConversationId, + tx + ) + const settingKey = `${StorageKeys.settings.conversationClearBeforePrefix}${clientConversationId}` + const oldClearBefore = (await db.getSetting(settingKey, tx)) || 0 + const deletedSettingKey = `${StorageKeys.settings.conversationDeletedMessagesPrefix}${clientConversationId}` + const oldDeletedKeys = (await db.getSetting(deletedSettingKey, tx)) || [] + const pendingClientKeys = new Set( + [...storedMessages, ...messages] + .filter((message) => !message.id && !!message.clientMessageId) + .map((message) => `client:${message.clientMessageId}`) + ) + const clearBefore = Math.max( + oldClearBefore, + conversation?.lastMessageId || 0, + ...storedMessages.map((message) => message.id || 0) + ) + await db.setSetting(settingKey, clearBefore, tx) + if (pendingClientKeys.size > 0) { + await db.setSetting( + deletedSettingKey, + Array.from(new Set([...oldDeletedKeys, ...pendingClientKeys])), + tx + ) + } + await db.deleteByIndex('messages', 'clientConversationId', clientConversationId, tx) + }) + // 2. 事务提交后清理内存消息和媒体资源 messages.forEach((message) => { - revokeBlobUrlsInContent(message.content); - message._localFile = undefined; - }); - Reflect.deleteProperty(this.messagesByConversation, clientConversationId); - Reflect.deleteProperty(this.messageDOPageCursors, clientConversationId); + revokeBlobUrlsInContent(message.content) + message._localFile = undefined + }) + delete this.messagesByConversation[clientConversationId] + delete this.messageDOPageCursors[clientConversationId] this.loadedConversationKeys = this.loadedConversationKeys.filter( - (key) => key !== clientConversationId, - ); - // 2. 删除 IndexedDB 消息 - getDb() - .deleteByIndex('messages', 'clientConversationId', clientConversationId) - .catch((error) => - console.warn('[IM messageStore] 会话消息删除失败', error), - ); - }, - }, -}); - -export const useMessageStoreWithOut = () => useMessageStore(); + (key) => key !== clientConversationId + ) + } + } +}) if (import.meta.hot) { - import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot)); + import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot)) } diff --git a/apps/web-antd/src/views/im/home/store/rtcStore.ts b/apps/web-antd/src/views/im/home/store/rtcStore.ts index 1137cf906..92439b7dc 100644 --- a/apps/web-antd/src/views/im/home/store/rtcStore.ts +++ b/apps/web-antd/src/views/im/home/store/rtcStore.ts @@ -1,94 +1,96 @@ -import type { - ImRtcCallEndReasonValue, - ImRtcCallStageValue, - ImRtcParticipantStatusValue, -} from '../../utils/constants'; +import type { ImRtcApi } from '#/api/im/rtc' -import type { ImRtcApi } from '#/api/im/rtc'; +import { computed, ref } from 'vue' -import { computed, ref } from 'vue'; +import { isEqual } from '@vben/utils' -import { defineStore } from 'pinia'; +import { defineStore } from 'pinia' -import { getCurrentUserId } from '#/views/im/utils/auth'; +import { getCurrentUserId } from '#/views/im/utils/auth' import { ImConversationType, + type ImRtcCallEndReasonValue, ImRtcCallStage, + type ImRtcCallStageValue, ImRtcCallStatus, -} from '../../utils/constants'; -import { useFriendStore } from './friendStore'; -import { useGroupStore } from './groupStore'; + type ImRtcParticipantStatusValue +} from '../../utils/constants' +import { useFriendStore } from './friendStore' +import { useGroupStore } from './groupStore' + +type ImRtcCallRespVO = ImRtcApi.RtcCallRespVO +type ImRtcGroupCallRespVO = ImRtcApi.RtcGroupCallRespVO type GroupActiveCallCache = { - participantsLoaded?: boolean; // 是否已拉取完整参与者列表 -} & ImRtcApi.RtcGroupCallRespVO; + participantsLoaded?: boolean // 是否已拉取完整参与者列表 +} & ImRtcGroupCallRespVO // RTC_CALL 通话信令载荷;按 status 区分子类型语义 export interface ImRtcCallNotification { - status: ImRtcParticipantStatusValue; - room: string; - conversationType: number; - mediaType: number; - groupId?: number; + status: ImRtcParticipantStatusValue + room: string + conversationType: number + mediaType: number + groupId?: number // INVITE 专属:被叫接通需要的 LiveKit 连接参数 + 主叫展示信息 - livekitUrl?: string; - token?: string; - inviterUserId?: number; - inviterNickname?: string; - inviterAvatar?: string; + livekitUrl?: string + token?: string + inviterUserId?: number + inviterNickname?: string + inviterAvatar?: string // INVITE 专属:本次被邀请人列表;包含收件人自身,前端来电小条按需过滤展示「邀请的其他人」 - inviteeIds?: number[]; + inviteeIds?: number[] // REJECT 专属:操作者展示信息(其它子类型走 RTC_CALL_END) - operatorUserId?: number; - operatorNickname?: string; - operatorAvatar?: string; + operatorUserId?: number + operatorNickname?: string + operatorAvatar?: string } // RTC_PARTICIPANT_CONNECTED 通话参与者加入载荷(LiveKit webhook 转推) export interface ImRtcParticipantConnectedNotification { - room: string; - userId: number; - conversationType: number; - groupId?: number; + room: string + userId: number + conversationType: number + groupId?: number // 群聊场景非邀请成员首次填充胶囊条用 - mediaType?: number; - inviterUserId?: number; + mediaType?: number + inviterUserId?: number } // RTC_PARTICIPANT_DISCONNECTED 通话参与者离开载荷(LiveKit webhook 转推) export interface ImRtcParticipantDisconnectedNotification { - room: string; - userId: number; - conversationType: number; - groupId?: number; + room: string + userId: number + conversationType: number + groupId?: number } // RTC_CALL_END 通话结束载荷(入消息流;私聊渲染消息气泡,群聊渲染系统提示行) export interface ImRtcCallEndNotification { - room: string; - conversationType: number; - mediaType: number; - endReason: ImRtcCallEndReasonValue; - durationSeconds?: number; + room: string + conversationType: number + mediaType: number + endReason: ImRtcCallEndReasonValue + durationSeconds?: number // 操作者聚合字段:HANGUP/CANCEL/REJECT 触发人;webhook 兜底为 null - operatorUserId?: number; - operatorNickname?: string; - operatorAvatar?: string; + operatorUserId?: number + operatorNickname?: string + operatorAvatar?: string } export const useRtcStore = defineStore('imRtc', () => { /** 当前阶段 */ - const stage = ref(ImRtcCallStage.IDLE); + const stage = ref(ImRtcCallStage.IDLE) /** 当前通话;invite / accept / refreshToken 拿到的完整信息 */ - const call = ref(null); + const call = ref(null) /** 来电载荷;仅 INCOMING 阶段使用;status 固定 INVITING,其它字段 INVITE 专属 */ - const incomingPayload = ref(null); + const incomingPayload = ref(null) /** 进入 RUNNING 的时间戳;用于通话时长展示;reset 时清零 */ - const startedAt = ref(0); + const startedAt = ref(0) /** 是否处于通话相关阶段 */ - const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE); + const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE) /** * 对端展示名;按阶段 + 会话类型分支: @@ -96,61 +98,57 @@ export const useRtcStore = defineStore('imRtc', () => { */ const peerNickname = computed(() => { if (stage.value === ImRtcCallStage.INCOMING) { - return incomingPayload.value?.inviterNickname || ''; + return incomingPayload.value?.inviterNickname || '' } - const c = call.value; - if (!c) return ''; + const c = call.value + if (!c) return '' if (c.conversationType === ImConversationType.GROUP) { - return useGroupStore().getGroup(c.groupId ?? 0)?.name || ''; + return useGroupStore().getGroup(c.groupId ?? 0)?.name || '' } - const peerUserId = resolvePrivatePeerUserId(c); - return ( - (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || '' - ); - }); + const peerUserId = resolvePrivatePeerUserId(c) + return (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || '' + }) /** 对端头像;策略同 peerNickname */ const peerAvatar = computed(() => { if (stage.value === ImRtcCallStage.INCOMING) { - return incomingPayload.value?.inviterAvatar || ''; + return incomingPayload.value?.inviterAvatar || '' } - const c = call.value; - if (!c) return ''; + const c = call.value + if (!c) return '' if (c.conversationType === ImConversationType.GROUP) { - return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || ''; + return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || '' } - const peerUserId = resolvePrivatePeerUserId(c); - return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || ''; - }); + const peerUserId = resolvePrivatePeerUserId(c) + return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || '' + }) /** 私聊场景对端 userId:自己是主叫则取首个 invitee,否则取 inviter */ - function resolvePrivatePeerUserId( - c: ImRtcApi.RtcCallRespVO, - ): number | undefined { - const myId = getCurrentUserId(); - return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId; + function resolvePrivatePeerUserId(c: ImRtcCallRespVO): number | undefined { + const myId = getCurrentUserId() + return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId } /** 群活跃通话索引;groupId -> 群通话摘要;用于群聊顶部胶囊条 */ - const groupActiveCalls = ref>(new Map()); + const groupActiveCalls = ref>(new Map()) /** * 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除; * 来源:参与者离开通知 + 群通话单人拒绝的 operatorUserId;通话结束(reset)时清空 */ - const leftUserIds = ref>(new Set()); + const leftUserIds = ref>(new Set()) /** 是否已记录某 userId 已退出 / 拒绝 */ function isUserLeft(userId: number): boolean { - return leftUserIds.value.has(userId); + return leftUserIds.value.has(userId) } /** 标记某个 userId 已退出 / 拒绝;用于 pending 占位渲染时排除 */ function markUserLeft(userId: number) { if (!userId || leftUserIds.value.has(userId)) { - return; + return } - leftUserIds.value = new Set([...leftUserIds.value, userId]); + leftUserIds.value = new Set([...leftUserIds.value, userId]) } /** @@ -158,30 +156,30 @@ export const useRtcStore = defineStore('imRtc', () => { * 群通话:发起人直接进 RUNNING 多人卡片视图,房内可能只有自己,等其他人陆续加入; * 私聊:按 status 走;RUNNING(已加入已有通话场景)→ RUNNING;CREATED → INVITING 等被叫接通 */ - function startInviting(data: ImRtcApi.RtcCallRespVO) { - call.value = data; + function startInviting(data: ImRtcCallRespVO) { + call.value = data // 群通话场景写入本地胶囊条缓存 - syncGroupActiveCall(data); + syncGroupActiveCall(data) // 更新 stage 状态 if (data.conversationType === ImConversationType.GROUP) { - stage.value = ImRtcCallStage.RUNNING; - startedAt.value = Date.now(); - return; + stage.value = ImRtcCallStage.RUNNING + startedAt.value = Date.now() + return } - const running = data.status === ImRtcCallStatus.RUNNING; - stage.value = running ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING; + const running = data.status === ImRtcCallStatus.RUNNING + stage.value = running ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING if (running) { - startedAt.value = Date.now(); + startedAt.value = Date.now() } } /** 被叫收到来电;切到 INCOMING;接收 RTC_CALL(INVITE) payload */ function showIncoming(payload: ImRtcCallNotification) { if (isActive.value) { - return; + return } - incomingPayload.value = payload; - stage.value = ImRtcCallStage.INCOMING; + incomingPayload.value = payload + stage.value = ImRtcCallStage.INCOMING // 按 inviter 兜底首次填充胶囊条 syncGroupActiveCall({ conversationType: payload.conversationType, @@ -190,19 +188,19 @@ export const useRtcStore = defineStore('imRtc', () => { mediaType: payload.mediaType, inviterId: payload.inviterUserId ?? 0, joinedUserIds: payload.inviterUserId ? [payload.inviterUserId] : [], - inviteeIds: payload.inviteeIds, - }); + inviteeIds: payload.inviteeIds + }) } /** 进入通话中阶段 */ - function enterRunning(data: ImRtcApi.RtcCallRespVO) { - call.value = data; + function enterRunning(data: ImRtcCallRespVO) { + call.value = data // 离开 INCOMING 阶段;清空来电载荷 - incomingPayload.value = null; - stage.value = ImRtcCallStage.RUNNING; - startedAt.value = Date.now(); + incomingPayload.value = null + stage.value = ImRtcCallStage.RUNNING + startedAt.value = Date.now() // 接通后用 RespVO 完整覆盖胶囊条 - syncGroupActiveCall(data); + syncGroupActiveCall(data) } /** @@ -211,16 +209,16 @@ export const useRtcStore = defineStore('imRtc', () => { * 被叫场景通知载荷无 joinedUserIds,调用方按主叫人兜底,后续 getActiveCall / 参与者事件刷新成完整列表 */ function syncGroupActiveCall(input: { - conversationType: number; - groupId?: number; - inviteeIds?: number[]; - inviterId: number; - joinedUserIds?: number[]; - mediaType: number; - room: string; + conversationType: number + groupId?: number + inviteeIds?: number[] + inviterId: number + joinedUserIds?: number[] + mediaType: number + room: string }) { if (input.conversationType !== ImConversationType.GROUP || !input.groupId) { - return; + return } // 写入或更新群活跃通话缓存 setGroupCall({ @@ -229,31 +227,31 @@ export const useRtcStore = defineStore('imRtc', () => { mediaType: input.mediaType, inviterId: input.inviterId, joinedUserIds: input.joinedUserIds ?? [], - inviteeIds: input.inviteeIds ?? [], - }); + inviteeIds: input.inviteeIds ?? [] + }) } /** 重置;通话结束统一调用 */ function reset() { - stage.value = ImRtcCallStage.IDLE; - call.value = null; - incomingPayload.value = null; - startedAt.value = 0; - leftUserIds.value = new Set(); + stage.value = ImRtcCallStage.IDLE + call.value = null + incomingPayload.value = null + startedAt.value = 0 + leftUserIds.value = new Set() } /** 通话中追加被邀请人;让 participants 网格出现 pending 占位、胶囊条同步更新 */ function appendInvitees(userIds: number[]) { if (!call.value || userIds.length === 0) { - return; + return } - const existing = call.value.inviteeIds ?? []; - const merged = [...new Set([...existing, ...userIds])]; + const existing = call.value.inviteeIds ?? [] + const merged = Array.from(new Set([...existing, ...userIds])) if (merged.length === existing.length) { - return; + return } - call.value = { ...call.value, inviteeIds: merged }; - syncGroupActiveCall(call.value); + call.value = { ...call.value, inviteeIds: merged } + syncGroupActiveCall(call.value) } // ==================== 群通话胶囊条状态 ==================== @@ -263,117 +261,85 @@ export const useRtcStore = defineStore('imRtc', () => { * 房内成员同步交给 LiveKit 客户端事件(ParticipantConnected / Disconnected); * 胶囊条不实时刷新 joinedUserIds / inviteeIds,展开 / 加入时再走 getActiveCall 接口拉最新 */ - function setGroupCall( - payload: ImRtcApi.RtcGroupCallRespVO, - participantsLoaded?: boolean, - ) { + function setGroupCall(payload: ImRtcGroupCallRespVO, participantsLoaded?: boolean) { if (!payload?.groupId) { - return; + return } - useGroupStore().markGroupActiveCallLoaded(payload.groupId); + useGroupStore().markGroupActiveCallLoaded(payload.groupId) // 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 - const existing = groupActiveCalls.value.get(payload.groupId); + const existing = groupActiveCalls.value.get(payload.groupId) const nextParticipantsLoaded = - participantsLoaded ?? - (existing?.room === payload.room && !!existing.participantsLoaded); + participantsLoaded ?? (existing?.room === payload.room && !!existing.participantsLoaded) if ( existing && isSameGroupCall(existing, payload) && !!existing.participantsLoaded === nextParticipantsLoaded ) { - return; + return } - const newGroupActiveCalls = new Map(groupActiveCalls.value); + const newGroupActiveCalls = new Map(groupActiveCalls.value) newGroupActiveCalls.set(payload.groupId, { ...payload, - participantsLoaded: nextParticipantsLoaded, - }); - groupActiveCalls.value = newGroupActiveCalls; + participantsLoaded: nextParticipantsLoaded + }) + groupActiveCalls.value = newGroupActiveCalls } /** 清空指定群的通话缓存 */ function clearGroupCallCache(groupId?: number) { if (!groupId) { - groupActiveCalls.value = new Map(); - return; + groupActiveCalls.value = new Map() + return } - const next = new Map(groupActiveCalls.value); - next.delete(groupId); - groupActiveCalls.value = next; + const next = new Map(groupActiveCalls.value) + next.delete(groupId) + groupActiveCalls.value = next } /** 判断群通话是否已补齐 */ - function isGroupCallParticipantsLoaded( - groupId: number, - room?: string, - ): boolean { - const call = groupActiveCalls.value.get(groupId); - return ( - !!groupId && - !!room && - !!call && - call.room === room && - !!call.participantsLoaded - ); + function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean { + const call = groupActiveCalls.value.get(groupId) + return !!groupId && !!room && !!call && call.room === room && !!call.participantsLoaded } /** 两条群通话摘要内容相等(room / mediaType / inviterId / 两个 userId 数组逐项相等) */ - function isSameGroupCall( - a: ImRtcApi.RtcGroupCallRespVO, - b: ImRtcApi.RtcGroupCallRespVO, - ): boolean { - if ( - a.room !== b.room || - a.mediaType !== b.mediaType || - a.inviterId !== b.inviterId - ) { - return false; + function isSameGroupCall(a: ImRtcGroupCallRespVO, b: ImRtcGroupCallRespVO): boolean { + if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) { + return false } return ( - isSameNumberList(a.joinedUserIds ?? [], b.joinedUserIds ?? []) && - isSameNumberList(a.inviteeIds ?? [], b.inviteeIds ?? []) - ); - } - - /** 判断两个用户编号列表是否逐项相等 */ - function isSameNumberList(a: number[], b: number[]): boolean { - if (a.length !== b.length) { - return false; - } - return a.every((item, index) => item === b[index]); + isEqual(a.joinedUserIds ?? [], b.joinedUserIds ?? []) && + isEqual(a.inviteeIds ?? [], b.inviteeIds ?? []) + ) } /** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */ function removeGroupCall(groupId: number, room?: string) { if (!groupId) { - return; + return } - const existing = groupActiveCalls.value.get(groupId); + const existing = groupActiveCalls.value.get(groupId) if (room && existing?.room !== room) { - return; + return } - clearGroupCallCache(groupId); - useGroupStore().markGroupActiveCallLoaded(groupId); + clearGroupCallCache(groupId) + useGroupStore().markGroupActiveCallLoaded(groupId) } /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ - function getGroupCall( - groupId: number, - ): ImRtcApi.RtcGroupCallRespVO | undefined { - return groupActiveCalls.value.get(groupId); + function getGroupCall(groupId: number): ImRtcGroupCallRespVO | undefined { + return groupActiveCalls.value.get(groupId) } /** 通话参与者加入:把 userId 加进 joinedUserIds;群聊场景无活跃记录时首次填充胶囊条 */ - function applyParticipantConnected( - payload: ImRtcParticipantConnectedNotification, - ) { - const isGroup = payload.conversationType === ImConversationType.GROUP; + function applyParticipantConnected(payload: ImRtcParticipantConnectedNotification) { + const isGroup = payload.conversationType === ImConversationType.GROUP if (!isGroup || !payload.groupId) { - return; + return } // 胶囊条懒填充:本端可能在通话开始后才打开该群会话,没收到过 setGroupCall; // 此处用加入通知建一条最小记录,inviteeIds 留空,展开 popover / 加入时再走 getActiveCall 补 - const existing = groupActiveCalls.value.get(payload.groupId); + const existing = groupActiveCalls.value.get(payload.groupId) if (!existing) { setGroupCall({ room: payload.room, @@ -381,94 +347,72 @@ export const useRtcStore = defineStore('imRtc', () => { mediaType: payload.mediaType ?? 0, inviterId: payload.inviterUserId ?? 0, joinedUserIds: [payload.userId], - inviteeIds: [], - }); - return; + inviteeIds: [] + }) + return } if (existing.room !== payload.room) { - return; + return } - const joined = existing.joinedUserIds ?? []; + const joined = existing.joinedUserIds ?? [] if (joined.includes(payload.userId)) { - return; + return } - setGroupCall({ ...existing, joinedUserIds: [...joined, payload.userId] }); + setGroupCall({ ...existing, joinedUserIds: [...joined, payload.userId] }) } /** 通话参与者离开:从 joinedUserIds 移除;同时标记 leftUserIds(pending 占位渲染排除) */ - function applyParticipantDisconnected( - payload: ImRtcParticipantDisconnectedNotification, - ) { - markUserLeft(payload.userId); - const isGroup = payload.conversationType === ImConversationType.GROUP; + function applyParticipantDisconnected(payload: ImRtcParticipantDisconnectedNotification) { + markUserLeft(payload.userId) + const isGroup = payload.conversationType === ImConversationType.GROUP if (!isGroup || !payload.groupId) { - return; + return } - dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId); + dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId) } /** 群通话单人拒绝邀请:标记 leftUserIds + 从胶囊条 inviteeIds 移除(私聊拒绝走 RTC_CALL_END,不入本通道) */ function applyParticipantRejected( - payload: Pick< - ImRtcCallNotification, - 'conversationType' | 'groupId' | 'operatorUserId' | 'room' - >, + payload: Pick ) { if (!payload.operatorUserId) { - return; + return } - markUserLeft(payload.operatorUserId); - if ( - payload.conversationType === ImConversationType.GROUP && - payload.groupId - ) { - dropFromGroupActiveCall( - payload.groupId, - payload.room, - payload.operatorUserId, - ); + markUserLeft(payload.operatorUserId) + if (payload.conversationType === ImConversationType.GROUP && payload.groupId) { + dropFromGroupActiveCall(payload.groupId, payload.room, payload.operatorUserId) } } /** 群通话单人振铃超时;对 banner 的处理与拒接一致(语义独立、实现共享) */ function applyParticipantNoAnswer( - payload: Pick< - ImRtcCallNotification, - 'conversationType' | 'groupId' | 'operatorUserId' | 'room' - >, + payload: Pick ) { - applyParticipantRejected(payload); + applyParticipantRejected(payload) } /** 从指定群活跃通话的 joined / pending 列表里同步移除某用户;用于 disconnect / reject 让胶囊条不再展示 */ - function dropFromGroupActiveCall( - groupId: number, - room: string, - userId: number, - ) { - const existing = groupActiveCalls.value.get(groupId); + function dropFromGroupActiveCall(groupId: number, room: string, userId: number) { + const existing = groupActiveCalls.value.get(groupId) if (!existing || existing.room !== room) { - return; + return } - const joined = existing.joinedUserIds ?? []; - const invitee = existing.inviteeIds ?? []; - const nextJoined = joined.filter((id) => id !== userId); - const nextInvitee = invitee.filter((id) => id !== userId); - if ( - nextJoined.length === joined.length && - nextInvitee.length === invitee.length - ) { - return; + const joined = existing.joinedUserIds ?? [] + const invitee = existing.inviteeIds ?? [] + const nextJoined = joined.filter((id) => id !== userId) + const nextInvitee = invitee.filter((id) => id !== userId) + if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) { + return } if (nextJoined.length === 0 && nextInvitee.length === 0) { - removeGroupCall(groupId, room); - return; + removeGroupCall(groupId, room) + return } setGroupCall({ ...existing, joinedUserIds: nextJoined, - inviteeIds: nextInvitee, - }); + inviteeIds: nextInvitee + }) } return { @@ -487,13 +431,13 @@ export const useRtcStore = defineStore('imRtc', () => { markUserLeft, isUserLeft, setGroupCall, - isGroupCallParticipantsLoaded, - clearGroupCallCache, removeGroupCall, getGroupCall, + isGroupCallParticipantsLoaded, + clearGroupCallCache, applyParticipantConnected, applyParticipantDisconnected, applyParticipantRejected, - applyParticipantNoAnswer, - }; -}); + applyParticipantNoAnswer + } +}) diff --git a/apps/web-antd/src/views/im/home/store/websocketStore.ts b/apps/web-antd/src/views/im/home/store/websocketStore.ts index 4d2136b1a..cb6909971 100644 --- a/apps/web-antd/src/views/im/home/store/websocketStore.ts +++ b/apps/web-antd/src/views/im/home/store/websocketStore.ts @@ -1,4 +1,3 @@ -// oxlint-disable no-console import type { Group, ImGroupMessageNotification, @@ -8,33 +7,26 @@ import type { ImNotificationWebSocketDTO, ImPrivateMessageNotification, Message, - WebSocketFrame, -} from '../types'; -import type { FriendNotificationPayload } from './friendStore'; -import type { - ImRtcCallEndNotification, - ImRtcCallNotification, - ImRtcParticipantConnectedNotification, - ImRtcParticipantDisconnectedNotification, -} from './rtcStore'; + WebSocketFrame +} from '../types' -import type { ImChannelMessageApi } from '#/api/im/message/channel'; +import type { ImChannelMessageApi } from '#/api/im/message/channel' -import { acceptHMRUpdate, defineStore } from 'pinia'; +import { acceptHMRUpdate, defineStore } from 'pinia' -import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel'; -import { readGroupMessages as apiReadGroupMessages } from '#/api/im/message/group'; -import { readPrivateMessages as apiReadPrivateMessages } from '#/api/im/message/private'; -import { getCurrentUserId, getRefreshToken } from '#/views/im/utils/auth'; +import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel' +import { readGroupMessages as apiReadGroupMessages } from '#/api/im/message/group' +import { readPrivateMessages as apiReadPrivateMessages } from '#/api/im/message/private' +import { getCurrentUserId, getRefreshToken } from '#/views/im/utils/auth' -import { buildChannelConversationStub } from '../../utils/channel'; +import { buildChannelConversationStub } from '../../utils/channel' import { MESSAGE_GROUP_READ_ENABLED, MESSAGE_PRIVATE_READ_ENABLED, WS_RECONNECT_BASE_MS, WS_RECONNECT_JITTER_MS, - WS_RECONNECT_MAX_MS, -} from '../../utils/config'; + WS_RECONNECT_MAX_MS +} from '../../utils/config' import { ImContentType, ImConversationType, @@ -46,121 +38,86 @@ import { isFriendChatTip, isFriendNotification, isGroupRequestNotification, - isNormalMessage, -} from '../../utils/constants'; + isNormalMessage +} from '../../utils/constants' +import { getDb } from '../../utils/db' import { getPrivateMessagePeerId, parseRtcCallPayload, playAudioTip, - resolveCallEndReasonText, -} from '../../utils/message'; -import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'; -import { useConversationStore } from './conversationStore'; -import { useFriendStore } from './friendStore'; -import { useGroupRequestStore } from './groupRequestStore'; -import { useGroupStore } from './groupStore'; -import { useMessageStore } from './messageStore'; -import { useRtcStore } from './rtcStore'; + resolveCallEndReasonText +} from '../../utils/message' +import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user' +import { useConversationStore } from './conversationStore' +import { type FriendNotificationPayload, useFriendStore } from './friendStore' +import { useGroupRequestStore } from './groupRequestStore' +import { useGroupStore } from './groupStore' +import { useMessageStore } from './messageStore' +import { + type ImRtcCallEndNotification, + type ImRtcCallNotification, + type ImRtcParticipantConnectedNotification, + type ImRtcParticipantDisconnectedNotification, + useRtcStore +} from './rtcStore' + +type ImChannelMessageRespVO = ImChannelMessageApi.ChannelMessageRespVO /** FRIEND_DELETE 帧 payload 是否带 clear=true:clear 语义是清会话本身,跳过气泡渲染 */ -const isFriendDeleteWithClear = ( - frame: ImPrivateMessageNotification, -): boolean => { +const isFriendDeleteWithClear = (frame: ImPrivateMessageNotification): boolean => { if (frame.type !== ImContentType.FRIEND_DELETE) { - return false; + return false } try { - const payload = JSON.parse(frame.content || '{}') as { clear?: boolean }; - return payload.clear === true; + const payload = JSON.parse(frame.content || '{}') as { clear?: boolean } + return payload.clear === true } catch { - return false; + return false } -}; +} /** 从私聊消息帧解析好友通知 payload */ const parseFriendNotificationPayload = ( - frame: ImPrivateMessageNotification, -): FriendNotificationPayload => - JSON.parse(frame.content || '{}') as FriendNotificationPayload; + frame: ImPrivateMessageNotification +): FriendNotificationPayload => JSON.parse(frame.content || '{}') as FriendNotificationPayload /** 私聊消息帧是否可推断好友对端 */ const isPrivateMessageNotification = ( - frame: ImNoConversationNotification | ImPrivateMessageNotification, -): frame is ImPrivateMessageNotification => - 'senderId' in frame && 'receiverId' in frame; + frame: ImNoConversationNotification | ImPrivateMessageNotification +): frame is ImPrivateMessageNotification => 'senderId' in frame && 'receiverId' in frame -const RTC_LIVEKIT_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:']); -const RTC_MEDIA_TYPES = new Set(Object.values(ImRtcCallMediaType)); +const RTC_LIVEKIT_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:']) +const RTC_MEDIA_TYPES = new Set(Object.values(ImRtcCallMediaType)) /** 忽略普通实时帧持久化失败 */ function ignoreRealtimePersistError(promise: Promise): void { - void promise.catch(() => undefined); -} - -interface WebSocketListenerSet { - close: (event: CloseEvent) => void; - error: (event: Event) => void; - message: (event: MessageEvent) => void; - open: (event: Event) => void; -} - -const websocketListenerMap = new WeakMap(); - -/** 绑定 WebSocket 事件 */ -function bindWebSocketListeners( - socket: WebSocket, - listeners: WebSocketListenerSet, -): void { - websocketListenerMap.set(socket, listeners); - socket.addEventListener('open', listeners.open); - socket.addEventListener('message', listeners.message); - socket.addEventListener('close', listeners.close); - socket.addEventListener('error', listeners.error); -} - -/** 解绑 WebSocket 事件 */ -function unbindWebSocketListeners(socket: WebSocket): void { - const listeners = websocketListenerMap.get(socket); - if (!listeners) { - return; - } - socket.removeEventListener('open', listeners.open); - socket.removeEventListener('message', listeners.message); - socket.removeEventListener('close', listeners.close); - socket.removeEventListener('error', listeners.error); - websocketListenerMap.delete(socket); + void promise.catch(() => undefined) } /** 校验 LiveKit 连接地址 */ function isValidLiveKitUrl(url?: string): boolean { if (!url) { - return false; + return false } try { - return RTC_LIVEKIT_PROTOCOLS.has(new URL(url).protocol); + return RTC_LIVEKIT_PROTOCOLS.has(new URL(url).protocol) } catch { - return false; + return false } } /** 校验来电信令载荷 */ function isValidRtcInvitePayload(payload: ImRtcCallNotification): boolean { - if ( - !payload.room || - !payload.token || - !isValidLiveKitUrl(payload.livekitUrl) - ) { - return false; + if (!payload.room || !payload.token || !isValidLiveKitUrl(payload.livekitUrl)) { + return false } if (!RTC_MEDIA_TYPES.has(payload.mediaType) || !payload.inviterUserId) { - return false; + return false } if (payload.conversationType === ImConversationType.PRIVATE) { - return true; + return true } - return ( - payload.conversationType === ImConversationType.GROUP && !!payload.groupId - ); + return payload.conversationType === ImConversationType.GROUP && !!payload.groupId } /** @@ -169,7 +126,7 @@ function isValidRtcInvitePayload(payload: ImRtcCallNotification): boolean { */ const convertPrivateMessage = ( websocketMessage: ImPrivateMessageNotification, - currentUserId: number, + currentUserId: number ): Message => ({ id: websocketMessage.id, clientMessageId: websocketMessage.clientMessageId, @@ -180,8 +137,8 @@ const convertPrivateMessage = ( sendTime: new Date(websocketMessage.sendTime).getTime(), senderId: websocketMessage.senderId, targetId: getPrivateMessagePeerId(websocketMessage, currentUserId), - selfSend: websocketMessage.senderId === currentUserId, -}); + selfSend: websocketMessage.senderId === currentUserId +}) /** * WebSocket 群聊 DTO -> 前端 Message @@ -190,7 +147,7 @@ const convertPrivateMessage = ( */ const convertGroupMessage = ( websocketMessage: ImGroupMessageNotification, - currentUserId: number, + currentUserId: number ): Message => ({ id: websocketMessage.id, clientMessageId: websocketMessage.clientMessageId, @@ -204,8 +161,8 @@ const convertGroupMessage = ( atUserIds: websocketMessage.atUserIds || [], receiverUserIds: websocketMessage.receiverUserIds || [], receiptStatus: websocketMessage.receiptStatus, - readCount: websocketMessage.readCount, -}); + readCount: websocketMessage.readCount +}) /** * IM WebSocket Store @@ -232,19 +189,16 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { reconnectAttempts: 0, heartbeatTimer: null as null | ReturnType, messageBuffer: [] as Array< + | { conversationType: typeof ImConversationType.CHANNEL; payload: ImChannelMessageRespVO } | { - conversationType: typeof ImConversationType.CHANNEL; - payload: ImChannelMessageApi.ChannelMessageRespVO; + conversationType: typeof ImConversationType.GROUP + payload: ImGroupMessageNotification } | { - conversationType: typeof ImConversationType.GROUP; - payload: ImGroupMessageNotification; + conversationType: typeof ImConversationType.PRIVATE + payload: ImPrivateMessageNotification } - | { - conversationType: typeof ImConversationType.PRIVATE; - payload: ImPrivateMessageNotification; - } - >, // 初始化加载期内,先把普通消息丢进缓冲区,pull 完成后再一次性回放 + > // 初始化加载期内,先把普通消息丢进缓冲区,pull 完成后再一次性回放 }), actions: { @@ -253,99 +207,102 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 配合 messageBuffer 实现:在 conversationStore.loading 期间收到的 WS 消息先暂存,避免和 pull 的 minId 游标打架 */ flushBuffer() { - const msgs = [...this.messageBuffer]; - this.messageBuffer = []; - return msgs; + const msgs = [...this.messageBuffer] + this.messageBuffer = [] + return msgs }, - /** 直接丢弃缓冲帧不回放(cancelPull / 离开 IM 调用,防止下次进 IM 把旧 session 帧回放进新 store) */ + /** 直接丢弃缓冲帧不回放(cancelPull / 离开 IM 调用) */ discardBuffer() { - this.messageBuffer = []; + this.messageBuffer = [] }, /** * 连接 WebSocket * 复用 yudao 内置 /infra/ws 通道,后端通过 sendObject(type, content) 下发 * - * 调用契约:切账号 / token 刷新前必须先 `disconnect()` 再 `connect()`; - * 本方法不感知 token 变化,旧 socket 在 CONNECTING / OPEN 状态会直接复用旧 token,可能拿到错误身份 + * socket 实例即连接 owner,旧连接回调不得进入新连接 */ connect() { // 鉴权用 refreshToken(生命周期更长;access token 过期后服务端会通过 frame 通知重登) - const refreshToken = getRefreshToken(); - if (!refreshToken) { - console.warn('[IM WS] refreshToken 为空,跳过连接'); - return; + const refreshToken = getRefreshToken() + const currentUserId = getCurrentUserId() + if (!refreshToken || !currentUserId) { + console.warn('[IM WS] 登录信息不完整,跳过连接') + return } // 旧 socket 还在 CONNECTING / OPEN 直接复用,避免叠加多份 onmessage 监听导致重复消息 / 提示音 / 已读上报 - const existingSocket = this.socket; + const existingSocket = this.socket if ( existingSocket && (existingSocket.readyState === WebSocket.OPEN || existingSocket.readyState === WebSocket.CONNECTING) ) { - return; + return } // 旧 socket 已 CLOSING / CLOSED:解绑回调 + 清引用再 new,避免老 handler 仍持有 store 引用阻碍 GC if (existingSocket) { - unbindWebSocketListeners(existingSocket); - this.socket = null; + this.disconnect() } - const url = `${this.buildWsUrl()}/infra/ws?token=${refreshToken}`; - const socket = new WebSocket(url); - this.socket = socket; + const url = `${this.buildWsUrl()}/infra/ws?token=${refreshToken}` + const socket = new WebSocket(url) + this.socket = socket + const isActive = () => this.socket === socket - bindWebSocketListeners(socket, { - // 连接建立:标记上线 + 启动心跳保活;重连退避计数归零 - open: () => { - this.isConnected = true; - this.reconnectAttempts = 0; - console.log('[IM WS] connected'); - this.startHeartbeat(); - }, + // 连接建立:标记上线 + 启动心跳保活;重连退避计数归零 + socket.onopen = () => { + if (!isActive()) return + this.isConnected = true + this.reconnectAttempts = 0 + console.log('[IM WS] connected') + this.startHeartbeat() + } - // 收到帧:'pong' 是心跳应答直接吞掉;其余按 WebSocketFrame 解析后交给 dispatchFrame 分流 - message: (event) => { - if (event.data === 'pong') { - return; - } - try { - const frame = JSON.parse(event.data) as WebSocketFrame; - this.dispatchFrame(frame); - } catch (error) { - console.error('[IM WS] message parse error:', error); - } - }, + // 收到帧:'pong' 是心跳应答直接吞掉;其余按 WebSocketFrame 解析后交给 dispatchFrame 分流 + socket.onmessage = (event) => { + if (!isActive()) return + if (event.data === 'pong') { + return + } + try { + const frame = JSON.parse(event.data) as WebSocketFrame + this.dispatchFrame(frame) + } catch (e) { + console.error('[IM WS] message parse error:', e) + } + } - // 服务端关闭 / 网络断:标记下线,按指数退避自动重连 - close: () => { - this.isConnected = false; - console.log('[IM WS] disconnected'); - this.reconnect(); - }, + // 服务端关闭 / 网络断:标记下线,按指数退避自动重连 + socket.onclose = () => { + if (!isActive()) return + this.socket = null + this.isConnected = false + console.log('[IM WS] disconnected') + this.reconnect() + } - // 异常时不主动 reconnect,主动 close() 让 close 成为唯一重连入口 - error: (error) => { - console.error('[IM WS] error:', error); - this.isConnected = false; - this.socket?.close(); - }, - }); + // 异常时不主动 reconnect,主动 close() 让 onclose 成为唯一重连入口: + // 1)避免 onerror / onclose 双触把 reconnectAttempts 一次断连 +2 + // 2)兜底某些平台 onerror 后 onclose 延迟 / 丢失导致重连卡住 + socket.onerror = (error) => { + if (!isActive()) return + console.error('[IM WS] error:', error) + this.isConnected = false + socket.close() + } }, /** 拼接 WebSocket 基础地址 */ buildWsUrl(): string { // VITE_BASE_URL 可能是 http:// 或 https:// 开头,替换成 ws:// 或 wss://;如果没配置,就用当前页面的协议 + host - const baseUrl = (import.meta as any).env?.VITE_BASE_URL as - | string - | undefined; + const baseUrl = (import.meta as any).env?.VITE_BASE_URL as string | undefined if (baseUrl && baseUrl.length > 0) { - return baseUrl.replace(/^http/, 'ws'); + return baseUrl.replace(/^http/, 'ws') } // 当前页面协议 + host(如 http://localhost:8080),替换成 ws://localhost:8080 - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const host = window.location.host; - return `${protocol}//${host}`; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + return `${protocol}//${host}` }, /** @@ -353,133 +310,111 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { */ dispatchFrame(frame: WebSocketFrame) { if (frame.type !== ImWebSocketMessageType.NOTIFICATION) { - console.debug('[IM WS] 未识别事件', frame); - return; + console.debug('[IM WS] 未识别事件', frame) + return } - const notification = this.safeParse( - frame.content, - ) as ImNotificationWebSocketDTO | null; + const notification = this.safeParse(frame.content) as ImNotificationWebSocketDTO | null if (!notification?.payload || !notification.contentType) { - return; + return } const payload = { ...notification.payload, - type: notification.contentType, - }; + type: notification.contentType + } switch (notification.conversationType) { - case ImConversationType.CHANNEL: { - this.dispatchChannelFrame( - payload as ImChannelMessageApi.ChannelMessageRespVO, - ); - break; - } - case ImConversationType.GROUP: { - this.dispatchGroupFrame(payload as ImGroupMessageNotification); - break; - } - case ImConversationType.NONE: { - this.dispatchNoConversationFrame( - payload as ImNoConversationNotification, - ); - break; - } - case ImConversationType.PRIVATE: { - this.dispatchPrivateFrame(payload as ImPrivateMessageNotification); - break; - } - default: { - console.debug('[IM WS] 未识别通知', notification); - } + case ImConversationType.CHANNEL: + this.dispatchChannelFrame(payload as ImChannelMessageRespVO) + break + case ImConversationType.GROUP: + this.dispatchGroupFrame(payload as ImGroupMessageNotification) + break + case ImConversationType.NONE: + this.dispatchNoConversationFrame(payload as ImNoConversationNotification) + break + case ImConversationType.PRIVATE: + this.dispatchPrivateFrame(payload as ImPrivateMessageNotification) + break + default: + console.debug('[IM WS] 未识别通知', notification) } }, /** * 无会话通知分发 */ - dispatchNoConversationFrame( - websocketMessage: ImNoConversationNotification, - ) { + dispatchNoConversationFrame(websocketMessage: ImNoConversationNotification) { if (isFriendNotification(websocketMessage.type)) { - this.handleFriendNotification(websocketMessage); - return; + this.handleFriendNotification(websocketMessage) + return } if (isGroupRequestNotification(websocketMessage.type)) { - this.handleGroupRequestNotification(websocketMessage); - return; + this.handleGroupRequestNotification(websocketMessage) + return } switch (websocketMessage.type) { case ImContentType.RTC_CALL: case ImContentType.RTC_PARTICIPANT_CONNECTED: - case ImContentType.RTC_PARTICIPANT_DISCONNECTED: { - this.handleRtcSignaling(websocketMessage); - break; - } - default: { - console.debug('[IM WS] 未识别无会话通知', websocketMessage); - } + case ImContentType.RTC_PARTICIPANT_DISCONNECTED: + this.handleRtcSignaling(websocketMessage) + break + default: + console.debug('[IM WS] 未识别无会话通知', websocketMessage) } }, /** * 频道帧分发:按 payload.type 分到 READ(多端已读同步)或普通素材推送 */ - dispatchChannelFrame( - websocketMessage: ImChannelMessageApi.ChannelMessageRespVO, - ) { + dispatchChannelFrame(websocketMessage: ImChannelMessageRespVO) { if (websocketMessage.type === ImContentType.READ) { - this.handleChannelRead(websocketMessage); - return; + this.handleChannelRead(websocketMessage) + return } - ignoreRealtimePersistError(this.handleChannelMessage(websocketMessage)); + ignoreRealtimePersistError(this.handleChannelMessage(websocketMessage)) }, /** 频道 READ:自己其它终端在某频道里标为已读,本端同步清零该频道未读 */ - handleChannelRead( - websocketMessage: ImChannelMessageApi.ChannelMessageRespVO, - ) { + handleChannelRead(websocketMessage: ImChannelMessageRespVO) { void useConversationStore() .applyConversationReadList([ { id: websocketMessage.id, conversationType: ImConversationType.CHANNEL, targetId: websocketMessage.channelId, - messageId: websocketMessage.id, - }, + messageId: websocketMessage.id + } ]) - .catch((error) => console.warn('[IM WS] 频道已读同步失败', error)); + .catch((e) => console.warn('[IM WS] 频道已读同步失败', e)) }, /** * 频道消息实时入会话;频道消息单向 + 无状态机,直接 insertMessage 即可 * pull 与 WS 拿到同一条 id 时,messageStore.insertMessage 内部按 id 去重,不会重复 */ - handleChannelMessage( - websocketMessage: ImChannelMessageApi.ChannelMessageRespVO, - ): Promise { - const conversationStore = useConversationStore(); - const messageStore = useMessageStore(); + handleChannelMessage(websocketMessage: ImChannelMessageRespVO): Promise { + const conversationStore = useConversationStore() + const messageStore = useMessageStore() // 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱 if (conversationStore.loading) { this.messageBuffer.push({ conversationType: ImConversationType.CHANNEL, - payload: websocketMessage, - }); - return Promise.resolve(); + payload: websocketMessage + }) + return Promise.resolve() } + const db = getDb() const sendTimeMs = typeof websocketMessage.sendTime === 'number' ? websocketMessage.sendTime - : new Date(websocketMessage.sendTime).getTime(); + : new Date(websocketMessage.sendTime).getTime() const conversation = conversationStore.getConversation( ImConversationType.CHANNEL, - websocketMessage.channelId, - ); + websocketMessage.channelId + ) const isActive = - conversationStore.activeConversation?.type === - ImConversationType.CHANNEL && - conversationStore.activeConversation?.targetId === - websocketMessage.channelId; + conversationStore.activeConversation?.type === ImConversationType.CHANNEL && + conversationStore.activeConversation?.targetId === websocketMessage.channelId // 频道单向订阅,receiptStatus 表达「我是否已读这条」:会话打开即已读 DONE,否则 PENDING(与 pull 口径一致) const persistPromise = messageStore.insertMessage( buildChannelConversationStub(websocketMessage.channelId), @@ -489,75 +424,71 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { type: websocketMessage.type, content: websocketMessage.content, status: ImMessageStatus.NORMAL, - receiptStatus: isActive - ? ImMessageReceiptStatus.DONE - : ImMessageReceiptStatus.PENDING, + receiptStatus: isActive ? ImMessageReceiptStatus.DONE : ImMessageReceiptStatus.PENDING, sendTime: sendTimeMs, senderId: 0, targetId: websocketMessage.channelId, selfSend: false, - materialId: websocketMessage.materialId, + materialId: websocketMessage.materialId }, - ); + db + ) if (isActive) { // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.CHANNEL, websocketMessage.channelId, - websocketMessage.id, - ); + websocketMessage.id + ) conversationStore.markConversationRead( ImConversationType.CHANNEL, websocketMessage.channelId, websocketMessage.id, - ); + db + ) if (!readReported) { - apiReadChannelMessages( - websocketMessage.channelId, - websocketMessage.id, - ) - .then(() => + apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id) + .then(() => { conversationStore.markConversationReadReported( ImConversationType.CHANNEL, websocketMessage.channelId, websocketMessage.id, - ), - ) - .catch((error) => { + db + ) + }) + .catch((e) => { console.warn( '[IM WS] 频道自动已读上报失败', { conversationType: ImConversationType.CHANNEL, channelId: websocketMessage.channelId, messageId: websocketMessage.id, + messageType: websocketMessage.type }, - error, - ); - }); + e + ) + }) } - } else if ( - !conversation?.silent && - isNormalMessage(websocketMessage.type) - ) { + } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { // 非当前会话且未免打扰:响一下提示音 - playAudioTip(); + playAudioTip() } - return persistPromise; + return persistPromise.then(() => undefined) }, /** content 既可能已是对象也可能是 JSON 字符串(后端用 Map 序列化下发) */ safeParse(raw: unknown): null | Record { if (!raw) { - return null; + return null } if (typeof raw === 'object') { - return raw as Record; + return raw as Record } try { - return JSON.parse(raw as string); - } catch (error) { - console.error('[IM WS] content 解析失败', error); - return null; + return JSON.parse(raw as string) + } catch (e) { + console.error('[IM WS] content 解析失败', e) + return null } }, @@ -571,50 +502,32 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { dispatchPrivateFrame(websocketMessage: ImPrivateMessageNotification) { try { switch (websocketMessage.type) { - case ImContentType.READ: { - this.handlePrivateRead( - websocketMessage as ImMessageReadNotification, - ); - break; - } - case ImContentType.RECEIPT: { - this.handlePrivateReceipt( - websocketMessage as ImMessageReceiptNotification, - ); - break; - } - case ImContentType.RTC_CALL_END: { + case ImContentType.READ: + this.handlePrivateRead(websocketMessage as ImMessageReadNotification) + break + case ImContentType.RECEIPT: + this.handlePrivateReceipt(websocketMessage as ImMessageReceiptNotification) + break + case ImContentType.RTC_CALL_END: // 入库 + 关闭通话窗 + 渲染聊天 tip(私聊场景) - this.handleRtcCallEnd(websocketMessage); - ignoreRealtimePersistError( - this.handlePrivateMessage(websocketMessage), - ); - break; - } - default: { + this.handleRtcCallEnd(websocketMessage) + ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage)) + break + default: if (isFriendChatTip(websocketMessage.type)) { - this.handleFriendNotification(websocketMessage); + this.handleFriendNotification(websocketMessage) // FRIEND_DELETE 的 clear=true 语义是清会话本身,跳过气泡避免在已清会话里写入虚拟消息 if (!isFriendDeleteWithClear(websocketMessage)) { - ignoreRealtimePersistError( - this.handlePrivateMessage(websocketMessage), - ); + ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage)) } } else { // TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息 - ignoreRealtimePersistError( - this.handlePrivateMessage(websocketMessage), - ); + ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage)) } - } } - } catch (error) { + } catch (e) { // 单条帧的处理异常不应阻断后续帧;打印完整 websocketMessage 便于排查 - console.warn( - '[IM WS] dispatchPrivateFrame 处理失败', - websocketMessage, - error, - ); + console.warn('[IM WS] dispatchPrivateFrame 处理失败', websocketMessage, e) } }, @@ -626,54 +539,35 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { dispatchGroupFrame(websocketMessage: ImGroupMessageNotification) { try { switch (websocketMessage.type) { - case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: { - this.handleGroupMemberNicknameUpdate(websocketMessage); - break; - } - case ImContentType.GROUP_MEMBER_SETTING_UPDATE: { - this.handleGroupMemberSettingUpdate(websocketMessage); - break; - } - case ImContentType.READ: { - this.handleGroupRead(websocketMessage as ImMessageReadNotification); - break; - } - case ImContentType.RECEIPT: { - this.handleGroupReceipt( - websocketMessage as ImMessageReceiptNotification, - ); - break; - } - case ImContentType.RTC_CALL_END: { + case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: + this.handleGroupMemberNicknameUpdate(websocketMessage) + break + case ImContentType.GROUP_MEMBER_SETTING_UPDATE: + this.handleGroupMemberSettingUpdate(websocketMessage) + break + case ImContentType.READ: + this.handleGroupRead(websocketMessage as ImMessageReadNotification) + break + case ImContentType.RECEIPT: + this.handleGroupReceipt(websocketMessage as ImMessageReceiptNotification) + break + case ImContentType.RTC_CALL_END: // 入库 + 移除胶囊条 + 关闭通话窗(如果当前在该群通话内) - this.handleRtcCallEnd(websocketMessage); - ignoreRealtimePersistError( - this.handleGroupMessage(websocketMessage), - ); - break; - } - case ImContentType.RTC_CALL_START: { + this.handleRtcCallEnd(websocketMessage) + ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage)) + break + case ImContentType.RTC_CALL_START: // 入库 + 渲染聊天 tip;同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员 - this.handleRtcCallStart(websocketMessage); - ignoreRealtimePersistError( - this.handleGroupMessage(websocketMessage), - ); - break; - } - default: { + this.handleRtcCallStart(websocketMessage) + ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage)) + break + default: // TEXT / IMAGE / FILE / VOICE / VIDEO + GROUP_* 群广播事件 - ignoreRealtimePersistError( - this.handleGroupMessage(websocketMessage), - ); - } + ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage)) } - } catch (error) { + } catch (e) { // 单条帧的处理异常不应阻断后续帧;打印完整 websocketMessage 便于排查 - console.warn( - '[IM WS] dispatchGroupFrame 处理失败', - websocketMessage, - error, - ); + console.warn('[IM WS] dispatchGroupFrame 处理失败', websocketMessage, e) } }, @@ -687,12 +581,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 4. 构造前端 Message,插入到对应私聊会话 * 5. 当前会话激活时自动上报已读;否则非免打扰响提示音 */ - handlePrivateMessage( - websocketMessage: ImPrivateMessageNotification, - ): Promise { - const conversationStore = useConversationStore(); - const friendStore = useFriendStore(); - const currentUserId = getCurrentUserId(); + handlePrivateMessage(websocketMessage: ImPrivateMessageNotification): Promise { + const conversationStore = useConversationStore() + const friendStore = useFriendStore() + const db = getDb() + const currentUserId = db.userId // 0. 防御层:senderId / receiverId 均不含当前用户的私聊帧直接丢弃,避免后端路由 / 多端串号污染会话 // (FRIEND_* 等系统通知也走这条通道,但 fromUserId=senderId、toUserId=receiverId 仍是当前用户视角) @@ -701,29 +594,29 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { websocketMessage.senderId !== currentUserId && websocketMessage.receiverId !== currentUserId ) { - console.warn('[IM WS] 丢弃不属于当前用户的私聊帧', websocketMessage); - return Promise.resolve(); + console.warn('[IM WS] 丢弃不属于当前用户的私聊帧', websocketMessage) + return Promise.resolve() } // 1. 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱 if (conversationStore.loading) { this.messageBuffer.push({ conversationType: ImConversationType.PRIVATE, - payload: websocketMessage, - }); - return Promise.resolve(); + payload: websocketMessage + }) + return Promise.resolve() } // 2. selfSend / peerId:自己发的消息属于「发给 receiverId 的会话」,别人发的属于「发送者的会话」 - const selfSend = websocketMessage.senderId === currentUserId; - const peerId = getPrivateMessagePeerId(websocketMessage, currentUserId); + const selfSend = websocketMessage.senderId === currentUserId + const peerId = getPrivateMessagePeerId(websocketMessage, currentUserId) // 未知对端(陌生人加好友前先收到消息等场景):异步补拉一次,下次再渲染就有 name/avatar - const friend = friendStore.getFriend(peerId); + const friend = friendStore.getFriend(peerId) if (!friend) { - friendStore.fetchFriendInfo(peerId).catch(() => undefined); + friendStore.fetchFriendInfo(peerId).catch(() => undefined) } // 会话标题永远跟「对端」走(不管谁发的消息);这里只算一次给 insertMessage 用 - const peerDisplayName = friend ? getFriendDisplayName(friend) : ''; + const peerDisplayName = friend ? getFriendDisplayName(friend) : '' // 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImContentTypeEnum.RECALL → RecallMessage) // 这里拦截下来改走 recallMessage(把原消息更新为 RECALL 态),不让它作为新消息进列表 @@ -732,84 +625,83 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { ImConversationType.PRIVATE, peerId, websocketMessage.content, - ); + db + ) } // 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段 - const message = convertPrivateMessage(websocketMessage, currentUserId); + const message = convertPrivateMessage(websocketMessage, currentUserId) const persistPromise = useMessageStore().insertMessage( { type: ImConversationType.PRIVATE, targetId: peerId, name: peerDisplayName || String(peerId), avatar: friend?.avatar || '', - silent: friend?.silent, + silent: friend?.silent }, message, - ); + db + ) // 5. 仅对方消息才走「自动已读 / 提示音」分支:自己发的不会触发 if (!selfSend) { - const conversation = conversationStore.getConversation( - ImConversationType.PRIVATE, - peerId, - ); + const conversation = conversationStore.getConversation(ImConversationType.PRIVATE, peerId) const isActive = - conversationStore.activeConversation?.type === - ImConversationType.PRIVATE && - conversationStore.activeConversation?.targetId === peerId; + conversationStore.activeConversation?.type === ImConversationType.PRIVATE && + conversationStore.activeConversation?.targetId === peerId if (isActive) { // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.PRIVATE, peerId, - websocketMessage.id, - ); + websocketMessage.id + ) conversationStore.markConversationRead( ImConversationType.PRIVATE, peerId, websocketMessage.id, - ); + db + ) if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) { apiReadPrivateMessages(peerId, websocketMessage.id) - .then(() => + .then(() => { conversationStore.markConversationReadReported( ImConversationType.PRIVATE, peerId, websocketMessage.id, - ), - ) - .catch((error) => { + db + ) + }) + .catch((e) => { console.warn( '[IM WS] 私聊自动已读上报失败', { conversationType: ImConversationType.PRIVATE, peerId, messageId: websocketMessage.id, + messageType: websocketMessage.type, + senderId: websocketMessage.senderId }, - error, - ); - }); + e + ) + }) } - } else if ( - !conversation?.silent && - isNormalMessage(websocketMessage.type) - ) { + } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { // 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip);FRIEND_* 等系统事件不响 - playAudioTip(); + playAudioTip() } } - return persistPromise; + return persistPromise.then(() => undefined) }, /** 私聊 READ 事件:自己的其它终端在对方会话里标为已读,本端同步清零未读;私聊已读关闭时兜底忽略 */ handlePrivateRead(websocketMessage: ImMessageReadNotification) { if (!MESSAGE_PRIVATE_READ_ENABLED) { - return; + return } if (!websocketMessage.id || !websocketMessage.receiverId) { - return; + return } void useConversationStore() .applyConversationReadList([ @@ -817,10 +709,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { id: websocketMessage.id, conversationType: ImConversationType.PRIVATE, targetId: websocketMessage.receiverId, - messageId: websocketMessage.id, - }, + messageId: websocketMessage.id + } ]) - .catch((error) => console.warn('[IM WS] 私聊已读同步失败', error)); + .catch((e) => console.warn('[IM WS] 私聊已读同步失败', e)) }, /** @@ -830,19 +722,21 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { */ handlePrivateReceipt(websocketMessage: ImMessageReceiptNotification) { if (!MESSAGE_PRIVATE_READ_ENABLED) { - return; + return } if (!websocketMessage.id) { - return; + return } if (!websocketMessage.senderId) { - return; + return } - useMessageStore().applyMessageReadReceipt({ - conversationType: ImConversationType.PRIVATE, - targetId: websocketMessage.senderId, - privateReadMaxId: websocketMessage.id, - }); + void useMessageStore() + .applyMessageReadReceipt({ + conversationType: ImConversationType.PRIVATE, + targetId: websocketMessage.senderId, + privateReadMaxId: websocketMessage.id + }) + .catch((e) => console.warn('[IM WS] 私聊回执同步失败', e)) }, /** @@ -855,17 +749,16 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 4. 构造 Message + at 字段,插入到对应群聊会话(发送人名渲染时实时算) * 5. 当前会话激活时自动上报已读(带 lastMessageId);否则非免打扰响提示音 */ - handleGroupMessage( - websocketMessage: ImGroupMessageNotification, - ): Promise { - const conversationStore = useConversationStore(); - const groupStore = useGroupStore(); - const currentUserId = getCurrentUserId(); - const selfSend = websocketMessage.senderId === currentUserId; + handleGroupMessage(websocketMessage: ImGroupMessageNotification): Promise { + const conversationStore = useConversationStore() + const groupStore = useGroupStore() + const db = getDb() + const currentUserId = db.userId + const selfSend = websocketMessage.senderId === currentUserId // 0. 防御层:定向群消息 receiverUserIds 非空且未包含当前用户时丢弃 // 自己发的(selfSend)始终通过;全员可见(receiverUserIds 为空 / 缺失)也通过 - const receiverUserIds = websocketMessage.receiverUserIds; + const receiverUserIds = websocketMessage.receiverUserIds if ( currentUserId && !selfSend && @@ -873,28 +766,23 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { receiverUserIds.length > 0 && !receiverUserIds.includes(currentUserId) ) { - console.warn( - '[IM WS] 丢弃不属于当前用户的定向群消息', - websocketMessage, - ); - return Promise.resolve(); + console.warn('[IM WS] 丢弃不属于当前用户的定向群消息', websocketMessage) + return Promise.resolve() } // 1. 离线加载期缓冲(与私聊对称) if (conversationStore.loading) { this.messageBuffer.push({ conversationType: ImConversationType.GROUP, - payload: websocketMessage, - }); - return Promise.resolve(); + payload: websocketMessage + }) + return Promise.resolve() } // 2. 未知群时自动拉群详情 + 成员(被拉入群但还没收到 GROUP_CREATE 时的兜底) - const group = groupStore.getGroup(websocketMessage.groupId); + const group = groupStore.getGroup(websocketMessage.groupId) if (!group) { - groupStore - .fetchGroupInfo(websocketMessage.groupId) - .catch(() => undefined); + groupStore.fetchGroupInfo(websocketMessage.groupId, true, db).catch(() => undefined) } // 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}` @@ -904,77 +792,76 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { ImConversationType.GROUP, websocketMessage.groupId, websocketMessage.content, - ); + db + ) } // 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段 - const message = convertGroupMessage(websocketMessage, currentUserId); + const message = convertGroupMessage(websocketMessage, currentUserId) const persistPromise = useMessageStore().insertMessage( { type: ImConversationType.GROUP, targetId: websocketMessage.groupId, - name: group - ? getGroupDisplayName(group) - : String(websocketMessage.groupId), + name: group ? getGroupDisplayName(group) : String(websocketMessage.groupId), avatar: group?.avatar || '', - silent: group?.silent, + silent: group?.silent }, message, - ); + db + ) // 5. 仅对方消息才走「自动已读 / 提示音」(与私聊对称) if (!selfSend) { const conversation = conversationStore.getConversation( ImConversationType.GROUP, - websocketMessage.groupId, - ); + websocketMessage.groupId + ) const isActive = - conversationStore.activeConversation?.type === - ImConversationType.GROUP && - conversationStore.activeConversation?.targetId === - websocketMessage.groupId; + conversationStore.activeConversation?.type === ImConversationType.GROUP && + conversationStore.activeConversation?.targetId === websocketMessage.groupId if (isActive) { // 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零 const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.GROUP, websocketMessage.groupId, - websocketMessage.id, - ); + websocketMessage.id + ) conversationStore.markConversationRead( ImConversationType.GROUP, websocketMessage.groupId, websocketMessage.id, - ); + db + ) if (MESSAGE_GROUP_READ_ENABLED && !readReported) { apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id) - .then(() => + .then(() => { conversationStore.markConversationReadReported( ImConversationType.GROUP, websocketMessage.groupId, websocketMessage.id, - ), - ) - .catch((error) => { + db + ) + }) + .catch((e) => { console.warn( '[IM WS] 群聊自动已读上报失败', { conversationType: ImConversationType.GROUP, groupId: websocketMessage.groupId, messageId: websocketMessage.id, + messageType: websocketMessage.type, + senderId: websocketMessage.senderId }, - error, - ); - }); + e + ) + }) } - } else if ( - !conversation?.silent && - isNormalMessage(websocketMessage.type) - ) { + } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { // GROUP_* 群广播事件等系统消息不响提示音 - playAudioTip(); + playAudioTip() } } - return persistPromise; + return persistPromise.then(() => undefined) }, // ==================== 群聊已读 / 回执 ==================== @@ -982,11 +869,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { /** 群聊 READ:自己其它终端在某群里标为已读,本端同步清零该群未读 + @ 红字;群已读关闭时兜底忽略 */ handleGroupRead(websocketMessage: ImMessageReadNotification) { if (!MESSAGE_GROUP_READ_ENABLED) { - return; + return } - const readMessageId = websocketMessage.readId || websocketMessage.id; + const readMessageId = websocketMessage.readId || websocketMessage.id if (!readMessageId || !websocketMessage.groupId) { - return; + return } void useConversationStore() .applyConversationReadList([ @@ -994,27 +881,29 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { id: readMessageId, conversationType: ImConversationType.GROUP, targetId: websocketMessage.groupId, - messageId: readMessageId, - }, + messageId: readMessageId + } ]) - .catch((error) => console.warn('[IM WS] 群聊已读同步失败', error)); + .catch((e) => console.warn('[IM WS] 群聊已读同步失败', e)) }, /** 群聊 RECEIPT:更新某条群消息的 readCount / receiptStatus;群已读关闭时兜底忽略 */ handleGroupReceipt(websocketMessage: ImMessageReceiptNotification) { if (!MESSAGE_GROUP_READ_ENABLED) { - return; + return } if (!websocketMessage.id || !websocketMessage.groupId) { - return; + return } - useMessageStore().applyMessageReadReceipt({ - conversationType: ImConversationType.GROUP, - targetId: websocketMessage.groupId, - groupMessageId: websocketMessage.id, - readCount: websocketMessage.readCount, - receiptStatus: websocketMessage.receiptStatus, - }); + void useMessageStore() + .applyMessageReadReceipt({ + conversationType: ImConversationType.GROUP, + targetId: websocketMessage.groupId, + groupMessageId: websocketMessage.id, + readCount: websocketMessage.readCount, + receiptStatus: websocketMessage.receiptStatus + }) + .catch((e) => console.warn('[IM WS] 群聊回执同步失败', e)) }, // ==================== 好友通知(1201-1210 段位) ==================== @@ -1024,72 +913,60 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * becomeFriends 单条入库后双方收到同一份 payload,payload.friendUserId 固定是 toUserId,本端真正的对端要从帧 sender / receiver 反推 */ computeFriendPeerId(frame: ImPrivateMessageNotification): number { - const currentUserId = getCurrentUserId(); - return getPrivateMessagePeerId(frame, currentUserId); + const currentUserId = getCurrentUserId() + return getPrivateMessagePeerId(frame, currentUserId) }, /** * 好友通知统一入口:按 type 分发到 friendStore 内部 dispatcher */ handleFriendNotification( - websocketMessage: - | ImNoConversationNotification - | ImPrivateMessageNotification, + websocketMessage: ImNoConversationNotification | ImPrivateMessageNotification ) { const payload = isPrivateMessageNotification(websocketMessage) ? parseFriendNotificationPayload(websocketMessage) - : (websocketMessage as unknown as FriendNotificationPayload); - const friendStore = useFriendStore(); + : (websocketMessage as unknown as FriendNotificationPayload) + const friendStore = useFriendStore() switch (websocketMessage.type) { - case ImContentType.FRIEND_ADD: { + case ImContentType.FRIEND_ADD: friendStore.applyFriendAddNotification( payload, isPrivateMessageNotification(websocketMessage) ? this.computeFriendPeerId(websocketMessage) - : payload.friendUserId, - ); - break; - } - case ImContentType.FRIEND_BLOCK: { - friendStore.applyFriendBlockNotification(payload); - break; - } - case ImContentType.FRIEND_DELETE: { + : payload.friendUserId + ) + break + case ImContentType.FRIEND_BLOCK: + friendStore.applyFriendBlockNotification(payload) + break + case ImContentType.FRIEND_DELETE: friendStore.applyFriendDeleteNotification( payload, isPrivateMessageNotification(websocketMessage) ? this.computeFriendPeerId(websocketMessage) - : payload.friendUserId, - ); - break; - } - case ImContentType.FRIEND_INFO_UPDATED: { - friendStore.applyFriendInfoUpdatedNotification(payload); - break; - } - case ImContentType.FRIEND_REQUEST_APPROVED: { - friendStore.applyFriendRequestApprovedNotification(payload); - break; - } - case ImContentType.FRIEND_REQUEST_RECEIVED: { - friendStore.applyFriendRequestReceivedNotification(payload); - break; - } - case ImContentType.FRIEND_REQUEST_REJECTED: { - friendStore.applyFriendRequestRejectedNotification(payload); - break; - } - case ImContentType.FRIEND_UNBLOCK: { - friendStore.applyFriendUnblockNotification(payload); - break; - } - case ImContentType.FRIEND_UPDATE: { - friendStore.applyFriendUpdateNotification(payload); - break; - } - default: { - console.debug('[IM WS] 未识别好友通知', websocketMessage); - } + : payload.friendUserId + ) + break + case ImContentType.FRIEND_INFO_UPDATED: + friendStore.applyFriendInfoUpdatedNotification(payload) + break + case ImContentType.FRIEND_REQUEST_APPROVED: + friendStore.applyFriendRequestApprovedNotification(payload) + break + case ImContentType.FRIEND_REQUEST_RECEIVED: + friendStore.applyFriendRequestReceivedNotification(payload) + break + case ImContentType.FRIEND_REQUEST_REJECTED: + friendStore.applyFriendRequestRejectedNotification(payload) + break + case ImContentType.FRIEND_UNBLOCK: + friendStore.applyFriendUnblockNotification(payload) + break + case ImContentType.FRIEND_UPDATE: + friendStore.applyFriendUpdateNotification(payload) + break + default: + console.debug('[IM WS] 未识别好友通知', websocketMessage) } }, @@ -1098,29 +975,22 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { /** * 加群申请通知统一入口:分发到 groupRequestStore,驱动横幅 + Drawer 同步 */ - handleGroupRequestNotification( - websocketMessage: ImNoConversationNotification, - ) { - const payload = websocketMessage as { requestId?: number }; + handleGroupRequestNotification(websocketMessage: ImNoConversationNotification) { + const payload = websocketMessage as { requestId?: number } if (!payload.requestId) { - return; + return } - const groupRequestStore = useGroupRequestStore(); + const groupRequestStore = useGroupRequestStore() switch (websocketMessage.type) { case ImContentType.GROUP_REQUEST_APPROVED: - case ImContentType.GROUP_REQUEST_REJECTED: { - groupRequestStore.removeGroupRequestById(payload.requestId); - break; - } - case ImContentType.GROUP_REQUEST_RECEIVED: { - groupRequestStore - .addGroupRequestById(payload.requestId) - .catch(() => undefined); - break; - } - default: { - break; - } + case ImContentType.GROUP_REQUEST_REJECTED: + groupRequestStore.removeGroupRequestById(payload.requestId) + break + case ImContentType.GROUP_REQUEST_RECEIVED: + groupRequestStore.addGroupRequestById(payload.requestId).catch(() => undefined) + break + default: + break } }, @@ -1131,39 +1001,38 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * * payload 携带变更字段,按非 null 字段直接局部更新;省一次 fetchGroupMemberList 接口 */ - handleGroupMemberSettingUpdate( - websocketMessage: ImGroupMessageNotification, - ) { + handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageNotification) { // content 解析失败由外层 dispatchGroupFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch - const payload: { groupRemark?: string; silent?: boolean } = JSON.parse( - websocketMessage.content || '{}', - ); - const groupStore = useGroupStore(); - const group = groupStore.getGroup(websocketMessage.groupId); + const payload: { groupRemark?: string; silent?: boolean; } = JSON.parse( + websocketMessage.content || '{}' + ) + const groupStore = useGroupStore() + const group = groupStore.getGroup(websocketMessage.groupId) if (!group) { - return; + return } - const fields: Partial = {}; - if (payload.silent !== null) { - fields.silent = payload.silent; + const fields: Partial = {} + if (payload.silent != null) { + fields.silent = payload.silent } - if (payload.groupRemark !== null) { - fields.groupRemark = payload.groupRemark; + if (payload.groupRemark != null) { + fields.groupRemark = payload.groupRemark } if (Object.keys(fields).length > 0) { - groupStore.updateGroupFields(websocketMessage.groupId, fields); + groupStore.updateGroupFields(websocketMessage.groupId, fields) } }, /** GROUP_MEMBER_NICKNAME_UPDATE:同步成员在群里的昵称 */ - handleGroupMemberNicknameUpdate( - websocketMessage: ImGroupMessageNotification, - ) { - useGroupStore().applyGroupNotification( - websocketMessage.groupId, - websocketMessage.type, - websocketMessage.content, - ); + handleGroupMemberNicknameUpdate(websocketMessage: ImGroupMessageNotification) { + void useGroupStore() + .applyGroupNotification( + websocketMessage.groupId, + websocketMessage.type, + websocketMessage.content, + websocketMessage.id + ) + .catch((e) => console.warn('[IM WS] 群成员昵称同步失败', e)) }, // ==================== 心跳 / 重连 ==================== @@ -1171,7 +1040,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { /** 心跳包:纯文本 'ping',对应服务端 'pong'(后端这层用纯字符串约定,避免 JSON 解析开销) */ sendHeartBeat() { if (this.socket && this.isConnected) { - this.socket.send('ping'); + this.socket.send('ping') } }, @@ -1180,19 +1049,22 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (this.socket) { // close() 异步触发 onclose / onerror,回调里会无条件 reconnect; // 主动关闭路径必须先全部解绑,否则 onclose 会引发自动重连,CONNECTING 期间的 in-flight message 也可能被老 onmessage 投递到 stale 上下文 - unbindWebSocketListeners(this.socket); - this.socket.close(); - this.socket = null; + this.socket.onopen = null + this.socket.onmessage = null + this.socket.onclose = null + this.socket.onerror = null + this.socket.close() + this.socket = null } // onclose 已被解绑,不会再帮我们设 isConnected=false,这里手动复位 - this.isConnected = false; - this.stopHeartbeat(); + this.isConnected = false + this.stopHeartbeat() if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null } // 主动断开(切账号 / 退出):清零退避计数,下次 connect 重新从最短间隔起算 - this.reconnectAttempts = 0; + this.reconnectAttempts = 0 }, /** @@ -1202,41 +1074,38 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 不设次数上限,频率封顶在 WS_RECONNECT_MAX_MS(约 30s)持续重试,直到链路恢复或主动 disconnect */ reconnect() { - this.stopHeartbeat(); + this.stopHeartbeat() if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null } const backoff = Math.min( WS_RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, - WS_RECONNECT_MAX_MS, - ); - const delay = - backoff + Math.floor(Math.random() * WS_RECONNECT_JITTER_MS); - this.reconnectAttempts++; - console.log( - `[IM WS] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`, - ); + WS_RECONNECT_MAX_MS + ) + const delay = backoff + Math.floor(Math.random() * WS_RECONNECT_JITTER_MS) + this.reconnectAttempts++ + console.log(`[IM WS] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`) this.reconnectTimer = setTimeout(() => { - this.connect(); - }, delay); + this.connect() + }, delay) }, /** 心跳 5 秒一次,保活 + 探活(链路断了 onclose 会触发,由 reconnect 兜底) */ startHeartbeat() { - if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer) this.heartbeatTimer = setInterval(() => { if (this.socket && this.isConnected) { - this.sendHeartBeat(); + this.sendHeartBeat() } - }, 5000); + }, 5000) }, /** 停心跳:disconnect / 重连前调,避免老 timer 在新 socket 上继续触发 sendHeartBeat */ stopHeartbeat() { if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = null; + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null } }, @@ -1248,55 +1117,48 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 单一 dispatcher,按 type 分发到 rtcStore */ handleRtcSignaling(websocketMessage: ImNoConversationNotification) { - const rtcStore = useRtcStore(); + const rtcStore = useRtcStore() switch (websocketMessage.type) { case ImContentType.RTC_CALL: { - const payload = websocketMessage as unknown as ImRtcCallNotification; + const payload = websocketMessage as unknown as ImRtcCallNotification switch (payload.status) { - case ImRtcParticipantStatus.INVITING: { + case ImRtcParticipantStatus.INVITING: if (!isValidRtcInvitePayload(payload)) { - console.warn('[IM WS] RTC_CALL invite payload 不合法', payload); - return; + console.warn('[IM WS] RTC_CALL invite payload 不合法', payload) + return } // 当前已在通话中:忽略新来电;后端层面也会拒绝,这里是兜底 if (!rtcStore.isActive) { - rtcStore.showIncoming(payload); + rtcStore.showIncoming(payload) } - break; - } + break case ImRtcParticipantStatus.JOINED: - case ImRtcParticipantStatus.LEFT: { + case ImRtcParticipantStatus.LEFT: // ACCEPT / HUNGUP 暂不需要本端额外响应;rtcStore 状态由 1602/1603 + END 维护 - break; - } - case ImRtcParticipantStatus.NO_ANSWER: { + break + case ImRtcParticipantStatus.NO_ANSWER: // 群通话单人振铃超时;信令独立保留语义,处理与 REJECTED 一致 - rtcStore.applyParticipantNoAnswer(payload); - break; - } - case ImRtcParticipantStatus.REJECTED: { - rtcStore.applyParticipantRejected(payload); - break; - } - default: { - console.warn('[IM WS] 未识别的 RTC_CALL status', payload); - } + rtcStore.applyParticipantNoAnswer(payload) + break + case ImRtcParticipantStatus.REJECTED: + rtcStore.applyParticipantRejected(payload) + break + default: + console.warn('[IM WS] 未识别的 RTC_CALL status', payload) } - return; + return } case ImContentType.RTC_PARTICIPANT_CONNECTED: { - const payload = - websocketMessage as unknown as ImRtcParticipantConnectedNotification; + const payload = websocketMessage as unknown as ImRtcParticipantConnectedNotification if (payload?.room && payload.userId) { - rtcStore.applyParticipantConnected(payload); + rtcStore.applyParticipantConnected(payload) } - return; + return } case ImContentType.RTC_PARTICIPANT_DISCONNECTED: { - const payload = - websocketMessage as unknown as ImRtcParticipantDisconnectedNotification; + const payload = websocketMessage as unknown as ImRtcParticipantDisconnectedNotification if (payload?.room && payload.userId) { - rtcStore.applyParticipantDisconnected(payload); + rtcStore.applyParticipantDisconnected(payload) } } } @@ -1304,14 +1166,14 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { /** RTC_CALL_START 通话开始 */ handleRtcCallStart(websocketMessage: ImGroupMessageNotification) { - const payload = parseRtcCallPayload(websocketMessage.content); + const payload = parseRtcCallPayload(websocketMessage.content) if (!payload?.room || !payload.mediaType || !payload.inviterUserId) { console.warn('[IM WS] RTC_CALL_START payload 不合法', { groupId: websocketMessage.groupId, messageId: websocketMessage.id, - contentLength: websocketMessage.content?.length ?? 0, - }); - return; + contentLength: websocketMessage.content?.length ?? 0 + }) + return } useRtcStore().setGroupCall({ room: payload.room, @@ -1319,8 +1181,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { mediaType: payload.mediaType, inviterId: payload.inviterUserId, joinedUserIds: [payload.inviterUserId], - inviteeIds: [], - }); + inviteeIds: [] + }) }, /** @@ -1329,43 +1191,33 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 私聊:关闭当前通话窗 * 群聊:移除胶囊条;如本端在该群通话内则关闭通话窗 */ - handleRtcCallEnd( - websocketMessage: - | ImGroupMessageNotification - | ImPrivateMessageNotification, - ) { - const payload = this.safeParse( - websocketMessage.content, - ) as ImRtcCallEndNotification | null; + handleRtcCallEnd(websocketMessage: ImGroupMessageNotification | ImPrivateMessageNotification) { + const payload = this.safeParse(websocketMessage.content) as ImRtcCallEndNotification | null if (!payload?.room) { - return; + return } - const rtcStore = useRtcStore(); - const isGroup = payload.conversationType === ImConversationType.GROUP; + const rtcStore = useRtcStore() + const isGroup = payload.conversationType === ImConversationType.GROUP // 群通话:移除对应房间的胶囊条 - const groupId = (websocketMessage as ImGroupMessageNotification).groupId; + const groupId = (websocketMessage as ImGroupMessageNotification).groupId if (isGroup && groupId) { - rtcStore.removeGroupCall(groupId, payload.room); + rtcStore.removeGroupCall(groupId, payload.room) } // 通话窗 / 来电窗指向同一 room 时关闭: // RUNNING / INVITING 阶段对比 call.room;INCOMING 阶段对比 incomingPayload.room - const matchCall = rtcStore.call?.room === payload.room; - const matchIncoming = rtcStore.incomingPayload?.room === payload.room; + const matchCall = rtcStore.call?.room === payload.room + const matchIncoming = rtcStore.incomingPayload?.room === payload.room if (rtcStore.isActive && (matchCall || matchIncoming)) { - const reasonText = resolveCallEndReasonText(payload.endReason); - console.info('[Call] end:', reasonText); - rtcStore.reset(); + const reasonText = resolveCallEndReasonText(payload.endReason) + console.info('[Call] end:', reasonText) + rtcStore.reset() } - }, - }, -}); - -export const useImWebSocketStoreWithOut = () => { - return useImWebSocketStore(); -}; + } + } +}) // dev: 让 Pinia 的 actions / state 改动支持 HMR,避免每次改 store 都得硬刷 // 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体 if (import.meta.hot) { - import.meta.hot.accept(acceptHMRUpdate(useImWebSocketStore, import.meta.hot)); + import.meta.hot.accept(acceptHMRUpdate(useImWebSocketStore, import.meta.hot)) } diff --git a/apps/web-antd/src/views/im/home/types/index.ts b/apps/web-antd/src/views/im/home/types/index.ts index 071cb6fb8..ef3a4c103 100644 --- a/apps/web-antd/src/views/im/home/types/index.ts +++ b/apps/web-antd/src/views/im/home/types/index.ts @@ -1,3 +1,6 @@ +import type { ImGroupRequestApi } from '#/api/im/group/request'; +import type { ImManagerChannelApi } from '#/api/im/manager/channel'; + // ==================== WebSocket 帧 / 事件 ==================== // 后端 WebSocket 统一帧结构:{ type, content } @@ -240,6 +243,9 @@ export interface GroupMember { isOwner?: boolean; // 是否群主(前端从 Group.ownerUserId 计算) } +/** 群成员 IndexedDB 存储结构 */ +export type GroupMemberDO = GroupMember; + // ==================== 好友 ==================== // 好友实体(前端内部结构) @@ -261,6 +267,9 @@ export interface Friend { deleteTime?: number; // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) } +/** 好友 IndexedDB 存储结构 */ +export type FriendDO = Friend; + /** * 好友申请记录(前端内部结构,对齐后端 ImFriendRequestApi.FriendRequestRespVO) */ @@ -283,6 +292,15 @@ export interface FriendRequest { toAvatar?: string; // 接收方头像 } +/** 好友申请 IndexedDB 存储结构 */ +export type FriendRequestDO = FriendRequest; + +/** 加群申请 IndexedDB 存储结构 */ +export type GroupRequestDO = ImGroupRequestApi.GroupRequestRespVO; + +/** 频道 IndexedDB 存储结构 */ +export type ChannelDO = ImManagerChannelApi.Channel; + // ==================== 用户名片 ==================== // 用户精简信息(对齐后端 UserSimpleRespVO,名片 / 头像 hover 等场景共用) diff --git a/apps/web-antd/src/views/im/utils/auth.ts b/apps/web-antd/src/views/im/utils/auth.ts index 64ece8403..d38939dfc 100644 --- a/apps/web-antd/src/views/im/utils/auth.ts +++ b/apps/web-antd/src/views/im/utils/auth.ts @@ -1,7 +1,5 @@ import { useAccessStore, useUserStore } from '@vben/stores'; -// TODO DONE @AI:已使用 Vben 的 useUserStore / useAccessStore 获取登录信息; - /** 获取当前用户编号 */ export function getCurrentUserId(): number { const userInfo = useUserStore().userInfo; diff --git a/apps/web-antd/src/views/im/utils/config.ts b/apps/web-antd/src/views/im/utils/config.ts index cbff180bf..0fe21d92a 100644 --- a/apps/web-antd/src/views/im/utils/config.ts +++ b/apps/web-antd/src/views/im/utils/config.ts @@ -11,13 +11,10 @@ // ==================== 后端镜像(与 ImProperties 默认值对齐) ==================== /** 群最大成员人数(对齐 yudao.im.group.max-member) */ -export const GROUP_MAX_MEMBER = 500; +export const GROUP_MAX_MEMBER = 500 /** 单群管理员人数上限(对齐 yudao.im.group.admin-max-count) */ -export const GROUP_ADMIN_MAX_COUNT = 3; - -/** 单群置顶消息条数上限(对齐 yudao.im.group.pin-max-count) */ -export const GROUP_PIN_MAX_COUNT = 5; +export const GROUP_ADMIN_MAX_COUNT = 3 /** * 是否启用私聊已读功能(对齐 yudao.im.message.private-read-enabled) @@ -25,7 +22,7 @@ export const GROUP_PIN_MAX_COUNT = 5; * 关闭后:进入私聊会话不再上报已读位置;气泡的「已读 / 未读」标签隐藏; * 管理后台私聊消息列表的「状态」列与详情中的状态字段隐藏 */ -export const MESSAGE_PRIVATE_READ_ENABLED = true; +export const MESSAGE_PRIVATE_READ_ENABLED = true /** * 是否启用群聊已读功能(含群消息回执,对齐 yudao.im.message.group-read-enabled) @@ -33,41 +30,29 @@ export const MESSAGE_PRIVATE_READ_ENABLED = true; * 关闭后:进入群会话不再上报已读位置;输入框的「发送回执消息」入口隐藏; * 群消息气泡上的「N 人已读」popover 隐藏;管理后台群消息列表的「状态」「回执」列与详情中对应字段隐藏 */ -export const MESSAGE_GROUP_READ_ENABLED = true; - -/** 消息撤回时间限制(分钟,对齐 yudao.im.message.recall-timeout-minutes) */ -export const MESSAGE_RECALL_TIMEOUT_MINUTES = 5; - -/** 私聊离线消息最大拉取天数(对齐 yudao.im.message.private-pull-max-days) */ -export const MESSAGE_PRIVATE_PULL_MAX_DAYS = 30; - -/** 群聊离线消息最大拉取天数(对齐 yudao.im.message.group-pull-max-days) */ -export const MESSAGE_GROUP_PULL_MAX_DAYS = 30; +export const MESSAGE_GROUP_READ_ENABLED = true // ==================== 前端独有:拉取 / 分页 ==================== /** 每次拉取私聊消息的最大条数(后端上限 1000,前端取保守值 100) */ -export const MESSAGE_PRIVATE_PULL_SIZE = 100; +export const MESSAGE_PRIVATE_PULL_SIZE = 100 /** 每次拉取群聊消息的最大条数(后端上限 1000,前端取保守值 100) */ -export const MESSAGE_GROUP_PULL_SIZE = 100; +export const MESSAGE_GROUP_PULL_SIZE = 100 /** 「我相关」好友申请列表的单次拉取条数(游标分页 page size) */ -export const FRIEND_REQUEST_PAGE_SIZE = 100; - -/** 「我相关」加群申请列表的单次拉取条数 */ -export const GROUP_REQUEST_PAGE_SIZE = 100; +export const FRIEND_REQUEST_PAGE_SIZE = 100 // ==================== 上传安全策略 ==================== // 数值与后端 Spring multipart 配置对齐(`spring.servlet.multipart.max-file-size = 16MB`) // 后端调大后这里同步调;不要单边放宽,否则用户上传完到后端 413 /** 图片 / 视频 / 普通文件单文件上限(MB),对齐后端 max-file-size */ -export const MESSAGE_IMAGE_MAX_MB = 16; -export const MESSAGE_VIDEO_MAX_MB = 16; -export const MESSAGE_FILE_MAX_MB = 16; +export const MESSAGE_IMAGE_MAX_MB = 16 +export const MESSAGE_VIDEO_MAX_MB = 16 +export const MESSAGE_FILE_MAX_MB = 16 /** 语音单文件上限(MB):60s @ 64kbps 约 0.5MB,5MB 已远超录制可能产出的大小 */ -export const MESSAGE_VOICE_MAX_MB = 5; +export const MESSAGE_VOICE_MAX_MB = 5 /** 可执行 / 脚本类扩展名黑名单;接收端点击下载后本地双击就跑,html 本地打开还能执行脚本 */ export const DANGEROUS_FILE_EXTENSIONS = [ @@ -90,26 +75,26 @@ export const DANGEROUS_FILE_EXTENSIONS = [ 'ps1', 'reg', 'html', - 'htm', -]; + 'htm' +] // ==================== 前端独有:UI 阈值 ==================== /** 消息之间渲染「时间分隔条」的阈值:10 分钟 */ -export const MESSAGE_TIME_TIP_GAP_MS = 10 * 60 * 1000; +export const MESSAGE_TIME_TIP_GAP_MS = 10 * 60 * 1000 /** * 撤回菜单可见的时间窗:自己发送的消息超过这个时长就不能再撤回,菜单回退为「删除」 * - * 比后端 MESSAGE_RECALL_TIMEOUT_MINUTES(5 分钟)严格,对齐微信 PC 的 2 分钟 + * 比后端 5 分钟撤回限制更严格,对齐微信 PC 的 2 分钟 */ -export const MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000; +export const MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000 /** 合并转发消息(MERGE 类型)气泡内预览的最大行数(对齐微信「聊天记录」气泡) */ -export const MESSAGE_MERGE_PREVIEW_LINES = 3; +export const MESSAGE_MERGE_PREVIEW_LINES = 3 /** 最近转发会话 key 列表的最大保留数量(对齐微信 PC 横向头像区可见容量) */ -export const CONVERSATION_RECENT_FORWARD_MAX = 12; +export const CONVERSATION_RECENT_FORWARD_MAX = 12 // ==================== 前端独有:RTC 通话兜底 ==================== @@ -117,17 +102,17 @@ export const CONVERSATION_RECENT_FORWARD_MAX = 12; * 振铃超时兜底;通话存活期间 timer 调用 noAnswerCallCheck 接口的间隔; * 实际超时阈值由后端 yudao.im.rtc.invite-timeout-minutes 决定,前端仅决定触发频率 */ -export const RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS = 60 * 1000; +export const RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS = 60 * 1000 // ==================== 前端独有:WebSocket 自动重连 ==================== // 指数退避:base * 2^attempt,上限封顶;每次再叠加 0~jitter ms 随机偏移 // 避免服务端重启时全量客户端在同一秒打过来形成「惊群」;不设次数上限,持续重连直到链路恢复 /** 首次重连等待,单位 ms */ -export const WS_RECONNECT_BASE_MS = 1000; +export const WS_RECONNECT_BASE_MS = 1000 /** 退避上限,单位 ms;连续失败 5 次后稳定在 30s 不再增长 */ -export const WS_RECONNECT_MAX_MS = 30 * 1000; +export const WS_RECONNECT_MAX_MS = 30 * 1000 /** 退避叠加的随机抖动上限,单位 ms */ -export const WS_RECONNECT_JITTER_MS = 1000; +export const WS_RECONNECT_JITTER_MS = 1000 diff --git a/apps/web-antd/src/views/im/utils/constants.ts b/apps/web-antd/src/views/im/utils/constants.ts index e6d1d40f7..03556b1d0 100644 --- a/apps/web-antd/src/views/im/utils/constants.ts +++ b/apps/web-antd/src/views/im/utils/constants.ts @@ -58,8 +58,8 @@ export const ImContentType = { GROUP_MEMBER_SETTING_UPDATE: 1530, // 群成员个人设置变更:silent / groupRemark 个人多端同步 GROUP_MESSAGE_PIN: 1531, // 群消息置顶(自有扩展,OpenIM 无) GROUP_MESSAGE_UNPIN: 1532, // 群消息取消置顶(自有扩展,OpenIM 无) - GROUP_BANNED: 1533, // 群封禁变更(自有扩展,OpenIM 无) -} as const; + GROUP_BANNED: 1533 // 群封禁变更(自有扩展,OpenIM 无) +} as const /** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_BANNED] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */ export function isGroupNotification(type: number): boolean { @@ -67,15 +67,12 @@ export function isGroupNotification(type: number): boolean { type >= ImContentType.GROUP_CREATE && type <= ImContentType.GROUP_BANNED && type !== ImContentType.GROUP_MEMBER_SETTING_UPDATE - ); + ) } /** 判断是否「好友通知事件」:1201-1210 段位 */ export function isFriendNotification(type: number): boolean { - return ( - type >= ImContentType.FRIEND_REQUEST_APPROVED && - type <= ImContentType.FRIEND_UPDATE - ); + return type >= ImContentType.FRIEND_REQUEST_APPROVED && type <= ImContentType.FRIEND_UPDATE } /** 判断是否「加群申请通知事件」:1503/1505/1506 */ @@ -84,21 +81,17 @@ export function isGroupRequestNotification(type: number): boolean { type === ImContentType.GROUP_REQUEST_RECEIVED || type === ImContentType.GROUP_REQUEST_APPROVED || type === ImContentType.GROUP_REQUEST_REJECTED - ); + ) } /** 判断是否「会话内的好友事件气泡」:FRIEND_ADD / FRIEND_DELETE 直接渲染成灰色提示,与群事件同处理 */ export function isFriendChatTip(type: number): boolean { - return ( - type === ImContentType.FRIEND_ADD || type === ImContentType.FRIEND_DELETE - ); + return type === ImContentType.FRIEND_ADD || type === ImContentType.FRIEND_DELETE } /** 判断是否「会话内的通话事件气泡」:RTC_CALL_START / RTC_CALL_END 渲染成灰色提示 */ export function isRtcCallTip(type: number): boolean { - return ( - type === ImContentType.RTC_CALL_START || type === ImContentType.RTC_CALL_END - ); + return type === ImContentType.RTC_CALL_START || type === ImContentType.RTC_CALL_END } /** @@ -108,38 +101,38 @@ export function isRtcCallTip(type: number): boolean { * 1. 后端发送入口校验(Im{Private,Group}MessageSendReqVO.isNormalType)—— 用户发送的内容类型必须 normal=true * 2. 前端接收侧未读 / 提示音(websocketStore)—— normal 消息计入会话未读数 + 触发声音 * 3. 前端会话列表 lastType / @ 标签(ConversationItem)—— 只有 normal 才算「最后一条聊天消息」 - * 4. 前端群消息置顶菜单(message-item.vue 的 canPin)—— normal 才允许群主 / 管理员置顶 + * 4. 前端群消息置顶菜单(MessageItem.vue 的 canPin)—— normal 才允许群主 / 管理员置顶 * * 名片(CARD)/ 表情(FACE)都是「用户主动发的聊天消息」,1/2/3 都符合预期;4 同时放开 = 群主可置顶,语义合理 */ -const ImContentTypeNormals = new Set([ +const ImContentTypeNormals: number[] = [ + ImContentType.TEXT, + ImContentType.IMAGE, + ImContentType.FILE, + ImContentType.VOICE, + ImContentType.VIDEO, ImContentType.CARD, ImContentType.FACE, - ImContentType.FILE, - ImContentType.IMAGE, - ImContentType.MATERIAL, // 频道素材计入未读数 + 进会话列表 ImContentType.MERGE, - ImContentType.TEXT, - ImContentType.VIDEO, - ImContentType.VOICE, -]); + ImContentType.MATERIAL // 频道素材计入未读数 + 进会话列表 +] /** 判断是否"普通消息" */ export function isNormalMessage(type: number): boolean { - return ImContentTypeNormals.has(type); + return ImContentTypeNormals.includes(type) } /** IM 媒体内容类型集合:发送依赖本地 File 上传,刷新后 _localFile 丢失即不可恢复 */ -const ImContentTypeMedia = new Set([ - ImContentType.FILE, +const ImContentTypeMedia: number[] = [ ImContentType.IMAGE, - ImContentType.VIDEO, + ImContentType.FILE, ImContentType.VOICE, -]); + ImContentType.VIDEO +] /** 判断是否「媒体消息」:图片 / 文件 / 语音 / 视频 */ export function isMediaMessageType(type: number): boolean { - return ImContentTypeMedia.has(type); + return ImContentTypeMedia.includes(type) } /** @@ -151,48 +144,47 @@ export const ImMessageStatus = { FAILED: -2, // 发送失败(前端独有) SENDING: -1, // 发送中(前端独有) NORMAL: 0, // 正常 - RECALL: 2, // 已撤回 -} as const; + RECALL: 2 // 已撤回 +} as const /** IM 会话类型枚举 */ export const ImConversationType = { NONE: 0, // 无会话 PRIVATE: 1, // 私聊 GROUP: 2, // 群聊 - CHANNEL: 3, // 频道 / 公众号 -} as const; + CHANNEL: 3 // 频道 / 公众号 +} as const /** ImConversationType 取值(用于消息 payload 字段类型收窄) */ -export type ImConversationTypeValue = - (typeof ImConversationType)[keyof typeof ImConversationType]; +export type ImConversationTypeValue = (typeof ImConversationType)[keyof typeof ImConversationType] /** 是否私聊会话;同时收窄类型 */ export function isPrivateConversation(type: number | undefined): boolean { - return type === ImConversationType.PRIVATE; + return type === ImConversationType.PRIVATE } /** 是否群聊会话;同时收窄类型 */ export function isGroupConversation(type: number | undefined): boolean { - return type === ImConversationType.GROUP; + return type === ImConversationType.GROUP } /** 是否频道会话;同时收窄类型 */ export function isChannelConversation(type: number | undefined): boolean { - return type === ImConversationType.CHANNEL; + return type === ImConversationType.CHANNEL } /** IM 通话媒体类型(对齐后端 ImRtcCallMediaTypeEnum) */ export const ImRtcCallMediaType = { VOICE: 1, - VIDEO: 2, -} as const; + VIDEO: 2 +} as const /** IM 通话状态(对齐后端 ImRtcCallStatusEnum) */ export const ImRtcCallStatus = { CREATED: 10, // 创建:私聊等被叫接听;群聊发起人已进房等其他人加入 RUNNING: 20, // 进行中:第一个非发起人接通后进入 - ENDED: 30, // 已结束 -} as const; + ENDED: 30 // 已结束 +} as const /** IM 通话结束原因(对齐后端 ImRtcCallEndReasonEnum) */ export const ImRtcCallEndReason = { @@ -201,12 +193,11 @@ export const ImRtcCallEndReason = { CANCEL: 3, // 主叫接通前主动取消 NO_ANSWER: 4, // 振铃超时未接通 BUSY: 5, // 私聊呼叫时对方正忙 - ERROR: 9, // 网络中断 / 设备失败 -} as const; + ERROR: 9 // 网络中断 / 设备失败 +} as const /** ImRtcCallEndReason 取值类型 */ -export type ImRtcCallEndReasonValue = - (typeof ImRtcCallEndReason)[keyof typeof ImRtcCallEndReason]; +export type ImRtcCallEndReasonValue = (typeof ImRtcCallEndReason)[keyof typeof ImRtcCallEndReason] /** IM 通话参与者状态(对齐后端 ImRtcParticipantStatusEnum);同时作为 RTC_CALL 信令 status 字段取值 */ export const ImRtcParticipantStatus = { @@ -214,12 +205,12 @@ export const ImRtcParticipantStatus = { JOINED: 20, // 接听 / 已加入 REJECTED: 30, // 拒接 NO_ANSWER: 40, // 主叫取消,被邀请方未应答 - LEFT: 50, // 挂断离开 -} as const; + LEFT: 50 // 挂断离开 +} as const /** ImRtcParticipantStatus 取值类型 */ export type ImRtcParticipantStatusValue = - (typeof ImRtcParticipantStatus)[keyof typeof ImRtcParticipantStatus]; + (typeof ImRtcParticipantStatus)[keyof typeof ImRtcParticipantStatus] /** * IM 通话 UI 阶段;前端独有,用于驱动 inviting / incoming / running 三种弹窗切换; @@ -229,61 +220,60 @@ export const ImRtcCallStage = { IDLE: 'idle', // 空闲;后端无对应(本端无活跃通话) INVITING: 'inviting', // 主叫等待对方接受;对应后端 ImRtcCallStatus.CREATED(自己是主叫) INCOMING: 'incoming', // 被叫来电响铃;对应后端 ImRtcCallStatus.CREATED(自己是被叫) - RUNNING: 'running', // 通话中;对应后端 ImRtcCallStatus.RUNNING -} as const; + RUNNING: 'running' // 通话中;对应后端 ImRtcCallStatus.RUNNING +} as const /** ImRtcCallStage 取值类型 */ -export type ImRtcCallStageValue = - (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage]; +export type ImRtcCallStageValue = (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage] /** IM WebSocket 外层帧类型 */ export const ImWebSocketMessageType = { - NOTIFICATION: 'im-notification', // IM 通知 -} as const; + NOTIFICATION: 'im-notification' // IM 通知 +} as const /** IM 消息回执状态枚举(对齐后端 ImMessageReceiptStatusEnum) */ export const ImMessageReceiptStatus = { NO_RECEIPT: 0, // 不需要回执 PENDING: 1, // 待完成 - DONE: 2, // 已完成 -} as const; + DONE: 2 // 已完成 +} as const /** 群成员角色(对齐后端 ImGroupMemberRoleEnum) */ export const ImGroupMemberRole = { OWNER: 1, // 群主 ADMIN: 2, // 管理员 - NORMAL: 3, // 普通成员 -} as const; + NORMAL: 3 // 普通成员 +} as const /** 加群来源(对齐后端 ImGroupAddSourceEnum) */ export const ImGroupAddSource = { SEARCH: 1, // 搜索 INVITE: 2, // 邀请 QR_CODE: 3, // 扫码 - SHARE_LINK: 4, // 分享链接 -} as const; + SHARE_LINK: 4 // 分享链接 +} as const /** 加群申请处理结果(对齐后端 ImGroupRequestHandleResultEnum) */ export const ImGroupRequestHandleResult = { UNHANDLED: 0, // 未处理 AGREED: 1, // 同意 - REFUSED: 2, // 拒绝 -} as const; + REFUSED: 2 // 拒绝 +} as const /** 好友添加来源(对齐后端 ImFriendAddSourceEnum) */ export const ImFriendAddSource = { SEARCH: 1, // 搜索 GROUP: 2, // 群聊 QR_CODE: 3, // 扫码 - CARD: 4, // 名片 -} as const; + CARD: 4 // 名片 +} as const /** 好友申请处理结果(对齐后端 ImFriendRequestHandleResultEnum) */ export const ImFriendRequestHandleResult = { UNHANDLED: 0, // 未处理 AGREED: 1, // 同意 - REFUSED: 2, // 拒绝 -} as const; + REFUSED: 2 // 拒绝 +} as const /** * @全体成员 的特殊 userId 标识:atUserIds 中包含 -1 表示 @ 全体成员 @@ -291,17 +281,16 @@ export const ImFriendRequestHandleResult = { * 与后端约定:群消息 atUserIds 数组里出现 -1 时,所有成员都收到提醒 * MentionPicker 渲染虚拟项 + conversationStore.applyAt 判定 atAll 都靠这个值 */ -export const IM_AT_ALL_USER_ID = -1; +export const IM_AT_ALL_USER_ID = -1 /** @全体成员 的展示名(对齐微信 PC) */ -export const IM_AT_ALL_NICKNAME = '所有人'; +export const IM_AT_ALL_NICKNAME = '所有人' /** 转发模式:SINGLE 逐条原样转 / MERGE 打包成 MergeMessage */ export const ImForwardMode = { SINGLE: 'single', - MERGE: 'merge', -} as const; + MERGE: 'merge' +} as const /** ImForwardMode 取值类型 */ -export type ImForwardModeValue = - (typeof ImForwardMode)[keyof typeof ImForwardMode]; +export type ImForwardModeValue = (typeof ImForwardMode)[keyof typeof ImForwardMode] diff --git a/apps/web-antd/src/views/im/utils/conversation.ts b/apps/web-antd/src/views/im/utils/conversation.ts index ce4a9cfa6..a9e4ea8d9 100644 --- a/apps/web-antd/src/views/im/utils/conversation.ts +++ b/apps/web-antd/src/views/im/utils/conversation.ts @@ -6,63 +6,58 @@ // 2. fallbackName 由调用方传入(典型来源:Conversation.lastSenderDisplayName 快照),透传到 getSenderDisplayName 内部,算不出真名时兜底 // ==================================================================== -import type { Message } from '../home/types'; -import type { - CardMessage, - FaceMessage, - FileMessage, - MaterialMessage, - TextMessage, - TipSegment, -} from './message'; +import type { Message } from '../home/types' import { ImContentType, ImConversationType, isFriendChatTip, isGroupNotification, - isRtcCallTip, -} from './constants'; + isRtcCallTip +} from './constants' import { + type CardMessage, + type FaceMessage, + type FileMessage, getCardLabelInfo, + type MaterialMessage, parseMessage, + segmentsToText, + type TextMessage, + tipMention, + type TipSegment, + tipText +} from './message' +import { resolveFriendNotificationText, resolveGroupNotificationText, - resolveRtcCallLastContent, - segmentsToText, - tipMention, - tipText, -} from './message'; -import { getSenderDisplayName } from './user'; + resolveRtcCallLastContent +} from './message' +import { getSenderDisplayName } from './user' /** 会话主键:`type-targetId` 拼成稳定字符串,给 v-for :key、active 比对、map key 等场景共用 */ -export function getConversationKey(conversation: { - targetId: number; - type: number; -}): string { - return `${conversation.type}-${conversation.targetId}`; +export function getConversationKey(conversation: { targetId: number; type: number; }): string { + return `${conversation.type}-${conversation.targetId}` } /** 按昵称模糊过滤会话列表:空 keyword 原样返回,命中走 toLowerCase 不区分大小写 */ export function filterConversationsByKeyword( list: T[], - keyword: string, + keyword: string ): T[] { - const trimmed = keyword.trim().toLowerCase(); + const trimmed = keyword.trim().toLowerCase() if (!trimmed) { - return list; + return list } - return list.filter((c) => (c.name || '').toLowerCase().includes(trimmed)); + return list.filter((c) => (c.name || '').toLowerCase().includes(trimmed)) } /** * 表情消息的统一文本预览:摘要 / 历史搜索 / 引用块 / 管理后台预览共用一份 * 有 name 时走 `[表情] name`(系统包),无 name 时走 `[表情]`(个人表情通常无 name) */ -export function buildFacePreviewText( - facePayload: null | undefined | { name?: string }, -): string { - return facePayload?.name ? `[表情] ${facePayload.name}` : '[表情]'; +export function buildFacePreviewText(facePayload: null | undefined | { name?: string }): string { + return facePayload?.name ? `[表情] ${facePayload.name}` : '[表情]' } /** @@ -75,24 +70,21 @@ export function buildRecallTipSegments( selfSend: boolean, conversationType: number, conversationTargetId: number, - fallbackName?: string, + fallbackName?: string ): TipSegment[] { if (selfSend) { - return [tipText('你撤回了一条消息')]; + return [tipText('你撤回了一条消息')] } const senderDisplayName = getSenderDisplayName( senderId, conversationType, conversationTargetId, - fallbackName, - ); + fallbackName + ) if (!senderId) { - return [tipText(`${senderDisplayName || '对方'} 撤回了一条消息`)]; + return [tipText(`${senderDisplayName || '对方'} 撤回了一条消息`)] } - return [ - tipMention(senderId, senderDisplayName || '对方'), - tipText(' 撤回了一条消息'), - ]; + return [tipMention(senderId, senderDisplayName || '对方'), tipText(' 撤回了一条消息')] } /** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */ @@ -101,17 +93,11 @@ export function buildRecallTip( selfSend: boolean, conversationType: number, conversationTargetId: number, - fallbackName?: string, + fallbackName?: string ): string { return segmentsToText( - buildRecallTipSegments( - senderId, - selfSend, - conversationType, - conversationTargetId, - fallbackName, - ), - ); + buildRecallTipSegments(senderId, selfSend, conversationType, conversationTargetId, fallbackName) + ) } /** @@ -122,48 +108,39 @@ export function buildRecallTip( */ export function summarizeMessageContent( message: Pick, - opts?: { withFileName?: boolean }, + opts?: { withFileName?: boolean } ): string { switch (message.type) { - case ImContentType.CARD: { - return `[${getCardLabelInfo(parseMessage(message.content)).label}]`; - } - case ImContentType.FACE: { - return buildFacePreviewText(parseMessage(message.content)); - } + case ImContentType.CARD: + return `[${getCardLabelInfo(parseMessage(message.content)).label}]` + case ImContentType.FACE: + return buildFacePreviewText(parseMessage(message.content)) case ImContentType.FILE: { if (opts?.withFileName) { - const file = parseMessage(message.content); - return file?.name ? `[文件] ${file.name}` : '[文件]'; + const file = parseMessage(message.content) + return file?.name ? `[文件] ${file.name}` : '[文件]' } - return '[文件]'; - } - case ImContentType.IMAGE: { - return '[图片]'; + return '[文件]' } + case ImContentType.IMAGE: + return '[图片]' case ImContentType.MATERIAL: { - const material = parseMessage(message.content); - return material?.title ? `[频道] ${material.title}` : '[频道]'; - } - case ImContentType.MERGE: { - return '[聊天记录]'; + const material = parseMessage(message.content) + return material?.title ? `[频道] ${material.title}` : '[频道]' } + case ImContentType.MERGE: + return '[聊天记录]' case ImContentType.RTC_CALL_END: - case ImContentType.RTC_CALL_START: { - return '[语音通话]'; - } - case ImContentType.TEXT: { - return parseMessage(message.content)?.content ?? ''; - } - case ImContentType.VIDEO: { - return '[视频]'; - } - case ImContentType.VOICE: { - return '[语音]'; - } - default: { - return ''; - } + case ImContentType.RTC_CALL_START: + return '[语音通话]' + case ImContentType.TEXT: + return parseMessage(message.content)?.content ?? '' + case ImContentType.VIDEO: + return '[视频]' + case ImContentType.VOICE: + return '[语音]' + default: + return '' } } @@ -172,7 +149,7 @@ export function resolveConversationLastContent( message: Message, conversationType: number, conversationTargetId: number, - fallbackName?: string, + fallbackName?: string ): string { if (message.type === ImContentType.RECALL) { return buildRecallTip( @@ -180,19 +157,19 @@ export function resolveConversationLastContent( message.selfSend, conversationType, conversationTargetId, - fallbackName, - ); + fallbackName + ) } if (isFriendChatTip(message.type)) { - return resolveFriendNotificationText(message); + return resolveFriendNotificationText(message) } if (isGroupNotification(message.type)) { return resolveGroupNotificationText(message, (id) => - getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0), - ); + getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0) + ) } if (isRtcCallTip(message.type)) { - return resolveRtcCallLastContent(message, conversationType); + return resolveRtcCallLastContent(message, conversationType) } - return summarizeMessageContent(message); + return summarizeMessageContent(message) } diff --git a/apps/web-antd/src/views/im/utils/db.ts b/apps/web-antd/src/views/im/utils/db.ts index e90ccb0f3..4dfb38892 100644 --- a/apps/web-antd/src/views/im/utils/db.ts +++ b/apps/web-antd/src/views/im/utils/db.ts @@ -1,12 +1,12 @@ -import type { MessageDO, SettingDO } from '../home/types'; +import type { MessageDO, SettingDO } from '../home/types' -import { toRaw } from 'vue'; +import { toRaw } from 'vue' -import { getCurrentUserId } from '#/views/im/utils/auth'; +import { getCurrentUserId } from '#/views/im/utils/auth' -import { ImConversationType } from './constants'; +import { ImConversationType } from './constants' -export const DB_SCHEMA_VERSION = 2; +export const DB_SCHEMA_VERSION = 2 export type DbStoreName = | 'channels' @@ -18,20 +18,20 @@ export type DbStoreName = | 'groupRequests' | 'groups' | 'messages' - | 'settings'; + | 'settings' -export type DbTransaction = IDBTransaction; +export type DbTransaction = IDBTransaction /** 数据库消息分页游标 */ export interface MessageDOPageCursor { - messageKey: string; - sendTime: number; + sendTime: number + messageKey: string } /** 数据库消息分页结果 */ export interface MessageDOPageResult { - hasMore: boolean; - list: MessageDO[]; + list: MessageDO[] + hasMore: boolean } /** IM 本地存储 key */ @@ -40,7 +40,7 @@ export const StorageKeys = { /** 侧边栏宽度,三个 Tab 共用一份记忆 */ asideWidth: 'im:aside', /** 会话列表置顶折叠展开态 */ - conversationPinnedExpanded: 'im:conversation:pinnedExpanded', + conversationPinnedExpanded: 'im:conversation:pinnedExpanded' }, settings: { /** 私聊消息拉取游标 */ @@ -61,44 +61,43 @@ export const StorageKeys = { groupRequestPullCursor: 'groupRequestPullCursor', /** 会话读位置增量拉取游标 */ conversationReadPullCursor: 'conversationReadPullCursor', - }, -} as const; + /** 单会话清理边界 */ + conversationClearBeforePrefix: 'conversationClearBefore:', + /** 单会话本地删除消息 key */ + conversationDeletedMessagesPrefix: 'conversationDeletedMessages:', + /** 单会话已撤回消息 key */ + conversationRecalledMessagesPrefix: 'conversationRecalledMessages:' + } +} as const -let currentDb: IDBDatabase | null = null; -let currentUserId: null | number = null; -let currentSession = 0; -let stopPromise: Promise | undefined; +let currentClient: DbClient | null = null +let initialization: + | undefined + | { + promise: Promise + userId: number + } -/** 校验当前 IM IndexedDB session 仍有效 */ -export function isCurrentDbSession(session: number): boolean { - return session === currentSession; -} - -/** 获取当前 IM IndexedDB session */ -export function getDbSession(): number { - return currentSession; -} - -/** 拼接当前用户 IM DB 名称 */ +/** 拼接当前身份 IM DB 名称 */ function getDbName(userId: number): string { - return `im:${userId}`; + return `im:${userId}` } /** 包装 IndexedDB request */ function requestToPromise(request: IDBRequest): Promise { return new Promise((resolve, reject) => { - request.addEventListener('success', () => resolve(request.result)); - request.addEventListener('error', () => reject(request.error)); - }); + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) } /** 等待事务完成 */ -function transactionDone(transaction: DbTransaction): Promise { +function transactionDone(transaction: IDBTransaction): Promise { return new Promise((resolve, reject) => { - transaction.addEventListener('complete', () => resolve()); - transaction.addEventListener('error', () => reject(transaction.error)); - transaction.addEventListener('abort', () => reject(transaction.error)); - }); + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(transaction.error) + }) } /** 创建索引 */ @@ -106,200 +105,168 @@ function createIndex( store: IDBObjectStore, name: string, keyPath: string | string[], - options?: IDBIndexParameters, + options?: IDBIndexParameters ) { if (!store.indexNames.contains(name)) { - store.createIndex(name, keyPath, options); + 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'); + const store = db.createObjectStore('conversations', { keyPath: 'clientConversationId' }) + createIndex(store, 'lastSendTime', 'lastSendTime') } if (!db.objectStoreNames.contains('conversationReads')) { - const store = db.createObjectStore('conversationReads', { - keyPath: 'clientConversationId', - }); - createIndex( - store, - 'conversationType+targetId', - ['conversationType', 'targetId'], - { - unique: true, - }, - ); + const store = db.createObjectStore('conversationReads', { keyPath: 'clientConversationId' }) + createIndex(store, 'conversationType+targetId', ['conversationType', 'targetId'], { + unique: true + }) } 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 }); + 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'); + 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'); + 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'); + 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, - }); + 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'); + 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'); + 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' }); + db.createObjectStore('settings', { keyPath: 'key' }) } } /** 打开 IM IndexedDB */ function openDb(name: string): Promise { return new Promise((resolve, reject) => { - const request = indexedDB.open(name, DB_SCHEMA_VERSION); + const request = indexedDB.open(name, DB_SCHEMA_VERSION) // 创建或升级对象仓库 - request.addEventListener('upgradeneeded', () => - upgradeSchema(request.result), - ); + request.onupgradeneeded = () => upgradeSchema(request.result) // 返回可复用连接 - request.addEventListener('success', () => resolve(request.result)); - request.addEventListener('error', () => reject(request.error)); - }); + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) } /** 初始化当前用户 IM DB */ -export async function initDb(): Promise { - while (stopPromise) { - await stopPromise; - } - const userId = getCurrentUserId(); +export async function initDb(): Promise { + const userId = getCurrentUserId() if (!Number.isFinite(userId) || userId <= 0) { - throw new Error('当前用户不存在,无法初始化 IM DB'); + throw new Error('当前用户不存在,无法初始化 IM DB') } - if (currentDb && currentUserId === userId) { - return; + if (currentClient?.userId === userId) { + return currentClient } - const session = ++currentSession; - const previousDb = currentDb; - currentDb = null; - currentUserId = userId; - previousDb?.close(); - const nextDb = await openDb(getDbName(userId)); - if ( - !isCurrentDbSession(session) || - currentUserId !== userId || - getCurrentUserId() !== userId - ) { - nextDb.close(); - throw new Error('IM DB 初始化已失效'); + if (initialization?.userId === userId) { + return initialization.promise + } + const promise = openDb(getDbName(userId)).then((nextDb) => { + if (initialization?.promise !== promise || getCurrentUserId() !== userId) { + nextDb.close() + throw new Error('IM DB 初始化已失效') + } + const nextClient = new DbClient(nextDb, userId) + currentClient?.close() + currentClient = nextClient + return nextClient + }) + initialization = { userId, promise } + try { + return await promise + } finally { + if (initialization?.promise === promise) { + initialization = undefined + } } - currentDb = nextDb; } /** 关闭当前 IM DB 连接 */ -function closeDbConnection() { - currentDb?.close(); - currentDb = null; - currentUserId = null; -} - -/** 获取当前 IM DB */ -function getRawDb(): IDBDatabase { - if (!currentDb) { - throw new Error('IM DB 未初始化'); - } - return currentDb; -} - -/** 校验单次事务仍属于当前用户与 DB session */ -function guardSession(session: number, userId: number) { - if ( - !isCurrentDbSession(session) || - currentUserId !== userId || - getCurrentUserId() !== userId - ) { - throw new Error('IM DB session 已失效'); - } +export function closeDb(): Promise { + initialization = undefined + currentClient?.close() + currentClient = null + return Promise.resolve() } /** 克隆可入库对象 */ function toDbValue(value: T): T { - return cloneDbValue(value) as T; + return cloneDbValue(value) as T } /** 转换为 IndexedDB 可克隆对象 */ function cloneDbValue(value: unknown): unknown { - const raw = toRaw(value); + const raw = toRaw(value) if (Array.isArray(raw)) { - return raw.map((item) => cloneDbValue(item)); + return raw.map((item) => cloneDbValue(item)) } if (!raw || typeof raw !== 'object') { - return raw; + return raw } - const prototype = Object.getPrototypeOf(raw); + const prototype = Object.getPrototypeOf(raw) if (prototype !== Object.prototype && prototype !== null) { - return raw; + return raw } return Object.fromEntries( - Object.entries(raw as Record).map(([key, item]) => [ - key, - cloneDbValue(item), - ]), - ); + Object.entries(raw as Record).map(([key, item]) => [key, cloneDbValue(item)]) + ) } -class DbClient { +export class DbClient { + constructor( + private readonly db: IDBDatabase, + readonly userId: number + ) {} + /** 清空 store 记录 */ async clearStore(storeName: DbStoreName, tx?: DbTransaction): Promise { if (tx) { - await requestToPromise(tx.objectStore(storeName).clear()); - return; + await requestToPromise(tx.objectStore(storeName).clear()) + return } - await this.transaction([storeName], 'readwrite', (tx) => - this.clearStore(storeName, tx), - ); + await this.transaction([storeName], 'readwrite', (tx) => this.clearStore(storeName, tx)) + } + + /** 关闭底层 IndexedDB 连接 */ + close(): void { + this.db.close() } /** 删除记录 */ - async delete( - storeName: DbStoreName, - key: IDBValidKey, - tx?: DbTransaction, - ): Promise { + async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTransaction): Promise { if (tx) { - await requestToPromise(tx.objectStore(storeName).delete(key)); - return; + await requestToPromise(tx.objectStore(storeName).delete(key)) + return } - await this.transaction([storeName], 'readwrite', (tx) => - this.delete(storeName, key, tx), - ); + await this.transaction([storeName], 'readwrite', (tx) => this.delete(storeName, key, tx)) } /** 按索引删除记录 */ @@ -307,54 +274,50 @@ class DbClient { storeName: DbStoreName, indexName: string, query: IDBKeyRange | IDBValidKey, - tx?: DbTransaction, + tx?: DbTransaction ): Promise { if (!tx) { await this.transaction([storeName], 'readwrite', (tx) => - this.deleteByIndex(storeName, indexName, query, tx), - ); - return; + this.deleteByIndex(storeName, indexName, query, tx) + ) + return } - const index = tx.objectStore(storeName).index(indexName); + const index = tx.objectStore(storeName).index(indexName) await new Promise((resolve, reject) => { - const request = index.openCursor(query); - request.addEventListener('error', () => reject(request.error)); - request.addEventListener('success', () => { - const cursor = request.result; + const request = index.openCursor(query) + request.onerror = () => reject(request.error) + request.onsuccess = () => { + const cursor = request.result if (!cursor) { - resolve(); - return; + resolve() + return } - cursor.delete(); - cursor.continue(); - }); - }); + cursor.delete() + cursor.continue() + } + }) } /** 获取单条记录 */ async get( storeName: DbStoreName, key: IDBValidKey, - tx?: DbTransaction, + tx?: DbTransaction ): Promise { if (tx) { - return requestToPromise( - tx.objectStore(storeName).get(key), - ); + return requestToPromise(tx.objectStore(storeName).get(key)) } return this.transaction([storeName], 'readonly', (tx) => - this.get(storeName, key, tx), - ); + this.get(storeName, key, tx) + ) } /** 获取 store 全量记录 */ async getAll(storeName: DbStoreName, tx?: DbTransaction): Promise { if (tx) { - return requestToPromise(tx.objectStore(storeName).getAll()); + return requestToPromise(tx.objectStore(storeName).getAll()) } - return this.transaction([storeName], 'readonly', (tx) => - this.getAll(storeName, tx), - ); + return this.transaction([storeName], 'readonly', (tx) => this.getAll(storeName, tx)) } /** 按索引获取记录列表 */ @@ -362,16 +325,14 @@ class DbClient { storeName: DbStoreName, indexName: string, query?: IDBKeyRange | IDBValidKey, - tx?: DbTransaction, + tx?: DbTransaction ): Promise { if (tx) { - return requestToPromise( - tx.objectStore(storeName).index(indexName).getAll(query), - ); + return requestToPromise(tx.objectStore(storeName).index(indexName).getAll(query)) } return this.transaction([storeName], 'readonly', (tx) => - this.getAllByIndex(storeName, indexName, query, tx), - ); + this.getAllByIndex(storeName, indexName, query, tx) + ) } /** 按唯一索引获取单条记录 */ @@ -379,184 +340,152 @@ class DbClient { storeName: DbStoreName, indexName: string, query: IDBKeyRange | IDBValidKey, - tx?: DbTransaction, + tx?: DbTransaction ): Promise { if (tx) { - return requestToPromise( - tx.objectStore(storeName).index(indexName).get(query), - ); + return requestToPromise(tx.objectStore(storeName).index(indexName).get(query)) } return this.transaction([storeName], 'readonly', (tx) => - this.getByIndex(storeName, indexName, query, tx), - ); + this.getByIndex(storeName, indexName, query, tx) + ) } /** 按会话分页获取消息 */ async getMessageListByConversation( clientConversationId: string, options?: { before?: MessageDOPageCursor; limit?: number }, - tx?: DbTransaction, + tx?: DbTransaction ): Promise { - const limit = options?.limit ?? 50; - const before = options?.before; - const upper = before?.sendTime ?? Number.MAX_SAFE_INTEGER; + const limit = options?.limit ?? 50 + const before = options?.before + const upper = before?.sendTime ?? Number.MAX_SAFE_INTEGER const range = IDBKeyRange.bound( [clientConversationId, 0], [clientConversationId, upper], false, - !before, - ); + !before + ) const read = async (tx: DbTransaction): Promise => { - const index = tx - .objectStore('messages') - .index('clientConversationId+sendTime'); - const out: MessageDO[] = []; + const index = tx.objectStore('messages').index('clientConversationId+sendTime') + const out: MessageDO[] = [] await new Promise((resolve, reject) => { // 从新到旧读取一页 - const request = index.openCursor(range, 'prev'); - request.addEventListener('error', () => reject(request.error)); - request.addEventListener('success', () => { - const cursor = request.result; + const request = index.openCursor(range, 'prev') + request.onerror = () => reject(request.error) + request.onsuccess = () => { + const cursor = request.result if (!cursor) { - resolve(); - return; + resolve() + return } - const message = cursor.value as MessageDO; + const message = cursor.value as MessageDO if ( before && message.sendTime === before.sendTime && message.messageKey >= before.messageKey ) { - cursor.continue(); - return; + cursor.continue() + return } - out.push(message); + out.push(message) if (out.length > limit) { - resolve(); - return; + resolve() + return } - cursor.continue(); - }); - }); + cursor.continue() + } + }) // 气泡渲染需要按时间升序 return { - hasMore: out.length > limit, - list: out.slice(0, limit).toReversed(), - }; - }; - if (tx) { - return read(tx); + list: out.slice(0, limit).reverse(), + hasMore: out.length > limit + } } - return this.transaction( - ['messages'], - 'readonly', - read, - ); + if (tx) { + return read(tx) + } + return this.transaction(['messages'], 'readonly', read) } /** 读取设置 */ async getSetting(key: string, tx?: DbTransaction): Promise { - const item = await this.get>('settings', key, tx); - return item?.value; + const item = await this.get>('settings', key, tx) + return item?.value } /** 写入记录 */ - async put( - storeName: DbStoreName, - value: T, - tx?: DbTransaction, - ): Promise { + async put(storeName: DbStoreName, value: T, tx?: DbTransaction): Promise { if (tx) { - await requestToPromise(tx.objectStore(storeName).put(toDbValue(value))); - return; + await requestToPromise(tx.objectStore(storeName).put(toDbValue(value))) + return } - await this.transaction([storeName], 'readwrite', (tx) => - this.put(storeName, value, tx), - ); + await this.transaction([storeName], 'readwrite', (tx) => this.put(storeName, value, tx)) } /** 写入设置 */ - async setSetting( - key: string, - value: T, - tx?: DbTransaction, - ): Promise { - await this.put>( - 'settings', - { key, value, updateTime: Date.now() }, - tx, - ); + async setSetting(key: string, value: T, tx?: DbTransaction): Promise { + await this.put>('settings', { key, value, updateTime: Date.now() }, tx) } /** 执行事务 */ - async transaction( + transaction( storeNames: DbStoreName[], mode: IDBTransactionMode, - runner: (tx: DbTransaction) => Promise, + runner: (tx: DbTransaction) => Promise ): Promise { - // 开启事务前校验 session - const session = getDbSession(); - const userId = getCurrentUserId(); - guardSession(session, userId); - const tx = getRawDb().transaction(storeNames, mode); - const done = transactionDone(tx); - let result: T; - try { - // 事务内只执行 IndexedDB request 链 - result = await runner(tx); - } catch (error) { + return (async () => { + const tx = this.db.transaction(storeNames, mode) + const done = transactionDone(tx) + let result: T try { - tx.abort(); - } catch {} - await done.catch(() => undefined); - throw error; - } - // commit 后再次校验 session - await done; - guardSession(session, userId); - return result; + result = await runner(tx) + } catch (e) { + try { + tx.abort() + } catch {} + await done.catch(() => undefined) + throw e + } + await done + return result + })() } } -const dbClient = new DbClient(); - -/** 获取当前 IM DB client */ +/** 获取当前用户 IM DB client */ export function getDb(): DbClient { - return dbClient; + if (!currentClient || currentClient.userId !== getCurrentUserId()) { + throw new Error('IM DB 未初始化,请先调用 initDb()') + } + return currentClient } /** 当前用户会话主键 */ -export function getClientConversationId( - type: number, - targetId: number, -): string { - return `${type}:${targetId}`; +export function getClientConversationId(type: number, targetId: number): string { + return `${type}:${targetId}` } /** 解析当前用户会话主键 */ export function parseClientConversationId( - clientConversationId: string, -): null | { targetId: number; type: number } { - const [typeText, targetIdText] = clientConversationId.split(':'); - const type = Number(typeText); - const targetId = Number(targetIdText); + clientConversationId: string +): null | { targetId: number; type: number; } { + const [typeText, targetIdText] = clientConversationId.split(':') + const type = Number(typeText) + const targetId = Number(targetIdText) if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) { - return null; + return null } - return { type, targetId }; + return { type, targetId } } /** 服务端消息主键 */ -export function getServerMessageKey( - conversationType: number, - id: number, -): string { - return `${conversationType}:${id}`; +export function getServerMessageKey(conversationType: number, id: number): string { + return `${conversationType}:${id}` } /** 客户端临时消息主键 */ export function getClientMessageKey(clientMessageId: string): string { - return `client:${clientMessageId}`; + return `client:${clientMessageId}` } /** 更新消息拉取游标 */ @@ -564,84 +493,34 @@ export async function setMessageMaxId( conversationType: number, maxId: number | undefined, tx?: DbTransaction, + db: DbClient = getDb() ): Promise { if (!maxId) { - return; + return } - let key: string; + let key: string switch (conversationType) { - case ImConversationType.CHANNEL: { - key = StorageKeys.settings.channelMessageMaxId; - break; - } - case ImConversationType.GROUP: { - key = StorageKeys.settings.groupMessageMaxId; - break; - } - case ImConversationType.PRIVATE: { - key = StorageKeys.settings.privateMessageMaxId; - break; - } - default: { - throw new Error(`未知 IM 会话类型:${conversationType}`); - } + case ImConversationType.CHANNEL: + key = StorageKeys.settings.channelMessageMaxId + break + case ImConversationType.GROUP: + key = StorageKeys.settings.groupMessageMaxId + break + case ImConversationType.PRIVATE: + key = StorageKeys.settings.privateMessageMaxId + break + default: + throw new Error(`未知 IM 会话类型:${conversationType}`) } - const db = getDb(); const updateMaxId = async (transaction: DbTransaction) => { - const current = (await db.getSetting(key, transaction)) || 0; + const current = (await db.getSetting(key, transaction)) || 0 if (maxId > current) { - await db.setSetting(key, maxId, transaction); + await db.setSetting(key, maxId, transaction) } - }; - if (tx) { - await updateMaxId(tx); - return; } - await db.transaction(['settings'], 'readwrite', updateMaxId); -} - -/** 停止当前 IM DB session */ -export function stopRequests(): Promise { - const session = ++currentSession; - closeDbConnection(); - const task = (async () => { - const [ - { useMessageStoreWithOut }, - { useConversationStoreWithOut }, - { useFriendStoreWithOut }, - { useGroupStoreWithOut }, - { useChannelStoreWithOut }, - { useGroupRequestStoreWithOut }, - { useFaceStoreWithOut }, - { useRtcStore }, - ] = 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/faceStore'), - import('../home/store/rtcStore'), - ]); - if (!isCurrentDbSession(session)) { - return; - } - useMessageStoreWithOut().clear(); - useConversationStoreWithOut().clear(); - useFriendStoreWithOut().clear(); - useGroupStoreWithOut().clear(); - useChannelStoreWithOut().clear(); - useGroupRequestStoreWithOut().clear(); - useFaceStoreWithOut().clear(); - useRtcStore().reset(); - useRtcStore().clearGroupCallCache(); - })(); - const settled = task.finally(() => { - if (stopPromise === settled) { - stopPromise = undefined; - } - }); - stopPromise = settled; - return settled; + if (tx) { + await updateMaxId(tx) + return + } + await db.transaction(['settings'], 'readwrite', updateMaxId) } diff --git a/apps/web-antd/src/views/im/utils/emoji.ts b/apps/web-antd/src/views/im/utils/emoji.ts index d9813a7c2..6154f8d28 100644 --- a/apps/web-antd/src/views/im/utils/emoji.ts +++ b/apps/web-antd/src/views/im/utils/emoji.ts @@ -134,5 +134,5 @@ export const IM_EMOJI_LIST: string[] = [ '✅', '❌', '⚠️', - '❓', -]; + '❓' +] diff --git a/apps/web-antd/src/views/im/utils/group.ts b/apps/web-antd/src/views/im/utils/group.ts index 4d1664ddb..bfc5feada 100644 --- a/apps/web-antd/src/views/im/utils/group.ts +++ b/apps/web-antd/src/views/im/utils/group.ts @@ -1,54 +1,50 @@ -import type { FriendLite } from '../home/types'; +import type { FriendLite } from '../home/types' -import { loadImage } from './image'; -import { getAvatarBgColor, getAvatarText } from './user'; +import { loadImage } from './image' +import { getAvatarBgColor, getAvatarText } from './user' /** 默认群名生成:所选好友前 4 个名字拼接,超过补「等 N 人」;为空兜底「群聊」 */ export function buildDefaultGroupName(members: FriendLite[]): string { if (members.length === 0) { - return '群聊'; + return '群聊' } - const names = members - .slice(0, 4) - .map((m) => m.displayName || m.nickname || ''); - const head = names.filter(Boolean).join('、'); + const names = members.slice(0, 4).map((m) => m.displayName || m.nickname || '') + const head = names.filter(Boolean).join('、') if (members.length > 4) { // members 只含被选好友,+1 把创建者也计入实际成员数 - return `${head}等${members.length + 1}人`; + return `${head}等${members.length + 1}人` } - return head || '群聊'; + return head || '群聊' } /** 群头像单格的输入:头像 URL + 名字 ,至少给一个,二者都缺时走灰底空格 */ export interface GroupAvatarMember { /** 头像 URL;缺失或加载失败时按 name 画色卡 */ - avatar?: string; + avatar?: string /** 显示名(昵称 / 备注);色卡文字 + 底色 hash 来源 */ - name?: string; + name?: string } /** 群头像拼接的可选参数 */ export interface BuildGroupAvatarOptions { /** 输出画布边长(像素);默认 64 */ - targetSize?: number; + targetSize?: number /** 单格之间的间隔(像素);默认 1 */ - divider?: number; + divider?: number /** 画布底色;默认透明,让上下留白透出宿主容器底色 */ - background?: string; + background?: string } /** 单格在画布上的位置 + 尺寸 */ interface CellRect { - x: number; - y: number; - w: number; - h: number; + x: number + y: number + w: number + h: number } /** 单格的最终绘制内容 */ -type Cell = - | { bg: string; kind: 'color'; text: string } - | { img: HTMLImageElement; kind: 'image' }; +type Cell = { bg: string; kind: 'color'; text: string; } | { img: HTMLImageElement; kind: 'image'; } /** * 把群成员头像拼成一张方形群头像 dataURL,按 1 ~ 9 张走九宫格变体布局 @@ -59,108 +55,104 @@ type Cell = */ export async function buildGroupAvatar( members: GroupAvatarMember[], - options: BuildGroupAvatarOptions = {}, + options: BuildGroupAvatarOptions = {} ): Promise { - const targetSize = options.targetSize ?? 64; - const divider = options.divider ?? 1; - const background = options.background; + const targetSize = options.targetSize ?? 64 + const divider = options.divider ?? 1 + const background = options.background - const top = members.slice(0, 9); + const top = members.slice(0, 9) if (top.length === 0) { - return ''; + return '' } - const cells = await Promise.all(top.map((m) => resolveCell(m))); - const rects = computeCellRects(cells.length, targetSize, divider); + const cells = await Promise.all(top.map((m) => resolveCell(m))) + const rects = computeCellRects(cells.length, targetSize, divider) - const canvas = document.createElement('canvas'); - canvas.width = targetSize; - canvas.height = targetSize; - const ctx = canvas.getContext('2d'); + const canvas = document.createElement('canvas') + canvas.width = targetSize + canvas.height = targetSize + const ctx = canvas.getContext('2d') if (!ctx) { - return ''; + return '' } if (background) { - ctx.fillStyle = background; - ctx.fillRect(0, 0, targetSize, targetSize); + ctx.fillStyle = background + ctx.fillRect(0, 0, targetSize, targetSize) } cells.forEach((cell, idx) => { - const rect = rects[idx]; + const rect = rects[idx] if (!rect) { - return; + return } - drawCell(ctx, rect, cell); - }); - return canvas.toDataURL('image/png'); + drawCell(ctx, rect, cell) + }) + return canvas.toDataURL('image/png') } /** 群头像 dataURL 缓存上限:每条约 5 ~ 30KB,200 条软封顶约 5MB 常驻 */ -const MERGED_AVATAR_CACHE_MAX = 200; -const mergedAvatarCache = new Map(); +const MERGED_AVATAR_CACHE_MAX = 200 +const mergedAvatarCache = new Map() /** 取群头像 dataURL 缓存;命中时按 LRU 提到末尾 */ export function getCachedGroupAvatar(key: string): string | undefined { - const cached = mergedAvatarCache.get(key); + const cached = mergedAvatarCache.get(key) if (cached === undefined) { - return undefined; + return undefined } - mergedAvatarCache.delete(key); - mergedAvatarCache.set(key, cached); - return cached; + mergedAvatarCache.delete(key) + mergedAvatarCache.set(key, cached) + return cached } /** 写群头像 dataURL 缓存;超上限时丢弃最早一条(Map 迭代顺序 = 插入顺序,配合 get 的提升即 LRU) */ export function setCachedGroupAvatar(key: string, value: string): void { if (mergedAvatarCache.has(key)) { - mergedAvatarCache.delete(key); + mergedAvatarCache.delete(key) } else if (mergedAvatarCache.size >= MERGED_AVATAR_CACHE_MAX) { - const oldest = mergedAvatarCache.keys().next().value; + const oldest = mergedAvatarCache.keys().next().value if (oldest !== undefined) { - mergedAvatarCache.delete(oldest); + mergedAvatarCache.delete(oldest) } } - mergedAvatarCache.set(key, value); + mergedAvatarCache.set(key, value) } /** 单成员 → Cell:有 avatar 且加载成功走 image,否则走 color */ async function resolveCell(member: GroupAvatarMember): Promise { if (member.avatar) { - const img = await loadImage(member.avatar); + const img = await loadImage(member.avatar) if (img) { - return { kind: 'image', img }; + return { kind: 'image', img } } } return { kind: 'color', text: getAvatarText(member.name), - bg: getAvatarBgColor(member.name), - }; + bg: getAvatarBgColor(member.name) + } } /** 把单个 Cell 画到 ctx 上指定格子里;image 走 cover 裁剪保比例,color 走 fillRect + 居中文字 */ -function drawCell( - ctx: CanvasRenderingContext2D, - rect: CellRect, - cell: Cell, -): void { - const { x, y, w, h } = rect; +function drawCell(ctx: CanvasRenderingContext2D, rect: CellRect, cell: Cell): void { + const { x, y, w, h } = rect if (cell.kind === 'image') { - drawImageCover(ctx, cell.img, x, y, w, h); - return; + drawImageCover(ctx, cell.img, x, y, w, h) + return } - ctx.fillStyle = cell.bg; - ctx.fillRect(x, y, w, h); + ctx.fillStyle = cell.bg + ctx.fillRect(x, y, w, h) if (!cell.text) { - return; + return } // 字号按短边算;单字 0.42、双字 0.34 - const baseSize = Math.min(w, h); - const fontSize = Math.floor(baseSize * (cell.text.length > 1 ? 0.34 : 0.42)); - ctx.fillStyle = '#FFFFFF'; - ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(cell.text, x + w / 2, y + h / 2); + const baseSize = Math.min(w, h) + const fontSize = Math.floor(baseSize * (cell.text.length > 1 ? 0.34 : 0.42)) + ctx.fillStyle = '#FFFFFF' + ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(cell.text, x + w / 2, y + h / 2) } /** cover 裁剪:从源图中心裁出与目标矩形同比例的子区域,画到目标矩形(保持比例不变形) */ @@ -170,22 +162,22 @@ function drawImageCover( dx: number, dy: number, dw: number, - dh: number, + dh: number ): void { - const srcAspect = img.width / img.height; - const dstAspect = dw / dh; - let sx = 0; - let sy = 0; - let sw = img.width; - let sh = img.height; + const srcAspect = img.width / img.height + const dstAspect = dw / dh + let sx = 0 + let sy = 0 + let sw = img.width + let sh = img.height if (srcAspect > dstAspect) { - sw = sh * dstAspect; - sx = (img.width - sw) / 2; + sw = sh * dstAspect + sx = (img.width - sw) / 2 } else if (srcAspect < dstAspect) { - sh = sw / dstAspect; - sy = (img.height - sh) / 2; + sh = sw / dstAspect + sy = (img.height - sh) / 2 } - ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh); + ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) } /** @@ -196,154 +188,122 @@ function drawImageCover( * - 5~6 张:3 列 2 行正方形单格 + 垂直居中 * - 7~9 张:3 列 3 行正方形单格 */ -function computeCellRects( - count: number, - target: number, - divider: number, -): CellRect[] { +function computeCellRects(count: number, target: number, divider: number): CellRect[] { if (count <= 0) { - return []; + return [] } if (count === 1) { - return [{ x: 0, y: 0, w: target, h: target }]; + return [{ x: 0, y: 0, w: target, h: target }] } if (count <= 4) { - return computeCellRectsSmall(count, target, divider); + return computeCellRectsSmall(count, target, divider) } if (count <= 6) { - return computeCellRectsMedium(count, target, divider); + return computeCellRectsMedium(count, target, divider) } - return computeCellRectsLarge(count, target, divider); + return computeCellRectsLarge(count, target, divider) } /** 2~4 张:2 列 2 行正方形 */ -function computeCellRectsSmall( - count: number, - target: number, - divider: number, -): CellRect[] { - const s = (target - 3 * divider) / 2; - const step = s + divider; - const rects: CellRect[] = []; +function computeCellRectsSmall(count: number, target: number, divider: number): CellRect[] { + const s = (target - 3 * divider) / 2 + const step = s + divider + const rects: CellRect[] = [] switch (count) { case 2: { // 居中 1 行(留上下空白) - const y = target / 4; - rects.push( - { x: divider, y, w: s, h: s }, - { x: divider + step, y, w: s, h: s }, - ); - break; + const y = target / 4 + rects.push({ x: divider, y, w: s, h: s }) + rects.push({ x: divider + step, y, w: s, h: s }) + break } case 3: { // 上 1 居中 + 下 2 - rects.push({ x: target / 4, y: divider, w: s, h: s }); - const y1 = divider + step; - rects.push( - { x: divider, y: y1, w: s, h: s }, - { x: divider + step, y: y1, w: s, h: s }, - ); - break; + rects.push({ x: target / 4, y: divider, w: s, h: s }) + const y1 = divider + step + rects.push({ x: divider, y: y1, w: s, h: s }) + rects.push({ x: divider + step, y: y1, w: s, h: s }) + break } case 4: { // 2×2 满铺 - rects.push( - { x: divider, y: divider, w: s, h: s }, - { x: divider + step, y: divider, w: s, h: s }, - ); - const y1 = divider + step; - rects.push( - { x: divider, y: y1, w: s, h: s }, - { x: divider + step, y: y1, w: s, h: s }, - ); - break; + rects.push({ x: divider, y: divider, w: s, h: s }) + rects.push({ x: divider + step, y: divider, w: s, h: s }) + const y1 = divider + step + rects.push({ x: divider, y: y1, w: s, h: s }) + rects.push({ x: divider + step, y: y1, w: s, h: s }) + break } } - return rects; + return rects } /** 5~6 张:3 列 2 行正方形单格 + 垂直居中;上下留白由透明背景透出 GroupAvatar 容器底色 */ -function computeCellRectsMedium( - count: number, - target: number, - divider: number, -): CellRect[] { - const s = (target - 4 * divider) / 3; - const step = s + divider; - const xs = [divider, divider + step, divider + 2 * step] as const; +function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] { + const s = (target - 4 * divider) / 3 + const step = s + divider + const xs: [number, number, number] = [divider, divider + step, divider + 2 * step] // 2 行高 + 1 行间 div - const totalH = 2 * s + divider; - const y0 = (target - totalH) / 2; - const y1 = y0 + step; - const rects: CellRect[] = []; + const totalH = 2 * s + divider + const y0 = (target - totalH) / 2 + const y1 = y0 + step + const rects: CellRect[] = [] if (count === 5) { // 上 2 居中(左右对称留白) + 下 3 左右贴边 - const upX0 = (target - 2 * s - divider) / 2; - rects.push( - { x: upX0, y: y0, w: s, h: s }, - { x: upX0 + step, y: y0, w: s, h: s }, - { x: xs[0], y: y1, w: s, h: s }, - { x: xs[1], y: y1, w: s, h: s }, - { x: xs[2], y: y1, w: s, h: s }, - ); - return rects; + const upX0 = (target - 2 * s - divider) / 2 + rects.push({ x: upX0, y: y0, w: s, h: s }) + rects.push({ x: upX0 + step, y: y0, w: s, h: s }) + rects.push({ x: xs[0], y: y1, w: s, h: s }) + rects.push({ x: xs[1], y: y1, w: s, h: s }) + rects.push({ x: xs[2], y: y1, w: s, h: s }) + return rects } // count === 6:上 3 + 下 3 满铺 - rects.push( - { x: xs[0], y: y0, w: s, h: s }, - { x: xs[1], y: y0, w: s, h: s }, - { x: xs[2], y: y0, w: s, h: s }, - { x: xs[0], y: y1, w: s, h: s }, - { x: xs[1], y: y1, w: s, h: s }, - { x: xs[2], y: y1, w: s, h: s }, - ); - return rects; + rects.push({ x: xs[0], y: y0, w: s, h: s }) + rects.push({ x: xs[1], y: y0, w: s, h: s }) + rects.push({ x: xs[2], y: y0, w: s, h: s }) + rects.push({ x: xs[0], y: y1, w: s, h: s }) + rects.push({ x: xs[1], y: y1, w: s, h: s }) + rects.push({ x: xs[2], y: y1, w: s, h: s }) + return rects } /** 7~9 张:3 列 3 行正方形单格 */ -function computeCellRectsLarge( - count: number, - target: number, - divider: number, -): CellRect[] { - const s = (target - 4 * divider) / 3; - const step = s + divider; - const xs = [divider, divider + step, divider + 2 * step] as const; - const ys = [divider, divider + step, divider + 2 * step] as const; - const rects: CellRect[] = []; +function computeCellRectsLarge(count: number, target: number, divider: number): CellRect[] { + const s = (target - 4 * divider) / 3 + const step = s + divider + const xs: [number, number, number] = [divider, divider + step, divider + 2 * step] + const ys: [number, number, number] = [divider, divider + step, divider + 2 * step] + const rects: CellRect[] = [] if (count === 7) { // 上 1 居中 + 中 3 + 下 3 - rects.push( - { x: xs[1], y: ys[0], w: s, h: s }, - { x: xs[0], y: ys[1], w: s, h: s }, - { x: xs[1], y: ys[1], w: s, h: s }, - { x: xs[2], y: ys[1], w: s, h: s }, - { x: xs[0], y: ys[2], w: s, h: s }, - { x: xs[1], y: ys[2], w: s, h: s }, - { x: xs[2], y: ys[2], w: s, h: s }, - ); - return rects; + rects.push({ x: xs[1], y: ys[0], w: s, h: s }) + rects.push({ x: xs[0], y: ys[1], w: s, h: s }) + rects.push({ x: xs[1], y: ys[1], w: s, h: s }) + rects.push({ x: xs[2], y: ys[1], w: s, h: s }) + rects.push({ x: xs[0], y: ys[2], w: s, h: s }) + rects.push({ x: xs[1], y: ys[2], w: s, h: s }) + rects.push({ x: xs[2], y: ys[2], w: s, h: s }) + return rects } if (count === 8) { // 上 2 居中 + 中 3 + 下 3 - const upX0 = (target - 2 * s - divider) / 2; - rects.push( - { x: upX0, y: ys[0], w: s, h: s }, - { x: upX0 + step, y: ys[0], w: s, h: s }, - { x: xs[0], y: ys[1], w: s, h: s }, - { x: xs[1], y: ys[1], w: s, h: s }, - { x: xs[2], y: ys[1], w: s, h: s }, - { x: xs[0], y: ys[2], w: s, h: s }, - { x: xs[1], y: ys[2], w: s, h: s }, - { x: xs[2], y: ys[2], w: s, h: s }, - ); - return rects; + const upX0 = (target - 2 * s - divider) / 2 + rects.push({ x: upX0, y: ys[0], w: s, h: s }) + rects.push({ x: upX0 + step, y: ys[0], w: s, h: s }) + rects.push({ x: xs[0], y: ys[1], w: s, h: s }) + rects.push({ x: xs[1], y: ys[1], w: s, h: s }) + rects.push({ x: xs[2], y: ys[1], w: s, h: s }) + rects.push({ x: xs[0], y: ys[2], w: s, h: s }) + rects.push({ x: xs[1], y: ys[2], w: s, h: s }) + rects.push({ x: xs[2], y: ys[2], w: s, h: s }) + return rects } // count === 9:3×3 满铺 - for (const y of ys) { - for (const x of xs) { - rects.push({ x, y, w: s, h: s }); + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + rects.push({ x: xs[col]!, y: ys[row]!, w: s, h: s }) } } - return rects; + return rects } diff --git a/apps/web-antd/src/views/im/utils/message.ts b/apps/web-antd/src/views/im/utils/message.ts index 5d804f9cc..91fca90cc 100644 --- a/apps/web-antd/src/views/im/utils/message.ts +++ b/apps/web-antd/src/views/im/utils/message.ts @@ -1,28 +1,20 @@ -// oxlint-disable no-console -import type { - Conversation, - GroupLite, - Message, - QuoteMessage, - User, -} from '../home/types'; -import type { ImConversationTypeValue } from './constants'; +import type { Conversation, GroupLite, Message, QuoteMessage, User } from '../home/types' -import { useUserStore } from '@vben/stores'; +import { useUserStore } from '@vben/stores' -import tipAudioUrl from '#/assets/audio/im/message-tip.mp3'; -import { getCurrentUserId } from '#/views/im/utils/auth'; +import { getCurrentUserId } from '#/views/im/utils/auth' -import { useFriendStore } from '../home/store/friendStore'; -import { useGroupStore } from '../home/store/groupStore'; +import { useFriendStore } from '../home/store/friendStore' +import { useGroupStore } from '../home/store/groupStore' import { ImContentType, ImConversationType, - ImRtcCallEndReason, -} from './constants'; -import { formatCallDuration } from './time'; + type ImConversationTypeValue, + ImRtcCallEndReason +} from './constants' +import { formatCallDuration } from './time' -export type { QuoteMessage } from '../home/types'; +export type { QuoteMessage } from '../home/types' // ==================================================================== // IM 消息 content 编解码 & 展示工具 @@ -38,77 +30,74 @@ export type { QuoteMessage } from '../home/types'; /** 生成客户端消息 ID(纯 UUID),用于前端去重 & ACK 回写 */ export const generateClientMessageId = (): string => { if (globalThis.crypto?.randomUUID) { - return globalThis.crypto.randomUUID(); + return globalThis.crypto.randomUUID() } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replaceAll(/[xy]/g, (char) => { - const random = Math.trunc(Math.random() * 16); - const value = char === 'x' ? random : (random & 0x3) | 0x8; - return value.toString(16); - }); -}; + const random = Math.trunc(Math.random() * 16) + const value = char === 'x' ? random : (random & 0x3) | 0x8 + return value.toString(16) + }) +} // ==================== 私聊对端 userId ==================== /** * 私聊消息 / DTO 的对端 userId:自己发的对端是 receiver,别人发的对端是 sender * - * 收口 4 处旧 inline(websocketStore.convertPrivateMessage / handlePrivateMessage / computeFriendPeerId - * 和 useMessagePuller.getPrivatePeerId),结构类型只要 senderId / receiverId 两个字段,REST 与 WS DTO 都满足 + * WebSocket 与 HTTP pull 共用;结构类型只要求 senderId / receiverId,REST 与 WS DTO 都满足 */ export function getPrivateMessagePeerId( - message: { receiverId: number; senderId: number }, - currentUserId: number, + message: { receiverId: number; senderId: number; }, + currentUserId: number ): number { - return message.senderId === currentUserId - ? message.receiverId - : message.senderId; + return message.senderId === currentUserId ? message.receiverId : message.senderId } // ==================== 文本片段(tip 文案 + TEXT 气泡共用) ==================== // 既用于灰条 tip("XX 邀请 YY 加入群聊"),也用于 TEXT 气泡正文(@xxx 高亮 + URL 自动识别)。 // mention 段携带 userId 用于挂点击弹 UserInfoCard;link 段携带 href 用于 跳转; -// text 段是纯文本兜底。渲染层(tip-segments.vue)按 type 分发统一处理。 +// text 段是纯文本兜底。渲染层(TipSegments.vue)按 type 分发统一处理。 export type TipSegment = - | { href: string; text: string; type: 'link' } - | { text: string; type: 'mention'; userId: number } - | { text: string; type: 'text' }; + | { href: string; text: string; type: 'link'; } + | { text: string; type: 'mention'; userId: number; } + | { text: string; type: 'text'; } -export const tipText = (text: string): TipSegment => ({ type: 'text', text }); +export const tipText = (text: string): TipSegment => ({ type: 'text', text }) export const tipMention = (userId: number, text: string): TipSegment => ({ type: 'mention', userId, - text, -}); + text +}) -export const tipLink = (href: string, text: string): TipSegment => ({ +const tipLink = (href: string, text: string): TipSegment => ({ type: 'link', href, - text, -}); + text +}) export const segmentsToText = (segments: TipSegment[]): string => - segments.map((s) => s.text).join(''); + segments.map((s) => s.text).join('') /** 多个 userId 用同一个分隔符插值成 segments,每个 user 单独成 mention 段 */ -export function joinMentionSegments( +function joinMentionSegments( userIds: number[], separator: string, - resolveName: (userId: number) => string, + resolveName: (userId: number) => string ): TipSegment[] { return userIds.flatMap((id, index) => index === 0 ? [tipMention(id, resolveName(id))] - : [tipText(separator), tipMention(id, resolveName(id))], - ); + : [tipText(separator), tipMention(id, resolveName(id))] + ) } /** mention 候选;name 用于匹配文本字面量,displayName 用于覆盖渲染(不传则按 name 渲染) */ export interface MentionCandidate { - userId: number; + userId: number /** 用来在 content 中前缀匹配的字面量(不含 @) */ - name: string; + name: string /** * 渲染时强制使用的显示名(不含 @) * @@ -116,157 +105,142 @@ export interface MentionCandidate { * 候选可以携带多种字面量(match 用),但 displayName 统一指向 nickname, * 让所有历史 / 新消息的 @ 都收敛到一致的展示 */ - displayName?: string; + displayName?: string /** * 歧义标记:同 name 对应多个 userId 时为 true * * parser 仍会按 name 命中(最长优先),但命中后整段吃成普通文本—— * 避免「@张三」被剔除后,短前缀候选「@张」抢匹配错绑 */ - ambiguous?: boolean; + ambiguous?: boolean } /** URL 锚定正则;终止于空白、中文、@(避免吞掉下一个 mention)、< > " '(防 HTML / 引号串入);y 标志走 sticky 匹配,省 text.slice */ -const URL_STICKY_REGEX = - /(https?:\/\/[^\s一-龥@<>"']+|www\.[^\s一-龥@<>"']+)/iy; +const URL_STICKY_REGEX = /(https?:\/\/[^\s一-龥@<>"']+|www\.[^\s一-龥@<>"']+)/iy /** URL 末尾常见标点剔除,避免吞掉句末中英文标点 */ -const URL_TRAILING_PUNCTUATION = /[.,!?;:)\]、,。!?;:)】]+$/; +const URL_TRAILING_PUNCTUATION = /[.,!?;:)\]、,。!?;:)】]+$/ /** 最短 URL:`www.ab` / `http:/` 这种孤段不算链接 */ -const URL_MIN_LENGTH = 6; +const URL_MIN_LENGTH = 6 /** * 文本气泡 content 拆段:mention 段按候选最长前缀匹配,URL 段走锚定正则,剩余归 text * * mentions 不传或匹配不上时,@xxx 退化为普通 text;解析与渲染解耦,工具函数本身不依赖 store */ -export function parseTextSegments( - text: string, - mentions: MentionCandidate[] = [], -): TipSegment[] { +export function parseTextSegments(text: string, mentions: MentionCandidate[] = []): TipSegment[] { if (!text) { - return []; + return [] } // 「@张三丰」不能被「@张三」截胡,候选按 name 长度倒序 const sortedMentions = - mentions.length > 1 - ? mentions.toSorted((a, b) => b.name.length - a.name.length) - : mentions; - const out: TipSegment[] = []; - let buffer = ''; + mentions.length > 1 ? [...mentions].sort((a, b) => b.name.length - a.name.length) : mentions + const out: TipSegment[] = [] + let buffer = '' const flush = () => { if (buffer) { - out.push(tipText(buffer)); - buffer = ''; + out.push(tipText(buffer)) + buffer = '' } - }; + } - let i = 0; + let i = 0 while (i < text.length) { if (text[i] === '@' && sortedMentions.length > 0) { - const matched = sortedMentions.find( - (m) => m.name && text.startsWith(m.name, i + 1), - ); + const matched = sortedMentions.find((m) => m.name && text.startsWith(m.name, i + 1)) if (matched) { if (matched.ambiguous) { // 同字面量对应多 userId,无法判定意图;整段累入 buffer 让短前缀候选没机会再扫这段 - buffer += `@${matched.name}`; + buffer += '@' + matched.name } else { - flush(); + flush() // 渲染统一走 displayName(即真实昵称),让历史 content 里残留的备注 / 群昵称也收敛到 nickname - out.push( - tipMention( - matched.userId, - `@${matched.displayName || matched.name}`, - ), - ); + out.push(tipMention(matched.userId, '@' + (matched.displayName || matched.name))) } - i += 1 + matched.name.length; - continue; + i += 1 + matched.name.length + continue } } - const head = text[i]; + const head = text[i] if (head === 'h' || head === 'H' || head === 'w' || head === 'W') { - URL_STICKY_REGEX.lastIndex = i; - const linkMatch = URL_STICKY_REGEX.exec(text); + URL_STICKY_REGEX.lastIndex = i + const linkMatch = URL_STICKY_REGEX.exec(text) if (linkMatch) { - const urlText = linkMatch[0].replace(URL_TRAILING_PUNCTUATION, ''); + const urlText = linkMatch[0].replace(URL_TRAILING_PUNCTUATION, '') if (urlText.length >= URL_MIN_LENGTH) { - flush(); - const href = /^https?:\/\//i.test(urlText) - ? urlText - : `https://${urlText}`; - out.push(tipLink(href, urlText)); - i += urlText.length; - continue; + flush() + const href = /^https?:\/\//i.test(urlText) ? urlText : `https://${urlText}` + out.push(tipLink(href, urlText)) + i += urlText.length + continue } } } - buffer += text[i]; - i += 1; + buffer += text[i] + i += 1 } - flush(); - return out; + flush() + return out } // ==================== 引用消息 ==================== /** 引用容器:5 种普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */ interface Quotable { - quote?: QuoteMessage; + quote?: QuoteMessage } // ==================== 消息 payload ==================== /** 文本消息 payload(对齐后端 TextMessage) */ export interface TextMessage extends Quotable { - content: string; + content: string } /** 图片消息 payload(对齐后端 ImageMessage) */ export interface ImageMessage extends Quotable { - url: string; + url: string /** 缩略图 URL */ - thumbnailUrl?: string; + thumbnailUrl?: string /** 图片宽度 */ - width?: number; + width?: number /** 图片高度 */ - height?: number; + height?: number /** 文件大小(字节) */ - size?: number; + size?: number } /** 语音消息 payload(对齐后端 AudioMessage;ImContentType 保留 VOICE 命名) */ export interface AudioMessage extends Quotable { - url: string; + url: string /** 时长(秒) */ - duration: number; + duration: number /** 文件大小(字节) */ - size?: number; + size?: number } /** 文件消息 payload(对齐后端 FileMessage) */ export interface FileMessage extends Quotable { - url: string; - name: string; - size: number; + url: string + name: string + size: number /** MIME 类型 */ - type?: string; + type?: string } /** 视频消息 payload(对齐后端 VideoMessage;暂未接入渲染) */ export interface VideoMessage extends Quotable { - url: string; + url: string /** 封面 URL */ - coverUrl?: string; + coverUrl?: string /** 时长(秒) */ - duration?: number; - width?: number; - height?: number; + duration?: number + width?: number + height?: number /** 文件大小(字节) */ - size?: number; + size?: number } /** @@ -278,51 +252,47 @@ export interface VideoMessage extends Quotable { */ export interface CardMessage extends Quotable { /** 名片对象类型 */ - targetType: ImConversationTypeValue; + targetType: ImConversationTypeValue /** 目标对象编号:PRIVATE 时 = userId;GROUP 时 = groupId */ - targetId: number; + targetId: number /** 显示名快照:PRIVATE 时 = 用户昵称;GROUP 时 = 群名 */ - name: string; + name: string /** 头像(快照) */ - avatar?: string; + avatar?: string /** 群成员数(仅 targetType = GROUP;接收端展示「N 人群聊」) */ - memberCount?: number; + memberCount?: number } /** * 名片转发的源对象(RecommendCardDialog 入参);字段与 CardMessage 1:1,无 quote */ -export type CardTarget = Omit; +export type CardTarget = Omit /** 用户对象 → 用户名片源(PRIVATE);缺 id 返回 null 让调用方 v-bind 绑定为不渲染 */ -export function toUserCardTarget( - user: null | undefined | User, -): CardTarget | null { +export function toUserCardTarget(user: null | undefined | User): CardTarget | null { if (!user?.id) { - return null; + return null } return { targetType: ImConversationType.PRIVATE, targetId: user.id, name: user.nickname || '', - avatar: user.avatar, - }; + avatar: user.avatar + } } /** 群对象 → 群名片源(GROUP);缺 id 返回 null */ -export function toGroupCardTarget( - group: GroupLite | null | undefined, -): CardTarget | null { +export function toGroupCardTarget(group: GroupLite | null | undefined): CardTarget | null { if (!group?.id) { - return null; + return null } return { targetType: ImConversationType.GROUP, targetId: group.id, name: group.name || '', avatar: group.showImage || group.showImageThumb, - memberCount: group.memberCount, - }; + memberCount: group.memberCount + } } /** @@ -330,70 +300,68 @@ export function toGroupCardTarget( * * 缺失 / 非法 targetType 走「个人名片」兜底,避免老消息或脏数据导致 UI 空白 */ -export function getCardLabelInfo( - card: null | undefined | { targetType?: number }, -): { - icon: string; - label: string; +export function getCardLabelInfo(card: null | undefined | { targetType?: number }): { + icon: string + label: string } { if (card?.targetType === ImConversationType.GROUP) { - return { label: '群名片', icon: 'ant-design:usergroup-outlined' }; + return { label: '群名片', icon: 'ant-design:usergroup-outlined' } } - return { label: '个人名片', icon: 'ant-design:user-outlined' }; + return { label: '个人名片', icon: 'ant-design:user-outlined' } } /** 表情消息 payload(对齐后端 FaceMessage;Unicode emoji 仍走 TEXT,本类型只承载贴图 / 自定义表情包) */ export interface FaceMessage extends Quotable { /** 表情图 URL */ - url: string; + url: string /** 渲染宽度(像素),避免布局抖动;可选,缺失时调用方走 CSS max-w 兜底 */ - width?: number; + width?: number /** 渲染高度(像素),可选 */ - height?: number; + height?: number /** 表情名(系统包通常有,个人表情包通常无) */ - name?: string; + name?: string } /** 合并转发的单条内嵌消息快照(对齐后端 MergeMessage.Item) */ -export interface MergeMessageItem { +interface MergeMessageItem { /** 原消息编号;仅做溯源标识 */ - messageId: number; + messageId: number /** 发送人编号 */ - senderId: number; + senderId: number /** 发送人昵称快照;对端可能不在原会话里,无法实时查到 */ - senderNickname: string; + senderNickname: string /** 发送人头像快照 */ - senderAvatar?: string; + senderAvatar?: string /** 内容类型,对齐 ImContentType */ - type: number; + type: number /** 原消息 content(JSON);嵌套合并消息时仍按本结构层层展开 */ - content: string; + content: string /** 发送时间戳(毫秒) */ - sendTime: number; + sendTime: number } /** 合并转发消息 payload(对齐后端 MergeMessage) */ export interface MergeMessage { - title: string; // 合并标题;例:「张三和李四的聊天记录」「群聊的聊天记录」 - messages: MergeMessageItem[]; // 内嵌的完整消息快照 + title: string // 合并标题;例:「张三和李四的聊天记录」「群聊的聊天记录」 + messages: MergeMessageItem[] // 内嵌的完整消息快照 } /** 频道素材消息 payload(对齐后端 MaterialMessage) */ export interface MaterialMessage { - materialId?: number; - channelId?: number; // 频道编号;转发后渲染卡片底部的频道头像 + 名称 - title?: string; - coverUrl?: string; - summary?: string; - url?: string; // 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url + materialId?: number + channelId?: number // 频道编号;转发后渲染卡片底部的频道头像 + 名称 + title?: string + coverUrl?: string + summary?: string + url?: string // 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url } // ==================== 合并转发 payload 构造 ==================== /** 单个发送人的快照昵称 / 头像 */ interface SenderSnapshot { - nickname: string; - avatar: string; + nickname: string + avatar: string } /** @@ -403,54 +371,50 @@ interface SenderSnapshot { */ function buildSenderSnapshotMap( senderIds: number[], - conversation: Conversation, + conversation: Conversation ): Map { - const userStore = useUserStore(); - const friendStore = useFriendStore(); - const selfUserId = getCurrentUserId(); - const result = new Map(); + const userStore = useUserStore() + const friendStore = useFriendStore() + const selfUserId = getCurrentUserId() + const result = new Map() - let groupMembers: Map | null = - null; + let groupMembers: Map | null = null if (conversation.type === ImConversationType.GROUP) { - const group = useGroupStore().getGroup(conversation.targetId); - groupMembers = new Map((group?.members || []).map((m) => [m.userId, m])); + const group = useGroupStore().getGroup(conversation.targetId) + groupMembers = new Map((group?.members || []).map((m) => [m.userId, m])) } for (const senderId of senderIds) { if (result.has(senderId)) { - continue; + continue } if (senderId === selfUserId) { result.set(senderId, { nickname: userStore.userInfo?.nickname || String(senderId), - avatar: userStore.userInfo?.avatar || '', - }); - continue; + avatar: userStore.userInfo?.avatar || '' + }) + continue } - const member = groupMembers?.get(senderId); + const member = groupMembers?.get(senderId) if (member?.nickname) { - result.set(senderId, { - nickname: member.nickname, - avatar: member.avatar || '', - }); - continue; + result.set(senderId, { nickname: member.nickname, avatar: member.avatar || '' }) + continue } - const friend = friendStore.getFriend(senderId); + const friend = friendStore.getFriend(senderId) result.set(senderId, { nickname: friend?.nickname || String(senderId), - avatar: friend?.avatar || '', - }); + avatar: friend?.avatar || '' + }) } - return result; + return result } /** 把单条 Message 转成 MergeMessageItem 快照;剥离 quote 防引用穿透 */ function mapMessageToMergeItem( message: Message, - senderSnapshots: Map, + senderSnapshots: Map ): MergeMessageItem { - const snapshot = senderSnapshots.get(message.senderId); + const snapshot = senderSnapshots.get(message.senderId) return { messageId: message.id || 0, senderId: message.senderId, @@ -458,38 +422,38 @@ function mapMessageToMergeItem( senderAvatar: snapshot?.avatar ?? '', type: message.type, content: removeQuotePayload(message.content), - sendTime: message.sendTime, - }; + sendTime: message.sendTime + } } /** 合并转发标题:私聊「{对方} 和 {自己} 的聊天记录」;群聊「{群名} 的聊天记录」 */ -export function buildMergeTitle(conversation: Conversation): string { +function buildMergeTitle(conversation: Conversation): string { if (conversation.type === ImConversationType.GROUP) { - return `${conversation.name || '群聊'} 的聊天记录`; + return `${conversation.name || '群聊'} 的聊天记录` } - const myName = useUserStore().userInfo?.nickname || '我'; - return `${conversation.name || '对方'} 和 ${myName} 的聊天记录`; + const myName = useUserStore().userInfo?.nickname || '我' + return `${conversation.name || '对方'} 和 ${myName} 的聊天记录` } /** 把一组 Message 打包成 MergeMessage payload;调用方负责按 sendTime 排序后传入 */ export function buildMergeMessagePayload( messages: Message[], - conversation: Conversation, + conversation: Conversation ): MergeMessage { - const senderIds = messages.map((m) => m.senderId); - const senderSnapshots = buildSenderSnapshotMap(senderIds, conversation); + const senderIds = messages.map((m) => m.senderId) + const senderSnapshots = buildSenderSnapshotMap(senderIds, conversation) return { title: buildMergeTitle(conversation), - messages: messages.map((m) => mapMessageToMergeItem(m, senderSnapshots)), - }; + messages: messages.map((m) => mapMessageToMergeItem(m, senderSnapshots)) + } } /** 「添加到表情」的可发起源:FACE / IMAGE 都允许(GIF 图片也常被收藏) */ -export interface AddableFacePayload { - url: string; - width: number; - height: number; - name?: string; +interface AddableFacePayload { + url: string + width: number + height: number + name?: string } /** @@ -497,50 +461,38 @@ export interface AddableFacePayload { * * 调用方(MessageItem 的右键菜单)按 nullable 决定是否展示「添加到表情」入口 */ -export function extractAddableFace( - message: Message, -): AddableFacePayload | null { +export function extractAddableFace(message: Message): AddableFacePayload | null { if (message.type === ImContentType.FACE) { - const face = parseMessage(message.content); + const face = parseMessage(message.content) if (!face?.url) { - return null; + return null } - return { - url: face.url, - width: face.width || 200, - height: face.height || 200, - name: face.name, - }; + return { url: face.url, width: face.width || 200, height: face.height || 200, name: face.name } } if (message.type === ImContentType.IMAGE) { - const image = parseMessage(message.content); + const image = parseMessage(message.content) if (!image?.url) { - return null; + return null } - return { - url: image.url, - width: image.width || 200, - height: image.height || 200, - }; + return { url: image.url, width: image.width || 200, height: image.height || 200 } } - return null; + return null } /** 解析消息 content(JSON 字符串)为指定 payload,非法 JSON 返回 null */ export const parseMessage = (content: string): null | T => { try { - return JSON.parse(content) as T; + return JSON.parse(content) as T } catch { - return null; + return null } -}; +} /** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */ -export const serializeMessage = (payload: T): string => - JSON.stringify(payload); +export const serializeMessage = (payload: T): string => JSON.stringify(payload) /** `URL.createObjectURL(file)` 生成的 URL 前缀;占位 / revoke / 重传旧值识别共用 */ -export const BLOB_URL_PREFIX = 'blob:'; +export const BLOB_URL_PREFIX = 'blob:' /** * 媒体 payload 里可能包含 blob URL 的字段(图片/文件/视频/语音都对齐这套 url 字段命名) @@ -550,7 +502,7 @@ export const BLOB_URL_PREFIX = 'blob:'; * - coverUrl:视频封面(commit 后是真实 URL;占位阶段不设以避免传 blob 当 poster 在部分浏览器退化) * - thumbnailUrl:图片缩略图(当前未占位时使用 blob,预留) */ -const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const; +const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const /** * 释放 content 中媒体 payload 字段上的 blob URL 内存映射 @@ -560,32 +512,29 @@ const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const; */ export const revokeBlobUrlsInContent = (content: string): void => { if (!content || !content.includes(BLOB_URL_PREFIX)) { - return; + return } - const payload = parseMessage>(content); + const payload = parseMessage>(content) if (!payload) { - return; + return } for (const field of MEDIA_BLOB_URL_FIELDS) { - const value = payload[field]; + const value = payload[field] if (typeof value === 'string' && value.startsWith(BLOB_URL_PREFIX)) { - URL.revokeObjectURL(value); + URL.revokeObjectURL(value) } } -}; +} // ==================== 引用消息 helper ==================== /** 把 quote 合进 payload(序列化前调用);quote 缺失时原样返回 */ -export const withQuotePayload = ( - payload: T, - quote?: QuoteMessage, -): T => { +export const withQuotePayload = (payload: T, quote?: QuoteMessage): T => { if (!quote) { - return payload; + return payload } - return { ...payload, quote }; -}; + return { ...payload, quote } +} /** * 从 content JSON 字符串里清掉 quote 字段 @@ -595,15 +544,15 @@ export const withQuotePayload = ( */ export const removeQuotePayload = (content: string): string => { if (!content || !content.includes('"quote"')) { - return content; + return content } - const parsed = parseMessage>(content); + const parsed = parseMessage>(content) if (!parsed || !('quote' in parsed)) { - return content; + return content } - delete parsed.quote; - return JSON.stringify(parsed); -}; + delete parsed.quote + return JSON.stringify(parsed) +} /** 由 Message 派生 QuoteMessage 用于乐观渲染;ack 后会被服务端权威版本覆盖 */ export const buildQuoteFromMessage = (message: Message): QuoteMessage => { @@ -611,19 +560,19 @@ export const buildQuoteFromMessage = (message: Message): QuoteMessage => { messageId: message.id || 0, senderId: message.senderId, type: message.type, - content: removeQuotePayload(message.content), - }; -}; + content: removeQuotePayload(message.content) + } +} /** 从已序列化 message.content 中解出 quote;非 JSON / 无 quote 返回 null */ export const getQuoteFromMessage = (content: string): null | QuoteMessage => { // 长会话每条消息渲染都走 quote computed,非引用消息字符串预扫直接返回,免一次 JSON.parse if (!content || !content.includes('"quote"')) { - return null; + return null } - const parsed = parseMessage(content); - return parsed?.quote ?? null; -}; + const parsed = parseMessage(content) + return parsed?.quote ?? null +} // ==================== 撤回 ==================== @@ -633,136 +582,133 @@ export const getQuoteFromMessage = (content: string): null | QuoteMessage => { */ export const parseRecallMessageId = (content: string): number => { try { - const parsed = JSON.parse(content); - return parsed?.messageId === null ? 0 : Number(parsed.messageId); + const parsed = JSON.parse(content) + return parsed?.messageId != null ? Number(parsed.messageId) : 0 } catch { - return 0; + return 0 } -}; +} // ==================== 新消息提示音 ==================== +import tipAudioUrl from '#/assets/audio/im/message-tip.mp3' + /** * 新消息提示音,带 1 秒节流:短时间内多条消息只响一次,避免疲劳轰炸。 * * 浏览器自动播放策略要求页面有过用户交互;若无法播放会静默失败。 */ -let __lastPlayAudioTipAt = 0; -let __tipAudio: HTMLAudioElement | null = null; +let __lastPlayAudioTipAt = 0 +let __tipAudio: HTMLAudioElement | null = null export const playAudioTip = () => { - const now = Date.now(); + const now = Date.now() if (now - __lastPlayAudioTipAt < 1000) { - return; + return } - __lastPlayAudioTipAt = now; + __lastPlayAudioTipAt = now try { if (!__tipAudio) { - __tipAudio = new Audio(tipAudioUrl); - __tipAudio.preload = 'auto'; + __tipAudio = new Audio(tipAudioUrl) + __tipAudio.preload = 'auto' } - __tipAudio.currentTime = 0; - const playPromise = __tipAudio.play(); + __tipAudio.currentTime = 0 + const playPromise = __tipAudio.play() if (playPromise && typeof playPromise.catch === 'function') { - playPromise.catch((error) => - console.debug('[IM] playAudioTip 失败', error), - ); + playPromise.catch((e) => console.debug('[IM] playAudioTip 失败', e)) } - } catch (error) { - console.debug('[IM] playAudioTip 失败', error); + } catch (e) { + console.debug('[IM] playAudioTip 失败', e) } -}; +} // ==================== 文件图标 ==================== /** * 按文件扩展名挑文件图标 + 颜色,对齐微信观感 * - * message-item.vue(主气泡)和 reply-preview.vue(引用预览)共用同一份映射,避免视觉两处不一致 + * MessageItem.vue(主气泡)和 ReplyPreview.vue(引用预览)共用同一份映射,避免视觉两处不一致 */ -export function getFileIconInfo(filename: string | undefined): { - color: string; - icon: string; -} { - const ext = (filename || '').split('.').pop()?.toLowerCase() || ''; +export function getFileIconInfo(filename: string | undefined): { color: string; icon: string; } { + const ext = (filename || '').split('.').pop()?.toLowerCase() || '' if (ext === 'pdf') { - return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }; + return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' } } if (['doc', 'docx'].includes(ext)) { - return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }; + return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' } } if (['xls', 'xlsx'].includes(ext)) { - return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }; + return { icon: 'ant-design:file-excel-filled', color: '#1f7244' } } if (['ppt', 'pptx'].includes(ext)) { - return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }; + return { icon: 'ant-design:file-ppt-filled', color: '#d24726' } } if (['7z', 'gz', 'rar', 'tar', 'zip'].includes(ext)) { - return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }; + return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' } } if (['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp'].includes(ext)) { - return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }; + return { icon: 'ant-design:file-image-filled', color: '#9c27b0' } } if (['avi', 'flv', 'mkv', 'mov', 'mp4', 'wmv'].includes(ext)) { - return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }; + return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' } } if (['aac', 'flac', 'mp3', 'ogg', 'wav'].includes(ext)) { - return { icon: 'ant-design:audio-filled', color: '#9c27b0' }; + return { icon: 'ant-design:audio-filled', color: '#9c27b0' } } if (['json', 'log', 'md', 'txt', 'xml'].includes(ext)) { - return { icon: 'ant-design:file-text-filled', color: '#909399' }; + return { icon: 'ant-design:file-text-filled', color: '#909399' } } - return { icon: 'ant-design:file-filled', color: '#909399' }; + return { icon: 'ant-design:file-filled', color: '#909399' } } // ==================== 管理后台展示工具 ==================== /** 详情弹窗里把 content JSON 美化成 2 缩进 */ export const formatJson = (content?: string): string => { - if (!content) return ''; + if (!content) return '' try { - return JSON.stringify(JSON.parse(content), null, 2); + return JSON.stringify(JSON.parse(content), null, 2) } catch { - return content; + return content } -}; +} // ==================== 群广播事件 payload + 文案 ==================== // 群广播事件 payload;对齐后端 GroupNotificationMessage 子类聚合字段 export type GroupNotificationPayload = { - addSource?: number; + addSource?: number // 全群封禁 - banned?: boolean; - displayUserName?: string; + banned?: boolean + displayUserName?: string // 自由进群事件 - entrantUserId?: number; - memberUserIds?: number[]; + entrantUserId?: number + memberUserIds?: number[] // PIN 事件携带的被置顶消息展示数据 message?: { - atUserIds?: number[]; - content: string; - groupId: number; - id: number; - receiverUserIds?: number[]; - senderId: number; - sendTime: string; - type: number; - }; - messageId?: number; + atUserIds?: number[] + content: string + groupId: number + id: number + receiverUserIds?: number[] + senderId: number + sendTime: string + type: number + } + messageId?: number // 禁言事件 - mutedUserId?: number; - muteEndTime?: string; - newAvatar?: string; - newJoinApproval?: boolean; - newName?: string; - newNotice?: string; - newOwnerUserId?: number; - oldAvatar?: string; - oldJoinApproval?: boolean; - oldName?: string; - oldNotice?: string; - operatorUserId?: number; -}; + mutedUserId?: number + muteEndTime?: string + newAvatar?: string + newJoinApproval?: boolean + newName?: string + newNotice?: string + newOwnerUserId?: number + oldAvatar?: string + oldJoinApproval?: boolean + oldName?: string + oldNotice?: string + operatorUserId?: number +} /** * 群广播事件 segments @@ -771,221 +717,155 @@ export type GroupNotificationPayload = { * operatorNameOverride 仅覆盖 operator 段文案,mention userId 仍用 payload.operatorUserId */ export function resolveGroupNotificationSegments( - message: { content?: string; targetId?: number; type?: number }, + message: { content?: string; targetId?: number; type?: number; }, resolveName: (userId: number) => string, - operatorNameOverride?: string, + operatorNameOverride?: string ): TipSegment[] { - let payload: GroupNotificationPayload; + let payload: GroupNotificationPayload try { - payload = JSON.parse(message.content || '{}'); + payload = JSON.parse(message.content || '{}') } catch { - return []; + return [] } // ENTER 主语是 entrant 而非 operator,独立处理;其它 case 都以 operatorUserId 为主语 if (message.type === ImContentType.GROUP_MEMBER_ENTER) { - const entrantId = payload.entrantUserId ?? payload.operatorUserId; - return entrantId - ? [tipMention(entrantId, resolveName(entrantId)), tipText(' 加入了群聊')] - : []; + const entrantId = payload.entrantUserId ?? payload.operatorUserId + return entrantId ? [tipMention(entrantId, resolveName(entrantId)), tipText(' 加入了群聊')] : [] } if (!payload.operatorUserId) { - return []; + return [] } const operatorSegment = tipMention( payload.operatorUserId, - operatorNameOverride ?? resolveName(payload.operatorUserId), - ); - const memberSegments = joinMentionSegments( - payload.memberUserIds || [], - '、', - resolveName, - ); + operatorNameOverride ?? resolveName(payload.operatorUserId) + ) + const memberSegments = joinMentionSegments(payload.memberUserIds || [], '、', resolveName) switch (message.type) { - case ImContentType.GROUP_ADMIN_ADD: { - return [ - operatorSegment, - tipText(' 将 '), - ...memberSegments, - tipText(' 设为管理员'), - ]; - } - case ImContentType.GROUP_ADMIN_REMOVE: { - return [ - operatorSegment, - tipText(' 撤销了 '), - ...memberSegments, - tipText(' 的管理员身份'), - ]; - } - case ImContentType.GROUP_BANNED: { - return [ - operatorSegment, - tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群'), - ]; - } - case ImContentType.GROUP_CANCEL_MUTED: { - return [operatorSegment, tipText(' 关闭了全群禁言')]; - } - case ImContentType.GROUP_CREATE: { - return [operatorSegment, tipText(' 创建了群聊')]; - } - case ImContentType.GROUP_DISSOLVE: { - return [operatorSegment, tipText(' 解散了群聊')]; - } - case ImContentType.GROUP_INFO_UPDATE: { + case ImContentType.GROUP_ADMIN_ADD: + return [operatorSegment, tipText(' 将 '), ...memberSegments, tipText(' 设为管理员')] + case ImContentType.GROUP_ADMIN_REMOVE: + return [operatorSegment, tipText(' 撤销了 '), ...memberSegments, tipText(' 的管理员身份')] + case ImContentType.GROUP_BANNED: + return [operatorSegment, tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群')] + case ImContentType.GROUP_CANCEL_MUTED: + return [operatorSegment, tipText(' 关闭了全群禁言')] + case ImContentType.GROUP_CREATE: + return [operatorSegment, tipText(' 创建了群聊')] + case ImContentType.GROUP_DISSOLVE: + return [operatorSegment, tipText(' 解散了群聊')] + case ImContentType.GROUP_INFO_UPDATE: return payload.newAvatar ? [operatorSegment, tipText(' 更换了群头像')] - : [operatorSegment, tipText(' 更新了群信息')]; - } - case ImContentType.GROUP_MEMBER_CANCEL_MUTED: { + : [operatorSegment, tipText(' 更新了群信息')] + case ImContentType.GROUP_MEMBER_CANCEL_MUTED: return payload.mutedUserId ? [ operatorSegment, tipText(' 解除了 '), tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)), - tipText(' 的禁言'), + tipText(' 的禁言') ] - : []; - } - case ImContentType.GROUP_MEMBER_INVITE: { - return [ - operatorSegment, - tipText(' 邀请 '), - ...memberSegments, - tipText(' 加入群聊'), - ]; - } - case ImContentType.GROUP_MEMBER_KICK: { - return [operatorSegment, tipText(' 移出了 '), ...memberSegments]; - } - case ImContentType.GROUP_MEMBER_MUTED: { + : [] + case ImContentType.GROUP_MEMBER_INVITE: + return [operatorSegment, tipText(' 邀请 '), ...memberSegments, tipText(' 加入群聊')] + case ImContentType.GROUP_MEMBER_KICK: + return [operatorSegment, tipText(' 移出了 '), ...memberSegments] + case ImContentType.GROUP_MEMBER_MUTED: return payload.mutedUserId ? [ operatorSegment, tipText(' 将 '), tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)), - tipText(' 禁言'), + tipText(' 禁言') ] - : []; - } - case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: { - return [ - operatorSegment, - tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`), - ]; - } - case ImContentType.GROUP_MEMBER_QUIT: { - return [operatorSegment, tipText(' 退出了群聊')]; - } - case ImContentType.GROUP_MESSAGE_PIN: { - return [operatorSegment, tipText(' 置顶了一条消息')]; - } - case ImContentType.GROUP_MESSAGE_UNPIN: { - return [operatorSegment, tipText(' 取消了一条置顶消息')]; - } - case ImContentType.GROUP_MUTED: { - return [operatorSegment, tipText(' 开启了全群禁言')]; - } - case ImContentType.GROUP_NAME_UPDATE: { - return [ - operatorSegment, - tipText(` 将群名修改为 "${payload.newName ?? ''}"`), - ]; - } - case ImContentType.GROUP_NOTICE_UPDATE: { - return [operatorSegment, tipText(' 更新了群公告')]; - } - case ImContentType.GROUP_OWNER_TRANSFER: { + : [] + case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: + return [operatorSegment, tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`)] + case ImContentType.GROUP_MEMBER_QUIT: + return [operatorSegment, tipText(' 退出了群聊')] + case ImContentType.GROUP_MESSAGE_PIN: + return [operatorSegment, tipText(' 置顶了一条消息')] + case ImContentType.GROUP_MESSAGE_UNPIN: + return [operatorSegment, tipText(' 取消了一条置顶消息')] + case ImContentType.GROUP_MUTED: + return [operatorSegment, tipText(' 开启了全群禁言')] + case ImContentType.GROUP_NAME_UPDATE: + return [operatorSegment, tipText(` 将群名修改为 "${payload.newName ?? ''}"`)] + case ImContentType.GROUP_NOTICE_UPDATE: + return [operatorSegment, tipText(' 更新了群公告')] + case ImContentType.GROUP_OWNER_TRANSFER: return payload.newOwnerUserId ? [ operatorSegment, tipText(' 已将群主转让给 '), - tipMention( - payload.newOwnerUserId, - resolveName(payload.newOwnerUserId), - ), + tipMention(payload.newOwnerUserId, resolveName(payload.newOwnerUserId)) ] - : []; - } - default: { - return []; - } + : [] + default: + return [] } } /** 群广播事件中文文案 */ export function resolveGroupNotificationText( - message: { content?: string; targetId?: number; type?: number }, + message: { content?: string; targetId?: number; type?: number; }, resolveName: (userId: number) => string, - operatorNameOverride?: string, + operatorNameOverride?: string ): string { return segmentsToText( - resolveGroupNotificationSegments( - message, - resolveName, - operatorNameOverride, - ), - ); + resolveGroupNotificationSegments(message, resolveName, operatorNameOverride) + ) } // ==================== 好友事件 ==================== /** 会话内好友事件 segments */ -export function resolveFriendNotificationSegments(message: { - type?: number; -}): TipSegment[] { +export function resolveFriendNotificationSegments(message: { type?: number }): TipSegment[] { switch (message.type) { - case ImContentType.FRIEND_ADD: { - return [tipText('你们已经是好友了,开始聊天吧')]; - } - case ImContentType.FRIEND_DELETE: { - return [tipText('你已删除好友')]; - } - default: { - return []; - } + case ImContentType.FRIEND_ADD: + return [tipText('你们已经是好友了,开始聊天吧')] + case ImContentType.FRIEND_DELETE: + return [tipText('你已删除好友')] + default: + return [] } } /** 会话内好友事件文案:FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示气泡,文案固定不依赖 payload */ -export function resolveFriendNotificationText(message: { - type?: number; -}): string { - return segmentsToText(resolveFriendNotificationSegments(message)); +export function resolveFriendNotificationText(message: { type?: number }): string { + return segmentsToText(resolveFriendNotificationSegments(message)) } // ==================== RTC 通话事件 ==================== // RTC_CALL_START payload;仅群聊;用于聊天 tip 文案「{inviter} 发起了{voice/video}通话」 export type RtcCallStartPayload = { - conversationType?: number; - inviterAvatar?: string; - inviterNickname?: string; - inviterUserId?: number; - mediaType?: number; - room?: string; -}; + conversationType?: number + inviterAvatar?: string + inviterNickname?: string + inviterUserId?: number + mediaType?: number + room?: string +} // RTC_CALL_END payload;私聊准气泡 + 群聊「通话已结束 [时长 X]」共用 export type RtcCallEndPayload = { - conversationType?: number; - durationSeconds?: number; - endReason?: number; - mediaType?: number; - operatorAvatar?: string; - operatorNickname?: string; - operatorUserId?: number; - room?: string; -}; + conversationType?: number + durationSeconds?: number + endReason?: number + mediaType?: number + operatorAvatar?: string + operatorNickname?: string + operatorUserId?: number + room?: string +} /** 解析 RTC_CALL_START / RTC_CALL_END 消息 content;解析失败返回 null */ export function parseRtcCallPayload( - content?: string, + content?: string ): null | (RtcCallEndPayload & RtcCallStartPayload) { - return content - ? parseMessage(content) - : null; + return content ? parseMessage(content) : null } /** @@ -996,33 +876,31 @@ export function parseRtcCallPayload( * 私聊气泡走 {@link resolveRtcCallPrivateBubbleText} */ export function resolveRtcCallTipSegments(message: { - content?: string; - selfSend?: boolean; - type?: number; + content?: string + selfSend?: boolean + type?: number }): TipSegment[] { - const payload = parseRtcCallPayload(message.content); + const payload = parseRtcCallPayload(message.content) if (!payload) { - return []; + return [] } if (message.type === ImContentType.RTC_CALL_START) { return payload.inviterUserId ? [ tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)), - tipText(' 发起了语音通话'), + tipText(' 发起了语音通话') ] - : []; + : [] } if (message.type === ImContentType.RTC_CALL_END) { - return [tipText('语音通话已经结束')]; + return [tipText('语音通话已经结束')] } - return []; + return [] } /** 取 RTC 通话发起人展示名;昵称为空时回退「用户 {id}」 */ function resolveRtcInviterLabel(payload: RtcCallStartPayload): string { - return ( - payload.inviterNickname?.trim() || `用户 ${payload.inviterUserId ?? ''}` - ); + return payload.inviterNickname?.trim() || `用户 ${payload.inviterUserId ?? ''}` } /** @@ -1033,22 +911,22 @@ function resolveRtcInviterLabel(payload: RtcCallStartPayload): string { * 群聊:START「{inviter} 发起了语音通话」、END「语音通话已经结束」,与聊天 tip 同源 */ export function resolveRtcCallLastContent( - message: { content?: string; type?: number }, - conversationType: number, + message: { content?: string; type?: number; }, + conversationType: number ): string { if (conversationType === ImConversationType.PRIVATE) { - return '[语音通话]'; + return '[语音通话]' } if (message.type === ImContentType.RTC_CALL_END) { - return '语音通话已经结束'; + return '语音通话已经结束' } if (message.type === ImContentType.RTC_CALL_START) { - const payload = parseRtcCallPayload(message.content); + const payload = parseRtcCallPayload(message.content) if (payload) { - return `${resolveRtcInviterLabel(payload)} 发起了语音通话`; + return `${resolveRtcInviterLabel(payload)} 发起了语音通话` } } - return ''; + return '' } /** @@ -1063,55 +941,31 @@ export function resolveRtcCallLastContent( * NO_ANSWER → 操作者「未接听」/ 另一方「对方未接听」(振铃超时 Job 触发) * ERROR → 「通话中断 [N]」(接通后异常断开;duration > 0 时带时长) */ -export function resolveRtcCallPrivateBubbleText( - payload: null | RtcCallEndPayload, -): string { +export function resolveRtcCallPrivateBubbleText(payload: null | RtcCallEndPayload): string { if (!payload) { - return '通话已结束'; + return '通话已结束' } - const duration = payload.durationSeconds ?? 0; - const hasDuration = duration > 0; - const isOperator = payload.operatorUserId === getCurrentUserId(); + const duration = payload.durationSeconds ?? 0 + const hasDuration = duration > 0 + const isOperator = payload.operatorUserId === getCurrentUserId() switch (payload.endReason) { - case ImRtcCallEndReason.BUSY: { - return isOperator ? '忙线未接听' : '对方忙线中'; - } - case ImRtcCallEndReason.CANCEL: { - return isOperator ? '已取消' : '对方已取消'; - } - case ImRtcCallEndReason.ERROR: { - return hasDuration - ? `通话中断 ${formatCallDuration(duration)}` - : '通话中断'; - } - case ImRtcCallEndReason.HANGUP: { - return hasDuration - ? `通话时长 ${formatCallDuration(duration)}` - : '通话中断'; - } - case ImRtcCallEndReason.NO_ANSWER: { - return isOperator ? '未接听' : '对方未接听'; - } - case ImRtcCallEndReason.REJECT: { - return isOperator ? '已拒绝' : '对方已拒绝'; - } - default: { - return hasDuration - ? `通话时长 ${formatCallDuration(duration)}` - : '通话已结束'; - } + case ImRtcCallEndReason.BUSY: + return isOperator ? '忙线未接听' : '对方忙线中' + case ImRtcCallEndReason.CANCEL: + return isOperator ? '已取消' : '对方已取消' + case ImRtcCallEndReason.ERROR: + return hasDuration ? `通话中断 ${formatCallDuration(duration)}` : '通话中断' + case ImRtcCallEndReason.HANGUP: + return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话中断' + case ImRtcCallEndReason.NO_ANSWER: + return isOperator ? '未接听' : '对方未接听' + case ImRtcCallEndReason.REJECT: + return isOperator ? '已拒绝' : '对方已拒绝' + default: + return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话已结束' } } -/** 会话内通话事件文案 */ -export function resolveRtcCallTipText(message: { - content?: string; - selfSend?: boolean; - type?: number; -}): string { - return segmentsToText(resolveRtcCallTipSegments(message)); -} - /** * RTC_CALL_END 结束原因兜底文案;前端 toast / console 兜底用 *

@@ -1119,23 +973,17 @@ export function resolveRtcCallTipText(message: { */ export function resolveCallEndReasonText(reason: number | undefined): string { switch (reason) { - case ImRtcCallEndReason.BUSY: { - return '对方忙线中'; - } - case ImRtcCallEndReason.CANCEL: { - return '对方已取消'; - } - case ImRtcCallEndReason.ERROR: { - return '通话异常'; - } - case ImRtcCallEndReason.HANGUP: { - return '通话已结束'; - } - case ImRtcCallEndReason.REJECT: { - return '对方已拒绝'; - } - default: { - return '通话已断开'; - } + case ImRtcCallEndReason.BUSY: + return '对方忙线中' + case ImRtcCallEndReason.CANCEL: + return '对方已取消' + case ImRtcCallEndReason.ERROR: + return '通话异常' + case ImRtcCallEndReason.HANGUP: + return '通话已结束' + case ImRtcCallEndReason.REJECT: + return '对方已拒绝' + default: + return '通话已断开' } } diff --git a/apps/web-antd/src/views/im/utils/pull.ts b/apps/web-antd/src/views/im/utils/pull.ts index 6524af26b..826de0202 100644 --- a/apps/web-antd/src/views/im/utils/pull.ts +++ b/apps/web-antd/src/views/im/utils/pull.ts @@ -1,6 +1,4 @@ -import { isUndefined } from '@vben/utils'; - -import { getDb } from './db'; +import type { DbClient } from './db' /** * IM 状态事件补偿(增量拉取)通用编排 @@ -11,29 +9,29 @@ import { getDb } from './db'; */ /** 增量拉取游标:上次拉到的位置 */ -export interface PullCursor { - lastUpdateTime?: number; - lastId?: number; +interface PullCursor { + lastUpdateTime?: number + lastId?: number } /** 可作为游标的拉取记录:服务端按 update_time + id 返回,客户端取最后一条推进游标 */ interface PullRecord { - id: number; - updateTime?: number; + id: number + updateTime?: number } /** 单次拉取条数(与后端 limit 上限对齐) */ -const PULL_PAGE_SIZE = 100; +const PULL_PAGE_SIZE = 100 /** 单轮最多翻页数,兜底防御异常游标导致的死循环 */ -const PULL_MAX_PAGES = 100; +const PULL_MAX_PAGES = 100 /** 状态事件拉取回扫窗口,覆盖同秒内旧行更新和客户端 / 服务端时钟精度差 */ -const PULL_OVERLAP_MS = 5000; +const PULL_OVERLAP_MS = 5000 /** 消息类 minId 拉取的单轮翻页上限,兜底防御异常游标死翻;消息量可能远大于状态事件,放宽到 1000 */ -const MIN_ID_PULL_MAX_PAGES = 1000; +const MIN_ID_PULL_MAX_PAGES = 1000 /** 读取某模块的拉取游标;无则返回空游标(首次拉全量) */ -export async function getPullCursor(key: string): Promise { - return (await getDb().getSetting(key)) ?? {}; +async function getPullCursor(db: DbClient, key: string): Promise { + return (await db.getSetting(key)) ?? {} } /** @@ -45,72 +43,51 @@ export async function getPullCursor(key: string): Promise { * 此时不推进游标并终止本轮,避免游标越过未落地的记录、导致后续增量永久漏拉。可返回 Promise */ export async function runIncrementalPull( + db: DbClient, cursorKey: string, - fetchPage: (params: { - lastId?: number; - lastUpdateTime?: number; - limit: number; - }) => Promise, - apply: (records: T[]) => boolean | Promise, - isActive?: () => boolean, + fetchPage: (params: { lastId?: number; lastUpdateTime?: number; limit: number }) => Promise, + apply: (records: T[]) => boolean | Promise ): Promise { - const storedCursor = await getPullCursor(cursorKey); - const highWater = { ...storedCursor }; - let cursor = isUndefined(storedCursor.lastUpdateTime) - ? {} - : { - lastUpdateTime: Math.max( - 0, - storedCursor.lastUpdateTime - PULL_OVERLAP_MS, - ), - lastId: 0, - }; + const storedCursor = await getPullCursor(db, cursorKey) + const highWater = { ...storedCursor } + let cursor = + storedCursor.lastUpdateTime != null + ? { lastUpdateTime: Math.max(0, storedCursor.lastUpdateTime - PULL_OVERLAP_MS), lastId: 0 } + : {} for (let page = 0; page < PULL_MAX_PAGES; page++) { - if (isActive && !isActive()) { - return; - } const list = await fetchPage({ lastUpdateTime: cursor.lastUpdateTime, lastId: cursor.lastId, - limit: PULL_PAGE_SIZE, - }); - if (isActive && !isActive()) { - return; - } - if (list.length > 0) { + limit: PULL_PAGE_SIZE + }) + if (list.length) { // apply 未完全落地(返回 false)时直接终止:游标只能跟着已落地的数据走,否则会跳过本页记录 if ((await apply(list)) === false) { - return; - } - if (isActive && !isActive()) { - return; + return } // 推进游标到本页最后一条并持久化:下次从这里接着拉 - const last = list[list.length - 1]; - if (!last) { - return; + const last = list[list.length - 1]! + if (last.updateTime == null) { + return } - if (isUndefined(last.updateTime)) { - return; - } - cursor = { lastUpdateTime: last.updateTime, lastId: last.id }; + cursor = { lastUpdateTime: last.updateTime, lastId: last.id } if ( - isUndefined(highWater.lastUpdateTime) || + highWater.lastUpdateTime == null || cursor.lastUpdateTime > highWater.lastUpdateTime || (cursor.lastUpdateTime === highWater.lastUpdateTime && cursor.lastId > (highWater.lastId ?? 0)) ) { - highWater.lastUpdateTime = cursor.lastUpdateTime; - highWater.lastId = cursor.lastId; - await getDb().setSetting(cursorKey, highWater); + highWater.lastUpdateTime = cursor.lastUpdateTime + highWater.lastId = cursor.lastId + await db.setSetting(cursorKey, highWater) } } // 不满一页 = 没有更多变更 if (list.length < PULL_PAGE_SIZE) { - return; + return } } - console.warn(`[IM pull] ${cursorKey} 达到单轮翻页上限,提前结束本轮补偿`); + console.warn(`[IM pull] ${cursorKey} 达到单轮翻页上限,提前结束本轮补偿`) } /** @@ -127,45 +104,40 @@ export async function runIncrementalPull( * @param maxPages 单轮翻页上限 */ export async function runMinIdPull(options: { - applyPage: ( - records: T[], - nextMinId?: number, - ) => Promise | Promise; - fetchPage: (params: { minId: number; size: number }) => Promise; - initialMinId: number; - isActive?: () => boolean; - maxPages?: number; - pageSize: number; + applyPage: (records: T[], nextMinId?: number) => Promise + fetchPage: (params: { minId: number; size: number }) => Promise + initialMinId: number + isActive?: () => boolean + maxPages?: number + pageSize: number }): Promise { - const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options; - const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES; - let minId = initialMinId || 0; + const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options + const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES + let minId = initialMinId || 0 for (let page = 0; page < maxPages; page++) { if (isActive && !isActive()) { - return; + return } - const list = await fetchPage({ minId, size: pageSize }); + const list = await fetchPage({ minId, size: pageSize }) // 拉取期间取消 / 切账号:丢弃本批不入库,也不再翻页 if (isActive && !isActive()) { - return; + return } if (!list || list.length === 0) { - return; + return } // 本批最大消息 id 作为下次游标;无有效 id 则本批 apply 后停(无法继续翻页) - const validIds = list - .map((record) => record.id) - .filter((id): id is number => id !== null); - const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined; + const validIds = list.map((record) => record.id).filter((id): id is number => id != null) + const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined // applyPage 返回 false:本页未落地(如入库失败),不推进游标并终止,避免漏消息 if ((await applyPage(list, nextMinId)) === false) { - return; + return } // 无有效 id,或游标没前进(后端契约是 id > minId,理论不会出现):停,防御死翻 - if (isUndefined(nextMinId) || nextMinId <= minId) { - return; + if (nextMinId == null || nextMinId <= minId) { + return } - minId = nextMinId; + minId = nextMinId } - console.warn('[IM pull] runMinIdPull 达到单轮翻页上限,提前结束本轮'); + console.warn('[IM pull] runMinIdPull 达到单轮翻页上限,提前结束本轮') } diff --git a/apps/web-antdv-next/src/views/im/home/components/friend/friend-add-dialog.vue b/apps/web-antdv-next/src/views/im/home/components/friend/friend-add-dialog.vue index 7224ecb12..b4c3bf60e 100644 --- a/apps/web-antdv-next/src/views/im/home/components/friend/friend-add-dialog.vue +++ b/apps/web-antdv-next/src/views/im/home/components/friend/friend-add-dialog.vue @@ -67,6 +67,8 @@ const dialogTitle = computed(() => const presetMode = computed(() => !!presetUser.value); function resetAll() { + loading.value = false; + submitting.value = false; keyword.value = ''; users.value = []; searched.value = false; @@ -102,14 +104,17 @@ function buildPresetApplyContent(): string { /** 按昵称搜索用户:空关键字直接清空结果 */ async function handleSearch() { searched.value = true; - if (!keyword.value.trim()) { + const query = keyword.value.trim(); + if (!query) { users.value = []; + loading.value = false; return; } loading.value = true; try { - users.value = - (await getSimpleUserListByNickname(keyword.value.trim())) || []; + users.value = (await getSimpleUserListByNickname(query)) || []; + } catch (error) { + console.warn('[IM FriendAddDialog] 搜索用户失败', error); } finally { loading.value = false; } @@ -132,25 +137,27 @@ function backToSearch() { /** 提交好友申请:返回 requestId 走「等待验证」;返回 null 表示后端命中「单向好友静默重启」分支,已直接成为好友 */ async function handleSubmitApply() { - if (!targetUser.value) { + const target = targetUser.value; + if (!target) { return; } // 预校验:不能加自己(搜索列表已过滤,这里兜底 presetUser / 名片入口等场景) - if (targetUser.value.id === currentUserId.value) { + if (target.id === currentUserId.value) { message.warning('不能添加自己为好友'); return; } + const payload = { + toUserId: target.id, + applyContent: applyContent.value.trim() || undefined, + displayName: displayName.value.trim() || undefined, + addSource: addSource.value, + }; submitting.value = true; try { - const requestId = await friendStore.applyFriendRequest({ - toUserId: targetUser.value.id, - applyContent: applyContent.value.trim() || undefined, - displayName: displayName.value.trim() || undefined, - addSource: addSource.value, - }); + const requestId = await friendStore.applyFriendRequest(payload); // silent 分支(已是单向好友被静默重启):主动 fetchFriendInfo 入库,不依赖 WS FRIEND_ADD 推送,避免丢推时列表看不到 if (requestId === null) { - await friendStore.fetchFriendInfo(targetUser.value.id); + await friendStore.fetchFriendInfo(target.id); } message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友'); visible.value = false; diff --git a/apps/web-antdv-next/src/views/im/home/components/group/group-info-card.vue b/apps/web-antdv-next/src/views/im/home/components/group/group-info-card.vue index 9142f1164..492605b39 100644 --- a/apps/web-antdv-next/src/views/im/home/components/group/group-info-card.vue +++ b/apps/web-antdv-next/src/views/im/home/components/group/group-info-card.vue @@ -66,6 +66,7 @@ function handleChat(group: GroupLite) { /** 加入群聊:先关浮层(避免与 prompt 的 mask 互相遮挡)→ 弹申请理由(可选)→ applyJoinGroup */ async function handleApply(group: GroupLite) { + const groupId = group.id; handleClose(); let applyContent: string; try { @@ -86,12 +87,16 @@ async function handleApply(group: GroupLite) { } catch { return; } - await applyJoinGroup({ - groupId: group.id, - applyContent: applyContent || undefined, - addSource: ImGroupAddSource.SHARE_LINK, - }); - message.success('加群申请已发送'); + try { + await applyJoinGroup({ + groupId, + applyContent: applyContent || undefined, + addSource: ImGroupAddSource.SHARE_LINK, + }); + message.success('加群申请已发送'); + } catch (error) { + console.warn('[IM GroupInfoCard] 申请加群失败', error); + } } diff --git a/apps/web-antdv-next/src/views/im/home/components/group/group-info.vue b/apps/web-antdv-next/src/views/im/home/components/group/group-info.vue index cf7aa6eff..651490fa1 100644 --- a/apps/web-antdv-next/src/views/im/home/components/group/group-info.vue +++ b/apps/web-antdv-next/src/views/im/home/components/group/group-info.vue @@ -78,7 +78,7 @@ const memberCountText = computed(() => { return count ? `${count} 位成员` : ''; }); -/** member 切群 / 首挂:拉成员;竞态用 group.id 比对丢弃陈旧响应避免上一条群成员错位 */ +/** member 切群 / 首挂:拉取群成员 */ watch( () => [props.group?.id, isMember.value] as const, async ([id, member]) => { @@ -86,13 +86,14 @@ watch( if (!id || !member) { return; } - const list = await groupStore.fetchGroupMemberList(id, true); - if (props.group?.id !== id) { - return; + try { + const list = await groupStore.fetchGroupMemberList(id, true); + members.value = list.map((m) => + convertGroupMemberLite(m, friendStore.getFriend(m.userId)), + ); + } catch (error) { + console.warn('[IM GroupInfo] 群成员加载失败', { groupId: id }, error); } - members.value = list.map((m) => - convertGroupMemberLite(m, friendStore.getFriend(m.userId)), - ); }, { immediate: true }, ); diff --git a/apps/web-antdv-next/src/views/im/home/components/group/group-owner-transfer-dialog.vue b/apps/web-antdv-next/src/views/im/home/components/group/group-owner-transfer-dialog.vue index 6f90adff2..7577d37e5 100644 --- a/apps/web-antdv-next/src/views/im/home/components/group/group-owner-transfer-dialog.vue +++ b/apps/web-antdv-next/src/views/im/home/components/group/group-owner-transfer-dialog.vue @@ -52,12 +52,15 @@ const newOwner = computed(() => { /** 二次确认转让:转让后旧群主降为普通成员,无法撤销 */ async function handleOk() { - if (!groupId.value || !newOwner.value) { + const targetGroupId = groupId.value; + const newOwnerUserId = newOwner.value?.userId; + const newOwnerName = newOwner.value?.showName; + if (!targetGroupId || !newOwnerUserId) { return; } try { await confirm( - `确定将群主转让给 ${newOwner.value.showName}?转让后你将变为普通成员,无法撤销。`, + `确定将群主转让给 ${newOwnerName}?转让后你将变为普通成员,无法撤销。`, '确认转让群主', ); } catch { @@ -66,12 +69,14 @@ async function handleOk() { submitting.value = true; try { await transferGroupOwner({ - id: groupId.value, - newOwnerUserId: newOwner.value.userId, + id: targetGroupId, + newOwnerUserId, }); message.success('群主转让成功'); emit('reload'); visible.value = false; + } catch (error) { + console.warn('[IM GroupOwnerTransferDialog] 转让群主失败', error); } finally { submitting.value = false; } diff --git a/apps/web-antdv-next/src/views/im/home/components/rtc/rtc-call-container.vue b/apps/web-antdv-next/src/views/im/home/components/rtc/rtc-call-container.vue index 9c5aeb26a..35d6ee204 100644 --- a/apps/web-antdv-next/src/views/im/home/components/rtc/rtc-call-container.vue +++ b/apps/web-antdv-next/src/views/im/home/components/rtc/rtc-call-container.vue @@ -1,11 +1,13 @@ diff --git a/apps/web-antdv-next/src/views/im/home/components/user/user-info.vue b/apps/web-antdv-next/src/views/im/home/components/user/user-info.vue index 963072223..30871fd69 100644 --- a/apps/web-antdv-next/src/views/im/home/components/user/user-info.vue +++ b/apps/web-antdv-next/src/views/im/home/components/user/user-info.vue @@ -10,7 +10,14 @@ import { DICT_TYPE } from '@vben/constants'; import { getDictLabel } from '@vben/hooks'; import { IconifyIcon as Icon } from '@vben/icons'; -import { Button, Checkbox, Dropdown, Input, Menu, message } from 'antdv-next'; +import { + Button, + Checkbox, + Dropdown, + Input, + Menu, + message, +} from 'antdv-next'; import { getSimpleUser } from '#/api/system/user'; import { formatDate } from '#/views/im/utils/time'; @@ -98,7 +105,6 @@ const remarkInputRef = ref void; select?: () => void }>( * user.id 变化的统一处理: * 1. 起手用 prop 兜底首屏(full = props.user),再 getSimpleUser 命中后合并替换 * 2. 顺便复位备注编辑态,避免上一个用户的脏输入泄漏到下一个 - * 3. 竞态用 id 比对丢弃陈旧响应 */ watch( () => props.user?.id, @@ -109,9 +115,6 @@ watch( return; } const data = (await getSimpleUser(id)) as User; - if (props.user?.id !== id) { - return; - } full.value = { ...props.user, ...data }; }, { immediate: true }, @@ -228,10 +231,11 @@ async function handleBlock() { /** 移出黑名单:操作温和不弹 confirm;后端 FRIEND_UNBLOCK 推到时由 dispatcher 同步多端 */ async function handleUnblock() { - if (!props.user?.id) { + const targetId = props.user?.id; + if (!targetId) { return; } - await friendStore.unblockFriend(props.user.id); + await friendStore.unblockFriend(targetId); message.success('已移出黑名单'); } @@ -265,7 +269,12 @@ async function handleDeleteFriend() { } catch { return; } - await friendStore.deleteFriend(target.id, clearConversation.value); + try { + await friendStore.deleteFriend(target.id, clearConversation.value); + } catch (error) { + console.warn('[IM UserInfo] 删除好友失败', error); + return; + } message.success('已删除好友'); emit('deleted', target); } diff --git a/apps/web-antdv-next/src/views/im/home/composables/useLiveKitRoom.ts b/apps/web-antdv-next/src/views/im/home/composables/useLiveKitRoom.ts index ad1604615..0b541e206 100644 --- a/apps/web-antdv-next/src/views/im/home/composables/useLiveKitRoom.ts +++ b/apps/web-antdv-next/src/views/im/home/composables/useLiveKitRoom.ts @@ -1,66 +1,78 @@ -import type { - LocalParticipant, - Participant, - RemoteParticipant, -} from 'livekit-client'; - -import { computed, ref, shallowRef } from 'vue'; +import { computed, ref, shallowRef } from 'vue' import { - ConnectionQuality, + type LocalParticipant, + type Participant, + type RemoteParticipant, Room, RoomEvent, Track, - VideoPresets, -} from 'livekit-client'; + VideoPresets +} from 'livekit-client' -type ParticipantEventHandler = (userId: number) => void; +type ParticipantEventHandler = (userId: number) => void + +const roomDisconnectTasks = new WeakMap>() + +/** 同一个物理 Room 只执行一次 listener 清理与断开 */ +function disconnectPhysicalRoom(target: Room): Promise { + const current = roomDisconnectTasks.get(target) + if (current) { + return current + } + const task = Promise.resolve().then(async () => { + target.removeAllListeners() + await target.disconnect() + }) + roomDisconnectTasks.set(target, task) + return task +} /** LiveKit Room 连接 / 设备 / 事件的薄封装;UI 组件只关心响应式状态 */ export function useLiveKitRoom() { /** Room 实例;模块内部状态,不对外暴露,避免调用方误写 */ - const _room = shallowRef(null); + const _room = shallowRef(null) /** 只读 room 引用;调用方仅用于幂等判定 */ - const room = computed(() => _room.value); + const room = computed(() => _room.value) /** 本地参与者;连接成功后赋值 */ - const localParticipant = shallowRef(null); + const localParticipant = shallowRef(null) /** 远端参与者列表;ParticipantConnected / Disconnected 时刷新;shallowRef 避免 Vue 深度代理 SDK class 内部 */ - const remoteParticipants = shallowRef([]); + const remoteParticipants = shallowRef([]) /** 连接状态 */ - const isConnected = ref(false); - /** 连接质量 */ - const connectionQuality = ref(ConnectionQuality.Unknown); + const isConnected = ref(false) /** 麦克风开关 */ - const micEnabled = ref(true); + const micEnabled = ref(true) /** 摄像头开关 */ - const cameraEnabled = ref(false); + const cameraEnabled = ref(false) /** 扬声器开关;浏览器无系统级 API,通过 audio 元素 muted 属性实现远端音频静音 */ - const speakerEnabled = ref(true); + const speakerEnabled = ref(true) /** 屏幕共享开关 */ - const screenShareEnabled = ref(false); + const screenShareEnabled = ref(false) /** 当前是否处于「重连中」;瞬断时 UI 显示提示而不强制结束通话 */ - const reconnecting = ref(false); + const reconnecting = ref(false) /** 远端断开订阅者;通话结束时统一清空 */ - const disconnectedHandlers = new Set<() => void>(); + const disconnectedHandlers = new Set<() => void>() /** 房内某人加入订阅者;主叫端用于从 INVITING 切到 RUNNING */ - const participantConnectedHandlers = new Set(); + const participantConnectedHandlers = new Set() /** 房内某人离开订阅者;用于把 userId 标记为「已退出」从 pending 占位中移除 */ - const participantDisconnectedHandlers = new Set(); + const participantDisconnectedHandlers = new Set() + let connectionOwner = {} // 连接 owner 用于阻断旧 connect/callback 修改新 Room 状态 /** 同步远端参与者列表到响应式数组 */ function syncRemotes(r: Room) { - remoteParticipants.value = [...r.remoteParticipants.values()]; + remoteParticipants.value = Array.from(r.remoteParticipants.values()) } /** 连接 LiveKit Server;audio / video 控制初始默认开关 */ - async function connect( - url: string, - token: string, - opts: { audio?: boolean; video?: boolean }, - ) { + async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) { + const owner = {} + connectionOwner = owner // 新连接前先断开旧 Room;保留本次注册的事件回调 if (_room.value) { - await disconnectRoom(false); + await disconnectRoom(false, false) + } + if (connectionOwner !== owner) { + return null } const r = new Room({ // 按格子尺寸自动选 simulcast 层 @@ -69,117 +81,121 @@ export function useLiveKitRoom() { dynacast: true, // 采集分辨率 720p,确保大格子清晰 videoCaptureDefaults: { - resolution: VideoPresets.h720.resolution, + resolution: VideoPresets.h720.resolution }, // 发布编码上限 1.5 Mbps / 30fps;保留默认 simulcast 三层(180p / 360p / 720p) publishDefaults: { videoEncoding: { maxBitrate: 1_500_000, maxFramerate: 30, - priority: 'high', + priority: 'high' }, // 屏幕共享码率 3 Mbps,文字界面清晰 screenShareEncoding: { maxBitrate: 3_000_000, maxFramerate: 15, - priority: 'medium', - }, - }, - }); - _room.value = r; + priority: 'medium' + } + } + }) + _room.value = r + const isCurrentRoom = () => _room.value === r && connectionOwner === owner r.on(RoomEvent.ParticipantConnected, (rp) => { - syncRemotes(r); - const userId = parseUserId(rp.identity); - if (userId !== null) { - participantConnectedHandlers.forEach((cb) => cb(userId)); + if (!isCurrentRoom()) return + syncRemotes(r) + const userId = parseUserId(rp.identity) + if (userId != null) { + participantConnectedHandlers.forEach((cb) => cb(userId)) } }) .on(RoomEvent.ParticipantDisconnected, (rp) => { - syncRemotes(r); + if (!isCurrentRoom()) return + syncRemotes(r) // 离开的参与者缓存清掉,避免下次同 sid 重连命中失效引用 - for (const key of streamCache.keys()) { + for (const key of Array.from(streamCache.keys())) { if (key.startsWith(`${rp.sid}:`)) { - streamCache.delete(key); + streamCache.delete(key) } } - const userId = parseUserId(rp.identity); - if (userId !== null) { - participantDisconnectedHandlers.forEach((cb) => cb(userId)); + const userId = parseUserId(rp.identity) + if (userId != null) { + participantDisconnectedHandlers.forEach((cb) => cb(userId)) } }) - .on(RoomEvent.TrackSubscribed, () => syncRemotes(r)) - .on(RoomEvent.TrackUnsubscribed, () => syncRemotes(r)) + .on(RoomEvent.TrackSubscribed, () => isCurrentRoom() && syncRemotes(r)) + .on(RoomEvent.TrackUnsubscribed, () => isCurrentRoom() && syncRemotes(r)) // mute / unmute 让 pickStream 的 isMuted 短路重算,video 元素能解绑 srcObject 而不是卡最后一帧 - .on(RoomEvent.TrackMuted, () => syncRemotes(r)) - .on(RoomEvent.TrackUnmuted, () => syncRemotes(r)) - .on(RoomEvent.ConnectionQualityChanged, (quality) => { - connectionQuality.value = quality; - }) + .on(RoomEvent.TrackMuted, () => isCurrentRoom() && syncRemotes(r)) + .on(RoomEvent.TrackUnmuted, () => isCurrentRoom() && syncRemotes(r)) // 瞬断 → 显示「重连中」;不关通话窗,由 SDK 内部重连机制恢复 .on(RoomEvent.Reconnecting, () => { - reconnecting.value = true; + if (!isCurrentRoom()) return + reconnecting.value = true }) .on(RoomEvent.Reconnected, () => { - reconnecting.value = false; + if (!isCurrentRoom()) return + reconnecting.value = false }) // 重连失败 / 主动断 / 被踢时触发清理 .on(RoomEvent.Disconnected, () => { - isConnected.value = false; - reconnecting.value = false; - disconnectedHandlers.forEach((cb) => cb()); - }); + if (!isCurrentRoom()) return + isConnected.value = false + reconnecting.value = false + disconnectedHandlers.forEach((cb) => cb()) + }) // 预热 getUserMedia 与 WebSocket 握手并行,省 100~300ms 串行延迟; // 拿到的 stream 仅用于触发权限弹窗 + 设备就绪,握手完成后由 LiveKit 内部重新请求设备发布轨 - const warmup = prewarmMedia(opts); + const warmup = prewarmMedia(opts) // 建立 WebSocket 信令 + WebRTC 媒体通道;完成后 localParticipant 可用,已在房参与者会通过 ParticipantConnected 事件批量推送 - await r.connect(url, token); + await r.connect(url, token) // 期间被外部 disconnect 替换;中止后续 publish,避免摄像头被重新启用 - if (_room.value !== r) { - return; + if (!isCurrentRoom()) { + return null } - localParticipant.value = r.localParticipant; - isConnected.value = true; + localParticipant.value = r.localParticipant + isConnected.value = true // 预热结果不直接发布(避免 SDK 与外部 track 生命周期纠缠),仅等待权限就绪后再走标准 setXxxEnabled - await warmup; - if (_room.value !== r) { - return; + await warmup + if (!isCurrentRoom()) { + return null } // 麦克风与摄像头权限相互独立,并行启用发布 - const inits: Promise[] = []; + const inits: Promise[] = [] if (opts.audio) { - inits.push(r.localParticipant.setMicrophoneEnabled(true)); + inits.push(r.localParticipant.setMicrophoneEnabled(true)) } if (opts.video) { - inits.push(r.localParticipant.setCameraEnabled(true)); + inits.push(r.localParticipant.setCameraEnabled(true)) } if (inits.length > 0) { - await Promise.all(inits); + await Promise.all(inits) } - micEnabled.value = !!opts.audio; - cameraEnabled.value = !!opts.video; + if (!isCurrentRoom()) { + return null + } + micEnabled.value = !!opts.audio + cameraEnabled.value = !!opts.video // 兜底同步一次远端列表:r.connect 期间 ParticipantConnected 事件可能在 handler 绑定前触发被吞,导致首屏漏人 - syncRemotes(r); + syncRemotes(r) + return r } /** 提前触发权限弹窗 + 设备唤起,串行延迟在 r.connect 期间一起跑;失败静默(连接后会再试一次) */ - async function prewarmMedia(opts: { - audio?: boolean; - video?: boolean; - }): Promise { + async function prewarmMedia(opts: { audio?: boolean; video?: boolean }): Promise { if (!opts.audio && !opts.video) { - return; + return } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: !!opts.audio, - video: !!opts.video, - }); + video: !!opts.video + }) // 拿到权限即可,立即停掉所有 track 释放设备;正式发布走 SDK 流程重新请求 - stream.getTracks().forEach((t) => t.stop()); + stream.getTracks().forEach((t) => t.stop()) } catch { // 用户拒绝 / 设备占用等异常,交给后续 setXxxEnabled 再次尝试报错 } @@ -187,25 +203,27 @@ export function useLiveKitRoom() { /** 切麦克风 */ async function setMicEnabled(enabled: boolean) { - if (!_room.value) { - return; + const r = _room.value + if (!r) { + return } - await _room.value.localParticipant.setMicrophoneEnabled(enabled); - micEnabled.value = enabled; + await r.localParticipant.setMicrophoneEnabled(enabled) + if (_room.value === r) micEnabled.value = enabled } /** 切摄像头 */ async function setCameraEnabled(enabled: boolean) { - if (!_room.value) { - return; + const r = _room.value + if (!r) { + return } - await _room.value.localParticipant.setCameraEnabled(enabled); - cameraEnabled.value = enabled; + await r.localParticipant.setCameraEnabled(enabled) + if (_room.value === r) cameraEnabled.value = enabled } /** 切扬声器;仅切响应式状态,实际静音由模板上 audio 元素 :muted 绑定生效 */ function setSpeakerEnabled(enabled: boolean) { - speakerEnabled.value = enabled; + speakerEnabled.value = enabled } /** @@ -214,40 +232,42 @@ export function useLiveKitRoom() { * 浏览器会弹原生「选择共享内容」对话框,用户在弹窗里点取消时 setScreenShareEnabled 会抛错,捕获并把状态复位回 SDK 的实际值 */ async function setScreenShareEnabled(enabled: boolean) { - if (!_room.value) return; + const r = _room.value + if (!r) return try { - await _room.value.localParticipant.setScreenShareEnabled(enabled); - screenShareEnabled.value = enabled; - } catch (error) { + await r.localParticipant.setScreenShareEnabled(enabled) + if (_room.value === r) screenShareEnabled.value = enabled + } catch (e) { // 用户在浏览器原生对话框里取消选择,不当作错误 - screenShareEnabled.value = - _room.value.localParticipant.isScreenShareEnabled; - throw error; + if (_room.value === r) { + screenShareEnabled.value = r.localParticipant.isScreenShareEnabled + } + throw e } } /** 注册「远端连接异常断开」回调;返回反注册函数 */ function onDisconnected(cb: () => void): () => void { - disconnectedHandlers.add(cb); - return () => disconnectedHandlers.delete(cb); + disconnectedHandlers.add(cb) + return () => disconnectedHandlers.delete(cb) } /** 注册「房内某人加入」回调;返回反注册函数 */ function onParticipantConnected(cb: ParticipantEventHandler): () => void { - participantConnectedHandlers.add(cb); - return () => participantConnectedHandlers.delete(cb); + participantConnectedHandlers.add(cb) + return () => participantConnectedHandlers.delete(cb) } /** 注册「房内某人离开」回调;返回反注册函数 */ function onParticipantDisconnected(cb: ParticipantEventHandler): () => void { - participantDisconnectedHandlers.add(cb); - return () => participantDisconnectedHandlers.delete(cb); + participantDisconnectedHandlers.add(cb) + return () => participantDisconnectedHandlers.delete(cb) } /** identity 是后端签 token 时塞的 userId 字符串,转 number 返回;非数字(兼容性兜底)返回 null */ function parseUserId(identity: string): null | number { - const id = Number(identity); - return Number.isNaN(id) ? null : id; + const id = Number(identity) + return Number.isNaN(id) ? null : id } /** @@ -255,10 +275,7 @@ export function useLiveKitRoom() { * 同一条 MediaStreamTrack 复用同一个 MediaStream,避免 跳转; -// text 段是纯文本兜底。渲染层(tip-segments.vue)按 type 分发统一处理。 +// text 段是纯文本兜底。渲染层(TipSegments.vue)按 type 分发统一处理。 export type TipSegment = - | { href: string; text: string; type: 'link' } - | { text: string; type: 'mention'; userId: number } - | { text: string; type: 'text' }; + | { href: string; text: string; type: 'link'; } + | { text: string; type: 'mention'; userId: number; } + | { text: string; type: 'text'; } -export const tipText = (text: string): TipSegment => ({ type: 'text', text }); +export const tipText = (text: string): TipSegment => ({ type: 'text', text }) export const tipMention = (userId: number, text: string): TipSegment => ({ type: 'mention', userId, - text, -}); + text +}) -export const tipLink = (href: string, text: string): TipSegment => ({ +const tipLink = (href: string, text: string): TipSegment => ({ type: 'link', href, - text, -}); + text +}) export const segmentsToText = (segments: TipSegment[]): string => - segments.map((s) => s.text).join(''); + segments.map((s) => s.text).join('') /** 多个 userId 用同一个分隔符插值成 segments,每个 user 单独成 mention 段 */ -export function joinMentionSegments( +function joinMentionSegments( userIds: number[], separator: string, - resolveName: (userId: number) => string, + resolveName: (userId: number) => string ): TipSegment[] { return userIds.flatMap((id, index) => index === 0 ? [tipMention(id, resolveName(id))] - : [tipText(separator), tipMention(id, resolveName(id))], - ); + : [tipText(separator), tipMention(id, resolveName(id))] + ) } /** mention 候选;name 用于匹配文本字面量,displayName 用于覆盖渲染(不传则按 name 渲染) */ export interface MentionCandidate { - userId: number; + userId: number /** 用来在 content 中前缀匹配的字面量(不含 @) */ - name: string; + name: string /** * 渲染时强制使用的显示名(不含 @) * @@ -116,157 +105,142 @@ export interface MentionCandidate { * 候选可以携带多种字面量(match 用),但 displayName 统一指向 nickname, * 让所有历史 / 新消息的 @ 都收敛到一致的展示 */ - displayName?: string; + displayName?: string /** * 歧义标记:同 name 对应多个 userId 时为 true * * parser 仍会按 name 命中(最长优先),但命中后整段吃成普通文本—— * 避免「@张三」被剔除后,短前缀候选「@张」抢匹配错绑 */ - ambiguous?: boolean; + ambiguous?: boolean } /** URL 锚定正则;终止于空白、中文、@(避免吞掉下一个 mention)、< > " '(防 HTML / 引号串入);y 标志走 sticky 匹配,省 text.slice */ -const URL_STICKY_REGEX = - /(https?:\/\/[^\s一-龥@<>"']+|www\.[^\s一-龥@<>"']+)/iy; +const URL_STICKY_REGEX = /(https?:\/\/[^\s一-龥@<>"']+|www\.[^\s一-龥@<>"']+)/iy /** URL 末尾常见标点剔除,避免吞掉句末中英文标点 */ -const URL_TRAILING_PUNCTUATION = /[.,!?;:)\]、,。!?;:)】]+$/; +const URL_TRAILING_PUNCTUATION = /[.,!?;:)\]、,。!?;:)】]+$/ /** 最短 URL:`www.ab` / `http:/` 这种孤段不算链接 */ -const URL_MIN_LENGTH = 6; +const URL_MIN_LENGTH = 6 /** * 文本气泡 content 拆段:mention 段按候选最长前缀匹配,URL 段走锚定正则,剩余归 text * * mentions 不传或匹配不上时,@xxx 退化为普通 text;解析与渲染解耦,工具函数本身不依赖 store */ -export function parseTextSegments( - text: string, - mentions: MentionCandidate[] = [], -): TipSegment[] { +export function parseTextSegments(text: string, mentions: MentionCandidate[] = []): TipSegment[] { if (!text) { - return []; + return [] } // 「@张三丰」不能被「@张三」截胡,候选按 name 长度倒序 const sortedMentions = - mentions.length > 1 - ? mentions.toSorted((a, b) => b.name.length - a.name.length) - : mentions; - const out: TipSegment[] = []; - let buffer = ''; + mentions.length > 1 ? [...mentions].sort((a, b) => b.name.length - a.name.length) : mentions + const out: TipSegment[] = [] + let buffer = '' const flush = () => { if (buffer) { - out.push(tipText(buffer)); - buffer = ''; + out.push(tipText(buffer)) + buffer = '' } - }; + } - let i = 0; + let i = 0 while (i < text.length) { if (text[i] === '@' && sortedMentions.length > 0) { - const matched = sortedMentions.find( - (m) => m.name && text.startsWith(m.name, i + 1), - ); + const matched = sortedMentions.find((m) => m.name && text.startsWith(m.name, i + 1)) if (matched) { if (matched.ambiguous) { // 同字面量对应多 userId,无法判定意图;整段累入 buffer 让短前缀候选没机会再扫这段 - buffer += `@${matched.name}`; + buffer += '@' + matched.name } else { - flush(); + flush() // 渲染统一走 displayName(即真实昵称),让历史 content 里残留的备注 / 群昵称也收敛到 nickname - out.push( - tipMention( - matched.userId, - `@${matched.displayName || matched.name}`, - ), - ); + out.push(tipMention(matched.userId, '@' + (matched.displayName || matched.name))) } - i += 1 + matched.name.length; - continue; + i += 1 + matched.name.length + continue } } - const head = text[i]; + const head = text[i] if (head === 'h' || head === 'H' || head === 'w' || head === 'W') { - URL_STICKY_REGEX.lastIndex = i; - const linkMatch = URL_STICKY_REGEX.exec(text); + URL_STICKY_REGEX.lastIndex = i + const linkMatch = URL_STICKY_REGEX.exec(text) if (linkMatch) { - const urlText = linkMatch[0].replace(URL_TRAILING_PUNCTUATION, ''); + const urlText = linkMatch[0].replace(URL_TRAILING_PUNCTUATION, '') if (urlText.length >= URL_MIN_LENGTH) { - flush(); - const href = /^https?:\/\//i.test(urlText) - ? urlText - : `https://${urlText}`; - out.push(tipLink(href, urlText)); - i += urlText.length; - continue; + flush() + const href = /^https?:\/\//i.test(urlText) ? urlText : `https://${urlText}` + out.push(tipLink(href, urlText)) + i += urlText.length + continue } } } - buffer += text[i]; - i += 1; + buffer += text[i] + i += 1 } - flush(); - return out; + flush() + return out } // ==================== 引用消息 ==================== /** 引用容器:5 种普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */ interface Quotable { - quote?: QuoteMessage; + quote?: QuoteMessage } // ==================== 消息 payload ==================== /** 文本消息 payload(对齐后端 TextMessage) */ export interface TextMessage extends Quotable { - content: string; + content: string } /** 图片消息 payload(对齐后端 ImageMessage) */ export interface ImageMessage extends Quotable { - url: string; + url: string /** 缩略图 URL */ - thumbnailUrl?: string; + thumbnailUrl?: string /** 图片宽度 */ - width?: number; + width?: number /** 图片高度 */ - height?: number; + height?: number /** 文件大小(字节) */ - size?: number; + size?: number } /** 语音消息 payload(对齐后端 AudioMessage;ImContentType 保留 VOICE 命名) */ export interface AudioMessage extends Quotable { - url: string; + url: string /** 时长(秒) */ - duration: number; + duration: number /** 文件大小(字节) */ - size?: number; + size?: number } /** 文件消息 payload(对齐后端 FileMessage) */ export interface FileMessage extends Quotable { - url: string; - name: string; - size: number; + url: string + name: string + size: number /** MIME 类型 */ - type?: string; + type?: string } /** 视频消息 payload(对齐后端 VideoMessage;暂未接入渲染) */ export interface VideoMessage extends Quotable { - url: string; + url: string /** 封面 URL */ - coverUrl?: string; + coverUrl?: string /** 时长(秒) */ - duration?: number; - width?: number; - height?: number; + duration?: number + width?: number + height?: number /** 文件大小(字节) */ - size?: number; + size?: number } /** @@ -278,51 +252,47 @@ export interface VideoMessage extends Quotable { */ export interface CardMessage extends Quotable { /** 名片对象类型 */ - targetType: ImConversationTypeValue; + targetType: ImConversationTypeValue /** 目标对象编号:PRIVATE 时 = userId;GROUP 时 = groupId */ - targetId: number; + targetId: number /** 显示名快照:PRIVATE 时 = 用户昵称;GROUP 时 = 群名 */ - name: string; + name: string /** 头像(快照) */ - avatar?: string; + avatar?: string /** 群成员数(仅 targetType = GROUP;接收端展示「N 人群聊」) */ - memberCount?: number; + memberCount?: number } /** * 名片转发的源对象(RecommendCardDialog 入参);字段与 CardMessage 1:1,无 quote */ -export type CardTarget = Omit; +export type CardTarget = Omit /** 用户对象 → 用户名片源(PRIVATE);缺 id 返回 null 让调用方 v-bind 绑定为不渲染 */ -export function toUserCardTarget( - user: null | undefined | User, -): CardTarget | null { +export function toUserCardTarget(user: null | undefined | User): CardTarget | null { if (!user?.id) { - return null; + return null } return { targetType: ImConversationType.PRIVATE, targetId: user.id, name: user.nickname || '', - avatar: user.avatar, - }; + avatar: user.avatar + } } /** 群对象 → 群名片源(GROUP);缺 id 返回 null */ -export function toGroupCardTarget( - group: GroupLite | null | undefined, -): CardTarget | null { +export function toGroupCardTarget(group: GroupLite | null | undefined): CardTarget | null { if (!group?.id) { - return null; + return null } return { targetType: ImConversationType.GROUP, targetId: group.id, name: group.name || '', avatar: group.showImage || group.showImageThumb, - memberCount: group.memberCount, - }; + memberCount: group.memberCount + } } /** @@ -330,70 +300,68 @@ export function toGroupCardTarget( * * 缺失 / 非法 targetType 走「个人名片」兜底,避免老消息或脏数据导致 UI 空白 */ -export function getCardLabelInfo( - card: null | undefined | { targetType?: number }, -): { - icon: string; - label: string; +export function getCardLabelInfo(card: null | undefined | { targetType?: number }): { + icon: string + label: string } { if (card?.targetType === ImConversationType.GROUP) { - return { label: '群名片', icon: 'ant-design:usergroup-outlined' }; + return { label: '群名片', icon: 'ant-design:usergroup-outlined' } } - return { label: '个人名片', icon: 'ant-design:user-outlined' }; + return { label: '个人名片', icon: 'ant-design:user-outlined' } } /** 表情消息 payload(对齐后端 FaceMessage;Unicode emoji 仍走 TEXT,本类型只承载贴图 / 自定义表情包) */ export interface FaceMessage extends Quotable { /** 表情图 URL */ - url: string; + url: string /** 渲染宽度(像素),避免布局抖动;可选,缺失时调用方走 CSS max-w 兜底 */ - width?: number; + width?: number /** 渲染高度(像素),可选 */ - height?: number; + height?: number /** 表情名(系统包通常有,个人表情包通常无) */ - name?: string; + name?: string } /** 合并转发的单条内嵌消息快照(对齐后端 MergeMessage.Item) */ -export interface MergeMessageItem { +interface MergeMessageItem { /** 原消息编号;仅做溯源标识 */ - messageId: number; + messageId: number /** 发送人编号 */ - senderId: number; + senderId: number /** 发送人昵称快照;对端可能不在原会话里,无法实时查到 */ - senderNickname: string; + senderNickname: string /** 发送人头像快照 */ - senderAvatar?: string; + senderAvatar?: string /** 内容类型,对齐 ImContentType */ - type: number; + type: number /** 原消息 content(JSON);嵌套合并消息时仍按本结构层层展开 */ - content: string; + content: string /** 发送时间戳(毫秒) */ - sendTime: number; + sendTime: number } /** 合并转发消息 payload(对齐后端 MergeMessage) */ export interface MergeMessage { - title: string; // 合并标题;例:「张三和李四的聊天记录」「群聊的聊天记录」 - messages: MergeMessageItem[]; // 内嵌的完整消息快照 + title: string // 合并标题;例:「张三和李四的聊天记录」「群聊的聊天记录」 + messages: MergeMessageItem[] // 内嵌的完整消息快照 } /** 频道素材消息 payload(对齐后端 MaterialMessage) */ export interface MaterialMessage { - materialId?: number; - channelId?: number; // 频道编号;转发后渲染卡片底部的频道头像 + 名称 - title?: string; - coverUrl?: string; - summary?: string; - url?: string; // 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url + materialId?: number + channelId?: number // 频道编号;转发后渲染卡片底部的频道头像 + 名称 + title?: string + coverUrl?: string + summary?: string + url?: string // 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url } // ==================== 合并转发 payload 构造 ==================== /** 单个发送人的快照昵称 / 头像 */ interface SenderSnapshot { - nickname: string; - avatar: string; + nickname: string + avatar: string } /** @@ -403,54 +371,50 @@ interface SenderSnapshot { */ function buildSenderSnapshotMap( senderIds: number[], - conversation: Conversation, + conversation: Conversation ): Map { - const userStore = useUserStore(); - const friendStore = useFriendStore(); - const selfUserId = getCurrentUserId(); - const result = new Map(); + const userStore = useUserStore() + const friendStore = useFriendStore() + const selfUserId = getCurrentUserId() + const result = new Map() - let groupMembers: Map | null = - null; + let groupMembers: Map | null = null if (conversation.type === ImConversationType.GROUP) { - const group = useGroupStore().getGroup(conversation.targetId); - groupMembers = new Map((group?.members || []).map((m) => [m.userId, m])); + const group = useGroupStore().getGroup(conversation.targetId) + groupMembers = new Map((group?.members || []).map((m) => [m.userId, m])) } for (const senderId of senderIds) { if (result.has(senderId)) { - continue; + continue } if (senderId === selfUserId) { result.set(senderId, { nickname: userStore.userInfo?.nickname || String(senderId), - avatar: userStore.userInfo?.avatar || '', - }); - continue; + avatar: userStore.userInfo?.avatar || '' + }) + continue } - const member = groupMembers?.get(senderId); + const member = groupMembers?.get(senderId) if (member?.nickname) { - result.set(senderId, { - nickname: member.nickname, - avatar: member.avatar || '', - }); - continue; + result.set(senderId, { nickname: member.nickname, avatar: member.avatar || '' }) + continue } - const friend = friendStore.getFriend(senderId); + const friend = friendStore.getFriend(senderId) result.set(senderId, { nickname: friend?.nickname || String(senderId), - avatar: friend?.avatar || '', - }); + avatar: friend?.avatar || '' + }) } - return result; + return result } /** 把单条 Message 转成 MergeMessageItem 快照;剥离 quote 防引用穿透 */ function mapMessageToMergeItem( message: Message, - senderSnapshots: Map, + senderSnapshots: Map ): MergeMessageItem { - const snapshot = senderSnapshots.get(message.senderId); + const snapshot = senderSnapshots.get(message.senderId) return { messageId: message.id || 0, senderId: message.senderId, @@ -458,38 +422,38 @@ function mapMessageToMergeItem( senderAvatar: snapshot?.avatar ?? '', type: message.type, content: removeQuotePayload(message.content), - sendTime: message.sendTime, - }; + sendTime: message.sendTime + } } /** 合并转发标题:私聊「{对方} 和 {自己} 的聊天记录」;群聊「{群名} 的聊天记录」 */ -export function buildMergeTitle(conversation: Conversation): string { +function buildMergeTitle(conversation: Conversation): string { if (conversation.type === ImConversationType.GROUP) { - return `${conversation.name || '群聊'} 的聊天记录`; + return `${conversation.name || '群聊'} 的聊天记录` } - const myName = useUserStore().userInfo?.nickname || '我'; - return `${conversation.name || '对方'} 和 ${myName} 的聊天记录`; + const myName = useUserStore().userInfo?.nickname || '我' + return `${conversation.name || '对方'} 和 ${myName} 的聊天记录` } /** 把一组 Message 打包成 MergeMessage payload;调用方负责按 sendTime 排序后传入 */ export function buildMergeMessagePayload( messages: Message[], - conversation: Conversation, + conversation: Conversation ): MergeMessage { - const senderIds = messages.map((m) => m.senderId); - const senderSnapshots = buildSenderSnapshotMap(senderIds, conversation); + const senderIds = messages.map((m) => m.senderId) + const senderSnapshots = buildSenderSnapshotMap(senderIds, conversation) return { title: buildMergeTitle(conversation), - messages: messages.map((m) => mapMessageToMergeItem(m, senderSnapshots)), - }; + messages: messages.map((m) => mapMessageToMergeItem(m, senderSnapshots)) + } } /** 「添加到表情」的可发起源:FACE / IMAGE 都允许(GIF 图片也常被收藏) */ -export interface AddableFacePayload { - url: string; - width: number; - height: number; - name?: string; +interface AddableFacePayload { + url: string + width: number + height: number + name?: string } /** @@ -497,50 +461,38 @@ export interface AddableFacePayload { * * 调用方(MessageItem 的右键菜单)按 nullable 决定是否展示「添加到表情」入口 */ -export function extractAddableFace( - message: Message, -): AddableFacePayload | null { +export function extractAddableFace(message: Message): AddableFacePayload | null { if (message.type === ImContentType.FACE) { - const face = parseMessage(message.content); + const face = parseMessage(message.content) if (!face?.url) { - return null; + return null } - return { - url: face.url, - width: face.width || 200, - height: face.height || 200, - name: face.name, - }; + return { url: face.url, width: face.width || 200, height: face.height || 200, name: face.name } } if (message.type === ImContentType.IMAGE) { - const image = parseMessage(message.content); + const image = parseMessage(message.content) if (!image?.url) { - return null; + return null } - return { - url: image.url, - width: image.width || 200, - height: image.height || 200, - }; + return { url: image.url, width: image.width || 200, height: image.height || 200 } } - return null; + return null } /** 解析消息 content(JSON 字符串)为指定 payload,非法 JSON 返回 null */ export const parseMessage = (content: string): null | T => { try { - return JSON.parse(content) as T; + return JSON.parse(content) as T } catch { - return null; + return null } -}; +} /** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */ -export const serializeMessage = (payload: T): string => - JSON.stringify(payload); +export const serializeMessage = (payload: T): string => JSON.stringify(payload) /** `URL.createObjectURL(file)` 生成的 URL 前缀;占位 / revoke / 重传旧值识别共用 */ -export const BLOB_URL_PREFIX = 'blob:'; +export const BLOB_URL_PREFIX = 'blob:' /** * 媒体 payload 里可能包含 blob URL 的字段(图片/文件/视频/语音都对齐这套 url 字段命名) @@ -550,7 +502,7 @@ export const BLOB_URL_PREFIX = 'blob:'; * - coverUrl:视频封面(commit 后是真实 URL;占位阶段不设以避免传 blob 当 poster 在部分浏览器退化) * - thumbnailUrl:图片缩略图(当前未占位时使用 blob,预留) */ -const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const; +const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const /** * 释放 content 中媒体 payload 字段上的 blob URL 内存映射 @@ -560,32 +512,29 @@ const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const; */ export const revokeBlobUrlsInContent = (content: string): void => { if (!content || !content.includes(BLOB_URL_PREFIX)) { - return; + return } - const payload = parseMessage>(content); + const payload = parseMessage>(content) if (!payload) { - return; + return } for (const field of MEDIA_BLOB_URL_FIELDS) { - const value = payload[field]; + const value = payload[field] if (typeof value === 'string' && value.startsWith(BLOB_URL_PREFIX)) { - URL.revokeObjectURL(value); + URL.revokeObjectURL(value) } } -}; +} // ==================== 引用消息 helper ==================== /** 把 quote 合进 payload(序列化前调用);quote 缺失时原样返回 */ -export const withQuotePayload = ( - payload: T, - quote?: QuoteMessage, -): T => { +export const withQuotePayload = (payload: T, quote?: QuoteMessage): T => { if (!quote) { - return payload; + return payload } - return { ...payload, quote }; -}; + return { ...payload, quote } +} /** * 从 content JSON 字符串里清掉 quote 字段 @@ -595,15 +544,15 @@ export const withQuotePayload = ( */ export const removeQuotePayload = (content: string): string => { if (!content || !content.includes('"quote"')) { - return content; + return content } - const parsed = parseMessage>(content); + const parsed = parseMessage>(content) if (!parsed || !('quote' in parsed)) { - return content; + return content } - delete parsed.quote; - return JSON.stringify(parsed); -}; + delete parsed.quote + return JSON.stringify(parsed) +} /** 由 Message 派生 QuoteMessage 用于乐观渲染;ack 后会被服务端权威版本覆盖 */ export const buildQuoteFromMessage = (message: Message): QuoteMessage => { @@ -611,19 +560,19 @@ export const buildQuoteFromMessage = (message: Message): QuoteMessage => { messageId: message.id || 0, senderId: message.senderId, type: message.type, - content: removeQuotePayload(message.content), - }; -}; + content: removeQuotePayload(message.content) + } +} /** 从已序列化 message.content 中解出 quote;非 JSON / 无 quote 返回 null */ export const getQuoteFromMessage = (content: string): null | QuoteMessage => { // 长会话每条消息渲染都走 quote computed,非引用消息字符串预扫直接返回,免一次 JSON.parse if (!content || !content.includes('"quote"')) { - return null; + return null } - const parsed = parseMessage(content); - return parsed?.quote ?? null; -}; + const parsed = parseMessage(content) + return parsed?.quote ?? null +} // ==================== 撤回 ==================== @@ -633,136 +582,133 @@ export const getQuoteFromMessage = (content: string): null | QuoteMessage => { */ export const parseRecallMessageId = (content: string): number => { try { - const parsed = JSON.parse(content); - return parsed?.messageId === null ? 0 : Number(parsed.messageId); + const parsed = JSON.parse(content) + return parsed?.messageId != null ? Number(parsed.messageId) : 0 } catch { - return 0; + return 0 } -}; +} // ==================== 新消息提示音 ==================== +import tipAudioUrl from '#/assets/audio/im/message-tip.mp3' + /** * 新消息提示音,带 1 秒节流:短时间内多条消息只响一次,避免疲劳轰炸。 * * 浏览器自动播放策略要求页面有过用户交互;若无法播放会静默失败。 */ -let __lastPlayAudioTipAt = 0; -let __tipAudio: HTMLAudioElement | null = null; +let __lastPlayAudioTipAt = 0 +let __tipAudio: HTMLAudioElement | null = null export const playAudioTip = () => { - const now = Date.now(); + const now = Date.now() if (now - __lastPlayAudioTipAt < 1000) { - return; + return } - __lastPlayAudioTipAt = now; + __lastPlayAudioTipAt = now try { if (!__tipAudio) { - __tipAudio = new Audio(tipAudioUrl); - __tipAudio.preload = 'auto'; + __tipAudio = new Audio(tipAudioUrl) + __tipAudio.preload = 'auto' } - __tipAudio.currentTime = 0; - const playPromise = __tipAudio.play(); + __tipAudio.currentTime = 0 + const playPromise = __tipAudio.play() if (playPromise && typeof playPromise.catch === 'function') { - playPromise.catch((error) => - console.debug('[IM] playAudioTip 失败', error), - ); + playPromise.catch((e) => console.debug('[IM] playAudioTip 失败', e)) } - } catch (error) { - console.debug('[IM] playAudioTip 失败', error); + } catch (e) { + console.debug('[IM] playAudioTip 失败', e) } -}; +} // ==================== 文件图标 ==================== /** * 按文件扩展名挑文件图标 + 颜色,对齐微信观感 * - * message-item.vue(主气泡)和 reply-preview.vue(引用预览)共用同一份映射,避免视觉两处不一致 + * MessageItem.vue(主气泡)和 ReplyPreview.vue(引用预览)共用同一份映射,避免视觉两处不一致 */ -export function getFileIconInfo(filename: string | undefined): { - color: string; - icon: string; -} { - const ext = (filename || '').split('.').pop()?.toLowerCase() || ''; +export function getFileIconInfo(filename: string | undefined): { color: string; icon: string; } { + const ext = (filename || '').split('.').pop()?.toLowerCase() || '' if (ext === 'pdf') { - return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }; + return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' } } if (['doc', 'docx'].includes(ext)) { - return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }; + return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' } } if (['xls', 'xlsx'].includes(ext)) { - return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }; + return { icon: 'ant-design:file-excel-filled', color: '#1f7244' } } if (['ppt', 'pptx'].includes(ext)) { - return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }; + return { icon: 'ant-design:file-ppt-filled', color: '#d24726' } } if (['7z', 'gz', 'rar', 'tar', 'zip'].includes(ext)) { - return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }; + return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' } } if (['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp'].includes(ext)) { - return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }; + return { icon: 'ant-design:file-image-filled', color: '#9c27b0' } } if (['avi', 'flv', 'mkv', 'mov', 'mp4', 'wmv'].includes(ext)) { - return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }; + return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' } } if (['aac', 'flac', 'mp3', 'ogg', 'wav'].includes(ext)) { - return { icon: 'ant-design:audio-filled', color: '#9c27b0' }; + return { icon: 'ant-design:audio-filled', color: '#9c27b0' } } if (['json', 'log', 'md', 'txt', 'xml'].includes(ext)) { - return { icon: 'ant-design:file-text-filled', color: '#909399' }; + return { icon: 'ant-design:file-text-filled', color: '#909399' } } - return { icon: 'ant-design:file-filled', color: '#909399' }; + return { icon: 'ant-design:file-filled', color: '#909399' } } // ==================== 管理后台展示工具 ==================== /** 详情弹窗里把 content JSON 美化成 2 缩进 */ export const formatJson = (content?: string): string => { - if (!content) return ''; + if (!content) return '' try { - return JSON.stringify(JSON.parse(content), null, 2); + return JSON.stringify(JSON.parse(content), null, 2) } catch { - return content; + return content } -}; +} // ==================== 群广播事件 payload + 文案 ==================== // 群广播事件 payload;对齐后端 GroupNotificationMessage 子类聚合字段 export type GroupNotificationPayload = { - addSource?: number; + addSource?: number // 全群封禁 - banned?: boolean; - displayUserName?: string; + banned?: boolean + displayUserName?: string // 自由进群事件 - entrantUserId?: number; - memberUserIds?: number[]; + entrantUserId?: number + memberUserIds?: number[] // PIN 事件携带的被置顶消息展示数据 message?: { - atUserIds?: number[]; - content: string; - groupId: number; - id: number; - receiverUserIds?: number[]; - senderId: number; - sendTime: string; - type: number; - }; - messageId?: number; + atUserIds?: number[] + content: string + groupId: number + id: number + receiverUserIds?: number[] + senderId: number + sendTime: string + type: number + } + messageId?: number // 禁言事件 - mutedUserId?: number; - muteEndTime?: string; - newAvatar?: string; - newJoinApproval?: boolean; - newName?: string; - newNotice?: string; - newOwnerUserId?: number; - oldAvatar?: string; - oldJoinApproval?: boolean; - oldName?: string; - oldNotice?: string; - operatorUserId?: number; -}; + mutedUserId?: number + muteEndTime?: string + newAvatar?: string + newJoinApproval?: boolean + newName?: string + newNotice?: string + newOwnerUserId?: number + oldAvatar?: string + oldJoinApproval?: boolean + oldName?: string + oldNotice?: string + operatorUserId?: number +} /** * 群广播事件 segments @@ -771,221 +717,155 @@ export type GroupNotificationPayload = { * operatorNameOverride 仅覆盖 operator 段文案,mention userId 仍用 payload.operatorUserId */ export function resolveGroupNotificationSegments( - message: { content?: string; targetId?: number; type?: number }, + message: { content?: string; targetId?: number; type?: number; }, resolveName: (userId: number) => string, - operatorNameOverride?: string, + operatorNameOverride?: string ): TipSegment[] { - let payload: GroupNotificationPayload; + let payload: GroupNotificationPayload try { - payload = JSON.parse(message.content || '{}'); + payload = JSON.parse(message.content || '{}') } catch { - return []; + return [] } // ENTER 主语是 entrant 而非 operator,独立处理;其它 case 都以 operatorUserId 为主语 if (message.type === ImContentType.GROUP_MEMBER_ENTER) { - const entrantId = payload.entrantUserId ?? payload.operatorUserId; - return entrantId - ? [tipMention(entrantId, resolveName(entrantId)), tipText(' 加入了群聊')] - : []; + const entrantId = payload.entrantUserId ?? payload.operatorUserId + return entrantId ? [tipMention(entrantId, resolveName(entrantId)), tipText(' 加入了群聊')] : [] } if (!payload.operatorUserId) { - return []; + return [] } const operatorSegment = tipMention( payload.operatorUserId, - operatorNameOverride ?? resolveName(payload.operatorUserId), - ); - const memberSegments = joinMentionSegments( - payload.memberUserIds || [], - '、', - resolveName, - ); + operatorNameOverride ?? resolveName(payload.operatorUserId) + ) + const memberSegments = joinMentionSegments(payload.memberUserIds || [], '、', resolveName) switch (message.type) { - case ImContentType.GROUP_ADMIN_ADD: { - return [ - operatorSegment, - tipText(' 将 '), - ...memberSegments, - tipText(' 设为管理员'), - ]; - } - case ImContentType.GROUP_ADMIN_REMOVE: { - return [ - operatorSegment, - tipText(' 撤销了 '), - ...memberSegments, - tipText(' 的管理员身份'), - ]; - } - case ImContentType.GROUP_BANNED: { - return [ - operatorSegment, - tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群'), - ]; - } - case ImContentType.GROUP_CANCEL_MUTED: { - return [operatorSegment, tipText(' 关闭了全群禁言')]; - } - case ImContentType.GROUP_CREATE: { - return [operatorSegment, tipText(' 创建了群聊')]; - } - case ImContentType.GROUP_DISSOLVE: { - return [operatorSegment, tipText(' 解散了群聊')]; - } - case ImContentType.GROUP_INFO_UPDATE: { + case ImContentType.GROUP_ADMIN_ADD: + return [operatorSegment, tipText(' 将 '), ...memberSegments, tipText(' 设为管理员')] + case ImContentType.GROUP_ADMIN_REMOVE: + return [operatorSegment, tipText(' 撤销了 '), ...memberSegments, tipText(' 的管理员身份')] + case ImContentType.GROUP_BANNED: + return [operatorSegment, tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群')] + case ImContentType.GROUP_CANCEL_MUTED: + return [operatorSegment, tipText(' 关闭了全群禁言')] + case ImContentType.GROUP_CREATE: + return [operatorSegment, tipText(' 创建了群聊')] + case ImContentType.GROUP_DISSOLVE: + return [operatorSegment, tipText(' 解散了群聊')] + case ImContentType.GROUP_INFO_UPDATE: return payload.newAvatar ? [operatorSegment, tipText(' 更换了群头像')] - : [operatorSegment, tipText(' 更新了群信息')]; - } - case ImContentType.GROUP_MEMBER_CANCEL_MUTED: { + : [operatorSegment, tipText(' 更新了群信息')] + case ImContentType.GROUP_MEMBER_CANCEL_MUTED: return payload.mutedUserId ? [ operatorSegment, tipText(' 解除了 '), tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)), - tipText(' 的禁言'), + tipText(' 的禁言') ] - : []; - } - case ImContentType.GROUP_MEMBER_INVITE: { - return [ - operatorSegment, - tipText(' 邀请 '), - ...memberSegments, - tipText(' 加入群聊'), - ]; - } - case ImContentType.GROUP_MEMBER_KICK: { - return [operatorSegment, tipText(' 移出了 '), ...memberSegments]; - } - case ImContentType.GROUP_MEMBER_MUTED: { + : [] + case ImContentType.GROUP_MEMBER_INVITE: + return [operatorSegment, tipText(' 邀请 '), ...memberSegments, tipText(' 加入群聊')] + case ImContentType.GROUP_MEMBER_KICK: + return [operatorSegment, tipText(' 移出了 '), ...memberSegments] + case ImContentType.GROUP_MEMBER_MUTED: return payload.mutedUserId ? [ operatorSegment, tipText(' 将 '), tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)), - tipText(' 禁言'), + tipText(' 禁言') ] - : []; - } - case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: { - return [ - operatorSegment, - tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`), - ]; - } - case ImContentType.GROUP_MEMBER_QUIT: { - return [operatorSegment, tipText(' 退出了群聊')]; - } - case ImContentType.GROUP_MESSAGE_PIN: { - return [operatorSegment, tipText(' 置顶了一条消息')]; - } - case ImContentType.GROUP_MESSAGE_UNPIN: { - return [operatorSegment, tipText(' 取消了一条置顶消息')]; - } - case ImContentType.GROUP_MUTED: { - return [operatorSegment, tipText(' 开启了全群禁言')]; - } - case ImContentType.GROUP_NAME_UPDATE: { - return [ - operatorSegment, - tipText(` 将群名修改为 "${payload.newName ?? ''}"`), - ]; - } - case ImContentType.GROUP_NOTICE_UPDATE: { - return [operatorSegment, tipText(' 更新了群公告')]; - } - case ImContentType.GROUP_OWNER_TRANSFER: { + : [] + case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: + return [operatorSegment, tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`)] + case ImContentType.GROUP_MEMBER_QUIT: + return [operatorSegment, tipText(' 退出了群聊')] + case ImContentType.GROUP_MESSAGE_PIN: + return [operatorSegment, tipText(' 置顶了一条消息')] + case ImContentType.GROUP_MESSAGE_UNPIN: + return [operatorSegment, tipText(' 取消了一条置顶消息')] + case ImContentType.GROUP_MUTED: + return [operatorSegment, tipText(' 开启了全群禁言')] + case ImContentType.GROUP_NAME_UPDATE: + return [operatorSegment, tipText(` 将群名修改为 "${payload.newName ?? ''}"`)] + case ImContentType.GROUP_NOTICE_UPDATE: + return [operatorSegment, tipText(' 更新了群公告')] + case ImContentType.GROUP_OWNER_TRANSFER: return payload.newOwnerUserId ? [ operatorSegment, tipText(' 已将群主转让给 '), - tipMention( - payload.newOwnerUserId, - resolveName(payload.newOwnerUserId), - ), + tipMention(payload.newOwnerUserId, resolveName(payload.newOwnerUserId)) ] - : []; - } - default: { - return []; - } + : [] + default: + return [] } } /** 群广播事件中文文案 */ export function resolveGroupNotificationText( - message: { content?: string; targetId?: number; type?: number }, + message: { content?: string; targetId?: number; type?: number; }, resolveName: (userId: number) => string, - operatorNameOverride?: string, + operatorNameOverride?: string ): string { return segmentsToText( - resolveGroupNotificationSegments( - message, - resolveName, - operatorNameOverride, - ), - ); + resolveGroupNotificationSegments(message, resolveName, operatorNameOverride) + ) } // ==================== 好友事件 ==================== /** 会话内好友事件 segments */ -export function resolveFriendNotificationSegments(message: { - type?: number; -}): TipSegment[] { +export function resolveFriendNotificationSegments(message: { type?: number }): TipSegment[] { switch (message.type) { - case ImContentType.FRIEND_ADD: { - return [tipText('你们已经是好友了,开始聊天吧')]; - } - case ImContentType.FRIEND_DELETE: { - return [tipText('你已删除好友')]; - } - default: { - return []; - } + case ImContentType.FRIEND_ADD: + return [tipText('你们已经是好友了,开始聊天吧')] + case ImContentType.FRIEND_DELETE: + return [tipText('你已删除好友')] + default: + return [] } } /** 会话内好友事件文案:FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示气泡,文案固定不依赖 payload */ -export function resolveFriendNotificationText(message: { - type?: number; -}): string { - return segmentsToText(resolveFriendNotificationSegments(message)); +export function resolveFriendNotificationText(message: { type?: number }): string { + return segmentsToText(resolveFriendNotificationSegments(message)) } // ==================== RTC 通话事件 ==================== // RTC_CALL_START payload;仅群聊;用于聊天 tip 文案「{inviter} 发起了{voice/video}通话」 export type RtcCallStartPayload = { - conversationType?: number; - inviterAvatar?: string; - inviterNickname?: string; - inviterUserId?: number; - mediaType?: number; - room?: string; -}; + conversationType?: number + inviterAvatar?: string + inviterNickname?: string + inviterUserId?: number + mediaType?: number + room?: string +} // RTC_CALL_END payload;私聊准气泡 + 群聊「通话已结束 [时长 X]」共用 export type RtcCallEndPayload = { - conversationType?: number; - durationSeconds?: number; - endReason?: number; - mediaType?: number; - operatorAvatar?: string; - operatorNickname?: string; - operatorUserId?: number; - room?: string; -}; + conversationType?: number + durationSeconds?: number + endReason?: number + mediaType?: number + operatorAvatar?: string + operatorNickname?: string + operatorUserId?: number + room?: string +} /** 解析 RTC_CALL_START / RTC_CALL_END 消息 content;解析失败返回 null */ export function parseRtcCallPayload( - content?: string, + content?: string ): null | (RtcCallEndPayload & RtcCallStartPayload) { - return content - ? parseMessage(content) - : null; + return content ? parseMessage(content) : null } /** @@ -996,33 +876,31 @@ export function parseRtcCallPayload( * 私聊气泡走 {@link resolveRtcCallPrivateBubbleText} */ export function resolveRtcCallTipSegments(message: { - content?: string; - selfSend?: boolean; - type?: number; + content?: string + selfSend?: boolean + type?: number }): TipSegment[] { - const payload = parseRtcCallPayload(message.content); + const payload = parseRtcCallPayload(message.content) if (!payload) { - return []; + return [] } if (message.type === ImContentType.RTC_CALL_START) { return payload.inviterUserId ? [ tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)), - tipText(' 发起了语音通话'), + tipText(' 发起了语音通话') ] - : []; + : [] } if (message.type === ImContentType.RTC_CALL_END) { - return [tipText('语音通话已经结束')]; + return [tipText('语音通话已经结束')] } - return []; + return [] } /** 取 RTC 通话发起人展示名;昵称为空时回退「用户 {id}」 */ function resolveRtcInviterLabel(payload: RtcCallStartPayload): string { - return ( - payload.inviterNickname?.trim() || `用户 ${payload.inviterUserId ?? ''}` - ); + return payload.inviterNickname?.trim() || `用户 ${payload.inviterUserId ?? ''}` } /** @@ -1033,22 +911,22 @@ function resolveRtcInviterLabel(payload: RtcCallStartPayload): string { * 群聊:START「{inviter} 发起了语音通话」、END「语音通话已经结束」,与聊天 tip 同源 */ export function resolveRtcCallLastContent( - message: { content?: string; type?: number }, - conversationType: number, + message: { content?: string; type?: number; }, + conversationType: number ): string { if (conversationType === ImConversationType.PRIVATE) { - return '[语音通话]'; + return '[语音通话]' } if (message.type === ImContentType.RTC_CALL_END) { - return '语音通话已经结束'; + return '语音通话已经结束' } if (message.type === ImContentType.RTC_CALL_START) { - const payload = parseRtcCallPayload(message.content); + const payload = parseRtcCallPayload(message.content) if (payload) { - return `${resolveRtcInviterLabel(payload)} 发起了语音通话`; + return `${resolveRtcInviterLabel(payload)} 发起了语音通话` } } - return ''; + return '' } /** @@ -1063,55 +941,31 @@ export function resolveRtcCallLastContent( * NO_ANSWER → 操作者「未接听」/ 另一方「对方未接听」(振铃超时 Job 触发) * ERROR → 「通话中断 [N]」(接通后异常断开;duration > 0 时带时长) */ -export function resolveRtcCallPrivateBubbleText( - payload: null | RtcCallEndPayload, -): string { +export function resolveRtcCallPrivateBubbleText(payload: null | RtcCallEndPayload): string { if (!payload) { - return '通话已结束'; + return '通话已结束' } - const duration = payload.durationSeconds ?? 0; - const hasDuration = duration > 0; - const isOperator = payload.operatorUserId === getCurrentUserId(); + const duration = payload.durationSeconds ?? 0 + const hasDuration = duration > 0 + const isOperator = payload.operatorUserId === getCurrentUserId() switch (payload.endReason) { - case ImRtcCallEndReason.BUSY: { - return isOperator ? '忙线未接听' : '对方忙线中'; - } - case ImRtcCallEndReason.CANCEL: { - return isOperator ? '已取消' : '对方已取消'; - } - case ImRtcCallEndReason.ERROR: { - return hasDuration - ? `通话中断 ${formatCallDuration(duration)}` - : '通话中断'; - } - case ImRtcCallEndReason.HANGUP: { - return hasDuration - ? `通话时长 ${formatCallDuration(duration)}` - : '通话中断'; - } - case ImRtcCallEndReason.NO_ANSWER: { - return isOperator ? '未接听' : '对方未接听'; - } - case ImRtcCallEndReason.REJECT: { - return isOperator ? '已拒绝' : '对方已拒绝'; - } - default: { - return hasDuration - ? `通话时长 ${formatCallDuration(duration)}` - : '通话已结束'; - } + case ImRtcCallEndReason.BUSY: + return isOperator ? '忙线未接听' : '对方忙线中' + case ImRtcCallEndReason.CANCEL: + return isOperator ? '已取消' : '对方已取消' + case ImRtcCallEndReason.ERROR: + return hasDuration ? `通话中断 ${formatCallDuration(duration)}` : '通话中断' + case ImRtcCallEndReason.HANGUP: + return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话中断' + case ImRtcCallEndReason.NO_ANSWER: + return isOperator ? '未接听' : '对方未接听' + case ImRtcCallEndReason.REJECT: + return isOperator ? '已拒绝' : '对方已拒绝' + default: + return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话已结束' } } -/** 会话内通话事件文案 */ -export function resolveRtcCallTipText(message: { - content?: string; - selfSend?: boolean; - type?: number; -}): string { - return segmentsToText(resolveRtcCallTipSegments(message)); -} - /** * RTC_CALL_END 结束原因兜底文案;前端 toast / console 兜底用 *

@@ -1119,23 +973,17 @@ export function resolveRtcCallTipText(message: { */ export function resolveCallEndReasonText(reason: number | undefined): string { switch (reason) { - case ImRtcCallEndReason.BUSY: { - return '对方忙线中'; - } - case ImRtcCallEndReason.CANCEL: { - return '对方已取消'; - } - case ImRtcCallEndReason.ERROR: { - return '通话异常'; - } - case ImRtcCallEndReason.HANGUP: { - return '通话已结束'; - } - case ImRtcCallEndReason.REJECT: { - return '对方已拒绝'; - } - default: { - return '通话已断开'; - } + case ImRtcCallEndReason.BUSY: + return '对方忙线中' + case ImRtcCallEndReason.CANCEL: + return '对方已取消' + case ImRtcCallEndReason.ERROR: + return '通话异常' + case ImRtcCallEndReason.HANGUP: + return '通话已结束' + case ImRtcCallEndReason.REJECT: + return '对方已拒绝' + default: + return '通话已断开' } } diff --git a/apps/web-antdv-next/src/views/im/utils/pull.ts b/apps/web-antdv-next/src/views/im/utils/pull.ts index 6524af26b..826de0202 100644 --- a/apps/web-antdv-next/src/views/im/utils/pull.ts +++ b/apps/web-antdv-next/src/views/im/utils/pull.ts @@ -1,6 +1,4 @@ -import { isUndefined } from '@vben/utils'; - -import { getDb } from './db'; +import type { DbClient } from './db' /** * IM 状态事件补偿(增量拉取)通用编排 @@ -11,29 +9,29 @@ import { getDb } from './db'; */ /** 增量拉取游标:上次拉到的位置 */ -export interface PullCursor { - lastUpdateTime?: number; - lastId?: number; +interface PullCursor { + lastUpdateTime?: number + lastId?: number } /** 可作为游标的拉取记录:服务端按 update_time + id 返回,客户端取最后一条推进游标 */ interface PullRecord { - id: number; - updateTime?: number; + id: number + updateTime?: number } /** 单次拉取条数(与后端 limit 上限对齐) */ -const PULL_PAGE_SIZE = 100; +const PULL_PAGE_SIZE = 100 /** 单轮最多翻页数,兜底防御异常游标导致的死循环 */ -const PULL_MAX_PAGES = 100; +const PULL_MAX_PAGES = 100 /** 状态事件拉取回扫窗口,覆盖同秒内旧行更新和客户端 / 服务端时钟精度差 */ -const PULL_OVERLAP_MS = 5000; +const PULL_OVERLAP_MS = 5000 /** 消息类 minId 拉取的单轮翻页上限,兜底防御异常游标死翻;消息量可能远大于状态事件,放宽到 1000 */ -const MIN_ID_PULL_MAX_PAGES = 1000; +const MIN_ID_PULL_MAX_PAGES = 1000 /** 读取某模块的拉取游标;无则返回空游标(首次拉全量) */ -export async function getPullCursor(key: string): Promise { - return (await getDb().getSetting(key)) ?? {}; +async function getPullCursor(db: DbClient, key: string): Promise { + return (await db.getSetting(key)) ?? {} } /** @@ -45,72 +43,51 @@ export async function getPullCursor(key: string): Promise { * 此时不推进游标并终止本轮,避免游标越过未落地的记录、导致后续增量永久漏拉。可返回 Promise */ export async function runIncrementalPull( + db: DbClient, cursorKey: string, - fetchPage: (params: { - lastId?: number; - lastUpdateTime?: number; - limit: number; - }) => Promise, - apply: (records: T[]) => boolean | Promise, - isActive?: () => boolean, + fetchPage: (params: { lastId?: number; lastUpdateTime?: number; limit: number }) => Promise, + apply: (records: T[]) => boolean | Promise ): Promise { - const storedCursor = await getPullCursor(cursorKey); - const highWater = { ...storedCursor }; - let cursor = isUndefined(storedCursor.lastUpdateTime) - ? {} - : { - lastUpdateTime: Math.max( - 0, - storedCursor.lastUpdateTime - PULL_OVERLAP_MS, - ), - lastId: 0, - }; + const storedCursor = await getPullCursor(db, cursorKey) + const highWater = { ...storedCursor } + let cursor = + storedCursor.lastUpdateTime != null + ? { lastUpdateTime: Math.max(0, storedCursor.lastUpdateTime - PULL_OVERLAP_MS), lastId: 0 } + : {} for (let page = 0; page < PULL_MAX_PAGES; page++) { - if (isActive && !isActive()) { - return; - } const list = await fetchPage({ lastUpdateTime: cursor.lastUpdateTime, lastId: cursor.lastId, - limit: PULL_PAGE_SIZE, - }); - if (isActive && !isActive()) { - return; - } - if (list.length > 0) { + limit: PULL_PAGE_SIZE + }) + if (list.length) { // apply 未完全落地(返回 false)时直接终止:游标只能跟着已落地的数据走,否则会跳过本页记录 if ((await apply(list)) === false) { - return; - } - if (isActive && !isActive()) { - return; + return } // 推进游标到本页最后一条并持久化:下次从这里接着拉 - const last = list[list.length - 1]; - if (!last) { - return; + const last = list[list.length - 1]! + if (last.updateTime == null) { + return } - if (isUndefined(last.updateTime)) { - return; - } - cursor = { lastUpdateTime: last.updateTime, lastId: last.id }; + cursor = { lastUpdateTime: last.updateTime, lastId: last.id } if ( - isUndefined(highWater.lastUpdateTime) || + highWater.lastUpdateTime == null || cursor.lastUpdateTime > highWater.lastUpdateTime || (cursor.lastUpdateTime === highWater.lastUpdateTime && cursor.lastId > (highWater.lastId ?? 0)) ) { - highWater.lastUpdateTime = cursor.lastUpdateTime; - highWater.lastId = cursor.lastId; - await getDb().setSetting(cursorKey, highWater); + highWater.lastUpdateTime = cursor.lastUpdateTime + highWater.lastId = cursor.lastId + await db.setSetting(cursorKey, highWater) } } // 不满一页 = 没有更多变更 if (list.length < PULL_PAGE_SIZE) { - return; + return } } - console.warn(`[IM pull] ${cursorKey} 达到单轮翻页上限,提前结束本轮补偿`); + console.warn(`[IM pull] ${cursorKey} 达到单轮翻页上限,提前结束本轮补偿`) } /** @@ -127,45 +104,40 @@ export async function runIncrementalPull( * @param maxPages 单轮翻页上限 */ export async function runMinIdPull(options: { - applyPage: ( - records: T[], - nextMinId?: number, - ) => Promise | Promise; - fetchPage: (params: { minId: number; size: number }) => Promise; - initialMinId: number; - isActive?: () => boolean; - maxPages?: number; - pageSize: number; + applyPage: (records: T[], nextMinId?: number) => Promise + fetchPage: (params: { minId: number; size: number }) => Promise + initialMinId: number + isActive?: () => boolean + maxPages?: number + pageSize: number }): Promise { - const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options; - const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES; - let minId = initialMinId || 0; + const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options + const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES + let minId = initialMinId || 0 for (let page = 0; page < maxPages; page++) { if (isActive && !isActive()) { - return; + return } - const list = await fetchPage({ minId, size: pageSize }); + const list = await fetchPage({ minId, size: pageSize }) // 拉取期间取消 / 切账号:丢弃本批不入库,也不再翻页 if (isActive && !isActive()) { - return; + return } if (!list || list.length === 0) { - return; + return } // 本批最大消息 id 作为下次游标;无有效 id 则本批 apply 后停(无法继续翻页) - const validIds = list - .map((record) => record.id) - .filter((id): id is number => id !== null); - const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined; + const validIds = list.map((record) => record.id).filter((id): id is number => id != null) + const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined // applyPage 返回 false:本页未落地(如入库失败),不推进游标并终止,避免漏消息 if ((await applyPage(list, nextMinId)) === false) { - return; + return } // 无有效 id,或游标没前进(后端契约是 id > minId,理论不会出现):停,防御死翻 - if (isUndefined(nextMinId) || nextMinId <= minId) { - return; + if (nextMinId == null || nextMinId <= minId) { + return } - minId = nextMinId; + minId = nextMinId } - console.warn('[IM pull] runMinIdPull 达到单轮翻页上限,提前结束本轮'); + console.warn('[IM pull] runMinIdPull 达到单轮翻页上限,提前结束本轮') } diff --git a/apps/web-ele/src/views/im/home/components/friend/friend-add-dialog.vue b/apps/web-ele/src/views/im/home/components/friend/friend-add-dialog.vue index 73d91f82a..75207b6e0 100644 --- a/apps/web-ele/src/views/im/home/components/friend/friend-add-dialog.vue +++ b/apps/web-ele/src/views/im/home/components/friend/friend-add-dialog.vue @@ -67,6 +67,8 @@ const dialogTitle = computed(() => const presetMode = computed(() => !!presetUser.value); function resetAll() { + loading.value = false; + submitting.value = false; keyword.value = ''; users.value = []; searched.value = false; @@ -102,14 +104,17 @@ function buildPresetApplyContent(): string { /** 按昵称搜索用户:空关键字直接清空结果 */ async function handleSearch() { searched.value = true; - if (!keyword.value.trim()) { + const query = keyword.value.trim(); + if (!query) { users.value = []; + loading.value = false; return; } loading.value = true; try { - users.value = - (await getSimpleUserListByNickname(keyword.value.trim())) || []; + users.value = (await getSimpleUserListByNickname(query)) || []; + } catch (error) { + console.warn('[IM FriendAddDialog] 搜索用户失败', error); } finally { loading.value = false; } @@ -132,25 +137,27 @@ function backToSearch() { /** 提交好友申请:返回 requestId 走「等待验证」;返回 null 表示后端命中「单向好友静默重启」分支,已直接成为好友 */ async function handleSubmitApply() { - if (!targetUser.value) { + const target = targetUser.value; + if (!target) { return; } // 预校验:不能加自己(搜索列表已过滤,这里兜底 presetUser / 名片入口等场景) - if (targetUser.value.id === currentUserId.value) { + if (target.id === currentUserId.value) { ElMessage.warning('不能添加自己为好友'); return; } + const payload = { + toUserId: target.id, + applyContent: applyContent.value.trim() || undefined, + displayName: displayName.value.trim() || undefined, + addSource: addSource.value, + }; submitting.value = true; try { - const requestId = await friendStore.applyFriendRequest({ - toUserId: targetUser.value.id, - applyContent: applyContent.value.trim() || undefined, - displayName: displayName.value.trim() || undefined, - addSource: addSource.value, - }); + const requestId = await friendStore.applyFriendRequest(payload); // silent 分支(已是单向好友被静默重启):主动 fetchFriendInfo 入库,不依赖 WS FRIEND_ADD 推送,避免丢推时列表看不到 if (requestId === null) { - await friendStore.fetchFriendInfo(targetUser.value.id); + await friendStore.fetchFriendInfo(target.id); } ElMessage.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友'); visible.value = false; diff --git a/apps/web-ele/src/views/im/home/components/group/group-info-card.vue b/apps/web-ele/src/views/im/home/components/group/group-info-card.vue index 91724407c..929170034 100644 --- a/apps/web-ele/src/views/im/home/components/group/group-info-card.vue +++ b/apps/web-ele/src/views/im/home/components/group/group-info-card.vue @@ -66,6 +66,7 @@ function handleChat(group: GroupLite) { /** 加入群聊:先关浮层(避免与 prompt 的 mask 互相遮挡)→ 弹申请理由(可选)→ applyJoinGroup */ async function handleApply(group: GroupLite) { + const groupId = group.id; handleClose(); let applyContent: string; try { @@ -86,12 +87,16 @@ async function handleApply(group: GroupLite) { } catch { return; } - await applyJoinGroup({ - groupId: group.id, - applyContent: applyContent || undefined, - addSource: ImGroupAddSource.SHARE_LINK, - }); - ElMessage.success('加群申请已发送'); + try { + await applyJoinGroup({ + groupId, + applyContent: applyContent || undefined, + addSource: ImGroupAddSource.SHARE_LINK, + }); + ElMessage.success('加群申请已发送'); + } catch (error) { + console.warn('[IM GroupInfoCard] 申请加群失败', error); + } } diff --git a/apps/web-ele/src/views/im/home/components/group/group-info.vue b/apps/web-ele/src/views/im/home/components/group/group-info.vue index d402a306c..4c9d29a5a 100644 --- a/apps/web-ele/src/views/im/home/components/group/group-info.vue +++ b/apps/web-ele/src/views/im/home/components/group/group-info.vue @@ -78,7 +78,7 @@ const memberCountText = computed(() => { return count ? `${count} 位成员` : ''; }); -/** member 切群 / 首挂:拉成员;竞态用 group.id 比对丢弃陈旧响应避免上一条群成员错位 */ +/** member 切群 / 首挂:拉取群成员 */ watch( () => [props.group?.id, isMember.value] as const, async ([id, member]) => { @@ -86,13 +86,14 @@ watch( if (!id || !member) { return; } - const list = await groupStore.fetchGroupMemberList(id, true); - if (props.group?.id !== id) { - return; + try { + const list = await groupStore.fetchGroupMemberList(id, true); + members.value = list.map((m) => + convertGroupMemberLite(m, friendStore.getFriend(m.userId)), + ); + } catch (error) { + console.warn('[IM GroupInfo] 群成员加载失败', { groupId: id }, error); } - members.value = list.map((m) => - convertGroupMemberLite(m, friendStore.getFriend(m.userId)), - ); }, { immediate: true }, ); diff --git a/apps/web-ele/src/views/im/home/components/group/group-owner-transfer-dialog.vue b/apps/web-ele/src/views/im/home/components/group/group-owner-transfer-dialog.vue index b8e37a247..36b1f2924 100644 --- a/apps/web-ele/src/views/im/home/components/group/group-owner-transfer-dialog.vue +++ b/apps/web-ele/src/views/im/home/components/group/group-owner-transfer-dialog.vue @@ -52,12 +52,15 @@ const newOwner = computed(() => { /** 二次确认转让:转让后旧群主降为普通成员,无法撤销 */ async function handleOk() { - if (!groupId.value || !newOwner.value) { + const targetGroupId = groupId.value; + const newOwnerUserId = newOwner.value?.userId; + const newOwnerName = newOwner.value?.showName; + if (!targetGroupId || !newOwnerUserId) { return; } try { await confirm( - `确定将群主转让给 ${newOwner.value.showName}?转让后你将变为普通成员,无法撤销。`, + `确定将群主转让给 ${newOwnerName}?转让后你将变为普通成员,无法撤销。`, '确认转让群主', ); } catch { @@ -66,12 +69,14 @@ async function handleOk() { submitting.value = true; try { await transferGroupOwner({ - id: groupId.value, - newOwnerUserId: newOwner.value.userId, + id: targetGroupId, + newOwnerUserId, }); ElMessage.success('群主转让成功'); emit('reload'); visible.value = false; + } catch (error) { + console.warn('[IM GroupOwnerTransferDialog] 转让群主失败', error); } finally { submitting.value = false; } diff --git a/apps/web-ele/src/views/im/home/components/rtc/rtc-call-container.vue b/apps/web-ele/src/views/im/home/components/rtc/rtc-call-container.vue index d004653fd..fb64cda32 100644 --- a/apps/web-ele/src/views/im/home/components/rtc/rtc-call-container.vue +++ b/apps/web-ele/src/views/im/home/components/rtc/rtc-call-container.vue @@ -1,11 +1,13 @@ diff --git a/apps/web-ele/src/views/im/home/components/user/user-info.vue b/apps/web-ele/src/views/im/home/components/user/user-info.vue index 2c23d132b..9d2a49792 100644 --- a/apps/web-ele/src/views/im/home/components/user/user-info.vue +++ b/apps/web-ele/src/views/im/home/components/user/user-info.vue @@ -108,7 +108,6 @@ const remarkInputRef = ref void; select?: () => void }>( * user.id 变化的统一处理: * 1. 起手用 prop 兜底首屏(full = props.user),再 getSimpleUser 命中后合并替换 * 2. 顺便复位备注编辑态,避免上一个用户的脏输入泄漏到下一个 - * 3. 竞态用 id 比对丢弃陈旧响应 */ watch( () => props.user?.id, @@ -119,9 +118,6 @@ watch( return; } const data = (await getSimpleUser(id)) as User; - if (props.user?.id !== id) { - return; - } full.value = { ...props.user, ...data }; }, { immediate: true }, @@ -238,10 +234,11 @@ async function handleBlock() { /** 移出黑名单:操作温和不弹 confirm;后端 FRIEND_UNBLOCK 推到时由 dispatcher 同步多端 */ async function handleUnblock() { - if (!props.user?.id) { + const targetId = props.user?.id; + if (!targetId) { return; } - await friendStore.unblockFriend(props.user.id); + await friendStore.unblockFriend(targetId); ElMessage.success('已移出黑名单'); } @@ -275,7 +272,12 @@ async function handleDeleteFriend() { } catch { return; } - await friendStore.deleteFriend(target.id, clearConversation.value); + try { + await friendStore.deleteFriend(target.id, clearConversation.value); + } catch (error) { + console.warn('[IM UserInfo] 删除好友失败', error); + return; + } ElMessage.success('已删除好友'); emit('deleted', target); } diff --git a/apps/web-ele/src/views/im/home/composables/useLiveKitRoom.ts b/apps/web-ele/src/views/im/home/composables/useLiveKitRoom.ts index ad1604615..0b541e206 100644 --- a/apps/web-ele/src/views/im/home/composables/useLiveKitRoom.ts +++ b/apps/web-ele/src/views/im/home/composables/useLiveKitRoom.ts @@ -1,66 +1,78 @@ -import type { - LocalParticipant, - Participant, - RemoteParticipant, -} from 'livekit-client'; - -import { computed, ref, shallowRef } from 'vue'; +import { computed, ref, shallowRef } from 'vue' import { - ConnectionQuality, + type LocalParticipant, + type Participant, + type RemoteParticipant, Room, RoomEvent, Track, - VideoPresets, -} from 'livekit-client'; + VideoPresets +} from 'livekit-client' -type ParticipantEventHandler = (userId: number) => void; +type ParticipantEventHandler = (userId: number) => void + +const roomDisconnectTasks = new WeakMap>() + +/** 同一个物理 Room 只执行一次 listener 清理与断开 */ +function disconnectPhysicalRoom(target: Room): Promise { + const current = roomDisconnectTasks.get(target) + if (current) { + return current + } + const task = Promise.resolve().then(async () => { + target.removeAllListeners() + await target.disconnect() + }) + roomDisconnectTasks.set(target, task) + return task +} /** LiveKit Room 连接 / 设备 / 事件的薄封装;UI 组件只关心响应式状态 */ export function useLiveKitRoom() { /** Room 实例;模块内部状态,不对外暴露,避免调用方误写 */ - const _room = shallowRef(null); + const _room = shallowRef(null) /** 只读 room 引用;调用方仅用于幂等判定 */ - const room = computed(() => _room.value); + const room = computed(() => _room.value) /** 本地参与者;连接成功后赋值 */ - const localParticipant = shallowRef(null); + const localParticipant = shallowRef(null) /** 远端参与者列表;ParticipantConnected / Disconnected 时刷新;shallowRef 避免 Vue 深度代理 SDK class 内部 */ - const remoteParticipants = shallowRef([]); + const remoteParticipants = shallowRef([]) /** 连接状态 */ - const isConnected = ref(false); - /** 连接质量 */ - const connectionQuality = ref(ConnectionQuality.Unknown); + const isConnected = ref(false) /** 麦克风开关 */ - const micEnabled = ref(true); + const micEnabled = ref(true) /** 摄像头开关 */ - const cameraEnabled = ref(false); + const cameraEnabled = ref(false) /** 扬声器开关;浏览器无系统级 API,通过 audio 元素 muted 属性实现远端音频静音 */ - const speakerEnabled = ref(true); + const speakerEnabled = ref(true) /** 屏幕共享开关 */ - const screenShareEnabled = ref(false); + const screenShareEnabled = ref(false) /** 当前是否处于「重连中」;瞬断时 UI 显示提示而不强制结束通话 */ - const reconnecting = ref(false); + const reconnecting = ref(false) /** 远端断开订阅者;通话结束时统一清空 */ - const disconnectedHandlers = new Set<() => void>(); + const disconnectedHandlers = new Set<() => void>() /** 房内某人加入订阅者;主叫端用于从 INVITING 切到 RUNNING */ - const participantConnectedHandlers = new Set(); + const participantConnectedHandlers = new Set() /** 房内某人离开订阅者;用于把 userId 标记为「已退出」从 pending 占位中移除 */ - const participantDisconnectedHandlers = new Set(); + const participantDisconnectedHandlers = new Set() + let connectionOwner = {} // 连接 owner 用于阻断旧 connect/callback 修改新 Room 状态 /** 同步远端参与者列表到响应式数组 */ function syncRemotes(r: Room) { - remoteParticipants.value = [...r.remoteParticipants.values()]; + remoteParticipants.value = Array.from(r.remoteParticipants.values()) } /** 连接 LiveKit Server;audio / video 控制初始默认开关 */ - async function connect( - url: string, - token: string, - opts: { audio?: boolean; video?: boolean }, - ) { + async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) { + const owner = {} + connectionOwner = owner // 新连接前先断开旧 Room;保留本次注册的事件回调 if (_room.value) { - await disconnectRoom(false); + await disconnectRoom(false, false) + } + if (connectionOwner !== owner) { + return null } const r = new Room({ // 按格子尺寸自动选 simulcast 层 @@ -69,117 +81,121 @@ export function useLiveKitRoom() { dynacast: true, // 采集分辨率 720p,确保大格子清晰 videoCaptureDefaults: { - resolution: VideoPresets.h720.resolution, + resolution: VideoPresets.h720.resolution }, // 发布编码上限 1.5 Mbps / 30fps;保留默认 simulcast 三层(180p / 360p / 720p) publishDefaults: { videoEncoding: { maxBitrate: 1_500_000, maxFramerate: 30, - priority: 'high', + priority: 'high' }, // 屏幕共享码率 3 Mbps,文字界面清晰 screenShareEncoding: { maxBitrate: 3_000_000, maxFramerate: 15, - priority: 'medium', - }, - }, - }); - _room.value = r; + priority: 'medium' + } + } + }) + _room.value = r + const isCurrentRoom = () => _room.value === r && connectionOwner === owner r.on(RoomEvent.ParticipantConnected, (rp) => { - syncRemotes(r); - const userId = parseUserId(rp.identity); - if (userId !== null) { - participantConnectedHandlers.forEach((cb) => cb(userId)); + if (!isCurrentRoom()) return + syncRemotes(r) + const userId = parseUserId(rp.identity) + if (userId != null) { + participantConnectedHandlers.forEach((cb) => cb(userId)) } }) .on(RoomEvent.ParticipantDisconnected, (rp) => { - syncRemotes(r); + if (!isCurrentRoom()) return + syncRemotes(r) // 离开的参与者缓存清掉,避免下次同 sid 重连命中失效引用 - for (const key of streamCache.keys()) { + for (const key of Array.from(streamCache.keys())) { if (key.startsWith(`${rp.sid}:`)) { - streamCache.delete(key); + streamCache.delete(key) } } - const userId = parseUserId(rp.identity); - if (userId !== null) { - participantDisconnectedHandlers.forEach((cb) => cb(userId)); + const userId = parseUserId(rp.identity) + if (userId != null) { + participantDisconnectedHandlers.forEach((cb) => cb(userId)) } }) - .on(RoomEvent.TrackSubscribed, () => syncRemotes(r)) - .on(RoomEvent.TrackUnsubscribed, () => syncRemotes(r)) + .on(RoomEvent.TrackSubscribed, () => isCurrentRoom() && syncRemotes(r)) + .on(RoomEvent.TrackUnsubscribed, () => isCurrentRoom() && syncRemotes(r)) // mute / unmute 让 pickStream 的 isMuted 短路重算,video 元素能解绑 srcObject 而不是卡最后一帧 - .on(RoomEvent.TrackMuted, () => syncRemotes(r)) - .on(RoomEvent.TrackUnmuted, () => syncRemotes(r)) - .on(RoomEvent.ConnectionQualityChanged, (quality) => { - connectionQuality.value = quality; - }) + .on(RoomEvent.TrackMuted, () => isCurrentRoom() && syncRemotes(r)) + .on(RoomEvent.TrackUnmuted, () => isCurrentRoom() && syncRemotes(r)) // 瞬断 → 显示「重连中」;不关通话窗,由 SDK 内部重连机制恢复 .on(RoomEvent.Reconnecting, () => { - reconnecting.value = true; + if (!isCurrentRoom()) return + reconnecting.value = true }) .on(RoomEvent.Reconnected, () => { - reconnecting.value = false; + if (!isCurrentRoom()) return + reconnecting.value = false }) // 重连失败 / 主动断 / 被踢时触发清理 .on(RoomEvent.Disconnected, () => { - isConnected.value = false; - reconnecting.value = false; - disconnectedHandlers.forEach((cb) => cb()); - }); + if (!isCurrentRoom()) return + isConnected.value = false + reconnecting.value = false + disconnectedHandlers.forEach((cb) => cb()) + }) // 预热 getUserMedia 与 WebSocket 握手并行,省 100~300ms 串行延迟; // 拿到的 stream 仅用于触发权限弹窗 + 设备就绪,握手完成后由 LiveKit 内部重新请求设备发布轨 - const warmup = prewarmMedia(opts); + const warmup = prewarmMedia(opts) // 建立 WebSocket 信令 + WebRTC 媒体通道;完成后 localParticipant 可用,已在房参与者会通过 ParticipantConnected 事件批量推送 - await r.connect(url, token); + await r.connect(url, token) // 期间被外部 disconnect 替换;中止后续 publish,避免摄像头被重新启用 - if (_room.value !== r) { - return; + if (!isCurrentRoom()) { + return null } - localParticipant.value = r.localParticipant; - isConnected.value = true; + localParticipant.value = r.localParticipant + isConnected.value = true // 预热结果不直接发布(避免 SDK 与外部 track 生命周期纠缠),仅等待权限就绪后再走标准 setXxxEnabled - await warmup; - if (_room.value !== r) { - return; + await warmup + if (!isCurrentRoom()) { + return null } // 麦克风与摄像头权限相互独立,并行启用发布 - const inits: Promise[] = []; + const inits: Promise[] = [] if (opts.audio) { - inits.push(r.localParticipant.setMicrophoneEnabled(true)); + inits.push(r.localParticipant.setMicrophoneEnabled(true)) } if (opts.video) { - inits.push(r.localParticipant.setCameraEnabled(true)); + inits.push(r.localParticipant.setCameraEnabled(true)) } if (inits.length > 0) { - await Promise.all(inits); + await Promise.all(inits) } - micEnabled.value = !!opts.audio; - cameraEnabled.value = !!opts.video; + if (!isCurrentRoom()) { + return null + } + micEnabled.value = !!opts.audio + cameraEnabled.value = !!opts.video // 兜底同步一次远端列表:r.connect 期间 ParticipantConnected 事件可能在 handler 绑定前触发被吞,导致首屏漏人 - syncRemotes(r); + syncRemotes(r) + return r } /** 提前触发权限弹窗 + 设备唤起,串行延迟在 r.connect 期间一起跑;失败静默(连接后会再试一次) */ - async function prewarmMedia(opts: { - audio?: boolean; - video?: boolean; - }): Promise { + async function prewarmMedia(opts: { audio?: boolean; video?: boolean }): Promise { if (!opts.audio && !opts.video) { - return; + return } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: !!opts.audio, - video: !!opts.video, - }); + video: !!opts.video + }) // 拿到权限即可,立即停掉所有 track 释放设备;正式发布走 SDK 流程重新请求 - stream.getTracks().forEach((t) => t.stop()); + stream.getTracks().forEach((t) => t.stop()) } catch { // 用户拒绝 / 设备占用等异常,交给后续 setXxxEnabled 再次尝试报错 } @@ -187,25 +203,27 @@ export function useLiveKitRoom() { /** 切麦克风 */ async function setMicEnabled(enabled: boolean) { - if (!_room.value) { - return; + const r = _room.value + if (!r) { + return } - await _room.value.localParticipant.setMicrophoneEnabled(enabled); - micEnabled.value = enabled; + await r.localParticipant.setMicrophoneEnabled(enabled) + if (_room.value === r) micEnabled.value = enabled } /** 切摄像头 */ async function setCameraEnabled(enabled: boolean) { - if (!_room.value) { - return; + const r = _room.value + if (!r) { + return } - await _room.value.localParticipant.setCameraEnabled(enabled); - cameraEnabled.value = enabled; + await r.localParticipant.setCameraEnabled(enabled) + if (_room.value === r) cameraEnabled.value = enabled } /** 切扬声器;仅切响应式状态,实际静音由模板上 audio 元素 :muted 绑定生效 */ function setSpeakerEnabled(enabled: boolean) { - speakerEnabled.value = enabled; + speakerEnabled.value = enabled } /** @@ -214,40 +232,42 @@ export function useLiveKitRoom() { * 浏览器会弹原生「选择共享内容」对话框,用户在弹窗里点取消时 setScreenShareEnabled 会抛错,捕获并把状态复位回 SDK 的实际值 */ async function setScreenShareEnabled(enabled: boolean) { - if (!_room.value) return; + const r = _room.value + if (!r) return try { - await _room.value.localParticipant.setScreenShareEnabled(enabled); - screenShareEnabled.value = enabled; - } catch (error) { + await r.localParticipant.setScreenShareEnabled(enabled) + if (_room.value === r) screenShareEnabled.value = enabled + } catch (e) { // 用户在浏览器原生对话框里取消选择,不当作错误 - screenShareEnabled.value = - _room.value.localParticipant.isScreenShareEnabled; - throw error; + if (_room.value === r) { + screenShareEnabled.value = r.localParticipant.isScreenShareEnabled + } + throw e } } /** 注册「远端连接异常断开」回调;返回反注册函数 */ function onDisconnected(cb: () => void): () => void { - disconnectedHandlers.add(cb); - return () => disconnectedHandlers.delete(cb); + disconnectedHandlers.add(cb) + return () => disconnectedHandlers.delete(cb) } /** 注册「房内某人加入」回调;返回反注册函数 */ function onParticipantConnected(cb: ParticipantEventHandler): () => void { - participantConnectedHandlers.add(cb); - return () => participantConnectedHandlers.delete(cb); + participantConnectedHandlers.add(cb) + return () => participantConnectedHandlers.delete(cb) } /** 注册「房内某人离开」回调;返回反注册函数 */ function onParticipantDisconnected(cb: ParticipantEventHandler): () => void { - participantDisconnectedHandlers.add(cb); - return () => participantDisconnectedHandlers.delete(cb); + participantDisconnectedHandlers.add(cb) + return () => participantDisconnectedHandlers.delete(cb) } /** identity 是后端签 token 时塞的 userId 字符串,转 number 返回;非数字(兼容性兜底)返回 null */ function parseUserId(identity: string): null | number { - const id = Number(identity); - return Number.isNaN(id) ? null : id; + const id = Number(identity) + return Number.isNaN(id) ? null : id } /** @@ -255,10 +275,7 @@ export function useLiveKitRoom() { * 同一条 MediaStreamTrack 复用同一个 MediaStream,避免 跳转; -// text 段是纯文本兜底。渲染层(tip-segments.vue)按 type 分发统一处理。 +// text 段是纯文本兜底。渲染层(TipSegments.vue)按 type 分发统一处理。 export type TipSegment = - | { href: string; text: string; type: 'link' } - | { text: string; type: 'mention'; userId: number } - | { text: string; type: 'text' }; + | { href: string; text: string; type: 'link'; } + | { text: string; type: 'mention'; userId: number; } + | { text: string; type: 'text'; } -export const tipText = (text: string): TipSegment => ({ type: 'text', text }); +export const tipText = (text: string): TipSegment => ({ type: 'text', text }) export const tipMention = (userId: number, text: string): TipSegment => ({ type: 'mention', userId, - text, -}); + text +}) -export const tipLink = (href: string, text: string): TipSegment => ({ +const tipLink = (href: string, text: string): TipSegment => ({ type: 'link', href, - text, -}); + text +}) export const segmentsToText = (segments: TipSegment[]): string => - segments.map((s) => s.text).join(''); + segments.map((s) => s.text).join('') /** 多个 userId 用同一个分隔符插值成 segments,每个 user 单独成 mention 段 */ -export function joinMentionSegments( +function joinMentionSegments( userIds: number[], separator: string, - resolveName: (userId: number) => string, + resolveName: (userId: number) => string ): TipSegment[] { return userIds.flatMap((id, index) => index === 0 ? [tipMention(id, resolveName(id))] - : [tipText(separator), tipMention(id, resolveName(id))], - ); + : [tipText(separator), tipMention(id, resolveName(id))] + ) } /** mention 候选;name 用于匹配文本字面量,displayName 用于覆盖渲染(不传则按 name 渲染) */ export interface MentionCandidate { - userId: number; + userId: number /** 用来在 content 中前缀匹配的字面量(不含 @) */ - name: string; + name: string /** * 渲染时强制使用的显示名(不含 @) * @@ -116,157 +105,142 @@ export interface MentionCandidate { * 候选可以携带多种字面量(match 用),但 displayName 统一指向 nickname, * 让所有历史 / 新消息的 @ 都收敛到一致的展示 */ - displayName?: string; + displayName?: string /** * 歧义标记:同 name 对应多个 userId 时为 true * * parser 仍会按 name 命中(最长优先),但命中后整段吃成普通文本—— * 避免「@张三」被剔除后,短前缀候选「@张」抢匹配错绑 */ - ambiguous?: boolean; + ambiguous?: boolean } /** URL 锚定正则;终止于空白、中文、@(避免吞掉下一个 mention)、< > " '(防 HTML / 引号串入);y 标志走 sticky 匹配,省 text.slice */ -const URL_STICKY_REGEX = - /(https?:\/\/[^\s一-龥@<>"']+|www\.[^\s一-龥@<>"']+)/iy; +const URL_STICKY_REGEX = /(https?:\/\/[^\s一-龥@<>"']+|www\.[^\s一-龥@<>"']+)/iy /** URL 末尾常见标点剔除,避免吞掉句末中英文标点 */ -const URL_TRAILING_PUNCTUATION = /[.,!?;:)\]、,。!?;:)】]+$/; +const URL_TRAILING_PUNCTUATION = /[.,!?;:)\]、,。!?;:)】]+$/ /** 最短 URL:`www.ab` / `http:/` 这种孤段不算链接 */ -const URL_MIN_LENGTH = 6; +const URL_MIN_LENGTH = 6 /** * 文本气泡 content 拆段:mention 段按候选最长前缀匹配,URL 段走锚定正则,剩余归 text * * mentions 不传或匹配不上时,@xxx 退化为普通 text;解析与渲染解耦,工具函数本身不依赖 store */ -export function parseTextSegments( - text: string, - mentions: MentionCandidate[] = [], -): TipSegment[] { +export function parseTextSegments(text: string, mentions: MentionCandidate[] = []): TipSegment[] { if (!text) { - return []; + return [] } // 「@张三丰」不能被「@张三」截胡,候选按 name 长度倒序 const sortedMentions = - mentions.length > 1 - ? mentions.toSorted((a, b) => b.name.length - a.name.length) - : mentions; - const out: TipSegment[] = []; - let buffer = ''; + mentions.length > 1 ? [...mentions].sort((a, b) => b.name.length - a.name.length) : mentions + const out: TipSegment[] = [] + let buffer = '' const flush = () => { if (buffer) { - out.push(tipText(buffer)); - buffer = ''; + out.push(tipText(buffer)) + buffer = '' } - }; + } - let i = 0; + let i = 0 while (i < text.length) { if (text[i] === '@' && sortedMentions.length > 0) { - const matched = sortedMentions.find( - (m) => m.name && text.startsWith(m.name, i + 1), - ); + const matched = sortedMentions.find((m) => m.name && text.startsWith(m.name, i + 1)) if (matched) { if (matched.ambiguous) { // 同字面量对应多 userId,无法判定意图;整段累入 buffer 让短前缀候选没机会再扫这段 - buffer += `@${matched.name}`; + buffer += '@' + matched.name } else { - flush(); + flush() // 渲染统一走 displayName(即真实昵称),让历史 content 里残留的备注 / 群昵称也收敛到 nickname - out.push( - tipMention( - matched.userId, - `@${matched.displayName || matched.name}`, - ), - ); + out.push(tipMention(matched.userId, '@' + (matched.displayName || matched.name))) } - i += 1 + matched.name.length; - continue; + i += 1 + matched.name.length + continue } } - const head = text[i]; + const head = text[i] if (head === 'h' || head === 'H' || head === 'w' || head === 'W') { - URL_STICKY_REGEX.lastIndex = i; - const linkMatch = URL_STICKY_REGEX.exec(text); + URL_STICKY_REGEX.lastIndex = i + const linkMatch = URL_STICKY_REGEX.exec(text) if (linkMatch) { - const urlText = linkMatch[0].replace(URL_TRAILING_PUNCTUATION, ''); + const urlText = linkMatch[0].replace(URL_TRAILING_PUNCTUATION, '') if (urlText.length >= URL_MIN_LENGTH) { - flush(); - const href = /^https?:\/\//i.test(urlText) - ? urlText - : `https://${urlText}`; - out.push(tipLink(href, urlText)); - i += urlText.length; - continue; + flush() + const href = /^https?:\/\//i.test(urlText) ? urlText : `https://${urlText}` + out.push(tipLink(href, urlText)) + i += urlText.length + continue } } } - buffer += text[i]; - i += 1; + buffer += text[i] + i += 1 } - flush(); - return out; + flush() + return out } // ==================== 引用消息 ==================== /** 引用容器:5 种普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */ interface Quotable { - quote?: QuoteMessage; + quote?: QuoteMessage } // ==================== 消息 payload ==================== /** 文本消息 payload(对齐后端 TextMessage) */ export interface TextMessage extends Quotable { - content: string; + content: string } /** 图片消息 payload(对齐后端 ImageMessage) */ export interface ImageMessage extends Quotable { - url: string; + url: string /** 缩略图 URL */ - thumbnailUrl?: string; + thumbnailUrl?: string /** 图片宽度 */ - width?: number; + width?: number /** 图片高度 */ - height?: number; + height?: number /** 文件大小(字节) */ - size?: number; + size?: number } /** 语音消息 payload(对齐后端 AudioMessage;ImContentType 保留 VOICE 命名) */ export interface AudioMessage extends Quotable { - url: string; + url: string /** 时长(秒) */ - duration: number; + duration: number /** 文件大小(字节) */ - size?: number; + size?: number } /** 文件消息 payload(对齐后端 FileMessage) */ export interface FileMessage extends Quotable { - url: string; - name: string; - size: number; + url: string + name: string + size: number /** MIME 类型 */ - type?: string; + type?: string } /** 视频消息 payload(对齐后端 VideoMessage;暂未接入渲染) */ export interface VideoMessage extends Quotable { - url: string; + url: string /** 封面 URL */ - coverUrl?: string; + coverUrl?: string /** 时长(秒) */ - duration?: number; - width?: number; - height?: number; + duration?: number + width?: number + height?: number /** 文件大小(字节) */ - size?: number; + size?: number } /** @@ -278,51 +252,47 @@ export interface VideoMessage extends Quotable { */ export interface CardMessage extends Quotable { /** 名片对象类型 */ - targetType: ImConversationTypeValue; + targetType: ImConversationTypeValue /** 目标对象编号:PRIVATE 时 = userId;GROUP 时 = groupId */ - targetId: number; + targetId: number /** 显示名快照:PRIVATE 时 = 用户昵称;GROUP 时 = 群名 */ - name: string; + name: string /** 头像(快照) */ - avatar?: string; + avatar?: string /** 群成员数(仅 targetType = GROUP;接收端展示「N 人群聊」) */ - memberCount?: number; + memberCount?: number } /** * 名片转发的源对象(RecommendCardDialog 入参);字段与 CardMessage 1:1,无 quote */ -export type CardTarget = Omit; +export type CardTarget = Omit /** 用户对象 → 用户名片源(PRIVATE);缺 id 返回 null 让调用方 v-bind 绑定为不渲染 */ -export function toUserCardTarget( - user: null | undefined | User, -): CardTarget | null { +export function toUserCardTarget(user: null | undefined | User): CardTarget | null { if (!user?.id) { - return null; + return null } return { targetType: ImConversationType.PRIVATE, targetId: user.id, name: user.nickname || '', - avatar: user.avatar, - }; + avatar: user.avatar + } } /** 群对象 → 群名片源(GROUP);缺 id 返回 null */ -export function toGroupCardTarget( - group: GroupLite | null | undefined, -): CardTarget | null { +export function toGroupCardTarget(group: GroupLite | null | undefined): CardTarget | null { if (!group?.id) { - return null; + return null } return { targetType: ImConversationType.GROUP, targetId: group.id, name: group.name || '', avatar: group.showImage || group.showImageThumb, - memberCount: group.memberCount, - }; + memberCount: group.memberCount + } } /** @@ -330,70 +300,68 @@ export function toGroupCardTarget( * * 缺失 / 非法 targetType 走「个人名片」兜底,避免老消息或脏数据导致 UI 空白 */ -export function getCardLabelInfo( - card: null | undefined | { targetType?: number }, -): { - icon: string; - label: string; +export function getCardLabelInfo(card: null | undefined | { targetType?: number }): { + icon: string + label: string } { if (card?.targetType === ImConversationType.GROUP) { - return { label: '群名片', icon: 'ant-design:usergroup-outlined' }; + return { label: '群名片', icon: 'ant-design:usergroup-outlined' } } - return { label: '个人名片', icon: 'ant-design:user-outlined' }; + return { label: '个人名片', icon: 'ant-design:user-outlined' } } /** 表情消息 payload(对齐后端 FaceMessage;Unicode emoji 仍走 TEXT,本类型只承载贴图 / 自定义表情包) */ export interface FaceMessage extends Quotable { /** 表情图 URL */ - url: string; + url: string /** 渲染宽度(像素),避免布局抖动;可选,缺失时调用方走 CSS max-w 兜底 */ - width?: number; + width?: number /** 渲染高度(像素),可选 */ - height?: number; + height?: number /** 表情名(系统包通常有,个人表情包通常无) */ - name?: string; + name?: string } /** 合并转发的单条内嵌消息快照(对齐后端 MergeMessage.Item) */ -export interface MergeMessageItem { +interface MergeMessageItem { /** 原消息编号;仅做溯源标识 */ - messageId: number; + messageId: number /** 发送人编号 */ - senderId: number; + senderId: number /** 发送人昵称快照;对端可能不在原会话里,无法实时查到 */ - senderNickname: string; + senderNickname: string /** 发送人头像快照 */ - senderAvatar?: string; + senderAvatar?: string /** 内容类型,对齐 ImContentType */ - type: number; + type: number /** 原消息 content(JSON);嵌套合并消息时仍按本结构层层展开 */ - content: string; + content: string /** 发送时间戳(毫秒) */ - sendTime: number; + sendTime: number } /** 合并转发消息 payload(对齐后端 MergeMessage) */ export interface MergeMessage { - title: string; // 合并标题;例:「张三和李四的聊天记录」「群聊的聊天记录」 - messages: MergeMessageItem[]; // 内嵌的完整消息快照 + title: string // 合并标题;例:「张三和李四的聊天记录」「群聊的聊天记录」 + messages: MergeMessageItem[] // 内嵌的完整消息快照 } /** 频道素材消息 payload(对齐后端 MaterialMessage) */ export interface MaterialMessage { - materialId?: number; - channelId?: number; // 频道编号;转发后渲染卡片底部的频道头像 + 名称 - title?: string; - coverUrl?: string; - summary?: string; - url?: string; // 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url + materialId?: number + channelId?: number // 频道编号;转发后渲染卡片底部的频道头像 + 名称 + title?: string + coverUrl?: string + summary?: string + url?: string // 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url } // ==================== 合并转发 payload 构造 ==================== /** 单个发送人的快照昵称 / 头像 */ interface SenderSnapshot { - nickname: string; - avatar: string; + nickname: string + avatar: string } /** @@ -403,54 +371,50 @@ interface SenderSnapshot { */ function buildSenderSnapshotMap( senderIds: number[], - conversation: Conversation, + conversation: Conversation ): Map { - const userStore = useUserStore(); - const friendStore = useFriendStore(); - const selfUserId = getCurrentUserId(); - const result = new Map(); + const userStore = useUserStore() + const friendStore = useFriendStore() + const selfUserId = getCurrentUserId() + const result = new Map() - let groupMembers: Map | null = - null; + let groupMembers: Map | null = null if (conversation.type === ImConversationType.GROUP) { - const group = useGroupStore().getGroup(conversation.targetId); - groupMembers = new Map((group?.members || []).map((m) => [m.userId, m])); + const group = useGroupStore().getGroup(conversation.targetId) + groupMembers = new Map((group?.members || []).map((m) => [m.userId, m])) } for (const senderId of senderIds) { if (result.has(senderId)) { - continue; + continue } if (senderId === selfUserId) { result.set(senderId, { nickname: userStore.userInfo?.nickname || String(senderId), - avatar: userStore.userInfo?.avatar || '', - }); - continue; + avatar: userStore.userInfo?.avatar || '' + }) + continue } - const member = groupMembers?.get(senderId); + const member = groupMembers?.get(senderId) if (member?.nickname) { - result.set(senderId, { - nickname: member.nickname, - avatar: member.avatar || '', - }); - continue; + result.set(senderId, { nickname: member.nickname, avatar: member.avatar || '' }) + continue } - const friend = friendStore.getFriend(senderId); + const friend = friendStore.getFriend(senderId) result.set(senderId, { nickname: friend?.nickname || String(senderId), - avatar: friend?.avatar || '', - }); + avatar: friend?.avatar || '' + }) } - return result; + return result } /** 把单条 Message 转成 MergeMessageItem 快照;剥离 quote 防引用穿透 */ function mapMessageToMergeItem( message: Message, - senderSnapshots: Map, + senderSnapshots: Map ): MergeMessageItem { - const snapshot = senderSnapshots.get(message.senderId); + const snapshot = senderSnapshots.get(message.senderId) return { messageId: message.id || 0, senderId: message.senderId, @@ -458,38 +422,38 @@ function mapMessageToMergeItem( senderAvatar: snapshot?.avatar ?? '', type: message.type, content: removeQuotePayload(message.content), - sendTime: message.sendTime, - }; + sendTime: message.sendTime + } } /** 合并转发标题:私聊「{对方} 和 {自己} 的聊天记录」;群聊「{群名} 的聊天记录」 */ -export function buildMergeTitle(conversation: Conversation): string { +function buildMergeTitle(conversation: Conversation): string { if (conversation.type === ImConversationType.GROUP) { - return `${conversation.name || '群聊'} 的聊天记录`; + return `${conversation.name || '群聊'} 的聊天记录` } - const myName = useUserStore().userInfo?.nickname || '我'; - return `${conversation.name || '对方'} 和 ${myName} 的聊天记录`; + const myName = useUserStore().userInfo?.nickname || '我' + return `${conversation.name || '对方'} 和 ${myName} 的聊天记录` } /** 把一组 Message 打包成 MergeMessage payload;调用方负责按 sendTime 排序后传入 */ export function buildMergeMessagePayload( messages: Message[], - conversation: Conversation, + conversation: Conversation ): MergeMessage { - const senderIds = messages.map((m) => m.senderId); - const senderSnapshots = buildSenderSnapshotMap(senderIds, conversation); + const senderIds = messages.map((m) => m.senderId) + const senderSnapshots = buildSenderSnapshotMap(senderIds, conversation) return { title: buildMergeTitle(conversation), - messages: messages.map((m) => mapMessageToMergeItem(m, senderSnapshots)), - }; + messages: messages.map((m) => mapMessageToMergeItem(m, senderSnapshots)) + } } /** 「添加到表情」的可发起源:FACE / IMAGE 都允许(GIF 图片也常被收藏) */ -export interface AddableFacePayload { - url: string; - width: number; - height: number; - name?: string; +interface AddableFacePayload { + url: string + width: number + height: number + name?: string } /** @@ -497,50 +461,38 @@ export interface AddableFacePayload { * * 调用方(MessageItem 的右键菜单)按 nullable 决定是否展示「添加到表情」入口 */ -export function extractAddableFace( - message: Message, -): AddableFacePayload | null { +export function extractAddableFace(message: Message): AddableFacePayload | null { if (message.type === ImContentType.FACE) { - const face = parseMessage(message.content); + const face = parseMessage(message.content) if (!face?.url) { - return null; + return null } - return { - url: face.url, - width: face.width || 200, - height: face.height || 200, - name: face.name, - }; + return { url: face.url, width: face.width || 200, height: face.height || 200, name: face.name } } if (message.type === ImContentType.IMAGE) { - const image = parseMessage(message.content); + const image = parseMessage(message.content) if (!image?.url) { - return null; + return null } - return { - url: image.url, - width: image.width || 200, - height: image.height || 200, - }; + return { url: image.url, width: image.width || 200, height: image.height || 200 } } - return null; + return null } /** 解析消息 content(JSON 字符串)为指定 payload,非法 JSON 返回 null */ export const parseMessage = (content: string): null | T => { try { - return JSON.parse(content) as T; + return JSON.parse(content) as T } catch { - return null; + return null } -}; +} /** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */ -export const serializeMessage = (payload: T): string => - JSON.stringify(payload); +export const serializeMessage = (payload: T): string => JSON.stringify(payload) /** `URL.createObjectURL(file)` 生成的 URL 前缀;占位 / revoke / 重传旧值识别共用 */ -export const BLOB_URL_PREFIX = 'blob:'; +export const BLOB_URL_PREFIX = 'blob:' /** * 媒体 payload 里可能包含 blob URL 的字段(图片/文件/视频/语音都对齐这套 url 字段命名) @@ -550,7 +502,7 @@ export const BLOB_URL_PREFIX = 'blob:'; * - coverUrl:视频封面(commit 后是真实 URL;占位阶段不设以避免传 blob 当 poster 在部分浏览器退化) * - thumbnailUrl:图片缩略图(当前未占位时使用 blob,预留) */ -const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const; +const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const /** * 释放 content 中媒体 payload 字段上的 blob URL 内存映射 @@ -560,32 +512,29 @@ const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const; */ export const revokeBlobUrlsInContent = (content: string): void => { if (!content || !content.includes(BLOB_URL_PREFIX)) { - return; + return } - const payload = parseMessage>(content); + const payload = parseMessage>(content) if (!payload) { - return; + return } for (const field of MEDIA_BLOB_URL_FIELDS) { - const value = payload[field]; + const value = payload[field] if (typeof value === 'string' && value.startsWith(BLOB_URL_PREFIX)) { - URL.revokeObjectURL(value); + URL.revokeObjectURL(value) } } -}; +} // ==================== 引用消息 helper ==================== /** 把 quote 合进 payload(序列化前调用);quote 缺失时原样返回 */ -export const withQuotePayload = ( - payload: T, - quote?: QuoteMessage, -): T => { +export const withQuotePayload = (payload: T, quote?: QuoteMessage): T => { if (!quote) { - return payload; + return payload } - return { ...payload, quote }; -}; + return { ...payload, quote } +} /** * 从 content JSON 字符串里清掉 quote 字段 @@ -595,15 +544,15 @@ export const withQuotePayload = ( */ export const removeQuotePayload = (content: string): string => { if (!content || !content.includes('"quote"')) { - return content; + return content } - const parsed = parseMessage>(content); + const parsed = parseMessage>(content) if (!parsed || !('quote' in parsed)) { - return content; + return content } - delete parsed.quote; - return JSON.stringify(parsed); -}; + delete parsed.quote + return JSON.stringify(parsed) +} /** 由 Message 派生 QuoteMessage 用于乐观渲染;ack 后会被服务端权威版本覆盖 */ export const buildQuoteFromMessage = (message: Message): QuoteMessage => { @@ -611,19 +560,19 @@ export const buildQuoteFromMessage = (message: Message): QuoteMessage => { messageId: message.id || 0, senderId: message.senderId, type: message.type, - content: removeQuotePayload(message.content), - }; -}; + content: removeQuotePayload(message.content) + } +} /** 从已序列化 message.content 中解出 quote;非 JSON / 无 quote 返回 null */ export const getQuoteFromMessage = (content: string): null | QuoteMessage => { // 长会话每条消息渲染都走 quote computed,非引用消息字符串预扫直接返回,免一次 JSON.parse if (!content || !content.includes('"quote"')) { - return null; + return null } - const parsed = parseMessage(content); - return parsed?.quote ?? null; -}; + const parsed = parseMessage(content) + return parsed?.quote ?? null +} // ==================== 撤回 ==================== @@ -633,136 +582,133 @@ export const getQuoteFromMessage = (content: string): null | QuoteMessage => { */ export const parseRecallMessageId = (content: string): number => { try { - const parsed = JSON.parse(content); - return parsed?.messageId === null ? 0 : Number(parsed.messageId); + const parsed = JSON.parse(content) + return parsed?.messageId != null ? Number(parsed.messageId) : 0 } catch { - return 0; + return 0 } -}; +} // ==================== 新消息提示音 ==================== +import tipAudioUrl from '#/assets/audio/im/message-tip.mp3' + /** * 新消息提示音,带 1 秒节流:短时间内多条消息只响一次,避免疲劳轰炸。 * * 浏览器自动播放策略要求页面有过用户交互;若无法播放会静默失败。 */ -let __lastPlayAudioTipAt = 0; -let __tipAudio: HTMLAudioElement | null = null; +let __lastPlayAudioTipAt = 0 +let __tipAudio: HTMLAudioElement | null = null export const playAudioTip = () => { - const now = Date.now(); + const now = Date.now() if (now - __lastPlayAudioTipAt < 1000) { - return; + return } - __lastPlayAudioTipAt = now; + __lastPlayAudioTipAt = now try { if (!__tipAudio) { - __tipAudio = new Audio(tipAudioUrl); - __tipAudio.preload = 'auto'; + __tipAudio = new Audio(tipAudioUrl) + __tipAudio.preload = 'auto' } - __tipAudio.currentTime = 0; - const playPromise = __tipAudio.play(); + __tipAudio.currentTime = 0 + const playPromise = __tipAudio.play() if (playPromise && typeof playPromise.catch === 'function') { - playPromise.catch((error) => - console.debug('[IM] playAudioTip 失败', error), - ); + playPromise.catch((e) => console.debug('[IM] playAudioTip 失败', e)) } - } catch (error) { - console.debug('[IM] playAudioTip 失败', error); + } catch (e) { + console.debug('[IM] playAudioTip 失败', e) } -}; +} // ==================== 文件图标 ==================== /** * 按文件扩展名挑文件图标 + 颜色,对齐微信观感 * - * message-item.vue(主气泡)和 reply-preview.vue(引用预览)共用同一份映射,避免视觉两处不一致 + * MessageItem.vue(主气泡)和 ReplyPreview.vue(引用预览)共用同一份映射,避免视觉两处不一致 */ -export function getFileIconInfo(filename: string | undefined): { - color: string; - icon: string; -} { - const ext = (filename || '').split('.').pop()?.toLowerCase() || ''; +export function getFileIconInfo(filename: string | undefined): { color: string; icon: string; } { + const ext = (filename || '').split('.').pop()?.toLowerCase() || '' if (ext === 'pdf') { - return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }; + return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' } } if (['doc', 'docx'].includes(ext)) { - return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }; + return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' } } if (['xls', 'xlsx'].includes(ext)) { - return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }; + return { icon: 'ant-design:file-excel-filled', color: '#1f7244' } } if (['ppt', 'pptx'].includes(ext)) { - return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }; + return { icon: 'ant-design:file-ppt-filled', color: '#d24726' } } if (['7z', 'gz', 'rar', 'tar', 'zip'].includes(ext)) { - return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }; + return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' } } if (['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp'].includes(ext)) { - return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }; + return { icon: 'ant-design:file-image-filled', color: '#9c27b0' } } if (['avi', 'flv', 'mkv', 'mov', 'mp4', 'wmv'].includes(ext)) { - return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }; + return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' } } if (['aac', 'flac', 'mp3', 'ogg', 'wav'].includes(ext)) { - return { icon: 'ant-design:audio-filled', color: '#9c27b0' }; + return { icon: 'ant-design:audio-filled', color: '#9c27b0' } } if (['json', 'log', 'md', 'txt', 'xml'].includes(ext)) { - return { icon: 'ant-design:file-text-filled', color: '#909399' }; + return { icon: 'ant-design:file-text-filled', color: '#909399' } } - return { icon: 'ant-design:file-filled', color: '#909399' }; + return { icon: 'ant-design:file-filled', color: '#909399' } } // ==================== 管理后台展示工具 ==================== /** 详情弹窗里把 content JSON 美化成 2 缩进 */ export const formatJson = (content?: string): string => { - if (!content) return ''; + if (!content) return '' try { - return JSON.stringify(JSON.parse(content), null, 2); + return JSON.stringify(JSON.parse(content), null, 2) } catch { - return content; + return content } -}; +} // ==================== 群广播事件 payload + 文案 ==================== // 群广播事件 payload;对齐后端 GroupNotificationMessage 子类聚合字段 export type GroupNotificationPayload = { - addSource?: number; + addSource?: number // 全群封禁 - banned?: boolean; - displayUserName?: string; + banned?: boolean + displayUserName?: string // 自由进群事件 - entrantUserId?: number; - memberUserIds?: number[]; + entrantUserId?: number + memberUserIds?: number[] // PIN 事件携带的被置顶消息展示数据 message?: { - atUserIds?: number[]; - content: string; - groupId: number; - id: number; - receiverUserIds?: number[]; - senderId: number; - sendTime: string; - type: number; - }; - messageId?: number; + atUserIds?: number[] + content: string + groupId: number + id: number + receiverUserIds?: number[] + senderId: number + sendTime: string + type: number + } + messageId?: number // 禁言事件 - mutedUserId?: number; - muteEndTime?: string; - newAvatar?: string; - newJoinApproval?: boolean; - newName?: string; - newNotice?: string; - newOwnerUserId?: number; - oldAvatar?: string; - oldJoinApproval?: boolean; - oldName?: string; - oldNotice?: string; - operatorUserId?: number; -}; + mutedUserId?: number + muteEndTime?: string + newAvatar?: string + newJoinApproval?: boolean + newName?: string + newNotice?: string + newOwnerUserId?: number + oldAvatar?: string + oldJoinApproval?: boolean + oldName?: string + oldNotice?: string + operatorUserId?: number +} /** * 群广播事件 segments @@ -771,221 +717,155 @@ export type GroupNotificationPayload = { * operatorNameOverride 仅覆盖 operator 段文案,mention userId 仍用 payload.operatorUserId */ export function resolveGroupNotificationSegments( - message: { content?: string; targetId?: number; type?: number }, + message: { content?: string; targetId?: number; type?: number; }, resolveName: (userId: number) => string, - operatorNameOverride?: string, + operatorNameOverride?: string ): TipSegment[] { - let payload: GroupNotificationPayload; + let payload: GroupNotificationPayload try { - payload = JSON.parse(message.content || '{}'); + payload = JSON.parse(message.content || '{}') } catch { - return []; + return [] } // ENTER 主语是 entrant 而非 operator,独立处理;其它 case 都以 operatorUserId 为主语 if (message.type === ImContentType.GROUP_MEMBER_ENTER) { - const entrantId = payload.entrantUserId ?? payload.operatorUserId; - return entrantId - ? [tipMention(entrantId, resolveName(entrantId)), tipText(' 加入了群聊')] - : []; + const entrantId = payload.entrantUserId ?? payload.operatorUserId + return entrantId ? [tipMention(entrantId, resolveName(entrantId)), tipText(' 加入了群聊')] : [] } if (!payload.operatorUserId) { - return []; + return [] } const operatorSegment = tipMention( payload.operatorUserId, - operatorNameOverride ?? resolveName(payload.operatorUserId), - ); - const memberSegments = joinMentionSegments( - payload.memberUserIds || [], - '、', - resolveName, - ); + operatorNameOverride ?? resolveName(payload.operatorUserId) + ) + const memberSegments = joinMentionSegments(payload.memberUserIds || [], '、', resolveName) switch (message.type) { - case ImContentType.GROUP_ADMIN_ADD: { - return [ - operatorSegment, - tipText(' 将 '), - ...memberSegments, - tipText(' 设为管理员'), - ]; - } - case ImContentType.GROUP_ADMIN_REMOVE: { - return [ - operatorSegment, - tipText(' 撤销了 '), - ...memberSegments, - tipText(' 的管理员身份'), - ]; - } - case ImContentType.GROUP_BANNED: { - return [ - operatorSegment, - tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群'), - ]; - } - case ImContentType.GROUP_CANCEL_MUTED: { - return [operatorSegment, tipText(' 关闭了全群禁言')]; - } - case ImContentType.GROUP_CREATE: { - return [operatorSegment, tipText(' 创建了群聊')]; - } - case ImContentType.GROUP_DISSOLVE: { - return [operatorSegment, tipText(' 解散了群聊')]; - } - case ImContentType.GROUP_INFO_UPDATE: { + case ImContentType.GROUP_ADMIN_ADD: + return [operatorSegment, tipText(' 将 '), ...memberSegments, tipText(' 设为管理员')] + case ImContentType.GROUP_ADMIN_REMOVE: + return [operatorSegment, tipText(' 撤销了 '), ...memberSegments, tipText(' 的管理员身份')] + case ImContentType.GROUP_BANNED: + return [operatorSegment, tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群')] + case ImContentType.GROUP_CANCEL_MUTED: + return [operatorSegment, tipText(' 关闭了全群禁言')] + case ImContentType.GROUP_CREATE: + return [operatorSegment, tipText(' 创建了群聊')] + case ImContentType.GROUP_DISSOLVE: + return [operatorSegment, tipText(' 解散了群聊')] + case ImContentType.GROUP_INFO_UPDATE: return payload.newAvatar ? [operatorSegment, tipText(' 更换了群头像')] - : [operatorSegment, tipText(' 更新了群信息')]; - } - case ImContentType.GROUP_MEMBER_CANCEL_MUTED: { + : [operatorSegment, tipText(' 更新了群信息')] + case ImContentType.GROUP_MEMBER_CANCEL_MUTED: return payload.mutedUserId ? [ operatorSegment, tipText(' 解除了 '), tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)), - tipText(' 的禁言'), + tipText(' 的禁言') ] - : []; - } - case ImContentType.GROUP_MEMBER_INVITE: { - return [ - operatorSegment, - tipText(' 邀请 '), - ...memberSegments, - tipText(' 加入群聊'), - ]; - } - case ImContentType.GROUP_MEMBER_KICK: { - return [operatorSegment, tipText(' 移出了 '), ...memberSegments]; - } - case ImContentType.GROUP_MEMBER_MUTED: { + : [] + case ImContentType.GROUP_MEMBER_INVITE: + return [operatorSegment, tipText(' 邀请 '), ...memberSegments, tipText(' 加入群聊')] + case ImContentType.GROUP_MEMBER_KICK: + return [operatorSegment, tipText(' 移出了 '), ...memberSegments] + case ImContentType.GROUP_MEMBER_MUTED: return payload.mutedUserId ? [ operatorSegment, tipText(' 将 '), tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)), - tipText(' 禁言'), + tipText(' 禁言') ] - : []; - } - case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: { - return [ - operatorSegment, - tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`), - ]; - } - case ImContentType.GROUP_MEMBER_QUIT: { - return [operatorSegment, tipText(' 退出了群聊')]; - } - case ImContentType.GROUP_MESSAGE_PIN: { - return [operatorSegment, tipText(' 置顶了一条消息')]; - } - case ImContentType.GROUP_MESSAGE_UNPIN: { - return [operatorSegment, tipText(' 取消了一条置顶消息')]; - } - case ImContentType.GROUP_MUTED: { - return [operatorSegment, tipText(' 开启了全群禁言')]; - } - case ImContentType.GROUP_NAME_UPDATE: { - return [ - operatorSegment, - tipText(` 将群名修改为 "${payload.newName ?? ''}"`), - ]; - } - case ImContentType.GROUP_NOTICE_UPDATE: { - return [operatorSegment, tipText(' 更新了群公告')]; - } - case ImContentType.GROUP_OWNER_TRANSFER: { + : [] + case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: + return [operatorSegment, tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`)] + case ImContentType.GROUP_MEMBER_QUIT: + return [operatorSegment, tipText(' 退出了群聊')] + case ImContentType.GROUP_MESSAGE_PIN: + return [operatorSegment, tipText(' 置顶了一条消息')] + case ImContentType.GROUP_MESSAGE_UNPIN: + return [operatorSegment, tipText(' 取消了一条置顶消息')] + case ImContentType.GROUP_MUTED: + return [operatorSegment, tipText(' 开启了全群禁言')] + case ImContentType.GROUP_NAME_UPDATE: + return [operatorSegment, tipText(` 将群名修改为 "${payload.newName ?? ''}"`)] + case ImContentType.GROUP_NOTICE_UPDATE: + return [operatorSegment, tipText(' 更新了群公告')] + case ImContentType.GROUP_OWNER_TRANSFER: return payload.newOwnerUserId ? [ operatorSegment, tipText(' 已将群主转让给 '), - tipMention( - payload.newOwnerUserId, - resolveName(payload.newOwnerUserId), - ), + tipMention(payload.newOwnerUserId, resolveName(payload.newOwnerUserId)) ] - : []; - } - default: { - return []; - } + : [] + default: + return [] } } /** 群广播事件中文文案 */ export function resolveGroupNotificationText( - message: { content?: string; targetId?: number; type?: number }, + message: { content?: string; targetId?: number; type?: number; }, resolveName: (userId: number) => string, - operatorNameOverride?: string, + operatorNameOverride?: string ): string { return segmentsToText( - resolveGroupNotificationSegments( - message, - resolveName, - operatorNameOverride, - ), - ); + resolveGroupNotificationSegments(message, resolveName, operatorNameOverride) + ) } // ==================== 好友事件 ==================== /** 会话内好友事件 segments */ -export function resolveFriendNotificationSegments(message: { - type?: number; -}): TipSegment[] { +export function resolveFriendNotificationSegments(message: { type?: number }): TipSegment[] { switch (message.type) { - case ImContentType.FRIEND_ADD: { - return [tipText('你们已经是好友了,开始聊天吧')]; - } - case ImContentType.FRIEND_DELETE: { - return [tipText('你已删除好友')]; - } - default: { - return []; - } + case ImContentType.FRIEND_ADD: + return [tipText('你们已经是好友了,开始聊天吧')] + case ImContentType.FRIEND_DELETE: + return [tipText('你已删除好友')] + default: + return [] } } /** 会话内好友事件文案:FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示气泡,文案固定不依赖 payload */ -export function resolveFriendNotificationText(message: { - type?: number; -}): string { - return segmentsToText(resolveFriendNotificationSegments(message)); +export function resolveFriendNotificationText(message: { type?: number }): string { + return segmentsToText(resolveFriendNotificationSegments(message)) } // ==================== RTC 通话事件 ==================== // RTC_CALL_START payload;仅群聊;用于聊天 tip 文案「{inviter} 发起了{voice/video}通话」 export type RtcCallStartPayload = { - conversationType?: number; - inviterAvatar?: string; - inviterNickname?: string; - inviterUserId?: number; - mediaType?: number; - room?: string; -}; + conversationType?: number + inviterAvatar?: string + inviterNickname?: string + inviterUserId?: number + mediaType?: number + room?: string +} // RTC_CALL_END payload;私聊准气泡 + 群聊「通话已结束 [时长 X]」共用 export type RtcCallEndPayload = { - conversationType?: number; - durationSeconds?: number; - endReason?: number; - mediaType?: number; - operatorAvatar?: string; - operatorNickname?: string; - operatorUserId?: number; - room?: string; -}; + conversationType?: number + durationSeconds?: number + endReason?: number + mediaType?: number + operatorAvatar?: string + operatorNickname?: string + operatorUserId?: number + room?: string +} /** 解析 RTC_CALL_START / RTC_CALL_END 消息 content;解析失败返回 null */ export function parseRtcCallPayload( - content?: string, + content?: string ): null | (RtcCallEndPayload & RtcCallStartPayload) { - return content - ? parseMessage(content) - : null; + return content ? parseMessage(content) : null } /** @@ -996,33 +876,31 @@ export function parseRtcCallPayload( * 私聊气泡走 {@link resolveRtcCallPrivateBubbleText} */ export function resolveRtcCallTipSegments(message: { - content?: string; - selfSend?: boolean; - type?: number; + content?: string + selfSend?: boolean + type?: number }): TipSegment[] { - const payload = parseRtcCallPayload(message.content); + const payload = parseRtcCallPayload(message.content) if (!payload) { - return []; + return [] } if (message.type === ImContentType.RTC_CALL_START) { return payload.inviterUserId ? [ tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)), - tipText(' 发起了语音通话'), + tipText(' 发起了语音通话') ] - : []; + : [] } if (message.type === ImContentType.RTC_CALL_END) { - return [tipText('语音通话已经结束')]; + return [tipText('语音通话已经结束')] } - return []; + return [] } /** 取 RTC 通话发起人展示名;昵称为空时回退「用户 {id}」 */ function resolveRtcInviterLabel(payload: RtcCallStartPayload): string { - return ( - payload.inviterNickname?.trim() || `用户 ${payload.inviterUserId ?? ''}` - ); + return payload.inviterNickname?.trim() || `用户 ${payload.inviterUserId ?? ''}` } /** @@ -1033,22 +911,22 @@ function resolveRtcInviterLabel(payload: RtcCallStartPayload): string { * 群聊:START「{inviter} 发起了语音通话」、END「语音通话已经结束」,与聊天 tip 同源 */ export function resolveRtcCallLastContent( - message: { content?: string; type?: number }, - conversationType: number, + message: { content?: string; type?: number; }, + conversationType: number ): string { if (conversationType === ImConversationType.PRIVATE) { - return '[语音通话]'; + return '[语音通话]' } if (message.type === ImContentType.RTC_CALL_END) { - return '语音通话已经结束'; + return '语音通话已经结束' } if (message.type === ImContentType.RTC_CALL_START) { - const payload = parseRtcCallPayload(message.content); + const payload = parseRtcCallPayload(message.content) if (payload) { - return `${resolveRtcInviterLabel(payload)} 发起了语音通话`; + return `${resolveRtcInviterLabel(payload)} 发起了语音通话` } } - return ''; + return '' } /** @@ -1063,55 +941,31 @@ export function resolveRtcCallLastContent( * NO_ANSWER → 操作者「未接听」/ 另一方「对方未接听」(振铃超时 Job 触发) * ERROR → 「通话中断 [N]」(接通后异常断开;duration > 0 时带时长) */ -export function resolveRtcCallPrivateBubbleText( - payload: null | RtcCallEndPayload, -): string { +export function resolveRtcCallPrivateBubbleText(payload: null | RtcCallEndPayload): string { if (!payload) { - return '通话已结束'; + return '通话已结束' } - const duration = payload.durationSeconds ?? 0; - const hasDuration = duration > 0; - const isOperator = payload.operatorUserId === getCurrentUserId(); + const duration = payload.durationSeconds ?? 0 + const hasDuration = duration > 0 + const isOperator = payload.operatorUserId === getCurrentUserId() switch (payload.endReason) { - case ImRtcCallEndReason.BUSY: { - return isOperator ? '忙线未接听' : '对方忙线中'; - } - case ImRtcCallEndReason.CANCEL: { - return isOperator ? '已取消' : '对方已取消'; - } - case ImRtcCallEndReason.ERROR: { - return hasDuration - ? `通话中断 ${formatCallDuration(duration)}` - : '通话中断'; - } - case ImRtcCallEndReason.HANGUP: { - return hasDuration - ? `通话时长 ${formatCallDuration(duration)}` - : '通话中断'; - } - case ImRtcCallEndReason.NO_ANSWER: { - return isOperator ? '未接听' : '对方未接听'; - } - case ImRtcCallEndReason.REJECT: { - return isOperator ? '已拒绝' : '对方已拒绝'; - } - default: { - return hasDuration - ? `通话时长 ${formatCallDuration(duration)}` - : '通话已结束'; - } + case ImRtcCallEndReason.BUSY: + return isOperator ? '忙线未接听' : '对方忙线中' + case ImRtcCallEndReason.CANCEL: + return isOperator ? '已取消' : '对方已取消' + case ImRtcCallEndReason.ERROR: + return hasDuration ? `通话中断 ${formatCallDuration(duration)}` : '通话中断' + case ImRtcCallEndReason.HANGUP: + return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话中断' + case ImRtcCallEndReason.NO_ANSWER: + return isOperator ? '未接听' : '对方未接听' + case ImRtcCallEndReason.REJECT: + return isOperator ? '已拒绝' : '对方已拒绝' + default: + return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话已结束' } } -/** 会话内通话事件文案 */ -export function resolveRtcCallTipText(message: { - content?: string; - selfSend?: boolean; - type?: number; -}): string { - return segmentsToText(resolveRtcCallTipSegments(message)); -} - /** * RTC_CALL_END 结束原因兜底文案;前端 toast / console 兜底用 *

@@ -1119,23 +973,17 @@ export function resolveRtcCallTipText(message: { */ export function resolveCallEndReasonText(reason: number | undefined): string { switch (reason) { - case ImRtcCallEndReason.BUSY: { - return '对方忙线中'; - } - case ImRtcCallEndReason.CANCEL: { - return '对方已取消'; - } - case ImRtcCallEndReason.ERROR: { - return '通话异常'; - } - case ImRtcCallEndReason.HANGUP: { - return '通话已结束'; - } - case ImRtcCallEndReason.REJECT: { - return '对方已拒绝'; - } - default: { - return '通话已断开'; - } + case ImRtcCallEndReason.BUSY: + return '对方忙线中' + case ImRtcCallEndReason.CANCEL: + return '对方已取消' + case ImRtcCallEndReason.ERROR: + return '通话异常' + case ImRtcCallEndReason.HANGUP: + return '通话已结束' + case ImRtcCallEndReason.REJECT: + return '对方已拒绝' + default: + return '通话已断开' } } diff --git a/apps/web-ele/src/views/im/utils/pull.ts b/apps/web-ele/src/views/im/utils/pull.ts index 6524af26b..826de0202 100644 --- a/apps/web-ele/src/views/im/utils/pull.ts +++ b/apps/web-ele/src/views/im/utils/pull.ts @@ -1,6 +1,4 @@ -import { isUndefined } from '@vben/utils'; - -import { getDb } from './db'; +import type { DbClient } from './db' /** * IM 状态事件补偿(增量拉取)通用编排 @@ -11,29 +9,29 @@ import { getDb } from './db'; */ /** 增量拉取游标:上次拉到的位置 */ -export interface PullCursor { - lastUpdateTime?: number; - lastId?: number; +interface PullCursor { + lastUpdateTime?: number + lastId?: number } /** 可作为游标的拉取记录:服务端按 update_time + id 返回,客户端取最后一条推进游标 */ interface PullRecord { - id: number; - updateTime?: number; + id: number + updateTime?: number } /** 单次拉取条数(与后端 limit 上限对齐) */ -const PULL_PAGE_SIZE = 100; +const PULL_PAGE_SIZE = 100 /** 单轮最多翻页数,兜底防御异常游标导致的死循环 */ -const PULL_MAX_PAGES = 100; +const PULL_MAX_PAGES = 100 /** 状态事件拉取回扫窗口,覆盖同秒内旧行更新和客户端 / 服务端时钟精度差 */ -const PULL_OVERLAP_MS = 5000; +const PULL_OVERLAP_MS = 5000 /** 消息类 minId 拉取的单轮翻页上限,兜底防御异常游标死翻;消息量可能远大于状态事件,放宽到 1000 */ -const MIN_ID_PULL_MAX_PAGES = 1000; +const MIN_ID_PULL_MAX_PAGES = 1000 /** 读取某模块的拉取游标;无则返回空游标(首次拉全量) */ -export async function getPullCursor(key: string): Promise { - return (await getDb().getSetting(key)) ?? {}; +async function getPullCursor(db: DbClient, key: string): Promise { + return (await db.getSetting(key)) ?? {} } /** @@ -45,72 +43,51 @@ export async function getPullCursor(key: string): Promise { * 此时不推进游标并终止本轮,避免游标越过未落地的记录、导致后续增量永久漏拉。可返回 Promise */ export async function runIncrementalPull( + db: DbClient, cursorKey: string, - fetchPage: (params: { - lastId?: number; - lastUpdateTime?: number; - limit: number; - }) => Promise, - apply: (records: T[]) => boolean | Promise, - isActive?: () => boolean, + fetchPage: (params: { lastId?: number; lastUpdateTime?: number; limit: number }) => Promise, + apply: (records: T[]) => boolean | Promise ): Promise { - const storedCursor = await getPullCursor(cursorKey); - const highWater = { ...storedCursor }; - let cursor = isUndefined(storedCursor.lastUpdateTime) - ? {} - : { - lastUpdateTime: Math.max( - 0, - storedCursor.lastUpdateTime - PULL_OVERLAP_MS, - ), - lastId: 0, - }; + const storedCursor = await getPullCursor(db, cursorKey) + const highWater = { ...storedCursor } + let cursor = + storedCursor.lastUpdateTime != null + ? { lastUpdateTime: Math.max(0, storedCursor.lastUpdateTime - PULL_OVERLAP_MS), lastId: 0 } + : {} for (let page = 0; page < PULL_MAX_PAGES; page++) { - if (isActive && !isActive()) { - return; - } const list = await fetchPage({ lastUpdateTime: cursor.lastUpdateTime, lastId: cursor.lastId, - limit: PULL_PAGE_SIZE, - }); - if (isActive && !isActive()) { - return; - } - if (list.length > 0) { + limit: PULL_PAGE_SIZE + }) + if (list.length) { // apply 未完全落地(返回 false)时直接终止:游标只能跟着已落地的数据走,否则会跳过本页记录 if ((await apply(list)) === false) { - return; - } - if (isActive && !isActive()) { - return; + return } // 推进游标到本页最后一条并持久化:下次从这里接着拉 - const last = list[list.length - 1]; - if (!last) { - return; + const last = list[list.length - 1]! + if (last.updateTime == null) { + return } - if (isUndefined(last.updateTime)) { - return; - } - cursor = { lastUpdateTime: last.updateTime, lastId: last.id }; + cursor = { lastUpdateTime: last.updateTime, lastId: last.id } if ( - isUndefined(highWater.lastUpdateTime) || + highWater.lastUpdateTime == null || cursor.lastUpdateTime > highWater.lastUpdateTime || (cursor.lastUpdateTime === highWater.lastUpdateTime && cursor.lastId > (highWater.lastId ?? 0)) ) { - highWater.lastUpdateTime = cursor.lastUpdateTime; - highWater.lastId = cursor.lastId; - await getDb().setSetting(cursorKey, highWater); + highWater.lastUpdateTime = cursor.lastUpdateTime + highWater.lastId = cursor.lastId + await db.setSetting(cursorKey, highWater) } } // 不满一页 = 没有更多变更 if (list.length < PULL_PAGE_SIZE) { - return; + return } } - console.warn(`[IM pull] ${cursorKey} 达到单轮翻页上限,提前结束本轮补偿`); + console.warn(`[IM pull] ${cursorKey} 达到单轮翻页上限,提前结束本轮补偿`) } /** @@ -127,45 +104,40 @@ export async function runIncrementalPull( * @param maxPages 单轮翻页上限 */ export async function runMinIdPull(options: { - applyPage: ( - records: T[], - nextMinId?: number, - ) => Promise | Promise; - fetchPage: (params: { minId: number; size: number }) => Promise; - initialMinId: number; - isActive?: () => boolean; - maxPages?: number; - pageSize: number; + applyPage: (records: T[], nextMinId?: number) => Promise + fetchPage: (params: { minId: number; size: number }) => Promise + initialMinId: number + isActive?: () => boolean + maxPages?: number + pageSize: number }): Promise { - const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options; - const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES; - let minId = initialMinId || 0; + const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options + const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES + let minId = initialMinId || 0 for (let page = 0; page < maxPages; page++) { if (isActive && !isActive()) { - return; + return } - const list = await fetchPage({ minId, size: pageSize }); + const list = await fetchPage({ minId, size: pageSize }) // 拉取期间取消 / 切账号:丢弃本批不入库,也不再翻页 if (isActive && !isActive()) { - return; + return } if (!list || list.length === 0) { - return; + return } // 本批最大消息 id 作为下次游标;无有效 id 则本批 apply 后停(无法继续翻页) - const validIds = list - .map((record) => record.id) - .filter((id): id is number => id !== null); - const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined; + const validIds = list.map((record) => record.id).filter((id): id is number => id != null) + const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined // applyPage 返回 false:本页未落地(如入库失败),不推进游标并终止,避免漏消息 if ((await applyPage(list, nextMinId)) === false) { - return; + return } // 无有效 id,或游标没前进(后端契约是 id > minId,理论不会出现):停,防御死翻 - if (isUndefined(nextMinId) || nextMinId <= minId) { - return; + if (nextMinId == null || nextMinId <= minId) { + return } - minId = nextMinId; + minId = nextMinId } - console.warn('[IM pull] runMinIdPull 达到单轮翻页上限,提前结束本轮'); + console.warn('[IM pull] runMinIdPull 达到单轮翻页上限,提前结束本轮') }