diff --git a/src/api/im/group/index.ts b/src/api/im/group/index.ts index a363ab9ae..ff732fa19 100644 --- a/src/api/im/group/index.ts +++ b/src/api/im/group/index.ts @@ -17,6 +17,7 @@ export interface ImGroupRespVO { // 群创建 Request VO export interface ImGroupCreateReqVO { name: string // 群名称 + memberUserIds?: number[] // 初始成员用户编号列表(建群同时邀请的好友,不含创建者自己) } // 群更新 Request VO diff --git a/src/views/im/home/components/group/GroupCreateDialog.vue b/src/views/im/home/components/group/GroupCreateDialog.vue index 89eb47920..bf2fcf428 100644 --- a/src/views/im/home/components/group/GroupCreateDialog.vue +++ b/src/views/im/home/components/group/GroupCreateDialog.vue @@ -93,7 +93,6 @@ import Icon from '@/components/Icon/src/Icon.vue' import { useMessage } from '@/hooks/web/useMessage' import { createGroup } from '@/api/im/group' -import { inviteGroupMember } from '@/api/im/group/member' import { useGroupStore } from '../../store/groupStore' import FriendItem from '../friend/FriendItem.vue' import type { FriendLite } from '../../types' @@ -195,7 +194,7 @@ function handleUncheck(friend: FriendCheckable) { friend.checked = false } -/** 创建群聊:建群 → 拉人 → upsert groupStore,最后 emit('created') 让父页跳转新会话 */ +/** 创建群聊:建群(同时邀请初始成员)→ upsert groupStore → emit('created') 让父页跳转新会话 */ async function handleOk() { const name = groupName.value.trim() const memberUserIds = checkedFriends.value.map((friend) => friend.id) @@ -205,13 +204,11 @@ async function handleOk() { } submitting.value = true try { - // 1.1 新建群聊 - const group = await createGroup({ name }) + // 1. 新建群聊 + const group = await createGroup({ name, memberUserIds }) if (!group?.id) { throw new Error('创建群失败:未返回群编号') } - // 1.2 拉好友入群 - await inviteGroupMember({ groupId: group.id, memberUserIds }) // 2.1 直接 upsert 进 groupStore,省一次 fetchGroups——服务端返回 VO 已经够建会话了 groupStore.upsertGroup({ 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 c59d27edf..6b8174401 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue @@ -152,6 +152,14 @@ {{ resolveTipText(message.content) }} + +
+ {{ resolveGroupNotificationText(message) }} +
+
+ +
+ {{ groupNotificationText }} +
+
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(() => isImage.value ? parseMessage(props.message.content) : null ) diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index 55da641dc..2a2372b63 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -8,6 +8,7 @@ import { ImMessageStatus, IM_AT_ALL_USER_ID, TIME_TIP_GAP_MS, + isGroupNotification, isNormalMessage } from '../../utils/constants' import { getCurrentUserId, imStorage, removeQuietly, StorageKeys } from '../../utils/storage' @@ -39,7 +40,7 @@ function deriveLastSenderDisplayName( // 2. 群聊兜底拉成员:分两种情况 // a. members 完全没加载(!membersLoaded)→ 拉整群(pullOnce 期间多个 senderId 都缺时,单飞表会 dedup 成一次请求) - // b. members 已加载但缺这一个(新加入的成员,本端未收到 GROUP_MEMBER_UPDATE)→ 补齐这一个 + // b. members 已加载但缺这一个(新加入的成员,本端未收到 GROUP_MEMBER_SETTING_UPDATE)→ 补齐这一个 if (conversation.type === ImConversationType.GROUP) { const groupStore = useGroupStore() const group = groupStore.getGroup(conversation.targetId) @@ -365,6 +366,18 @@ export const useConversationStore = defineStore('imConversationStore', { conversationInfo: { type: number; targetId: number; name: string; avatar: string }, messageInfo: Message ) { + // 0. 群广播事件旁路:按 type 局部更新 groupStore 的 role / ownerUserId / 成员列表等状态 + if ( + conversationInfo.type === ImConversationType.GROUP && + isGroupNotification(messageInfo.type) + ) { + useGroupStore().applyGroupNotification( + conversationInfo.targetId, + messageInfo.type, + messageInfo.content + ) + } + // 1.1 查找或自动创建会话 let conversation = this.getConversation(conversationInfo.type, conversationInfo.targetId) if (!conversation) { diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index aac70a9a6..5e8390c7c 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -13,7 +13,7 @@ import { type ImGroupMemberRespVO } from '@/api/im/group/member' import { useConversationStore } from './conversationStore' -import { ImConversationType, ImGroupMemberRole } from '../../utils/constants' +import { ImConversationType, ImGroupMemberRole, ImMessageType } from '../../utils/constants' import { getCurrentUserId, imStorage, @@ -431,6 +431,152 @@ export const useGroupStore = defineStore('imGroupStore', { this.saveGroups() }, + /** 本地剔除群成员(GROUP_MEMBER_QUIT / KICK 事件);不命中则等 fetchGroupMembers 兜底 */ + removeMembersLocal(groupId: number, userIds: number[]) { + 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)) + if (next.length === group.members.length) { + return + } + group.members = next + group.memberCount = next.length + this.saveGroupMembers(groupId) + }, + + /** 本地更新群成员的 displayUserName(GROUP_MEMBER_NICKNAME_UPDATE 事件);不命中则等 fetchGroupMembers 兜底 */ + updateMemberDisplayUserName(groupId: number, userId: number, displayUserName: string) { + const group = this.getGroup(groupId) + const member = group?.members?.find((m) => m.userId === userId) + if (!member || member.displayUserName === displayUserName) { + return + } + member.displayUserName = displayUserName + this.saveGroupMembers(groupId) + }, + + /** 局部更新群字段(name / notice / avatar 等);未命中本地缓存时静默忽略,等 fetchGroups 兜底 */ + updateGroupFields(groupId: number, fields: Partial) { + const group = this.getGroup(groupId) + if (!group) { + return + } + Object.assign(group, fields) + const conversationStore = useConversationStore() + conversationStore.updateConversation(ImConversationType.GROUP, groupId, { + name: getGroupDisplayName(group), + avatar: group.avatar, + muted: group.muted + }) + this.saveGroups() + }, + + /** + * 接收 GROUP_* 群广播事件,按 type 分发到对应 mutation + * + * WebSocket 实时收 + useMessagePuller 离线 pull 都走 conversationStore.insertMessage 旁路调用 + * store 里没缓存的群静默忽略,等 fetchGroups 兜底 + */ + applyGroupNotification(groupId: number, type: number, content?: string) { + if (!groupId) { + return + } + let payload: Record = {} + try { + payload = content ? JSON.parse(content) : {} + } catch (error) { + console.warn('[IM groupStore] applyGroupNotification 解析 content 失败', { groupId, type, content }, 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) + } + break + } + case ImMessageType.GROUP_NAME_UPDATE: + if (payload.newName) { + this.updateGroupFields(groupId, { name: payload.newName }) + } + break + case ImMessageType.GROUP_NOTICE_UPDATE: + this.updateGroupFields(groupId, { notice: payload.notice ?? '' }) + 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) + } + 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) + 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]) + } + 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) + } + break + } + case ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE: + if (payload.operatorUserId) { + this.updateMemberDisplayUserName(groupId, payload.operatorUserId, payload.displayUserName ?? '') + } + break + case ImMessageType.GROUP_ADMIN_ADD: + this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN) + break + case ImMessageType.GROUP_ADMIN_REMOVE: + 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) + } + break + } + }, + /** 切账号时仅清 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 82ea0dffe..4cf98112e 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -73,7 +73,7 @@ const convertGroupMessage = ( * - 普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT):入库 + 当前会话自动已读 / 提示音 * - 已读 / 回执(READ / RECEIPT):多端已读同步、对方读后回执 * - 好友变更(FRIEND_ADD / DELETE / UPDATE):同步 friendStore + 级联刷新私聊会话 - * - 群变更(GROUP_CREATE / UPDATE / DELETE / MEMBER_UPDATE):同步 groupStore + 级联刷新群聊会话 + * - 群个人信号(1530 GROUP_MEMBER_SETTING_UPDATE):同步 groupStore + 级联刷新群聊会话;群广播事件(1501-1520 OpenIM 段位)走 handleGroupMessage + applyGroupNotification 旁路(含 DISSOLVE/QUIT/KICK 自判清群) */ export const useImWebSocketStore = defineStore('imWebSocketStore', { state: () => ({ @@ -228,9 +228,9 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { }, /** - * 群聊统一帧分发:按 payload.type(ImMessageType)分到已读 / 回执 / 群变更 / 普通消息 + * 群聊统一帧分发:按 payload.type(ImMessageType)分到已读 / 回执 / 群个人信号 / 普通消息 * - * 对应后端 ImGroupMessageDTO 的 ofRead / ofReceipt / ofGroupCreate / ofGroupUpdate / ofGroupDelete / ofGroupMemberUpdate / ofSend + * 1530 GROUP_MEMBER_SETTING_UPDATE 是个人信号;其它(普通消息 + 1501-1520 OpenIM 段位群广播事件)走 handleGroupMessage 入库 + 触发 applyGroupNotification 旁路 */ dispatchGroupFrame(websocketMessage: ImGroupMessageDTO) { switch (websocketMessage.type) { @@ -240,20 +240,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { case ImMessageType.RECEIPT: this.handleGroupReceipt(websocketMessage) break - case ImMessageType.GROUP_CREATE: - this.handleGroupCreate(websocketMessage) - break - case ImMessageType.GROUP_UPDATE: - this.handleGroupUpdate(websocketMessage) - break - case ImMessageType.GROUP_DELETE: - this.handleGroupDelete(websocketMessage) - break - case ImMessageType.GROUP_MEMBER_UPDATE: - this.handleGroupMemberUpdate(websocketMessage) + case ImMessageType.GROUP_MEMBER_SETTING_UPDATE: + this.handleGroupMemberSettingUpdate(websocketMessage) break default: - // TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT 等普通消息 + // TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT + GROUP_* 群广播事件 this.handleGroupMessage(websocketMessage) } }, @@ -509,32 +500,34 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { // ==================== 群关系事件(承载于群聊通道,按 inner type 分流) ==================== - /** GROUP_CREATE:本端入群(建群 / 被拉入);拉取群详情入库 */ - handleGroupCreate(websocketMessage: ImGroupMessageDTO) { - const groupStore = useGroupStore() - groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined) - }, - - /** GROUP_UPDATE:群信息变更,重新拉一次群详情 */ - handleGroupUpdate(websocketMessage: ImGroupMessageDTO) { - const groupStore = useGroupStore() - groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined) - }, - - /** GROUP_DELETE:群解散 / 自己退群 / 被踢出;本端清除群 + 级联清理群聊会话 */ - handleGroupDelete(websocketMessage: ImGroupMessageDTO) { - const groupStore = useGroupStore() - groupStore.removeGroup(websocketMessage.groupId) - }, - /** - * GROUP_MEMBER_UPDATE:多端同步成员属性变更(昵称 / 免打扰 / 退群等) + * GROUP_MEMBER_SETTING_UPDATE:多端同步成员个人设置变更(muted / groupRemark) * - * 必须强刷成员而非群元数据——这些字段都在 ImGroupMemberRespVO 上,apiGetMyGroupList 不带;持久化后若不强刷,IDB 成员桶会长期陈旧 + * payload 携带变更字段,按非 null 字段直接局部更新;省一次 fetchGroupMembers 接口 */ - handleGroupMemberUpdate(websocketMessage: ImGroupMessageDTO) { + handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageDTO) { + let payload: { muted?: boolean; groupRemark?: string } = {} + try { + payload = JSON.parse(websocketMessage.content || '{}') + } catch (error) { + console.warn('[IM WS] handleGroupMemberSettingUpdate 解析 content 失败', error) + return + } const groupStore = useGroupStore() - groupStore.fetchGroupMembers(websocketMessage.groupId, true).catch(() => undefined) + const group = groupStore.getGroup(websocketMessage.groupId) + if (!group) { + return + } + const fields: Partial = {} + if (payload.muted != null) { + fields.muted = payload.muted + } + if (payload.groupRemark != null) { + fields.groupRemark = payload.groupRemark + } + if (Object.keys(fields).length > 0) { + groupStore.updateGroupFields(websocketMessage.groupId, fields) + } }, // ==================== 心跳 / 重连 ==================== diff --git a/src/views/im/manager/message/MessageContentPreview.vue b/src/views/im/manager/message/MessageContentPreview.vue index 4ae389791..6068634ce 100644 --- a/src/views/im/manager/message/MessageContentPreview.vue +++ b/src/views/im/manager/message/MessageContentPreview.vue @@ -76,7 +76,15 @@ [回执] - + + + {{ groupNotificationText }} + + + {{ fallbackText }} @@ -85,7 +93,7 @@ import { computed } from 'vue' import Icon from '@/components/Icon/src/Icon.vue' import { formatFileSize } from '@/utils/file' import { formatSeconds } from '@/utils/formatTime' -import { ImMessageType } from '@/views/im/utils/constants' +import { ImMessageType, isGroupNotification } from '@/views/im/utils/constants' import { parseMessage, resolveTipText, @@ -94,6 +102,8 @@ import { type AudioMessage, type VideoMessage } from '@/views/im/utils/message' +import { resolveGroupNotificationText } from '@/views/im/utils/user' +import type { Message } from '@/views/im/home/types' defineOptions({ name: 'ImMessageContentPreview' }) @@ -102,6 +112,8 @@ const props = defineProps<{ type?: number /** 消息 content(JSON 字符串或裸文本) */ content?: string + /** 发送人昵称:群广播事件用作 operatorName 兜底渲染 */ + senderNickname?: string }>() /** 各类型判定 */ @@ -185,4 +197,31 @@ const fallbackText = computed(() => { } catch {} 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) 兜底 */ +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})` + ) +) diff --git a/src/views/im/manager/message/group/GroupMessageDetail.vue b/src/views/im/manager/message/group/GroupMessageDetail.vue index 83fd24a15..4b1350305 100644 --- a/src/views/im/manager/message/group/GroupMessageDetail.vue +++ b/src/views/im/manager/message/group/GroupMessageDetail.vue @@ -32,7 +32,11 @@ {{ formatDate(detail.sendTime) }} - +
{{ formatJson(detail.content) }}
diff --git a/src/views/im/manager/message/group/index.vue b/src/views/im/manager/message/group/index.vue index 3eb1ff6fe..1edf550b2 100644 --- a/src/views/im/manager/message/group/index.vue +++ b/src/views/im/manager/message/group/index.vue @@ -90,7 +90,11 @@ /> diff --git a/src/views/im/manager/message/private/PrivateMessageDetail.vue b/src/views/im/manager/message/private/PrivateMessageDetail.vue index c13044b9a..c2683f9b2 100644 --- a/src/views/im/manager/message/private/PrivateMessageDetail.vue +++ b/src/views/im/manager/message/private/PrivateMessageDetail.vue @@ -2,7 +2,9 @@ {{ detail.id }} - {{ detail.clientMessageId || '-' }} + {{ + detail.clientMessageId || '-' + }} {{ detail.senderNickname }} ({{ detail.senderId }}) @@ -19,7 +21,11 @@ {{ formatDate(detail.sendTime) }}
- +
{{ formatJson(detail.content) }}
diff --git a/src/views/im/manager/message/private/index.vue b/src/views/im/manager/message/private/index.vue index 2df48240d..a9d954b71 100644 --- a/src/views/im/manager/message/private/index.vue +++ b/src/views/im/manager/message/private/index.vue @@ -87,7 +87,11 @@ diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index 823f1ac73..72bab1133 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -13,12 +13,53 @@ export const ImMessageType = { FRIEND_ADD: 100, // 好友添加 FRIEND_DELETE: 101, // 好友删除 FRIEND_UPDATE: 102, // 好友更新(客户端收到后自行拉取) - GROUP_CREATE: 200, // 群创建 - GROUP_UPDATE: 201, // 群信息变更 - GROUP_DELETE: 202, // 群删除(解散 / 退群 / 踢出均用此类型) - GROUP_MEMBER_UPDATE: 203 // 群成员信息变更(客户端收到后自行拉取) + // 群事件(1501-1520 直接复用 OpenIM 段位编号;1530+ 我们独有扩展;persistent=true 广播 + persistent=false 个人信号) + // 1500 mirror OpenIM GroupNotificationBegin marker,不使用 + GROUP_CREATE: 1501, // 群创建 + 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 未实现 + 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 未实现 + // 1514 GROUP_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 多端同步(个人) } 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 +] + +/** 判断是否「群广播事件」 */ +export function isGroupNotification(type: number): boolean { + return ImGroupNotificationTypes.includes(type) +} + /** IM 普通消息类型集合(聊天气泡中显示,并作为会话最后一条摘要) */ const ImMessageTypeNormals: number[] = [ ImMessageType.TEXT, diff --git a/src/views/im/utils/conversation.ts b/src/views/im/utils/conversation.ts index 680547bff..1d239f5de 100644 --- a/src/views/im/utils/conversation.ts +++ b/src/views/im/utils/conversation.ts @@ -6,9 +6,9 @@ // 2. fallbackName 由调用方传入(典型来源:Conversation.lastSenderDisplayName 快照),透传到 getSenderDisplayName 内部,算不出真名时兜底 // ==================================================================== -import { ImMessageType } from './constants' +import { ImMessageType, isGroupNotification } from './constants' import { parseMessage, resolveTipText, type TextMessage } from './message' -import { getSenderDisplayName } from './user' +import { getSenderDisplayName, resolveGroupNotificationText } from './user' import type { Message } from '../home/types' /** 会话主键:`type-targetId` 拼成稳定字符串,给 v-for :key、active 比对、map key 等场景共用 */ @@ -63,9 +63,12 @@ export function resolveConversationLastContent( case ImMessageType.TEXT: return parseMessage(message.content)?.content ?? '' case ImMessageType.TIP_TEXT: - // TIP_TEXT 后端常发裸字符串(群解散 / 退群 / 踢人),不能按 TextMessage JSON 解析,否则摘要变空 + // TIP_TEXT 后端常发裸字符串(私聊好友建立 / 解除等),不能按 TextMessage JSON 解析,否则摘要变空 return resolveTipText(message.content) default: + if (isGroupNotification(message.type)) { + return resolveGroupNotificationText(message) + } return parseMessage(message.content)?.content ?? '' } } diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts index b70f9e592..4eec0f253 100644 --- a/src/views/im/utils/message.ts +++ b/src/views/im/utils/message.ts @@ -9,7 +9,7 @@ import type { Message } from '../home/types' // 各类消息 payload interface 字段对齐后端;解析统一用 parseMessage, // 序列化直接 JSON.stringify(payload)。 // -// 例外:TIP_TEXT(系统提示,群解散 / 退群 / 踢人 等)后端会直接发裸字符串, +// 例外:TIP_TEXT(私聊好友建立 / 解除等系统提示)后端会直接发裸字符串, // 展示侧需走 resolveTipText 兼容裸字符串 + 老接口可能的 {"content":"..."} 两种形态。 // ==================================================================== @@ -151,7 +151,7 @@ export const getQuoteFromMessage = (content: string): QuoteMessage | null => { /** * 解析 TIP_TEXT(系统提示)文案 * - * 后端:群解散 / 退群 / 踢人 等系统提示直接发裸字符串;老接口可能包成 {"content": "..."}。 + * 后端:私聊好友建立 / 解除等系统提示直接发裸字符串;老接口可能包成 {"content": "..."}。 * 解析得到 .content 就用,否则当裸文案返回,避免出现空行。 * * MessageItem / conversationStore.resolveLastContent / MessageHistory.renderContent 三处共用, diff --git a/src/views/im/utils/user.ts b/src/views/im/utils/user.ts index 7bb1073a3..fcba2686f 100644 --- a/src/views/im/utils/user.ts +++ b/src/views/im/utils/user.ts @@ -10,11 +10,11 @@ // ==================================================================== import { useUserStore } from '@/store/modules/user' -import { ImConversationType } from './constants' +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 } from '../home/types' +import type { Friend, Group, Message } from '../home/types' /** * 私聊好友显示名:备注 > 真实昵称 @@ -150,6 +150,72 @@ export function getSenderRealNickname( return String(senderId) } +/** + * 群广播事件(GROUP_* 1501-1520 / 1530)的中文文案 + * + * 按 message.type 取 content payload 字段,昵称默认走 getSenderDisplayName(备注 / 群昵称 / 真实昵称兜底); + * 管理后台无 store,可传入 resolveName 自定义 id → 名字(如 senderNickname + 用户(id) 兜底); + * home 端 MessageItem.vue / ConversationItem.vue / MessageHistory.vue 与 admin 端 MessageContentPreview.vue 共用 + */ +export function resolveGroupNotificationText( + message: Pick, + resolveName?: (userId: number) => string +): string { + const groupId = message.targetId + let payload: { + operatorUserId?: number + memberUserIds?: number[] + newOwnerUserId?: number + oldName?: string + newName?: string + notice?: string + avatar?: string + displayUserName?: string + } = {} + 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) : '' + const memberNames = (payload.memberUserIds || []).map(resolve).join('、') + const newOwnerName = payload.newOwnerUserId ? resolve(payload.newOwnerUserId) : '' + switch (message.type) { + case ImMessageType.GROUP_CREATE: + return `${operatorName} 创建了群聊` + case ImMessageType.GROUP_NAME_UPDATE: + return `${operatorName} 将群名修改为 "${payload.newName ?? ''}"` + case ImMessageType.GROUP_NOTICE_UPDATE: + return `${operatorName} 更新了群公告` + case ImMessageType.GROUP_INFO_UPDATE: + // 兜底事件:按非 null 字段优先匹配特化文案,全部为空时降级为 "更新了群信息" 通用文案 + if (payload.avatar) { + return `${operatorName} 更换了群头像` + } + return `${operatorName} 更新了群信息` + case ImMessageType.GROUP_DISSOLVE: + return `${operatorName} 解散了群聊` + case ImMessageType.GROUP_MEMBER_INVITE: + return `${operatorName} 邀请 ${memberNames} 加入群聊` + case ImMessageType.GROUP_MEMBER_QUIT: + return `${operatorName} 退出了群聊` + case ImMessageType.GROUP_MEMBER_KICK: + return `${operatorName} 移出了 ${memberNames}` + case ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE: + return `${operatorName} 修改群昵称为 "${payload.displayUserName ?? ''}"` + case ImMessageType.GROUP_ADMIN_ADD: + return `${operatorName} 将 ${memberNames} 设为管理员` + case ImMessageType.GROUP_ADMIN_REMOVE: + return `${operatorName} 撤销了 ${memberNames} 的管理员身份` + case ImMessageType.GROUP_OWNER_TRANSFER: + return `${operatorName} 已将群主转让给 ${newOwnerName}` + default: + return '' + } +} + /** 性别图标:男 1 / 女 2,0 / null / undefined 一律不展示,对齐微信留白 */ export function getGenderIcon(sex?: number): string { if (sex === 1) {