diff --git a/src/App.ku.vue b/src/App.ku.vue index d5c4033..58c4ea7 100644 --- a/src/App.ku.vue +++ b/src/App.ku.vue @@ -1,5 +1,6 @@ @@ -7,6 +8,7 @@ const { theme } = useTheme() - diff --git a/src/App.vue b/src/App.vue index 3417c02..4dd9f70 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,13 +3,11 @@ import { onLaunch, onShow } from '@dcloudio/uni-app' import { useTheme } from '@/composables/useTheme' import { useAuthStore, useConversationStore } from '@/stores' import { wsManager } from '@/api/websocket' -import { useGroupWebRTC } from '@/composables/useGroupWebRTC' import type { ChatMessage } from '@/types/api' const { initTheme } = useTheme() const authStore = useAuthStore() const conversationStore = useConversationStore() -const groupWebRTC = useGroupWebRTC() // 全局消息处理器 function handleGlobalMessage(message: ChatMessage) { @@ -30,8 +28,7 @@ async function initWebSocket() { await wsManager.connect(userId) // 注册全局消息处理器 wsManager.onMessage(handleGlobalMessage) - // 初始化群通话信令监听器 - groupWebRTC.initListener() + // 群通话信令监听器已移至 App.ku.vue 初始化 console.log('✅ WebSocket 全局初始化成功') } catch (error) { console.error('WebSocket 初始化失败:', error) diff --git a/src/components/call/CallWindow.vue b/src/components/call/CallWindow.vue index 6711f0d..b740e64 100644 --- a/src/components/call/CallWindow.vue +++ b/src/components/call/CallWindow.vue @@ -31,13 +31,15 @@ + @@ -47,6 +49,7 @@ {{ call.callerName || '对方' }} + diff --git a/src/components/call/GlobalCallProvider.vue b/src/components/call/GlobalCallProvider.vue index d26a775..a13ffa7 100644 --- a/src/components/call/GlobalCallProvider.vue +++ b/src/components/call/GlobalCallProvider.vue @@ -1,4 +1,20 @@ diff --git a/src/composables/useWebRTC.ts b/src/composables/useWebRTC.ts index 0bb7ebf..5f250bd 100644 --- a/src/composables/useWebRTC.ts +++ b/src/composables/useWebRTC.ts @@ -1,11 +1,13 @@ /** - * WebRTC 通话组合式函数 - UniApp 适配版 - * 主要支持 H5 平台,APP/小程序需要平台特定实现 + * WebRTC 通话组合式函数 - 全局单例模式 + * 支持在任意页面接听来电 */ -import { reactive, shallowRef, ref } from 'vue' +import { reactive, shallowRef, ref, computed } from 'vue' import * as messageApi from '@/api/modules/message' import { wsManager } from '@/api/websocket' +import { useAuthStore } from '@/stores/auth' import { useChatStore } from '@/stores/chat' +import { useConversationStore } from '@/stores/conversation' import type { ChatMessage, Contact } from '@/types/api' import type { CallStatus } from '@/types/message' @@ -24,46 +26,46 @@ export interface CallState { startTime: number | null callerName?: string callerAvatar?: string + callerId?: string } -export function useWebRTC( - userId: string, - onIncomingCall?: (senderUserId: string) => void, - getRoomId?: (receiverUserId?: string) => string -) { +// --- 全局单例状态 --- +const call = reactive({ + active: false, + minimized: false, + type: 'video', + status: 'idle', + statusText: '', + id: null, + muted: false, + remoteMuted: false, + camOff: false, + remoteCamOff: false, + duration: 0, + startTime: null, +}) + +const isCaller = ref(false) +const localStream = shallowRef(null) +const remoteStream = shallowRef(null) +let durationTimer: ReturnType | null = null +let pc: RTCPeerConnection | null = null +const pendingCandidates: RTCIceCandidate[] = [] +let currentReceiverUserId = '' +let currentRoomId = '' +let audioContext: UniApp.InnerAudioContext | null = null + +export function useWebRTC() { + const authStore = useAuthStore() const chatStore = useChatStore() + const conversationStore = useConversationStore() - const call = reactive({ - active: false, - minimized: false, - type: 'video', - status: 'idle', - statusText: '', - id: null, - muted: false, - remoteMuted: false, - camOff: false, - remoteCamOff: false, - duration: 0, - startTime: null, - }) - - const isCaller = ref(false) - const localStream = shallowRef(null) - const remoteStream = shallowRef(null) - let durationTimer: ReturnType | null = null - let pc: RTCPeerConnection | null = null - const pendingCandidates: RTCIceCandidate[] = [] - let currentReceiverUserId = '' - let currentRoomId = '' - - // 音频播放(使用内置音频方法) - let audioContext: UniApp.InnerAudioContext | null = null + const userId = computed(() => authStore.user?.id || '') + const isActive = computed(() => call.active) function playRingtone() { stopRingtone() // #ifdef H5 - // H5 使用简单的提示音 try { const AudioContext = window.AudioContext || (window as any).webkitAudioContext const ctx = new AudioContext() @@ -87,22 +89,38 @@ export function useWebRTC( function getSafeRoomId(targetUserId?: string): string | null { if (currentRoomId) return currentRoomId - if (targetUserId && getRoomId) { - const rid = getRoomId(targetUserId) - if (rid) return rid + + // 尝试从联系人获取房间ID + if (targetUserId) { + const contact = chatStore.contacts.find(c => c.contact_user_id === targetUserId) + if (contact?.room_id) return contact.room_id } - if (targetUserId && userId) { - return [userId, targetUserId].sort().join('_') + + // 尝试从会话获取房间ID + if (targetUserId) { + const conv = conversationStore.conversations.find(c => c.target_id === targetUserId) + if (conv?.room_id) return conv.room_id + } + + // 生成临时房间ID + if (targetUserId && userId.value) { + return [userId.value, targetUserId].sort().join('_') } return null } + // 初始化信令监听器 + function initListener() { + wsManager.offSignal(handleSignaling) + wsManager.onSignal(handleSignaling) + } + // 信令处理 async function handleSignaling(message: ChatMessage) { try { const content = message.content ? JSON.parse(message.content) : {} - // 忽略群聊信令 + // 忽略群聊信令(由 useGroupWebRTC 处理) if (content.callRoomId || content.participantIds) { return } @@ -128,16 +146,28 @@ export function useWebRTC( call.minimized = false call.status = 'incoming' call.statusText = `邀请你${call.type === 'video' ? '视频' : '语音'}通话` + call.callerId = message.sender_user_id - // 获取来电者信息 + // 获取来电者信息 - 从联系人列表 const contact = chatStore.contacts.find(c => c.contact_user_id === message.sender_user_id) if (contact?.user) { call.callerName = contact.remark_name || contact.user.name call.callerAvatar = contact.user.avatar + } else { + // 尝试从会话列表获取 + const conv = conversationStore.conversations.find(c => c.target_id === message.sender_user_id) + if (conv) { + call.callerName = conv.name + call.callerAvatar = conv.avatar + } else { + // 最后尝试从消息中的额外信息获取 + if (extra.senderName) call.callerName = extra.senderName + if (extra.senderAvatar) call.callerAvatar = extra.senderAvatar + } } playRingtone() - if (onIncomingCall) onIncomingCall(message.sender_user_id) + console.log('📞 来电:', call.callerName, call.callerAvatar) } else if (signal === 'accepted') { stopRingtone() @@ -180,6 +210,8 @@ export function useWebRTC( uni.showToast({ title: '已在其他设备接听', icon: 'none' }) } else if (signal === 'reject') { uni.showToast({ title: '对方已拒绝', icon: 'none' }) + } else if (signal === 'hangup' || signal === 'ended') { + uni.showToast({ title: '通话已结束', icon: 'none' }) } } } catch (error) { @@ -249,6 +281,9 @@ export function useWebRTC( const targetUserId = receiverUserId || currentReceiverUserId const roomId = getSafeRoomId(targetUserId) if (!roomId) return + + // 获取当前用户信息用于传递给对方 + const currentUser = authStore.user const payload = { sender_client_id: wsManager.getClientId() || '', receiver_user_id: targetUserId, @@ -257,7 +292,11 @@ export function useWebRTC( content: JSON.stringify(data || {}), call_id: call.id, call_status: status, - extra: JSON.stringify({ type: call.type }), + extra: JSON.stringify({ + type: call.type, + senderName: currentUser?.name, + senderAvatar: currentUser?.avatar + }), } messageApi.sendMessage(payload).catch(console.error) } @@ -281,12 +320,11 @@ export function useWebRTC( } // 发起通话 - async function startCall(type: 'audio' | 'video', receiverUserId: string, roomId?: string, contact?: Contact) { + async function startCall(type: 'audio' | 'video', receiverUserId: string, roomId?: string, targetName?: string, targetAvatar?: string) { if (roomId) currentRoomId = roomId - else if (contact?.room_id) currentRoomId = contact.room_id - else if (getRoomId) { - const found = getRoomId(receiverUserId) - if (found) currentRoomId = found + else { + const foundRoomId = getSafeRoomId(receiverUserId) + if (foundRoomId) currentRoomId = foundRoomId } if (!currentRoomId) { @@ -306,6 +344,27 @@ export function useWebRTC( call.camOff = false call.remoteCamOff = false call.remoteMuted = false + call.callerId = receiverUserId + + // 设置对方信息 + if (targetName) call.callerName = targetName + if (targetAvatar) call.callerAvatar = targetAvatar + + // 尝试从联系人/会话获取对方信息 + if (!call.callerName || !call.callerAvatar) { + const contact = chatStore.contacts.find(c => c.contact_user_id === receiverUserId) + if (contact?.user) { + call.callerName = call.callerName || contact.remark_name || contact.user.name + call.callerAvatar = call.callerAvatar || contact.user.avatar + } else { + const conv = conversationStore.conversations.find(c => c.target_id === receiverUserId) + if (conv) { + call.callerName = call.callerName || conv.name + call.callerAvatar = call.callerAvatar || conv.avatar + } + } + } + remoteStream.value = null try { @@ -359,12 +418,18 @@ export function useWebRTC( function closeCall() { stopRingtone() currentRoomId = '' + currentReceiverUserId = '' call.active = false call.status = 'idle' call.statusText = '' call.id = null call.callerName = undefined call.callerAvatar = undefined + call.callerId = undefined + call.muted = false + call.camOff = false + call.remoteMuted = false + call.remoteCamOff = false stopCallTimer() // #ifdef H5 @@ -437,6 +502,8 @@ export function useWebRTC( call, localStream, remoteStream, + isActive, + initListener, startCall, acceptCall, rejectCall, @@ -450,4 +517,3 @@ export function useWebRTC( } export default useWebRTC - diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue index 0e670a7..1455ed1 100644 --- a/src/pages/chat/index.vue +++ b/src/pages/chat/index.vue @@ -37,21 +37,6 @@ - - - { console.log('来电:', senderUserId) }, - (receiverUserId) => { - if (roomId.value) return roomId.value - const contact = chatStore.contacts.find(c => c.contact_user_id === receiverUserId) - return contact?.room_id || '' - } -) +// 使用全局单例 webrtc +const webrtc = useWebRTC() const roomId = ref(''); const targetId = ref(''); const chatName = ref(''); const targetAvatar = ref(''); const inputText = ref(''); const messages = ref([]); const scrollToId = ref(''); const scrollTop = ref(0); const scrollWithAnimation = ref(false); const loadingMore = ref(false); const hasMore = ref(true); const showMore = ref(false); const showEmoji = ref(false); const playingAudioId = ref(null); const page = ref(1); @@ -409,16 +386,15 @@ const targetUser = computed(() => { const contact = chatStore.contacts.find(c => onLoad((options: any) => { roomId.value = options?.roomId || ''; targetId.value = options?.targetId || ''; chatName.value = decodeURIComponent(options?.name || '聊天'); targetAvatar.value = decodeURIComponent(options?.avatar || ''); isGroupChat.value = !targetId.value && !!roomId.value; loadMessages(); setupWebSocket(); if (isGroupChat.value && roomId.value) { loadGroupMembers() } const callType = options?.callType; if (callType && targetId.value) { setTimeout(() => { if (callType === 'audio') { startAudioCall() } else if (callType === 'video') { startVideoCall() } }, 500) } }); onMounted(() => { if (targetId.value) { conversationStore.clearUnread(targetId.value) } setTimeout(() => { scrollToBottom(false) }, 300) }); -onUnmounted(() => { wsManager.offMessage(handleNewMessage); wsManager.offSignal(handleSignal); stopAudio(); if (webrtc.call.active) { webrtc.endCall() } }); +onUnmounted(() => { wsManager.offMessage(handleNewMessage); stopAudio(); }); async function loadGroupMembers() { if (!roomId.value) return; try { groupMembers.value = await roomApi.getGroupMembers(roomId.value) } catch (e) { console.error('加载群成员失败:', e) } } async function loadMessages() { if (!roomId.value) return; try { const cached = chatStore.getRoomMessages(roomId.value); if (cached.length > 0) { messages.value = cached.map((m) => ({ ...m, isSelf: m.sender_user_id === currentUser.value?.id, extra: typeof m.extra === 'string' ? JSON.parse(m.extra || '{}') : m.extra })); scrollToBottom(false); return } const res = await messageApi.getMessages(roomId.value, 1, 50); messages.value = (res.data || []).reverse().map((m: ChatMessage) => ({ ...m, isSelf: m.sender_user_id === currentUser.value?.id, extra: typeof m.extra === 'string' ? JSON.parse(m.extra || '{}') : m.extra })); chatStore.setRoomMessages(roomId.value, messages.value); hasMore.value = res.data.length >= 50; scrollToBottom(false) } catch (error) { console.error('加载消息失败:', error) } } function onScrollToUpper() { if (!loadingMore.value && hasMore.value) { loadMoreMessages() } } async function loadMoreMessages() { if (loadingMore.value || !hasMore.value) { return } loadingMore.value = true; page.value++; const firstMsgId = messages.value.length > 0 ? messages.value[0].id : null; try { const res = await messageApi.getMessages(roomId.value, page.value, 50); const newMessages = (res.data || []).reverse().map((m: ChatMessage) => ({ ...m, isSelf: m.sender_user_id === currentUser.value?.id, extra: typeof m.extra === 'string' ? JSON.parse(m.extra || '{}') : m.extra })); messages.value = [...newMessages, ...messages.value]; hasMore.value = res.data.length >= 50; if (firstMsgId) { nextTick(() => { scrollWithAnimation.value = false; scrollToId.value = `msg-${firstMsgId}` }) } } catch { page.value-- } finally { loadingMore.value = false } } -function setupWebSocket() { wsManager.onMessage(handleNewMessage); wsManager.onSignal(handleSignal) } -function handleSignal(message: ChatMessage) { webrtc.handleSignaling(message) } -function startAudioCall() { if (!targetId.value) { toast.show('无法发起通话'); return } webrtc.startCall('audio', targetId.value, roomId.value) } -function startVideoCall() { if (!targetId.value) { toast.show('无法发起通话'); return } webrtc.startCall('video', targetId.value, roomId.value) } +function setupWebSocket() { wsManager.onMessage(handleNewMessage) } +function startAudioCall() { if (!targetId.value) { toast.show('无法发起通话'); return } webrtc.startCall('audio', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar) } +function startVideoCall() { if (!targetId.value) { toast.show('无法发起通话'); return } webrtc.startCall('video', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar) } function onMoreAudioCall() { showMore.value = false; startAudioCall() } function onMoreVideoCall() { showMore.value = false; startVideoCall() } function handleNewMessage(msg: ChatMessage) { if (msg.message_type === 6) return; const parsedExtra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra; const newMsg: ChatMessage = { ...msg, isSelf: msg.sender_user_id === currentUser.value?.id, extra: parsedExtra }; const isCurrentChat = msg.room_id === roomId.value; conversationStore.handleMessageUpdate(newMsg, newMsg.isSelf || false, isCurrentChat); if (!isCurrentChat) return; const exists = messages.value.some( (m) => m.id === newMsg.id || (m.isSelf && m.content === newMsg.content && Math.abs(new Date(m.created_at).getTime() - new Date(newMsg.created_at).getTime()) < 2000) ); if (!exists) { messages.value.push(newMsg); chatStore.addMessage(roomId.value, newMsg); scrollToBottom(true) } } diff --git a/src/pages/contact/index.vue b/src/pages/contact/index.vue index d4c54da..570f5c6 100644 --- a/src/pages/contact/index.vue +++ b/src/pages/contact/index.vue @@ -270,9 +270,6 @@ - - - @@ -290,7 +287,6 @@ import * as contactApi from '@/api/modules/contact' import * as roomApi from '@/api/modules/room' import AppAvatar from '@/components/common/AppAvatar.vue' import AppTabBar from '@/components/common/AppTabBar.vue' -import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue' import type { Contact, ContactGroup } from '@/types/api' // --- 逻辑完全不变 --- diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index bfc8c03..f687a2b 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -166,9 +166,6 @@ @logout="logout" /> - - - @@ -188,7 +185,6 @@ import * as conversationApi from '@/api/modules/conversation' import AppTabBar from '@/components/common/AppTabBar.vue' import AppDrawer from '@/components/common/AppDrawer.vue' import PlusMenu from '@/components/common/PlusMenu.vue' -import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue' import type { Conversation } from '@/types/conversation' const authStore = useAuthStore() diff --git a/src/pages/moment/index.vue b/src/pages/moment/index.vue index f0c3dfd..7f0564c 100644 --- a/src/pages/moment/index.vue +++ b/src/pages/moment/index.vue @@ -161,9 +161,6 @@ cancel-text="取消" /> - - - @@ -183,7 +180,6 @@ import { parseMediaUrls } from '@/types/moment' import { useToast, useMessage } from 'wot-design-uni' import AppTabBar from '@/components/common/AppTabBar.vue' import AppAvatar from '@/components/common/AppAvatar.vue' -import GlobalCallProvider from '@/components/call/GlobalCallProvider.vue' import type { Moment } from '@/types/moment' const authStore = useAuthStore()