| | |
| | | <div class="contactAvatar"> |
| | | <img :src="defaultAvatar" alt=""></img> |
| | | </div> |
| | | |
| | | |
| | | <!-- 联系人名称--> |
| | | <div class="contactName"> |
| | | {{ contactName }} |
| | |
| | | <strong v-if="callEnded" class="status-text ended-status">{{ endMessage }}</strong> |
| | | </div> |
| | | <div class="button-group"> |
| | | <button v-if="state === 'idle' && !receiveParameters" @click="requestCall" :disabled="!connected" class="btn btn-call">呼叫</button> |
| | | <!-- <button v-if="state === 'idle' && !receiveParameters && !callEnded" @click="requestCall" :disabled="!connected" class="btn btn-call">呼叫</button> --> |
| | | |
| | | <button v-if="state==='ringing'" @click="acceptCall" class="btn btn-accept incoming-accept">接听</button> |
| | | <button v-if="state==='ringing'" @click="rejectCall" class="btn btn-reject incoming-reject">拒绝</button> |
| | |
| | | <script setup> |
| | | import { useRoute } from 'vue-router' |
| | | import defaultAvatar from '@/appDataSource/appwork/defaultAvatar.svg' |
| | | import { showToast } from 'vant' |
| | | const route = useRoute() |
| | | /** ✅ 你的 WS 地址前缀(后面拼 userId) */ |
| | | // const WS_BASE = 'ws://218.202.104.82:38201/ws/chat?userId=' |
| | | const WS_BASE = 'ws://218.202.104.82:38201/ws/chat' |
| | | |
| | | const uid = ref('2011686857615683585') |
| | | const peerUid = ref('1') |
| | | // const WS_BASE = 'ws://218.202.104.82:38201/ws/chat' |
| | | const WS_BASE = 'ws://220.177.172.27:8100/webrtc/ws/chat' |
| | | const newUserInfo = ref(null) |
| | | const uid = ref('') |
| | | const peerUid = ref('') |
| | | // 联系人名称 |
| | | const contactName = ref('张三') |
| | | const contactName = ref('') |
| | | |
| | | const connected = ref(false) |
| | | const state = ref('idle') // idle | calling | ringing | in-call |
| | |
| | | const parseUrlParams = () => { |
| | | // 1. 优先从route.query.voiceparams获取参数 |
| | | if (route.query && route.query.voiceparams) { |
| | | console.log('从route.query.voiceparams获取参数:', route.query.voiceparams); |
| | | try { |
| | | // 解码并解析voiceparams参数 |
| | | const params = JSON.parse(decodeURIComponent(route.query.voiceparams)); |
| | | console.log('解析后的voiceparams:', params); |
| | | |
| | | if (params.peerUid) { |
| | | peerUid.value = String(params.peerUid); |
| | | log('🔗 从voiceparams获取peerUid:', params.peerUid); |
| | | } |
| | | |
| | | peerUid.value = String(params.peerUid); |
| | | contactName.value = params.callerName || '未知联系人' |
| | | // 如果是来电类型,直接触发来电状态 |
| | | if (params.type === 'incoming') { |
| | | log('📞 voiceparams检测到来电,直接进入来电状态'); |
| | |
| | | state.value = 'ringing'; |
| | | log('📞 收到来电 call,来自', incomingFrom); |
| | | |
| | | // 开始来电提示(铃声和震动) |
| | | playRingtone(); |
| | | startVibration(); |
| | | |
| | | } |
| | | } catch (e) { |
| | | log('❌ 解析voiceparams参数失败:', String(e)); |
| | |
| | | } |
| | | |
| | | // 2. 同时保留原有的URL参数解析逻辑,作为备选 |
| | | const url = new URL(window.location.href); |
| | | const paramsStr = url.searchParams.get('params'); |
| | | if (paramsStr) { |
| | | try { |
| | | const params = JSON.parse(decodeURIComponent(paramsStr)); |
| | | if (params.peerUid) { |
| | | peerUid.value = String(params.peerUid); |
| | | log('🔗 从URL参数获取peerUid:', params.peerUid); |
| | | } |
| | | } catch (e) { |
| | | log('❌ 解析URL参数失败:', String(e)); |
| | | } |
| | | } |
| | | // const url = new URL(window.location.href); |
| | | // const paramsStr = url.searchParams.get('params'); |
| | | // if (paramsStr) { |
| | | // try { |
| | | // const params = JSON.parse(decodeURIComponent(paramsStr)); |
| | | // if (params.peerUid) { |
| | | // peerUid.value = String(params.peerUid); |
| | | // log('🔗 从URL参数获取peerUid:', params.peerUid); |
| | | // } |
| | | // } catch (e) { |
| | | // log('❌ 解析URL参数失败:', String(e)); |
| | | // } |
| | | // } |
| | | } |
| | | |
| | | // 从URL参数或本地存储获取用户ID |
| | | const getUserIdFromStorage = () => { |
| | | // 从url获取用户id |
| | | const contact = JSON.parse(decodeURIComponent(receiveParameters.value)) |
| | | console.log('从url获取用户id',contact?.userId) |
| | | // 从本地获取用户id |
| | | const userId = localStorage.getItem('userId') || sessionStorage.getItem('userId') |
| | | console.log('从本地存储获取userId:', userId) |
| | | if (userId) { |
| | | const userId = '2021474815063486465' |
| | | try { |
| | | const params = JSON.parse(decodeURIComponent(route.query.params)); |
| | | newUserInfo.value = params.new_userInfo |
| | | const userIdFromParams = params.new_userInfo?.userId |
| | | console.log('从url获取用户id', userIdFromParams) |
| | | return userIdFromParams || userId |
| | | } catch (error) { |
| | | return userId |
| | | } |
| | | return '2011686857615683585' // 默认值 |
| | | } |
| | | |
| | | // 心跳 |
| | | let pingTimer = null |
| | | function startPing() { |
| | | stopPing() |
| | | // 每 25 秒发一次 ping(你后端有 PING/PONG) |
| | | pingTimer = setInterval(() => { |
| | | if (connected.value) send('ping', 'system', null) |
| | | }, 25000) |
| | | } |
| | | // function startPing() { |
| | | // stopPing() |
| | | // // 每 25 秒发一次 ping(你后端有 PING/PONG) |
| | | // pingTimer = setInterval(() => { |
| | | // if (connected.value) send('ping', 'system', null) |
| | | // }, 25000) |
| | | // } |
| | | |
| | | function stopPing() { |
| | | if (pingTimer) clearInterval(pingTimer) |
| | |
| | | |
| | | function createPC() { |
| | | pc = new RTCPeerConnection({ |
| | | iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], |
| | | iceServers: [{ urls: 'stun:stun.l.google.com:19302' }, |
| | | { urls: 'stun:stun.l.google.com:19302' }, |
| | | { urls: 'stun:stun1.l.google.com:19302' }, |
| | | { urls: 'stun:stun.stunprotocol.org:3478' }, |
| | | // 添加多个公共TURN服务器以提高跨网络连接成功率 |
| | | { urls: ['turn:freeturn.net:80', 'turn:freeturn.net:443'], username: 'free', credential: 'free' }, |
| | | // 备用TURN服务器 |
| | | { urls: 'turn:turn.cloudlifter.io:443', username: 'webrtc', credential: 'webrtc' }], |
| | | }) |
| | | |
| | | pc.onicecandidate = (e) => { |
| | |
| | | } |
| | | |
| | | try { |
| | | localStream = await navigator.mediaDevices.getUserMedia({ |
| | | audio: { |
| | | echoCancellation: true, |
| | | noiseSuppression: true, |
| | | autoGainControl: true, |
| | | // 移动端特定配置 |
| | | channelCount: 1, |
| | | sampleRate: 16000, |
| | | sampleSize: 16 |
| | | } |
| | | }) |
| | | log('✅ 已获取本地麦克风') |
| | | // 尝试使用完整约束 |
| | | try { |
| | | localStream = await navigator.mediaDevices.getUserMedia({ |
| | | audio: { |
| | | echoCancellation: true, |
| | | noiseSuppression: true, |
| | | autoGainControl: true, |
| | | channelCount: 1, |
| | | sampleRate: 16000, |
| | | sampleSize: 16 |
| | | } |
| | | }) |
| | | log('✅ 已获取本地麦克风(标准约束)') |
| | | } catch (strictError) { |
| | | // 如果标准约束失败,尝试最简单的约束 |
| | | log('⚠️ 标准约束失败,尝试简化约束:', String(strictError)) |
| | | localStream = await navigator.mediaDevices.getUserMedia({ |
| | | audio: true |
| | | }) |
| | | log('✅ 已获取本地麦克风(简化约束)') |
| | | } |
| | | return localStream |
| | | } catch (e) { |
| | | log('❌ 获取麦克风失败:', String(e)) |
| | | |
| | | // 提供更详细的错误信息 |
| | | if (e.name === 'NotReadableError') { |
| | | log('❌ NotReadableError: 麦克风可能被其他应用占用,或权限未正确配置') |
| | | showToast('麦克风被占用或权限未授予') |
| | | } else if (e.name === 'NotAllowedError') { |
| | | log('❌ NotAllowedError: 用户拒绝了麦克风权限') |
| | | showToast('请授予麦克风权限') |
| | | } else if (e.name === 'NotFoundError') { |
| | | log('❌ NotFoundError: 未找到麦克风设备') |
| | | showToast('未找到麦克风设备') |
| | | } |
| | | |
| | | throw e |
| | | } |
| | | } |
| | |
| | | ws.onopen = () => { |
| | | connected.value = true |
| | | log('WS 已连接 userId=', uid.value) |
| | | startPing() |
| | | // startPing() |
| | | } |
| | | |
| | | ws.onclose = () => { |
| | |
| | | send('accept', incomingFrom, null) |
| | | log('✅ 已点击接听,发送 accept 给', incomingFrom) |
| | | |
| | | |
| | | state.value = 'calling' |
| | | |
| | | // 如果 offer 已经提前到达(我们暂存了),立刻 answer |
| | |
| | | answerOffer(lastOfferSdp, incomingFrom) |
| | | lastOfferSdp = null |
| | | } |
| | | // 停止 |
| | | const transmitData = { data: { sign:'closeBox' } } |
| | | uni.postMessage(transmitData) |
| | | } |
| | | |
| | | function rejectCall() { |
| | |
| | | // 你后端没有 reject,用 busy 表示拒绝/不可接听 |
| | | send('busy', incomingFrom, null) |
| | | log('❌ 已拒绝来电,发送 busy 给', incomingFrom) |
| | | const transmitData = { data: { type: 'hangupVoice',sign:'closeBox' } } |
| | | uni.postMessage(transmitData) |
| | | |
| | | incomingFrom = null |
| | | acceptedByMe = false |
| | |
| | | } |
| | | |
| | | function hangup() { |
| | | // 停止铃声 |
| | | // const transmitData = { data: { type: 'stopVoice' } } |
| | | // uni.postMessage(transmitData) |
| | | cleanup(true) |
| | | } |
| | | |
| | |
| | | lastOfferSdp = null |
| | | |
| | | state.value = 'idle' |
| | | |
| | | |
| | | // 清除之前的定时器 |
| | | if (endTimer.value) { |
| | | clearTimeout(endTimer.value) |
| | | endTimer.value = null |
| | | } |
| | | |
| | | |
| | | // 根据是谁发起的挂断决定不同的行为 |
| | | if (sendToPeer) { |
| | | // 自己主动挂断:不提示,不延迟,直接跳转 |
| | |
| | | } else { |
| | | log('❌ uni对象不存在,无法返回上一页') |
| | | } |
| | | const transmitData = { data: { type: 'hangupVoice' } } |
| | | uni.postMessage(transmitData) |
| | | } else { |
| | | // 对方挂断:显示提示,延迟10秒跳转 |
| | | callEnded.value = true |
| | | endMessage.value = '对方已挂断' |
| | | |
| | | const transmitData = { data: { type: 'hangupVoice',sign:'closeBox' } } |
| | | uni.postMessage(transmitData) |
| | | // 延迟5秒后跳转页面 |
| | | endTimer.value = setTimeout(() => { |
| | | // 返回上一页(uni-app的语音通话列表页面) |
| | |
| | | } else { |
| | | log('❌ uni对象不存在,无法返回上一页') |
| | | } |
| | | |
| | | // 重置通话结束状态 |
| | | callEnded.value = false |
| | | }, 5000) |
| | | }, 2000) |
| | | } |
| | | } |
| | | |
| | |
| | | const receiveParameters = ref(route.query.contact || null); |
| | | |
| | | onMounted(() => { |
| | | console.log('收拾收拾',route.query.voiceparams); |
| | | console.log('接收参数', receiveParameters) |
| | | console.log('对方打给我的',route.query.voiceparams); |
| | | console.log('接收拨打电话:对方参数', receiveParameters) |
| | | if (route.query && route.query.params) { |
| | | try { |
| | | const params = JSON.parse(decodeURIComponent(route.query.params)); |
| | | // 获取token |
| | | if (params.access_token) { |
| | | accessToken.value = params.access_token; |
| | | log('🔗 从params获取access_token:', accessToken.value); |
| | | } |
| | | } |
| | | } catch (e) { |
| | | log('❌ 解析params参数失败:', String(e)); |
| | | } |
| | |
| | | if (receiveParameters.value) { |
| | | try { |
| | | const contact = JSON.parse(decodeURIComponent(receiveParameters.value)) |
| | | if (contact.friendId) { |
| | | peerUid.value = contact.friendId |
| | | contactName.value = contact.friendNickName || '未知联系人' |
| | | log('🔗 从contact参数获取peerUid:', contact.friendId) |
| | | log('👤 联系人名称:', contactName.value) |
| | | if (contact.userId) { |
| | | peerUid.value = contact.userId |
| | | contactName.value = contact.nickName || '未知联系人' |
| | | } |
| | | } catch (e) { |
| | | log('❌ 解析contact参数失败:', String(e)) |
| | |
| | | font-family: Arial, sans-serif; |
| | | background-color: #f5f5f5; |
| | | transition: all 0.3s ease; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | |
| | | /* 来电时的全屏样式 */ |
| | |
| | | .content-wrapper { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 20px; |
| | | gap: 30px; |
| | | max-width: 600px; |
| | | margin: 0 auto; |
| | | height: 100vh; |
| | | justify-content: center; |
| | | width: 100%; |
| | | align-items: center; |
| | | padding: 20px; |
| | | } |
| | | |
| | | .input-group { |
| | |
| | | gap: 50px; |
| | | flex-wrap: wrap; |
| | | justify-content: center; |
| | | margin-top: 80px; |
| | | margin-top: 40px; |
| | | } |
| | | |
| | | .btn { |
| | |
| | | font-size: 18px; |
| | | color: #333; |
| | | text-align: center; |
| | | margin-bottom: 20px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | /* 联系人名称样式 */ |
| | | .contactName { |
| | | font-size: 18px; |
| | | font-size: 20px; |
| | | font-weight: bold; |
| | | color: #333; |
| | | text-align: center; |
| | | margin-top: 10px; |
| | | margin-bottom: 40px; |
| | | |
| | | margin: 15px 0; |
| | | } |
| | | |
| | | /* 来电状态样式 */ |
| | |
| | | } |
| | | } |
| | | |
| | | /* 来电时的头像放大效果 */ |
| | | /* 来电时的头像样式 */ |
| | | .call-container.incoming-call .contactAvatar { |
| | | width: 220px; |
| | | height: 220px; |
| | | margin-bottom: 60px; |
| | | width: 180px; |
| | | height: 180px; |
| | | } |
| | | </style> |
| | | </style> |