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 }}
-
+
通话成员
@@ -42,6 +42,8 @@
:size="22"
radius="4px"
:clickable="false"
+ :class="{ 'opacity-50': member.pending }"
+ :title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
/>
@@ -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: