✨ feat(im): 联动好友 / 群通知重构,抽 useMuteOverlay 统一禁言拦截与媒体上传公共骨架

pull/881/head
YunaiV 2026-05-05 21:33:27 +08:00
parent e48316231c
commit 055d4bab27
2 changed files with 116 additions and 104 deletions

View File

@ -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<MuteOverlayInfo | null> {
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
})
}

View File

@ -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<HTMLDivElement>('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<GroupMemberLite[]>(() => {
@ -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<P extends { quote?: QuoteMessage }>(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<ImageMessage>({ url }, replyQuote)
await sendRaw(ImMessageType.IMAGE, serializeMessage(payload))
// 上传期间被禁言也要拦:上传可能耗时几秒到几十秒,期间 muteOverlay 会变
if (muteOverlay.value) {
return
}
const payload = withQuotePayload<P>(opts.buildPayload(url), replyQuote)
await sendRaw(opts.type, serializeMessage(payload))
}
/** 上传并发送 IMAGE 消息 */
async function uploadAndSendImage(file: File) {
await uploadAndSendMedia<ImageMessage>({
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<FileMessage>(
{ url, name: file.name, size: file.size },
replyQuote
)
await sendRaw(ImMessageType.FILE, serializeMessage(payload))
await uploadAndSendMedia<FileMessage>({
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<AudioMessage>(
{ url, duration: payload.duration },
replyQuote
)
await sendRaw(ImMessageType.VOICE, serializeMessage(audioPayload))
await uploadAndSendMedia<AudioMessage>({
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<VideoMessage>(