✨ feat(im): 联动好友 / 群通知重构,抽 useMuteOverlay 统一禁言拦截与媒体上传公共骨架
parent
e48316231c
commit
055d4bab27
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue