吉安感知网项目-前端
张含笑
2026-01-15 a6842ea879721063affc0eb51f75a02b59c7c1f4
feat:语音通话模板
3 files modified
1 files added
1542 ■■■■■ changed files
applications/mobile-web-view/src/appPages/voiceCallDetail/index.vue 1058 ●●●● patch | view | raw | blame | history
uniapps/work-app/src/hooks/useGlobalWS.js 243 ●●●●● patch | view | raw | blame | history
uniapps/work-app/src/pages/login/index.vue 53 ●●●●● patch | view | raw | blame | history
uniapps/work-app/src/utils/websocket.js 188 ●●●●● patch | view | raw | blame | history
applications/mobile-web-view/src/appPages/voiceCallDetail/index.vue
@@ -1,55 +1,55 @@
<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>
      <!--  头像-->
         <div class="contactAvatar">
             <img :src="defaultAvatar" alt=""></img>
         </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>
        </div>
        <div class="button-group">
            <button v-if="false" @click="requestCall" :disabled="!connected || state!=='idle'" 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 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>
<!--      <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="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="contactAvatar">
                <img :src="defaultAvatar" alt=""></img>
            </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>
            </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 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="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>
@@ -60,25 +60,35 @@
/** ✅ 你的 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 = () => {
    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
@@ -115,130 +125,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)
  }
}
  // 来电提示相关
  let ringtoneAudio = null
  let vibrationTimer = null
// 来电提示相关
let ringtoneAudio = null
let vibrationTimer = null
  // 播放来电铃声
  function playRingtone() {
// 播放来电铃声
function playRingtone() {
    // 创建音频对象(使用默认的系统提示音)
    ringtoneAudio = new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQAAAAAAA==');
    ringtoneAudio.loop = true;
    ringtoneAudio.volume = 0.5;
    try {
      ringtoneAudio.play();
      log('🔊 开始播放来电铃声')
        ringtoneAudio.play();
        log('🔊 开始播放来电铃声')
    } catch (e) {
      log('❌ 播放来电铃声失败:', String(e))
        log('❌ 播放来电铃声失败:', String(e))
    }
  }
}
  // 停止来电铃声
  function stopRingtone() {
// 停止来电铃声
function stopRingtone() {
    if (ringtoneAudio) {
      try {
        ringtoneAudio.pause();
        ringtoneAudio.currentTime = 0;
        ringtoneAudio = null;
        log('🔇 停止播放来电铃声')
      } catch (e) {
        log('❌ 停止来电铃声失败:', String(e))
      }
        try {
            ringtoneAudio.pause();
            ringtoneAudio.currentTime = 0;
            ringtoneAudio = null;
            log('🔇 停止播放来电铃声')
        } catch (e) {
            log('❌ 停止来电铃声失败:', String(e))
        }
    }
  }
}
  // 开始震动
  function startVibration() {
// 开始震动
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('ℹ️ 当前浏览器不支持震动功能')
    }
  }
        // 震动模式:震动1秒,暂停0.5秒,重复
        const vibrationPattern = [1000, 500];
  // 停止震动
  function stopVibration() {
        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;
        clearInterval(vibrationTimer);
        vibrationTimer = null;
    }
    if ('vibrate' in navigator) {
      try {
        navigator.vibrate(0); // 停止震动
        log('📳 停止震动提示')
      } catch (e) {
        log('❌ 停止震动失败:', String(e))
      }
        try {
            navigator.vibrate(0); // 停止震动
            log('📳 停止震动提示')
        } catch (e) {
            log('❌ 停止震动失败:', String(e))
        }
    }
  }
  function stopPing() {
}
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' }],
    })
@@ -318,6 +328,8 @@
/* ---------------- 信令处理 ---------------- */
async function onSignal(msg) {
console.log('1cesss',msg);
    if (!msg) return
    const t = (msg.type || '').toString()
@@ -343,11 +355,11 @@
        state.value = 'ringing'
        log('📞 收到来电 call,来自', incomingFrom)
        // 开始来电提示(铃声和震动)
        playRingtone()
        startVibration()
        return
    }
@@ -363,104 +375,107 @@
        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
