diff --git a/src/views/im/home/components/rtc/RtcCallContainer.vue b/src/views/im/home/components/rtc/RtcCallContainer.vue index 8918c7a7b..c28f7d52e 100644 --- a/src/views/im/home/components/rtc/RtcCallContainer.vue +++ b/src/views/im/home/components/rtc/RtcCallContainer.vue @@ -308,7 +308,8 @@ async function handleCancel() { /** 被叫拒绝来电 */ async function handleReject() { - const room = rtcStore.incomingPayload?.room + const payload = rtcStore.incomingPayload + const room = payload?.room if (room) { try { await rejectCall(room) @@ -316,6 +317,10 @@ async function handleReject() { console.warn('[Call] reject 失败', { room }, e) } } + // 本端先行把自己从胶囊条移除,避免等后端 RTC_CALL(REJECTED) 推回的延迟 + if (payload?.conversationType === ImConversationType.GROUP && payload.groupId) { + rtcStore.applyParticipantRejected({ ...payload, operatorUserId: getCurrentUserId() }) + } rtcStore.reset() } @@ -335,7 +340,8 @@ async function handleAccept() { /** 通话中挂断 */ async function handleHangup() { - const room = rtcStore.call?.room + const call = rtcStore.call + const room = call?.room if (room) { try { await leaveCall(room) @@ -343,6 +349,15 @@ async function handleHangup() { 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() rtcStore.reset() } @@ -402,6 +417,8 @@ async function handleAddMemberSuccess(userIds: number[]) { } try { await inviteCall({ room: call.room, inviteeIds: userIds }) + // 同步本地 inviteeIds,让新成员立即作为 pending 占位出现在网格里 + rtcStore.appendInvitees(userIds) message.success('已发送邀请') } catch (e: any) { console.error('[Call] invite 追加失败', { room: call.room, inviteeIds: userIds }, e) diff --git a/src/views/im/home/components/rtc/RtcCallIncoming.vue b/src/views/im/home/components/rtc/RtcCallIncoming.vue index 1154adaa7..8577e6d36 100644 --- a/src/views/im/home/components/rtc/RtcCallIncoming.vue +++ b/src/views/im/home/components/rtc/RtcCallIncoming.vue @@ -30,7 +30,7 @@
{{ tipText }}
- + @@ -69,12 +71,9 @@ import { computed } from 'vue' import Icon from '@/components/Icon/src/Icon.vue' import UserAvatar from '../user/UserAvatar.vue' -import { useRtcStore } from '../../store/rtcStore' import type { ImRtcCallNotification } from '../../store/rtcStore' +import { useGroupCallMembers } from '../../composables/useGroupCallMembers' 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<{ payload: ImRtcCallNotification | null @@ -83,34 +82,15 @@ const props = defineProps<{ defineEmits<{ accept: []; reject: [] }>() -const rtcStore = useRtcStore() - /** 来电提示文案;区分语音 / 视频 */ const tipText = computed(() => { if (!props.payload) return '' return `邀请你${getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, props.payload.mediaType)}通话` }) -/** 群聊:已加入通话的成员(自己除外);缓存里 joinedUserIds 为空时降级到主叫,保证至少一头像 */ -const callMembers = computed(() => { - if (!props.isGroup) { - return [] - } - 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 - })) -}) +/** 群通话成员;缓存为空时用 INVITE 载荷里的主叫兜底,避免空白 */ +const callMembers = useGroupCallMembers( + computed(() => (props.isGroup ? props.payload?.groupId : undefined)), + computed(() => props.payload?.inviterUserId) +) diff --git a/src/views/im/home/components/rtc/RtcGroupCallBanner.vue b/src/views/im/home/components/rtc/RtcGroupCallBanner.vue index de723fdc0..7d62d0e19 100644 --- a/src/views/im/home/components/rtc/RtcGroupCallBanner.vue +++ b/src/views/im/home/components/rtc/RtcGroupCallBanner.vue @@ -26,25 +26,25 @@ - +
-
- -
+ :url="member.avatar" + :name="member.nickname" + :size="40" + radius="6px" + :clickable="false" + :class="{ 'opacity-50': member.pending }" + :title="member.pending ? `${member.nickname}(接入中)` : member.nickname" + /> -
+
暂无成员在通话
@@ -67,11 +67,10 @@ import Icon from '@/components/Icon/src/Icon.vue' import UserAvatar from '../user/UserAvatar.vue' import { useMessage } from '@/hooks/web/useMessage' import { useRtcStore } from '../../store/rtcStore' +import { useGroupCallMembers } from '../../composables/useGroupCallMembers' import { joinCall, getActiveCall } from '@/api/im/home/rtc' 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<{ groupId: number @@ -87,10 +86,10 @@ const popoverVisible = ref(false) /** 当前群的活跃通话;rtcStore 维护,参与者加入 / 离开通知增删 joinedUserIds,通话结束移除 */ const activeCall = computed(() => rtcStore.getGroupCall(props.groupId)) -/** 胶囊条文案;有人在通话则带人数,初始 0 人时只显示媒体类型 */ +/** 胶囊条文案;有成员(已加入 + 接入中)则带人数,初始 0 人时只显示媒体类型 */ const pillText = computed(() => { 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}通话` }) @@ -129,17 +128,8 @@ watch( { immediate: true } ) -/** 在通话中的成员视图模型;昵称 / 头像走 user.ts 的 helper,自动处理 self / 群成员 / 好友 / 兜底 */ -const joinedMembers = computed(() => { - 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) +/** 在通话中的成员(已加入)+ 接入中的成员(已邀请未接通) */ +const memberList = useGroupCallMembers(computed(() => props.groupId)) /** 本端是否正在该房间通话(处于 INVITING / RUNNING) */ const isInThisCall = computed( diff --git a/src/views/im/home/composables/useGroupCallMembers.ts b/src/views/im/home/composables/useGroupCallMembers.ts new file mode 100644 index 000000000..78def6a73 --- /dev/null +++ b/src/views/im/home/composables/useGroupCallMembers.ts @@ -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, + fallbackInviterId?: Ref +): ComputedRef { + 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 + } +} diff --git a/src/views/im/home/store/rtcStore.ts b/src/views/im/home/store/rtcStore.ts index 131ffa34b..64586760f 100644 --- a/src/views/im/home/store/rtcStore.ts +++ b/src/views/im/home/store/rtcStore.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' 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 { ImRtcCallStage, @@ -225,6 +225,20 @@ export const useRtcStore = defineStore('imRtc', () => { 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) { return } - const existing = groupActiveCalls.value.get(payload.groupId) - if (!existing || existing.room !== payload.room) { + dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId) + } + + /** 群通话单人拒绝邀请:标记 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 } 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 } setGroupCall({ ...existing, - joinedUserIds: joined.filter((id) => id !== payload.userId) + joinedUserIds: nextJoined, + inviteeIds: nextInvitee }) } @@ -333,12 +367,14 @@ export const useRtcStore = defineStore('imRtc', () => { showIncoming, enterRunning, reset, + appendInvitees, markUserLeft, isUserLeft, setGroupCall, removeGroupCall, getGroupCall, applyParticipantConnected, - applyParticipantDisconnected + applyParticipantDisconnected, + applyParticipantRejected } }) diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 5016bcadb..a779d958e 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -741,10 +741,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { } break case ImRtcParticipantStatus.REJECTED: - // 群通话单人拒绝;把拒绝者从 pending 占位移除(私聊拒绝走 RTC_CALL_END 入消息流,不走本通道) - if (payload.operatorUserId) { - rtcStore.markUserLeft(payload.operatorUserId) - } + rtcStore.applyParticipantRejected(payload) break case ImRtcParticipantStatus.JOINED: case ImRtcParticipantStatus.NO_ANSWER: