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
parent
7b4c7028a0
commit
390b66aee8
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
// ==================== 群主操作 ====================
|
||||
// 移除群成员(群主 / 管理员可见)+ 设置群管理员(仅群主)+ 群主管理权转让(仅群主)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 CSS(UnoCSS 不带 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
// 「撤回 / 删除」二选一:
|
||||
// - 自己发送 + 已落库(id≠0)+ 未撤回 + 在撤回窗口内 → 撤回(推服务器把消息态置 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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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-memory,IDB 按 userId 分桶天然隔离,回切秒开 */
|
||||
clear() {
|
||||
this.groups = []
|
||||
|
|
@ -690,6 +725,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
}
|
||||
})
|
||||
|
||||
// TODO @AI:vo 改成 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 计算)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue