This commit is contained in:
2025-12-11 15:37:33 +08:00
parent 6c837516f6
commit 1cca159427
9 changed files with 166 additions and 103 deletions

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useTheme } from './composables/useTheme'
import GlobalCallProvider from './components/call/GlobalCallProvider.vue'
const { theme } = useTheme()
</script>
@@ -7,6 +8,7 @@ const { theme } = useTheme()
<template>
<wd-config-provider :theme="theme">
<KuRootView />
<!-- 全局通话组件单聊 + 群通话 -->
<global-call-provider />
</wd-config-provider>
</template>

View File

@@ -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)

View File

@@ -31,13 +31,15 @@
<!-- 视频区域 -->
<view v-if="call.type === 'video'" class="video-area">
<!-- 远端视频 - 使用原生 video 元素以支持 srcObject -->
<video
v-if="remoteStream"
ref="remoteVideoRef"
class="remote-video"
autoplay
playsinline
/>
:srcObject="remoteStream"
></video>
<view v-else class="video-placeholder">
<view class="avatar-section">
<view class="avatar-ring ring-1"></view>
@@ -47,6 +49,7 @@
<text class="caller-name">{{ call.callerName || '对方' }}</text>
</view>
<!-- 本地视频 - 使用原生 video 元素以支持 srcObject -->
<video
v-if="localStream && !call.camOff"
ref="localVideoRef"
@@ -54,7 +57,8 @@
autoplay
muted
playsinline
/>
:srcObject="localStream"
></video>
</view>
<!-- 语音通话 / 视频占位 -->

View File

@@ -1,4 +1,20 @@
<template>
<!-- ========== 单聊通话组件 ========== -->
<call-window
v-if="callActive"
:call="callState"
:local-stream="localStreamValue"
:remote-stream="remoteStreamValue"
:format-duration="webrtc.formatDuration"
@accept="webrtc.acceptCall()"
@reject="webrtc.rejectCall()"
@end="webrtc.endCall()"
@toggle-mute="webrtc.toggleMute()"
@toggle-camera="webrtc.toggleCamera()"
@toggle-minimize="webrtc.toggleMinimize()"
/>
<!-- ========== 群通话组件 ========== -->
<!-- 全局群通话来电覆盖层 -->
<group-call-incoming />
@@ -10,16 +26,30 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useWebRTC } from '@/composables/useWebRTC'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import CallWindow from './CallWindow.vue'
import GroupCallIncoming from './GroupCallIncoming.vue'
import GroupCallWindow from './GroupCallWindow.vue'
import GroupCallBanner from './GroupCallBanner.vue'
const { initListener } = useGroupWebRTC()
// 单聊通话
const webrtc = useWebRTC()
// 使用计算属性确保响应式
const callActive = computed(() => webrtc.call.active)
const callState = computed(() => webrtc.call)
const localStreamValue = computed(() => webrtc.localStream.value)
const remoteStreamValue = computed(() => webrtc.remoteStream.value)
// 群通话
const { initListener: initGroupListener } = useGroupWebRTC()
onMounted(() => {
// 初始化信令监听器
initListener()
// 初始化单聊通话信令监听器
webrtc.initListener()
// 初始化群通话信令监听器
initGroupListener()
})
</script>

View File

@@ -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,15 +26,10 @@ 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 chatStore = useChatStore()
// --- 全局单例状态 ---
const call = reactive<CallState>({
active: false,
minimized: false,
@@ -56,14 +53,19 @@ export function useWebRTC(
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 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

View File

@@ -37,21 +37,6 @@
</view>
</view>
<!-- WebRTC 通话窗口 -->
<call-window
v-if="webrtc"
:call="webrtc.call"
:local-stream="webrtc.localStream.value"
:remote-stream="webrtc.remoteStream.value"
:format-duration="webrtc.formatDuration"
@accept="webrtc.acceptCall()"
@reject="webrtc.rejectCall()"
@end="webrtc.endCall()"
@toggle-mute="webrtc.toggleMute()"
@toggle-camera="webrtc.toggleCamera()"
@toggle-minimize="webrtc.toggleMinimize()"
/>
<!-- 消息列表 -->
<scroll-view
class="chat-scroll-area custom-scrollbar"
@@ -372,7 +357,6 @@ import { useToast } from 'wot-design-uni'
import { useTheme } from '@/composables/useTheme'
import { useWebRTC } from '@/composables/useWebRTC'
import AppAvatar from '@/components/common/AppAvatar.vue'
import CallWindow from '@/components/call/CallWindow.vue'
import MentionPicker from '@/components/chat/MentionPicker.vue'
import type { MentionUser } from '@/components/chat/MentionPicker.vue'
import * as roomApi from '@/api/modules/room'
@@ -384,15 +368,8 @@ const conversationStore = useConversationStore()
const toast = useToast()
const { isDark } = useTheme()
const webrtc = useWebRTC(
authStore.user?.id || '',
(senderUserId) => { 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<ChatMessage[]>([]); 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<number | null>(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) } }

View File

@@ -270,9 +270,6 @@
</view>
</wd-popup>
<!-- 全局群通话组件 -->
<global-call-provider />
<wd-toast />
<wd-message-box />
<app-tab-bar current="contacts" />
@@ -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'
// --- 逻辑完全不变 ---

View File

@@ -166,9 +166,6 @@
@logout="logout"
/>
<!-- 全局群通话组件 -->
<global-call-provider />
<wd-toast />
<wd-message-box :z-index="11000" />
<app-tab-bar current="messages" />
@@ -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()

View File

@@ -161,9 +161,6 @@
cancel-text="取消"
/>
<!-- 全局群通话组件 -->
<global-call-provider />
<wd-toast />
<wd-message-box />
@@ -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()