feat(im): 群禁言/封禁 UI 交互 + 群主解散群聊

一、群禁言交互
- MessageItem 右键菜单新增「禁言/解除禁言/移除」(权限校验)
- 新增 GroupMuteMemberDialog 禁言时长选择弹窗
- ConversationGroupSide 新增「全群禁言」开关
- MessageInput 新增禁言/封禁覆盖层,拦截所有发送入口
- canManageSender 目标角色未知时不展示管理菜单
- 全群禁言群主按 ownerUserId 直接豁免,不依赖成员列表

二、群封禁(GROUP_BANNED 1533)
- constants 激活 GROUP_BANNED 常量,扩展 isGroupNotification 范围
- groupStore 处理 1533 事件,实时更新 banned 字段
- MessageInput 覆盖层最高优先级判断 banned(红底)
- Group 类型、convertGroup 映射 banned 字段

三、禁言事件(1512-1515)
- constants 激活 GROUP_MEMBER_MUTED / CANCEL_MUTED / MUTED / CANCEL_MUTED
- groupStore 处理 1512-1515 事件,更新 muteEndTime / mutedAll
- user.ts 新增 4 条禁言 + 2 条封禁通知文案
- GroupNotificationPayload 扩展 mutedUserId / muteEndTime / banned

四、群主解散群聊
- ConversationGroupSide 底部按钮:群主显示「解散群聊」,非群主显示「退出群聊」
- 新增 handleDissolve 实现(二次确认 → dissolveGroup API → 清本地数据)
im
YunaiV 2026-05-05 18:41:28 +08:00
parent 7b4c7028a0
commit 390b66aee8
9 changed files with 436 additions and 19 deletions

View File

@ -0,0 +1,95 @@
<template>
<!-- 禁言时长选择弹窗 -->
<!-- TODO @AI样式有点丑你看看怎么优化下例如说横着 radio/Users/yunai/Downloads/iShot_2026-05-05_17.51.35.png -->
<el-dialog v-model="visible" title="设置禁言" width="360px" :close-on-click-modal="false">
<div class="flex flex-col gap-3">
<div class="text-sm text-[var(--el-text-color-regular)]">
禁言成员<span class="font-medium text-[var(--el-text-color-primary)]">{{
memberName
}}</span>
</div>
<el-radio-group v-model="selected" class="flex flex-col gap-2">
<el-radio v-for="opt in presets" :key="opt.value" :value="opt.value">
{{ opt.label }}
</el-radio>
<el-radio :value="0">
<div class="flex items-center gap-2">
<span>自定义</span>
<el-input-number
v-model="customMinutes"
:min="1"
:max="43200"
:disabled="selected !== 0"
size="small"
class="!w-100px"
/>
<span class="text-sm">分钟</span>
</div>
</el-radio>
</el-radio-group>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleConfirm"></el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { muteMember } from '@/api/im/group'
defineOptions({ name: 'ImGroupMuteMemberDialog' })
const emit = defineEmits<{
success: []
}>()
const { success: successMessage } = useMessage()
const visible = ref(false)
const loading = ref(false)
const groupId = ref(0)
const userId = ref(0)
const memberName = ref('')
const selected = ref(600) // 10
const customMinutes = ref(30)
const presets = [
{ label: '10 分钟', value: 600 },
{ label: '1 小时', value: 3600 },
{ label: '12 小时', value: 43200 },
{ label: '1 天', value: 86400 },
{ label: '7 天', value: 604800 }
]
/** 打开弹窗 */
function open(gid: number, uid: number, name: string) {
groupId.value = gid
userId.value = uid
memberName.value = name
selected.value = 600
customMinutes.value = 30
visible.value = true
}
/** 确认禁言 */
async function handleConfirm() {
const seconds = selected.value === 0 ? customMinutes.value * 60 : selected.value
if (seconds <= 0) {
return
}
loading.value = true
try {
await muteMember({ groupId: groupId.value, userId: userId.value, mutedSeconds: seconds })
successMessage('禁言成功')
visible.value = false
emit('success')
} finally {
loading.value = false
}
}
defineExpose({ open })
</script>

View File

@ -267,6 +267,11 @@
<span class="im-conversation-group-side__label">置顶聊天</span>
<el-switch :model-value="!!conversation?.top" @change="onTopChange" />
</div>
<!-- 全群禁言仅群主或管理员可操作 -->
<div v-if="isOwnerOrAdmin" class="im-conversation-group-side__row">
<span class="im-conversation-group-side__label">全群禁言</span>
<el-switch :model-value="!!currentMutedAll" @change="onMuteAllChange" />
</div>
</div>
<!-- ==================== 群主操作 ==================== -->
@ -300,10 +305,21 @@
</template>
</div>
<!-- ==================== 底部退出群聊 ==================== -->
<!-- 仅非群主入口群主退出走"解散群"另起一条路径这里不处理 -->
<div v-if="!isOwner" class="im-conversation-group-side__footer">
<!-- ==================== 底部退出 / 解散群聊 ==================== -->
<div class="im-conversation-group-side__footer">
<!-- 群主解散群聊 -->
<el-button
v-if="isOwner"
class="im-conversation-group-side__quit-btn"
type="danger"
plain
@click="handleDissolve"
>
解散群聊
</el-button>
<!-- 非群主退出群聊 -->
<el-button
v-else
class="im-conversation-group-side__quit-btn"
type="danger"
plain
@ -364,7 +380,9 @@ import {
updateGroup,
addGroupAdmin,
removeGroupAdmin,
transferGroupOwner
transferGroupOwner,
muteAll,
dissolveGroup
} from '@/api/im/group'
import { quitGroup, removeGroupMember, updateGroupMember } from '@/api/im/group/member'
import { useConversationStore } from '../../../../store/conversationStore'
@ -575,6 +593,32 @@ function onTopChange(value: boolean | string | number) {
conversationStore.setTop(props.conversation.type, props.conversation.targetId, !!value)
}
// ==================== ====================
/** 当前群是否全群禁言 */
const currentMutedAll = computed(() => {
if (!props.group) {
return false
}
return groupStore.getGroup(props.group.id)?.mutedAll ?? false
})
/** 全群禁言开关切换 */
async function onMuteAllChange(value: boolean | string | number) {
if (!props.group) {
return
}
// TODO @AI next newValue
const next = !!value
try {
await muteAll({ groupId: props.group.id, mutedAll: next })
message.success(next ? '已开启全群禁言' : '已关闭全群禁言')
emit('reload')
} catch {
message.error('操作失败')
}
}
// ==================== 退 ====================
/** 退出群聊(普通成员入口;群主退出走"解散群"是另一条路径,这里不处理) */
@ -597,6 +641,24 @@ async function handleQuit() {
visible.value = false
}
/** 解散群聊(仅群主入口) */
async function handleDissolve() {
if (!props.group) {
return
}
try {
await message.confirm('解散后所有成员将被移出,且无法恢复,确认解散吗?', '确认解散')
} catch {
return
}
const groupId = props.group.id
await dissolveGroup(groupId)
conversationStore.removeConversation(ImConversationType.GROUP, groupId)
groupStore.removeGroup(groupId)
message.success('群聊已解散')
visible.value = false
}
// ==================== ====================
// / + +

View File

