diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue index bb0334fe4..5a0d62f7c 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue @@ -626,10 +626,11 @@ async function handleRemoveComplete(members: GroupMemberFlag[]) { // ---------- 设置群管理员 ---------- -/** 当前管理员的 userId 列表,作为 Selector 默认勾选 */ +/** 当前管理员的 userId 列表,作为 Selector 默认勾选;过滤已退群成员,避免 maxSize 名额被隐藏成员占用导致无法新增管理员 */ const adminCheckedIds = computed(() => props.members - .filter((member) => member.role === ImGroupMemberRole.ADMIN) + .filter((member) => member.role === ImGroupMemberRole.ADMIN + && member.status !== CommonStatusEnum.DISABLE) .map((member) => member.userId) ) diff --git a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue index 6b8174401..e9f8569d3 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue @@ -152,7 +152,7 @@ {{ resolveTipText(message.content) }} - +
- +
parseMessage(props.message.conte /** TIP_TEXT 文案:与 conversationStore.resolveLastContent / MessageHistory.renderContent 共用 helper,避免兼容性逻辑分裂 */ const tipText = computed(() => resolveTipText(props.message.content)) -/** 群广播事件(1501-1520 / 1530) */ +/** 群广播事件 */ const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type)) const groupNotificationText = computed(() => resolveGroupNotificationText(props.message)) const imagePayload = computed(() => diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index 5e8390c7c..01e4fba6e 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -21,7 +21,7 @@ import { setQuietly, StorageKeys } from '../../utils/storage' -import { getGroupDisplayName } from '../../utils/user' +import { getGroupDisplayName, type GroupNotificationPayload } from '../../utils/user' import type { Group, GroupMember } from '../types' /** @@ -38,9 +38,16 @@ const pendingMemberKey = (userId: number, groupId: number) => `${userId}:${group * 跟整群表分开:单成员 fetch 跟整群 fetch 语义不同(单成员不回填 me 的 muted),不能互相代替 */ const pendingSingleMemberFetches = new Map>() + const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: number) => `${userId}:${groupId}:${memberUserId}` +/** 判断当前用户是否在 payload.memberUserIds 里(GROUP_CREATE / INVITE / KICK 自判用) */ +function isSelfInPayloadMembers(payload: GroupNotificationPayload): boolean { + const selfUserId = getCurrentUserId() + return !!selfUserId && (payload.memberUserIds || []).includes(selfUserId) +} + /** * IM 群 Store * @@ -458,12 +465,16 @@ export const useGroupStore = defineStore('imGroupStore', { this.saveGroupMembers(groupId) }, - /** 局部更新群字段(name / notice / avatar 等);未命中本地缓存时静默忽略,等 fetchGroups 兜底 */ + /** 局部更新群字段(name / notice / avatar 等);未命中本地缓存时静默忽略,等 fetchGroups 兜底;新值跟旧值都相同时跳过响应式 + IDB 写 */ updateGroupFields(groupId: number, fields: Partial) { const group = this.getGroup(groupId) if (!group) { return } + const changed = (Object.keys(fields) as (keyof Group)[]).some((k) => group[k] !== fields[k]) + if (!changed) { + return + } Object.assign(group, fields) const conversationStore = useConversationStore() conversationStore.updateConversation(ImConversationType.GROUP, groupId, { @@ -475,7 +486,7 @@ export const useGroupStore = defineStore('imGroupStore', { }, /** - * 接收 GROUP_* 群广播事件,按 type 分发到对应 mutation + * 接收 GROUP_* 群广播事件,按 type 分发到对应私有 action * * WebSocket 实时收 + useMessagePuller 离线 pull 都走 conversationStore.insertMessage 旁路调用 * store 里没缓存的群静默忽略,等 fetchGroups 兜底 @@ -484,84 +495,44 @@ export const useGroupStore = defineStore('imGroupStore', { if (!groupId) { return } - let payload: Record = {} + let payload: GroupNotificationPayload = {} try { payload = content ? JSON.parse(content) : {} } catch (error) { - console.warn('[IM groupStore] applyGroupNotification 解析 content 失败', { groupId, type, content }, error) + console.warn( + '[IM groupStore] applyGroupNotification 解析 content 失败', + { groupId, type, contentLength: content?.length ?? 0 }, + error + ) return } switch (type) { - case ImMessageType.GROUP_CREATE: { - // 创建群广播:创建者多端同步 + 初始成员 bootstrap;payload.memberUserIds 含自己 → 拉群详情 / 成员 - const selfUserId = getCurrentUserId() - const memberIds: number[] = payload.memberUserIds || [] - if (selfUserId && memberIds.includes(selfUserId)) { - this.fetchGroupInfo(groupId).catch(() => undefined) - this.fetchGroupMembers(groupId, true).catch(() => undefined) - } + case ImMessageType.GROUP_CREATE: + this.applyGroupCreateNotification(groupId, payload) break - } case ImMessageType.GROUP_NAME_UPDATE: - if (payload.newName) { - this.updateGroupFields(groupId, { name: payload.newName }) - } + this.applyGroupNameUpdateNotification(groupId, payload) break case ImMessageType.GROUP_NOTICE_UPDATE: - this.updateGroupFields(groupId, { notice: payload.notice ?? '' }) + this.applyGroupNoticeUpdateNotification(groupId, payload) break - case ImMessageType.GROUP_INFO_UPDATE: { - // 兜底 NAME / NOTICE 之外的群字段变更(avatar 等);按非 null 字段累积更新 - const fields: Partial = {} - if (payload.avatar) { - fields.avatar = payload.avatar - } - if (Object.keys(fields).length > 0) { - this.updateGroupFields(groupId, fields) - } + case ImMessageType.GROUP_INFO_UPDATE: + this.applyGroupInfoUpdateNotification(groupId, payload) break - } case ImMessageType.GROUP_DISSOLVE: - // 群解散:所有成员(含群主)收到广播 → 本端自行清群 this.removeGroup(groupId) break - case ImMessageType.GROUP_MEMBER_INVITE: { - // 被邀请者:本端 group 还没就位,先 fetchGroupInfo bootstrap,再拉成员 - // 已在群成员:只刷成员列表(新成员 nickname / avatar 不在 payload,必须 fetch) - const selfUserId = getCurrentUserId() - const memberIds: number[] = payload.memberUserIds || [] - const selfInvited = !!selfUserId && memberIds.includes(selfUserId) - if (selfInvited && !this.getGroup(groupId)) { - this.fetchGroupInfo(groupId).catch(() => undefined) - } - this.fetchGroupMembers(groupId, true).catch(() => undefined) + case ImMessageType.GROUP_MEMBER_INVITE: + this.applyGroupMemberInviteNotification(groupId, payload) break - } - case ImMessageType.GROUP_MEMBER_QUIT: { - // 退群者本人多端同步:清群;其他成员:从本地成员列表移除 - const selfUserId = getCurrentUserId() - if (selfUserId && payload.operatorUserId === selfUserId) { - this.removeGroup(groupId) - } else if (payload.operatorUserId) { - this.removeMembersLocal(groupId, [payload.operatorUserId]) - } + case ImMessageType.GROUP_MEMBER_QUIT: + this.applyGroupMemberQuitNotification(groupId, payload) break - } - case ImMessageType.GROUP_MEMBER_KICK: { - // 被踢者本人:清群;其他成员:从本地成员列表移除 - const selfUserId = getCurrentUserId() - const memberIds: number[] = payload.memberUserIds || [] - if (selfUserId && memberIds.includes(selfUserId)) { - this.removeGroup(groupId) - } else if (memberIds.length) { - this.removeMembersLocal(groupId, memberIds) - } + case ImMessageType.GROUP_MEMBER_KICK: + this.applyGroupMemberKickNotification(groupId, payload) break - } case ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE: - if (payload.operatorUserId) { - this.updateMemberDisplayUserName(groupId, payload.operatorUserId, payload.displayUserName ?? '') - } + this.applyGroupMemberNicknameUpdateNotification(groupId, payload) break case ImMessageType.GROUP_ADMIN_ADD: this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN) @@ -570,13 +541,94 @@ export const useGroupStore = defineStore('imGroupStore', { this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.NORMAL) break case ImMessageType.GROUP_OWNER_TRANSFER: - if (payload.operatorUserId && payload.newOwnerUserId) { - this.transferOwner(groupId, payload.operatorUserId, payload.newOwnerUserId) - } + this.applyGroupOwnerTransferNotification(groupId, payload) break } }, + /** 创建群广播:创建者多端同步 + 初始成员 bootstrap;payload.memberUserIds 含自己 → 拉群详情 / 成员;本端发起者已经 upsert 过本群,跳过避免双拉 */ + applyGroupCreateNotification(groupId: number, payload: GroupNotificationPayload) { + if (!isSelfInPayloadMembers(payload)) { + return + } + const selfUserId = getCurrentUserId() + const selfIsOperator = !!selfUserId && payload.operatorUserId === selfUserId + if (selfIsOperator && this.getGroup(groupId)) { + return + } + this.fetchGroupInfo(groupId).catch(() => undefined) + this.fetchGroupMembers(groupId, true).catch(() => undefined) + }, + + /** 群名变更:按 newName 局部更新本地群名 */ + applyGroupNameUpdateNotification(groupId: number, payload: GroupNotificationPayload) { + if (payload.newName) { + this.updateGroupFields(groupId, { name: payload.newName }) + } + }, + + /** 群公告变更:按 newNotice 局部更新(允许空串作为「清空公告」) */ + applyGroupNoticeUpdateNotification(groupId: number, payload: GroupNotificationPayload) { + this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' }) + }, + + /** 群信息变更(NAME / NOTICE 之外字段,当前承载头像变更) */ + applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) { + const fields: Partial = {} + if (payload.newAvatar) { + fields.avatar = payload.newAvatar + } + if (Object.keys(fields).length > 0) { + this.updateGroupFields(groupId, fields) + } + }, + + /** 成员加入:被邀请者本端 group 未就位先 fetchGroupInfo bootstrap;所有人都刷成员列表(新成员 nickname / avatar 不在 payload) */ + applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) { + if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) { + this.fetchGroupInfo(groupId).catch(() => undefined) + } + this.fetchGroupMembers(groupId, true).catch(() => undefined) + }, + + /** 成员退群:退群者本人多端同步走 removeGroup;其他成员从本地列表移除 quitter */ + applyGroupMemberQuitNotification(groupId: number, payload: GroupNotificationPayload) { + const selfUserId = getCurrentUserId() + if (selfUserId && payload.operatorUserId === selfUserId) { + this.removeGroup(groupId) + } else if (payload.operatorUserId) { + this.removeMembersLocal(groupId, [payload.operatorUserId]) + } + }, + + /** 成员被移出:被踢者本人 removeGroup;其他成员从本地列表移除被踢者 */ + applyGroupMemberKickNotification(groupId: number, payload: GroupNotificationPayload) { + const memberIds = payload.memberUserIds || [] + if (isSelfInPayloadMembers(payload)) { + this.removeGroup(groupId) + } else if (memberIds.length) { + this.removeMembersLocal(groupId, memberIds) + } + }, + + /** 成员昵称变更:按 operatorUserId 局部更新对应 member.displayUserName */ + applyGroupMemberNicknameUpdateNotification(groupId: number, payload: GroupNotificationPayload) { + if (payload.operatorUserId) { + this.updateMemberDisplayUserName( + groupId, + payload.operatorUserId, + payload.displayUserName ?? '' + ) + } + }, + + /** 群主转让:旧群主 → NORMAL,新群主 → OWNER */ + applyGroupOwnerTransferNotification(groupId: number, payload: GroupNotificationPayload) { + if (payload.operatorUserId && payload.newOwnerUserId) { + this.transferOwner(groupId, payload.operatorUserId, payload.newOwnerUserId) + } + }, + /** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */ clear() { this.groups = [] diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 4cf98112e..3e59359a1 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -15,7 +15,8 @@ import type { WebSocketFrame, ImPrivateMessageDTO, ImGroupMessageDTO, - Message + Message, + Group } from '../types' /** @@ -73,7 +74,8 @@ const convertGroupMessage = ( * - 普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT):入库 + 当前会话自动已读 / 提示音 * - 已读 / 回执(READ / RECEIPT):多端已读同步、对方读后回执 * - 好友变更(FRIEND_ADD / DELETE / UPDATE):同步 friendStore + 级联刷新私聊会话 - * - 群个人信号(1530 GROUP_MEMBER_SETTING_UPDATE):同步 groupStore + 级联刷新群聊会话;群广播事件(1501-1520 OpenIM 段位)走 handleGroupMessage + applyGroupNotification 旁路(含 DISSOLVE/QUIT/KICK 自判清群) + * - 群个人信号(GROUP_MEMBER_SETTING_UPDATE):同步 groupStore + 级联刷新群聊会话 + * - 群广播事件(GROUP_*):走 handleGroupMessage + applyGroupNotification 旁路(含 DISSOLVE / QUIT / KICK 自判清群) */ export const useImWebSocketStore = defineStore('imWebSocketStore', { state: () => ({ @@ -518,7 +520,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (!group) { return } - const fields: Partial = {} + const fields: Partial = {} if (payload.muted != null) { fields.muted = payload.muted } diff --git a/src/views/im/manager/message/MessageContentPreview.vue b/src/views/im/manager/message/MessageContentPreview.vue index 6068634ce..ebcd650d6 100644 --- a/src/views/im/manager/message/MessageContentPreview.vue +++ b/src/views/im/manager/message/MessageContentPreview.vue @@ -76,7 +76,7 @@ [回执] - + { return raw }) -/** 是否群广播事件(1501-1520 / 1530) */ +/** 是否群广播事件 */ const isGroupNotificationType = computed(() => isGroupNotification(props.type ?? -1)) -/** 群广播事件 operatorUserId:用于把 senderNickname 仅覆盖到 operator 这一个 id 上 */ -const groupOperatorUserId = computed(() => { - try { - return JSON.parse(props.content || '{}')?.operatorUserId - } catch { - return undefined - } -}) - -/** 群广播事件文案:复用 utils/user.resolveGroupNotificationText,admin 端 resolveName 用 senderNickname(仅 operator)+ 用户(id) 兜底 */ +/** 群广播事件文案:复用 utils/user.resolveGroupNotificationText;admin 端 operator 用 senderNickname 直接覆盖,其它 id 退化为 用户(id) */ const groupNotificationText = computed(() => resolveGroupNotificationText( - { - type: props.type as number, - content: props.content || '', - targetId: 0 - } as Pick, - (id) => - id === groupOperatorUserId.value && props.senderNickname - ? props.senderNickname - : `用户(${id})` + { type: props.type, content: props.content }, + (id) => `用户(${id})`, + props.senderNickname ) ) diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index 72bab1133..07efad68b 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -13,51 +13,33 @@ export const ImMessageType = { FRIEND_ADD: 100, // 好友添加 FRIEND_DELETE: 101, // 好友删除 FRIEND_UPDATE: 102, // 好友更新(客户端收到后自行拉取) - // 群事件(1501-1520 直接复用 OpenIM 段位编号;1530+ 我们独有扩展;persistent=true 广播 + persistent=false 个人信号) - // 1500 mirror OpenIM GroupNotificationBegin marker,不使用 + // 群事件(1501-1520 复用 OpenIM 段位编号;1530+ 自有扩展段) GROUP_CREATE: 1501, // 群创建 - GROUP_INFO_UPDATE: 1502, // 群信息变更,NAME / NOTICE 之外字段兜底 + GROUP_INFO_UPDATE: 1502, // 群信息变更(NAME / NOTICE 之外字段兜底) // 1503 GROUP_JOIN_APPLICATION TODO 未实现:入群申请 GROUP_MEMBER_QUIT: 1504, // 成员退群 - // 1505 GROUP_APPLICATION_ACCEPTED TODO 未实现 - // 1506 GROUP_APPLICATION_REJECTED TODO 未实现 + // 1505 GROUP_APPLICATION_ACCEPTED TODO 未实现:入群申请通过 + // 1506 GROUP_APPLICATION_REJECTED TODO 未实现:入群申请拒绝 GROUP_OWNER_TRANSFER: 1507, // 群主转让 GROUP_MEMBER_KICK: 1508, // 成员被移出 GROUP_MEMBER_INVITE: 1509, // 成员加入 // 1510 GROUP_MEMBER_ENTER TODO 未实现:自由进群 GROUP_DISSOLVE: 1511, // 群解散 // 1512 GROUP_MEMBER_MUTED TODO 未实现:单成员禁言 - // 1513 GROUP_MEMBER_CANCEL_MUTED TODO 未实现 + // 1513 GROUP_MEMBER_CANCEL_MUTED TODO 未实现:单成员取消禁言 // 1514 GROUP_MUTED TODO 未实现:全群禁言 - // 1515 GROUP_CANCEL_MUTED TODO 未实现 + // 1515 GROUP_CANCEL_MUTED TODO 未实现:全群取消禁言 GROUP_MEMBER_NICKNAME_UPDATE: 1516, // 成员昵称变更(窄化到 displayUserName) GROUP_ADMIN_ADD: 1517, // 添加管理员 GROUP_ADMIN_REMOVE: 1518, // 撤销管理员 GROUP_NOTICE_UPDATE: 1519, // 群公告变更 GROUP_NAME_UPDATE: 1520, // 群名变更 - // 1530+ 我们独有扩展段 - GROUP_MEMBER_SETTING_UPDATE: 1530 // 群成员个人设置变更:muted / groupRemark 多端同步(个人) + GROUP_MEMBER_SETTING_UPDATE: 1530 // 群成员个人设置变更:muted / groupRemark 个人多端同步 } as const -/** 群广播事件 type 集合:1501-1520 OpenIM 段位(除 1530 个人设置同步),前端按 type 分发到 applyGroupNotification */ -const ImGroupNotificationTypes: number[] = [ - ImMessageType.GROUP_CREATE, - ImMessageType.GROUP_NAME_UPDATE, - ImMessageType.GROUP_NOTICE_UPDATE, - ImMessageType.GROUP_INFO_UPDATE, - ImMessageType.GROUP_DISSOLVE, - ImMessageType.GROUP_MEMBER_INVITE, - ImMessageType.GROUP_MEMBER_QUIT, - ImMessageType.GROUP_MEMBER_KICK, - ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE, - ImMessageType.GROUP_ADMIN_ADD, - ImMessageType.GROUP_ADMIN_REMOVE, - ImMessageType.GROUP_OWNER_TRANSFER -] - -/** 判断是否「群广播事件」 */ +/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_MEMBER_SETTING_UPDATE) 段位都算,GROUP_MEMBER_SETTING_UPDATE 是个人信号不算 */ export function isGroupNotification(type: number): boolean { - return ImGroupNotificationTypes.includes(type) + return type >= ImMessageType.GROUP_CREATE && type < ImMessageType.GROUP_MEMBER_SETTING_UPDATE } /** IM 普通消息类型集合(聊天气泡中显示,并作为会话最后一条摘要) */ diff --git a/src/views/im/utils/user.ts b/src/views/im/utils/user.ts index fcba2686f..06d63b862 100644 --- a/src/views/im/utils/user.ts +++ b/src/views/im/utils/user.ts @@ -14,16 +14,14 @@ import { ImConversationType, ImMessageType } from './constants' import { getCurrentUserId } from './storage' import { useFriendStore } from '../home/store/friendStore' import { useGroupStore } from '../home/store/groupStore' -import type { Friend, Group, Message } from '../home/types' +import type { Friend, Group } from '../home/types' /** * 私聊好友显示名:备注 > 真实昵称 * * displayName 是「我对这个人的私人称呼」属于我的数据,删好友(DISABLE)也保留;删了再加回来时备注自然延续,历史消息里仍以备注辨识 */ -export function getFriendDisplayName( - friend: Pick -): string { +export function getFriendDisplayName(friend: Pick): string { return friend.displayName || friend.nickname } @@ -151,35 +149,42 @@ export function getSenderRealNickname( } /** - * 群广播事件(GROUP_* 1501-1520 / 1530)的中文文案 + * 群广播事件(GROUP_* 系列)的中文文案 * * 按 message.type 取 content payload 字段,昵称默认走 getSenderDisplayName(备注 / 群昵称 / 真实昵称兜底); * 管理后台无 store,可传入 resolveName 自定义 id → 名字(如 senderNickname + 用户(id) 兜底); * home 端 MessageItem.vue / ConversationItem.vue / MessageHistory.vue 与 admin 端 MessageContentPreview.vue 共用 */ +export type GroupNotificationPayload = { + operatorUserId?: number + memberUserIds?: number[] + newOwnerUserId?: number + oldName?: string + newName?: string + oldNotice?: string + newNotice?: string + oldAvatar?: string + newAvatar?: string + displayUserName?: string +} + export function resolveGroupNotificationText( - message: Pick, - resolveName?: (userId: number) => string + message: { type?: number; content?: string; targetId?: number }, + resolveName?: (userId: number) => string, + operatorNameOverride?: string ): string { - const groupId = message.targetId - let payload: { - operatorUserId?: number - memberUserIds?: number[] - newOwnerUserId?: number - oldName?: string - newName?: string - notice?: string - avatar?: string - displayUserName?: string - } = {} + let payload: GroupNotificationPayload = {} try { payload = JSON.parse(message.content || '{}') } catch { return '' } const resolve = - resolveName || ((id: number) => getSenderDisplayName(id, ImConversationType.GROUP, groupId)) - const operatorName = payload.operatorUserId ? resolve(payload.operatorUserId) : '' + resolveName || + ((id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)) + const operatorName = payload.operatorUserId + ? (operatorNameOverride ?? resolve(payload.operatorUserId)) + : '' const memberNames = (payload.memberUserIds || []).map(resolve).join('、') const newOwnerName = payload.newOwnerUserId ? resolve(payload.newOwnerUserId) : '' switch (message.type) { @@ -191,7 +196,7 @@ export function resolveGroupNotificationText( return `${operatorName} 更新了群公告` case ImMessageType.GROUP_INFO_UPDATE: // 兜底事件:按非 null 字段优先匹配特化文案,全部为空时降级为 "更新了群信息" 通用文案 - if (payload.avatar) { + if (payload.newAvatar) { return `${operatorName} 更换了群头像` } return `${operatorName} 更新了群信息`