吉安感知网项目-前端
罗广辉
2026-08-13 1d552a3bad95caae4af15322df28e6240b797693
applications/mobile-web-view/src/appPages/voiceCallDetail/index.vue
@@ -1,18 +1,6 @@
<template>
   <div class="call-container" :class="{'incoming-call': state === 'ringing'} ">
      <div class="content-wrapper">
         <!--      <div class="input-group">
               <div class="input-item">
                  <span class="input-label">我的 userId:</span>
                  <input v-model="uid" class="input-field" />
               </div>
               <div class="input-item">
                  <span class="input-label">对方 userId:</span>
                  <input v-model="peerUid" class="input-field" />
               </div>
            </div>-->
         <div class="button-group" v-if="false">
            <button @click="connectWS" :disabled="connected" class="btn btn-connect">连接WS</button>
         </div>
@@ -20,14 +8,21 @@
         <div class="contactAvatar">
            <img :src="defaultAvatar" alt=""></img>
         </div>
         <!--  联系人名称-->
         <div class="contactName">
            {{ contactName }}
         </div>
         <div class="status-group">
            <strong v-if="state==='ringing'" class="status-text incoming-status">来电中...</strong>
            <strong v-if="state==='calling'" class="status-text calling-status">呼叫中...</strong>
            <!--   <strong class="status-text">状态:<span :style="{color: getStateColor(state)}">{{ state }}</span></strong>-->
            <strong v-if="state==='in-call'" class="status-text">通话时长:{{ durationText }}</strong>
            <!--  通话结束提示-->
            <strong v-if="callEnded" class="status-text ended-status">{{ endMessage }}</strong>
         </div>
         <div class="button-group">
            <button  @click="requestCall" :disabled="!connected || state!=='idle'" 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>
@@ -37,18 +32,6 @@
      </div>
      <!--   <div class="tip-message">
         <small>提示:若 getUserMedia 不可用,请用 https 或 localhost 访问页面。</small>
         </div>-->
      <!--   <div class="log-container">
         <div class="log-box">
            <div class="log-title">日志信息:</div>
            <pre class="log-content">{{ logText }}</pre>
         </div>
         </div>-->
      <audio ref="remoteAudio" autoplay class="hidden-audio"></audio>
   </div>
</template>
@@ -56,91 +39,28 @@
<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 = 'wss://wrj.shuixiongit.com/ws/chat?userId='
// 解析URL参数
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);
         }
         // 如果是来电类型,直接触发来电状态
         if (params.type === 'incoming') {
            log('📞 voiceparams检测到来电,直接进入来电状态');
            incomingFrom = String(params.from);
            peerUid.value = incomingFrom;
            acceptedByMe = false;
            offeredByPeer = false;
            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));
      }
   }
}
// 从URL参数或本地存储获取用户ID
const getUserIdFromStorage = () => {
   const userId = localStorage.getItem('userId') || sessionStorage.getItem('userId')
   if (userId) {
      log('🔗 从本地存储获取userId:', userId)
      return userId
   }
   return '3' // 默认值
}
const uid = ref('3')
const peerUid = ref('2')
// const WS_BASE = 'ws://218.202.104.82:38201/ws/chat?userId='
// 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 connected = ref(false)
const state = ref('idle') // idle | calling | ringing | in-call
const logText = ref('')
const remoteAudio = ref(null)
/**
 * 根据状态返回对应颜色
 */