@@ -476,12 +491,12 @@
    // 如果 offer 已经提前到达(我们暂存了),立刻 answer
    if (lastOfferSdp) {
      answerOffer(lastOfferSdp, incomingFrom)
      lastOfferSdp = null
        answerOffer(lastOfferSdp, incomingFrom)
        lastOfferSdp = null
    }
  }
  function rejectCall() {
}
function rejectCall() {
    if (!incomingFrom) return
    // 停止来电提示
@@ -491,102 +506,117 @@
    // 你后端没有 reject,用 busy 表示拒绝/不可接听
    send('busy', incomingFrom, null)
    log('❌ 已拒绝来电,发送 busy 给', incomingFrom)
    incomingFrom = null
    acceptedByMe = false
    offeredByPeer = false
    lastOfferSdp = null
    state.value = 'idle'
    // 返回上一页(uni-app的语音通话列表页面)
    if (window.uni) {
      window.uni.navigateBack()
        window.uni.navigateBack()
    } else {
      log('❌ uni对象不存在,无法返回上一页')
        log('❌ uni对象不存在,无法返回上一页')
    }
  }
  async function startOffer() {
}
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()
        window.uni.navigateBack()
    } else {
      log('❌ uni对象不存在,无法返回上一页')
        log('❌ uni对象不存在,无法返回上一页')
    }
}
onMounted(() => {
    const receiveParameters = route.query.contact
    console.log('接收参数', receiveParameters)
    // 解析并使用contact参数
    if (receiveParameters) {
        try {
            const contact = JSON.parse(decodeURIComponent(receiveParameters))
            if (contact.friendId) {
                peerUid.value = contact.friendId
                log('🔗 从contact参数获取peerUid:', contact.friendId)
                log('👤 联系人名称:', contact.friendNickName || '未知联系人')
            }
        } catch (e) {
            log('❌ 解析contact参数失败:', String(e))
        }
    }
    // 解析URL参数
    parseUrlParams()
    // 自动连接WebSocket
@@ -594,258 +624,258 @@
})
// 监听连接状态,连接成功后自动呼叫
watch(() => connected.value, (newVal) => {
    if (newVal && state.value === 'idle') {
        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;
        background-color: #f5f5f5;
        transition: all 0.3s ease;
/* 来电时的全屏样式 */
.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: 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-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
}
.input-label {
    font-weight: bold;
    color: #333;
}
.input-field {
    width: 120px;
    padding: 8px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 14px;
}
.button-group {
    display: flex;
    gap: 50px;
    flex-wrap: wrap;
    justify-content: center;
    margin-top: 80px;
}
.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;
    }
}
    /* 来电时的全屏样式 */
    .call-container.incoming-call {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        z-index: 99999;
        background-color: #ffffff;
        padding: 0;
/* 来电时的接听和拒绝按钮样式 */
.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.incoming-accept {
    background-color: #4CAF50;
    transform: scale(1.1);
}
.btn.incoming-reject {
    background-color: #f44336;
}
.btn.incoming-accept:active {
    transform: scale(0.95);
}
.btn.incoming-reject:active {
    transform: scale(0.95);
}
.btn-connect {
    background-color: #4CAF50;
}
.btn-call {
    background-color: #2196F3;
}
.btn-accept {
    background-color: #4CAF50;
}
.btn-reject {
    background-color: #f44336;
}
.btn-hangup {
    background-color: #9E9E9E;
    padding: 15px 40px;
    min-width: 150px;
}
.status-group {
    display: flex;
    flex-direction: column;
    gap: 10px;
    align-items: center;
}
.status-text {
    font-size: 18px;
    color: #333;
    text-align: center;
    margin-bottom: 20px;
}
/* 来电状态样式 */
.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);
    }
    .content-wrapper {
        display: flex;
        flex-direction: column;
        gap: 20px;
        max-width: 600px;
        margin: 0 auto;
        height: 100vh;
        justify-content: center;
        align-items: center;
    50% {
        opacity: 0.7;
        transform: scale(1.05);
    }
    .input-group {
        display: flex;
        flex-direction: column;
        gap: 10px;
    100% {
        opacity: 1;
        transform: scale(1);
    }
}
    .input-item {
        display: flex;
        justify-content: space-between;
        align-items: center;
.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;
    }
}
    .input-label {
        font-weight: bold;
        color: #333;
    }
    .input-field {
        width: 120px;
        padding: 8px;
        border: 1px solid #ddd;
        border-radius: 4px;
        font-size: 14px;
    }
    .button-group {
        display: flex;
        gap: 50px;
        flex-wrap: wrap;
        justify-content: center;
        margin-top: 80px;
    }
    .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;
        }
    }
    /* 来电时的接听和拒绝按钮样式 */
    .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.incoming-accept {
        background-color: #4CAF50;
        transform: scale(1.1);
    }
    .btn.incoming-reject {
        background-color: #f44336;
    }
    .btn.incoming-accept:active {
        transform: scale(0.95);
    }
    .btn.incoming-reject:active {
        transform: scale(0.95);
    }
    .btn-connect {
        background-color: #4CAF50;
    }
    .btn-call {
        background-color: #2196F3;
    }
    .btn-accept {
        background-color: #4CAF50;
    }
    .btn-reject {
        background-color: #f44336;
    }
    .btn-hangup {
        background-color: #9E9E9E;
        padding: 15px 40px;
        min-width: 150px;
    }
    .status-group {
        display: flex;
        flex-direction: column;
        gap: 10px;
        align-items: center;
    }
    .status-text {
        font-size: 18px;
        color: #333;
        text-align: center;
        margin-bottom: 20px;
    }
    /* 来电状态样式 */
    .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);
        }
        50% {
            opacity: 0.7;
            transform: scale(1.05);
        }
        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>
