chore: refact webRTC relative logic and update npm module

This commit is contained in:
2026-03-17 18:10:30 +08:00
parent 6f205d2408
commit 08ae7414d0
16 changed files with 6503 additions and 6241 deletions

4
.env_template Normal file
View File

@@ -0,0 +1,4 @@
STUN_URL=''
TURN_URL=''
TURN_USERNAME=''
TURN_CREDENTIAL=''

64
.gitignore vendored
View File

@@ -1,30 +1,34 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
.DS_Store .DS_Store
dist dist
dist-ssr dist-ssr
coverage coverage
*.local *.local
/cypress/videos/ /cypress/videos/
/cypress/screenshots/ /cypress/screenshots/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
*.tsbuildinfo *.tsbuildinfo
# dev environment variables
.env

9178
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,38 @@
{ {
"name": "myplayer_vue", "name": "myplayer_vue",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"proxy": "node proxy.js" "proxy": "node proxy.js"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.6", "axios": "^1.13.6",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dplayer": "^1.27.1", "dotenv": "^17.3.1",
"element-plus": "^2.9.4", "dplayer": "^1.27.1",
"express": "^4.21.2", "element-plus": "^2.9.4",
"hls.js": "^1.5.20", "express": "^4.21.2",
"idb": "^8.0.2", "hls.js": "^1.5.20",
"node-fetch": "^3.3.2", "idb": "^8.0.2",
"pinia": "^3.0.1", "node-fetch": "^3.3.2",
"pinia-plugin-persistedstate": "^4.2.0", "pinia": "^3.0.1",
"video.js": "^8.21.0", "pinia-plugin-persistedstate": "^4.2.0",
"videojs-vtt.js": "^0.15.5", "video.js": "^8.21.0",
"vue": "^3.5.13", "videojs-vtt.js": "^0.15.5",
"vue-axios": "^3.5.2", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-axios": "^3.5.2",
}, "vue-router": "^4.5.0"
"devDependencies": { },
"@vitejs/plugin-vue": "^5.2.1", "devDependencies": {
"vite": "^6.0.11", "@vitejs/plugin-vue": "^5.2.1",
"vite-plugin-vue-devtools": "^7.7.1" "vite": "^6.0.11",
} "vite-plugin-vue-devtools": "^7.7.1"
} },
"overrides": {
"axios": "^1.13.6"
}
}

View File

