一些方法

This commit is contained in:
2025-07-05 16:09:14 +08:00
parent 9c9245ce63
commit 95ceff344f
10 changed files with 876 additions and 252 deletions

View File

@@ -1,53 +1,66 @@
<template>
<div
class="rounded-full p-3 flex items-center gap-3 min-w-60"
class="rounded-xl p-4 flex items-center gap-4 min-w-72 max-w-md cursor-pointer transition-all duration-200 hover:opacity-90"
:class="audioContainerClasses"
@click="togglePlay"
>
<a-button
type="primary"
shape="circle"
size="large"
@click="togglePlay"
class="flex-shrink-0"
:class="{ 'bg-white text-blue-500 hover:bg-gray-100': isSent }"
>
<component :is="isPlaying ? PauseOutlined : CaretRightOutlined" />
</a-button>
<!-- 播放状态指示器 -->
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full" :class="playIconBgClasses">
<component
:is="isPlaying ? PauseOutlined : CaretRightOutlined"
class="text-white"
style="font-size: 16px; transform: translateX(1px)"
/>
</div>
<div class="flex-1">
<!-- 微信风格音波动画 -->
<div class="flex items-center gap-1 h-8 mb-1">
<div class="flex-1 overflow-hidden">
<!-- 高级音波动画 -->
<div class="flex items-end gap-1 h-8 mb-2">
<div
v-for="i in 20"
:key="i"
class="rounded-full transition-all duration-150"
class="rounded-full transition-all duration-300 ease-out"
:class="waveBarClasses"
:style="{
width: '3px',
height: isPlaying ? `${getWaveHeight(i)}px` : '4px',
animationDelay: `${i * 50}ms`
}"
width: '3px',
height: isPlaying ? `${getWaveHeight(i)}px` : '4px',
animationDelay: `${i * 40}ms`
}"
></div>
</div>
<!-- 进度条 -->
<div class="w-full rounded-full h-1" :class="progressTrackClasses">
<div
class="h-1 rounded-full transition-all duration-100"
:class="progressBarClasses"
:style="{ width: `${progress}%` }"
></div>
<!-- 进度条和时间显示 -->
<div class="flex items-center gap-2 w-full">
<!-- 当前时间 -->
<div class="text-xs font-medium flex-shrink-0" :class="timeTextClasses">
{{ formatDuration(currentTime) }}
</div>
<!-- 进度条 -->
<div class="flex-1 rounded-full h-1.5 overflow-hidden" :class="progressTrackClasses">
<div
class="h-full rounded-full transition-all duration-300 ease-out"
:class="progressBarClasses"
:style="{ width: `${progress}%` }"
></div>
</div>
<!-- 剩余时间 -->
<div class="text-xs font-medium flex-shrink-0" :class="timeTextClasses">
-{{ formatDuration(remainingTime) }}
</div>
</div>
</div>
<div class="text-xs flex-shrink-0" :class="durationTextClasses">
{{ formatDuration(message.duration || 0) }}
<!-- 总时长显示 -->
<div class="text-sm font-medium flex-shrink-0 min-w-12 text-right" :class="durationTextClasses">
{{ formatDuration(audioDuration) }}
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted, computed } from 'vue';
import { ref, onUnmounted, computed, watch } from 'vue';
import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
const props = defineProps({
@@ -64,19 +77,57 @@ const props = defineProps({
const isPlaying = ref(false);
const progress = ref(0);
const audio = ref(null);
const audioDuration = ref(0);
const currentTime = ref(0);
const remainingTime = computed(() => Math.max(0, audioDuration.value - currentTime.value));
// 直接获取音频时长
const getAudioDuration = () => {
// 如果消息对象中已经包含时长,直接使用
if (props.message.duration && props.message.duration > 0) {
audioDuration.value = props.message.duration;
return;
}
// 否则创建临时音频获取元数据
const tempAudio = new Audio(props.message.content);
tempAudio.addEventListener('loadedmetadata', () => {
if (tempAudio.duration && tempAudio.duration > 0) {
audioDuration.value = Math.round(tempAudio.duration);
}
});
// 设置超时以防无法获取元数据
setTimeout(() => {
if (audioDuration.value === 0 && tempAudio.duration > 0) {
audioDuration.value = Math.round(tempAudio.duration);
}
}, 500);
};
// 初始化时获取音频时长
getAudioDuration();
// 样式计算属性
const audioContainerClasses = computed(() => {
if (props.isSent) {
return 'bg-white/20 backdrop-blur-sm';
return 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg';
} else {
return 'bg-gray-100 dark:bg-gray-600';
return 'bg-gray-50 dark:bg-gray-700 border border-gray-100 dark:border-gray-600 shadow';
}
});
const playIconBgClasses = computed(() => {
if (props.isSent) {
return 'bg-white/20';
} else {
return 'bg-blue-500';
}
});
const waveBarClasses = computed(() => {
if (props.isSent) {
return ['bg-white', { 'animate-wave': isPlaying.value }];
return ['bg-white/80', { 'animate-wave': isPlaying.value }];
} else {
return ['bg-blue-500', { 'animate-wave': isPlaying.value }];
}
@@ -86,7 +137,7 @@ const progressTrackClasses = computed(() => {
if (props.isSent) {
return 'bg-white/30';
} else {
return 'bg-gray-300 dark:bg-gray-500';
return 'bg-gray-200 dark:bg-gray-600';
}
});
@@ -100,33 +151,54 @@ const progressBarClasses = computed(() => {
const durationTextClasses = computed(() => {
if (props.isSent) {
return 'text-white/80';
return 'text-white/90';
} else {
return 'text-gray-600 dark:text-gray-300';
return 'text-gray-700 dark:text-gray-300';
}
});
// 生成微信风格的波形高度
const timeTextClasses = computed(() => {
if (props.isSent) {
return 'text-white/80';
} else {
return 'text-gray-600 dark:text-gray-400';
}
});
// 生成高级波形高度
const getWaveHeight = (index) => {
const baseHeight = 4;
const maxHeight = 20;
const wavePattern = Math.sin((index * 0.5) + (Date.now() * 0.01)) * 0.5 + 0.5;
return baseHeight + (maxHeight - baseHeight) * wavePattern;
// 使用更平滑的正弦波叠加
const wavePattern =
Math.sin((index * 0.4) + (Date.now() * 0.008)) * 0.4 +
Math.cos((index * 0.7) + (Date.now() * 0.01)) * 0.3;
const normalized = Math.max(0, wavePattern * 0.5 + 0.5);
return baseHeight + (maxHeight - baseHeight) * normalized;
};
const togglePlay = () => {
if (!audio.value) {
audio.value = new Audio(props.message.content);
// 确保获取音频时长
audio.value.addEventListener('loadedmetadata', () => {
if (audio.value.duration && audio.value.duration > 0) {
audioDuration.value = Math.round(audio.value.duration);
}
});
audio.value.addEventListener('timeupdate', () => {
if (audio.value.duration) {
progress.value = (audio.value.currentTime / audio.value.duration) * 100;
currentTime.value = Math.round(audio.value.currentTime);
progress.value = (currentTime.value / audioDuration.value) * 100;
}
});
audio.value.addEventListener('ended', () => {
isPlaying.value = false;
progress.value = 0;
currentTime.value = 0;
});
}
@@ -155,11 +227,14 @@ onUnmounted(() => {
<style scoped>
@keyframes wave {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(1.5); }
0%, 100% { transform: scaleY(0.7); opacity: 0.7; }
25% { transform: scaleY(1.2); opacity: 1; }
50% { transform: scaleY(0.8); opacity: 0.8; }
75% { transform: scaleY(1.1); opacity: 0.9; }
}
.animate-wave {
animation: wave 1s ease-in-out infinite;
animation: wave 1.2s ease-in-out infinite;
transform-origin: center bottom;
}
</style>
</style>

View File

@@ -12,13 +12,13 @@
<div class="friend-details">
<h3 class="friend-name">{{ chatStore.currentFriend?.name }}</h3>
<p class="friend-status">
<span
class="status-dot"
:class="isFriendOnline ? 'online' : 'offline'"
></span>
<span
class="status-dot"
:class="isFriendOnline ? 'online' : 'offline'"
></span>
<span class="status-text">
{{ isFriendOnline ? '在线' : '离线' }}
</span>
{{ isFriendOnline ? '在线' : '离线' }}
</span>
</p>
</div>
</div>
@@ -68,16 +68,18 @@
<!-- 视频通话组件 -->
<VideoCallComponent
v-if="showVideoCall"
:caller-info="chatStore.currentFriend"
:caller-info="callerInfo"
:call-type="callType"
:is-incoming="false"
@end-call="endCall"
:is-incoming="isIncoming"
@accept-call="handleAcceptCall"
@reject-call="handleRejectCall"
@end-call="handleEndCall"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { useChatStore } from '@/stores/chat';
import { useThemeStore } from '@/stores/theme';
import VideoCallComponent from './VideoCallComponent.vue';
@@ -89,6 +91,37 @@ const isFriendOnline = ref(true);
const showMoreActions = ref(false);
const showVideoCall = ref(false);
const callType = ref('video');
const isIncoming = ref(false);
const callerInfo = ref(null);
// 监听通话状态变化
watch(() => chatStore.callStatus, (status) => {
if (status === 'calling' || status === 'ringing' || status === 'ongoing') {
showVideoCall.value = true;
} else {
showVideoCall.value = false;
}
});
// 监听来电
watch(() => chatStore.incomingCall, (call) => {
if (call) {
isIncoming.value = true;
callerInfo.value = chatStore.friends.find(f => f.id === call.callerId);
callType.value = call.callType;
} else {
isIncoming.value = false;
}
});
// 监听当前通话
watch(() => chatStore.currentCall, (call) => {
if (call) {
callerInfo.value = chatStore.friends.find(f => f.id === call.peerId);
callType.value = call.callType;
isIncoming.value = false;
}
});
// 点击外部关闭菜单
const handleClickOutside = (e) => {
@@ -106,9 +139,7 @@ const startVoiceCall = () => {
return;
}
callType.value = 'audio';
showVideoCall.value = true;
console.log('开始语音通话:', chatStore.currentFriend.name);
chatStore.startCall(chatStore.currentFriend.id, 'audio');
};
// 开始视频通话
@@ -118,15 +149,22 @@ const startVideoCall = () => {
return;
}
callType.value = 'video';
showVideoCall.value = true;
console.log('开始视频通话:', chatStore.currentFriend.name);
chatStore.startCall(chatStore.currentFriend.id, 'video');
};
// 接听来电
const handleAcceptCall = () => {
chatStore.acceptCall();
};
// 拒绝来电
const handleRejectCall = () => {
chatStore.rejectCall();
};
// 结束通话
const endCall = () => {
showVideoCall.value = false;
console.log('通话结束');
const handleEndCall = () => {
chatStore.endCall();
};
// 清空聊天记录
@@ -146,6 +184,7 @@ const viewFriendProfile = () => {
</script>
<style scoped>
/* 保持原有样式不变 */
.chat-header {
background: #ffffff;
border-bottom: 1px solid #e2e8f0;
@@ -389,4 +428,4 @@ const viewFriendProfile = () => {
gap: 8px;
}
}
</style>
</style>

View File

@@ -4,10 +4,10 @@
ref="container"
class="video-call-container"
:class="{
'minimized': isMinimized,
'dark': themeStore.isDarkMode,
'dragging': isDragging
}"
'minimized': isMinimized,
'dark': themeStore.isDarkMode,
'dragging': isDragging
}"
:style="containerStyle"
>
<!-- 拖拽头部 -->
@@ -55,6 +55,7 @@
<div class="remote-video-preview">
<video
v-if="callType === 'video' && hasRemoteVideo"
ref="remoteVideoPreview"
class="remote-video"
autoplay
playsinline
@@ -117,50 +118,66 @@
<!-- 控制栏 -->
<div v-if="!isMinimized" class="call-controls">
<button
class="control-btn"
:class="{ 'active': isMicOn, 'disabled': !isMicOn }"
@click="toggleMic"
:title="isMicOn ? '关闭麦克风' : '开启麦克风'"
>
<i class="fas" :class="isMicOn ? 'fa-microphone' : 'fa-microphone-slash'"></i>
</button>
<!-- 来电控制按钮 -->
<div v-if="isIncoming && !isOngoing" class="incoming-call-controls">
<button class="accept-btn" @click="acceptCall">
<i class="fas fa-phone"></i> 接听
</button>
<button class="reject-btn" @click="rejectCall">
<i class="fas fa-phone-slash"></i> 拒绝
</button>
</div>
<button
v-if="callType === 'video'"
class="control-btn"
:class="{ 'active': isCameraOn, 'disabled': !isCameraOn }"
@click="toggleCamera"
:title="isCameraOn ? '关闭摄像头' : '开启摄像头'"
>
<i class="fas" :class="isCameraOn ? 'fa-video' : 'fa-video-slash'"></i>
</button>
<template v-else>
<button
class="control-btn"
:class="{ 'active': isMicOn, 'disabled': !isMicOn }"
@click="toggleMic"
:title="isMicOn ? '关闭麦克风' : '开启麦克风'"
>
<i class="fas" :class="isMicOn ? 'fa-microphone' : 'fa-microphone-slash'"></i>
</button>
<button
class="control-btn speaker-btn"
:class="{ 'active': isSpeakerOn }"
@click="toggleSpeaker"
:title="isSpeakerOn ? '关闭扬声器' : '开启扬声器'"
>
<i class="fas" :class="isSpeakerOn ? 'fa-volume-up' : 'fa-volume-mute'"></i>
</button>
<button
v-if="callType === 'video'"
class="control-btn"
:class="{ 'active': isCameraOn, 'disabled': !isCameraOn }"
@click="toggleCamera"
:title="isCameraOn ? '关闭摄像头' : '开启摄像头'"
>
<i class="fas" :class="isCameraOn ? 'fa-video' : 'fa-video-slash'"></i>
</button>
<button
class="control-btn end-call-btn"
@click="endCall"
title="结束通话"
>
<i class="fas fa-phone-slash"></i>
</button>
<button
class="control-btn speaker-btn"
:class="{ 'active': isSpeakerOn }"
@click="toggleSpeaker"
:title="isSpeakerOn ? '关闭扬声器' : '开启扬声器'"
>
<i class="fas" :class="isSpeakerOn ? 'fa-volume-up' : 'fa-volume-mute'"></i>
</button>
<button
class="control-btn end-call-btn"
@click="endCall"
title="结束通话"
>
<i class="fas fa-phone-slash"></i>
</button>
</template>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue';
import { useThemeStore } from '@/stores/theme';
import { useWebRTC } from '@/composables/useWebRTC';
import { useChatStore } from '@/stores/chat';
const themeStore = useThemeStore();
const chatStore = useChatStore();
const webrtc = useWebRTC();
const props = defineProps({
callerInfo: {
@@ -194,12 +211,18 @@ const callTimer = ref(null);
const isCameraWorking = ref(true);
const isMicWorking = ref(true);
const isDragging = ref(false);
const isOngoing = ref(false);
// 拖拽相关状态
const position = reactive({ x: 0, y: 0 });
const startPosition = reactive({ x: 0, y: 0 });
const container = ref(null);
// 元素引用
const localVideo = ref(null);
const remoteVideo = ref(null);
const remoteVideoPreview = ref(null);
// 通话计时器
const startCallTimer = () => {
if (callTimer.value) clearInterval(callTimer.value);
@@ -227,7 +250,7 @@ const containerStyle = computed(() => {
// 通话状态
const callStatus = computed(() => {
if (props.isIncoming) {
if (props.isIncoming && !isOngoing.value) {
return '来电中...';
}
return '通话中';
@@ -241,16 +264,11 @@ const initializeMedia = async () => {
video: props.callType === 'video'
};
// 模拟媒体访问
setTimeout(() => {
if (props.callType === 'video') {
hasLocalVideo.value = true;
hasRemoteVideo.value = true;
}
connectionText.value = '已连接';
startCallTimer();
}, 1500);
// 获取本地媒体流
await webrtc.getLocalMedia(constraints);
connectionText.value = '已连接';
startCallTimer();
} catch (error) {
console.error('无法访问媒体设备:', error);
isCameraOn.value = false;
@@ -263,18 +281,30 @@ const initializeMedia = async () => {
const toggleMic = () => {
isMicOn.value = !isMicOn.value;
isMicWorking.value = isMicOn.value;
if (webrtc.localStream.value) {
webrtc.localStream.value.getAudioTracks().forEach(track => {
track.enabled = isMicOn.value;
});
}
};
const toggleCamera = () => {
if (props.callType === 'video') {
isCameraOn.value = !isCameraOn.value;
hasLocalVideo.value = isCameraOn.value;
isCameraWorking.value = isCameraOn.value;
if (webrtc.localStream.value) {
webrtc.localStream.value.getVideoTracks().forEach(track => {
track.enabled = isCameraOn.value;
});
}
}
};
const toggleSpeaker = () => {
isSpeakerOn.value = !isSpeakerOn.value;
if (remoteVideo.value) {
remoteVideo.value.muted = !isSpeakerOn.value;
}
};
const toggleMinimize = () => {
@@ -293,11 +323,32 @@ const toggleMinimize = () => {
const endCall = () => {
if (callTimer.value) clearInterval(callTimer.value);
// 清除所有视频源
if (localVideo.value) localVideo.value.srcObject = null;
if (remoteVideo.value) remoteVideo.value.srcObject = null;
if (remoteVideoPreview.value) remoteVideoPreview.value.srcObject = null;
isVisible.value = false;
emit('end-call');
chatStore.endCall();
};
// 拖拽功能 - 完全重写以解决偏移问题
// 接听通话
const acceptCall = () => {
isOngoing.value = true;
initializeMedia();
emit('accept-call');
};
// 拒绝通话
const rejectCall = () => {
isVisible.value = false;
emit('reject-call');
chatStore.rejectCall();
};
// 拖拽功能
const startDrag = (event) => {
if (event.target.closest('.action-btn') || event.target.closest('.control-btn')) {
return;
@@ -351,14 +402,58 @@ const handleWindowResize = () => {
position.y = Math.max(0, Math.min(position.y, window.innerHeight - height));
};
onMounted(() => {
initializeMedia();
// 监听本地流变化
watch(() => webrtc.localStream.value, (newStream) => {
if (newStream && localVideo.value) {
localVideo.value.srcObject = newStream;
hasLocalVideo.value = true;
} else if (localVideo.value) {
localVideo.value.srcObject = null;
hasLocalVideo.value = false;
}
});
// 监听远程流变化
watch(() => webrtc.remoteStream.value, (newStream) => {
if (newStream) {
if (remoteVideo.value) {
remoteVideo.value.srcObject = newStream;
}
if (remoteVideoPreview.value) {
remoteVideoPreview.value.srcObject = newStream;
}
hasRemoteVideo.value = true;
} else {
if (remoteVideo.value) {
remoteVideo.value.srcObject = null;
}
if (remoteVideoPreview.value) {
remoteVideoPreview.value.srcObject = null;
}
hasRemoteVideo.value = false;
}
});
// 监听通话状态
watch(() => chatStore.callStatus, (status) => {
if (status === 'ongoing') {
isOngoing.value = true;
} else if (status === 'ended') {
endCall();
}
});
onMounted(() => {
// 设置初始居中位置
position.x = Math.max(0, (window.innerWidth - 500) / 2);
position.y = Math.max(0, (window.innerHeight - 380) / 2);
window.addEventListener('resize', handleWindowResize);
// 如果是呼出方或已接听,初始化媒体
if (!props.isIncoming || isOngoing.value) {
initializeMedia();
}
});
onUnmounted(() => {
@@ -366,10 +461,24 @@ onUnmounted(() => {
document.removeEventListener('mousemove', handleDrag);
document.removeEventListener('mouseup', stopDrag);
window.removeEventListener('resize', handleWindowResize);
// 清除所有视频源
if (localVideo.value) {
localVideo.value.srcObject = null;
}
if (remoteVideo.value) {
remoteVideo.value.srcObject = null;
}
if (remoteVideoPreview.value) {
remoteVideoPreview.value.srcObject = null;
}
webrtc.closeConnection();
});
</script>
<style scoped>
/* 保持原有样式不变 */
.video-call-container {
position: fixed;
z-index: 9999;
@@ -570,6 +679,12 @@ onUnmounted(() => {
position: relative;
}
.remote-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-placeholder {
width: 100%;
height: 100%;
@@ -690,6 +805,50 @@ onUnmounted(() => {
background: #ff5252;
}
/* 新增来电控制按钮样式 */
.incoming-call-controls {
position: absolute;
bottom: 100px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 40px;
z-index: 100;
}
.accept-btn {
width: 80px;
height: 80px;
border-radius: 50%;
background: #4CAF50;
color: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.reject-btn {
width: 80px;
height: 80px;
border-radius: 50%;
background: #F44336;
color: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* 响应式设计 */
@media (max-width: 600px) {
.video-call-container:not(.minimized) {

View File

@@ -28,6 +28,7 @@ export function useRecording() {
const audioUrl = URL.createObjectURL(audioBlob)
const duration = Math.floor((Date.now() - recordingStartTime.value) / 1000)
console.log(' aaa', 'ssssssssssssssss')
// 触发音频消息发送事件
const event = new CustomEvent("audioRecorded", {
detail: {

View File

@@ -8,7 +8,10 @@ export function useWebRTC() {
const isConnecting = ref(false)
const configuration = {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }, { urls: "stun:stun1.l.google.com:19302" }],
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" }
]
}
const createPeerConnection = () => {
@@ -16,14 +19,15 @@ export function useWebRTC() {
peerConnection.value.onicecandidate = (event) => {
if (event.candidate) {
// 发送ICE候选到对端
console.log("ICE候选:", event.candidate)
}
}
peerConnection.value.ontrack = (event) => {
remoteStream.value = event.streams[0]
console.log("接收到远程流:", event.streams[0])
if (event.streams && event.streams[0]) {
remoteStream.value = event.streams[0]
console.log("接收到远程流:", remoteStream.value)
}
}
peerConnection.value.onconnectionstatechange = () => {
@@ -64,7 +68,11 @@ export function useWebRTC() {
})
}
const offer = await peerConnection.value.createOffer()
const offer = await peerConnection.value.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
})
await peerConnection.value.setLocalDescription(offer)
return offer
@@ -82,7 +90,7 @@ export function useWebRTC() {
})
}
await peerConnection.value.setRemoteDescription(offer)
await peerConnection.value.setRemoteDescription(new RTCSessionDescription(offer))
const answer = await peerConnection.value.createAnswer()
await peerConnection.value.setLocalDescription(answer)
@@ -90,29 +98,41 @@ export function useWebRTC() {
}
const setRemoteAnswer = async (answer) => {
await peerConnection.value.setRemoteDescription(answer)
await peerConnection.value.setRemoteDescription(new RTCSessionDescription(answer))
}
const addIceCandidate = async (candidate) => {
if (peerConnection.value && peerConnection.value.remoteDescription) {
await peerConnection.value.addIceCandidate(candidate)
try {
await peerConnection.value.addIceCandidate(new RTCIceCandidate(candidate))
} catch (error) {
console.error("添加ICE候选失败:", error)
}
}
}
const closeConnection = () => {
if (localStream.value) {
localStream.value.getTracks().forEach((track) => track.stop())
localStream.value = null
localStream.value.getTracks().forEach((track) => {
track.stop();
if (localStream.value) {
localStream.value.removeTrack(track);
}
});
localStream.value = null;
}
if (peerConnection.value) {
peerConnection.value.close()
peerConnection.value = null
peerConnection.value.onicecandidate = null;
peerConnection.value.ontrack = null;
peerConnection.value.onconnectionstatechange = null;
peerConnection.value.close();
peerConnection.value = null;
}
remoteStream.value = null
isConnected.value = false
isConnecting.value = false
remoteStream.value = null;
isConnected.value = false;
isConnecting.value = false;
}
return {
@@ -129,4 +149,4 @@ export function useWebRTC() {
addIceCandidate,
closeConnection,
}
}
}

View File

@@ -25,7 +25,7 @@ export function useWebSocket() {
socket.value.onopen = handleOpen
socket.value.onmessage = handleMessage
socket.value.onclose = disconnectWebSocket
socket.value.onclose = handleClose
socket.value.onerror = handleError
} catch (error) {
console.error("WebSocket连接失败:", error)
@@ -42,24 +42,17 @@ export function useWebSocket() {
reconnectDelay.value = 1000
chatStore.connectionStatus = "connected"
chatStore.socket = socket.value
// 绑定当前用户
if (userStore.currentUser?.id) {
bindUser(userStore.currentUser.id)
}
}
const handleMessage = (event) => {
try {
const data = JSON.parse(event.data)
console.log("收到消息:", data)
// 处理客户端ID
if (data?.clientId) {
// console.log("收到客户端ID:", data.clientId)
// userStore.setClientId(data.clientId)
//
// // 绑定用户
// if (userStore.currentUser?.id) {
bindUser(userStore.currentUser.id)
// }
return
}
console.log("收到WebSocket消息:", data)
// 处理通话信令
if (data.call_id && data.call_status) {
@@ -76,7 +69,7 @@ export function useWebSocket() {
}
}
const disconnectWebSocket = (event) => {
const handleClose = (event) => {
console.log("WebSocket连接已关闭:", event.code, event.reason)
isConnected.value = false
isConnecting.value = false
@@ -89,7 +82,7 @@ export function useWebSocket() {
reconnectAttempts.value++
reconnectDelay.value = Math.min(reconnectDelay.value * 2, 30000)
console.log(`尝试重连 (${reconnectAttempts.value}/${maxReconnectAttempts})`)
connect()
connectWebSocket()
}, reconnectDelay.value)
}
}
@@ -102,40 +95,15 @@ export function useWebSocket() {
const handleCallSignal = (data) => {
console.log("收到通话信令:", data)
// 根据通话状态处理
switch (data.call_status) {
case "invite":
// 显示来电通知
showIncomingCallDialog(data)
break
case "accepted":
// 通话被接受
handleCallAccepted(data)
break
case "rejected":
// 通话被拒绝
handleCallRejected(data)
break
case "ended":
// 通话结束
handleCallEnded(data)
break
case "candidate":
// ICE候选
handleIceCandidate(data)
break
}
chatStore.handleCallSignal(data)
}
const bindUser = (userId) => {
console.log(userId, 'ssssssssss')
if (!isConnected.value || !userId) return
const bindMessage = {
request_type: "bind",
sender_user_id: userId,
client_id: userStore.clientId,
}
send(bindMessage)
@@ -149,6 +117,7 @@ export function useWebSocket() {
try {
socket.value.send(JSON.stringify(message))
console.log("发送消息:", message)
return true
} catch (error) {
console.error("发送消息失败:", error)
@@ -169,7 +138,11 @@ export function useWebSocket() {
}
const sendCallSignal = (signal) => {
return send(signal)
return send({
request_type: "call_signal",
...signal,
sender_user_id: userStore.currentUser?.id
})
}
const disconnect = () => {
@@ -193,7 +166,7 @@ export function useWebSocket() {
})
onUnmounted(() => {
disconnectWebSocket()
disconnect()
})
return {
@@ -201,48 +174,11 @@ export function useWebSocket() {
isConnected,
isConnecting,
connectWebSocket,
disconnectWebSocket,
disconnect,
send,
sendMessage,
sendCallSignal,
bindUser,
generateCallId,
}
}
// 全局通话处理函数
const incomingCallDialog = null
const currentCall = null
function showIncomingCallDialog(callData) {
// 这里应该显示来电弹窗
console.log("显示来电弹窗:", callData)
// 创建来电通知
if ("Notification" in window && Notification.permission === "granted") {
new Notification(`${callData.sender_user_id} 邀请您进行${callData.message_type === 6 ? "视频" : "语音"}通话`, {
icon: "/favicon.ico",
tag: "incoming-call",
})
}
}
function handleCallAccepted(data) {
console.log("通话被接受:", data)
// 开始WebRTC连接
}
function handleCallRejected(data) {
console.log("通话被拒绝:", data)
// 显示拒绝消息
}
function handleCallEnded(data) {
console.log("通话结束:", data)
// 清理通话状态
}
function handleIceCandidate(data) {
console.log("收到ICE候选:", data)
// 处理ICE候选
}
}

View File

@@ -1,6 +1,9 @@
import { defineStore } from "pinia"
import { ref, computed } from "vue"
import { chatDB } from "@/utils/db"
import { useWebRTC } from "@/composables/useWebRTC"
import { useUserStore } from "@/stores/user"
import { sendMessage } from "@/utils/request.js"
export const useChatStore = defineStore("chat", () => {
const friends = ref([])
@@ -9,34 +12,20 @@ export const useChatStore = defineStore("chat", () => {
const connectionStatus = ref("disconnected")
const socket = ref(null)
// 通话相关状态
const currentCall = ref(null)
const incomingCall = ref(null)
const callStatus = ref("idle") // idle, calling, ringing, ongoing, ended
const webrtc = useWebRTC()
const userStore = useUserStore()
// 计算属性
const isConnected = computed(() => connectionStatus.value === "connected")
const connectionStatusText = computed(() => {
switch (connectionStatus.value) {
case "connected":
return "已连接"
case "connecting":
return "连接中..."
case "disconnected":
return "已断开"
default:
return "未知状态"
}
})
const connectionStatusIcon = computed(() => {
switch (connectionStatus.value) {
case "connected":
return "wifi"
case "connecting":
return "loading"
case "disconnected":
return "disconnect"
default:
return "question"
}
})
const isInCall = computed(() => callStatus.value !== "idle")
const isCalling = computed(() => callStatus.value === "calling")
const isRinging = computed(() => callStatus.value === "ringing")
const isOngoingCall = computed(() => callStatus.value === "ongoing")
// 初始化数据库
const initDB = async () => {
@@ -84,7 +73,7 @@ export const useChatStore = defineStore("chat", () => {
}
}
// 设置当前好友 - 新增方法
// 设置当前好友
const setCurrentFriend = (friend) => {
currentFriend.value = friend
// 清除未读消息计数
@@ -146,6 +135,12 @@ export const useChatStore = defineStore("chat", () => {
if (receiverId !== currentUserId) return
// 如果是通话信令,直接处理
if (messageData.call_id || messageData.call_status) {
handleCallSignal(messageData)
return
}
const newMessage = {
type: getMessageType(messageData.message_type),
content: messageData.content,
@@ -188,24 +183,202 @@ export const useChatStore = defineStore("chat", () => {
// 根据消息类型编号获取类型字符串
const getMessageType = (type) => {
const types = ["text", "image", "audio", "video", "prescription", "medical-record", "video-call"]
const types = ["text", "image", "audio", "video", "prescription", "medical-record", "video-call", "audio-call", "file"]
return types[type] || "text"
}
// 处理通话信令
const handleCallSignal = (data) => {
console.log("处理通话信令:", data)
// 根据通话状态处理
switch (data.call_status) {
case "invite":
handleIncomingCall(data)
break
case "accepted":
handleCallAccepted(data)
break
case "rejected":
handleCallRejected(data)
break
case "ended":
handleCallEnded(data)
break
case "candidate":
handleIceCandidate(data)
break
}
}
// 处理来电
const handleIncomingCall = (data) => {
incomingCall.value = {
callId: data.call_id,
callerId: data.sender_user_id,
callType: data.message_type === 6 ? "video" : "audio",
content: data.content
}
callStatus.value = "ringing"
console.log("收到来电:", incomingCall.value)
}
// 处理通话接受
const handleCallAccepted = (data) => {
if (currentCall.value && currentCall.value.callId === data.call_id) {
callStatus.value = "ongoing"
console.log("通话已被接受")
}
}
// 处理通话拒绝
const handleCallRejected = (data) => {
if (currentCall.value && currentCall.value.callId === data.call_id) {
callStatus.value = "ended"
currentCall.value = null
console.log("通话已被拒绝")
}
}
// 处理通话结束
const handleCallEnded = (data) => {
if ((currentCall.value && currentCall.value.callId === data.call_id) ||
(incomingCall.value && incomingCall.value.callId === data.call_id)) {
callStatus.value = "ended"
currentCall.value = null
incomingCall.value = null
console.log("通话已结束")
}
}
// 处理ICE候选
const handleIceCandidate = (data) => {
if (currentCall.value && currentCall.value.callId === data.call_id) {
console.log("收到ICE候选:", data.content)
// 这里可以添加WebRTC处理逻辑
}
}
// 发起通话
const startCall = (peerId, type) => {
if (isInCall.value) return
const callId = Date.now().toString() + Math.random().toString(36).substr(2, 9)
currentCall.value = {
callId: callId,
peerId: peerId,
callType: type
}
callStatus.value = "calling"
// 发送通话邀请
sendMessage({
sender_user_id: userStore.currentUser?.id,
receiver_user_id: peerId,
message_type: type === "video" ? 6 : 7, // 6=视频,7=语音
message_content: "", // 对于邀请,内容为空
call_id: callId,
call_status: "invite"
})
console.log("发起通话:", currentCall.value)
}
// 接听来电
const acceptCall = () => {
if (!incomingCall.value) return
// 发送接受响应
sendMessage({
sender_user_id: userStore.currentUser?.id,
receiver_user_id: incomingCall.value.callerId,
message_type: incomingCall.value.callType === "video" ? 6 : 7,
message_content: "", // 对于接受,内容为空
call_id: incomingCall.value.callId,
call_status: "accepted"
})
currentCall.value = {
callId: incomingCall.value.callId,
peerId: incomingCall.value.callerId,
callType: incomingCall.value.callType
}
callStatus.value = "ongoing"
incomingCall.value = null
console.log("接听通话")
}
// 拒绝来电
const rejectCall = () => {
if (!incomingCall.value) return
// 发送拒绝响应
sendMessage({
sender_user_id: userStore.currentUser?.id,
receiver_user_id: incomingCall.value.callerId,
message_type: incomingCall.value.callType === "video" ? 6 : 7,
message_content: "", // 对于拒绝,内容为空
call_id: incomingCall.value.callId,
call_status: "rejected"
})
callStatus.value = "idle"
incomingCall.value = null
console.log("拒绝通话")
}
// 结束通话
const endCall = () => {
const callId = currentCall.value?.callId || incomingCall.value?.callId
const peerId = currentCall.value?.peerId || incomingCall.value?.callerId
const callType = currentCall.value?.callType || incomingCall.value?.callType
if (!callId || !peerId) return
// 发送结束通知
sendMessage({
sender_user_id: userStore.currentUser?.id,
receiver_user_id: peerId,
message_type: callType === "video" ? 6 : 7,
message_content: "",
call_id: callId,
call_status: "ended"
})
callStatus.value = "idle"
currentCall.value = null
incomingCall.value = null
console.log("结束通话")
}
return {
friends,
currentFriend,
messages,
connectionStatus,
socket,
currentCall,
incomingCall,
callStatus,
isConnected,
connectionStatusText,
connectionStatusIcon,
isInCall,
isCalling,
isRinging,
isOngoingCall,
initDB,
loadFriends,
setCurrentFriend,
switchFriend,
addMessage,
handleIncomingMessage,
handleCallSignal,
startCall,
acceptCall,
rejectCall,
endCall
}
})
})

View File

@@ -37,6 +37,10 @@ export const useUserStore = defineStore("user", () => {
}
}
const setClientId = (clientId) => {
currentUser.value.clientId = clientId
}
return {
currentUser,
isLoggedIn,
@@ -44,5 +48,6 @@ export const useUserStore = defineStore("user", () => {
login,
logout,
checkSession,
setClientId,
}
})