gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/components/rtc/rtc-group-call-banner.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,205 @@
<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>