Bläddra i källkod

群聊多人音视频通知开发

wangqi49 1 månad sedan
förälder
incheckning
7078668fb8

+ 37 - 12
webchat-front-client/src/views/ChatGroup.vue

@@ -68,15 +68,37 @@
         <p>群成员列表等群聊详情内容...</p>
         <p>群公告等内容...</p>
     </a-drawer>
+    
+
+    <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">
+                群聊多人音视频通话
+            </div>
+        </template>
+        <Video2Group :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 Video2Group from './video2group.vue';
   export default defineComponent({
     props: {
         selectChatUserRef: Object
     },
+    components: {
+        Video2Group
+    },
     setup(props) {
         const inputValue = ref('');
         const socket = ref(null);
@@ -88,6 +110,7 @@
         const heartbeatInterval = ref(30000); // 心跳间隔时间,单位:毫秒
         const chatMessageArr = ref([]);
         const groupDetailVisible = ref(false)
+        const videoModalVisible = ref(false);
 
         const loadChatMessages = (chatUserId) => {
             // 先清空
@@ -210,21 +233,22 @@
         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;
+                    console.log('点击了音视频按钮');
+                    // 在此添加音视频按钮的逻辑,例如打开音视频通话界面等
+                    videoModalVisible.value = true;
+                    break;
                 case 'file':
-                console.log('点击了文件发送按钮');
-                // 在此添加文件发送按钮的逻辑,例如打开文件选择器等
-                break;
+                    console.log('点击了文件发送按钮');
+                    // 在此添加文件发送按钮的逻辑,例如打开文件选择器等
+                    break;
                 default:
                 break;
             }
@@ -237,6 +261,7 @@
       });
       
       return {
+        videoModalVisible,
         inputValue,
         isConnected,
         selectChatUser,

+ 1 - 1
webchat-front-client/src/views/WaitConfirmList.vue

@@ -40,7 +40,7 @@ export default {
                 message.error(response.data.message || '审核失败');
             }
         } catch (error) {
-            message.error('审核失败');
+            message.error('审核失败', error);
         }
     } 
 

+ 28 - 2
webchat-front-client/src/views/chat.vue

@@ -74,6 +74,7 @@
             :messageList="messageList"
             :autoSelectedChatting="autoSelectedChatting"
             :selectChatUser="selectChatUser"
+            @update:waitConfirmList="newList => waitConfirmList = newList"
             @select-chat-user="handleSelectChatUser" 
             @dblclick-user="onUserDblClick">
         </component>
@@ -169,6 +170,17 @@
         <a-button type="primary" @click="aggreeVideoOffer" style="width: 60px; background-color: #1FB759; margin-left: 20px;">接 听</a-button>
       </div>
   </div>
+
+  <div class="video-offer-card" v-if="groupVideoOfferCardVisible">
+    <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>{{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>
+      </div>
+  </div>
 </template>
 
 <script>
@@ -230,7 +242,11 @@ export default defineComponent({
     const createGroupVisible = ref(false)
     const createGroupLoading = ref(false)
     const videoOfferUser = ref(null);
+    const videoOfferProxyUser = ref(null);
+    // 一对一音视频呼叫提醒显示状态
     const videoOfferCardVisible = ref(false);
+     // 多人群聊音视频呼叫提醒显示状态
+    const groupVideoOfferCardVisible = ref(false);
     const parentSetUser = ref(null);
     const openVideo = ref(false);
 
@@ -275,6 +291,7 @@ export default defineComponent({
     const aggreeVideoOffer = () => {
       // 父组件记录呼叫人
       parentSetUser.value = videoOfferUser.value;
+      selectChatUser.value = videoOfferUser.value;
       // 隐藏音视频呼叫提示
       videoOfferCardVisible.value = false;
       // 初始化自动打开音视频窗口参数
@@ -385,7 +402,7 @@ export default defineComponent({
             } else if (response.data.code === 200) {
                message.info(searchAccountInfo.value.roleCode <= 2 ? '好友申请已发出' : searchAccountInfo.value.roleCode == 5 ? '成功添加机器人' : '成功订阅公众号'); 
                // 显示订阅/添加好友弹窗
-               subscribeModalVisible.value = true;
+               subscribeModalVisible.value = false;
             } else {
               message.error(response.data.msg);
             }
@@ -403,7 +420,7 @@ export default defineComponent({
               }
             });
             if (response.data.code === 200) {
-              window.location.href = response.data.redirect_url;
+              window.location.href = response.data.data;
             }
         } catch (error) {
             message.error('退出失败');
@@ -571,9 +588,16 @@ export default defineComponent({
             loadChattingList();
         }
         else if (messageType === 8) {
+          // 一对一音视频邀请通知
           videoOfferUser.value = eventMessage.sender;
           videoOfferCardVisible.value = true;
         }
+        else if (messageType === 9) {
+           // 群聊多人音视频邀请通知
+          videoOfferUser.value = eventMessage.sender;
+          videoOfferProxyUser.value = eventMessage.proxySender;
+          groupVideoOfferCardVisible.value = true;
+        }
       };
 
       socket.value.onerror = (error) => {
@@ -658,7 +682,9 @@ export default defineComponent({
       openVideo,
       parentSetUser,
       videoOfferCardVisible,
+      groupVideoOfferCardVisible,
       videoOfferUser,
+      videoOfferProxyUser,
       userId,
       photo,
       loginUser,

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

@@ -99,6 +99,7 @@
                         userId: loginUser.value.userId,
                         targetUserId: selectChatUser.value.userId
                     });
+                    alert(selectChatUser.value.userName);
                 }
             } catch (error) {
                 console.error('初始化本地流失败:', error);

+ 242 - 0
webchat-front-client/src/views/video2group.vue

@@ -0,0 +1,242 @@
+<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>
+        </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>
+    </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 reconnectInterval = ref(5000); // 重连间隔时间,单位:毫秒
+        const heartbeatInterval = ref(30000); // 心跳间隔时间,单位:毫秒
+
+        const loginUser = ref(inject('loginUser'));
+        const selectChatUser = ref(props.selectChatUserRef);
+        // 本地音视频流
+        const localVideoRef = ref(null)
+        const localStream = ref(null)
+
+        const videos = ref([])
+
+        // 配置ICE Server(用于网络穿透)
+        const iceServerConfiguration = {
+            "iceServers": [
+                {
+                    "urls": "stun:stun.l.google.com:19302"
+                }
+            ]
+        }
+
+        // 1. 每个用户进度,先获取本地媒体流
+        // const peerConn = ref(null);
+        // 修改点 1:使用标准的 RTCPeerConnection
+        // const PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
+        // 修改点 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);
+                // }
+            } catch (error) {
+                console.error('初始化本地流失败:', error);
+            }
+        };
+
+
+        // 初始化信令服务器连接(ws)
+        const initSignalServerConnection = () => {
+            socket.value = new WebSocket(`/connect-service/ws/group/video/PC_WEB_CHAT/` + selectChatUser.value.userId + `/` + 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;
+                }
+            };
+            const startHeartbeat = () => {
+                const heartbeat = () => {
+                    if (isConnected.value) {
+                    socket.value.send('ping'); // 发送心跳包
+                    }
+                };
+                setInterval(heartbeat, heartbeatInterval.value);
+            };
+        
+            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);
+            };
+        }
+
+         // 初始化ws连接
+         initSignalServerConnection();
+
+         const sendObjMessage = (obj) => {
+            sendMessage(JSON.stringify(obj))
+        }
+        const sendMessage = (message) => {
+            if (isConnected.value) {
+                socket.value.send(message);
+            } else {
+                messageQueue.value.push(message);
+            }
+        };
+
+        onBeforeUnmount(() => {
+            cleanup();
+        }) ;
+        onMounted(() => {
+        
+        });
+        onUnmounted(() => {
+       
+        });
+      
+      return {
+        selectChatUser,
+        videos,
+        initLocalStream,
+        initSignalServerConnection
+      };
+    }
+  });
+
+</script>
+
+<style>
+    
+    .group-video-container {
+        position: relative;
+        float: left;
+        width: 250px;
+        height: 200px;
+        overflow: hidden;
+    }
+    .group-video-container video {
+        position: absolute;
+        left: 0px;
+        top: 0px;
+        width: 250px;
+        height: 200px;
+    }
+    .video-username {
+        position: absolute;
+        top: 20px;
+        right: 20px;
+        background-color: black;
+        opacity: 0.5;
+        color: white;
+        padding: 3px 15px;
+        border-radius: 50px;
+    }
+    
+</style>

