diff --git a/src/assets/styles/main.scss b/src/assets/styles/main.scss index f168068..2972ebd 100644 --- a/src/assets/styles/main.scss +++ b/src/assets/styles/main.scss @@ -29,6 +29,11 @@ body { background: transparent; } +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: #475569 transparent; +} + /* 气泡尖角 */ .bubble-self { border-top-right-radius: 2px; diff --git a/src/components/call/CallWindow.vue b/src/components/call/CallWindow.vue new file mode 100644 index 0000000..19be607 --- /dev/null +++ b/src/components/call/CallWindow.vue @@ -0,0 +1,187 @@ + + + + + + + {{ call.type === 'video' ? '视频通话' : '语音通话' }} + + + + + + + + + + + + + + + {{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }} + + + {{ target?.remark_name || target?.user?.name || '未知用户' }} + + + + {{ call.statusText }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/chat/MessageList.vue b/src/components/chat/MessageList.vue index 8427f69..8ce442f 100644 --- a/src/components/chat/MessageList.vue +++ b/src/components/chat/MessageList.vue @@ -14,10 +14,11 @@ > diff --git a/src/components/common/Avatar.vue b/src/components/common/Avatar.vue index b2518e5..591d3b7 100644 --- a/src/components/common/Avatar.vue +++ b/src/components/common/Avatar.vue @@ -1,8 +1,8 @@ {{ avatar || name?.charAt(0).toUpperCase() || '?' }} @@ -17,25 +17,36 @@ interface Props { avatar?: string name?: string color?: string - size?: 'sm' | 'md' | 'lg' | 'xl' + size?: 'sm' | 'md' | 'lg' | 'xl' | 'contact' + rounded?: 'full' | 'xl' | 'lg' } const props = withDefaults(defineProps(), { size: 'md', + rounded: 'full', }) const sizeClass = computed(() => { const sizes = { - sm: 'w-8 h-8 text-xs', + sm: 'w-9 h-9 text-xs', md: 'w-10 h-10 text-sm', lg: 'w-12 h-12 text-base', xl: 'w-16 h-16 text-xl', + contact: 'w-11 h-11 text-lg', } return sizes[props.size] }) +const roundedClass = computed(() => { + const rounded = { + full: 'rounded-full', + xl: 'rounded-xl', + lg: 'rounded-lg', + } + return rounded[props.rounded] +}) + defineEmits<{ click: [] }>() - diff --git a/src/components/common/FileConfirmModal.vue b/src/components/common/FileConfirmModal.vue new file mode 100644 index 0000000..bf1b784 --- /dev/null +++ b/src/components/common/FileConfirmModal.vue @@ -0,0 +1,65 @@ + + + + + 发送确认 + + + + + + + + {{ name || '未命名文件' }} + {{ fileSize }} + + + + 取消 + + + 确认发送 + + + + + + + + diff --git a/src/composables/useWebRTC.ts b/src/composables/useWebRTC.ts new file mode 100644 index 0000000..726d5a4 --- /dev/null +++ b/src/composables/useWebRTC.ts @@ -0,0 +1,289 @@ +import { ref, reactive } from 'vue' +import * as systemApi from '@/api/modules/system' +import * as messageApi from '@/api/modules/message' +import { wsManager } from '@/api/websocket' +import type { ChatMessage } from '@/types/api' +import type { CallStatus } from '@/types/message' + +export interface CallState { + active: boolean + minimized: boolean + type: 'audio' | 'video' + status: 'idle' | 'outgoing' | 'incoming' | 'connected' + statusText: string + id: string | null + muted: boolean + camOff: boolean +} + +export function useWebRTC(userId: string) { + const call = reactive({ + active: false, + minimized: false, + type: 'video', + status: 'idle', + statusText: '', + id: null, + muted: false, + camOff: false, + }) + + const localVideo = ref(null) + const remoteVideo = ref(null) + let pc: RTCPeerConnection | null = null + let localStream: MediaStream | null = null + + /** + * 初始化媒体流 + */ + async function initMedia(videoEnabled: boolean): Promise { + try { + const constraints = { video: videoEnabled, audio: true } + const stream = await navigator.mediaDevices.getUserMedia(constraints) + localStream = stream + if (videoEnabled && localVideo.value) { + localVideo.value.srcObject = stream + } + } catch (error) { + console.error('Failed to get media:', error) + throw new Error('无法获取设备权限或设备不支持') + } + } + + /** + * 获取ICE服务器配置 + */ + async function getIceServers() { + try { + const servers = await systemApi.getIceServers(userId) + if (servers && servers.length > 0) { + return servers.map((s) => ({ + urls: s.urls, + username: s.username, + credential: s.credential, + })) + } + } catch (error) { + console.error('Failed to fetch ICE servers:', error) + } + // Fallback: 使用默认STUN + return [{ urls: 'stun:stun.l.google.com:19302' }] + } + + /** + * 创建PeerConnection + */ + async function createPC(): Promise { + const iceServers = await getIceServers() + iceServers.push({ urls: 'stun:stun.l.google.com:19302' }) + + pc = new RTCPeerConnection({ iceServers }) + + if (localStream) { + localStream.getTracks().forEach((track) => { + pc!.addTrack(track, localStream!) + }) + } + + pc.ontrack = (e) => { + if (remoteVideo.value) { + remoteVideo.value.srcObject = e.streams[0] + } + } + + pc.onicecandidate = (e) => { + if (e.candidate && call.id) { + sendSignal('candidate', e.candidate) + } + } + } + + let currentReceiverUserId = '' + + /** + * 发送信令 + */ + function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) { + if (!call.id) return + + const payload = { + sender_client_id: wsManager.getClientId() || '', + receiver_user_id: receiverUserId || currentReceiverUserId, + room_id: '', + message_type: 6, + content: JSON.stringify(data || {}), + call_id: call.id, + call_status: status, + extra: JSON.stringify({ type: call.type }), + } + + messageApi.sendMessage(payload).catch(console.error) + } + + /** + * 开始通话 + */ + async function startCall(type: 'audio' | 'video', receiverUserId: string) { + currentReceiverUserId = receiverUserId + call.type = type + call.id = Date.now().toString() + call.active = true + call.minimized = false + call.status = 'outgoing' + call.statusText = '等待对方接听...' + + try { + await initMedia(type === 'video') + await createPC() + sendSignal('invite', undefined, receiverUserId) + } catch (error: any) { + alert(error.message || '无法启动通话') + endCall() + } + } + + /** + * 接听通话 + */ + async function acceptCall(senderUserId?: string) { + if (senderUserId) { + currentReceiverUserId = senderUserId + } + call.status = 'connected' + call.statusText = '连接中...' + + try { + await initMedia(call.type === 'video') + await createPC() + + const offer = await pc!.createOffer() + await pc!.setLocalDescription(offer) + sendSignal('accepted', undefined, senderUserId) + sendSignal('offer', offer, senderUserId) + } catch (error) { + console.error('Failed to accept call:', error) + endCall() + } + } + + /** + * 结束通话 + */ + function endCall() { + sendSignal('hangup') + closeCall() + } + + /** + * 关闭通话 + */ + function closeCall() { + call.active = false + call.status = 'idle' + call.statusText = '' + call.id = null + + if (pc) { + pc.close() + pc = null + } + + if (localStream) { + localStream.getTracks().forEach((track) => track.stop()) + localStream = null + } + + if (localVideo.value) { + localVideo.value.srcObject = null + } + if (remoteVideo.value) { + remoteVideo.value.srcObject = null + } + } + + /** + * 处理信令消息 + */ + async function handleSignaling(message: ChatMessage) { + const signal = message.call_status as CallStatus + const extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra + + let callType = 'video' + if (extra && extra.type) { + callType = extra.type + } + + if (signal === 'invite') { + currentReceiverUserId = message.sender_user_id + call.id = message.call_id || Date.now().toString() + call.type = callType as 'audio' | 'video' + call.active = true + call.minimized = false + call.status = 'incoming' + call.statusText = `对方邀请您进行${callType === 'video' ? '视频' : '语音'}通话...` + } else if (signal === 'accepted') { + call.status = 'connected' + call.statusText = '通话中' + const offer = await pc!.createOffer() + await pc!.setLocalDescription(offer) + sendSignal('offer', offer, message.sender_user_id) + } else if (signal === 'offer') { + const desc = JSON.parse(message.content) + if (!pc) { + await initMedia(call.type === 'video') + await createPC() + } + await pc!.setRemoteDescription(desc) + const answer = await pc!.createAnswer() + await pc!.setLocalDescription(answer) + sendSignal('answer', answer, message.sender_user_id) + call.status = 'connected' + call.statusText = '通话中' + } else if (signal === 'answer') { + await pc!.setRemoteDescription(JSON.parse(message.content)) + } else if (signal === 'candidate') { + await pc!.addIceCandidate(JSON.parse(message.content)) + } else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) { + closeCall() + if (signal === 'answered_elsewhere') { + alert('已在其他设备接听') + } + } + } + + /** + * 切换静音 + */ + function toggleMute() { + call.muted = !call.muted + if (localStream) { + localStream.getAudioTracks()[0].enabled = !call.muted + } + } + + /** + * 切换摄像头 + */ + function toggleCamera() { + call.camOff = !call.camOff + if (localStream) { + const videoTrack = localStream.getVideoTracks()[0] + if (videoTrack) { + videoTrack.enabled = !call.camOff + } + } + } + + return { + call, + localVideo, + remoteVideo, + startCall, + acceptCall, + endCall, + handleSignaling, + toggleMute, + toggleCamera, + } +} + diff --git a/src/views/chat/ChatView.vue b/src/views/chat/ChatView.vue index c6e3bbd..58c0c6e 100644 --- a/src/views/chat/ChatView.vue +++ b/src/views/chat/ChatView.vue @@ -68,9 +68,10 @@ @@ -181,6 +183,29 @@ + + + + + + {{ authStore.user.name }} @@ -205,7 +231,7 @@
{{ name || '未命名文件' }}
{{ fileSize }}