@@ -1,162 +1,159 @@
<template> <template>
<nav class="navbar"> <nav class="navbar">
<div class="navbar-left"> <div class="navbar-left">
<router-link to="/home" class="navbar-logo"> <router-link to="/home" class="navbar-logo">
<img :src="logo" alt="MyPlayer" /> <img :src="logo" alt="MyPlayer" />
</router-link> </router-link>
</div> </div>
<div class="navbar-right"> <div class="navbar-right">
<router-link to="/home/search">搜索</router-link> <router-link to="/home/search">搜索</router-link>
<router-link to="/home/friends">好友</router-link> <router-link to="/home/friends">好友</router-link>
<router-link to="/home/group">群聊</router-link> <router-link to="/home/group">群聊</router-link>
<router-link to="/home/playroom">Playroom</router-link> <router-link to="/home/playroom">Playroom</router-link>
<div class="more-dropdown" @click="toggleDropdown" @blur="closeDropdown"> <div class="more-dropdown" @click="toggleDropdown" @blur="closeDropdown">
更多 更多
<ul v-if="isDropdownOpen" class="dropdown-content"> <ul v-if="isDropdownOpen" class="dropdown-content">
<li>设置</li> <li>设置</li>
<li @click="aboutUs">关于我们</li> <li @click="aboutUs">关于我们</li>
<li>帮助</li> <li>帮助</li>
<li @click="logout">退出登录</li> <li @click="logout">退出登录</li>
</ul> </ul>
</div> </div>
</div> </div>
</nav> </nav>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, initCustomFormatter } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
import logo from '../assets/logo.png'; import logo from '../assets/logo.png';
import { onlineSocketStore } from '@/store/Online'; import { onlineSocketStore } from '@/store/Online';
import router from '@/router'; import router from '@/router';
import { voiceStore } from '@/store/Voice'; import { groupMessageStore } from '@/store/message.ts';
import { groupMessageStore } from '@/store/message.ts'; import { userInfoStore } from '@/store/user';
import { userInfoStore } from '@/store/user';
const userInfo = userInfoStore()
const userInfo = userInfoStore() const socket = onlineSocketStore()
const voice = voiceStore() const groupMessage = groupMessageStore()
const socket = onlineSocketStore()
const groupMessage = groupMessageStore()
const isDropdownOpen = ref(false); // 控制下拉框的显示与隐藏
const isDropdownOpen = ref(false); // 控制下拉框的显示与隐藏 const toggleDropdown = () => {
isDropdownOpen.value = !isDropdownOpen.value; // 切换下拉框的显示状态
const toggleDropdown = () => { };
isDropdownOpen.value = !isDropdownOpen.value; // 切换下拉框的显示状态
}; const closeDropdown = () => {
isDropdownOpen.value = false; // 关闭下拉框
const closeDropdown = () => { };
isDropdownOpen.value = false; // 关闭下拉框
}; // 监听全局点击事件
const handleGlobalClick = (event) => {
// 监听全局点击事件 // 检查点击是否发生在下拉框外部
const handleGlobalClick = (event) => { if (!event.target.closest('.more-dropdown')) {
// 检查点击是否发生在下拉框外部 closeDropdown();
if (!event.target.closest('.more-dropdown')) { }
closeDropdown(); };
}
}; onMounted(() => {
document.addEventListener('click', handleGlobalClick);
onMounted(() => { });
document.addEventListener('click', handleGlobalClick);
}); onUnmounted(() => {
document.removeEventListener('click', handleGlobalClick);
onUnmounted(() => { });
document.removeEventListener('click', handleGlobalClick);
});
//退出登录逻辑
const logout = () => {
//退出登录逻辑 //保存群聊消息
const logout = () => { groupMessage.saveMessagesHistory(socket.u_id,groupMessage.g_id)
//保存群聊消 // 清除用户全局信
groupMessage.saveMessagesHistory(socket.u_id,groupMessage.g_id) userInfo.clearUserInfo();
// 清除用户全局信息 localStorage.clear();
userInfo.clearUserInfo(); // 断开websocket链接
localStorage.clear(); socket.disconnect();
// 断开websocket链接 // 跳转到登录页面
voice.disconnect(); window.location.href = '/';
socket.disconnect();
// 跳转到登录页面 };
window.location.href = '/';
const aboutUs = () => {
}; router.push('/home/about')
}
const aboutUs = () => {
router.push('/home/about')
} </script>
<style scoped>
</script> .navbar {
display: flex;
<style scoped> justify-content: space-between;
.navbar { align-items: center;
display: flex; position: fixed;
justify-content: space-between; top: 0;
align-items: center; left: 0;
position: fixed; width: 100%;
top: 0; padding: 10px 20px;
left: 0; background-color: #60605e;
width: 100%; color: #fff;
padding: 10px 20px; z-index: 1000;
background-color: #60605e; }
color: #fff;
z-index: 1000; .navbar-left,
} .navbar-right {
display: flex;
.navbar-left, align-items: center;
.navbar-right { margin-right: 10%;
display: flex; margin-left: 10%;
align-items: center; }
margin-right: 10%;
margin-left: 10%; .navbar-logo img {
} width: 40px;
height: 40px;
.navbar-logo img { }
width: 40px;
height: 40px; .navbar a {
} color: #fff;
margin: 0 10px;
.navbar a { text-decoration: none;
color: #fff; }
margin: 0 10px;
text-decoration: none; .navbar a.router-link-active {
} font-weight: bold;
}
.navbar a.router-link-active {
font-weight: bold; .more-dropdown {
} position: relative;
cursor: pointer;
.more-dropdown { }
position: relative;
cursor: pointer; .dropdown-content {
} position: absolute;
width: 100px;
.dropdown-content { margin-top: 22px;
position: absolute; right: -50px;
width: 100px; color: #fff;
margin-top: 22px; background-color: #444444;
right: -50px; border: 1px solid #000000;
color: #fff; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
background-color: #444444; padding: 5px;
border: 1px solid #000000; z-index: 1000;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); list-style-type: none;
padding: 5px; }
z-index: 1000;
list-style-type: none; .dropdown-content a {
} display: block;
margin: 5px 0;
.dropdown-content a { }
display: block;
margin: 5px 0; .dropdown-content li {
} height: 40px;
padding: 10px;
.dropdown-content li { }
height: 40px;
padding: 10px; .dropdown-content li:hover {
}
background-color: #ffffff;
.dropdown-content li:hover { color: #000000;
}
background-color: #ffffff;
color: #000000;
}
</style> </style>

View File

@@ -1,153 +1,152 @@
<template> <template>
<el-button class="telegram-btn" @click="showTelegramPanel"> <el-button class="telegram-btn" @click="showTelegramPanel">
<el-icon> <el-icon>
<Mic /> <Mic />
</el-icon> </el-icon>
<div class="telegramTag"></div> <div class="telegramTag"></div>
</el-button> </el-button>
<div class="voice" v-if="onCall.panel"> <div class="voice" v-if="onCall.panel">
<div> <div>
<div class="profilebox"> <div class="profilebox">
<img :src="onCall.target.avatar" alt="头像"> <img :src="onCall.target.avatar" alt="头像">
</div> </div>
<div class="infobox"> <div class="infobox">
<p>{{ onCall.target.name }}</p> <p>{{ onCall.target.name }}</p>
</div> </div>
<div class="buttonsbox"> <div class="buttonsbox">
<button @click="answer" v-if="!onCall.from"> <button @click="answer" v-if="!onCall.from">
接听 接听
</button> </button>
<button @click="hangup"> <button @click="denyCall(userinfo.user.id, onCall.target.u_id)" v-if="!onCall.from">
挂断 拒绝
</button> </button>
</div> <button @click="hangupCall(userinfo.user.id, onCall.target.u_id)" v-if="onCall.from">
</div> 挂断
<el-button @click="playRemoteAudio">没有声音试试我</el-button> </button>
</div> </div>
<audio ref="remoteAudio" autoplay></audio> </div>
<el-button @click="playRemoteAudio">没有声音试试我</el-button>
</template> </div>
<script setup> <audio ref="remoteAudio" id="remoteAudio" autoplay playsinline></audio>
import { userInfoStore } from '@/store/store';
import { voiceStore } from '@/store/Voice'; </template>
import { onCallStore } from '@/store/VoiceTarget'; <script setup>
import { Mic } from '@element-plus/icons-vue'; import { userInfoStore } from '@/store/user';
import { ref, onMounted, onBeforeUnmount } from 'vue'; import { onCallStore } from '@/store/VoiceTarget';
import { Mic } from '@element-plus/icons-vue';
const voice = voiceStore(); import { ref, onMounted, onBeforeUnmount } from 'vue';
const onCall = onCallStore(); import { denyCall, hangupCall,sendOffer } from '@/store/Voice.ts';
const userinfo = userInfoStore()
const onCall = onCallStore();
const localAudio = ref(null); const userinfo = userInfoStore()
const remoteAudio = ref(null);
const localAudio = ref(null);
onMounted(() => { const remoteAudio = ref(null);
onCall.setRemoteElement(remoteAudio.value);
}); onMounted(() => {
onCall.setRemoteElement(remoteAudio.value);
onBeforeUnmount(() => { });
// 清理音频流和元素
if (localAudio.value.srcObject) { onBeforeUnmount(() => {
localAudio.value.srcObject.getTracks().forEach(track => track.stop()); // 清理音频流和元素
} if (localAudio.value && localAudio.value.srcObject) {
if (remoteAudio.value.srcObject) { localAudio.value.srcObject.getTracks().forEach(track => track.stop());
remoteAudio.value.srcObject.getTracks().forEach(track => track.stop()); }
} if (remoteAudio.value && remoteAudio.value.srcObject) {
}) remoteAudio.value.srcObject.getTracks().forEach(track => track.stop());
}
const answer = () => { })
console.log("接听");
onCall.callingOff(); const answer = async () => {
onCall.fromOn(); console.log("接听");
voice.pickup(userinfo.user.u_id, onCall.target.u_id) onCall.callingOff();
} onCall.fromOn();
// voice.pickup(userinfo.user.id, onCall.target.u_id)
const hangup = () => { await sendOffer(userinfo.user.id, onCall.target.u_id);
voice.hangup(); }
}
const showTelegramPanel = () => { const showTelegramPanel = () => {
onCall.panel = !onCall.panel; onCall.panel = !onCall.panel;
} }
const playRemoteAudio = () => { const playRemoteAudio = () => {
const audio = document.getElementById("remoteAudio"); const audio = remoteAudio.value;
if (audio) { if (!audio) return;
audio.play().catch(err => { audio.play().catch(err => {
console.log(err); console.log(err);
}); });
} }
}
</script>
</script> <style>
<style> .voice {
.voice { position: fixed;
position: fixed; top: 210px;
top: 210px; right: 100px;
right: 100px; width: 300px;
width: 300px; height: 400px;
height: 400px; background-color: #d7d7d7;
background-color: #d7d7d7; z-index: 999;
z-index: 999; }
}
.profilebox {
.profilebox { width: 100%;
width: 100%; height: 150px;
height: 150px; display: flex;
display: flex; justify-content: center;
justify-content: center; align-items: center;
align-items: center; }
}
.profilebox img {
.profilebox img { width: 100px;
width: 100px; height: 100px;
height: 100px; border-radius: 50%;
border-radius: 50%; background-color: #fff;
background-color: #fff; }
}
.infobox {
.infobox { width: 100%;
width: 100%; height: 50px;
height: 50px; display: flex;
display: flex; justify-content: center;
justify-content: center; align-items: center;
align-items: center; font-size: 18px;
font-size: 18px; color: #646464;
color: #646464; }
}
.buttonsbox {
.buttonsbox { width: 100%;
width: 100%; height: 100px;
height: 100px; display: flex;
display: flex; justify-content: center;
justify-content: center; align-items: center;
align-items: center; }
}
.buttonsbox button {
.buttonsbox button { margin: 0 20px 0 20px;
margin: 0 20px 0 20px; width: 50px;
width: 50px; height: 50px;
height: 50px; border-radius: 50%;
border-radius: 50%; }
}
.telegram-btn {
.telegram-btn { position: fixed;
position: fixed; top: 45%;
top: 45%; right: 0;
right: 0; z-index: 1000;
z-index: 1000; }
}
.telegramTag {
.telegramTag { position: absolute;
position: absolute; right: 0;
right: 0; bottom: 0;
bottom: 0; width: 10px;
width: 10px; height: 10px;
height: 10px; border-radius: 50%;
border-radius: 50%; background-color: #f00;
background-color: #f00; }
}
</style> </style>

View File

@@ -1,72 +1,72 @@
<template> <template>
<router-link to="/home/user" v-if="userinfo.user.u_avatar" class="user-profile-link"> <router-link to="/home/user" v-if="userinfo.user.u_avatar" class="user-profile-link">
<div class="user-profile"> <div class="user-profile">
<img :src="userinfo.user.u_avatar" alt="User Avatar" /> <img :src="userinfo.user.u_avatar" alt="User Avatar" />
<div :class="['status-dot', statusClass]"></div> <div :class="['status-dot', statusClass]"></div>
</div> </div>
</router-link> </router-link>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { userInfoStore } from '@/store/store'; import { userInfoStore } from '@/store/user.ts';
const userinfo = userInfoStore(); const userinfo = userInfoStore();
const isLoggedIn = ref(true); // 假设用户已登录 const isLoggedIn = ref(true); // 假设用户已登录
const statusClass = computed(() => { const statusClass = computed(() => {
return isLoggedIn.value ? 'online' : 'offline'; return isLoggedIn.value ? 'online' : 'offline';
}); });
</script> </script>
<style scoped> <style scoped>
.user-profile-link { .user-profile-link {
display: inline-block; display: inline-block;
text-decoration: none; text-decoration: none;
/* 去掉链接的下划线 */ /* 去掉链接的下划线 */
} }
.user-profile { .user-profile {
position: fixed; position: fixed;
bottom: 30px; bottom: 30px;
right: 30px; right: 30px;
width: 150px; width: 150px;
height: 150px; height: 150px;
border-radius: 50%; border-radius: 50%;
background-color: #fff; background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
z-index: 500; z-index: 500;
} }
.user-profile img { .user-profile img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
border-radius: 50%; border-radius: 50%;
} }
.status-dot { .status-dot {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
right: 0; right: 0;
width: 50px; width: 50px;
height: 50px; height: 50px;
border-radius: 50%; border-radius: 50%;
border: 2px solid white; border: 2px solid white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
} }
.online { .online {
background-color: green; background-color: green;
/* 在线状态 */ /* 在线状态 */
} }
.offline { .offline {
background-color: red; background-color: red;
/* 离线状态 */ /* 离线状态 */
} }
</style> </style>

View File

@@ -1,37 +1,42 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { connectWebSocket, disconnectWebSocket, sendMessage, setIsManualClose } from '@/websocket/onlineSocket' import { connectWebSocket, disconnectWebSocket, sendMessage, setIsManualClose } from '@/websocket/onlineSocket'
import { messageStore } from './message' import { messageStore } from './message'
const message = messageStore() const message = messageStore()
export const onlineSocketStore = defineStore('onlineSocket', { export const onlineSocketStore = defineStore('onlineSocket', {
state: () => ({ state: () => ({
isConnected: false, isConnected: false,
hasGotMessage: false, hasGotMessage: false,
id: '' id: ''
}), }),
actions: { actions: {
connect(id) { connect(id: number) {
this.id = id; this.id = id;
if (this.isConnected === true) return if (this.isConnected === true) return
connectWebSocket(); connectWebSocket();
this.isConnected = true; this.isConnected = true;
if (!this.hasGotMessage) { if (!this.hasGotMessage) {
message.loadMessagesHistory(this.id) message.loadMessagesHistory(this.id)
this.hasGotMessage = true this.hasGotMessage = true
} }
}, },
disconnect() { disconnect() {
setIsManualClose(true); setIsManualClose(true);
disconnectWebSocket(); disconnectWebSocket();
this.isConnected = false; this.isConnected = false;
if (this.hasGotMessage) { if (this.hasGotMessage) {
message.saveMessagesHistory(this.id) message.saveMessagesHistory(this.id)
} }
}, },
send(message) { send(message: any) {
sendMessage(JSON.stringify(message)); try {
} sendMessage(message);
} } catch (error) {
console.error('Failed to stringify message:', error);
return;
}
}
}
}) })

View File

@@ -1,50 +0,0 @@
import { connectVoicesocket, disconnectVoicesocket, sendMessage, hangup } from "@/websocket/voiceSocket";
import { defineStore } from "pinia";
export const voiceStore = defineStore("voice", {
state: () => ({
isConnected: false
}),
actions: {
connect() {
connectVoicesocket();
this.isConnected = true;
},
disconnect() {
disconnectVoicesocket();
this.isConnected = false;
},
startCall(from, from_name, from_avatar, to) {
if (this.isConnected) {
const message = {
type: "incomingcall",
from: from,
from_name: from_name,
from_avatar: from_avatar,
to: to
}
sendMessage(JSON.stringify(message))
} else {
console.log("voice socket is not connected")
}
},
pickup(from,to){
if (this.isConnected) {
const message ={
type: "pickup",
from: from,
to: to
}
sendMessage(JSON.stringify(message))
} else {
console.log("voice socket is not connected")
}
},
hangup(){
hangup()
}
}
}
)

235
src/store/Voice.ts Normal file
View File

@@ -0,0 +1,235 @@
import { sendMessage } from "@/websocket/onlineSocket";
import { onCallStore } from "@/store/VoiceTarget";
import { ref } from "vue";
const iceserver = {
iceServers: [
{
urls: process.env.STUN_URL,
},
{
urls: process.env.TURN_URL,
username: process.env.TURN_USERNAME,
credential: process.env.TURN_CREDENTIAL,
},
],
};
interface message {
cmd: "VOICE_ICE_CANDIDATE" | "VOICE_SDP_OFFER" | "VOICE_SDP_ANSWER",
from: number,
to: number,
content: any,
}
const oncall = onCallStore();
const RTCpeerConnection = ref(null);
const localstream = ref(null);
const remotestream = ref<MediaStream | null>(null);
// 本地候选:为了避免“绑定 onicecandidate 太晚”导致丢失,统一先缓存,等 remoteDescription 就绪后再发送
const pendingLocalCandidates = ref<any[]>([]);
// 远端候选:避免远端 candidate 早到但 remoteDescription 未 set 导致 addIceCandidate 失败
const pendingRemoteCandidates = ref<any[]>([]);
// 当前会话对端信息(用于发送 ICE 时带上 from/to
const currentFrom = ref<number | null>(null);
const currentTo = ref<number | null>(null);
const canSendIceNow = () => {
const pc: any = RTCpeerConnection.value;
return (
!!pc &&
!!pc.localDescription &&
!!pc.remoteDescription &&
currentFrom.value !== null &&
currentTo.value !== null
);
};
const flushLocalCandidatesIfReady = () => {
if (!canSendIceNow()) return;
if (pendingLocalCandidates.value.length === 0) return;
pendingLocalCandidates.value.forEach((candidate) => {
const message: message = {
cmd: "VOICE_ICE_CANDIDATE",
from: currentFrom.value as number,
to: currentTo.value as number,
content: candidate,
};
sendMessage(message);
});
pendingLocalCandidates.value = [];
};
const flushRemoteCandidatesIfReady = async () => {
const pc: any = RTCpeerConnection.value;
if (!pc || !pc.remoteDescription) return;
if (pendingRemoteCandidates.value.length === 0) return;
for (const c of pendingRemoteCandidates.value) {
await pc.addIceCandidate(new RTCIceCandidate(c));
}
pendingRemoteCandidates.value = [];
};
const getlocalStream = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log("获取本地音频流成功");
return stream;
// // 获取音频和视频轨道
// const audioTrack = stream.getAudioTracks()[0];
// // 将轨道添加到 RTCPeerConnection
// peerConnection.addTrack(audioTrack, stream);
};
const closeLocalStream = (stream: MediaStream) => {
stream.getTracks().forEach(track => track.stop());
}
export const initRTCconnection = async () => {
RTCpeerConnection.value = new RTCPeerConnection(iceserver);
localstream.value = await getlocalStream();
RTCpeerConnection.value.addTrack(localstream.value.getAudioTracks()[0], localstream.value);
// 必须尽早绑定,否则可能在 setLocalDescription 后就把 candidate 产完了
RTCpeerConnection.value.onicecandidate = (event: { candidate: any }) => {
if (!event.candidate) return;
pendingLocalCandidates.value.push(event.candidate);
flushLocalCandidatesIfReady();
};
// 远端音频:收到 track 后把 MediaStream 绑定到 UI 的 <audio>
RTCpeerConnection.value.ontrack = (event: RTCTrackEvent) => {
// 优先用浏览器提供的 streams[0];否则手动聚合 track
let stream = event.streams && event.streams[0] ? event.streams[0] : null;
if (!stream) {
if (!remotestream.value) remotestream.value = new MediaStream();
if (event.track) remotestream.value.addTrack(event.track);
stream = remotestream.value;
}
if (stream) {
oncall.setRemoteStream(stream);
}
};
};
export const sendOffer = async (from: number,to: number) => {
if (!RTCpeerConnection.value) {
await initRTCconnection();
};
currentFrom.value = from;
currentTo.value = to;
const offer = await RTCpeerConnection.value.createOffer();
await RTCpeerConnection.value.setLocalDescription(offer);
const message: message = {
cmd: "VOICE_SDP_OFFER",
from: from,
to: to,
content: offer
}
sendMessage(message);
}
export const handleOffer = async (offer: any, from: number, to: number) => {
if (!RTCpeerConnection.value) {
await initRTCconnection();
}
// 这里是“我收到对方 offer”因此我这端的 from/to 应该是 (to -> from)
currentFrom.value = to;
currentTo.value = from;
await RTCpeerConnection.value.setRemoteDescription(new RTCSessionDescription(offer));
await flushRemoteCandidatesIfReady();
const answer = await RTCpeerConnection.value.createAnswer();
await RTCpeerConnection.value.setLocalDescription(answer);
const message: message = {
cmd: "VOICE_SDP_ANSWER",
from: to,
to: from,
content: answer
}
sendMessage(message);
// remoteDescription + localDescription 都已具备,允许发送本地候选
flushLocalCandidatesIfReady();
}
export const handleAnswer = async (answer: any, from: number, to: number) => {
if (!RTCpeerConnection.value)
{
console.error("connection lost");
return;
}
// 这里是“我收到对方 answer”因此我这端的 from/to 应该是 (to -> from)
currentFrom.value = to;
currentTo.value = from;
await RTCpeerConnection.value.setRemoteDescription(new RTCSessionDescription(answer));
await flushRemoteCandidatesIfReady();
flushLocalCandidatesIfReady();
}
export const handleCandidate = async (candidate: any) => {
if (!RTCpeerConnection.value) return;
// candidate 可能早到remoteDescription 未 set 时直接 add 会失败,先缓存
if (!RTCpeerConnection.value.remoteDescription) {
pendingRemoteCandidates.value.push(candidate);
return;
}
await RTCpeerConnection.value.addIceCandidate(new RTCIceCandidate(candidate));
}
export const hangupCall = (from: number, to: number) => {
if(from === null || to === null) return;
if (RTCpeerConnection.value) {
RTCpeerConnection.value.close();
RTCpeerConnection.value = null;
const msg = {
cmd: "VOICE_CALL_END",
from: from,
to: to,
time: new Date().toLocaleString()
}
sendMessage(msg);
oncall.clear();
closeLocalStream(localstream.value);
remotestream.value = null;
pendingLocalCandidates.value = [];
pendingRemoteCandidates.value = [];
currentFrom.value = null;
currentTo.value = null;
}
}
export const denyCall = (from: number, to: number) => {
if(from === null || to === null) return;
const msg = {
cmd: "VOICE_CALL_DENY",
from: from,
to: to,
time: new Date().toLocaleString()
}
sendMessage(msg);
oncall.clear();
remotestream.value = null;
pendingLocalCandidates.value = [];
pendingRemoteCandidates.value = [];
currentFrom.value = null;
currentTo.value = null;
}
export const closeConnection = () => {
if (RTCpeerConnection.value) {
RTCpeerConnection.value.close();
RTCpeerConnection.value = null;
}
closeLocalStream(localstream.value);
remotestream.value = null;
pendingLocalCandidates.value = [];
pendingRemoteCandidates.value = [];
currentFrom.value = null;
currentTo.value = null;
}

View File

@@ -1,93 +1,115 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export const onCallStore = defineStore("onCall", { export const onCallStore = defineStore("onCall", {
state: () => ({ state: () => ({
target: { target: {
u_id: "", u_id: "",
name: "", name: "",
avatar: "" avatar: ""
}, },
panel: false, panel: false,
calling: false, calling: false,
from: false, from: false,
status: false, status: false,
localstream: { localstream: {
audioStream: null, audioStream: null,
audioElement: null audioElement: null
}, },
remotestream: { remotestream: {
audioStream: null, audioStream: null,
audioElement: null audioElement: null
} }
}), }),
actions: { actions: {
setTarget(u_id, u_name, u_avatar) { getTarget() {
this.target = { return this.target;
u_id: u_id, },
name: u_name, setTarget(u_id:number, u_name:string, u_avatar:string) {
avatar: u_avatar this.target = {
} u_id: u_id,
}, name: u_name,
panelOn() { avatar: u_avatar
this.panel = true }
}, },
panelOff() { panelOn() {
this.panel = false this.panel = true
}, },
callingOn() { panelOff() {
this.calling = true this.panel = false
}, },
callingOff() { callingOn() {
this.calling = false this.calling = true
}, },
fromOn() { callingOff() {
this.from = true this.calling = false
}, },
fromOff() { fromOn() {
this.from = false this.from = true
}, },
statusOn() { fromOff() {
this.status = true this.from = false
}, },
statusOff() { statusOn() {
this.status = false this.status = true
}, },
setLocalStream(stream) { statusOff() {
this.localstream.audioStream = stream; this.status = false
if (this.localstream.audioStream) { },
this.localstream.audioElement.srcObject = stream; clear(){
} this.target = {
}, u_id: "",
setLocalElement(element) { name: "",
this.localstream.audioElement = element; avatar: ""
if (this.localstream.audioStream) { }
this.localstream.audioElement.srcObject = this.localstream.audioStream; this.panel = false;
} this.calling = false;
}, this.from = false;
setRemoteStream(stream) { this.status = false;
this.remotestream.audioStream = stream; this.localstream = {
if (this.remotestream.audioStream) { audioStream: null,
this.remotestream.audioElement.srcObject = stream; audioElement: null
} }
}, this.remotestream = {
setRemoteElement(element) { audioStream: null,
this.remotestream.audioElement = element; audioElement: null
if (this.remotestream.audioStream) { }
this.remotestream.audioElement.srcObject = this.remotestream.audioStream; },
} setLocalStream(stream) {
}, this.localstream.audioStream = stream;
resetStream() { if (this.localstream.audioStream) {
// 停止本地媒体流 this.localstream.audioElement.srcObject = stream;
if (this.localstream.audioStream) { }
this.localstream.audioStream.getTracks().forEach(track => track.stop()); },
this.localstream.audioStream = null; setLocalElement(element) {
} this.localstream.audioElement = element;
if (this.localstream.audioStream) {
// 停止远程媒体流 this.localstream.audioElement.srcObject = this.localstream.audioStream;
if (this.remotestream.videoStream) { }
this.remotestream.videoStream.getTracks().forEach(track => track.stop()); },
this.remotestream.videoStream = null; setRemoteStream(stream) {
} this.remotestream.audioStream = stream;
} if (this.remotestream.audioStream) {
} this.remotestream.audioElement.srcObject = stream;
}
},
setRemoteElement(element) {
this.remotestream.audioElement = element;
if (this.remotestream.audioStream) {
this.remotestream.audioElement.srcObject = this.remotestream.audioStream;
}
},
resetStream() {
// 停止本地媒体流
if (this.localstream.audioStream) {
this.localstream.audioStream.getTracks().forEach(track => track.stop());
this.localstream.audioStream = null;
}
// 停止远程媒体流
if (this.remotestream.videoStream) {
this.remotestream.videoStream.getTracks().forEach(track => track.stop());
this.remotestream.videoStream = null;
}
}
}
}) })

View File

@@ -1,402 +1,410 @@
<template> <template>
<el-row class="container"> <el-row class="container">
<el-col :span="6"> <el-col :span="6">
<el-button type="primary" @click="dialogVisibleFriendManage = true">好友管理</el-button> <el-button type="primary" @click="dialogVisibleFriendManage = true">好友管理</el-button>
<el-input v-model="tempsearch" :prefix-icon="Search" style="width: 90%;" clearable></el-input> <el-input v-model="tempsearch" :prefix-icon="Search" style="width: 90%;" clearable></el-input>
<el-scrollbar style="height: 500px;width: 90%;"> <el-scrollbar style="height: 500px;width: 90%;">
<ul class="infinitelist" style="overflow: auto"> <ul class="infinitelist" style="overflow: auto">
<li v-for="(item) in tempsearchResult" :key="item.id" class="infinitelistitem" <li v-for="(item) in tempsearchResult" :key="item.id" class="infinitelistitem"
@click="switchTemplate(item.id, item.u_name, item.u_avatar)" @click="switchTemplate(item.id, item.u_name, item.u_avatar)"
:class="{ 'selected': selectedFriendId === item.id }"> :class="{ 'selected': selectedFriendId === item.id }">
<div class="user-profile"> <div class="user-profile">
<img :src="item.u_avatar" alt="User Avatar" /> <img :src="item.u_avatar" alt="User Avatar" />
<div :class="['status-dot', statusClass]"></div> <div :class="['status-dot', statusClass]"></div>
</div> </div>
<div style="display: inline-block;">{{ item.u_name }}#{{ item.u_id }}</div> <div style="display: inline-block;">{{ item.u_name }}#{{ item.u_id }}</div>
</li> </li>
</ul> </ul>
</el-scrollbar> </el-scrollbar>
</el-col> </el-col>
<el-col :span="18"> <el-col :span="18">
<el-row class="messagebox"> <el-row class="messagebox">
<el-scrollbar ref="scrollbarRef" style="width: 100%;height: 100%;"> <el-scrollbar ref="scrollbarRef" style="width: 100%;height: 100%;">
<!-- 聊天消息指示框 头像时间内容 --> <!-- 聊天消息指示框 头像时间内容 -->
<div v-for="(item) in messagebox" class="message-item"> <div v-for="(item) in messagebox" class="message-item">
<div <div
:class="{ 'message-item-profile': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }"> :class="{ 'message-item-profile': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
<img :src="item.from !== userinfo.user.id ? oppositeAvatar : userinfo.user.u_avatar" <img :src="item.from !== userinfo.user.id ? oppositeAvatar : userinfo.user.u_avatar"
alt="User Avatar" /> alt="User Avatar" />
</div> </div>
<div <div
:class="{ 'message-item-content': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }"> :class="{ 'message-item-content': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
{{ item.content }}</div> {{ item.content }}</div>
<div <div
:class="{ 'message-item-time': true, 'left': item.from === userinfo.user.id, 'right': item.from !== userinfo.user.id }"> :class="{ 'message-item-time': true, 'left': item.from === userinfo.user.id, 'right': item.from !== userinfo.user.id }">
{{ item.time }}</div> {{ item.time }}</div>
</div> </div>
</el-scrollbar> </el-scrollbar>
</el-row> </el-row>
<el-row> <el-row>
<el-input class="inputbox" v-model="inputValue" :placeholder="placeholder" @keyup.enter="handleEnter" clearable <el-input class="inputbox" v-model="inputValue" :placeholder="placeholder" @keyup.enter="handleEnter" clearable
:disabled="inputDisabled"></el-input> :disabled="inputDisabled"></el-input>
<el-button class="call" :disabled="inputDisabled" @click="confirmCall()">语音通话</el-button> <el-button class="call" :disabled="inputDisabled" @click="confirmCall()">语音通话</el-button>
</el-row> </el-row>
</el-col> </el-col>
</el-row> </el-row>
<!-- 确认通话弹窗 --> <!-- 确认通话弹窗 -->
<el-dialog v-model="dialogVisibleCallConfirm" title="确认通话对象" width="30%" :before-close="handleClose"> <el-dialog v-model="dialogVisibleCallConfirm" title="确认通话对象" width="30%" :before-close="handleClose">
<el-row style="height: 80px;align-items: center;"> <el-row style="height: 80px;align-items: center;">
<div class="user-profile"> <div class="user-profile">
<img :src="oppositeAvatar" alt="User Avatar" /> <img :src="oppositeAvatar" alt="User Avatar" />
</div> </div>
<div>{{ oppositeName }}</div> <div>{{ oppositeName }}</div>
</el-row> </el-row>
<el-row> <el-row>
<el-button type="primary" @click="handleConfirmCall()" style="margin: auto;">确认</el-button> <el-button type="primary" @click="handleConfirmCall()" style="margin: auto;">确认</el-button>
</el-row> </el-row>
</el-dialog> </el-dialog>
<!-- 好友管理弹窗 --> <!-- 好友管理弹窗 -->
<el-dialog v-model="dialogVisibleFriendManage" title="好友管理" style="width: 850px;height: 500px;"> <el-dialog v-model="dialogVisibleFriendManage" title="好友管理" style="width: 850px;height: 500px;">
<el-input v-model="friendSearch" placeholder="搜索好友" style="margin-bottom: 10px;width: 100%;"></el-input> <el-input v-model="friendSearch" placeholder="搜索好友" style="margin-bottom: 10px;width: 100%;"></el-input>
<el-table :data="friendSearchResult" style="width: 100%;height: 400px;"> <el-table :data="friendSearchResult" style="width: 100%;height: 400px;">
<el-table-column prop="u_avatar" label="" width="100"> <el-table-column prop="u_avatar" label="" width="100">
<template #default="scope"> <template #default="scope">
<!-- 使用 el-avatar 组件显示头像 --> <!-- 使用 el-avatar 组件显示头像 -->
<el-avatar :src="friendSearchResult[scope.$index].u_avatar" size="large" /> <el-avatar :src="friendSearchResult[scope.$index].u_avatar" size="large" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="u_name" label="姓名" width="100"></el-table-column> <el-table-column prop="u_name" label="姓名" width="100"></el-table-column>
<!-- <el-table-column prop="id" label="id" width="0"></el-table-column> --> <!-- <el-table-column prop="id" label="id" width="0"></el-table-column> -->
<el-table-column prop="u_id" label="u_id" width="100"></el-table-column> <el-table-column prop="u_id" label="u_id" width="100"></el-table-column>
<el-table-column prop="u_introduction" label="个性签名" width="200"></el-table-column> <el-table-column prop="u_introduction" label="个性签名" width="200"></el-table-column>
<el-table-column> <el-table-column>
<template #default="scope"> <template #default="scope">
<el-button @click="deleteChatHisory(scope.row.u_id)">删除聊天记录</el-button> <el-button @click="deleteChatHisory(scope.row.u_id)">删除聊天记录</el-button>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column> <el-table-column>
<template #default="scope"> <template #default="scope">
<el-button @click="deletefriend(scope.row.id)">删除好友</el-button> <el-button @click="deletefriend(scope.row.id)">删除好友</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-dialog> </el-dialog>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, nextTick, watch, computed } from 'vue' import { ref, onMounted, nextTick, watch, computed } from 'vue'
import { userInfoStore } from '@/store/user'; import { userInfoStore } from '@/store/user';
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { onlineSocketStore } from '@/store/Online'; import { sendMessage } from '@/websocket/onlineSocket';
import { messageStore } from '@/store/message.ts'; import { messageStore } from '@/store/message.ts';
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { voiceStore } from '@/store/Voice'; import { onCallStore } from '@/store/VoiceTarget.ts';
import { onCallStore } from '@/store/VoiceTarget'; import { getFriends, deleteFriend } from '@/api/friend';
import { getFriends, deleteFriend } from '@/api/friend';
const socket = onlineSocketStore() const userinfo = userInfoStore()
const userinfo = userInfoStore() const message = messageStore()
const message = messageStore() const oncall = onCallStore()
const voice = voiceStore()
const oncall = onCallStore() const messagebox = ref([])
const scrollbarRef = ref(null)
const messagebox = ref([]) const dialogVisibleFriendManage = ref(false)
const scrollbarRef = ref(null) const dialogVisibleCallConfirm = ref(false)
const dialogVisibleFriendManage = ref(false)
const dialogVisibleCallConfirm = ref(false) const tempsearch = ref('')
const inputValue = ref('')
const tempsearch = ref('') const statusClass = ref('online')
const inputValue = ref('') const inputDisabled = ref(true)
const statusClass = ref('online') const placeholder = ref('请选择聊天对象')
const inputDisabled = ref(true) const oppositeId = ref('')
const placeholder = ref('请选择聊天对象') const oppositeName = ref('')
const oppositeId = ref('') const oppositeAvatar = ref('')
const oppositeName = ref('')
const oppositeAvatar = ref('') const friends = ref([])
const friendSearch = ref('')
const friends = ref([]) const selectedFriendId = ref('')
const friendSearch = ref('')
const selectedFriendId = ref('') const tempsearchResult = computed(() => {
if (!tempsearch.value)
const tempsearchResult = computed(() => { return friends.value
if (!tempsearch.value) else
return friends.value return friends.value.filter(item => item.u_name.includes(tempsearch.value))
friends.value.filter(item => item.u_name.includes(tempsearch.value)) })
})
const friendSearchResult = computed(() => {
const friendSearchResult = computed(() => { if (!friendSearch.value)
if (!friendSearch.value) return friends.value
return friends.value else
else return friends.value.filter(item => item.u_name.includes(friendSearch.value))
return friends.value.filter(item => item.u_name.includes(friendSearch.value))
})
})
//切换聊天对象
//切换聊天对象 const switchTemplate = (id, u_name, u_avatar) => {
const switchTemplate = (id, u_name, u_avatar) => { message.from = id
message.from = id oppositeId.value = id
oppositeId.value = id oppositeName.value = u_name
oppositeName.value = u_name oppositeAvatar.value = u_avatar
oppositeAvatar.value = u_avatar message.setCorresponding()
message.setCorresponding() messagebox.value = message.corresponding
messagebox.value = message.corresponding inputDisabled.value = false
inputDisabled.value = false placeholder.value = '请输入消息内容'
placeholder.value = '请输入消息内容' scrollToBottom()
scrollToBottom() selectedFriendId.value = id
selectedFriendId.value = id console.log(message.corresponding)
console.log(message.corresponding) }
}
// 定义回车键的处理函数
// 定义回车键的处理函数 const handleEnter = () => {
const handleEnter = () => { if (inputValue.value === '') {
if (inputValue.value === '') { ElMessage.error('消息不能为空')
ElMessage.error('消息不能为空') return
return }
}
// 发送一类消息给目标用户
// 发送一类消息给目标用户 const msg = {
const msg = { cmd: "MESSAGE",
cmd: "MESSAGE", from: userinfo.user.id,
from: userinfo.user.id, to: message.from,
to: message.from, content: inputValue.value,
content: inputValue.value, time: new Date().toLocaleString()
time: new Date().toLocaleString() };
};
sendMessage(msg);
socket.send(msg) message.addMessage(msg)
message.addMessage(msg) inputValue.value = ''
inputValue.value = '' scrollToBottom()
scrollToBottom() };
};
const scrollToBottom = () => {
const scrollToBottom = () => { nextTick(() => {
nextTick(() => { const scrollbar = scrollbarRef.value?.$el; // 获取 el-scrollbar 的根元素
const scrollbar = scrollbarRef.value?.$el; // 获取 el-scrollbar 的根元素 if (scrollbar) {
if (scrollbar) { const scrollWrap = scrollbar.querySelector(".el-scrollbar__wrap"); // 找到滚动区域
const scrollWrap = scrollbar.querySelector(".el-scrollbar__wrap"); // 找到滚动区域 if (scrollWrap) {
if (scrollWrap) { scrollWrap.scrollTop = scrollWrap.scrollHeight; // 滚动到底部
scrollWrap.scrollTop = scrollWrap.scrollHeight; // 滚动到底部 } else {
} else { console.error("滚动容器未找到,请检查 el-scrollbar 的 DOM 结构");
console.error("滚动容器未找到,请检查 el-scrollbar 的 DOM 结构"); }
} }
} });
}); };
};
// 监听消息
// 监听消息 watch(() => message.messages.length, (newLength, oldLength) => {
watch(() => message.messages.length, (newLength, oldLength) => { const msg = message.messages[newLength - 1]
const msg = message.messages[newLength - 1] console.log("监听到新消息:", msg)
console.log("监听到新消息:", msg) if (newLength > oldLength && (msg.from === message.from && msg.to === message.to) || (msg.from === message.to && msg.to === message.from)) {
if (newLength > oldLength && (msg.from === message.from && msg.to === message.to) || (msg.from === message.to && msg.to === message.from)) { messagebox.value.push(msg)
messagebox.value.push(msg) scrollToBottom()
scrollToBottom() }
} })
})
const deletefriend = (id) => {
const deletefriend = (id) => { if(deleteFriend(id)){
if(deleteFriend(id)){ inputValue.value = ''
inputValue.value = '' inputDisabled.value = true
inputDisabled.value = true placeholder.value = '请选择聊天对象'
placeholder.value = '请选择聊天对象' oppositeAvatar.value = ''
oppositeAvatar.value = '' ElMessage.success('删除好友成功')
ElMessage.success('删除好友成功') friends.value = getFriends()
friends.value = getFriends() return
return }else{
}else{ ElMessage.error('删除好友失败')
ElMessage.error('删除好友失败') return
return }
} }
}
const deleteChatHisory = (u_id) => {
const deleteChatHisory = (u_id) => { try {
try { if (u_id === message.sender) messagebox.value = []
if (u_id === message.sender) messagebox.value = [] message.deleteMessagesHistory(userinfo.user.u_id, u_id)
message.deleteMessagesHistory(userinfo.user.u_id, u_id) } catch (e) {
} catch (e) { ElMessage.error('删除聊天记录失败')
ElMessage.error('删除聊天记录失败') }
} }
}
const confirmCall = () => {
const confirmCall = () => { dialogVisibleCallConfirm.value = true
dialogVisibleCallConfirm.value = true }
}
const handleConfirmCall = () => {
const handleConfirmCall = () => { dialogVisibleCallConfirm.value = false
dialogVisibleCallConfirm.value = false oncall.setTarget(oppositeId.value, oppositeName.value, oppositeAvatar.value)
oncall.setTarget(oppositeId.value, oppositeName.value, oppositeAvatar.value) oncall.panelOn()
oncall.panelOn() oncall.callingOn()
oncall.callingOn() oncall.fromOn()
oncall.fromOn() // voice.startCall(userinfo.user.u_id, userinfo.user.u_name, userinfo.user.u_avatar, oppositeId.value)
voice.startCall(userinfo.user.u_id, userinfo.user.u_name, userinfo.user.u_avatar, oppositeId.value) const msg = {
} cmd: "VOICE_CALL_REQUEST",
from: userinfo.user.id,
from_name: userinfo.user.u_name,
onMounted(async () => { from_avatar: userinfo.user.u_avatar,
friends.value = await getFriends() to: oppositeId.value,
await nextTick() time: new Date().toLocaleString()
console.log(friends.value) }
console.log(message.from) sendMessage(msg);
if (message.from !== '' || message.from !== null) { }
console.log("切换聊天对象")
const foundUser = friends.value.find((friend) => friend.id === message.from);
switchTemplate(message.from, foundUser.u_name, foundUser.u_avatar) onMounted(async () => {
} friends.value = await getFriends()
}) await nextTick()
console.log(friends.value)
</script> console.log(message.from)
if (message.from !== '' || message.from !== null) {
<style> console.log("切换聊天对象")
.container { const foundUser = friends.value.find((friend) => friend.id === message.from);
height: 70%; switchTemplate(message.from, foundUser.u_name, foundUser.u_avatar)
margin-top: 4%; }
width: 80%; })
}
</script>
.infinitelist {
list-style: none; <style>
padding: 0; .container {
margin: 0; height: 70%;
width: 100%; margin-top: 4%;
} width: 80%;
}
.infinitelistitem {
padding: 10px; .infinitelist {
width: 100%; list-style: none;
} padding: 0;
margin: 0;
.infinitelistitem.selected { width: 100%;
background-color: #f0f0f0; }
/* 灰色背景 */
} .infinitelistitem {
padding: 10px;
.messagebox { width: 100%;
height: 400px; }
border: 1px solid #cccccc;
border-radius: 20px; .infinitelistitem.selected {
} background-color: #f0f0f0;
/* 灰色背景 */
.inputbox { }
margin-top: 10px;
margin-left: 10px; .messagebox {
width: 80%; height: 400px;
} border: 1px solid #cccccc;
border-radius: 20px;
.user-profile-link { }
display: inline-block;
text-decoration: none; .inputbox {
/* 去掉链接的下划线 */ margin-top: 10px;
} margin-left: 10px;
width: 80%;
.user-profile { }
position: relative;
width: 50px; .user-profile-link {
height: 50px; display: inline-block;
border-radius: 50%; text-decoration: none;
background-color: #fff; /* 去掉链接的下划线 */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); }
} .user-profile {
position: relative;
.user-profile img { width: 50px;
width: 100%; height: 50px;
height: 100%; border-radius: 50%;
object-fit: cover; background-color: #fff;
border-radius: 50%; box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
}
}
.status-dot {
position: absolute; .user-profile img {
bottom: -5px; width: 100%;
right: -5px; height: 100%;
z-index: 1; object-fit: cover;
width: 20px; border-radius: 50%;
height: 20px; }
border-radius: 50%;
border: 2px solid white; .status-dot {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); position: absolute;
} bottom: -5px;
right: -5px;
.online { z-index: 1;
background-color: green; width: 20px;
/* 在线状态 */ height: 20px;
} border-radius: 50%;
border: 2px solid white;
.offline { box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
background-color: red; }
/* 离线状态 */
} .online {
background-color: green;
.message-item { /* 在线状态 */
padding: 10px; }
height: 80px;
width: 100%; .offline {
position: relative; background-color: red;
} /* 离线状态 */
}
.message-item-profile {
position: absolute; .message-item {
width: 40px; padding: 10px;
height: 40px; height: 80px;
border-radius: 50%; width: 100%;
background-color: #fff; position: relative;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); }
display: inline-block;
} .message-item-profile {
position: absolute;
.message-item-profile img { width: 40px;
width: 100%; height: 40px;
height: 100%; border-radius: 50%;
object-fit: cover; background-color: #fff;
border-radius: 50%; box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
} display: inline-block;
}
.message-item-profile.left {
left: 20px; .message-item-profile img {
} width: 100%;
height: 100%;
.message-item-profile.right { object-fit: cover;
right: 20px; border-radius: 50%;
} }
.message-item-content { .message-item-profile.left {
position: absolute; left: 20px;
display: inline-block; }
bottom: 28px;
} .message-item-profile.right {
right: 20px;
.message-item-content.left { }
left: 90px;
} .message-item-content {
position: absolute;
.message-item-content.right { display: inline-block;
right: 90px; bottom: 28px;
} }
.message-item-time { .message-item-content.left {
position: absolute; left: 90px;
bottom: 23px; }
height: 20px;
text-align: left; .message-item-content.right {
font-size: small; right: 90px;
} }
.message-item-time.left { .message-item-time {
left: 20px; position: absolute;
} bottom: 23px;
height: 20px;
.message-item-time.right { text-align: left;
right: 20px; font-size: small;
} }
.call { .message-item-time.left {
margin: auto; left: 20px;
margin-top: 10px; }
}
.message-item-time.right {
right: 20px;
}
.call {
margin: auto;
margin-top: 10px;
}
</style> </style>

View File

@@ -1,88 +1,85 @@
<template> <template>
<!-- 顶部导航栏 --> <!-- 顶部导航栏 -->
<Navbar /> <Navbar />
<div class="home-container"> <div class="home-container">
<div id="mainContent" class="main-content"> <div id="mainContent" class="main-content">
<!-- 左侧主空间 --> <!-- 左侧主空间 -->
<div class="left-content"> <div class="left-content">
<router-view /> <router-view />
</div> </div>
</div> </div>
<!-- 语音面板 --> <!-- 语音面板 -->
<phonePanel /> <phonePanel />
<!-- 消息按钮 --> <!-- 消息按钮 -->
<GlobalMessageButton /> <GlobalMessageButton />
<!-- 右下角用户头像框 --> <!-- 右下角用户头像框 -->
<UserProfile /> <UserProfile />
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, watchEffect } from 'vue'; import { onMounted, watchEffect } from 'vue';
import Navbar from '../../components/navBar.vue'; import Navbar from '../../components/navBar.vue';
import UserProfile from '../../components/userProfile.vue'; import UserProfile from '../../components/userProfile.vue';
import { userInfoStore } from '@/store/user'; import { userInfoStore } from '@/store/user';
import { onlineSocketStore } from '@/store/Online'; import { onlineSocketStore } from '@/store/Online.ts';
import { getUserInfo } from '@/api/user'; import { getUserInfo } from '@/api/user';
import GlobalMessageButton from '@/components/GlobalMessageButton.vue'; import GlobalMessageButton from '@/components/GlobalMessageButton.vue';
import { messageStore } from '@/store/message.ts'; import { messageStore } from '@/store/message.ts';
import phonePanel from '@/components/phonePanel.vue'; import phonePanel from '@/components/phonePanel.vue';
import { voiceStore } from '@/store/Voice'; import { initDB } from '@/functions/historyMessages';
import { initDB } from '@/functions/historyMessages';
const userinfo = userInfoStore();
const voice = voiceStore(); const socket = onlineSocketStore();
const message = messageStore();
const userinfo = userInfoStore();
const socket = onlineSocketStore(); onMounted(() => {
const message = messageStore(); getUserInfo()
onMounted(() => { // 使用 watchEffect 监听 u_id 是否为空
getUserInfo() watchEffect(() => {
if (userinfo.user.u_id) { // 如果 u_id 不为空
// 使用 watchEffect 监听 u_id 是否为空 console.log('User ID is available:', userinfo.user.u_id);
watchEffect(() => { socket.connect(userinfo.user.id); // 建立 WebSocket 连接
if (userinfo.user.u_id) { // 如果 u_id 不为空 message.to = userinfo.user.id; // 设置消息发送者为当前用户 ID
console.log('User ID is available:', userinfo.user.u_id);
socket.connect(userinfo.user.id); // 建立 WebSocket 连接 // voice.connect(userinfo.user.u_id); // 建立语音通话连接
message.to = userinfo.user.id; // 设置消息发送者为当前用户 ID }
});
// voice.connect(userinfo.user.u_id); // 建立语音通话连接
} initDB(); // 初始化历史消息数据库
}); })
initDB(); // 初始化历史消息数据库
})
</script>
<style scoped>
</script> .home-container {
position: relative;
<style scoped> top: 0px;
.home-container { display: flex;
position: relative; flex-direction: column;
top: 0px; height: 100vh;
display: flex; width: 100vw;
flex-direction: column; }
height: 100vh;
width: 100vw; .main-content {
} position: relative;
left: 0;
.main-content { display: flex;
position: relative; flex: 1;
left: 0; margin-top: 72px;
display: flex;
flex: 1; }
margin-top: 72px;
.left-content {
} flex: 1;
padding: 20px;
.left-content { }
flex: 1;
padding: 20px;
}
</style> </style>

View File

@@ -1,191 +1,218 @@
import { ref } from "vue"; import { ref } from "vue";
import { userInfoStore } from "@/store/user"; import { userInfoStore } from "@/store/user";
import { messageStore } from "@/store/message"; import { messageStore } from "@/store/message";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { messageSignStore } from "@/store/message_sign"; import { messageSignStore } from "@/store/message_sign";
import { groupMessageStore } from "@/store/group_message"; import { groupMessageStore } from "@/store/group_message";
import { onCallStore } from "@/store/VoiceTarget";
// userinfo 实例 import { handleOffer, handleAnswer, closeConnection, handleCandidate } from "@/store/Voice.ts";
const userinfo = userInfoStore();
// message 实例 // userinfo 实例
const message = messageStore(); const userinfo = userInfoStore();
// groupMessage 实例 // message 实例
const groupMessage = groupMessageStore(); const message = messageStore();
// messageSignStoregn 实例 // groupMessage 实例
// const messageSign = messageSignStore(); const groupMessage = groupMessageStore();
// oncall 实例
// WebSocket 实例 const oncall = onCallStore();
const socket = ref(null); // messageSignStoregn 实例
const messageSign = messageSignStore(); // const messageSign = messageSignStore();
const isManualClose = ref(false); // WebSocket 实例
const socket = ref(null);
const reconnectScheduled = ref(false); const messageSign = messageSignStore();
const retryCount = ref(0); const isManualClose = ref(false);
export const getRetryCount = () => { const reconnectScheduled = ref(false);
return retryCount.value;
}; const retryCount = ref(0);
export const addRetryCount = () => {
retryCount.value = retryCount.value + 1; export const getRetryCount = () => {
}; return retryCount.value;
};
export const resetRetryCount = () => {
retryCount.value = 0; export const addRetryCount = () => {
}; retryCount.value = retryCount.value + 1;
export const setReconnectScheduled = (value) => { };
reconnectScheduled.value = value;
}; export const resetRetryCount = () => {
retryCount.value = 0;
export const getReconnectScheduled = () => { };
return reconnectScheduled.value; export const setReconnectScheduled = (value) => {
}; reconnectScheduled.value = value;
};
export const setIsManualClose = (value) => {
isManualClose.value = value; export const getReconnectScheduled = () => {
}; return reconnectScheduled.value;
};
export const getIsManualClose = () => {
return isManualClose.value; export const setIsManualClose = (value) => {
}; isManualClose.value = value;
};
// 连接WebSocket
export const connectWebSocket = () => { export const getIsManualClose = () => {
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://"; return isManualClose.value;
const host = window.location.host; };
const socketUrl = `${protocol}${host}/ws/online?name=${userinfo.user.u_name}`;
// 连接WebSocket
// const socketUrl = `ws://localhost:8080/online?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`; export const connectWebSocket = () => {
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) { const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
console.log("还在重连中..."); const host = window.location.host;
return; const socketUrl = `${protocol}${host}/ws/online?name=${userinfo.user.u_name}`;
}
const retrytime = getRetryCount(); // const socketUrl = `ws://localhost:8080/online?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
if (retrytime >= 10) { if (socket.value && socket.value.readyState !== WebSocket.CLOSED) {
console.log("重连失败,请稍后再试"); console.log("还在重连中...");
return; return;
} }
console.log(retrytime); const retrytime = getRetryCount();
socket.value = new WebSocket(socketUrl, "token-"+ userinfo.token); if (retrytime >= 10) {
console.log("重连失败,请稍后再试");
socket.value.onopen = (event) => { return;
console.log("WebSocket连接已建立", event); }
setReconnectScheduled(false); console.log(retrytime);
setIsManualClose(false); socket.value = new WebSocket(socketUrl, "token-"+ userinfo.token);
resetRetryCount();
}; socket.value.onopen = (event) => {
console.log("WebSocket连接已建立", event);
//处理消息逻辑 setReconnectScheduled(false);
socket.value.onmessage = (event) => { setIsManualClose(false);
console.log("从服务器收到消息:", event.data); resetRetryCount();
try{ };
const MessageData = JSON.parse(event.data);
const cmd = MessageData.cmd; //处理消息逻辑
switch(cmd){ socket.value.onmessage = async (event) => {
case "MESSAGE": console.log("从服务器收到消息:", event.data);
message.addMessage(MessageData); try{
// TODO需要使用u_name或者u_id进行消息标记 const MessageData = JSON.parse(event.data);
// messageSign.setMessageSign(true); const cmd = MessageData.cmd;
// ElMessage.info("您有一条新的消息"); switch(cmd){
break; case "MESSAGE":
case "PERSONAL_NOTIFY": message.addMessage(MessageData);
messageSign.setMessageSign(true); // TODO需要使用u_name或者u_id进行消息标记
ElMessage.info("您有一条新的邀请消息"); // messageSign.setMessageSign(true);
break; // ElMessage.info("您有一条新的消息");
case "GROUP_MESSAGE": break;
groupMessage.recieveMessage(userinfo.user.id, MessageData); case "PERSONAL_NOTIFY":
messageSign.setMessageSign(true);
// TODO: 需要使用groupId进行消息标记 ElMessage.info("您有一条新的邀请消息");
break; break;
case "VIDEO_SYNC": case "GROUP_MESSAGE":
console.log("视频同步消息", MessageData); groupMessage.recieveMessage(userinfo.user.id, MessageData);
break;
// TODO: 需要使用groupId进行消息标记
// 语音通话相关: break;
case "VOICE_CALL_REQUEST": case "VIDEO_SYNC":
ElMessage.info("您有一个新的语音通话请求"); console.log("视频同步消息", MessageData);
break; break;
case "VOICE_CALL_RESPONSE":
console.log("收到语音通话响应消息"); // 语音通话相关:
break; case "VOICE_CALL_REQUEST":
case "VOICE_CALL_DENY": ElMessage.info("您有一个新的语音通话请求");
ElMessage.info("对方拒绝了您的语音通话请求"); oncall.setTarget(MessageData.from, MessageData.from_name, MessageData.from_avatar);
break; oncall.panelOn();
case "VOICE_CALL_END": oncall.callingOn();
ElMessage.info("语音通话已结束"); oncall.fromOff();
break; oncall.statusOff();
case "VOICE_ICE_CANDIDATE": break;
console.log("收到新的ICE候选", MessageData.candidate); case "VOICE_CALL_RESPONSE":
break; console.log("收到语音通话响应消息");
case "VOICE_SDP_OFFER": //直接跳转到icecandidate阶段不再接收额外的响应消息
console.log("收到SDP Offer", MessageData.sdp); break;
break; case "VOICE_CALL_DENY":
case "VOICE_SDP_ANSWER": ElMessage.info("对方拒绝了您的语音通话请求");
console.log("收到SDP Answer", MessageData.sdp); oncall.clear();
break; break;
case "VOICE_CALL_END":
} ElMessage.info("语音通话已结束");
}catch(error){ closeConnection();
console.error("解析 JSON 失败:", error); oncall.clear();
} break;
case "VOICE_ICE_CANDIDATE":
}; // stage3 : 收到ICE候选添加到RTC连接中
console.log("收到新的ICE候选");
socket.value.onerror = (error) => { await handleCandidate(MessageData.content);
console.error("WebSocket发生错误", error); break;
// console.log(error); case "VOICE_SDP_OFFER":
setReconnectScheduled(true); // stage1 收到offer发送answer
socket.value.close(); console.log("收到SDP Offer");
}; await handleOffer(MessageData.content, MessageData.from, MessageData.to);
break;
socket.value.onclose = (event) => { case "VOICE_SDP_ANSWER":
message.saveMessagesHistory(userinfo.user.id); // stage2 : 收到answer设置远端SDP
if (!getIsManualClose()) { console.log("收到SDP Answer");
if (getReconnectScheduled()) { await handleAnswer(MessageData.content, MessageData.from, MessageData.to);
socket.value = null; break;
addRetryCount();
setTimeout(reConnectWebSocket, 5000); }
setReconnectScheduled(false); }catch(error){
} else { console.error("解析 JSON 失败:", error);
// console.log("websocket因为浏览器省电设置断开"); }
console.log("WebSocket连接已关闭", event);
} };
}
}; socket.value.onerror = (error) => {
}; console.error("WebSocket发生错误", error);
// console.log(error);
// 断开WebSocket连接 setReconnectScheduled(true);
export const disconnectWebSocket = () => { socket.value.close();
if (socket.value && socket.value.readyState === WebSocket.OPEN) { };
socket.value.close();
} socket.value.onclose = (event) => {
}; message.saveMessagesHistory(userinfo.user.id);
if (!getIsManualClose()) {
// 重连机制 if (getReconnectScheduled()) {
export const reConnectWebSocket = () => { socket.value = null;
connectWebSocket(); addRetryCount();
}; setTimeout(reConnectWebSocket, 5000);
setReconnectScheduled(false);
// 发送消息 } else {
export const sendMessage = (message) => { // console.log("websocket因为浏览器省电设置断开");
if (socket.value && socket.value.readyState === WebSocket.OPEN) { console.log("WebSocket连接已关闭", event);
socket.value.send(message); }
} else { }
console.warn("WebSocket未连接无法发送消息"); };
} };
};
// 断开WebSocket连接
//没有错误的重连,只是浏览器在后台断开了连接 export const disconnectWebSocket = () => {
document.addEventListener("visibilitychange", () => { if (socket.value && socket.value.readyState === WebSocket.OPEN) {
if (document.hidden) { socket.value.close();
} else { }
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) { };
if (getReconnectScheduled()) {
return; // 重连机制
} export const reConnectWebSocket = () => {
reConnectWebSocket(); connectWebSocket();
} };
}
}); // 发送消息
export const sendMessage = (message) => {
try {
const jsonmessage = JSON.stringify(message);
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
socket.value.send(jsonmessage);
} else {
console.warn("WebSocket未连接无法发送消息");
}
}
catch (error) {
console.error("Failed to stringify message:", error);
}
};
//没有错误的重连,只是浏览器在后台断开了连接
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
} else {
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) {
if (getReconnectScheduled()) {
return;
}
reConnectWebSocket();
}
}
});

View File

@@ -1,287 +1,286 @@
import { ref } from "vue"; import { ref } from "vue";
import { userInfoStore } from "@/store/user"; import { userInfoStore } from "@/store/user";
import { onCallStore } from "@/store/VoiceTarget"; import { onCallStore } from "@/store/VoiceTarget";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
// userinfo 实例 // userinfo 实例
const userinfo = userInfoStore(); const userinfo = userInfoStore();
// oncall 实例 // oncall 实例
const oncall = onCallStore(); const oncall = onCallStore();
// WebSocket 实例 // WebSocket 实例
const socket = ref(null); const socket = ref(null);
const isManualClose = ref(false); const isManualClose = ref(false);
const reconnectScheduled = ref(false); const reconnectScheduled = ref(false);
const retryCount = ref(0); const retryCount = ref(0);
const getRetryCount = () => { const getRetryCount = () => {
return retryCount.value; return retryCount.value;
}; };
const addRetryCount = () => { const addRetryCount = () => {
retryCount.value = retryCount.value + 1; retryCount.value = retryCount.value + 1;
}; };
const resetRetryCount = () => { const resetRetryCount = () => {
retryCount.value = 0; retryCount.value = 0;
}; };
const setReconnectScheduled = (value) => { const setReconnectScheduled = (value) => {
reconnectScheduled.value = value; reconnectScheduled.value = value;
}; };
const getReconnectScheduled = () => { const getReconnectScheduled = () => {
return reconnectScheduled.value; return reconnectScheduled.value;
}; };
const setIsManualClose = (value) => { const setIsManualClose = (value) => {
isManualClose.value = value; isManualClose.value = value;
}; };
const getIsManualClose = () => { const getIsManualClose = () => {
return isManualClose.value; return isManualClose.value;
}; };
const iceserver = { const iceserver = {
iceServers: [ iceServers: [
{ {
// urls: "stun:stun.l.google.com:19302" urls: process.env.STUN_URL,
urls: "stun:8.134.92.199:3478", },
}, {
{ urls: process.env.TURN_URL,
urls: "turn:8.134.92.199:3478", username: process.env.TURN_USERNAME,
username: "test", credential: process.env.TURN_CREDENTIAL,
credential: "123456", },
}, ],
], };
};
// 连接WebSocket
// 连接WebSocket export const connectVoicesocket = () => {
export const connectVoicesocket = () => { const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://"; const host = window.location.host;
const host = window.location.host; const socketUrl = `${protocol}${host}/voice?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
const socketUrl = `${protocol}${host}/voice?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) {
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) { console.log("还在重连中...");
console.log("还在重连中..."); return;
return; }
} const retrytime = getRetryCount();
const retrytime = getRetryCount(); if (retrytime >= 10) {
if (retrytime >= 10) { console.log("重连失败,请稍后再试");
console.log("重连失败,请稍后再试"); return;
return; }
} console.log(retrytime);
console.log(retrytime); socket.value = new WebSocket(socketUrl);
socket.value = new WebSocket(socketUrl);
socket.value.onopen = (event) => {
socket.value.onopen = (event) => { console.log("Voice连接已建立", event);
console.log("Voice连接已建立", event); setReconnectScheduled(false);
setReconnectScheduled(false); setIsManualClose(false);
setIsManualClose(false); resetRetryCount();
resetRetryCount(); };
};
// 处理消息逻辑
// 处理消息逻辑 socket.value.onmessage = async (event) => {
socket.value.onmessage = async (event) => { try {
try { const data = JSON.parse(event.data);
const data = JSON.parse(event.data); if (data.type === "status") {
if (data.type === "status") { ElMessage.error("对方已离线");
ElMessage.error("对方已离线"); oncall.panelOff();
oncall.panelOff(); oncall.callingOff();
oncall.callingOff(); } else if (data.type === "incomingcall") {
} else if (data.type === "incomingcall") { // 通话请求处理
// 通话请求处理 oncall.setTarget(data.from, data.from_name, data.from_avatar);
oncall.setTarget(data.from, data.from_name, data.from_avatar); oncall.panelOn();
oncall.panelOn(); oncall.callingOn();
oncall.callingOn(); oncall.fromOff();
oncall.fromOff(); oncall.statusOff();
oncall.statusOff(); } else if (data.type === "pickup") {
} else if (data.type === "pickup") { ElMessage.success("对方已接听,等待连接中...");
ElMessage.success("对方已接听,等待连接中..."); try {
try { await initRTCconnection();
await initRTCconnection(); RTCpeerConnection.value.onicecandidate = (event) => {
RTCpeerConnection.value.onicecandidate = (event) => { if (event.candidate) {
if (event.candidate) { console.log("发送candidate", event.candidate);
console.log("发送candidate", event.candidate); const message = {
const message = { type: "single",
type: "single", content: "candidate",
content: "candidate", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, candidate: event.candidate,
candidate: event.candidate, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); }
} };
};
RTCpeerConnection.value.ontrack = (event) => {
RTCpeerConnection.value.ontrack = (event) => { console.log("收到对方音频流", event);
console.log("收到对方音频流", event); oncall.setRemoteStream(event.streams[0]);
oncall.setRemoteStream(event.streams[0]); };
}; } catch (e) {
} catch (e) { console.log("webRTC初始化失败", e);
console.log("webRTC初始化失败", e); }
}
const offer = await RTCpeerConnection.value.createOffer({
const offer = await RTCpeerConnection.value.createOffer({ offerToReceiveAudio: 1,
offerToReceiveAudio: 1, });
});
await RTCpeerConnection.value.setLocalDescription(offer);
await RTCpeerConnection.value.setLocalDescription(offer);
// 发送offer
// 发送offer const message = {
const message = { type: "single",
type: "single", content: "offer",
content: "offer", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, offer: offer,
offer: offer, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); } else if (data.type === "hangup") {
} else if (data.type === "hangup") { ElMessage.success("对方已挂断");
ElMessage.success("对方已挂断"); oncall.panelOff();
oncall.panelOff(); oncall.callingOff();
oncall.callingOff(); oncall.fromOff();
oncall.fromOff(); oncall.statusOff();
oncall.statusOff(); RTCpeerConnection.value.close();
RTCpeerConnection.value.close(); RTCpeerConnection.value = null;
RTCpeerConnection.value = null; oncall.resetStream();
oncall.resetStream(); } else if (data.content === "offer") {
} else if (data.content === "offer") { ElMessage.success("收到对方offer");
ElMessage.success("收到对方offer"); console.log("收到对方offer", data);
console.log("收到对方offer", data);
try {
try { await initRTCconnection();
await initRTCconnection();
RTCpeerConnection.value.onicecandidate = (event) => {
RTCpeerConnection.value.onicecandidate = (event) => { if (event.candidate) {
if (event.candidate) { console.log("发送candidate", event.candidate);
console.log("发送candidate", event.candidate); const message = {
const message = { type: "single",
type: "single", content: "candidate",
content: "candidate", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, candidate: event.candidate,
candidate: event.candidate, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); }
} };
};
RTCpeerConnection.value.ontrack = (event) => {
RTCpeerConnection.value.ontrack = (event) => { console.log("收到对方音频流", event);
console.log("收到对方音频流", event); oncall.setRemoteStream(event.streams[0]);
oncall.setRemoteStream(event.streams[0]); };
}; } catch (e) {
} catch (e) { console.log("webRTC初始化失败", e);
console.log("webRTC初始化失败", e); }
}
await RTCpeerConnection.value.setRemoteDescription(data.offer);
await RTCpeerConnection.value.setRemoteDescription(data.offer); const answer = await RTCpeerConnection.value.createAnswer();
const answer = await RTCpeerConnection.value.createAnswer(); await RTCpeerConnection.value.setLocalDescription(answer);
await RTCpeerConnection.value.setLocalDescription(answer);
// 发送answer
// 发送answer const message = {
const message = { type: "single",
type: "single", content: "answer",
content: "answer", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, answer: answer,
answer: answer, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); } else if (data.content === "answer") {
} else if (data.content === "answer") { ElMessage.success("对方已接受");
ElMessage.success("对方已接受"); console.log("对方已接受", data);
console.log("对方已接受", data); await RTCpeerConnection.value.setRemoteDescription(data.answer);
await RTCpeerConnection.value.setRemoteDescription(data.answer); } else if (data.content === "candidate") {
} else if (data.content === "candidate") { console.log("收到candidate", data);
console.log("收到candidate", data); await RTCpeerConnection.value.addIceCandidate(data.candidate);
await RTCpeerConnection.value.addIceCandidate(data.candidate); }
} } catch (e) {
} catch (e) { console.log("VoiceSocket消息格式错误", e);
console.log("VoiceSocket消息格式错误", e); }
} };
};
socket.value.onerror = (error) => {
socket.value.onerror = (error) => { console.error("Voicesocket发生错误", error);
console.error("Voicesocket发生错误", error); setReconnectScheduled(true);
setReconnectScheduled(true); socket.value.close();
socket.value.close(); };
};
socket.value.onclose = (event) => {
socket.value.onclose = (event) => { if (!getIsManualClose()) {
if (!getIsManualClose()) { if (getReconnectScheduled()) {
if (getReconnectScheduled()) { socket.value = null;
socket.value = null; addRetryCount();
addRetryCount(); setTimeout(reConnectVoicesocket, 5000);
setTimeout(reConnectVoicesocket, 5000); setReconnectScheduled(false);
setReconnectScheduled(false); } else {
} else { console.log("Voicesocket因为浏览器省电设置断开");
console.log("Voicesocket因为浏览器省电设置断开"); console.log("Voicesocket连接已关闭", event);
console.log("Voicesocket连接已关闭", event); }
} }
} };
}; };
};
// 断开Voicesocket连接
// 断开Voicesocket连接 export const disconnectVoicesocket = () => {
export const disconnectVoicesocket = () => { if (socket.value && socket.value.readyState === WebSocket.OPEN) {
if (socket.value && socket.value.readyState === WebSocket.OPEN) { socket.value.close();
socket.value.close(); }
} };
};
// 重连机制
// 重连机制 export const reConnectVoicesocket = () => {
export const reConnectVoicesocket = () => { connectVoicesocket();
connectVoicesocket(); };
};
// 发送消息
// 发送消息 export const sendMessage = (message) => {
export const sendMessage = (message) => { if (socket.value && socket.value.readyState === WebSocket.OPEN) {
if (socket.value && socket.value.readyState === WebSocket.OPEN) { socket.value.send(message);
socket.value.send(message); } else {
} else { console.warn("Voicesocket未连接无法发送消息");
console.warn("Voicesocket未连接无法发送消息"); }
} };
};
//没有错误的重连,只是浏览器在后台断开了连接
//没有错误的重连,只是浏览器在后台断开了连接 document.addEventListener("visibilitychange", () => {
document.addEventListener("visibilitychange", () => { if (document.hidden) {
if (document.hidden) { } else {
} else { if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) {
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) { if (getReconnectScheduled()) {
if (getReconnectScheduled()) { return;
return; }
} reConnectVoicesocket();
reConnectVoicesocket(); }
} }
} });
});
//WebRTC连接相关代码
//WebRTC连接相关代码 export const RTCpeerConnection = ref(null);
const RTCpeerConnection = ref(null);
const getlocalStream = async () => {
export const getlocalStream = async () => { const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); console.log("获取本地音频流成功", stream);
console.log("获取本地音频流成功", stream); return stream;
return stream; // // 获取音频和视频轨道
// // 获取音频和视频轨道 // const audioTrack = stream.getAudioTracks()[0];
// const audioTrack = stream.getAudioTracks()[0]; // // 将轨道添加到 RTCPeerConnection
// // 将轨道添加到 RTCPeerConnection // peerConnection.addTrack(audioTrack, stream);
// peerConnection.addTrack(audioTrack, stream); };
};
const initRTCconnection = async () => {
const initRTCconnection = async () => { RTCpeerConnection.value = new RTCPeerConnection(iceserver);
RTCpeerConnection.value = new RTCPeerConnection(iceserver); const stream = await getlocalStream();
const stream = await getlocalStream(); RTCpeerConnection.value.addTrack(stream.getAudioTracks()[0], stream);
RTCpeerConnection.value.addTrack(stream.getAudioTracks()[0], stream); };
};
export const hangup = () => {
export const hangup = () => { oncall.panelOff();
oncall.panelOff(); oncall.callingOff();
oncall.callingOff(); oncall.fromOff();
oncall.fromOff(); oncall.statusOff();
oncall.statusOff(); oncall.resetStream();
oncall.resetStream(); RTCpeerConnection.value.close();
RTCpeerConnection.value.close(); RTCpeerConnection.value = null;
RTCpeerConnection.value = null;
const message = {
const message = { type: "hangup",
type: "hangup", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); };
};

View File

@@ -1,61 +1,70 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import dotenv from 'dotenv';
export default defineConfig({
plugins: [vue()], dotenv.config();
resolve: {
alias: { export default defineConfig({
'@': '/src', plugins: [vue()],
} resolve: {
}, alias: {
server: { '@': '/src',
proxy: { }
'/api': { },
target: 'http://localhost:8080', // 后端服务器地址 server: {
changeOrigin: true, // 允许跨域 proxy: {
rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀 '/api': {
}, target: 'http://localhost:8080', // 后端服务器地址
'/proxy': { changeOrigin: true, // 允许跨域
target: 'http://localhost:3000', // 代理服务器的地址 rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀
changeOrigin: true, // 必须设置为 true才能避免跨域问题 },
// rewrite: (path) => path.replace(/^\/proxy/, ''), // 重写路径,去掉 /api 前缀 '/proxy': {
target: 'http://localhost:3000', // 代理服务器的地址
}, changeOrigin: true, // 必须设置为 true才能避免跨域问题
'/ws': { // rewrite: (path) => path.replace(/^\/proxy/, ''), // 重写路径,去掉 /api 前缀
target: 'ws://localhost:8080',
changeOrigin: true, },
ws: true, '/ws': {
}, target: 'ws://localhost:8080',
// '/ws': { changeOrigin: true,
// target: 'ws://localhost:8080', ws: true,
// changeOrigin: true, },
// ws: true, // '/ws': {
// } // target: 'ws://localhost:8080',
}, // changeOrigin: true,
host: '0.0.0.0', // ws: true,
port: 5173, // }
}, },
// server: { host: '0.0.0.0',
// https:{ port: 5173,
// key: fs.readFileSync('./cert/merlin.xin.key'), },
// cert: fs.readFileSync('./cert/merlin.xin.pem'), // server: {
// }, // https:{
// proxy: { // key: fs.readFileSync('./cert/merlin.xin.key'),
// '/api': { // cert: fs.readFileSync('./cert/merlin.xin.pem'),
// target: 'https://localhost:8443', // 后端服务器地址 // },
// changeOrigin: true, // 允许跨域 // proxy: {
// rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀 // '/api': {
// }, // target: 'https://localhost:8443', // 后端服务器地址
// '/online':{ // changeOrigin: true, // 允许跨域
// target:'wss://localhost:8443/online', // rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀
// changeOrigin:true, // },
// ws:true, // '/online':{
// }, // target:'wss://localhost:8443/online',
// '/voice':{ // changeOrigin:true,
// target:'wss://localhost:8443/voice', // ws:true,
// changeOrigin:true, // },
// ws:true, // '/voice':{
// } // target:'wss://localhost:8443/voice',
// }, // changeOrigin:true,
// }, // ws:true,
}); // }
// },
// },
define: {
'process.env.STUN_URL': JSON.stringify(process.env.STUN_URL),
'process.env.TURN_URL': JSON.stringify(process.env.TURN_URL),
'process.env.TURN_USERNAME': JSON.stringify(process.env.TURN_USERNAME),
'process.env.TURN_CREDENTIAL': JSON.stringify(process.env.TURN_CREDENTIAL),
},
});