From 5f3dea3fd930afde720677fba10b6bfb2e836a18 Mon Sep 17 00:00:00 2001
From: zhangwencui <1064582902@qq.com>
Date: 星期五, 24 七月 2026 09:54:17 +0800
Subject: [PATCH] Merge branch 'dev_pro2.0' of http://114.132.189.42:9002/r/mom-pro2-before into dev_pro2.0

---
 src/views/im/home/components/rtc/rtc-call-container.vue |  488 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 488 insertions(+), 0 deletions(-)

diff --git a/src/views/im/home/components/rtc/rtc-call-container.vue b/src/views/im/home/components/rtc/rtc-call-container.vue
new file mode 100644
index 0000000..02be586
--- /dev/null
+++ b/src/views/im/home/components/rtc/rtc-call-container.vue
@@ -0,0 +1,488 @@
+<script lang="ts" setup>
+import type { CallParticipantVM } from './rtc-call-participant-tile.vue'
+
+import { computed, ref, watch } from 'vue'
+
+import { useIntervalFn } from '@vueuse/core'
+import { message } from 'ant-design-vue'
+import { Track } from 'livekit-client'
+
+import {
+  acceptCall,
+  cancelCall,
+  inviteCall,
+  leaveCall,
+  noAnswerCallCheck,
+  rejectCall
+} from '#/api/im/rtc'
+import { getCurrentUserId } from '#/views/im/utils/auth'
+import { RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS } from '#/views/im/utils/config'
+import {
+  ImConversationType,
+  ImRtcCallMediaType,
+  ImRtcCallStage
+} from '#/views/im/utils/constants'
+import { getSenderAvatar, getSenderDisplayName } from '#/views/im/utils/user'
+
+import { useLiveKitRoom } from '../../composables/useLiveKitRoom'
+import { useRtcStore } from '../../store/rtcStore'
+import RtcCallIncoming from './rtc-call-incoming.vue'
+import RtcCallInviting from './rtc-call-inviting.vue'
+import RtcCallMemberPickerDialog from './rtc-call-member-picker-dialog.vue'
+import RtcCallRunning from './rtc-call-running.vue'
+
+defineOptions({ name: 'ImRtcCallContainer' })
+
+const rtcStore = useRtcStore()
+const lk = useLiveKitRoom()
+
+const memberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
+const connecting = ref(false)
+const accepting = ref(false)
+const rejecting = ref(false)
+const cancelling = ref(false)
+const hangingUp = ref(false)
+
+// ==================== 瑙嗗浘妯″瀷 ====================
+
+/** 褰撳墠鏄惁瑙嗛閫氳瘽 */
+const isVideo = computed(() => {
+  const t =
+    rtcStore.call?.mediaType ||
+    rtcStore.incomingPayload?.mediaType ||
+    ImRtcCallMediaType.VOICE
+  return t === ImRtcCallMediaType.VIDEO
+})
+
+/** 褰撳墠鏄惁缇ら�氳瘽锛涘喅瀹氭诞鍔ㄧ獥澶у皬 */
+const isGroup = computed(
+  () =>
+    (rtcStore.call?.conversationType ??
+      rtcStore.incomingPayload?.conversationType) === ImConversationType.GROUP
+)
+
+/** 鍒濆鎽勫儚澶存槸鍚︽墦寮�锛涚兢閫氳瘽榛樿鍏ㄩ儴鍏抽棴锛岃繘鍏ュ悗鐢ㄦ埛涓诲姩寮� */
+const initialCamera = computed(() => {
+  if (rtcStore.call?.conversationType === ImConversationType.GROUP) {
+    return false
+  }
+  return isVideo.value
+})
+
+/** 鏈瑙嗛娴侊紱浼樺厛 ScreenShare锛堝睆鍏辨椂涔熼摵搴曪級锛屾棤鍒� Camera锛涙樉寮忚闃� screenShareEnabled / cameraEnabled 瑙﹀彂閲嶇畻 */
+const localStream = computed<MediaStream | null>(() => {
+  // 瑙︽懜鍝嶅簲寮忎緷璧栵紝纭繚鍒囧睆鍏变韩 / 鎽勫儚澶村悗 computed 閲嶆柊姹傚�硷紙pickStream 鍐呴儴鐢ㄦ櫘閫� Map 缂撳瓨锛岃嚜韬笉鍝嶅簲锛�
+  void lk.screenShareEnabled.value
+  void lk.cameraEnabled.value
+  const lp = lk.localParticipant.value
+  if (!lp) {
+    return null
+  }
+  return lk.pickStream(lp, Track.Source.ScreenShare) || lk.pickStream(lp, Track.Source.Camera)
+})
+
+/** 杩滅瑙嗛娴侊紙浠� 1v1 鐢級锛涗紭鍏� ScreenShare锛屾棤鍒欏彇 Camera */
+const remoteVideoStream = computed<MediaStream | null>(() => {
+  if (isGroup.value) {
+    return null
+  }
+  for (const rp of lk.remoteParticipants.value) {
+    const screen = lk.pickStream(rp, Track.Source.ScreenShare)
+    if (screen) {
+      return screen
+    }
+    const camera = lk.pickStream(rp, Track.Source.Camera)
+    if (camera) {
+      return camera
+    }
+  }
+  return null
+})
+
+/** 杩滅闊抽娴侊紙浠� 1v1 鐢級 */
+const remoteAudioStream = computed<MediaStream | null>(() => {
+  if (isGroup.value) {
+    return null
+  }
+  for (const rp of lk.remoteParticipants.value) {
+    const stream = lk.pickStream(rp, Track.Source.Microphone)
+    if (stream) {
+      return stream
+    }
+  }
+  return null
+})
+
+/** 缇ら�氳瘽缃戞牸鐢細鑷繁 + 杩滅鍦ㄦ埧 + 寰呭姞鍏ユ垚鍛橈紱鏄电О / 澶村儚璧� user.ts helper 鑷姩澶勭悊 self / 缇ゆ垚鍛� / 濂藉弸 / 鍏滃簳 */
+const participants = computed<CallParticipantVM[]>(() => {
+  const call = rtcStore.call
+  if (!call) {
+    return []
+  }
+  const conversationType = call.conversationType
+  const targetId = call.groupId ?? 0
+  const myId = getCurrentUserId()
+  const result: CallParticipantVM[] = [{
+    userId: myId,
+    nickname: getSenderDisplayName(myId, conversationType, targetId),
+    avatar: getSenderAvatar(myId, conversationType, targetId) || undefined,
+    isLocal: true,
+    videoStream: localStream.value
+  }]
+
+  // 宸插姞鍏ョ殑杩滅锛氬疄闄呮帹娴侊紱灞忓箷鍏变韩鍦ㄧ綉鏍奸噷鐙崰璇ユ垚鍛樼殑鏍煎瓙锛屾棤鍒欓檷绾� Camera
+  const joined = new Set<number>()
+  for (const rp of lk.remoteParticipants.value) {
+    const userId = Number(rp.identity)
+    if (Number.isNaN(userId)) {
+      continue
+    }
+    joined.add(userId)
+    result.push({
+      userId,
+      nickname: getSenderDisplayName(userId, conversationType, targetId),
+      avatar: getSenderAvatar(userId, conversationType, targetId) || undefined,
+      isLocal: false,
+      videoStream:
+        lk.pickStream(rp, Track.Source.ScreenShare) || lk.pickStream(rp, Track.Source.Camera),
+      audioStream: lk.pickStream(rp, Track.Source.Microphone)
+    })
+  }
+
+  // 缇ら�氳瘽锛氭湭鍔犲叆鐨勮閭�璇蜂汉浣滀负 pending 鍗犱綅锛涘凡閫�鍑� / 宸叉嫆缁濈殑浜轰笉娓叉煋
+  if (conversationType === ImConversationType.GROUP) {
+    const inviteeIds = call.inviteeIds || []
+    for (const userId of inviteeIds) {
+      if (userId === myId || joined.has(userId) || rtcStore.isUserLeft(userId)) {
+        continue
+      }
+      result.push({
+        userId,
+        nickname: getSenderDisplayName(userId, ImConversationType.GROUP, targetId),
+        avatar: getSenderAvatar(userId, ImConversationType.GROUP, targetId) || undefined,
+        isLocal: false,
+        pending: true
+      })
+    }
+  }
+  return result
+})
+
+// ==================== LiveKit 杩炴帴 ====================
+
+/** 杩炲叆 LiveKit 鎴块棿骞舵敞鍐岀寮�鍥炶皟锛汭NVITING 涓诲彨棰勮繛鍜岃鍙� accept 鍚庤繛鍏ュ叡鐢� */
+async function connectLiveKit(livekitUrl: string, token: string) {
+  // 骞傜瓑锛歭k.connect 鍐呴儴杩涘叆鍚庡氨鎶� room.value 璧嬪�硷紱闈炵┖琛ㄧず宸茬粡鍦ㄨ繛鎺ユ垨宸茶繛鎺ワ紱stage 澶氭鍒囨崲鏃堕噸澶嶈Е鍙戜篃璺宠繃
+  if (lk.room.value || connecting.value) {
+    return
+  }
+  connecting.value = true
+  try {
+    // 鍏堟敞鍐屽洖璋冿紝鍐� connect锛涗俊浠ゆ彙鎵嬭繃绋嬩細鍗虫椂鎺ㄩ�佸凡鍦ㄦ埧鍙備笌鑰咃紝涓氬姟 handler 蹇呴』鍏堝氨缁�
+    lk.onDisconnected(() => handlePeerDisconnected())
+    lk.onParticipantConnected(maybeEnterRunning)
+    lk.onParticipantDisconnected((userId) => rtcStore.markUserLeft(userId))
+    await lk.connect(livekitUrl, token, { audio: true, video: initialCamera.value })
+    // 鍏滃簳锛歝onnect 鏈熼棿鑻ュ凡鏈夎繙绔湪鎴匡紝浜嬩欢鍙兘鍦� handler 娉ㄥ唽鍓嶅凡瑙﹀彂锛屼富鍔ㄥ垏鍒� RUNNING
+    if (lk.remoteParticipants.value.length > 0) {
+      maybeEnterRunning()
+    }
+  } finally {
+    connecting.value = false
+  }
+}
+
+/** 涓诲彨绔細浠� INVITING 鍒囧埌 RUNNING锛涘叾瀹冮樁娈典笉澶勭悊 */
+function maybeEnterRunning() {
+  if (rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call) {
+    rtcStore.enterRunning(rtcStore.call)
+  }
+}
+
+watch(
+  () => rtcStore.stage,
+  async (stage) => {
+    if (
+      stage === ImRtcCallStage.INVITING &&
+      rtcStore.call?.token &&
+      rtcStore.call?.livekitUrl
+    ) {
+      try {
+        await connectLiveKit(rtcStore.call.livekitUrl, rtcStore.call.token)
+      } catch (error) {
+        console.error('[Call] connect 澶辫触', { room: rtcStore.call?.room }, error)
+        message.error('閫氳瘽杩炴帴澶辫触')
+        await handleCancel()
+      }
+    }
+    if (stage === ImRtcCallStage.IDLE) {
+      await lk.disconnect()
+    }
+  }
+)
+
+/** 琚彨绔� accept 鍚庝細鎷垮埌 token锛涜繖閲岀洃鍚� stage + token 鍙樺寲瑙﹀彂杩炴帴 */
+watch(
+  () => [rtcStore.stage, rtcStore.call?.token],
+  async ([stage, token], [prevStage]) => {
+    if (
+      stage === ImRtcCallStage.RUNNING &&
+      prevStage !== ImRtcCallStage.RUNNING &&
+      token &&
+      !lk.isConnected.value &&
+      rtcStore.call?.livekitUrl
+    ) {
+      try {
+        await connectLiveKit(rtcStore.call.livekitUrl, token as string)
+      } catch (error) {
+        console.error('[Call] accept connect 澶辫触', { room: rtcStore.call?.room }, error)
+        message.error('閫氳瘽杩炴帴澶辫触')
+        // 鍚庣 accept 宸插啓 JOINED锛涘墠绔繛鎺ュけ璐ラ渶璋� leave 鍥炴粴锛岄伩鍏嶅悗绔褰曟畫鐣欏繖绾�
+        if (rtcStore.call?.room) {
+          leaveCall(rtcStore.call.room).catch(() => undefined)
+        }
+        rtcStore.reset()
+      }
+    }
+  }
+)
+
+// ==================== 閫氳瘽鐢熷懡鍛ㄦ湡 ====================
+
+/** 涓诲彨鍙栨秷閭�璇� */
+async function handleCancel() {
+  if (cancelling.value) {
+    return
+  }
+  cancelling.value = true
+  const room = rtcStore.call?.room
+  try {
+    if (room) {
+      await cancelCall(room)
+    }
+    await lk.disconnect()
+    rtcStore.reset()
+  } finally {
+    cancelling.value = false
+  }
+}
+
+/** 琚彨鎷掔粷鏉ョ數 */
+async function handleReject() {
+  if (rejecting.value) {
+    return
+  }
+  rejecting.value = true
+  const payload = rtcStore.incomingPayload
+  try {
+    if (payload?.room) {
+      await rejectCall(payload.room)
+      // 鏈鍏堣浠庤兌鍥婃潯绉婚櫎鑷繁锛屽厤绛夊悗绔� RTC_CALL(REJECTED) 鎺ㄥ洖锛涚鑱婂満鏅� store 鍐呴儴 no-op
+      rtcStore.applyParticipantRejected({
+        room: payload.room,
+        conversationType: payload.conversationType,
+        groupId: payload.groupId,
+        operatorUserId: getCurrentUserId()
+      })
+    }
+    rtcStore.reset()
+  } finally {
+    rejecting.value = false
+  }
+}
+
+/** 琚彨鎺ュ惉鏉ョ數 */
+async function handleAccept() {
+  if (accepting.value) {
+    return
+  }
+  const payload = rtcStore.incomingPayload
+  if (!payload) return
+  accepting.value = true
+  try {
+    const data = await acceptCall(payload.room)
+    rtcStore.enterRunning(data)
+  } finally {
+    accepting.value = false
+  }
+}
+
+/** 閫氳瘽涓寕鏂� */
+async function handleHangup() {
+  if (hangingUp.value) {
+    return
+  }
+  hangingUp.value = true
+  const call = rtcStore.call
+  try {
+    if (call?.room) {
+      await leaveCall(call.room)
+      // 鏈鍏堣浠庤兌鍥婃潯绉婚櫎鑷繁锛屽厤绛夊悗绔� RTC_PARTICIPANT_DISCONNECTED 鎺ㄥ洖锛涚鑱婂満鏅� store 鍐呴儴 no-op锛屾暣閫氳瘽鐢� END 鍏虫帀
+      rtcStore.applyParticipantDisconnected({
+        room: call.room,
+        userId: getCurrentUserId(),
+        conversationType: call.conversationType,
+        groupId: call.groupId
+      })
+    }
+    await lk.disconnect()
+    rtcStore.reset()
+  } finally {
+    hangingUp.value = false
+  }
+}
+
+/** LiveKit Room 寮傚父鏂紑锛涘瑙佷簬缃戠粶涓柇 */
+function handlePeerDisconnected() {
+  if (!rtcStore.isActive) {
+    return
+  }
+  const room = rtcStore.call?.room
+  // 缁� RTC_CALL_END WebSocket 鎺ㄩ�佷竴涓皬绐楀彛锛涚鑱婅秴鏃� / 涓诲姩鎸傛柇绛夊満鏅笅锛屽悗绔� endSession 浼氬厛鎺� RTC_CALL_END锛�
+  // 璁╁墠绔寜涓氬姟璇箟锛�"瀵规柟鏈帴鍚�" / "宸插彇娑�" 绛夛級reset锛岄伩鍏嶉敊鎶婁笟鍔℃柇寮� toast 鎴愩�岄�氳瘽宸叉柇寮�銆�
+  setTimeout(() => {
+    if (!rtcStore.isActive) {
+      return
+    }
+    // 涓婃姤绂诲紑鎴块棿
+    if (room) {
+      leaveCall(room).catch(() => undefined)
+    }
+    // 娓呯悊鏈湴閫氳瘽鐘舵��
+    message.warning('閫氳瘽宸叉柇寮�')
+    rtcStore.reset()
+  }, 100)
+}
+
+// ==================== 鎸搩瓒呮椂鍏滃簳 ====================
+
+/** 閫氳瘽瀛樻椿鏈熼棿锛圛NVITING / INCOMING / RUNNING锛夊懆鏈熸�цЕ鍙戝悗绔壂璇� room 鐨勮秴鏃� INVITING锛涗繚鎸� timer 鏄负浜� inviteCall 杩藉姞鏂颁汉鍚庝篃鑳借鐩栵紱闃堝�肩敱鍚庣閰嶇疆鍐冲畾锛屽墠绔彧璐熻矗 trigger */
+const { resume: resumeNoAnswerTimer, pause: pauseNoAnswerTimer } = useIntervalFn(
+  triggerNoAnswerCallCheck, RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS, { immediate: false }
+)
+watch(
+  () => rtcStore.isActive,
+  (active) => (active ? resumeNoAnswerTimer() : pauseNoAnswerTimer()),
+  { immediate: true }
+)
+
+/** 鏈湴浠嶆湁 pending 鎵嶈皟锛汭NVITING / RUNNING 鍙� call銆両NCOMING 鍙� incomingPayload锛涙帴鍙i潤榛橀敊璇� fire-and-forget */
+function triggerNoAnswerCallCheck() {
+  const source = rtcStore.call ?? rtcStore.incomingPayload
+  if (!source?.room || !source.inviteeIds?.length) {
+    return
+  }
+  noAnswerCallCheck(source.room).catch(() => undefined)
+}
+
+// ==================== 璁惧鎺у埗 ====================
+
+async function toggleMic() {
+  await lk.setMicEnabled(!lk.micEnabled.value)
+}
+async function toggleCamera() {
+  await lk.setCameraEnabled(!lk.cameraEnabled.value)
+}
+function toggleSpeaker() {
+  lk.setSpeakerEnabled(!lk.speakerEnabled.value)
+}
+
+/** 鍒囧睆骞曞叡浜紱娴忚鍣ㄥ脊鍘熺敓銆岄�夋嫨鍏变韩鍐呭銆嶅璇濇锛岀敤鎴峰彇娑堟椂浼氭姏閿欙紝UI 涓嶅脊鎻愮ず */
+async function handleScreenShare() {
+  const enabled = !lk.screenShareEnabled.value
+  try {
+    await lk.setScreenShareEnabled(enabled)
+  } catch (error: any) {
+    // 鐢ㄦ埛鍙栨秷閫夋嫨锛屼笉褰撲綔閿欒锛涘叾瀹冨紓甯告墦鏃ュ織
+    if (error?.name !== 'NotAllowedError' && error?.message !== 'permission denied') {
+      console.warn('[Call] screenShare 鍒囨崲澶辫触', { enabled }, error)
+    }
+  }
+}
+
+// ==================== 娣诲姞鎴愬憳 ====================
+
+/** 鎵撳紑銆屾坊鍔犳垚鍛樸�嶅脊绐楋紱鍗犱綅缇ら�氳瘽 + 鎺ラ�氫腑鐘舵�佹墠鍏佽 */
+function openAddMember() {
+  const call = rtcStore.call
+  if (!call?.groupId) {
+    return
+  }
+  memberPickerRef.value?.open({
+    groupId: call.groupId,
+    mode: 'add',
+    excludeUserIds: participants.value.map((p) => p.userId)
+  })
+}
+
+/** picker 閫夊畬鎴愬憳锛涜蛋 invite 杩藉姞閭�璇锋帴鍙o紝鍚庣鎺� RTC_INVITE 缁欐柊鎴愬憳 */
+async function handleAddMemberSuccess(userIds: number[]) {
+  const call = rtcStore.call
+  if (!call?.room || userIds.length === 0) {
+    return
+  }
+  await inviteCall({ room: call.room, inviteeIds: userIds })
+  // 鍚屾鏈湴 inviteeIds锛岃鏂版垚鍛樼珛鍗充綔涓� pending 鍗犱綅鍑虹幇鍦ㄧ綉鏍奸噷
+  rtcStore.appendInvitees(userIds)
+  message.success('宸插彂閫侀個璇�')
+}
+</script>
+
+<template>
+  <!-- 閫氳瘽闃舵瀵瑰簲寮圭獥锛汭NVITING / INCOMING / RUNNING 涓夐�変竴浜掓枼 -->
+  <template v-if="rtcStore.isActive">
+    <!-- 涓诲彨绔細绛夊緟瀵规柟鎺ュ惉 -->
+    <RtcCallInviting
+      v-if="rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call"
+      :peer-nickname="rtcStore.peerNickname"
+      :peer-avatar="rtcStore.peerAvatar"
+      :is-group="isGroup"
+      :is-video="isVideo"
+      :mic-enabled="lk.micEnabled.value"
+      :camera-enabled="lk.cameraEnabled.value"
+      :speaker-enabled="lk.speakerEnabled.value"
+      :local-stream="localStream"
+      @cancel="handleCancel"
+      @toggle-mic="toggleMic"
+      @toggle-camera="toggleCamera"
+      @toggle-speaker="toggleSpeaker"
+    />
+    <!-- 琚彨绔細鏉ョ數鍝嶉搩 -->
+    <RtcCallIncoming
+      v-else-if="rtcStore.stage === ImRtcCallStage.INCOMING"
+      :payload="rtcStore.incomingPayload"
+      :is-group="isGroup"
+      :accepting="accepting"
+      :rejecting="rejecting"
+      @accept="handleAccept"
+      @reject="handleReject"
+    />
+    <!-- 閫氳瘽杩涜涓細1v1 瑙嗛 / 璇煶 + 缇ら�氳瘽瀹牸 -->
+    <RtcCallRunning
+      v-else-if="rtcStore.stage === ImRtcCallStage.RUNNING && rtcStore.call"
+      :is-group="isGroup"
+      :is-video="isVideo"
+      :mic-enabled="lk.micEnabled.value"
+      :camera-enabled="lk.cameraEnabled.value"
+      :speaker-enabled="lk.speakerEnabled.value"
+      :screen-share-enabled="lk.screenShareEnabled.value"
+      :reconnecting="lk.reconnecting.value"
+      :started-at="rtcStore.startedAt"
+      :participants="participants"
+      :peer-nickname="rtcStore.peerNickname"
+      :peer-avatar="rtcStore.peerAvatar"
+      :local-stream="localStream"
+      :remote-video-stream="remoteVideoStream"
+      :remote-audio-stream="remoteAudioStream"
+      :hanging-up="hangingUp"
+      @hangup="handleHangup"
+      @toggle-mic="toggleMic"
+      @toggle-camera="toggleCamera"
+      @toggle-speaker="toggleSpeaker"
+      @toggle-screen-share="handleScreenShare"
+      @add-member="openAddMember"
+    />
+  </template>
+  <!-- 閫氳瘽涓�屾坊鍔犳垚鍛樸�嶉�変汉寮圭獥锛涙寕鍦� isActive 澶栵紝閬垮厤 stage 鍒囨崲鐬棿寮圭獥琚嵏杞� -->
+  <RtcCallMemberPickerDialog ref="memberPickerRef" @success="handleAddMemberSuccess" />
+</template>

--
Gitblit v1.9.3