+ 6 - 0
webchat-front-oauth/src/router/index.js

@@ -1,11 +1,17 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import Login from '../views/login.vue'
 import Register from '../views/register.vue'
+import Nav from '../views/302.vue'
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   routes: [
     {
+      path: '/302',
+      name: '302',
+      component: Nav,
+    },
+    {
       path: '/',
       name: 'login',
       component: Login,

+ 64 - 0
webchat-front-oauth/src/views/302.vue

@@ -0,0 +1,64 @@
+<template>
+
+  <div class="login-container">
+      <p class="login-form-title"><h2>未正确配置302地址</h2></p>
+      <p class="login-form-title-l2"><h4>请检查系统配置,未识别到redirect_url参数配置</h4></p>
+      <p class="login-form-title-l2"><h4>或者尝试点击下方按钮,访问相关服务:</h4></p>
+      <a href="http://localhost:5001"><a-button class="nav-system-menu">WebChat客户端</a-button></a>
+      <a href="http://localhost:5002"><a-button class="nav-system-menu">WebChat管理后台</a-button></a>
+  </div>
+</template>
+
+<script setup>
+import { Button, Input, InputPassword, message } from 'ant-design-vue';
+import axios from 'axios';
+import { ref } from 'vue';
+
+</script>
+
+<style scoped>
+  * {
+    margin: 0px;
+    padding: 0px;
+  }
+  .login-container {
+    width: 400px;
+    height: 500px;
+    background-color: white;
+    border-radius: 10px;
+    overflow: hidden;
+    box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .1), 0 1px 2px -1px rgba(0, 0, 0, .1);
+    border: 1px solid #c0c5cd;
+    padding: 40px;
+  }
+  .login-form-title {
+    line-height: 60px;
+  }
+  .login-form-title-l2 {
+    line-height: 30px;
+    margin-bottom: 20px;
+    color: rgb(84, 84, 84);
+  }
+  .login-btn {
+    width: 100%; 
+    background-color: black; 
+    height: 50px;
+  }
+  .oauth-input {
+    height: 50px;
+    text-indent: 2em;
+    margin-bottom: 10px;
+    overflow: hidden;
+  }
+  .oauth-input input {
+    margin-left: 20px;
+  }
+  .nav-system-menu {
+    border: 1px solid black;
+    margin: 10px;
+    padding: 0px 10px;
+    color: black;
+    height: 40px;
+    line-height: 40px;
+  }
+</style>

+ 5 - 2
webchat-front-oauth/src/views/login.vue

@@ -21,8 +21,9 @@
 import { Button, Input, InputPassword, message } from 'ant-design-vue';
 import axios from 'axios';
 import { ref } from 'vue';
-import { useRoute } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
 
+const router = useRouter();
 const mobile = ref('');
 const password = ref('');
 const route = useRoute();
@@ -35,7 +36,9 @@ const login = async () => {
       password: password.value
     });
     if (response.data.code == 200) {
-      if(callBackUrl.value.indexOf("?") > -1) {
+      if(callBackUrl.value == null) {
+        router.push({ name: '302' });
+      }else if(callBackUrl.value.indexOf("?") > -1) {
         window.location.href = callBackUrl.value+'&oauthCode='+response.data.data;
       } else {
         window.location.href = callBackUrl.value+'?oauthCode='+response.data.data;