From 055d4bab27ca5cade1f952f225a6a3cab44cf1d6 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 5 May 2026 21:33:27 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E8=81=94=E5=8A=A8?= =?UTF-8?q?=E5=A5=BD=E5=8F=8B=20/=20=E7=BE=A4=E9=80=9A=E7=9F=A5=E9=87=8D?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E6=8A=BD=20useMuteOverlay=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=A6=81=E8=A8=80=E6=8B=A6=E6=88=AA=E4=B8=8E=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E4=B8=8A=E4=BC=A0=E5=85=AC=E5=85=B1=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/home/composables/useMuteOverlay.ts | 63 +++++++ .../components/input/MessageInput.vue | 157 ++++++------------ 2 files changed, 116 insertions(+), 104 deletions(-) create mode 100644 src/views/im/home/composables/useMuteOverlay.ts diff --git a/src/views/im/home/composables/useMuteOverlay.ts b/src/views/im/home/composables/useMuteOverlay.ts new file mode 100644 index 000000000..bf1c468ae --- /dev/null +++ b/src/views/im/home/composables/useMuteOverlay.ts @@ -0,0 +1,63 @@ +import { computed, type ComputedRef } from 'vue' + +import { useUserStore } from '@/store/modules/user' +import { useConversationStore } from '../store/conversationStore' +import { useGroupStore } from '../store/groupStore' +import { ImConversationType, ImGroupMemberRole } from '../../utils/constants' + +export type MuteOverlayInfo = { text: string; icon: string } + +/** + * 当前激活会话的禁言 / 封禁状态;非群聊或无禁言时返回 null + * + * 优先级:封禁 > 全群禁言(群主 / 管理员豁免)> 成员禁言 + * + * MessageInput 顶部 overlay / handleResend 重试 / uploadAndSendMedia 上传完前都共用一份, + * 避免「输入框拦了但重试绕过」「上传期间被禁言但 sendRaw 仍发出去」之类的不一致 + */ +export function useMuteOverlay(): ComputedRef { + const conversationStore = useConversationStore() + const groupStore = useGroupStore() + const userStore = useUserStore() + return computed(() => { + 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' } + } + // 全群禁言:群主走 ownerUserId 比较直接豁免;其它人需要成员列表加载完才能区分管理员 vs 普通成员 + // - 加载完 + 我是管理员 → 豁免 + // - 加载完 + 我不是管理员(含已退群)→ 拦 + // - 加载未完 → 不显示 overlay,后端兜底拒绝普通成员;避免误拦管理员 + if (group.mutedAll && myId !== group.ownerUserId && group.membersLoaded) { + const myMember = group.members?.find((m) => m.userId === myId) + if (myMember?.role !== ImGroupMemberRole.ADMIN) { + return { text: '全群禁言中,暂时无法发送消息', icon: 'ant-design:audio-muted-outlined' } + } + } + // 成员禁言:muteEndTime 在未来才算 + 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 + }) +} diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue index 9037873c5..08a3e0d99 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue @@ -164,11 +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 { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay' import { getConversationKey } from '@/views/im/utils/conversation' -import { ImConversationType, ImMessageType, ImGroupMemberRole } from '@/views/im/utils/constants' +import { ImConversationType, ImMessageType } from '@/views/im/utils/constants' import { serializeMessage, type ImageMessage, @@ -191,7 +191,6 @@ const conversationStore = useConversationStore() const groupStore = useGroupStore() const friendStore = useFriendStore() const draftStore = useDraftStore() -const userStore = useUserStore() const { send, sendRaw } = useMessageSender() const editorRef = useTemplateRef('editorRef') @@ -597,55 +596,10 @@ 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 -}) +/** 禁言 / 封禁覆盖层;handleResend 重试 / uploadAndSendMedia 上传完后也共用同一份,避免绕过 overlay */ +const muteOverlay = useMuteOverlay() /** 从 groupStore 读当前激活群的成员(切会话时由 MessagePanel 预拉) */ const groupMembers = computed(() => { @@ -850,9 +804,19 @@ function onKeydown(e: KeyboardEvent) { } } -// ==================== 图片 / 文件上传 ==================== -/** 上传并发送 IMAGE 消息;quote 抓取后立即清 draft.reply 让顶部引用条同步消失 */ -async function uploadAndSendImage(file: File) { +// ==================== 图片 / 文件 / 语音 上传 ==================== + +/** + * 媒体上传 → 发送的公共骨架(image / file / voice 共用;video 因 probe + 双上传链路保留独立) + * + * 禁言态直接返回;锁会话 key + 消费 reply → 上传 → 校验仍在原会话 → 拼 payload + quote → sendRaw + */ +async function uploadAndSendMedia

(opts: { + file: File + type: number + kind: string + buildPayload: (url: string) => P +}) { if (muteOverlay.value) { return } @@ -862,42 +826,40 @@ async function uploadAndSendImage(file: File) { } const replyQuote = consumeReply() const form = new FormData() - form.append('file', file) + form.append('file', opts.file) const url = ((await updateFile(form)) as { data?: string })?.data if (!url) { return } - if (!isStillSameConversation(startKey, '图片')) { + if (!isStillSameConversation(startKey, opts.kind)) { return } - const payload = withQuotePayload({ url }, replyQuote) - await sendRaw(ImMessageType.IMAGE, serializeMessage(payload)) + // 上传期间被禁言也要拦:上传可能耗时几秒到几十秒,期间 muteOverlay 会变 + if (muteOverlay.value) { + return + } + const payload = withQuotePayload

(opts.buildPayload(url), replyQuote) + await sendRaw(opts.type, serializeMessage(payload)) +} + +/** 上传并发送 IMAGE 消息 */ +async function uploadAndSendImage(file: File) { + await uploadAndSendMedia({ + file, + type: ImMessageType.IMAGE, + kind: '图片', + buildPayload: (url) => ({ url }) + }) } /** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */ async function uploadAndSendFile(file: File) { - if (muteOverlay.value) { - return - } - const startKey = getActiveConversationKey() - if (!startKey) { - return - } - const replyQuote = consumeReply() - const form = new FormData() - form.append('file', file) - const url = ((await updateFile(form)) as { data?: string })?.data - if (!url) { - return - } - if (!isStillSameConversation(startKey, '文件')) { - return - } - const payload = withQuotePayload( - { url, name: file.name, size: file.size }, - replyQuote - ) - await sendRaw(ImMessageType.FILE, serializeMessage(payload)) + await uploadAndSendMedia({ + file, + type: ImMessageType.FILE, + kind: '文件', + buildPayload: (url) => ({ url, name: file.name, size: file.size }) + }) } /** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor,整体走 sendRaw) */ @@ -927,32 +889,15 @@ function openVoice() { voiceVisible.value = true emojiVisible.value = false } -/** VoiceRecorder 录完后回传 blob,包成 webm 文件上传,发送 VOICE 消息 */ +/** VoiceRecorder 录完回传 blob,包成 webm File 后走通用 uploadAndSendMedia */ async function onVoiceSend(payload: { blob: Blob; duration: number }) { - if (muteOverlay.value) { - return - } - const startKey = getActiveConversationKey() - if (!startKey) { - return - } - const replyQuote = consumeReply() const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type }) - const form = new FormData() - form.append('file', file) - // request.upload 返回完整 axios response(不是 res.data,跟 get/post/put 不一样),URL 在 .data 里取 - const url = ((await updateFile(form)) as { data?: string })?.data - if (!url) { - return - } - if (!isStillSameConversation(startKey, '语音')) { - return - } - const audioPayload = withQuotePayload( - { url, duration: payload.duration }, - replyQuote - ) - await sendRaw(ImMessageType.VOICE, serializeMessage(audioPayload)) + await uploadAndSendMedia({ + file, + type: ImMessageType.VOICE, + kind: '语音', + buildPayload: (url) => ({ url, duration: payload.duration }) + }) } // ==================== 视频 ==================== @@ -1118,6 +1063,10 @@ async function uploadAndSendVideo(file: File) { if (!isStillSameConversation(startKey, '视频')) { return } + // 3.4 视频上传期间被禁言也要拦:链路最长,最容易踩到 muteOverlay 期间触发 + if (muteOverlay.value) { + return + } // 4. 拼 VideoMessage payload 走通用 sendRaw(与图片 / 文件 / 语音同链路) const videoPayload = withQuotePayload(