<script lang="ts" setup>
|
import { computed, ref, watch } from 'vue'
|
|
import { DICT_TYPE } from '#/packages/constants/src'
|
import { getDictLabel } from '#/packages/effects/hooks/src'
|
import { IconifyIcon as Icon } from '#/packages/icons/src'
|
|
import { message, Popover } from 'ant-design-vue'
|
|
import { getActiveCall, joinCall } from '#/api/im/rtc'
|
import { getCurrentUserId } from '#/views/im/utils/auth'
|
|
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
|
import { useGroupStore } from '../../store/groupStore'
|
import { useRtcStore } from '../../store/rtcStore'
|
import { UserAvatar } from '../user'
|
|
defineOptions({ name: 'ImRtcGroupCallBanner' })
|
|
const props = defineProps<{
|
groupId: number
|
}>()
|
|
const rtcStore = useRtcStore()
|
const groupStore = useGroupStore()
|
|
const popoverVisible = ref(false)
|
|
/** 当前群的活跃通话;rtcStore 维护,参与者加入 / 离开通知增删 joinedUserIds,通话结束移除 */
|
const activeCall = computed(() => rtcStore.getGroupCall(props.groupId))
|
|
/** 胶囊条文案;有成员(已加入 + 接入中)则带人数,初始 0 人时只显示媒体类型 */
|
const pillText = computed(() => {
|
const media = getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, activeCall.value?.mediaType)
|
const count = memberList.value.length
|
return count > 0 ? `正在${media}通话(${count} 人)` : `正在${media}通话`
|
})
|
|
/**
|
* 切到群 / 通话 room 变化时拉一次最新参与者列表;
|
* 两个触发场景:1)用户切群,本端可能没有该群通话的最新缓存;2)参与者通知首次填充后只含本次加入者,缺历史加入者;
|
* 用 [groupId, room] 双源监听 + 已填充守卫,避免切群 / 首次填充触发的双次重复拉取
|
*/
|
watch(
|
() =>
|
[
|
props.groupId,
|
activeCall.value?.room,
|
groupStore.isGroupActiveCallExpired(props.groupId)
|
] as const,
|
async ([groupId, room], oldValues) => {
|
if (!groupId) {
|
return
|
}
|
|
if (!activeCall.value) {
|
if (!groupStore.isGroupActiveCallExpired(groupId)) {
|
return
|
}
|
try {
|
const data = await getActiveCall(groupId)
|
if (data) {
|
rtcStore.setGroupCall(data, true)
|
} else {
|
rtcStore.removeGroupCall(groupId)
|
}
|
} catch (error) {
|
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
|
}
|
return
|
}
|
|
// 决策是否需要拉取:补齐本地已有通话;没有本地通话时按群缓存过期状态懒探测一次
|
const groupChanged = !oldValues || oldValues[0] !== groupId
|
const roomChanged = oldValues && oldValues[1] !== room
|
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
|
if (
|
!activeCallExpired &&
|
(rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
|
(!groupChanged && !roomChanged && participantsLoaded))
|
) {
|
return
|
}
|
|
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存
|
try {
|
const data = await getActiveCall(groupId)
|
if (data) {
|
rtcStore.setGroupCall(data, true)
|
} else {
|
rtcStore.removeGroupCall(groupId)
|
}
|
} catch (error) {
|
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
|
}
|
},
|
{ immediate: true }
|
)
|
|
/** 在通话中的成员(已加入)+ 接入中的成员(已邀请未接通) */
|
const memberList = useGroupCallMembers(computed(() => props.groupId))
|
|
/** 本端是否正在该房间通话(处于 INVITING / RUNNING) */
|
const isInThisCall = computed(
|
() => rtcStore.isActive && rtcStore.call?.room === activeCall.value?.room
|
)
|
|
/**
|
* 服务端是否记录我已加入;刷新后 LiveKit 连接已断但 webhook 还没把 status 标为 LEFT 时仍为 true;
|
* 用于把按钮文案切到「重新加入」,但不 disable 按钮
|
*/
|
const serverSaysJoined = computed(() => {
|
const myId = getCurrentUserId()
|
return activeCall.value?.joinedUserIds?.includes(myId) ?? false
|
})
|
|
/** 加入按钮禁用:仅在本端实际持有 LiveKit 连接时禁用 */
|
const joinDisabled = computed(() => isInThisCall.value)
|
|
/** 加入按钮文案;本端连着 → 已在通话中;服务端还残留我但本端断了 → 重新加入;其它 → 加入 */
|
const joinLabel = computed(() => {
|
if (isInThisCall.value) return '已在通话中'
|
if (serverSaysJoined.value) return '重新加入'
|
return '加入'
|
})
|
|
/** 主动加入:调 invite 命中已有 call 拿 token;rtcStore 按 status 自动进 RUNNING */
|
async function handleJoin() {
|
const call = activeCall.value
|
if (!call || joinDisabled.value) {
|
return
|
}
|
if (rtcStore.isActive) {
|
message.warning('您正在通话中')
|
return
|
}
|
popoverVisible.value = false
|
const data = await joinCall(call.room)
|
rtcStore.startInviting(data)
|
}
|
</script>
|
|
<template>
|
<!-- 仅当该群有活跃通话时显示;点击胶囊条展开 popover 看在通话成员 + 加入 -->
|
<div v-if="activeCall" class="flex-shrink-0 px-4 pb-2 bg-[var(--ant-color-fill-secondary)]">
|
<Popover
|
v-model:open="popoverVisible"
|
placement="bottomLeft"
|
:overlay-style="{ width: '280px' }"
|
trigger="click"
|
>
|
<!-- 胶囊条本体:电话图标 + 文案(含人数)+ 右箭头 -->
|
<div
|
class="inline-flex gap-2 items-center px-2.5 py-1 text-13px rounded-full cursor-pointer select-none transition-colors duration-150 bg-[var(--ant-color-success-bg)] text-[var(--ant-color-text)] hover:bg-[var(--ant-color-success-bg-hover)]"
|
>
|
<span
|
class="inline-flex flex-shrink-0 justify-center items-center w-[18px] h-[18px] text-white rounded-full bg-[#07c160]"
|
>
|
<Icon icon="ant-design:phone-filled" :size="14" />
|
</span>
|
<span class="font-medium">{{ pillText }}</span>
|
<Icon
|
icon="ant-design:right-outlined"
|
:size="12"
|
class="text-[var(--ant-color-text-secondary)]"
|
/>
|
</div>
|
|
<!-- 展开面板:通话成员(含接入中)头像横排 + 加入按钮 -->
|
<template #content>
|
<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]">
|
<UserAvatar
|
v-for="member in memberList"
|
:key="member.userId"
|
:url="member.avatar"
|
:name="member.nickname"
|
:size="40"
|
radius="6px"
|
:clickable="false"
|
:class="{ 'opacity-50': member.pending }"
|
:title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
|
/>
|
<!-- 首次填充时房内可能暂时 0 人;加入后由 ParticipantConnected 事件追加 -->
|
<div
|
v-if="memberList.length === 0"
|
class="p-3 text-13px text-[var(--ant-color-text-secondary)]"
|
>
|
暂无成员在通话
|
</div>
|
</div>
|
<!-- 本端在通话内时置灰「已在通话中」;服务端残留我但本端连接断了显示「重新加入」(刷新页面后场景) -->
|
<button
|
class="w-[200px] h-9 text-sm font-medium rounded-lg cursor-pointer border-none bg-[#f1f1f3] text-[#1a1a1c] transition-colors duration-150 disabled:cursor-not-allowed disabled:text-[#999] hover:[&:not(:disabled)]:bg-[#e7e7ea]"
|
:disabled="joinDisabled"
|
@click="handleJoin"
|
>
|
{{ joinLabel }}
|
</button>
|
</div>
|
</template>
|
</Popover>
|
</div>
|
</template>
|