@ -4,6 +4,15 @@
padding 给内层白卡片呼吸空间卡片自带边框就够区分输入区不再需要一条 border-t
-->
<div class="relative bg-[var(--el-bg-color-page)] px-3 pt-2 pb-3">
<!-- 禁言 / 封禁覆盖层优先级 封禁 > 全群禁言 > 成员禁言 -->
<div
v-if="muteOverlay"
class="message-input__mute-overlay"
:class="{ 'message-input__mute-overlay--banned': muteOverlay.icon === 'ant-design:stop-outlined' }"
>
<Icon :icon="muteOverlay.icon" :size="18" />
<span>{{ muteOverlay.text }}</span>
</div>
<!--
内层白色圆角卡片 = editor + 工具栏border + rounded 模拟微信"输入框"边界
避免之前"无框 Web 输入"的散开感border scoped CSSUnoCSS 不带 border-style preflight
@ -155,10 +164,11 @@ import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useGroupStore } from '@/views/im/home/store/groupStore'
import { useFriendStore } from '@/views/im/home/store/friendStore'
import { useDraftStore } from '@/views/im/home/store/draftStore'
import { useUserStore } from '@/store/modules/user'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
import { getConversationKey } from '@/views/im/utils/conversation'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import { ImConversationType, ImMessageType, ImGroupMemberRole } from '@/views/im/utils/constants'
import {
serializeMessage,
type ImageMessage,
@ -181,6 +191,7 @@ const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const userStore = useUserStore()
const { send, sendRaw } = useMessageSender()
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
@ -195,8 +206,8 @@ const canSend = ref(false) // editor 是否有可发送内容contenteditable
/** 维护 canSend + data-empty撑起 placeholder不写草稿restoreDraftToEditor 复用避免回流 */
function applyEditorUiState(editor: HTMLDivElement) {
const raw = editor.textContent || ''
// canSend trim /
canSend.value = !!raw.trim() && !!conversationStore.activeConversation
// canSend trim /
canSend.value = !!raw.trim() && !!conversationStore.activeConversation && !muteOverlay.value
// data-empty placeholder
// " / " 'true'/'false' CSS [data-empty]::before
// [data-empty='true'] <br> :empty JS
@ -331,7 +342,7 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
*/
async function handleSend(options?: { receipt?: boolean }) {
const editor = editorRef.value
if (!canSend.value || !editor) {
if (!canSend.value || !editor || muteOverlay.value) {
return
}
const { text, atUserIds } = collectFromEditor(editor)
@ -586,6 +597,56 @@ const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
// ==================== / ====================
/** 禁言/封禁覆盖层信息:优先级 封禁 > 全群禁言(群主/管理员豁免) > 成员禁言 */
const muteOverlay = computed<{ text: string; icon: string } | null>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return null
}
const group = groupStore.getGroup(conversation.targetId)
if (!group) {
return null
}
const myId = Number(userStore.getUser?.id) || 0
//
if (group.banned) {
return { text: '该群已被管理员封禁,无法发送消息', icon: 'ant-design:stop-outlined' }
}
// /
if (group.mutedAll) {
//
if (myId === group.ownerUserId) {
//
} else {
const myMember = group.members?.find((m) => m.userId === myId)
// /
const myRole = myMember?.role
if (!myRole || myRole === ImGroupMemberRole.NORMAL) {
//
if (myRole === ImGroupMemberRole.NORMAL) {
return { text: '全群禁言中,暂时无法发送消息', icon: 'ant-design:audio-muted-outlined' }
}
}
}
}
//
const myMember = group.members?.find((m) => m.userId === myId)
if (myMember?.muteEndTime) {
const endTime = new Date(myMember.muteEndTime)
if (endTime > new Date()) {
const pad = (n: number) => n.toString().padStart(2, '0')
const timeStr = `${pad(endTime.getMonth() + 1)}-${pad(endTime.getDate())} ${pad(endTime.getHours())}:${pad(endTime.getMinutes())}`
return {
text: `您已被禁言,解除时间:${timeStr}`,
icon: 'ant-design:audio-muted-outlined'
}
}
}
return null
})
/** 从 groupStore 读当前激活群的成员(切会话时由 MessagePanel 预拉) */
const groupMembers = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation
@ -792,6 +853,9 @@ function onKeydown(e: KeyboardEvent) {
// ==================== / ====================
/** 上传并发送 IMAGE 消息;quote 抓取后立即清 draft.reply 让顶部引用条同步消失 */
async function uploadAndSendImage(file: File) {
if (muteOverlay.value) {
return
}
const startKey = getActiveConversationKey()
if (!startKey) {
return
@ -812,6 +876,9 @@ async function uploadAndSendImage(file: File) {
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
async function uploadAndSendFile(file: File) {
if (muteOverlay.value) {
return
}
const startKey = getActiveConversationKey()
if (!startKey) {
return
@ -862,6 +929,9 @@ function openVoice() {
}
/** VoiceRecorder 录完后回传 blob包成 webm 文件上传,发送 VOICE 消息 */
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
if (muteOverlay.value) {
return
}
const startKey = getActiveConversationKey()
if (!startKey) {
return
@ -990,6 +1060,9 @@ async function probeVideoFile(file: File): Promise<VideoProbe> {
* 否则会落到错误的会话里切走再切回来不算变化key 仍相等
*/
async function uploadAndSendVideo(file: File) {
if (muteOverlay.value) {
return
}
// 1. key key
const startKey = getActiveConversationKey()
if (!startKey) {
@ -1123,4 +1196,26 @@ async function onVideoPicked(e: Event) {
.message-input__editor :deep(.mention-token) {
color: var(--el-color-primary);
}
/* 禁言 / 封禁覆盖层:绝对定位在外层容器上,遮挡整个输入卡片 */
.message-input__mute-overlay {
position: absolute;
inset: 8px 12px 12px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
font-size: 14px;
color: var(--el-color-warning-dark-2);
background-color: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-5);
}
/* 封禁态:红底,区别于禁言的橙底 */
.message-input__mute-overlay--banned {
color: var(--el-color-danger-dark-2);
background-color: var(--el-color-danger-light-9);
border-color: var(--el-color-danger-light-5);
}
</style>

View File

@ -230,7 +230,8 @@ import {
isGroupNotification,
isNormalMessage
} from '@/views/im/utils/constants'
import { pinGroupMessage as apiPinGroupMessage } from '@/api/im/group'
import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '@/api/im/group'
import { removeGroupMember } from '@/api/im/group/member'
import {
buildQuoteFromMessage,
getQuoteFromMessage,
@ -276,6 +277,10 @@ const props = defineProps<{
const emit = defineEmits<{
/** 引用块点击 → MessagePanel 滚定位 + 高亮 */
locate: [messageId: number]
/** 禁言:需要父组件打开时长选择弹窗 */
mute: [groupId: number, userId: number, displayName: string]
/** 数据变更后刷新群信息 */
reload: []
}>()
// ==================== Stores / Hooks ====================
@ -540,6 +545,9 @@ const isAtMe = computed(() => {
const MENU_KEYS = {
REPLY: 'REPLY',
PIN: 'PIN',
MUTE: 'MUTE',
UNMUTE: 'UNMUTE',
KICK: 'KICK',
RECALL: 'RECALL',
DELETE: 'DELETE'
} as const
@ -585,6 +593,32 @@ async function handleContextMenu(e: MouseEvent) {
icon: 'ant-design:pushpin-outlined'
})
}
// / / + +
if (currentGroup.value && !props.message.selfSend && canManageSender.value) {
const senderMember = currentGroup.value.members?.find((m) => m.userId === props.message.senderId)
const isMuted = senderMember?.muteEndTime && new Date(senderMember.muteEndTime) > new Date()
if (isMuted) {
items.push({
key: MENU_KEYS.UNMUTE,
name: '解除禁言',
icon: 'ant-design:audio-outlined',
divided: true
})
} else {
items.push({
key: MENU_KEYS.MUTE,
name: '禁言',
icon: 'ant-design:audio-muted-outlined',
divided: true
})
}
items.push({
key: MENU_KEYS.KICK,
name: '移除',
icon: 'ant-design:user-delete-outlined',
danger: true
})
}
// /
// - + id0+ + RECALL
// - / /
@ -618,6 +652,12 @@ async function handleContextMenu(e: MouseEvent) {
handleReply()
} else if (item.key === MENU_KEYS.PIN) {
await handlePin()
} else if (item.key === MENU_KEYS.MUTE) {
handleMute()
} else if (item.key === MENU_KEYS.UNMUTE) {
await handleUnmute()
} else if (item.key === MENU_KEYS.KICK) {
await handleKick()
} else if (item.key === MENU_KEYS.RECALL) {
await handleRecall()
} else if (item.key === MENU_KEYS.DELETE) {
@ -641,6 +681,20 @@ const myGroupRole = computed(() => {
return currentGroup.value?.members?.find((m) => m.userId === myId)?.role
})
/** 是否可管理该消息发送人:我的角色高于目标角色(群主 > 管理员 > 普通成员);目标角色未知时不展示 */
const canManageSender = computed(() => {
if (!currentGroup.value || !myGroupRole.value) {
return false
}
const senderMember = currentGroup.value.members?.find((m) => m.userId === props.message.senderId)
//
if (!senderMember?.role) {
return false
}
// 1= 2= 3=
return myGroupRole.value < senderMember.role
})
/** 是否允许置顶(已置顶消息不再展示菜单项,由置顶面板的「移除」入口承接):群聊 + 普通消息 + 已落库 + 未撤回 + 群主或管理员 + 未置顶 */
const canPin = computed(
() =>
@ -720,6 +774,45 @@ async function handleResend() {
* 删除消息本地软删仅从 conversationStore.messages 移除不调后端
* 区别于"撤回"服务端没动多端登录时其它客户端 / 群里其他人依然能看到这条
*/
/** 禁言emit 给父组件打开时长选择弹窗(避免 MessageItem 过重) */
function handleMute() {
const group = currentGroup.value
if (!group) {
return
}
const name = senderDisplayName.value || '该成员'
emit('mute', group.id, props.message.senderId, name)
}
/** 解除禁言 */
async function handleUnmute() {
const group = currentGroup.value
if (!group) {
return
}
try {
await confirmDialog('确定解除该成员的禁言吗?', '解除禁言')
await cancelMuteMember({ groupId: group.id, userId: props.message.senderId })
successMessage('已解除禁言')
emit('reload')
} catch {}
}
/** 移除群成员 */
async function handleKick() {
const group = currentGroup.value
if (!group) {
return
}
const name = senderDisplayName.value || '该成员'
try {
await confirmDialog(`确定将「${name}」移出群聊吗?`, '移除成员')
await removeGroupMember({ groupId: group.id, memberUserIds: [props.message.senderId] })
successMessage('已移除')
emit('reload')
} catch {}
}
function handleDelete() {
const conversation = conversationStore.activeConversation
if (!conversation) {

View File

@ -103,6 +103,8 @@
:message="msg"
:prev-message="messages[index - 1]"
@locate="handleLocate"
@mute="handleMuteMember"
@reload="reloadGroupData"
/>
</div>
@ -147,6 +149,9 @@
<!-- 历史消息抽屉 -->
<MessageHistory v-model="historyVisible" @locate="handleLocate" />
<!-- 禁言时长选择弹窗 -->
<GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" />
</template>
<div
v-else
@ -177,6 +182,7 @@ import ConversationGroupPinned from './ConversationGroupPinned.vue'
import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue'
import type { FriendLite, GroupLite } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
import GroupMuteMemberDialog from '../../../../components/group/GroupMuteMemberDialog.vue'
defineOptions({ name: 'ImMessagePanel' })
@ -338,6 +344,12 @@ function reloadGroupData() {
const historyVisible = ref(false)
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>()
/** 消息右键菜单「禁言」→ 打开时长选择弹窗 */
function handleMuteMember(groupId: number, userId: number, displayName: string) {
muteMemberDialogRef.value?.open(groupId, userId, displayName)
}
/** 信息抽屉的 toggle跟 header 上 3 点图标按钮共用 */
function toggleSide() {

View File

@ -547,6 +547,21 @@ export const useGroupStore = defineStore('imGroupStore', {
case ImMessageType.GROUP_MESSAGE_UNPIN:
this.applyGroupMessageUnpinNotification(groupId, payload)
break
case ImMessageType.GROUP_MEMBER_MUTED:
this.applyGroupMemberMutedNotification(groupId, payload)
break
case ImMessageType.GROUP_MEMBER_CANCEL_MUTED:
this.applyGroupMemberCancelMutedNotification(groupId, payload)
break
case ImMessageType.GROUP_MUTED:
this.updateGroupFields(groupId, { mutedAll: true })
break
case ImMessageType.GROUP_CANCEL_MUTED:
this.updateGroupFields(groupId, { mutedAll: false })
break
case ImMessageType.GROUP_BANNED:
this.updateGroupFields(groupId, { banned: !!payload.banned })
break
}
},
@ -679,6 +694,26 @@ export const useGroupStore = defineStore('imGroupStore', {
this.saveGroups()
},
/** 单成员禁言:更新目标成员的 muteEndTime */
applyGroupMemberMutedNotification(groupId: number, payload: GroupNotificationPayload) {
const group = this.getGroup(groupId)
const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
if (member && payload.muteEndTime) {
member.muteEndTime = payload.muteEndTime
this.saveGroupMembers(groupId)
}
},
/** 单成员取消禁言:清空目标成员的 muteEndTime */
applyGroupMemberCancelMutedNotification(groupId: number, payload: GroupNotificationPayload) {
const group = this.getGroup(groupId)
const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
if (member) {
member.muteEndTime = undefined
this.saveGroupMembers(groupId)
}
},
/** 切账号时仅清 in-memoryIDB 按 userId 分桶天然隔离,回切秒开 */
clear() {
this.groups = []
@ -690,6 +725,7 @@ export const useGroupStore = defineStore('imGroupStore', {
}
})
// TODO @AIvo 改成 group更好理解点
function convertGroup(vo: ImGroupRespVO): Group {
return {
id: vo.id,
@ -697,7 +733,9 @@ function convertGroup(vo: ImGroupRespVO): Group {
avatar: vo.avatar,
notice: vo.notice,
ownerUserId: vo.ownerUserId,
pinnedMessages: vo.pinnedMessages?.map(convertGroupMessageVO)
pinnedMessages: vo.pinnedMessages?.map(convertGroupMessageVO),
mutedAll: vo.mutedAll,
banned: vo.banned
}
}
@ -730,7 +768,8 @@ function convertGroupMember(member: ImGroupMemberRespVO, groupId: number): Group
avatar: member.avatar,
displayUserName: member.displayUserName,
status: member.status,
role: member.role
role: member.role,
muteEndTime: member.muteEndTime
}
}

View File

@ -113,6 +113,8 @@ export interface Group {
notice?: string // 群公告
ownerUserId?: number // 群主用户编号
pinnedMessages?: Message[] // 群置顶消息列表
mutedAll?: boolean // 是否全群禁言
banned?: boolean // 是否被管理员封禁
// ========== 前端扩展字段user-per-group 维度) ==========
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
@ -133,6 +135,7 @@ export interface GroupMember {
displayUserName?: string // 该成员在群内自定义昵称(每个 member 一份;不与 nickname 合并,由消费方按需取舍)
status?: number // 在群 / 退群状态,对齐 CommonStatusEnum
role?: number // 成员角色,参见 ImGroupMemberRole 枚举1=群主 2=管理员 3=普通成员
muteEndTime?: string // 禁言到期时间ISO 字符串)
// ========== 前端扩展字段 ==========
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)

View File

@ -32,10 +32,10 @@ export const ImMessageType = {
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_MUTED: 1512, // 单成员禁言
GROUP_MEMBER_CANCEL_MUTED: 1513, // 单成员取消禁言
GROUP_MUTED: 1514, // 全群禁言
GROUP_CANCEL_MUTED: 1515, // 全群取消禁言
GROUP_MEMBER_NICKNAME_UPDATE: 1516, // 成员昵称变更(窄化到 displayUserName
GROUP_ADMIN_ADD: 1517, // 添加管理员
GROUP_ADMIN_REMOVE: 1518, // 撤销管理员
@ -44,14 +44,15 @@ export const ImMessageType = {
// ========== 自有扩展段1530+OpenIM 1500-1520 段位无对应物) ==========
GROUP_MEMBER_SETTING_UPDATE: 1530, // 群成员个人设置变更silent / groupRemark 个人多端同步
GROUP_MESSAGE_PIN: 1531, // 群消息置顶自有扩展OpenIM 无)
GROUP_MESSAGE_UNPIN: 1532 // 群消息取消置顶自有扩展OpenIM 无)
GROUP_MESSAGE_UNPIN: 1532, // 群消息取消置顶自有扩展OpenIM 无)
GROUP_BANNED: 1533 // 群封禁变更自有扩展OpenIM 无)
} as const
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_MESSAGE_UNPIN] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_BANNED] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */
export function isGroupNotification(type: number): boolean {
return (
type >= ImMessageType.GROUP_CREATE
&& type <= ImMessageType.GROUP_MESSAGE_UNPIN
&& type <= ImMessageType.GROUP_BANNED
&& type !== ImMessageType.GROUP_MEMBER_SETTING_UPDATE
)
}

View File

@ -167,7 +167,10 @@ export type GroupNotificationPayload = {
newAvatar?: string
displayUserName?: string
messageId?: number
/** PIN 事件携带的完整被置顶消息对象(前端直接 push 进 group.pinnedMessages避免回查群详情 */
mutedUserId?: number // 禁言目标用户
muteEndTime?: string // 禁言到期时间
banned?: boolean // 封禁状态
/** PIN 事件携带的完整被置顶消息对象 */
message?: {
id: number
clientMessageId?: string
@ -236,6 +239,20 @@ export function resolveGroupNotificationText(
return `${operatorName} 置顶了一条消息`
case ImMessageType.GROUP_MESSAGE_UNPIN:
return `${operatorName} 取消了一条置顶消息`
case ImMessageType.GROUP_MEMBER_MUTED: {
const mutedName = payload.mutedUserId ? resolve(payload.mutedUserId) : ''
return `${operatorName}${mutedName} 禁言`
}
case ImMessageType.GROUP_MEMBER_CANCEL_MUTED: {
const mutedName = payload.mutedUserId ? resolve(payload.mutedUserId) : ''
return `${operatorName} 解除了 ${mutedName} 的禁言`
}
case ImMessageType.GROUP_MUTED:
return `${operatorName} 开启了全群禁言`
case ImMessageType.GROUP_CANCEL_MUTED:
return `${operatorName} 关闭了全群禁言`
case ImMessageType.GROUP_BANNED:
return payload.banned ? `${operatorName} 封禁了该群` : `${operatorName} 解封了该群`
default:
return ''
}