Bläddra i källkod

群聊多人音视频支持

wangqi49 1 månad sedan
förälder
incheckning
3b809c5520

+ 82 - 11
webchat-front-client/src/views/ChatGroup.vue

@@ -24,6 +24,7 @@
                 <div class="message-avatar other-avatar">
                     <img :src="chatMessage.proxySender != null ? chatMessage.proxySender?.photo : selectChatUser.photo" alt="avatar" />
                 </div>
+                <div class="proxy-user-name">{{ chatMessage.proxySender.userName }}</div>
                 <div class="message-content other-message-content" :style="{ backgroundColor: '#f5f5f5' }">
                     {{ chatMessage.message }}
                 </div>
@@ -71,20 +72,23 @@
     
 
     <a-modal
-        v-model:visible="videoModalVisible"
+        v-model:visible="groupVideoModalVisible"
         :width="1045"
-        :height="530"
+        :height="800"
         :okButtonProps="{ style: { display: 'none' } }"
         :cancelButtonProps="{ style: { display: 'none' } }"
         @update:visible="handleVideoModalVisibleChange"
-        :maskClosable="false"
         >
         <template #title>
-            <div class="modal-title">
+            <div class="modal-title" style="color: white">
                 群聊多人音视频通话
             </div>
         </template>
-        <Video2Group :selectChatUserRef="selectChatUser"  :openVideo="openVideo"  v-if="videoModalVisible" />
+        <Video2Group 
+            v-if="groupVideoModalVisible" 
+            :selectChatUserRef="selectChatUser" 
+            :openVideo="openVideo"  
+            @close="handleVideoClose"/>
     </a-modal>
   </template>
   
@@ -94,6 +98,7 @@
   import Video2Group from './video2group.vue';
   export default defineComponent({
     props: {
+        openVideoRef: Boolean,
         selectChatUserRef: Object
     },
     components: {
@@ -106,11 +111,12 @@
         const isConnected = ref(false);
         const loginUser = ref(inject('loginUser'));
         const selectChatUser = ref(props.selectChatUserRef);
+        const openVideo = ref(props.openVideoRef);
         const reconnectInterval = ref(5000); // 重连间隔时间,单位:毫秒
         const heartbeatInterval = ref(30000); // 心跳间隔时间,单位:毫秒
         const chatMessageArr = ref([]);
         const groupDetailVisible = ref(false)
-        const videoModalVisible = ref(false);
+        const groupVideoModalVisible = ref(false);
 
         const loadChatMessages = (chatUserId) => {
             // 先清空
@@ -200,6 +206,27 @@
             }
         };
 
+        watch(() => props.openVideoRef, (newValue) => {
+            openVideo.value = newValue;  
+            groupVideoModalVisible.value = openVideo.value;
+        }, { deep: true, immediate: true })
+
+        watch(() => openVideo.value, (newValue) => {
+            openVideo.value = newValue;  
+            groupVideoModalVisible.value = openVideo.value;
+        }, { deep: true, immediate: true })
+
+        const handleVideoClose = () => {
+            openVideo.value = false;
+            groupVideoModalVisible.value = false;
+        }
+
+        // 监听音视频弹窗显示状态
+        const handleVideoModalVisibleChange = (newVisible) => {
+            openVideo.value = newVisible;
+            groupVideoModalVisible.value = openVideo.value;
+        };
+
         const afterVisibleChange = (status) => {
             console.log('visible', status);
         };
@@ -243,7 +270,7 @@
                 case 'audioVideo':
                     console.log('点击了音视频按钮');
                     // 在此添加音视频按钮的逻辑,例如打开音视频通话界面等
-                    videoModalVisible.value = true;
+                    groupVideoModalVisible.value = true;
                     break;
                 case 'file':
                     console.log('点击了文件发送按钮');
@@ -261,7 +288,8 @@
       });
       
       return {
-        videoModalVisible,
+        openVideo,
+        groupVideoModalVisible,
         inputValue,
         isConnected,
         selectChatUser,
@@ -271,19 +299,21 @@
         heartbeatInterval,
         chatMessageArr,
         groupDetailVisible,
+        handleVideoModalVisibleChange,
         handleClick,
         handleSendMessage,
         handleInput,
         connectWebSocket,
         afterVisibleChange,
         showDrawer,
-        loadChatMessages
+        loadChatMessages,
+        handleVideoClose
       };
     }
   });
   </script>
   
-  <style scoped>
+  <style>
     .chat-button-group {
         position: absolute;
         left: 0px;
@@ -366,7 +396,7 @@
     }
     .chat-message-card {
   display: flex;
-  margin: 10px 0;
+  margin: 20px 0;
 }
 .my-message {
   justify-content: flex-end;
@@ -413,4 +443,45 @@
     border-style: solid;
     border-color: transparent #f5f5f5 transparent transparent;
 }
+.other-message-content {
+    margin-left: 5px;
+    margin-top: 12px;
+}
+.proxy-user-name {
+    position: absolute;
+    left: 80px;
+    font-size: 10px;
+    margin-top: -5px;
+}
+/* 修改遮罩背景色 */
+::v-deep(.ant-modal-mask) {
+  background-color: rgba(0, 0, 0, 0.7); /* 半透明黑色遮罩 */
+}
+
+/* 修改遮罩背景色 */
+.ant-modal-mask {
+  background-color: rgba(0, 0, 0, 0.7); /* 半透明黑色遮罩 */
+}
+
+/* 修改模态框内容背景色 */
+.ant-modal-content {
+  background-color: black !important; /* 模态框整体背景色 */
+}
+
+.ant-modal-body {
+  background-color: black !important; /* 模态框主体内容背景色 */
+}
+
+.ant-modal-title {
+  color: white; /* 标题颜色 */
+}
+
+.ant-modal-header {
+  background-color: black !important; /* 头部背景色 */
+  border-bottom: none; /* 去掉底部边框 */
+}
+
+.ant-modal-footer {
+  background-color: black !important; /* 底部背景色 */
+}
   </style>

+ 5 - 3
webchat-front-client/src/views/ChatRobot.vue

@@ -167,7 +167,7 @@
         const startHeartbeat = () => {
             const heartbeat = () => {
                 if (isConnected.value) {
-                socket.value.send('ping'); // 发送心跳包
+                    socket.value.send('ping'); // 发送心跳包
                 }
             };
             setInterval(heartbeat, heartbeatInterval.value);
@@ -215,7 +215,6 @@
                     chatMessageArr.value.push(JSON.parse(robotMsg));
                     // ws发送消息
                     sendMessage(meMessage);
-                    scrollToBottom();
                     // 清空输入框
                     inputValue.value = ''; 
                 }
@@ -224,7 +223,8 @@
 
          // 定义滚动到底部函数
     function scrollToBottom() {
-        nextTick(() => { // 确保 DOM 更新完成后再执行滚动操作
+        nextTick(() => { 
+            // 确保 DOM 更新完成后再执行滚动操作
             if (chatCoreContainerRef.value) {
                 chatCoreContainerRef.value.scrollTop = chatCoreContainerRef.value.scrollHeight;
             }
@@ -257,6 +257,7 @@
         reconnectInterval,
         heartbeatInterval,
         chatMessageArr,
+        chatCoreContainerRef,
         handleSendMessage,
         handleInput,
         connectWebSocket,
@@ -353,6 +354,7 @@
     }
     .other-message-content {
         margin-left: 5px;
+        margin-top: 0px;
     }
     .other-message-content p {
         margin-bottom: 0px;

+ 12 - 2
webchat-front-client/src/views/ChatUser.vue

@@ -70,7 +70,11 @@
                 正在与 <b style="color: brown">{{ selectChatUser?.userName }} </b>音视频通话
             </div>
         </template>
-        <Video2User :selectChatUserRef="selectChatUser"  :openVideo="openVideo"  v-if="videoModalVisible" />
+        <Video2User 
+            v-if="videoModalVisible"
+            :selectChatUserRef="selectChatUser"  
+            :openVideo="openVideo"  
+            @close="handleVideoClose"/>
     </a-modal>
 
   </template>
@@ -134,6 +138,11 @@
             openVideo.value = newValue;
             videoModalVisible.value = openVideo.value;
         })
+
+        const handleVideoClose = () => {
+            openVideo.value = false;
+            videoModalVisible.value = false;
+        }
        
         // 监听音视频弹窗显示状态
         const handleVideoModalVisibleChange = (newVisible) => {
@@ -272,7 +281,8 @@
         handleSendMessage,
         handleInput,
         connectWebSocket,
-        loadChatMessages
+        loadChatMessages,
+        handleVideoClose
       };
     }
   });

+ 10 - 3
webchat-front-client/src/views/Chatting.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="conversation-list">
       <div v-for="chatting in chattingList" :key="chatting.user?.userId" class="account-card"
-        :style="{ backgroundColor: selectedUserId === chatting.user?.userId ? 'white' : 'transparent' }"
+        :style="{ backgroundColor: selectedUserId === chatting.user?.userId ? 'white' : 'transparent'}"
         @click="selectChatting(chatting.user)"
       >
         <div>
@@ -10,7 +10,7 @@
           </a-badge>
         </div>
         <div style="margin-left: 10px; height: 40px">
-          <div style="font-size: 14px; color: black;">
+          <div style="font-size: 14px; color: black; overflow: hidden;" class="text-ellipsis">
             <span v-if="chatting.user.roleCode > 3" 
             :class="{
                 'account-type': true,
@@ -21,7 +21,8 @@
                   ( chatting.user.roleCode === 5 ? '机器人' : '号') 
               }}
             </span>
-            {{ chatting.user.userName }}</div>
+            {{ chatting.user.userName }}
+          </div>
           <div class="last-message" style="font-size: 11px; color: gray">{{ chatting.lastOfflineMessage }}</div>
         </div>
       </div>
@@ -122,6 +123,12 @@
     border: 1px solid rgb(198, 198, 198);
     border-radius: 3px;
   }
+  .text-ellipsis {  
+    white-space: nowrap; /* 确保文本不会换行 */  
+    overflow: hidden; /* 隐藏超出容器的文本 */  
+    text-overflow: ellipsis; /* 当文本超出容器时显示省略号 */  
+    width: 200px; /* 设置一个宽度,你可以根据需要调整 */  
+  }
   /deep/ .ant-badge-dot {
     width: 9px;
     height: 9px;

+ 24 - 3
webchat-front-client/src/views/chat.vue

@@ -1,7 +1,7 @@
 <template >
 
   <h3 style="position: absolute; left: 50px; top: 30px;">
-      <i>WebChat</i>
+      <i style="font-weight: 700;">WebChat</i><a-button type="primary" style="background-color: black; margin-left: 10px; padding: 0px 10px;">教学版</a-button>
   </h3>
 
   <div class="chat-container">
@@ -177,8 +177,8 @@
         <b>{{videoOfferProxyUser?.userName}}</b>在<b style="color: orangered;">{{videoOfferUser?.userName}}群组</b>内发起了音视频邀请
       </p>
       <div style="margin-top: 15px; text-align: right;">
-        <a-button type="primary" @click="rejectVideoOffer" style="width: 60px; background-color: #D34343;">拒 接</a-button>
-        <a-button type="primary" @click="aggreeVideoOffer" style="width: 60px; background-color: #1FB759; margin-left: 20px;">接 听</a-button>
+        <a-button type="primary" @click="rejectGroupVideoOffer" style="width: 60px; background-color: #D34343;">拒 接</a-button>
+        <a-button type="primary" @click="aggreeGroupVideoOffer" style="width: 60px; background-color: #1FB759; margin-left: 20px;">接 听</a-button>
       </div>
   </div>
 </template>
@@ -307,6 +307,25 @@ export default defineComponent({
       openVideo.value = false;
     }
 
+    const aggreeGroupVideoOffer = () => {
+      // 父组件记录呼叫人
+      parentSetUser.value = videoOfferUser.value;
+      selectChatUser.value = videoOfferUser.value;
+      // 隐藏音视频呼叫提示
+      groupVideoOfferCardVisible.value = false;
+      // 初始化自动打开音视频窗口参数
+      openVideo.value = true;
+      // 切换到对话列表
+      currentCenterComponent.value = 'ChattingList';
+    }
+    
+    const rejectGroupVideoOffer = () => {
+      currentCenterComponent.value = 'ChattingList';
+      parentSetUser.value = null;
+      groupVideoOfferCardVisible.value = false;
+      openVideo.value = false;
+    }
+
     // 处理对话选择事件
     const handleSelectChatUser = (selectUser) => {
       selectChatUser.value = selectUser;
@@ -711,6 +730,8 @@ export default defineComponent({
       createGroupVisible,
       selectedFriends,
       createGroupLoading,
+      aggreeGroupVideoOffer,
+      rejectGroupVideoOffer,
       rejectVideoOffer,
       aggreeVideoOffer,
       getFriendName,

+ 11 - 5
webchat-front-client/src/views/video2User.vue

@@ -7,20 +7,20 @@
         <video autoplay class="local-video" ref="localVideoRef"></video>
 
         <!-- 挂断通话 -->
-        <svg class="leave-btn icon" t="1739368256646" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3572" width="40" height="40"><path d="M0 512a512 512 0 1 0 1024 0A512 512 0 1 0 0 512z" fill="#F75855" p-id="3573"></path><path d="M502.24 414.2c78.71 2.19 155.93 11.61 225.57 52.4 46.19 27.06 59.2 52.68 52.6 96.3-4.54 30-21.84 44.86-51.68 46.7-58.81 3.61-84.16-15.87-90.44-72.24-2.11-18.94-14-26.82-29.03-30.22-63.34-14.31-127.14-13.52-190.84-2.28-20.32 3.59-29.81 17.2-28.68 37.58 2.13 38.35-20.49 54.18-54.04 60.39-41.35 7.65-70-3.01-84.89-31.53-15.68-30.04-10.26-65.69 14.51-90.09 28.34-27.91 64.05-42.34 101.72-51.48 44.29-10.76 89.33-17.04 135.2-15.53z" fill="#FEFEFE" p-id="3574"></path></svg>
+        <svg class="leave-btn icon" @click="leave()" t="1739368256646" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3572" width="40" height="40"><path d="M0 512a512 512 0 1 0 1024 0A512 512 0 1 0 0 512z" fill="#F75855" p-id="3573"></path><path d="M502.24 414.2c78.71 2.19 155.93 11.61 225.57 52.4 46.19 27.06 59.2 52.68 52.6 96.3-4.54 30-21.84 44.86-51.68 46.7-58.81 3.61-84.16-15.87-90.44-72.24-2.11-18.94-14-26.82-29.03-30.22-63.34-14.31-127.14-13.52-190.84-2.28-20.32 3.59-29.81 17.2-28.68 37.58 2.13 38.35-20.49 54.18-54.04 60.39-41.35 7.65-70-3.01-84.89-31.53-15.68-30.04-10.26-65.69 14.51-90.09 28.34-27.91 64.05-42.34 101.72-51.48 44.29-10.76 89.33-17.04 135.2-15.53z" fill="#FEFEFE" p-id="3574"></path></svg>
     </div>
 
 </template>
 
 <script>
   import axios from 'axios';
-  import { inject, defineComponent, ref, onMounted, onUnmounted, watch, onBeforeUnmount } from 'vue';
+  import { inject, defineEmits, defineComponent, ref, onMounted, onUnmounted, watch, onBeforeUnmount } from 'vue';
   export default defineComponent({
     props: {
         openVideo: Boolean,
         selectChatUserRef: Object
     },
-    setup(props) {
+    setup(props, { emit }) {
         const socket = ref(null);
         const messageQueue = ref([]); // 存储未发送的消息队列
         const isConnected = ref(false);
@@ -99,7 +99,6 @@
                         userId: loginUser.value.userId,
                         targetUserId: selectChatUser.value.userId
                     });
-                    alert(selectChatUser.value.userName);
                 }
             } catch (error) {
                 console.error('初始化本地流失败:', error);
@@ -298,6 +297,12 @@
             // TODO
         }
 
+        const leave = () => {
+            cleanup();
+            // 触发关闭事件
+            emit('close', 'close');
+        }
+
         onBeforeUnmount(() => {
             cleanup();
         }) ;
@@ -316,7 +321,8 @@
         handleOffer,
         handleAnswer,
         handleCandidate,
-        cleanup
+        cleanup,
+        leave
       };
     }
   });

+ 339 - 96
webchat-front-client/src/views/video2group.vue

@@ -1,46 +1,54 @@
 <template>
-
     <div class="video-group-container">
-       
-        <div v-for="video in videos" :key="video.userId" class="video-card">
-            <!-- 遍历渲染多人音视频控件 -->
-             <div class="group-video-container">
-                <video autoplay class="group-video" :srcObject="video.stream"></video>
-                <span class="video-username">{{video.userName}}</span>
-             </div>
+        <video autoplay class="select-video" ref="selectStream"></video>
+        <div class="video-list">
+            <div v-for="video in videos" :key="video.userId" class="video-card">
+                <!-- 遍历渲染多人音视频控件 -->
+                <div :class="['group-video-container', playerUserId === video.userId ? 'playerVideo' : 'defaultVideo']" @click="selectVideoPlay(video)">
+                    <video autoplay class="group-video" :srcObject="video.stream"></video>
+                    <span class="video-username">{{video.userName}}</span>
+                </div>
+            </div>
         </div>
-           
-        
-        <div style="clear: both;"></div>
         <!-- 挂断通话 -->
-        <svg class="leave-btn icon" t="1739368256646" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3572" width="40" height="40"><path d="M0 512a512 512 0 1 0 1024 0A512 512 0 1 0 0 512z" fill="#F75855" p-id="3573"></path><path d="M502.24 414.2c78.71 2.19 155.93 11.61 225.57 52.4 46.19 27.06 59.2 52.68 52.6 96.3-4.54 30-21.84 44.86-51.68 46.7-58.81 3.61-84.16-15.87-90.44-72.24-2.11-18.94-14-26.82-29.03-30.22-63.34-14.31-127.14-13.52-190.84-2.28-20.32 3.59-29.81 17.2-28.68 37.58 2.13 38.35-20.49 54.18-54.04 60.39-41.35 7.65-70-3.01-84.89-31.53-15.68-30.04-10.26-65.69 14.51-90.09 28.34-27.91 64.05-42.34 101.72-51.48 44.29-10.76 89.33-17.04 135.2-15.53z" fill="#FEFEFE" p-id="3574"></path></svg>
+        <svg class="leave-btn icon" @click="leave()" style="margin-left: -120px;" t="1739368256646" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3572" width="40" height="40"><path d="M0 512a512 512 0 1 0 1024 0A512 512 0 1 0 0 512z" fill="#F75855" p-id="3573"></path><path d="M502.24 414.2c78.71 2.19 155.93 11.61 225.57 52.4 46.19 27.06 59.2 52.68 52.6 96.3-4.54 30-21.84 44.86-51.68 46.7-58.81 3.61-84.16-15.87-90.44-72.24-2.11-18.94-14-26.82-29.03-30.22-63.34-14.31-127.14-13.52-190.84-2.28-20.32 3.59-29.81 17.2-28.68 37.58 2.13 38.35-20.49 54.18-54.04 60.39-41.35 7.65-70-3.01-84.89-31.53-15.68-30.04-10.26-65.69 14.51-90.09 28.34-27.91 64.05-42.34 101.72-51.48 44.29-10.76 89.33-17.04 135.2-15.53z" fill="#FEFEFE" p-id="3574"></path></svg>
     </div>
 
 </template>
 
 <script>
   import axios from 'axios';
-  import { inject, defineComponent, ref, onMounted, onUnmounted, watch, onBeforeUnmount } from 'vue';
+  import { defineEmits, inject, defineComponent, ref, onMounted, onUnmounted, watch, onBeforeUnmount } from 'vue';
   export default defineComponent({
     props: {
         openVideo: Boolean,
         selectChatUserRef: Object
     },
-    setup(props) {
-
+    setup(props, { emit }) {
+        
         const socket = ref(null);
         const messageQueue = ref([]); // 存储未发送的消息队列
         const isConnected = ref(false);
         const reconnectInterval = ref(5000); // 重连间隔时间,单位:毫秒
         const heartbeatInterval = ref(30000); // 心跳间隔时间,单位:毫秒
-
+    
         const loginUser = ref(inject('loginUser'));
+        // 反转下角色,由接听人来发offer信息
+        const offerUser = ref(props.openVideo);
         const selectChatUser = ref(props.selectChatUserRef);
         // 本地音视频流
-        const localVideoRef = ref(null)
         const localStream = ref(null)
-
+        const playerUserId = ref(loginUser.value.userId)
+        const selectStream = ref(null)
+        // 标记当前在线所有用户
+        const onlineUserIds = ref([])
+        const onlineUsernames = ref({})
+        const exchangeUserId = ref("")
         const videos = ref([])
+        // 当前已经完成peeConnection连接管理中的用户id
+        const connectedUserIds = ref([])
+        // 维护当前客户端与其他音视频在线用户的peerConnection对象
+        const peerConnnections = ref({})
 
         // 配置ICE Server(用于网络穿透)
         const iceServerConfiguration = {
@@ -51,73 +59,49 @@
             ]
         }
 
-        // 1. 每个用户进度,先获取本地媒体流
-        // const peerConn = ref(null);
+        // 每个用户进度,先获取本地媒体流
         // 修改点 1:使用标准的 RTCPeerConnection
-        // const PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
+        const PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
+        // 获取当前客户端与指定客户端uid端到端之间的音视频连接对象
+        // 如果连接存在,不重复创建,直接服用,反之则创建新链接对象
+        const getPeerConnetion = (uid) => {
+            let peerConntion = null;
+            let peerConnKey = getPeerConnId(uid);
+            if(peerConnnections.value[peerConnKey]) {
+                peerConntion = peerConnnections.value[peerConnKey];
+                return peerConntion;
+            }
+            peerConntion = new PeerConnection(iceServerConfiguration);
+            peerConnnections.value[peerConnKey] = peerConntion;
+            return peerConntion;
+        }
+
+        // 两两端之间音视频连接唯一标识获取
+        const getPeerConnId = (uid) => {
+            let peerConnKeyArr = [loginUser.value.userId, uid];
+            return peerConnKeyArr.sort().join('-');
+        }
+
         // 修改点 2:重构初始化本地流的逻辑
         const initLocalStream = async () => {
-    
-             // 作为呼叫方呼叫
-             sendObjMessage({
-                type: "call",
-                userId: loginUser.value.userId,
-                groupId: selectChatUser.value.userId
-             });
-
             try {
                 // 获取本地媒体流
-                // localStream.value = await navigator.mediaDevices.getUserMedia({
-                //     video: true,
-                //     audio: true
-                // });
-                
-                // videos.value.push({
-                //     userId: loginUser.value.userId,
-                //     userName: loginUser.value.userName,
-                //     stream: localStream.value
-                // }) 
-                // 初始化 PeerConnection
-                // peerConn.value = new PeerConnection(iceServerConfiguration);
-                // // 添加本地轨道(替代已废弃的 addStream)
-                // localStream.value.getTracks().forEach(track => {
-                //     peerConn.value.addTrack(track, localStream.value);
-                // });
-                // // 修改点 3:使用现代 ontrack 事件监听
-                // peerConn.value.ontrack = (event) => {
-                //     if (event.streams && event.streams.length > 0) {
-                //         remoteStream.value = event.streams[0];
-                //         remoteVideoRef.value.srcObject = remoteStream.value;
-                //     }
-                // };
-                // // ICE 候选处理
-                // peerConn.value.onicecandidate = (event) => {
-                //     if (event.candidate) {
-                //         sendObjMessage({
-                //             type: "candidate",
-                //             candidate: event.candidate,
-                //             userId: loginUser.value.userId,
-                //             targetUserId: selectChatUser.value.userId
-                //         });
-                //     }
-                // };
-                // // 创建并发送 Offer
-                // if (offerUser.value) {
-                //     const offer = await peerConn.value.createOffer();
-                //     await peerConn.value.setLocalDescription(offer);
-                //     sendObjMessage({
-                //         type: "offer",
-                //         offer: offer,
-                //         userId: loginUser.value.userId,
-                //         targetUserId: selectChatUser.value.userId
-                //     });
-                //     alert(selectChatUser.value.userName);
-                // }
+                localStream.value = await navigator.mediaDevices.getUserMedia({
+                    video: true,
+                    audio: true
+                });
+                selectStream.value.srcObject = localStream.value;
+                videos.value.push({
+                    userId: loginUser.value.userId,
+                    userName: "我",
+                    stream: localStream.value
+                }) 
+                initSignalServerConnection();
             } catch (error) {
                 console.error('初始化本地流失败:', error);
             }
         };
-
+        initLocalStream();
 
         // 初始化信令服务器连接(ws)
         const initSignalServerConnection = () => {
@@ -129,25 +113,29 @@
                 messageQueue.value.forEach(message => socket.value.send(message));
                 messageQueue.value = [];
                 startHeartbeat();
-                // 调起本地摄像头,初始化音视频流
-                // 初始化用户本地音视频资源
-                initLocalStream();
             };
 
             socket.value.onmessage = (event) => {
-                console.log('收到WS消息:', event.data);
                 const messageData = JSON.parse(event.data);
+                console.log('收到WS消息 messageData.type:' + messageData.type);
                 switch(messageData.type) {
+                    case "online": 
+                        handleUserOnline(messageData);
+                        break;
+                    case "offline": 
+                        handleUserOffline(messageData);
+                        break;
                     case "offer": 
-                        handleOffer(messageData.offer);
+                        handleOffer(messageData.userId, messageData.offer);
                         break;
                     case "answer": 
-                        handleAnswer(messageData.answer);
+                        handleAnswer(messageData.userId, messageData.answer);
                         break;
                     case "candidate" :
-                        handleCandidate(messageData.candidate);
+                        handleCandidate(messageData.userId, messageData.candidate);
                         break;
                     case "leave" :
+
                     break;
                 }
             };
@@ -166,23 +154,22 @@
                 // 关闭当前连接
                 socket.value.close();
                 // 尝试重连
-                setTimeout(connectWebSocket, reconnectInterval.value);
+                setTimeout(initSignalServerConnection, reconnectInterval.value);
             };
 
             socket.value.onclose = () => {
                 console.log('WebSocket 已关闭');
                 isConnected.value = false;
                 // 尝试重连
-                setTimeout(connectWebSocket, reconnectInterval.value);
+                setTimeout(initSignalServerConnection, reconnectInterval.value);
             };
         }
-
          // 初始化ws连接
-         initSignalServerConnection();
+        //  initSignalServerConnection();
 
          const sendObjMessage = (obj) => {
             sendMessage(JSON.stringify(obj))
-        }
+         }
         const sendMessage = (message) => {
             if (isConnected.value) {
                 socket.value.send(message);
@@ -190,6 +177,206 @@
                 messageQueue.value.push(message);
             }
         };
+        // 处理用户上线
+        const handleUserOnline = (message) => {
+            exchangeUserId.value = message.userId;
+            onlineUserIds.value = message.onlineUserIds;
+            onlineUsernames.value = message.onlineUsernames;
+            // 处理call类型消息
+            if(message.onlineUserIds.length === 1) {
+                // 认为当前客户端为第一个加入音视频房间的用户,由当前客户端发起call消息(呼叫所有群成员)
+                // 作为呼叫方呼叫
+                sendObjMessage({
+                    type: "call",
+                    userId: loginUser.value.userId,
+                    groupId: selectChatUser.value.userId
+                });
+            }
+            // 基于Mesh模式,实现两两端到端音视频通信连接
+            handlePeerConnByOnlineUsersMessage();
+        }
+
+        // 用户上线,基于Mesh模式实现多人音视频链接管理
+        const handlePeerConnByOnlineUsersMessage = async () => {
+            for(var i = 0; i < onlineUserIds.value.length; i ++) {
+                // 遍历当前所有音视频在线用户
+                let uid = onlineUserIds.value[i];
+                if (connectedUserIds.value.includes(uid)) {
+                    continue;
+                }
+                // 表示当前远程用户已被当前客户端建立音视频连接接通道,避免后续重复创建
+                connectedUserIds.value.push(uid);
+                if (uid === loginUser.value.userId) {
+                    // 当前用户自己
+                    continue;
+                }
+                let uname = onlineUsernames.value[uid];
+                let peerConn = getPeerConnetion(uid);
+                // 添加本地轨道(替代已废弃的 addStream)
+                localStream.value.getTracks().forEach(track => {
+                    peerConn.addTrack(track, localStream.value);
+                });
+                // 创建offer
+                // 这里来保证每两个客户端之间只有一个端来发起offer
+                // 对两个用户uid做自然排序,永远都只允许自排序更靠前的这个客户端来发起offer
+                let p2pClients = [loginUser.value.userId, uid];
+                let sortFirstUserId = p2pClients.sort()[0];
+                if (uid == sortFirstUserId) {
+                    const offer = await peerConn.createOffer();
+                    peerConn.setLocalDescription(offer);
+                    sendObjMessage({
+                        type: "offer",
+                        offer: offer,
+                        userId: loginUser.value.userId,
+                        targetUserId: uid,
+                        groupId: selectChatUser.value.userId
+                    });
+                }
+                
+                // 监听获取远程流
+                peerConn.ontrack = (event) => {
+                    if (event.streams && event.streams.length > 0) {
+                        const newStream = event.streams[0];
+                        const existingVideoIndex = videos.value.findIndex(video => video.userId === uid);
+                        if (existingVideoIndex !== -1) {
+                            // 如果已经存在相同uid的对象,更新stream属性
+                            videos.value[existingVideoIndex].stream = newStream;
+                        } else {
+                            // 如果不存在相同uid的对象,新增一个对象
+                            videos.value.push({
+                                userId: uid,
+                                userName: uname,
+                                stream: newStream
+                            });
+                        }
+                    }
+                };
+                // ICE 候选处理
+                peerConn.onicecandidate = (event) => {
+                    console.log("ICE Candidate:", event.candidate?.candidate);
+                    if (event.candidate) {
+                        sendObjMessage({
+                            type: "candidate",
+                            candidate: event.candidate,
+                            userId: loginUser.value.userId,
+                            targetUserId: uid,
+                            groupId: selectChatUser.value.userId
+                        });
+                    }
+                };
+                peerConn.oniceconnectionstatechange = () => {
+                    console.log("ICE state:", peerConn.iceConnectionState);
+                };
+            }
+        }
+
+        // 5:完善处理 Offer 的逻辑
+        const handleOffer = async (uid, offer) => {
+            try {
+                let peerConn = getPeerConnetion(uid);
+                if (offer && offer.type && offer.sdp) {
+                    await peerConn.setRemoteDescription(new RTCSessionDescription(offer));
+                    const answer = await peerConn.createAnswer();
+                    await peerConn.setLocalDescription(answer);
+                    sendObjMessage({
+                        type: "answer",
+                        answer: answer,
+                        userId: loginUser.value.userId,
+                        targetUserId: uid,
+                        groupId: selectChatUser.value.userId
+                    });
+                } else {
+                    console.error("无效的 offer 参数");
+                }
+            } catch (error) {
+                console.error("处理 Offer 失败:", error);
+            }
+        };
+
+        // 完善处理 Answer 的逻辑
+        const handleAnswer = async (uid, answer) => {
+            try {
+                let peerConn = getPeerConnetion(uid);
+                // 检查状态是否为 have-local-offer
+                if (peerConn.signalingState !== "have-local-offer") {
+                    console.warn("状态错误,当前状态:", peerConn.signalingState);
+                    return;
+                }
+                await peerConn.setRemoteDescription(new RTCSessionDescription(answer));
+            } catch (error) {
+                console.error("处理 Answer 失败:", error);
+            }
+        };
+
+        const handleCandidate = async (uid, candidate) => {
+            try {
+                let peerConn = getPeerConnetion(uid);
+                if (peerConn.iceConnectionState === 'closed') {
+                    console.error("PeerConnection 已关闭,无法添加 ICE 候选");
+                    return;
+                }
+                if (!candidate || typeof candidate !== 'object' || !candidate.candidate || !candidate.sdpMid) {
+                    console.error("Invalid ICE candidate:", candidate);
+                    return;
+                }
+                await peerConn.addIceCandidate(candidate);
+                console.log("成功添加 ICE 候选");
+            } catch (error) {
+                console.error("处理 ICE 候选失败:", error);
+            }
+        };
+    
+        // 处理用户离线
+        const handleUserOffline = (message) => {
+            exchangeUserId.value = message.userId;
+            onlineUserIds.value = message.onlineUserIds;
+            videos.value = videos.value.filter(v => v.userId !== exchangeUserId.value);
+            const mesage = onlineUsernames.value[message.userId] + "退出";
+            const firstVideo = videos.value[0];
+            if (message.userId === playerUserId.value) {
+                playerUserId.value = firstVideo.userId;
+                selectStream.value.scrObject = firstVideo.stream;
+            }
+        }
+
+        const selectVideoPlay = (video) => {
+            playerUserId.value = video.userId;
+            selectStream.value.srcObject = video.stream;
+        }
+
+        const leave = () => {
+            cleanup();
+            // 触发关闭事件
+            emit('close', 'close');
+        }
+
+        const cleanup = () => {
+             // WebSocket 清理
+            if (socket.value) {
+                try {
+                if ([WebSocket.OPEN, WebSocket.CONNECTING].includes(socket.value.readyState)) {
+                    socket.value.close()
+                }
+                } catch (err) {
+                    console.error('关闭 WebSocket 失败:', err)
+                } finally {
+                    socket.value = null
+                }
+            }
+            // WebRTC 清理
+            if (peerConnnections.value) {
+                peerConnnections.value = null
+            }
+            // 媒体流清理 (关键补充)
+            if (localStream.value) {
+                localStream.value.getTracks().forEach(track => {
+                    track.stop() // 必须停止每个媒体轨道
+                })
+                localStream.value = null
+            }
+            // 发送终止信令 (重要)
+            // TODO
+        }
 
         onBeforeUnmount(() => {
             cleanup();
@@ -202,10 +389,21 @@
         });
       
       return {
+        playerUserId,
+        selectStream,
+        onlineUserIds,
         selectChatUser,
         videos,
+        onlineUsernames,
+        leave,
         initLocalStream,
-        initSignalServerConnection
+        getPeerConnetion,
+        getPeerConnId,
+        initSignalServerConnection,
+        handleOffer,
+        handleAnswer,
+        handleCandidate,
+        selectVideoPlay
       };
     }
   });
@@ -213,20 +411,39 @@
 </script>
 
 <style>
-    
+    .video-group-container {
+        width: 1000px;
+        height: 600px;
+        overflow: hidden;
+        background-color: black;
+    }
+    .video-list {
+        position: absolute;
+        padding: 15px;
+        border-radius: 5px;
+        background-color:rgb(18, 20, 21);
+        top: 50px;
+        right: 20px;
+        width: 230px;
+        height: 615px;
+        overflow-y: scroll;
+    }
     .group-video-container {
         position: relative;
-        float: left;
-        width: 250px;
-        height: 200px;
+        width: 200px;
+        height: 150px;
         overflow: hidden;
+        margin-bottom: 10px;
+        border: 2px solid rgb(18, 20, 21);
+    }
+    .playerVideo {
+        border: 2px solid white;
     }
     .group-video-container video {
         position: absolute;
         left: 0px;
         top: 0px;
-        width: 250px;
-        height: 200px;
+        width: 200px;
     }
     .video-username {
         position: absolute;
@@ -238,5 +455,31 @@
         padding: 3px 15px;
         border-radius: 50px;
     }
-    
+    .select-video {
+        position: absolute;
+        left: 25px;
+        top: 50px;
+        width: 750px;
+    }
+    .video-list::-webkit-scrollbar {/*滚动条整体样式*/
+        width: 0px;     /*高宽分别对应横竖滚动条的尺寸*/
+        height: 0px;
+    }
+    .video-list::-webkit-scrollbar-thumb {/*滚动条里面小方块*/
+        border-radius: 10px;
+        background-color: transparent;
+        background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%, transparent 75%, transparent);
+    }
+    .video-list::-webkit-scrollbar-track {/*滚动条里面轨道*/
+        -webkit-box-shadow: inset 0 0 1px rgba(221, 35, 35, 0.2);
+        border-radius: 10px;
+        background: #EDEDED;
+    }
+    .leave-btn {
+        position: absolute;
+        left: 48%;
+        bottom: 20px;
+        border: 2px solid rgb(239, 133, 133);
+        border-radius: 100px;
+    }
 </style>