feat(im): 重构群通知相关,对齐 openim 的消息编号

im
YunaiV 2026-05-03 02:00:43 +08:00
parent fa27c27831
commit 43372c05ad
16 changed files with 407 additions and 67 deletions

View File

@ -17,6 +17,7 @@ export interface ImGroupRespVO {
// 群创建 Request VO
export interface ImGroupCreateReqVO {
name: string // 群名称
memberUserIds?: number[] // 初始成员用户编号列表(建群同时邀请的好友,不含创建者自己)
}
// 群更新 Request VO

View File

@ -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({

View File

@ -152,6 +152,14 @@
{{ resolveTipText(message.content) }}
</div>
<!-- 群广播事件文案1501-1520 / 1530 TIP_TEXT 同样的居中灰色样式 -->
<div
v-else-if="isGroupNotification(message.type)"
class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
>
{{ resolveGroupNotificationText(message) }}
</div>
<!-- 普通消息行 -->
<div
v-else
@ -303,11 +311,12 @@ import { useFriendStore } from '../../../../store/friendStore'
import {
getMemberDisplayName,
getSenderDisplayName,
getSenderRealNickname
getSenderRealNickname,
resolveGroupNotificationText
} from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation'
import { useMessagePuller } from '@/views/im/home/composables/useMessagePuller'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import { ImConversationType, ImMessageType, isGroupNotification } from '@/views/im/utils/constants'
import {
parseMessage,
resolveTipText,

View File

@ -18,6 +18,14 @@
{{ tipText }}
</div>
<!-- 群广播事件1501-1520 / 1530 TIP_TEXT 同样的居中灰色样式文案按 type 拼装 -->
<div
v-else-if="isGroupNotificationMessage"
class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
>
{{ groupNotificationText }}
</div>
<!-- 撤回消息整行展示灰色 tip 文案 -->
<div
v-else-if="isRecall"
@ -219,7 +227,8 @@ import {
ImMessageType,
ImMessageStatus,
ImGroupReceiptStatus,
ImConversationType
ImConversationType,
isGroupNotification
} from '../../../../../utils/constants'
import {
buildQuoteFromMessage,
@ -244,7 +253,8 @@ import { useDraftStore } from '../../../../store/draftStore'
import {
getMemberDisplayName,
getSenderDisplayName,
getSenderRealNickname
getSenderRealNickname,
resolveGroupNotificationText
} from '../../../../../utils/user'
import { useImUiStore } from '../../../../store/uiStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
@ -352,6 +362,10 @@ const textContent = computed(() => parseMessage<TextMessage>(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<ImageMessage>(props.message.content) : null
)

View File

@ -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) {

View File

@ -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)
},
/** 本地更新群成员的 displayUserNameGROUP_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<Group>) {
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<string, any> = {}
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: {
// 创建群广播:创建者多端同步 + 初始成员 bootstrappayload.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<Group> = {}
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-memoryIDB 按 userId 分桶天然隔离,回切秒开 */
clear() {
this.groups = []

View File

@ -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.typeImMessageType / / /
* payload.typeImMessageType / / /
*
* 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_UPDATEmuted / 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<typeof group> = {}
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)
}
},
// ==================== 心跳 / 重连 ====================

View File

@ -76,7 +76,15 @@
[回执]
</span>
<!-- 系统事件类FRIEND_* / GROUP_*content JSON退 -->
<!-- 群广播事件1501-1520 / 1530拼装中文 tip 文案operator senderNicknamemember / newOwner 退化为 用户(id) -->
<span
v-else-if="isGroupNotificationType"
class="text-12px text-[var(--el-text-color-secondary)]"
>
{{ groupNotificationText }}
</span>
<!-- 系统事件类FRIEND_*content 通常是结构化 JSON回退原始预览 -->
<span v-else class="whitespace-pre-wrap break-all">{{ fallbackText }}</span>
</template>
@ -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
/** 消息 contentJSON 字符串或裸文本) */
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<number | undefined>(() => {
try {
return JSON.parse(props.content || '{}')?.operatorUserId
} catch {
return undefined
}
})
/** 群广播事件文案:复用 utils/user.resolveGroupNotificationTextadmin 端 resolveName 用 senderNickname仅 operator+ 用户(id) 兜底 */
const groupNotificationText = computed(() =>
resolveGroupNotificationText(
{
type: props.type as number,
content: props.content || '',
targetId: 0
} as Pick<Message, 'type' | 'content' | 'targetId'>,
(id) =>
id === groupOperatorUserId.value && props.senderNickname
? props.senderNickname
: `用户(${id})`
)
)
</script>

View File

@ -32,7 +32,11 @@
{{ formatDate(detail.sendTime) }}
</el-descriptions-item>
<el-descriptions-item label="消息内容" :span="2">
<MessageContentPreview :type="detail.type" :content="detail.content" />
<MessageContentPreview
:type="detail.type"
:content="detail.content"
:sender-nickname="detail.senderNickname"
/>
</el-descriptions-item>
<el-descriptions-item label="原始 JSON" :span="2">
<pre class="content-pre">{{ formatJson(detail.content) }}</pre>

View File

@ -90,7 +90,11 @@
/>
<el-table-column label="内容预览" align="left" min-width="240">
<template #default="{ row }">
<MessageContentPreview :type="row.type" :content="row.content" />
<MessageContentPreview
:type="row.type"
:content="row.content"
:sender-nickname="row.senderNickname"
/>
</template>
</el-table-column>
<el-table-column label="@用户" align="left" min-width="200" show-overflow-tooltip>

View File

@ -2,7 +2,9 @@
<el-dialog v-model="dialogVisible" title="私聊消息详情" width="700">
<el-descriptions :column="2" border>
<el-descriptions-item label="编号">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="客户端编号">{{ detail.clientMessageId || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户端编号">{{
detail.clientMessageId || '-'
}}</el-descriptions-item>
<el-descriptions-item label="发送人">
{{ detail.senderNickname }} ({{ detail.senderId }})
</el-descriptions-item>
@ -19,7 +21,11 @@
{{ formatDate(detail.sendTime) }}
</el-descriptions-item>
<el-descriptions-item label="消息内容" :span="2">
<MessageContentPreview :type="detail.type" :content="detail.content" />
<MessageContentPreview
:type="detail.type"
:content="detail.content"
:sender-nickname="detail.senderNickname"
/>
</el-descriptions-item>
<el-descriptions-item label="原始 JSON" :span="2">
<pre class="content-pre">{{ formatJson(detail.content) }}</pre>

View File

@ -87,7 +87,11 @@
</el-table-column>
<el-table-column label="内容预览" align="left" min-width="240">
<template #default="{ row }">
<MessageContentPreview :type="row.type" :content="row.content" />
<MessageContentPreview
:type="row.type"
:content="row.content"
:sender-nickname="row.senderNickname"
/>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="100">

View File

@ -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,

View File

@ -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<TextMessage>(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<TextMessage>(message.content)?.content ?? ''
}
}

View File

@ -9,7 +9,7 @@ import type { Message } from '../home/types'
// 各类消息 payload interface 字段对齐后端;解析统一用 parseMessage<T>
// 序列化直接 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

View File

@ -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<Message, 'type' | 'content' | 'targetId'>,
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 / 女 20 / null / undefined 一律不展示,对齐微信留白 */
export function getGenderIcon(sex?: number): string {
if (sex === 1) {