吉安感知网项目-前端
罗广辉
2026-01-15 04a859aa95b231c032c71d06518497a973c2f4e8
applications/mobile-web-view/src/appPages/voiceCallDetail/index.vue
@@ -1,52 +1,55 @@
<template>
   <div class="call-container">
     <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 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>
         <div class="input-item">
            <span class="input-label">对方 userId:</span>
            <input v-model="peerUid" class="input-field" />
         </div>
      </div>
      <div class="button-group">
         <button @click="connectWS" :disabled="connected" class="btn btn-connect">连接WS</button>
      </div>
      <!--  头像-->
         <!--  头像-->
         <div class="contactAvatar">
            <img :src="defaultAvatar" alt=""></img>
         </div>
      <div class="button-group">
         <button @click="requestCall" :disabled="!connected || state!=='idle'" class="btn btn-call">呼叫</button>
         <button v-if="state==='ringing'" @click="acceptCall" class="btn btn-accept">接听</button>
         <button v-if="state==='ringing'" @click="rejectCall" class="btn btn-reject">拒绝</button>
         <button @click="hangup" :disabled="!connected || (state!=='calling' && state!=='in-call')" class="btn btn-hangup">挂断</button>
         <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>
         </div>
         <div class="button-group">
            <button v-if="state === 'idle' && !receiveParameters" @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>
            <button v-if="state==='calling' || state==='in-call'" @click="hangup" class="btn btn-hangup">挂断</button>
         </div>
      </div>
      <div class="status-group">
         <strong class="status-text">状态:<span :style="{color: getStateColor(state)}">{{ state }}</span></strong>
         <strong v-if="state==='in-call'" class="status-text">通话时长:{{ durationText }}</strong>
      </div>
     </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 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>
@@ -57,25 +60,69 @@
/** ✅ 你的 WS 地址前缀(后面拼 userId) */
const WS_BASE = 'wss://wrj.shuixiongit.com/ws/chat?userId='
  // 解析URL参数
  const parseUrlParams = () => {
    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参数
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));
      }
   }
}
  const uid = ref('3')
  const peerUid = ref('2')
// 从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 connected = ref(false)
const state = ref('idle') // idle | calling | ringing | in-call
@@ -112,59 +159,130 @@
   const m = String(Math.floor(seconds.value / 60)).padStart(2, '0')
   const s = String(seconds.value % 60).padStart(2, '0')
   return `${m}:${s}`
  })
  function startTimer() {
})
function startTimer() {
   stopTimer()
   seconds.value = 0
   timer = setInterval(() => seconds.value++, 1000)
  }
  function stopTimer() {
}
function stopTimer() {
   if (timer) clearInterval(timer)
   timer = null
  }
  // 心跳
  let pingTimer = null
  function startPing() {
}
// 心跳
let pingTimer = null
function startPing() {
   stopPing()
   // 每 25 秒发一次 ping(你后端有 PING/PONG)
   pingTimer = setInterval(() => {
     if (connected.value) send('ping', 'system', null)
      if (connected.value) send('ping', 'system', null)
   }, 25000)
  }
  function stopPing() {
}
// 来电提示相关
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
  }
  function log(...args) {
}
function log(...args) {
   const line = args.map(v => (typeof v === 'string' ? v : JSON.stringify(v))).join(' ')
   logText.value += `[${new Date().toLocaleTimeString()}] ${line}\n`
   console.log(...args)
  }
  /**
   * ✅ 严格按后端 ChatMessage 字段发:
   * { type, from, to, payload, timestamp }
   * 注意:后端会用 session 的 userId 覆盖 from,但我们带上也没坏处
   */
  function send(type, to, payload) {
}
/**
 * ✅ 严格按后端 ChatMessage 字段发:
 * { type, from, to, payload, timestamp }
 * 注意:后端会用 session 的 userId 覆盖 from,但我们带上也没坏处
 */
