Quellcode durchsuchen

一对一音视频通话支持

wangqi49 vor 1 Monat
Ursprung
Commit
81149fe2a8

+ 7 - 0
webchat-front-client/src/views/ChatCore.vue

@@ -3,6 +3,7 @@
       <!-- 根据选中的对话类型显示不同组件 -->
       <component
         :is="selectedComponent"
+        :openVideoRef="openVideoRef" 
         :selectChatUserRef="selectChatUserRef" 
         v-if="selectChatUser"
       ></component>
@@ -22,12 +23,18 @@
       type: Object,
       default: null
     },
+    openVideo: {
+      type: Boolean,
+      default: false
+    },
   });
   // 使用 ref 来存储 selectChatUser 的值
   const selectChatUserRef = ref({});
+  const openVideoRef = ref(false);
   // 特殊处理公众号对话与其他类型账号对话组件,公众号切换公众号推文组件、其他账号切换消息发送/展示组件
   const selectedComponent = computed(() => {
     selectChatUserRef.value = props.selectChatUser;
+    openVideoRef.value = props.openVideo;
     if (props.selectChatUser.roleCode === 0) {
       return ChatFile;
     } else if (props.selectChatUser.roleCode <= 3) {

+ 0 - 1
webchat-front-client/src/views/ChatOfficial.vue

@@ -132,7 +132,6 @@
             }
         };
 
-
         // 切换对话用户,父组件传递选中用户信息,监听选中用户变化
         watch(() => props.selectChatUserRef, (newValue) => {
           selectChatUser.value = newValue;

+ 60 - 17
webchat-front-client/src/views/ChatUser.vue

@@ -40,7 +40,7 @@
                 <FolderOpenOutlined style="font-size: 20px; color: black;" />
             </a-button>
             <a-button type="text" @click="handleClick('audioVideo')">
-                <VideoCameraOutlined style="font-size: 20px; color: black;" />
+                <VideoCameraOutlined style="font-size: 20px; color: black;"/>
             </a-button>
         </div>
     </div>
@@ -54,26 +54,52 @@
         @keydown="handleSendMessage"
         class="chat-input"
         ></textarea>
-    </div>  
+    </div> 
+
+    <a-modal
+        v-model:visible="videoModalVisible"
+        :width="1045"
+        :height="530"
+        :okButtonProps="{ style: { display: 'none' } }"
+        :cancelButtonProps="{ style: { display: 'none' } }"
+        @update:visible="handleVideoModalVisibleChange"
+        :maskClosable="false"
+        >
+        <template #title>
+            <div class="modal-title">
+                正在与 <b style="color: brown">{{ selectChatUser?.userName }} </b>音视频通话
+            </div>
+        </template>
+        <Video2User :selectChatUserRef="selectChatUser"  :openVideo="openVideo"  v-if="videoModalVisible" />
+    </a-modal>
+
   </template>
   
   <script>
   import axios from 'axios';
   import { inject, defineComponent, ref, onMounted, onUnmounted, watch } from 'vue';
+  import Video2User from './video2User.vue';
   export default defineComponent({
     props: {
-        selectChatUserRef: Object
+        selectChatUserRef: Object,
+        openVideoRef: Boolean
+    },
+    components: {
+        Video2User
     },
     setup(props) {
+
         const inputValue = ref('');
         const socket = ref(null);
         const messageQueue = ref([]); // 存储未发送的消息队列
         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 videoModalVisible = ref(false)
 
         const loadChatMessages = (chatUserId) => {
             // 先清空
@@ -92,6 +118,7 @@
                 console.error(error);
             });
         }
+       
         loadChatMessages( selectChatUser.value.userId);
         // 切换对话用户,父组件传递选中用户信息,监听选中用户变化
         watch(() => props.selectChatUserRef, (newValue) => {
@@ -99,6 +126,20 @@
           loadChatMessages(selectChatUser.value.userId);
         });
        
+        watch(() => props.openVideoRef, (newValue) => {
+            openVideo.value = newValue;    
+        }, { deep: true, immediate: true })
+
+        watch(() => openVideo.value, (newValue) => {
+            openVideo.value = newValue;
+            videoModalVisible.value = openVideo.value;
+        })
+       
+        // 监听音视频弹窗显示状态
+        const handleVideoModalVisibleChange = (newVisible) => {
+            openVideo.value = newVisible;
+        };
+
         const connectWebSocket = () => {
             socket.value = new WebSocket(`/connect-service/ws/chat/PC_WEB_CHAT/` + loginUser.value.userId);
             socket.value.onopen = () => {
@@ -189,23 +230,23 @@
         const handleClick = (type) => {
             switch (type) {
                 case 'emoji':
-                console.log('点击了表情按钮');
-                // 在此添加表情按钮的逻辑,例如显示表情选择器等
-                break;
+                    console.log('点击了表情按钮');
+                    // 在此添加表情按钮的逻辑,例如显示表情选择器等
+                    break;
                 case 'redPacket':
-                console.log('点击了红包按钮');
-                // 在此添加红包按钮的逻辑,例如打开红包发送界面等
-                break;
+                    console.log('点击了红包按钮');
+                    // 在此添加红包按钮的逻辑,例如打开红包发送界面等
+                    break;
                 case 'audioVideo':
-                console.log('点击了音视频按钮');
-                // 在此添加音视频按钮的逻辑,例如打开音视频通话界面等
-                break;
+                    // 发起音视频通话
+                    videoModalVisible.value = true;
+                    break;
                 case 'file':
-                console.log('点击了文件发送按钮');
-                // 在此添加文件发送按钮的逻辑,例如打开文件选择器等
-                break;
+                    console.log('点击了文件发送按钮');
+                    // 在此添加文件发送按钮的逻辑,例如打开文件选择器等
+                    break;
                 default:
-                break;
+                    break;
             }
         };
       onMounted(() => {
@@ -216,6 +257,8 @@
       });
       
       return {
+        openVideo,
+        videoModalVisible,
         inputValue,
         isConnected,
         selectChatUser,
@@ -224,6 +267,7 @@
         reconnectInterval,
         heartbeatInterval,
         chatMessageArr,
+        handleVideoModalVisibleChange,
         handleClick,
         handleSendMessage,
         handleInput,
@@ -364,5 +408,4 @@
     border-style: solid;
     border-color: transparent #f5f5f5 transparent transparent;
 }
-
   </style>

+ 18 - 11
webchat-front-client/src/views/Chatting.vue

@@ -2,7 +2,8 @@
   <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' }"
-        @click="selectChatting(chatting.user)">
+        @click="selectChatting(chatting.user)"
+      >
         <div>
           <a-badge :dot="chatting.unread">
             <a-avatar :src="chatting.user?.photo" :size="40" style="border-radius: 5px"/>
@@ -32,13 +33,24 @@
   import { defineEmits, defineComponent, ref, onMounted, onUnmounted, watch } from 'vue';
   export default defineComponent ({
     props: {
+      parentSetUser: Object,
       chattingList: Array,
       autoSelectedChatting: Boolean
     },
     setup(props, { emit }) {
       // 定义触发的事件
       const selectedUserId = ref('');
-      // 切换对话用户,父组件传递选中用户信息,监听选中用户变化
+      const selectChatting = (selectUser) => {
+        selectedUserId.value = selectUser.userId;
+        emit('select-chat-user', selectUser);
+        // 查找对应的 chatting 对象
+        const targetChatting = props.chattingList.find(chatting => chatting.user?.userId === selectUser.userId);
+        if (targetChatting) {
+          // 将 unread 属性设置为 false
+          targetChatting.unread = false;
+        }
+      }
+
       watch(() => props.chattingList, (newChattingList) => {
         if (props.autoSelectedChatting) {
           if(newChattingList.length > 1) {
@@ -49,16 +61,11 @@
         }
       });
 
-      const selectChatting = (selectUser) => {
-        selectedUserId.value = selectUser.userId;
-        emit('select-chat-user', selectUser);
-        // 查找对应的 chatting 对象
-        const targetChatting = props.chattingList.find(chatting => chatting.user?.userId === selectUser.userId);
-        if (targetChatting) {
-          // 将 unread 属性设置为 false
-          targetChatting.unread = false;
+      watch(() => props.parentSetUser, (newValue) => {
+        if (newValue?.userId) {
+          selectChatting(newValue);
         }
-      }
+      }, { deep: true, immediate: true });
 
       onMounted(() => {
         

+ 9 - 4
webchat-front-client/src/views/FriendList.vue

@@ -11,7 +11,7 @@
                 <template #title>群聊 
                     <span>({{friendsData.groups?.count}})</span>
                 </template>
-                <div v-for="group in friendsData.groups?.accounts" :key="group.userId" class="account-card">
+                <div v-for="group in friendsData.groups?.accounts" :key="group.userId" class="account-card" @dblclick="onFriendDblClick(group)">
                     <div class="menu-item-container">
                         <a-avatar
                         :src="group.photo"
@@ -28,7 +28,7 @@
                 <template #title>公众号
                     <span>({{friendsData.officials?.count}})</span>
                 </template>
-                <div v-for="official in friendsData.officials?.accounts" :key="official.userId" class="account-card">
+                <div v-for="official in friendsData.officials?.accounts" :key="official.userId" class="account-card" @dblclick="onFriendDblClick(official)">
                     <div class="menu-item-container">
                         <a-avatar
                         :src="official.photo"
@@ -45,7 +45,7 @@
                 <template #title>机器人
                     <span>({{friendsData.robots?.count}})</span>
                 </template>
-                <div v-for="robot in friendsData.robots?.accounts" :key="robot.userId" class="account-card">
+                <div v-for="robot in friendsData.robots?.accounts" :key="robot.userId" class="account-card" @dblclick="onFriendDblClick(robot)">
                     <div class="menu-item-container">
                         <a-avatar
                         :src="robot.photo"
@@ -62,7 +62,7 @@
                 <template #title>联系人
                     <span>({{friendsData.users?.count}})</span>
                 </template>
-                <div v-for="user in friendsData.users?.accounts" :key="user.userId" class="account-card">
+                <div v-for="user in friendsData.users?.accounts" :key="user.userId" class="account-card" @dblclick="onFriendDblClick(user)">
                     <div class="menu-item-container">
                         <a-avatar
                         :src="user.photo"
@@ -88,8 +88,13 @@
       }
     },
     data() {
+        const onFriendDblClick = (user) => {
+          // 通知父组件切换到对话列表,并传递选中的用户信息
+          this.$emit('dblclick-user', user);
+        }
         return {
             openKeys: ['sub4'],
+            onFriendDblClick,
         };
     },
     setup(props) {

+ 26 - 5
webchat-front-client/src/views/WaitConfirmList.vue

@@ -28,24 +28,45 @@ export default {
         try {
             const response = await axios.post(`/client-service/chat/account-relation/confirmSubscribe/` + relationId, {
                 headers: {
-                // 'oauth-code': oauthCode.value,
                 'origin-url': window.location.href
               }
             });
             if (response.data.code === 200) {
                 // 审核通过,返回对话列表
+                message.info("审核通过");
+                // 通知父组件删除当前待审核记录
+                emit('update:waitConfirmList', props.waitConfirmList.filter(item => item.relationId !== relationId));
+            } else {
+                message.error(response.data.message || '审核失败');
+            }
+        } catch (error) {
+            message.error('审核失败');
+        }
+    } 
 
+    const doReject = async (relationId) => {
+        try {
+            const response = await axios.post(`/client-service/chat/account-relation/rejectSubscribe/` + relationId, {
+                headers: {
+                'origin-url': window.location.href
+              }
+            });
+            if (response.data.code === 200) {
+                // 审核通过,返回对话列表
+                message.info("已拒绝");
+                // 通知父组件删除当前待审核记录
+                emit('update:waitConfirmList', props.waitConfirmList.filter(item => item.relationId !== relationId));
             } else {
-                message.error(response.data.message || '拒绝失败!');
+                message.error(response.data.message || '审核失败');
             }
         } catch (error) {
-            message.error('拒绝失败!');
+            message.error('审核失败');
         }
     } 
 
     return {
-        // oauthCode,
-        doConfirm
+        doConfirm,
+        doReject
     }
   },
  

+ 83 - 11
webchat-front-client/src/views/chat.vue

@@ -58,25 +58,29 @@
           </a-button>
           <template #overlay>
             <a-menu>
-              <a-menu-item key="1" @click="createGroup">创建群聊</a-menu-item>
+              <a-menu-item key="1" @click="createGroup">创建群聊{{ parentSetUser?.userName }}</a-menu-item>
               <a-menu-item key="2" @click="subscribe">加人/订阅</a-menu-item>
             </a-menu>
           </template>
         </a-dropdown>
       </div>
       <div class="chat-center-body-container">
-        <component :is="currentCenterComponent" 
-          :friendsData="friendsData" 
-          :waitConfirmList="waitConfirmList" 
-          :chattingList="chattingList" 
-          :messageList="messageList"
-          :autoSelectedChatting="autoSelectedChatting"
-          @select-chat-user="handleSelectChatUser" >
+        <component 
+            :is="currentCenterComponent" 
+            :parentSetUser="parentSetUser"
+            :friendsData="friendsData" 
+            :waitConfirmList="waitConfirmList" 
+            :chattingList="chattingList" 
+            :messageList="messageList"
+            :autoSelectedChatting="autoSelectedChatting"
+            :selectChatUser="selectChatUser"
+            @select-chat-user="handleSelectChatUser" 
+            @dblclick-user="onUserDblClick">
         </component>
       </div>
     </div>
     <div class="chat-right-container">
-        <component :is="currentChatComponent" :selectChatUser="selectChatUser" > </component>
+        <component :is="currentChatComponent" :selectChatUser="selectChatUser" :openVideo="openVideo" > </component>
     </div>
   </div>
 
@@ -153,6 +157,18 @@
       </div>
     </div>
   </a-modal>
+
+
+  <div class="video-offer-card" v-if="videoOfferCardVisible">
+    <svg style="position: absolute;left: 20px; top: 15px;" t="1739202022913" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="12218" width="32" height="32"><path d="M0 0m228.968944 0l566.062112 0q228.968944 0 228.968944 228.968944l0 566.062112q0 228.968944-228.968944 228.968944l-566.062112 0q-228.968944 0-228.968944-228.968944l0-566.062112q0-228.968944 228.968944-228.968944Z" fill="#FF9B5E" p-id="12219"></path><path d="M582.598758 528.99204c-13.568954-7.834554-13.568954-27.419031 0-35.252313l227.060869-131.093625c13.568954-7.834554 30.529193 1.957684 30.529193 17.62552v262.18725c0 15.667836-16.960239 25.460075-30.529193 17.626793L582.598758 528.99204z" fill="#FFFFFF" opacity=".5" p-id="12220"></path><path d="M249.321739 306.563975m38.161491 0l334.549068 0q38.161491 0 38.161491 38.161491l0 334.549068q0 38.161491-38.161491 38.161491l-334.549068 0q-38.161491 0-38.161491-38.161491l0-334.549068q0-38.161491 38.161491-38.161491Z" fill="#FFFFFF" p-id="12221"></path></svg>  
+      <p style="padding-left: 20px;">
+        <b style="color: blue;">{{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>
+      </div>
+  </div>
 </template>
 
 <script>
@@ -213,6 +229,11 @@ export default defineComponent({
     const autoSelectedChatting = ref(true)
     const createGroupVisible = ref(false)
     const createGroupLoading = ref(false)
+    const videoOfferUser = ref(null);
+    const videoOfferCardVisible = ref(false);
+    const parentSetUser = ref(null);
+    const openVideo = ref(false);
+
     // 用对象记录每个图标的颜色状态
     const iconColors = ref({
       message: '#28c940',
@@ -251,10 +272,36 @@ export default defineComponent({
       buildFriendsMap();
     });
 
+    const aggreeVideoOffer = () => {
+      // 父组件记录呼叫人
+      parentSetUser.value = videoOfferUser.value;
+      // 隐藏音视频呼叫提示
+      videoOfferCardVisible.value = false;
+      // 初始化自动打开音视频窗口参数
+      openVideo.value = true;
+      // 切换到对话列表
+      currentCenterComponent.value = 'ChattingList';
+    }
+
+    const rejectVideoOffer = () => {
+      currentCenterComponent.value = 'ChattingList';
+      parentSetUser.value = null;
+      videoOfferCardVisible.value = false;
+      openVideo.value = false;
+    }
+
     // 处理对话选择事件
     const handleSelectChatUser = (selectUser) => {
       selectChatUser.value = selectUser;
     };
+    
+    // 双击好友列表,选中用户对话
+    const onUserDblClick = (selectUser) => {
+      selectChatUser.value = selectUser;
+      parentSetUser.value = selectUser;
+      handleIconClick("message");
+    }
+
     // 处理图标点击事件
     const handleIconClick = (iconName) => {
       activeIcon.value = iconName;
@@ -512,15 +559,21 @@ export default defineComponent({
       socket.value.onmessage = (event) => {
         console.log('App组件收到消息:', event.data);
         // 在此处理收到的消息
-        const messageType = JSON.parse(event.data).type;
+        const eventMessage = JSON.parse(event.data);
+        const messageType = eventMessage.type;
         if (messageType === 5) {
             // 申请添加好友,红点提醒
             redPointStatus.value = "block";
+            loadWaitConfirmList();
         }
         else if (messageType === 7) {
             autoSelectedChatting.value = false;
             loadChattingList();
         }
+        else if (messageType === 8) {
+          videoOfferUser.value = eventMessage.sender;
+          videoOfferCardVisible.value = true;
+        }
       };
 
       socket.value.onerror = (error) => {
@@ -602,6 +655,10 @@ export default defineComponent({
     });
     
     return {
+      openVideo,
+      parentSetUser,
+      videoOfferCardVisible,
+      videoOfferUser,
       userId,
       photo,
       loginUser,
@@ -628,6 +685,8 @@ export default defineComponent({
       createGroupVisible,
       selectedFriends,
       createGroupLoading,
+      rejectVideoOffer,
+      aggreeVideoOffer,
       getFriendName,
       getFriendPhoto,
       handleCreateGroup,
@@ -648,7 +707,8 @@ export default defineComponent({
       loadFriendList,
       loadCurrentUserInfo,
       loadChattingList,
-      handleSelectChatUser
+      handleSelectChatUser,
+      onUserDblClick
     };
   }
 });
@@ -841,4 +901,16 @@ export default defineComponent({
       /*border-radius: 10px;*/
       background: #EDEDED;
   }
+  .video-offer-card {
+    position: fixed;
+    padding: 20px 40px;
+    border-radius: 8px;
+    overflow: hidden;
+    z-index: 2999;
+    right: 50px;
+    top: 50px;
+    background-color: white;
+    border: 1px solid rgb(241, 240, 240);
+    box-shadow: 0px 0px 30px rgb(215, 215, 215);
+  }
 </style>

+ 362 - 0
webchat-front-client/src/views/video2User.vue

@@ -0,0 +1,362 @@
+<template>
+
+    <div class="video-user-container">
+        <!-- 远程视频 -->
+        <video autoplay class="remote-video" ref="remoteVideoRef"></video>
+        <!-- 本地视频 -->
+        <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>
+    </div>
+
+</template>
+
+<script>
+  import axios from 'axios';
+  import { inject, defineComponent, ref, onMounted, onUnmounted, watch, onBeforeUnmount } from 'vue';
+  export default defineComponent({
+    props: {
+        openVideo: Boolean,
+        selectChatUserRef: Object
+    },
+    setup(props) {
+        const socket = ref(null);
+        const messageQueue = ref([]); // 存储未发送的消息队列
+        const isConnected = ref(false);
+        const loginUser = ref(inject('loginUser'));
+        // 反转下角色,由接听人来发offer信息
+        const offerUser = ref(props.openVideo);
+        const selectChatUser = ref(props.selectChatUserRef);
+        const reconnectInterval = ref(5000); // 重连间隔时间,单位:毫秒
+        const heartbeatInterval = ref(30000); // 心跳间隔时间,单位:毫秒
+        // 本地音视频流
+        const remoteVideoRef = ref(null)
+        const localVideoRef = ref(null)
+        const localStream = ref(null)
+        // 远程音视频流
+        const remoteStream = ref(null)
+        // 配置ICE Server(用于网络穿透)
+        const iceServerConfiguration = {
+            "iceServers": [
+                {
+                    "urls": "stun:stun.l.google.com:19302"
+                }
+            ]
+        }
+
+        const peerConn = ref(null);
+        // 修改点 1:使用标准的 RTCPeerConnection
+        const PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
+        // 修改点 2:重构初始化本地流的逻辑
+        const initLocalStream = async () => {
+            if (peerConn.value == null && !offerUser.value) {        
+                // 作为呼叫方呼叫
+                sendObjMessage({
+                    type: "call",
+                    userId: loginUser.value.userId,
+                    targetUserId: selectChatUser.value.userId
+                });
+            }
+            try {
+                // 获取本地媒体流
+                localStream.value = await navigator.mediaDevices.getUserMedia({
+                    video: true,
+                    audio: true
+                });
+                localVideoRef.value.srcObject = 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
+                    });
+                }
+            } catch (error) {
+                console.error('初始化本地流失败:', error);
+            }
+        };
+
+        // 5:完善处理 Offer 的逻辑
+        const handleOffer = async (offer) => {
+            try {
+                if (!peerConn.value) {
+                    // 初始化接收方的 PeerConnection
+                    peerConn.value = new PeerConnection(iceServerConfiguration);
+                    // 获取本地媒体流(接收方也需要本地流)
+                    const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
+                    localVideoRef.value.srcObject = stream;
+                    stream.getTracks().forEach(track => {
+                        peerConn.value.addTrack(track, stream);
+                    });
+                    // 设置远程流监听
+                    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
+                            });
+                        }
+                    };
+                }
+                await peerConn.value.setRemoteDescription(new RTCSessionDescription(offer));
+                const answer = await peerConn.value.createAnswer();
+                await peerConn.value.setLocalDescription(answer);
+                sendObjMessage({
+                    type: "answer",
+                    answer: answer,
+                    userId: loginUser.value.userId,
+                    targetUserId: selectChatUser.value.userId
+                });
+            } catch (error) {
+                console.error("处理 Offer 失败:", error);
+            }
+        };
+
+        // 完善处理 Answer 的逻辑
+        const handleAnswer = async (answer) => {
+            try {
+                if (!peerConn.value) {
+                    console.error("PeerConnection 未初始化");
+                    return;
+                }
+                await peerConn.value.setRemoteDescription(new RTCSessionDescription(answer));
+            } catch (error) {
+                console.error("处理 Answer 失败:", error);
+            }
+        };
+
+        const handleCandidate = async (candidate) => {
+            try {
+                if (!peerConn.value) {
+                    console.error("PeerConnection 未初始化");
+                    return;
+                }
+                if (peerConn.value.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.value.addIceCandidate(candidate);
+                console.log("成功添加 ICE 候选");
+            } catch (error) {
+                console.error("处理 ICE 候选失败:", error);
+            }
+        };
+    
+        const connectWebSocket = () => {
+            
+            socket.value = new WebSocket(`/connect-service/ws/p2p/video/PC_WEB_CHAT/` + loginUser.value.userId);
+            socket.value.onopen = () => {
+                console.log('WebSocket 已连接');
+                isConnected.value = true;
+                // 发送队列中的消息
+                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);
+                switch(messageData.type) {
+                    case "offer": 
+                        handleOffer(messageData.offer);
+                        break;
+                    case "answer": 
+                        handleAnswer(messageData.answer);
+                        break;
+                    case "candidate" :
+                        handleCandidate(messageData.candidate);
+                        break;
+                    case "leave" :
+                    break;
+                }
+            };
+
+            socket.value.onerror = (error) => {
+                console.error('WebSocket 错误:', error);
+                isConnected.value = false;
+                // 关闭当前连接
+                socket.value.close();
+                // 尝试重连
+                setTimeout(connectWebSocket, reconnectInterval.value);
+            };
+
+            socket.value.onclose = () => {
+                console.log('WebSocket 已关闭');
+                isConnected.value = false;
+                // 尝试重连
+                setTimeout(connectWebSocket, reconnectInterval.value);
+            };
+        };
+
+        const startHeartbeat = () => {
+            const heartbeat = () => {
+                if (isConnected.value) {
+                socket.value.send('ping'); // 发送心跳包
+                }
+            };
+            setInterval(heartbeat, heartbeatInterval.value);
+        };
+        
+        // 链接ws
+        connectWebSocket();
+
+
+        const sendObjMessage = (obj) => {
+            sendMessage(JSON.stringify(obj))
+        }
+        const sendMessage = (message) => {
+            if (isConnected.value) {
+                socket.value.send(message);
+            } else {
+                messageQueue.value.push(message);
+            }
+        };
+        
+        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 (peerConn.value) {
+                try {
+                    peerConn.value.close()
+                } catch (err) {
+                    console.error('关闭 PeerConnection 失败:', err)
+                } finally {
+                    peerConn.value = null
+                }
+            }
+            // 媒体流清理 (关键补充)
+            if (localStream.value) {
+                localStream.value.getTracks().forEach(track => {
+                    track.stop() // 必须停止每个媒体轨道
+                })
+                localStream.value = null
+            }
+            if (remoteStream.value) {
+                remoteStream.value.getTracks().forEach(track => {
+                    track.stop() // 必须停止每个媒体轨道
+                })
+                remoteStream.value = null
+            }
+            // 发送终止信令 (重要)
+            // TODO
+        }
+
+        onBeforeUnmount(() => {
+            cleanup();
+        }) ;
+        onMounted(() => {
+        
+        });
+        onUnmounted(() => {
+       
+        });
+      
+      return {
+        selectChatUser,
+        localVideoRef,
+        remoteVideoRef,
+        initLocalStream,
+        handleOffer,
+        handleAnswer,
+        handleCandidate,
+        cleanup
+      };
+    }
+  });
+
+</script>
+
+<style>
+    .video-user-container {
+        position: absolute;
+        top: 50px;
+        left: 0px;
+        width: 100%;
+        height: 530px;
+        background-color: black;
+    }
+    .remote-video {
+        position: absolute;
+        right: 20px;
+        top: 20px;
+        width: 500px;
+        height: 380px;
+    }
+    .local-video {
+        position: absolute;
+        left: 20px;
+        top: 20px;
+        width: 500px;
+        height: 380px;
+    }
+    .video-title {
+        position: absolute;
+        left: 10px;
+        top: 15px;
+        font-size: 15px;
+        color: white;
+    }
+    .leave-btn {
+        position: absolute;
+        left: 48%;
+        bottom: 40px;
+        border: 2px solid rgb(239, 133, 133);
+        border-radius: 100px;
+    }
+</style>