const getStateColor = (state) => {
   const colors = {
      'idle': '#666',
      'calling': '#2196F3',
      'ringing': '#FF9800',
      'in-call': '#4CAF50'
   }
   return colors[state] || '#666'
}
// 通话结束相关
const callEnded = ref(false)
const endMessage = ref('对方已挂断')
const endTimer = ref(null)
// token 存储
const accessToken = ref('')
let ws = null
let pc = null
@@ -169,87 +89,73 @@
   if (timer) clearInterval(timer)
   timer = null
}
// 解析URL参数
const parseUrlParams = () => {
   // 1. 优先从route.query.voiceparams获取参数
   if (route.query && route.query.voiceparams) {
      try {
         // 解码并解析voiceparams参数
         const params = JSON.parse(decodeURIComponent(route.query.voiceparams));
         peerUid.value = String(params.peerUid);
         contactName.value = params.callerName || '未知联系人'
         // 如果是来电类型,直接触发来电状态
         if (params.type === 'incoming') {
            log('📞 voiceparams检测到来电,直接进入来电状态');
            incomingFrom = String(params.from);
            peerUid.value = incomingFrom;
            acceptedByMe = false;
            offeredByPeer = false;
            state.value = 'ringing';
            log('📞 收到来电 call,来自', incomingFrom);
         }
      } 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));
   //    }
   // }
}
// 从URL参数或本地存储获取用户ID
const getUserIdFromStorage = () => {
   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
   }
}
// 心跳
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)
// }
// 来电提示相关
let ringtoneAudio = null
let vibrationTimer = null
// 播放来电铃声
function playRingtone() {
   // 创建音频对象(使用默认的系统提示音)
   ringtoneAudio = new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQAAAAAAA==');
   ringtoneAudio.loop = true;
   ringtoneAudio.volume = 0.5;
   try {
      ringtoneAudio.play();
      log('🔊 开始播放来电铃声')
   } catch (e) {
      log('❌ 播放来电铃声失败:', String(e))
   }
}
// 停止来电铃声
function stopRingtone() {
   if (ringtoneAudio) {
      try {
         ringtoneAudio.pause();
         ringtoneAudio.currentTime = 0;
         ringtoneAudio = null;
         log('🔇 停止播放来电铃声')
      } catch (e) {
         log('❌ 停止来电铃声失败:', String(e))
      }
   }
}
// 开始震动
function startVibration() {
   // 检查浏览器是否支持震动 API
   if ('vibrate' in navigator) {
      // 震动模式:震动1秒,暂停0.5秒,重复
      const vibrationPattern = [1000, 500];
      try {
         navigator.vibrate(vibrationPattern);
         vibrationTimer = setInterval(() => {
            navigator.vibrate(vibrationPattern);
         }, 1500); // 每1.5秒重复一次震动模式
         log('📳 开始震动提示')
      } catch (e) {
         log('❌ 震动失败:', String(e))
      }
   } else {
      log('ℹ️ 当前浏览器不支持震动功能')
   }
}
// 停止震动
function stopVibration() {
   if (vibrationTimer) {
      clearInterval(vibrationTimer);
      vibrationTimer = null;
   }
   if ('vibrate' in navigator) {
      try {
         navigator.vibrate(0); // 停止震动
         log('📳 停止震动提示')
      } catch (e) {
         log('❌ 停止震动失败:', String(e))
      }
   }
}
function stopPing() {
   if (pingTimer) clearInterval(pingTimer)
   pingTimer = null
@@ -284,7 +190,14 @@
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) => {
@@ -292,7 +205,7 @@
         send('candidate', peerUid.value, { candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate })
      }
   }
// 步骤七:建立连接
   pc.ontrack = (e) => {
      log('✅ 收到远端音频流')
      const stream = e.streams[0]
@@ -307,7 +220,7 @@
      log('pc.connectionState =', pc.connectionState)
   }
}
// 步骤六:连接音频
async function getLocalAudio() {
   if (localStream) return localStream
@@ -317,15 +230,47 @@
   }
   try {
      localStream = await navigator.mediaDevices.getUserMedia({ audio: true })
      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
   }
}
// 步骤5 确保本地音频流已添加
/** ✅ 关键:只 addTrack 一次,避免 “sender already exists” */
async function ensureLocalTracksAdded() {
   if (!pc) createPC()
@@ -346,7 +291,7 @@
   return stream
}
// 步骤4 发送本地音频流
async function flushCandidates() {
   if (!pc || !pc.remoteDescription) return
   while (pendingCandidates.length) {
@@ -362,8 +307,6 @@
/* ---------------- 信令处理 ---------------- */
async function onSignal(msg) {
console.log('1cesss',msg);
   if (!msg) return
   const t = (msg.type || '').toString()
@@ -388,12 +331,7 @@
      offeredByPeer = false
      state.value = 'ringing'
      log('📞 收到来电 call,来自', incomingFrom)
      // 开始来电提示(铃声和震动)
      playRingtone()
      startVibration()
         log('📞 收到来电 call,来自', incomingFrom)
      return
   }
@@ -423,12 +361,12 @@
         lastOfferSdp = msg.payload?.sdp
         return
      }
  // 步骤2
      // 已接听:直接 answer
      await answerOffer(msg.payload?.sdp, incomingFrom)
      return
   }
// 步骤3 接收 answer
   if (t === 'answer') {
      log('✅ 收到 answer,通话建立')
      if (!pc) createPC()
@@ -475,12 +413,14 @@
   if (ws) ws.close()
   ws = new WebSocket(WS_BASE + encodeURIComponent(uid.value))
   // 使用从参数中获取的token或默认token
   const token = accessToken.value
   // ws = new WebSocket(WS_BASE + encodeURIComponent(uid.value))
   ws = new WebSocket(WS_BASE, token)
   ws.onopen = () => {
      connected.value = true
      log('WS 已连接 userId=', uid.value)
      startPing()
      // startPing()
   }
   ws.onclose = () => {
@@ -497,7 +437,7 @@
      onSignal(msg)
   }
}
// 发送呼叫信息
function requestCall() {
   state.value = 'calling'
   incomingFrom = null
@@ -508,18 +448,15 @@
   send('call', peerUid.value, null)
   log('➡️ 发起呼叫 call 给', peerUid.value)
}
// 接收信息
function acceptCall() {
   if (!incomingFrom) return
   acceptedByMe = true
   // 停止来电提示
   stopRingtone()
   stopVibration()
   // 先告诉对方我接听了(后端会把双方置忙)
   send('accept', incomingFrom, null)
   log('✅ 已点击接听,发送 accept 给', incomingFrom)
   state.value = 'calling'
@@ -528,18 +465,19 @@
      answerOffer(lastOfferSdp, incomingFrom)
      lastOfferSdp = null
   }
   // 停止
   const transmitData = { data: { sign:'closeBox' } }
   uni.postMessage(transmitData)
}
function rejectCall() {
   if (!incomingFrom) return
   // 停止来电提示
   stopRingtone()
   stopVibration()
   // 你后端没有 reject,用 busy 表示拒绝/不可接听
   send('busy', incomingFrom, null)
   log('❌ 已拒绝来电,发送 busy 给', incomingFrom)
   const transmitData = { data: { type: 'hangupVoice',sign:'closeBox'  } }
uni.postMessage(transmitData)
   incomingFrom = null
   acceptedByMe = false
@@ -560,14 +498,14 @@
   // ✅ 不会重复 addTrack
   await ensureLocalTracksAdded()
// 步骤1:创建 offer
   const offer = await pc.createOffer()
   await pc.setLocalDescription(offer)
   send('offer', peerUid.value, { sdp: pc.localDescription })
   log('➡️ 已发送 offer 给', peerUid.value)
}
// 步骤2:等待 answer
async function answerOffer(offerSdp, from) {
   if (!offerSdp) {
      log('❌ offer sdp 为空,无法接听')
@@ -591,6 +529,9 @@
}
function hangup() {
   // 停止铃声
//    const transmitData = { data: { type: 'stopVoice' } }
// uni.postMessage(transmitData)
   cleanup(true)
}
@@ -602,9 +543,6 @@
   } catch {}
   stopTimer()
   // 停止来电提示
   stopRingtone()
   stopVibration()
   if (pc) {
      pc.getSenders().forEach(s => s.track && s.track.stop())
@@ -625,28 +563,70 @@
   state.value = 'idle'
   // 返回上一页(uni-app的语音通话列表页面)
   if (window.uni) {
      window.uni.navigateBack()
   // 清除之前的定时器
   if (endTimer.value) {
      clearTimeout(endTimer.value)
      endTimer.value = null
   }
   // 根据是谁发起的挂断决定不同的行为
   if (sendToPeer) {
      // 自己主动挂断:不提示,不延迟,直接跳转
      log('⏰ 自己主动挂断,直接返回')
      callEnded.value = false
      // 返回上一页(uni-app的语音通话列表页面)
      if (window.uni) {
         window.uni.navigateBack()
      } else {
         log('❌ uni对象不存在,无法返回上一页')
      }
      const transmitData = { data: { type: 'hangupVoice' } }
      uni.postMessage(transmitData)
   } else {
      log('❌ uni对象不存在,无法返回上一页')
      // 对方挂断:显示提示,延迟10秒跳转
      callEnded.value = true
      endMessage.value = '对方已挂断'
      const transmitData = { data: { type: 'hangupVoice',sign:'closeBox'  } }
      uni.postMessage(transmitData)
      // 延迟5秒后跳转页面
      endTimer.value = setTimeout(() => {
         // 返回上一页(uni-app的语音通话列表页面)
         if (window.uni) {
            window.uni.navigateBack()
         } else {
            log('❌ uni对象不存在,无法返回上一页')
         }
         // 重置通话结束状态
         callEnded.value = false
      }, 2000)
   }
}
// 定义receiveParameters变量,用于模板中判断是否显示呼叫按钮
const receiveParameters = ref(route.query.contact || null);
onMounted(() => {
console.log('收拾收拾',route.query.voiceparams);
   const receiveParameters = route.query.contact
   console.log('接收参数', receiveParameters)
   // 解析并使用contact参数
   if (receiveParameters) {
   console.log('对方打给我的',route.query.voiceparams);
   console.log('接收拨打电话:对方参数', receiveParameters)
   if (route.query && route.query.params) {
      try {
         const contact = JSON.parse(decodeURIComponent(receiveParameters))
         if (contact.friendId) {
            peerUid.value = contact.friendId
            log('🔗 从contact参数获取peerUid:', contact.friendId)
            log('👤 联系人名称:', contact.friendNickName || '未知联系人')
         const params = JSON.parse(decodeURIComponent(route.query.params));
         // 获取token
         if (params.access_token) {
            accessToken.value = params.access_token;
         }
      } catch (e) {
         log('❌ 解析params参数失败:', String(e));
      }
   }
   // 解析并使用contact参数
   if (receiveParameters.value) {
      try {
         const contact = JSON.parse(decodeURIComponent(receiveParameters.value))
         if (contact.userId) {
            peerUid.value = contact.userId
            contactName.value = contact.nickName || '未知联系人'
         }
      } catch (e) {
         log('❌ 解析contact参数失败:', String(e))
@@ -657,14 +637,16 @@
   parseUrlParams()
   // 自动连接WebSocket
   connectWS()
})
});
// 监听连接状态,连接成功后自动呼叫
// watch(() => connected.value, (newVal) => {
//    if (newVal && state.value === 'idle') {
//       requestCall()
//    }
// })
// 监听连接状态,当connected变为true且有receiveParameters时自动呼叫
watch(() => connected.value, (newVal) => {
   if (newVal && state.value === 'idle' && receiveParameters.value) {
      log('📞 连接成功且有contact参数,自动发起呼叫');
      requestCall();
   }
});
onBeforeUnmount(() => {
   cleanup(false)
@@ -679,6 +661,9 @@
   font-family: Arial, sans-serif;
   background-color: #f5f5f5;
   transition: all 0.3s ease;
   display: flex;
   align-items: center;
   justify-content: center;
}
/* 来电时的全屏样式 */
@@ -693,15 +678,23 @@
   padding: 0;
}
/* 通话结束状态样式 */
.status-text.ended-status {
   color: #e74c3c;
   font-size: 18px;
   font-weight: bold;
   margin-top: 10px;
   text-align: 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 {
@@ -734,7 +727,7 @@
   gap: 50px;
   flex-wrap: wrap;
   justify-content: center;
   margin-top: 80px;
   margin-top: 40px;
}
.btn {
@@ -816,8 +809,17 @@
   font-size: 18px;
   color: #333;
   text-align: center;
   margin-bottom: 20px;
   margin-bottom: 10px;
}
   /* 联系人名称样式 */
   .contactName {
      font-size: 20px;
      font-weight: bold;
      color: #333;
      text-align: center;
      margin: 15px 0;
   }
/* 来电状态样式 */
.status-text.incoming-status {
@@ -895,7 +897,6 @@
   display: flex;
   justify-content: center;
   align-items: center;
   margin-bottom: 40px;
   border-radius: 50%;
   background-color: #f0f0f0;
   overflow: hidden;
@@ -908,10 +909,9 @@
   }
}
/* 来电时的头像放大效果 */
/* 来电时的头像样式 */
.call-container.incoming-call .contactAvatar {
   width: 220px;
   height: 220px;
   margin-bottom: 60px;
   width: 180px;
   height: 180px;
}
</style>
</style>