function send(type, to, payload) {
   if (!type) return
   if (!ws || ws.readyState !== WebSocket.OPEN) return
   const msg = {
     type,                 // "call/accept/busy/offer/answer/candidate/hangup/ping"
     from: String(uid.value),
     to: String(to),
     payload: payload ?? null,
     timestamp: Date.now(),
      type,                 // "call/accept/busy/offer/answer/candidate/hangup/ping"
      from: String(uid.value),
      to: String(to),
      payload: payload ?? null,
      timestamp: Date.now(),
   }
   ws.send(JSON.stringify(msg))
  }
  /* ---------------- WebRTC ---------------- */
  function createPC() {
}
/* ---------------- WebRTC ---------------- */
function createPC() {
   pc = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
   })
@@ -244,6 +362,8 @@
/* ---------------- 信令处理 ---------------- */
async function onSignal(msg) {
console.log('1cesss',msg);
   if (!msg) return
   const t = (msg.type || '').toString()
@@ -269,6 +389,11 @@
      state.value = 'ringing'
      log('📞 收到来电 call,来自', incomingFrom)
      // 开始来电提示(铃声和震动)
      playRingtone()
      startVibration()
      return
   }
@@ -284,356 +409,518 @@
      cleanup(false)
      return
   }
   if (t === 'offer') {
     // 被叫收到 offer:只有点击接听后才真正应答(你要求“先点接听”)
     offeredByPeer = true
     incomingFrom = String(msg.from)
     peerUid.value = incomingFrom
     if (!acceptedByMe) {
      state.value = 'ringing'
      log('📩 收到 offer(但未接听),已等待用户点击接听')
      // 暂存 offer 到一个变量
      lastOfferSdp = msg.payload?.sdp
      // 被叫收到 offer:只有点击接听后才真正应答(你要求“先点接听”)
      offeredByPeer = true
      incomingFrom = String(msg.from)
      peerUid.value = incomingFrom
      if (!acceptedByMe) {
         state.value = 'ringing'
         log('📩 收到 offer(但未接听),已等待用户点击接听')
         // 暂存 offer 到一个变量
         lastOfferSdp = msg.payload?.sdp
         return
      }
      // 已接听:直接 answer
      await answerOffer(msg.payload?.sdp, incomingFrom)
      return
     }
     // 已接听:直接 answer
     await answerOffer(msg.payload?.sdp, incomingFrom)
     return
   }
   if (t === 'answer') {
     log('✅ 收到 answer,通话建立')
     if (!pc) createPC()
     await pc.setRemoteDescription(new RTCSessionDescription(msg.payload?.sdp))
     await flushCandidates()
     state.value = 'in-call'
     startTimer()
     return
      log('✅ 收到 answer,通话建立')
      if (!pc) createPC()
      await pc.setRemoteDescription(new RTCSessionDescription(msg.payload?.sdp))
      await flushCandidates()
      state.value = 'in-call'
      startTimer()
      return
   }
   if (t === 'candidate') {
     const c = msg.payload?.candidate
     if (!c) return
     if (pc && pc.remoteDescription) {
      await pc.addIceCandidate(new RTCIceCandidate(c))
     } else {
      pendingCandidates.push(c)
     }
     return
      const c = msg.payload?.candidate
      if (!c) return
      if (pc && pc.remoteDescription) {
         await pc.addIceCandidate(new RTCIceCandidate(c))
      } else {
         pendingCandidates.push(c)
      }
      return
   }
   if (t === 'hangup') {
     log('📴 对方挂断')
     cleanup(false)
     return
      log('📴 对方挂断')
      cleanup(false)
      return
   }
   if (t === 'pong') {
     // 心跳响应
     // log('pong')
     return
      // 心跳响应
      // log('pong')
      return
   }
  }
  // 暂存 offer(用于“先点接听再 answer”)
  let lastOfferSdp = null
  /* ---------------- 业务操作 ---------------- */
  function connectWS() {
}
// 暂存 offer(用于“先点接听再 answer”)
let lastOfferSdp = null
/* ---------------- 业务操作 ---------------- */
function connectWS() {
   // 确保获取最新的用户ID
   uid.value = getUserIdFromStorage()
   if (ws) ws.close()
   ws = new WebSocket(WS_BASE + encodeURIComponent(uid.value))
   ws.onopen = () => {
     connected.value = true
     log('WS 已连接 userId=', uid.value)
     startPing()
      connected.value = true
      log('WS 已连接 userId=', uid.value)
      startPing()
   }
   ws.onclose = () => {
     connected.value = false
     log('WS 已关闭')
     stopPing()
      connected.value = false
      log('WS 已关闭')
      stopPing()
   }
   ws.onerror = (e) => log('WS error', e)
   ws.onmessage = (e) => {
     const msg = JSON.parse(e.data)
     log('收到信令', msg)
     onSignal(msg)
      const msg = JSON.parse(e.data)
      log('收到信令', msg)
      onSignal(msg)
   }
  }
  function requestCall() {
}
function requestCall() {
   state.value = 'calling'
   incomingFrom = null
   acceptedByMe = false
   offeredByPeer = false
   lastOfferSdp = null
   send('call', peerUid.value, null)
   log('➡️ 发起呼叫 call 给', peerUid.value)
  }
  function acceptCall() {
}
function acceptCall() {
   if (!incomingFrom) return
   acceptedByMe = true
   // 停止来电提示
   stopRingtone()
   stopVibration()
   // 先告诉对方我接听了(后端会把双方置忙)
   send('accept', incomingFrom, null)
   log('✅ 已点击接听,发送 accept 给', incomingFrom)
   state.value = 'calling'
   // 如果 offer 已经提前到达(我们暂存了),立刻 answer
   if (lastOfferSdp) {
     answerOffer(lastOfferSdp, incomingFrom)
     lastOfferSdp = null
      answerOffer(lastOfferSdp, incomingFrom)
      lastOfferSdp = null
   }
  }
  function rejectCall() {
}
function rejectCall() {
   if (!incomingFrom) return
   // 停止来电提示
   stopRingtone()
   stopVibration()
   // 你后端没有 reject,用 busy 表示拒绝/不可接听
   send('busy', incomingFrom, null)
   log('❌ 已拒绝来电,发送 busy 给', incomingFrom)
   incomingFrom = null
   acceptedByMe = false
   offeredByPeer = false
   lastOfferSdp = null
   state.value = 'idle'
  }
  async function startOffer() {
   // 返回上一页(uni-app的语音通话列表页面)
   if (window.uni) {
      window.uni.navigateBack()
   } else {
      log('❌ uni对象不存在,无法返回上一页')
   }
}
async function startOffer() {
   if (!pc) createPC()
   // ✅ 不会重复 addTrack
   await ensureLocalTracksAdded()
   const offer = await pc.createOffer()
   await pc.setLocalDescription(offer)
   send('offer', peerUid.value, { sdp: pc.localDescription })
   log('➡️ 已发送 offer 给', peerUid.value)
  }
  async function answerOffer(offerSdp, from) {
}
async function answerOffer(offerSdp, from) {
   if (!offerSdp) {
     log('❌ offer sdp 为空,无法接听')
     return
      log('❌ offer sdp 为空,无法接听')
      return
   }
   if (!pc) createPC()
   await pc.setRemoteDescription(new RTCSessionDescription(offerSdp))
   await flushCandidates()
   await ensureLocalTracksAdded()
   const answer = await pc.createAnswer()
   await pc.setLocalDescription(answer)
   send('answer', from, { sdp: pc.localDescription })
   log('⬅️ 已发送 answer 给', from)
   state.value = 'in-call'
   startTimer()
  }
  function hangup() {
}
function hangup() {
   cleanup(true)
  }
  /* ---------------- 清理 ---------------- */
  function cleanup(sendToPeer) {
}
/* ---------------- 清理 ---------------- */
function cleanup(sendToPeer) {
   try {
     if (sendToPeer) send('hangup', peerUid.value, null)
      if (sendToPeer) send('hangup', peerUid.value, null)
   } catch {}
   stopTimer()
   // 停止来电提示
   stopRingtone()
   stopVibration()
   if (pc) {
     pc.getSenders().forEach(s => s.track && s.track.stop())
     pc.close()
      pc.getSenders().forEach(s => s.track && s.track.stop())
      pc.close()
   }
   if (localStream) {
     localStream.getTracks().forEach(t => t.stop())
      localStream.getTracks().forEach(t => t.stop())
   }
   pc = null
   localStream = null
   pendingCandidates.length = 0
   incomingFrom = null
   acceptedByMe = false
   offeredByPeer = false
   lastOfferSdp = null
   state.value = 'idle'
   // 返回上一页(uni-app的语音通话列表页面)
   if (window.uni) {
      window.uni.navigateBack()
   } else {
      log('❌ uni对象不存在,无法返回上一页')
   }
}
// 定义receiveParameters变量,用于模板中判断是否显示呼叫按钮
const receiveParameters = ref(route.query.contact || null);
onMounted(() => {
   const receiveParameters = route.query.contact
   console.log('接收参数', receiveParameters)
   console.log('收拾收拾',route.query.voiceparams);
   console.log('接收参数', receiveParameters.value)
   // 解析并使用contact参数
   if (receiveParameters.value) {
      try {
         const contact = JSON.parse(decodeURIComponent(receiveParameters.value))
         if (contact.friendId) {
            peerUid.value = contact.friendId
            log('🔗 从contact参数获取peerUid:', contact.friendId)
            log('👤 联系人名称:', contact.friendNickName || '未知联系人')
         }
      } catch (e) {
         log('❌ 解析contact参数失败:', String(e))
      }
   }
   // 解析URL参数
   parseUrlParams()
   // 自动连接WebSocket
   connectWS()
});
// 监听连接状态,当connected变为true且有receiveParameters时自动呼叫
watch(() => connected.value, (newVal) => {
   if (newVal && state.value === 'idle' && receiveParameters.value) {
      log('📞 连接成功且有contact参数,自动发起呼叫');
      requestCall();
   }
});
// 监听连接状态,连接成功后自动呼叫
// watch(() => connected.value, (newVal) => {
//    if (newVal && state.value === 'idle') {
//       requestCall()
//    }
// })
onBeforeUnmount(() => {
   cleanup(false)
   stopPing()
   if (ws) ws.close()
})
</script>
<style scoped lang="scss">
.call-container {
   padding: 20px;
   min-height: 100vh;
   font-family: Arial, sans-serif;
   background-color: #f5f5f5;
   transition: all 0.3s ease;
}
  onBeforeUnmount(() => {
    cleanup(false)
    stopPing()
    if (ws) ws.close()
  })
  </script>
  <style scoped lang="scss">
   .call-container {
      padding: 20px;
      min-height: 100vh;
      font-family: Arial, sans-serif;
   }
/* 来电时的全屏样式 */
.call-container.incoming-call {
   position: fixed;
   top: 0;
   left: 0;
   right: 0;
   bottom: 0;
   z-index: 99999;
   background-color: #ffffff;
   padding: 0;
}
   .content-wrapper {
      display: flex;
      flex-direction: column;
      gap: 15px;
      max-width: 600px;
      margin: 0 auto;
   }
.content-wrapper {
   display: flex;
   flex-direction: column;
   gap: 20px;
   max-width: 600px;
   margin: 0 auto;
   height: 100vh;
   justify-content: center;
   align-items: center;
}
   .input-group {
      display: flex;
      flex-direction: column;
      gap: 10px;
   }
.input-group {
   display: flex;
   flex-direction: column;
   gap: 10px;
}
   .input-item {
      display: flex;
      justify-content: space-between;
      align-items: center;
   }
.input-item {
   display: flex;
   justify-content: space-between;
   align-items: center;
}
   .input-label {
      font-weight: bold;
      color: #333;
   }
.input-label {
   font-weight: bold;
   color: #333;
}
   .input-field {
      width: 120px;
      padding: 8px;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 14px;
   }
.input-field {
   width: 120px;
   padding: 8px;
   border: 1px solid #ddd;
   border-radius: 4px;
   font-size: 14px;
}
   .button-group {
      display: flex;
      gap: 10px;
      flex-wrap: wrap;
      justify-content: center;
   }
.button-group {
   display: flex;
   gap: 50px;
   flex-wrap: wrap;
   justify-content: center;
   margin-top: 80px;
}
   .btn {
      padding: 10px 20px;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 14px;
      transition: background-color 0.3s;
.btn {
   padding: 15px 30px;
   color: white;
   border: none;
   border-radius: 8px;
   cursor: pointer;
   font-size: 16px;
   font-weight: bold;
   transition: all 0.3s ease;
   min-width: 120px;
      &:disabled {
         opacity: 0.6;
         cursor: not-allowed;
      }
   &:disabled {
      opacity: 0.6;
      cursor: not-allowed;
   }
}
   .btn-connect {
      background-color: #4CAF50;
   }
/* 来电时的接听和拒绝按钮样式 */
.btn.incoming-accept, .btn.incoming-reject {
   width: 80px;
   height: 80px;
   border-radius: 50%;
   padding: 0;
   display: flex;
   justify-content: center;
   align-items: center;
   box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
   .btn-call {
      background-color: #2196F3;
   }
.btn.incoming-accept {
   background-color: #4CAF50;
   transform: scale(1.1);
}
   .btn-accept {
      background-color: #4CAF50;
   }
.btn.incoming-reject {
   background-color: #f44336;
}
   .btn-reject {
      background-color: #f44336;
   }
.btn.incoming-accept:active {
   transform: scale(0.95);
}
   .btn-hangup {
      background-color: #9E9E9E;
   }
.btn.incoming-reject:active {
   transform: scale(0.95);
}
   .status-group {
      display: flex;
      flex-direction: column;
      gap: 5px;
   }
.btn-connect {
   background-color: #4CAF50;
}
   .status-text {
      font-size: 16px;
      color: #333;
   }
.btn-call {
   background-color: #2196F3;
}
   .tip-message {
      margin-top: 15px;
      color: #666;
      text-align: center;
   }
.btn-accept {
   background-color: #4CAF50;
}
   .log-container {
      margin-top: 20px;
      max-width: 600px;
      margin-left: auto;
      margin-right: auto;
   }
.btn-reject {
   background-color: #f44336;
}
   .log-box {
      background-color: white;
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 10px;
   }
.btn-hangup {
   background-color: #9E9E9E;
   padding: 15px 40px;
   min-width: 150px;
}
   .log-title {
      font-weight: bold;
      color: #333;
      margin-bottom: 8px;
   }
.status-group {
   display: flex;
   flex-direction: column;
   gap: 10px;
   align-items: center;
}
   .log-content {
      max-height: 200px;
      overflow: auto;
      background-color: #fafafa;
      padding: 10px;
      border-radius: 4px;
      font-size: 12px;
      line-height: 1.4;
   }
.status-text {
   font-size: 18px;
   color: #333;
   text-align: center;
   margin-bottom: 20px;
}
   .hidden-audio {
      display: none;
/* 来电状态样式 */
.status-text.incoming-status {
   font-size: 24px;
   color: #333;
   font-weight: bold;
   animation: pulse 1.5s infinite;
}
.status-text.calling-status {
   font-size: 20px;
   color: #666;
}
/* 呼吸灯动画 */
@keyframes pulse {
   0% {
      opacity: 1;
      transform: scale(1);
   }
   .contactAvatar {
      width: 172rpx;
      height: 172rpx;
      display: flex;
      justify-content: center;
      align-items: center;
      img{
         width: 172rpx;
         height: 172rpx;
      }
   50% {
      opacity: 0.7;
      transform: scale(1.05);
   }
  </style>
   100% {
      opacity: 1;
      transform: scale(1);
   }
}
.tip-message {
   margin-top: 15px;
   color: #666;
   text-align: center;
}
.log-container {
   margin-top: 20px;
   max-width: 600px;
   margin-left: auto;
   margin-right: auto;
}
.log-box {
   background-color: white;
   border: 1px solid #ddd;
   border-radius: 8px;
   padding: 10px;
}
.log-title {
   font-weight: bold;
   color: #333;
   margin-bottom: 8px;
}
.log-content {
   max-height: 200px;
   overflow: auto;
   background-color: #fafafa;
   padding: 10px;
   border-radius: 4px;
   font-size: 12px;
   line-height: 1.4;
}
.hidden-audio {
   display: none;
}
/* 头像样式 */
.contactAvatar {
   width: 180px;
   height: 180px;
   display: flex;
   justify-content: center;
   align-items: center;
   margin-bottom: 40px;
   border-radius: 50%;
   background-color: #f0f0f0;
   overflow: hidden;
   box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
   img {
      width: 100%;
      height: 100%;
      object-fit: cover;
   }
}
/* 来电时的头像放大效果 */
.call-container.incoming-call .contactAvatar {
   width: 220px;
   height: 220px;
   margin-bottom: 60px;
}
</style>