✨ feat(im): 群通话本端拒绝 / 挂断后立即从胶囊条移除自己,无需等后端推回
parent
dc318c8e75
commit
b9b085f1ee
|
|
@ -308,7 +308,8 @@ async function handleCancel() {
|
||||||
|
|
||||||
/** 被叫拒绝来电 */
|
/** 被叫拒绝来电 */
|
||||||
async function handleReject() {
|
async function handleReject() {
|
||||||
const room = rtcStore.incomingPayload?.room
|
const payload = rtcStore.incomingPayload
|
||||||
|
const room = payload?.room
|
||||||
if (room) {
|
if (room) {
|
||||||
try {
|
try {
|
||||||
await rejectCall(room)
|
await rejectCall(room)
|
||||||
|
|
@ -316,6 +317,10 @@ async function handleReject() {
|
||||||
console.warn('[Call] reject 失败', { room }, e)
|
console.warn('[Call] reject 失败', { room }, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 本端先行把自己从胶囊条移除,避免等后端 RTC_CALL(REJECTED) 推回的延迟
|
||||||
|
if (payload?.conversationType === ImConversationType.GROUP && payload.groupId) {
|
||||||
|
rtcStore.applyParticipantRejected({ ...payload, operatorUserId: getCurrentUserId() })
|
||||||
|
}
|
||||||
rtcStore.reset()
|
rtcStore.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,7 +340,8 @@ async function handleAccept() {
|
||||||
|
|
||||||
/** 通话中挂断 */
|
/** 通话中挂断 */
|
||||||
async function handleHangup() {
|
async function handleHangup() {
|
||||||
const room = rtcStore.call?.room
|
const call = rtcStore.call
|
||||||
|
const room = call?.room
|
||||||
if (room) {
|
if (room) {
|
||||||
try {
|
try {
|
||||||
await leaveCall(room)
|
await leaveCall(room)
|
||||||
|
|
@ -343,6 +349,15 @@ async function handleHangup() {
|
||||||
console.warn('[Call] leave 失败', { room }, e)
|
console.warn('[Call] leave 失败', { room }, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 群聊:本端先行把自己从胶囊条移除,避免等后端 1603 推回的延迟(私聊场景整通话结束走 END 移除整条)
|
||||||
|
if (call?.conversationType === ImConversationType.GROUP && call.groupId && room) {
|
||||||
|
rtcStore.applyParticipantDisconnected({
|
||||||
|
room,
|
||||||
|
userId: getCurrentUserId(),
|
||||||
|
conversationType: call.conversationType,
|
||||||
|
groupId: call.groupId
|
||||||
|
})
|
||||||
|
}
|
||||||
await lk.disconnect()
|
await lk.disconnect()
|
||||||
rtcStore.reset()
|
rtcStore.reset()
|
||||||
}
|
}
|
||||||
|
|
@ -402,6 +417,8 @@ async function handleAddMemberSuccess(userIds: number[]) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await inviteCall({ room: call.room, inviteeIds: userIds })
|
await inviteCall({ room: call.room, inviteeIds: userIds })
|
||||||
|
// 同步本地 inviteeIds,让新成员立即作为 pending 占位出现在网格里
|
||||||
|
rtcStore.appendInvitees(userIds)
|
||||||
message.success('已发送邀请')
|
message.success('已发送邀请')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[Call] invite 追加失败', { room: call.room, inviteeIds: userIds }, e)
|
console.error('[Call] invite 追加失败', { room: call.room, inviteeIds: userIds }, e)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
<div class="text-13px text-white/60 truncate">{{ tipText }}</div>
|
<div class="text-13px text-white/60 truncate">{{ tipText }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 群通话成员行;私聊无 -->
|
<!-- 群通话成员行;私聊无;接入中的人半透明展示 -->
|
||||||
<template v-if="isGroup && callMembers.length > 0">
|
<template v-if="isGroup && callMembers.length > 0">
|
||||||
<div class="mt-1 text-xs text-white/45">通话成员</div>
|
<div class="mt-1 text-xs text-white/45">通话成员</div>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
|
|
@ -42,6 +42,8 @@
|
||||||
:size="22"
|
:size="22"
|
||||||
radius="4px"
|
radius="4px"
|
||||||
:clickable="false"
|
:clickable="false"
|
||||||
|
:class="{ 'opacity-50': member.pending }"
|
||||||
|
:title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -69,12 +71,9 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import Icon from '@/components/Icon/src/Icon.vue'
|
import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import UserAvatar from '../user/UserAvatar.vue'
|
import UserAvatar from '../user/UserAvatar.vue'
|
||||||
import { useRtcStore } from '../../store/rtcStore'
|
|
||||||
import type { ImRtcCallNotification } from '../../store/rtcStore'
|
import type { ImRtcCallNotification } from '../../store/rtcStore'
|
||||||
|
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
|
||||||
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
||||||
import { ImConversationType } from '@/views/im/utils/constants'
|
|
||||||
import { getCurrentUserId } from '@/views/im/utils/storage'
|
|
||||||
import { getSenderAvatar, getSenderDisplayName } from '@/views/im/utils/user'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
payload: ImRtcCallNotification | null
|
payload: ImRtcCallNotification | null
|
||||||
|
|
@ -83,34 +82,15 @@ const props = defineProps<{
|
||||||
|
|
||||||
defineEmits<{ accept: []; reject: [] }>()
|
defineEmits<{ accept: []; reject: [] }>()
|
||||||
|
|
||||||
const rtcStore = useRtcStore()
|
|
||||||
|
|
||||||
/** 来电提示文案;区分语音 / 视频 */
|
/** 来电提示文案;区分语音 / 视频 */
|
||||||
const tipText = computed(() => {
|
const tipText = computed(() => {
|
||||||
if (!props.payload) return ''
|
if (!props.payload) return ''
|
||||||
return `邀请你${getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, props.payload.mediaType)}通话`
|
return `邀请你${getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, props.payload.mediaType)}通话`
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 群聊:已加入通话的成员(自己除外);缓存里 joinedUserIds 为空时降级到主叫,保证至少一头像 */
|
/** 群通话成员;缓存为空时用 INVITE 载荷里的主叫兜底,避免空白 */
|
||||||
const callMembers = computed(() => {
|
const callMembers = useGroupCallMembers(
|
||||||
if (!props.isGroup) {
|
computed(() => (props.isGroup ? props.payload?.groupId : undefined)),
|
||||||
return []
|
computed(() => props.payload?.inviterUserId)
|
||||||
}
|
)
|
||||||
const groupId = props.payload?.groupId
|
|
||||||
if (!groupId) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
const myId = getCurrentUserId()
|
|
||||||
const joined = rtcStore.getGroupCall(groupId)?.joinedUserIds ?? []
|
|
||||||
const ids = joined.length > 0
|
|
||||||
? joined.filter((id) => id !== myId)
|
|
||||||
: props.payload?.inviterUserId
|
|
||||||
? [props.payload.inviterUserId]
|
|
||||||
: []
|
|
||||||
return ids.map((userId) => ({
|
|
||||||
userId,
|
|
||||||
nickname: getSenderDisplayName(userId, ImConversationType.GROUP, groupId),
|
|
||||||
avatar: getSenderAvatar(userId, ImConversationType.GROUP, groupId) || undefined
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -26,25 +26,25 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 展开面板:在通话成员头像横排 + 加入按钮 -->
|
<!-- 展开面板:通话成员(含接入中)头像横排 + 加入按钮 -->
|
||||||
<div class="flex flex-col gap-4 items-center pt-2 pb-1">
|
<div class="flex flex-col gap-4 items-center pt-2 pb-1">
|
||||||
<div class="flex flex-wrap gap-1.5 justify-center max-w-[240px]">
|
<div class="flex flex-wrap gap-1.5 justify-center max-w-[240px]">
|
||||||
<div
|
<UserAvatar
|
||||||
v-for="member in joinedMembers"
|
v-for="member in memberList"
|
||||||
:key="member.userId"
|
:key="member.userId"
|
||||||
class="inline-flex"
|
:url="member.avatar"
|
||||||
:title="member.nickname"
|
:name="member.nickname"
|
||||||
>
|
:size="40"
|
||||||
<UserAvatar
|
radius="6px"
|
||||||
:url="member.avatar"
|
:clickable="false"
|
||||||
:name="member.nickname"
|
:class="{ 'opacity-50': member.pending }"
|
||||||
:size="40"
|
:title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
|
||||||
radius="6px"
|
/>
|
||||||
:clickable="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<!-- 首次填充时房内可能暂时 0 人;加入后由 ParticipantConnected 事件追加 -->
|
<!-- 首次填充时房内可能暂时 0 人;加入后由 ParticipantConnected 事件追加 -->
|
||||||
<div v-if="joinedCount === 0" class="p-3 text-13px text-[var(--el-text-color-secondary)]">
|
<div
|
||||||
|
v-if="memberList.length === 0"
|
||||||
|
class="p-3 text-13px text-[var(--el-text-color-secondary)]"
|
||||||
|
>
|
||||||
暂无成员在通话
|
暂无成员在通话
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,11 +67,10 @@ import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import UserAvatar from '../user/UserAvatar.vue'
|
import UserAvatar from '../user/UserAvatar.vue'
|
||||||
import { useMessage } from '@/hooks/web/useMessage'
|
import { useMessage } from '@/hooks/web/useMessage'
|
||||||
import { useRtcStore } from '../../store/rtcStore'
|
import { useRtcStore } from '../../store/rtcStore'
|
||||||
|
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
|
||||||
import { joinCall, getActiveCall } from '@/api/im/home/rtc'
|
import { joinCall, getActiveCall } from '@/api/im/home/rtc'
|
||||||
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
||||||
import { ImConversationType } from '@/views/im/utils/constants'
|
|
||||||
import { getCurrentUserId } from '@/views/im/utils/storage'
|
import { getCurrentUserId } from '@/views/im/utils/storage'
|
||||||
import { getSenderAvatar, getSenderDisplayName } from '@/views/im/utils/user'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
groupId: number
|
groupId: number
|
||||||
|
|
@ -87,10 +86,10 @@ const popoverVisible = ref(false)
|
||||||
/** 当前群的活跃通话;rtcStore 维护,参与者加入 / 离开通知增删 joinedUserIds,通话结束移除 */
|
/** 当前群的活跃通话;rtcStore 维护,参与者加入 / 离开通知增删 joinedUserIds,通话结束移除 */
|
||||||
const activeCall = computed(() => rtcStore.getGroupCall(props.groupId))
|
const activeCall = computed(() => rtcStore.getGroupCall(props.groupId))
|
||||||
|
|
||||||
/** 胶囊条文案;有人在通话则带人数,初始 0 人时只显示媒体类型 */
|
/** 胶囊条文案;有成员(已加入 + 接入中)则带人数,初始 0 人时只显示媒体类型 */
|
||||||
const pillText = computed(() => {
|
const pillText = computed(() => {
|
||||||
const media = getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, activeCall.value?.mediaType)
|
const media = getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, activeCall.value?.mediaType)
|
||||||
const count = joinedCount.value
|
const count = memberList.value.length
|
||||||
return count > 0 ? `正在${media}通话(${count} 人)` : `正在${media}通话`
|
return count > 0 ? `正在${media}通话(${count} 人)` : `正在${media}通话`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -129,17 +128,8 @@ watch(
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 在通话中的成员视图模型;昵称 / 头像走 user.ts 的 helper,自动处理 self / 群成员 / 好友 / 兜底 */
|
/** 在通话中的成员(已加入)+ 接入中的成员(已邀请未接通) */
|
||||||
const joinedMembers = computed(() => {
|
const memberList = useGroupCallMembers(computed(() => props.groupId))
|
||||||
const ids = activeCall.value?.joinedUserIds || []
|
|
||||||
return ids.map((userId) => ({
|
|
||||||
userId,
|
|
||||||
nickname: getSenderDisplayName(userId, ImConversationType.GROUP, props.groupId),
|
|
||||||
avatar: getSenderAvatar(userId, ImConversationType.GROUP, props.groupId) || undefined
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const joinedCount = computed(() => joinedMembers.value.length)
|
|
||||||
|
|
||||||
/** 本端是否正在该房间通话(处于 INVITING / RUNNING) */
|
/** 本端是否正在该房间通话(处于 INVITING / RUNNING) */
|
||||||
const isInThisCall = computed(
|
const isInThisCall = computed(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||||
|
import { useRtcStore } from '../store/rtcStore'
|
||||||
|
import { ImConversationType } from '../../utils/constants'
|
||||||
|
import { getSenderAvatar, getSenderDisplayName } from '../../utils/user'
|
||||||
|
|
||||||
|
/** 群通话成员视图模型:已加入 + 接入中;pending 头像 UI 半透明,joined 不透明 */
|
||||||
|
export interface GroupCallMember {
|
||||||
|
userId: number
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
pending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群通话成员列表 computed:joined 在前,未 joined 的 invitee 在后;
|
||||||
|
* 缓存为空(来电首屏渲染早于 syncGroupActiveCall 回写)时用 fallback 主叫兜底,避免空白
|
||||||
|
*
|
||||||
|
* @param groupId 群编号
|
||||||
|
* @param fallbackInviterId 兜底主叫;缓存为空时填一个头像,标记为已加入而非 pending
|
||||||
|
*/
|
||||||
|
export function useGroupCallMembers(
|
||||||
|
groupId: Ref<number | undefined>,
|
||||||
|
fallbackInviterId?: Ref<number | undefined>
|
||||||
|
): ComputedRef<GroupCallMember[]> {
|
||||||
|
const rtcStore = useRtcStore()
|
||||||
|
return computed(() => {
|
||||||
|
const gid = groupId.value
|
||||||
|
if (!gid) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const groupCall = rtcStore.getGroupCall(gid)
|
||||||
|
const joinedIds = groupCall?.joinedUserIds ?? []
|
||||||
|
const inviteeIds = groupCall?.inviteeIds ?? []
|
||||||
|
const joinedSet = new Set(joinedIds)
|
||||||
|
const orderedIds = [...joinedIds, ...inviteeIds.filter((id) => !joinedSet.has(id))]
|
||||||
|
if (orderedIds.length > 0) {
|
||||||
|
return orderedIds.map((userId) => toVM(userId, gid, !joinedSet.has(userId)))
|
||||||
|
}
|
||||||
|
const fallback = fallbackInviterId?.value
|
||||||
|
return fallback ? [toVM(fallback, gid, false)] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 userId 翻译成视图模型,统一走 user.ts helper 解析昵称 / 头像 */
|
||||||
|
function toVM(userId: number, groupId: number, pending: boolean): GroupCallMember {
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
nickname: getSenderDisplayName(userId, ImConversationType.GROUP, groupId),
|
||||||
|
avatar: getSenderAvatar(userId, ImConversationType.GROUP, groupId) || undefined,
|
||||||
|
pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { isEqual } from 'lodash-es'
|
import { isEqual, union } from 'lodash-es'
|
||||||
import type { ImRtcCallRespVO, ImRtcGroupCallRespVO } from '@/api/im/home/rtc'
|
import type { ImRtcCallRespVO, ImRtcGroupCallRespVO } from '@/api/im/home/rtc'
|
||||||
import {
|
import {
|
||||||
ImRtcCallStage,
|
ImRtcCallStage,
|
||||||
|
|
@ -225,6 +225,20 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||||
leftUserIds.value = new Set()
|
leftUserIds.value = new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 通话中追加被邀请人;让 participants 网格出现 pending 占位、胶囊条同步更新 */
|
||||||
|
function appendInvitees(userIds: number[]) {
|
||||||
|
if (!call.value || userIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existing = call.value.inviteeIds ?? []
|
||||||
|
const merged = union(existing, userIds)
|
||||||
|
if (merged.length === existing.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call.value = { ...call.value, inviteeIds: merged }
|
||||||
|
syncGroupActiveCall(call.value)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 群通话胶囊条状态 ====================
|
// ==================== 群通话胶囊条状态 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -307,17 +321,37 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||||
if (!isGroup || !payload.groupId) {
|
if (!isGroup || !payload.groupId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const existing = groupActiveCalls.value.get(payload.groupId)
|
dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId)
|
||||||
if (!existing || existing.room !== payload.room) {
|
}
|
||||||
|
|
||||||
|
/** 群通话单人拒绝邀请:标记 leftUserIds + 从胶囊条 inviteeIds 移除(私聊拒绝走 RTC_CALL_END,不入本通道) */
|
||||||
|
function applyParticipantRejected(payload: ImRtcCallNotification) {
|
||||||
|
if (!payload.operatorUserId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markUserLeft(payload.operatorUserId)
|
||||||
|
if (payload.conversationType === ImConversationType.GROUP && payload.groupId) {
|
||||||
|
dropFromGroupActiveCall(payload.groupId, payload.room, payload.operatorUserId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从指定群活跃通话的 joined / pending 列表里同步移除某用户;用于 disconnect / reject 让胶囊条不再展示 */
|
||||||
|
function dropFromGroupActiveCall(groupId: number, room: string, userId: number) {
|
||||||
|
const existing = groupActiveCalls.value.get(groupId)
|
||||||
|
if (!existing || existing.room !== room) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const joined = existing.joinedUserIds ?? []
|
const joined = existing.joinedUserIds ?? []
|
||||||
if (!joined.includes(payload.userId)) {
|
const invitee = existing.inviteeIds ?? []
|
||||||
|
const nextJoined = joined.filter((id) => id !== userId)
|
||||||
|
const nextInvitee = invitee.filter((id) => id !== userId)
|
||||||
|
if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setGroupCall({
|
setGroupCall({
|
||||||
...existing,
|
...existing,
|
||||||
joinedUserIds: joined.filter((id) => id !== payload.userId)
|
joinedUserIds: nextJoined,
|
||||||
|
inviteeIds: nextInvitee
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,12 +367,14 @@ export const useRtcStore = defineStore('imRtc', () => {
|
||||||
showIncoming,
|
showIncoming,
|
||||||
enterRunning,
|
enterRunning,
|
||||||
reset,
|
reset,
|
||||||
|
appendInvitees,
|
||||||
markUserLeft,
|
markUserLeft,
|
||||||
isUserLeft,
|
isUserLeft,
|
||||||
setGroupCall,
|
setGroupCall,
|
||||||
removeGroupCall,
|
removeGroupCall,
|
||||||
getGroupCall,
|
getGroupCall,
|
||||||
applyParticipantConnected,
|
applyParticipantConnected,
|
||||||
applyParticipantDisconnected
|
applyParticipantDisconnected,
|
||||||
|
applyParticipantRejected
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -741,10 +741,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case ImRtcParticipantStatus.REJECTED:
|
case ImRtcParticipantStatus.REJECTED:
|
||||||
// 群通话单人拒绝;把拒绝者从 pending 占位移除(私聊拒绝走 RTC_CALL_END 入消息流,不走本通道)
|
rtcStore.applyParticipantRejected(payload)
|
||||||
if (payload.operatorUserId) {
|
|
||||||
rtcStore.markUserLeft(payload.operatorUserId)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case ImRtcParticipantStatus.JOINED:
|
case ImRtcParticipantStatus.JOINED:
|
||||||
case ImRtcParticipantStatus.NO_ANSWER:
|
case ImRtcParticipantStatus.NO_ANSWER:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue