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

im
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 { useGroupStore } from '@/views/im/home/store/groupStore'
import { useFriendStore } from '@/views/im/home/store/friendStore' import { useFriendStore } from '@/views/im/home/store/friendStore'
import { useDraftStore } from '@/views/im/home/store/draftStore' import { useDraftStore } from '@/views/im/home/store/draftStore'
import { useUserStore } from '@/store/modules/user'
import { getMemberDisplayName } from '@/views/im/utils/user' import { getMemberDisplayName } from '@/views/im/utils/user'
import { useMessageSender } from '@/views/im/home/composables/useMessageSender' import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
import { getConversationKey } from '@/views/im/utils/conversation' 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 { import {
serializeMessage, serializeMessage,
type ImageMessage, type ImageMessage,
@ -191,7 +191,6 @@ const conversationStore = useConversationStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const friendStore = useFriendStore() const friendStore = useFriendStore()
const draftStore = useDraftStore() const draftStore = useDraftStore()
const userStore = useUserStore()
const { send, sendRaw } = useMessageSender() const { send, sendRaw } = useMessageSender()
const editorRef = useTemplateRef<HTMLDivElement>('editorRef') const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
@ -597,55 +596,10 @@ const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP () => conversationStore.activeConversation?.type === ImConversationType.GROUP
) )
// ==================== / ==================== // ==================== / ====================
/** 禁言/封禁覆盖层信息:优先级 封禁 > 全群禁言(群主/管理员豁免) > 成员禁言 */ /** 禁言 / 封禁覆盖层handleResend 重试 / uploadAndSendMedia 上传完后也共用同一份,避免绕过 overlay */
const muteOverlay = computed<{ text: string; icon: string } | null>(() => { const muteOverlay = useMuteOverlay()
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 预拉) */ /** 从 groupStore 读当前激活群的成员(切会话时由 MessagePanel 预拉) */
const groupMembers = computed<GroupMemberLite[]>(() => { 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) { if (muteOverlay.value) {
return return
} }
@ -862,42 +826,40 @@ async function uploadAndSendImage(file: File) {
} }
const replyQuote = consumeReply() const replyQuote = consumeReply()
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', opts.file)
const url = ((await updateFile(form)) as { data?: string })?.data const url = ((await updateFile(form)) as { data?: string })?.data
if (!url) { if (!url) {
return return
} }
if (!isStillSameConversation(startKey, '图片')) { if (!isStillSameConversation(startKey, opts.kind)) {
return return
} }
const payload = withQuotePayload<ImageMessage>({ url }, replyQuote) // muteOverlay
await sendRaw(ImMessageType.IMAGE, serializeMessage(payload)) 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 让接收端展示文件名和体积 */ /** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
async function uploadAndSendFile(file: File) { async function uploadAndSendFile(file: File) {
if (muteOverlay.value) { await uploadAndSendMedia<FileMessage>({
return file,
} type: ImMessageType.FILE,
const startKey = getActiveConversationKey() kind: '文件',
if (!startKey) { buildPayload: (url) => ({ url, name: file.name, size: file.size })
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))
} }
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor整体走 sendRaw */ /** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor整体走 sendRaw */
@ -927,32 +889,15 @@ function openVoice() {
voiceVisible.value = true voiceVisible.value = true
emojiVisible.value = false emojiVisible.value = false
} }
/** VoiceRecorder 录完后回传 blob包成 webm 文件上传,发送 VOICE 消息 */ /** VoiceRecorder 录完回传 blob包成 webm File 后走通用 uploadAndSendMedia */
async function onVoiceSend(payload: { blob: Blob; duration: number }) { 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 file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
const form = new FormData() await uploadAndSendMedia<AudioMessage>({
form.append('file', file) file,
// request.upload axios response res.data get/post/put URL .data type: ImMessageType.VOICE,
const url = ((await updateFile(form)) as { data?: string })?.data kind: '语音',
if (!url) { buildPayload: (url) => ({ url, duration: payload.duration })
return })
}
if (!isStillSameConversation(startKey, '语音')) {
return
}
const audioPayload = withQuotePayload<AudioMessage>(
{ url, duration: payload.duration },
replyQuote
)
await sendRaw(ImMessageType.VOICE, serializeMessage(audioPayload))
} }
// ==================== ==================== // ==================== ====================
@ -1118,6 +1063,10 @@ async function uploadAndSendVideo(file: File) {
if (!isStillSameConversation(startKey, '视频')) { if (!isStillSameConversation(startKey, '视频')) {
return return
} }
// 3.4 muteOverlay
if (muteOverlay.value) {
return
}
// 4. VideoMessage payload sendRaw / / // 4. VideoMessage payload sendRaw / /
const videoPayload = withQuotePayload<VideoMessage>( const videoPayload = withQuotePayload<VideoMessage>(