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