/* 来电时的头像放大效果 */
.call-container.incoming-call .contactAvatar {
    width: 220px;
    height: 220px;
    margin-bottom: 60px;
}
</style>
uniapps/work-app/src/hooks/useGlobalWS.js
@@ -1,104 +1,167 @@
import {useUserStore} from "@/store/index.js";
import {getEnvObj} from "@/utils/index.js";
// import {enterRoom} from "@/utils/voiceCallByTX/index.js";
import { useUserStore } from "@/store/index.js";
import { ref, computed, watch } from "vue";
import useAppStore from "../store/modules/app/index.js";
let socketTask = null
import websocketService from "@/utils/websocket.js";
export function useGlobalWS() {
    const userStore = useUserStore();
  const appStore = useAppStore();
    const userId = computed(() => userStore?.userInfo?.user_id)
    const access_token = computed(() => userStore?.userInfo?.access_token)
    const {VITE_APP_WS_API_URL} = getEnvObj()
  // 设置默认用户ID为3
  const defaultUserId = '3';
  // WebSocket基础URL
  const WS_BASE = 'wss://wrj.shuixiongit.com/ws/chat?userId=';
    // 消息处理
    function messageHandler(payload) {
        switch (payload.biz_code) {
            case 'JOB_ISREFRESH':
        appStore.setJobUpdateKeyAdd()
                break
            case 'DEVICE_ISREFRESH':
        appStore.setDeviceUpdateKeyAdd()
                break
            case 'DOWNLOAD_PROGRESS':
                break
            case 'LOGOUT_USER':
                userStore.setUserInfo(null)
                uni.reLaunch({
                    url: '/pages/login/index'
                })
                break
            case 'VoiceCall':
                // enterRoom(payload, userId.value)
                break
            default:
                break;
        }
    }
  function messageHandler(payload) {
    console.log('🌐 全局WebSocket收到消息111111111111111111111:', payload)
    // 先尝试直接处理消息(适用于mobile-web-view的voiceCallDetail页面的消息格式)
    const t = (payload.type || '').toString()
    const bizCode = payload.biz_code || ''
    // 关闭ws
    function closeWS() {
        socketTask?.close({
            success: () => {
                console.log('ws关闭连接');
            },
        })
    }
    console.log('📋 消息分析:type=' + t + ', biz_code=' + bizCode)
    // 初始化ws
    function initWS() {
        // 关闭,再连接ws
        closeWS()
        if (!access_token.value) return
        const url = VITE_APP_WS_API_URL
            + `?x-auth-token=${encodeURI(access_token?.value)}`
            + `&model_type=3&workspace-id=${userId.value}`
        // 创建连接
        socketTask = uni.connectSocket({
            url: url,
            success: () => {
                console.log('ws连接成功');
            },
            fail: (err) => {
                console.error('ws连接失败:', err);
            }
        });
        // 消息监听
        socketTask.onMessage((result) => {
            messageHandler(JSON.parse(result.data))
        })
    //==================================
    // 监听连接打开
    socketTask.onOpen((res) => {
      console.log('✅ WebSocket连接已建立')
      // reconnectAttempts = 0 // 连接成功后重置重连次数
      // 可以在这里发送心跳或订阅消息
      // startHeartbeat()
    })
    // 监听连接关闭
    socketTask.onClose((res) => {
      console.log(`WebSocket连接关闭,代码: ${res.code}, 原因: ${res.reason}`)
    // 处理语音通话请求
    if (t === 'call') {
      console.log('📞 全局收到来电 call,来自', payload.from)
      // 构建来电参数
      const callParams = {
        peerUid: payload.from,
        from: payload.from,
        type: 'incoming'
      };
      // 转义参数以便在URL中传递
      const encodedParams = encodeURIComponent(JSON.stringify(callParams));
      // 跳转到mobile-web-view应用中的语音通话页面
      const voiceCallUrl = `http://localhost:9527/work-app/#/subPackages/voiceCallDetail/index?params=${encodedParams}`;
      console.log('🔗 准备导航到:', voiceCallUrl)
      // 根据不同的关闭代码处理
      if (res.code === 1000) { // 正常关闭
        console.log('连接正常关闭')
      } else if (res.code === 1006) { // 异常关闭
        console.log('连接异常关闭,尝试重连...')
      } else if (res.code === 1011) { // 服务器内部错误
        console.log('服务器内部错误(1011),延迟重连...')
      } else {
        console.log('其他原因关闭,尝试重连...')
      try {
        // 优先使用uni-app的导航API(适用于uni-app环境)
        if (typeof uni !== 'undefined' && uni.navigateTo) {
          console.log('使用uni-app导航API跳转到语音通话页面');
          // 检查是否是跳转到应用内页面
          if (voiceCallUrl.includes('/subPackages/voiceCallDetail/')) {
            // 应用内页面跳转
            uni.navigateTo({
              url: voiceCallUrl.replace('http://localhost:9527/work-app/#', ''),
              success: () => {
                console.log('✅ 应用内导航成功!');
              },
              fail: (err) => {
                console.error('❌ 应用内导航失败:', err);
                // 如果应用内导航失败,尝试使用外部跳转
                fallbackToExternalNavigation();
              }
            });
          } else {
            // 外部页面跳转
            fallbackToExternalNavigation();
          }
        }
        // 否则尝试使用window.location跳转(适用于Web环境)
        else if (typeof window !== 'undefined' && window.location) {
          console.log('使用window.location跳转到语音通话页面');
          window.location.href = voiceCallUrl;
        } else {
          console.error('无法跳转到语音通话页面:当前环境不支持导航');
        }
      } catch (error) {
        console.error('跳转到语音通话页面失败:', error);
        // 尝试使用外部跳转作为最后的补救措施
        fallbackToExternalNavigation();
      }
    })
      // 外部页面跳转的回调函数
      function fallbackToExternalNavigation() {
        console.log('尝试使用外部导航跳转到语音通话页面');
        try {
          // 使用uni-app的外部链接跳转API
          if (typeof uni !== 'undefined' && uni.navigateToWebview) {
            // 这是一个示例,具体API可能需要根据uni-app版本和配置调整
            uni.navigateToWebview({
              url: voiceCallUrl,
              success: () => {
                console.log('✅ 外部Webview导航成功!');
              },
              fail: (err) => {
                console.error('❌ 外部Webview导航失败:', err);
                // 如果还是失败,尝试使用浏览器打开
                if (typeof plus !== 'undefined' && plus.runtime) {
                  plus.runtime.openURL(voiceCallUrl);
                } else {
                  console.error('❌ 所有导航方式都失败了');
                }
              }
            });
          }
          // 或者使用plus.runtime.openURL(适用于App环境)
          else if (typeof plus !== 'undefined' && plus.runtime) {
            plus.runtime.openURL(voiceCallUrl);
            console.log('尝试使用plus.runtime.openURL打开外部链接');
          } else {
            console.error('无法跳转到外部页面:当前环境不支持外部导航');
          }
        } catch (fallbackError) {
          console.error('外部导航尝试失败:', fallbackError);
        }
      }
      return
    }
    // 监听错误
    socketTask.onError((err) => {
      console.error('WebSocket发生错误:', err)
    })
    }
    // 然后处理biz_code格式的消息
    switch (bizCode) {
      case 'JOB_ISREFRESH':
        appStore.setJobUpdateKeyAdd()
        break
      case 'DEVICE_ISREFRESH':
        appStore.setDeviceUpdateKeyAdd()
        break
      case 'DOWNLOAD_PROGRESS':
        break
      case 'LOGOUT_USER':
        userStore.setUserInfo(null)
        uni.reLaunch({
          url: '/pages/login/index'
        })
        break
      case 'VoiceCall':
        // enterRoom(payload, userId.value)
        break
      default:
        // 记录未处理的消息
        console.log('未处理的WebSocket消息:', payload)
        break;
    }
  }
    // watch(access_token, initWS, {immediate: true})
  // 初始化WebSocket连接
  function initWS() {
    try {
      // 使用默认用户ID初始化WebSocket连接
      websocketService.init(defaultUserId, WS_BASE);
      console.log('🌐 全局WebSocket初始化成功,默认用户ID:', defaultUserId);
      // 设置全局消息处理回调
      websocketService.setOnMessageCallback(messageHandler);
      console.log('✅ 全局WebSocket消息处理器已设置');
    } catch (error) {
      console.error('❌ 全局WebSocket初始化失败:', error);
    }
  }
  // 监听用户信息变化,初始化WebSocket
  watch(
    () => userStore.userInfo,
    (newUserInfo) => {
      console.log('👤 用户信息变化,尝试初始化WebSocket');
      initWS();
    },
    { immediate: true }
  )
}
uniapps/work-app/src/pages/login/index.vue
@@ -45,6 +45,7 @@
import md5 from "js-md5";
import { loginByUsername } from "@/api/user/index.js";
import { useUserStore } from "@/store/index.js";
import websocketService from "@/utils/websocket.js";
import { HOME_PATH, LOGIN_PATH, removeQueryString } from "@/router";
@@ -134,7 +135,59 @@
      userInfo.key,
      userInfo.code
    );
    console.log('登录成功,用户信息:', res.data.data);
    userStore.setUserInfo(res.data.data);
    // 初始化WebSocket连接
    try {
      const wsUrl = 'wss://wrj.shuixiongit.com/ws/chat?userId=';
      // 设置默认用户ID为3
      const defaultUserId = '3';
      // 使用默认用户ID初始化WebSocket连接
      websocketService.init(defaultUserId, wsUrl);
      console.log('登录成功,WebSocket已初始化,默认用户ID:', defaultUserId);
      // 设置来电消息回调
      websocketService.setOnMessageCallback((message) => {
        if (message.type === 'call') {
          console.log('收到来电,准备跳转到语音通话页面');
          // 构建来电参数
          const callParams = {
            peerUid: message.from,
            from: message.from,
            type: 'incoming'
          };
          // 转义参数以便在URL中传递
          const encodedParams = encodeURIComponent(JSON.stringify(callParams));
          try {
            // 优先使用uni-app的导航API(适用于同应用内跳转)
            if (typeof uni !== 'undefined' && uni.navigateTo) {
              console.log('使用uni-app导航API跳转到语音通话页面');
              uni.navigateTo({
                url: `/subPackages/voiceCallDetail/index?params=${encodedParams}`
              });
            }
            // 否则使用window.location跳转(适用于Web环境)
            else if (typeof window !== 'undefined' && window.location) {
              console.log('使用window.location跳转到语音通话页面');
              // 注意:实际部署时需要使用正确的URL
              const voiceCallUrl = `http://localhost:8080/appPages/voiceCallDetail/index.html?params=${encodedParams}`;
              window.location.href = voiceCallUrl;
            } else {
              console.error('无法跳转到语音通话页面:当前环境不支持导航');
            }
          } catch (error) {
            console.error('跳转到语音通话页面失败:', error);
          }
        }
      });
    } catch (error) {
      console.error('WebSocket初始化失败:', error);
    }
    uni.reLaunch({
      url: "/pages/work/index",
    });
uniapps/work-app/src/utils/websocket.js
New file
@@ -0,0 +1,188 @@
// WebSocket服务管理
class WebSocketService {
  constructor() {
    this.socketTask = null
    this.connected = false
    this.url = ''
    this.userId = ''
    this.onMessageCallback = null
    this.onOpenCallback = null
    this.onCloseCallback = null
    this.onErrorCallback = null
    this.pingTimer = null
  }
  // 初始化WebSocket连接
  init(userId, url) {
    if (!url) {
      console.error('WebSocket初始化失败:缺少url')
      return
    }
    this.userId = userId || ''
    this.url = url + encodeURIComponent(this.userId)
    this.connect()
  }
  // 建立WebSocket连接
  connect() {
    try {
      // 关闭现有连接
      this.close()
      // 使用uni-app的WebSocket API
      this.socketTask = uni.connectSocket({
        url: this.url,
        success: () => {
          console.log('WebSocket连接已请求')
        },
        fail: (error) => {
          console.error('WebSocket连接请求失败:', error)
          if (this.onErrorCallback) {
            this.onErrorCallback(error)
          }
        }
      })
      // 监听连接打开
      this.socketTask.onOpen(() => {
        this.connected = true
        console.log('WebSocket已连接,userId:', this.userId)
        this.startPing()
        if (this.onOpenCallback) {
          this.onOpenCallback()
        }
      })
      // 监听连接关闭
      this.socketTask.onClose(() => {
        this.connected = false
        console.log('WebSocket已关闭')
        this.stopPing()
        if (this.onCloseCallback) {
          this.onCloseCallback()
        }
      })
      // 监听连接错误
      this.socketTask.onError((error) => {
        console.error('WebSocket错误:', error)
        this.connected = false
        this.stopPing()
        if (this.onErrorCallback) {
          this.onErrorCallback(error)
        }
      })
      // 监听消息
      this.socketTask.onMessage((res) => {
        try {
          const message = JSON.parse(res.data)
          console.log('收到WebSocket消息:', message)
          if (this.onMessageCallback) {
            this.onMessageCallback(message)
          }
        } catch (error) {
          console.error('解析WebSocket消息失败:', error)
        }
      })
    } catch (error) {
      console.error('WebSocket连接失败:', error)
      if (this.onErrorCallback) {
        this.onErrorCallback(error)
      }
    }
  }
  // 发送消息
  send(type, to, payload) {
    if (!type || !this.socketTask || !this.connected) {
      console.error('WebSocket发送失败:连接未建立')
      return
    }
    const message = {
      type,
      from: String(this.userId),
      to: String(to),
      payload: payload || null,
      timestamp: Date.now()
    }
    this.socketTask.send({
      data: JSON.stringify(message),
      success: () => {
        console.log('WebSocket消息发送成功:', message)
      },
      fail: (error) => {
        console.error('WebSocket消息发送失败:', error)
      }
    })
  }
  // 开始心跳检测
  startPing() {
    this.stopPing()
    // 每25秒发送一次心跳
    this.pingTimer = setInterval(() => {
      if (this.connected) {
        this.send('ping', 'system')
      }
    }, 25000)
  }
  // 停止心跳检测
  stopPing() {
    if (this.pingTimer) {
      clearInterval(this.pingTimer)
      this.pingTimer = null
    }
  }
  // 关闭连接
  close() {
    if (this.socketTask) {
      this.socketTask.close({
        code: 1000,
        reason: '正常关闭',
        success: () => {
          console.log('WebSocket已关闭')
        },
        fail: (error) => {
          console.error('WebSocket关闭失败:', error)
        }
      })
      this.socketTask = null
    }
    this.stopPing()
    this.connected = false
  }
  // 设置消息回调
  setOnMessageCallback(callback) {
    this.onMessageCallback = callback
  }
  // 设置连接打开回调
  setOnOpenCallback(callback) {
    this.onOpenCallback = callback
  }
  // 设置连接关闭回调
  setOnCloseCallback(callback) {
    this.onCloseCallback = callback
  }
  // 设置错误回调
  setOnErrorCallback(callback) {
    this.onErrorCallback = callback
  }
  // 获取连接状态
  getConnected() {
    return this.connected
  }
}
// 导出单例实例
export default new WebSocketService()