From d3950c44b2be58089fcf5193e1799cc5d6a3bed8 Mon Sep 17 00:00:00 2001
From: 张含笑 <zhx18749296735@163.com>
Date: Tue, 13 Jan 2026 14:54:43 +0800
Subject: [PATCH] feat:通话demo

---
 applications/mobile-web-view/src/appPages/voiceCallDetail/index.vue |  461 +++++++++++++++++++++++++++++++++++++++++++++++++++
 applications/mobile-web-view/src/router/page/index.js               |    6 
 uniapps/work-app/src/subPackages/voiceCallDetail/index.vue          |   51 +++++
 uniapps/work-app/src/pages/voiceCall/index.vue                      |   11 
 4 files changed, 525 insertions(+), 4 deletions(-)

diff --git a/applications/mobile-web-view/src/appPages/voiceCallDetail/index.vue b/applications/mobile-web-view/src/appPages/voiceCallDetail/index.vue
new file mode 100644
index 0000000..9bd9778
--- /dev/null
+++ b/applications/mobile-web-view/src/appPages/voiceCallDetail/index.vue
@@ -0,0 +1,461 @@
+<template>
+	<div style="padding:16px">
+	  <h2>1v1 语音通话 WebRTC Demo(对齐后端 ChatMessage,JS)</h2>
+  
+	  <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
+		<span>我的 userId:</span>
+		<input v-model="uid" style="width:120px" />
+  
+		<span>对方 userId:</span>
+		<input v-model="peerUid" style="width:120px" />
+  
+		<button @click="connectWS" :disabled="connected">连接WS</button>
+  
+		<button @click="requestCall" :disabled="!connected || state!=='idle'">呼叫</button>
+  
+		<button v-if="state==='ringing'" @click="acceptCall">接听</button>
+		<button v-if="state==='ringing'" @click="rejectCall">拒绝</button>
+  
+		<button @click="hangup" :disabled="!connected || (state!=='calling' && state!=='in-call')">挂断</button>
+  
+		<strong>状态:{{ state }}</strong>
+		<strong v-if="state==='in-call'">通话时长:{{ durationText }}</strong>
+	  </div>
+  
+	  <div style="margin-top:8px;color:#666">
+		<small>提示:若 getUserMedia 不可用,请用 https 或 localhost 访问页面。</small>
+	  </div>
+  
+	  <pre style="margin-top:12px;max-height:280px;overflow:auto;">{{ logText }}</pre>
+  
+	  <audio ref="remoteAudio" autoplay></audio>
+	</div>
+  </template>
+  
+  <script setup>
+import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
+  
+  /** ✅ 你的 WS 地址前缀(后面拼 userId) */
+  const WS_BASE = 'ws://192.168.1.33:8081/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))
+      }
+    }
+  }
+
+  const uid = ref('2')
+  const peerUid = ref('3')
+
+  const connected = ref(false)
+  const state = ref('idle') // idle | calling | ringing | in-call
+  const logText = ref('')
+  const remoteAudio = ref(null)
+  
+  let ws = null
+  let pc = null
+  let localStream = null
+  const pendingCandidates = []
+  
+  // 来电暂存
+  let incomingFrom = null
+  let acceptedByMe = false // 我是否已经点了接听
+  let offeredByPeer = false // 是否已收到对方 offer(用于流程判断)
+  
+  // 计时
+  let timer = null
+  const seconds = ref(0)
+  const durationText = computed(() => {
+	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() {
+	stopTimer()
+	seconds.value = 0
+	timer = setInterval(() => seconds.value++, 1000)
+  }
+  function stopTimer() {
+	if (timer) clearInterval(timer)
+	timer = null
+  }
+  
+  // 心跳
+  let pingTimer = null
+  function startPing() {
+	stopPing()
+	// 每 25 秒发一次 ping(你后端有 PING/PONG)
+	pingTimer = setInterval(() => {
+	  if (connected.value) send('ping', 'system', null)
+	}, 25000)
+  }
+  function stopPing() {
+	if (pingTimer) clearInterval(pingTimer)
+	pingTimer = null
+  }
+  
+  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) {
+	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(),
+	}
+	ws.send(JSON.stringify(msg))
+  }
+  
+  /* ---------------- WebRTC ---------------- */
+  
+  function createPC() {
+	pc = new RTCPeerConnection({
+	  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
+	})
+  
+	pc.onicecandidate = (e) => {
+	  if (e.candidate) {
+		send('candidate', peerUid.value, { candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate })
+	  }
+	}
+  
+	pc.ontrack = (e) => {
+	  log('✅ 收到远端音频流')
+	  const stream = e.streams[0]
+	  if (remoteAudio.value) {
+		remoteAudio.value.srcObject = stream
+		remoteAudio.value.play().catch(() => {})
+	  }
+	}
+  
+	pc.onconnectionstatechange = () => {
+	  log('pc.connectionState =', pc.connectionState)
+	}
+  }
+  
+  async function getLocalAudio() {
+	if (localStream) return localStream
+  
+	if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+	  log('❌ 当前环境不支持 getUserMedia:请使用 https 或 localhost 打开页面')
+	  throw new Error('getUserMedia not available')
+	}
+  
+	try {
+	  localStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+	  log('✅ 已获取本地麦克风')
+	  return localStream
+	} catch (e) {
+	  log('❌ 获取麦克风失败:', String(e))
+	  throw e
+	}
+  }
+  
+  /** ✅ 关键:只 addTrack 一次,避免 “sender already exists” */
+  async function ensureLocalTracksAdded() {
+	if (!pc) createPC()
+	const stream = await getLocalAudio()
+  
+	const existingTrackIds = new Set(
+	  pc.getSenders().map(s => (s.track ? s.track.id : null)).filter(Boolean)
+	)
+  
+	stream.getTracks().forEach(track => {
+	  if (!existingTrackIds.has(track.id)) {
+		pc.addTrack(track, stream)
+		log('addTrack ok:', track.id)
+	  } else {
+		// log('skip addTrack:', track.id)
+	  }
+	})
+  
+	return stream
+  }
+  
+  async function flushCandidates() {
+	if (!pc || !pc.remoteDescription) return
+	while (pendingCandidates.length) {
+	  const c = pendingCandidates.shift()
+	  try {
+		await pc.addIceCandidate(new RTCIceCandidate(c))
+	  } catch (e) {
+		log('addIceCandidate failed:', String(e))
+	  }
+	}
+  }
+  
+  /* ---------------- 信令处理 ---------------- */
+  
+  async function onSignal(msg) {
+	if (!msg) return
+	const t = (msg.type || '').toString()
+  
+	// ✅ 忽略后端回显给自己的消息(你的后端会回显 sender)
+	if (msg.from && String(msg.from) === String(uid.value)) {
+	  return
+	}
+  
+	// system 消息:login/pong/busy 仅记录
+	if (msg.from === 'system') {
+	  log('system msg:', msg)
+	  return
+	}
+  
+	if (!t) return
+  
+	if (t === 'call') {
+	  // 收到来电:弹接听按钮
+	  incomingFrom = String(msg.from)
+	  peerUid.value = incomingFrom
+	  acceptedByMe = false
+	  offeredByPeer = false
+  
+	  state.value = 'ringing'
+	  log('📞 收到来电 call,来自', incomingFrom)
+	  return
+	}
+  
+	if (t === 'accept') {
+	  // 主叫收到 accept:开始 offer
+	  log('✅ 对方接听 accept,开始建立连接(offer)')
+	  await startOffer()
+	  return
+	}
+  
+	if (t === 'busy') {
+	  log('❌ 对方忙线/拒绝')
+	  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
+		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
+	}
+  
+	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
+	}
+  
+	if (t === 'hangup') {
+	  log('📴 对方挂断')
+	  cleanup(false)
+	  return
+	}
+  
+	if (t === 'pong') {
+	  // 心跳响应
+	  // log('pong')
+	  return
+	}
+  }
+  
+  // 暂存 offer(用于“先点接听再 answer”)
+  let lastOfferSdp = null
+  
+  /* ---------------- 业务操作 ---------------- */
+  
+  function connectWS() {
+	if (ws) ws.close()
+  
+	ws = new WebSocket(WS_BASE + encodeURIComponent(uid.value))
+  
+	ws.onopen = () => {
+	  connected.value = true
+	  log('WS 已连接 userId=', uid.value)
+	  startPing()
+	}
+  
+	ws.onclose = () => {
+	  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)
+	}
+  }
+  
+  function requestCall() {
+	state.value = 'calling'
+	incomingFrom = null
+	acceptedByMe = false
+	offeredByPeer = false
+	lastOfferSdp = null
+  
+	send('call', peerUid.value, null)
+	log('➡️ 发起呼叫 call 给', peerUid.value)
+  }
+  
+  function acceptCall() {
+	if (!incomingFrom) return
+	acceptedByMe = true
+  
+	// 先告诉对方我接听了(后端会把双方置忙)
+	send('accept', incomingFrom, null)
+	log('✅ 已点击接听,发送 accept 给', incomingFrom)
+  
+	state.value = 'calling'
+  
+	// 如果 offer 已经提前到达(我们暂存了),立刻 answer
+	if (lastOfferSdp) {
+	  answerOffer(lastOfferSdp, incomingFrom)
+	  lastOfferSdp = null
+	}
+  }
+  
+  function rejectCall() {
+	if (!incomingFrom) return
+  
+	// 你后端没有 reject,用 busy 表示拒绝/不可接听
+	send('busy', incomingFrom, null)
+	log('❌ 已拒绝来电,发送 busy 给', incomingFrom)
+  
+	incomingFrom = null
+	acceptedByMe = false
+	offeredByPeer = false
+	lastOfferSdp = null
+	state.value = 'idle'
+  }
+  
+  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) {
+	if (!offerSdp) {
+	  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() {
+	cleanup(true)
+  }
+  
+  /* ---------------- 清理 ---------------- */
+  
+  function cleanup(sendToPeer) {
+	try {
+	  if (sendToPeer) send('hangup', peerUid.value, null)
+	} catch {}
+  
+	stopTimer()
+  
+	if (pc) {
+	  pc.getSenders().forEach(s => s.track && s.track.stop())
+	  pc.close()
+	}
+	if (localStream) {
+	  localStream.getTracks().forEach(t => t.stop())
+	}
+  
+	pc = null
+	localStream = null
+	pendingCandidates.length = 0
+  
+	incomingFrom = null
+	acceptedByMe = false
+	offeredByPeer = false
+	lastOfferSdp = null
+  
+	state.value = 'idle'
+  }
+  
+  onMounted(() => {
+    // 解析URL参数
+    parseUrlParams()
+  })
+
+  onBeforeUnmount(() => {
+    cleanup(false)
+    stopPing()
+    if (ws) ws.close()
+  })
+  </script>
+  
\ No newline at end of file
diff --git a/applications/mobile-web-view/src/router/page/index.js b/applications/mobile-web-view/src/router/page/index.js
index ff11b1a..2008d2d 100644
--- a/applications/mobile-web-view/src/router/page/index.js
+++ b/applications/mobile-web-view/src/router/page/index.js
@@ -104,6 +104,12 @@
 				component: () => import('@/appPages/inspectionTask/execution/index.vue'),
 				meta: { title: '任务详情'},
 			},
+			{
+				path: 'voiceCallDetail',
+				name: '语音通话',
+				component: () => import('@/appPages/voiceCallDetail/index.vue'),
+				meta: { title: '语音通话'},
+			},
 		],
 	},
 
diff --git a/uniapps/work-app/src/pages/voiceCall/index.vue b/uniapps/work-app/src/pages/voiceCall/index.vue
index 66b7a11..0eb1c1b 100644
--- a/uniapps/work-app/src/pages/voiceCall/index.vue
+++ b/uniapps/work-app/src/pages/voiceCall/index.vue
@@ -35,7 +35,7 @@
       <!-- 加载提示 -->
       <div class="loadingMore">
         <text v-if="loading">加载中...</text>
-        <text v-else-if="!hasMore && contacts.length > 0">没有更多数据了</text>
+
       </div>
     </div>
   </div>
@@ -123,9 +123,12 @@
   const contactName = contact.friendNickName || '未知联系人'
   console.log('拨打电话给', contactName)
   // 这里可以添加实际的拨打电话逻辑
-  uni.showToast({
-    title: `正在拨打${contactName}的电话`,
-    icon: 'none'
+  // uni.showToast({
+  //   title: `正在拨打${contactName}的电话`,
+  //   icon: 'none'
+  // })
+  uni.navigateTo({
+    url: `/subPackages/voiceCallDetail/index`,
   })
 }
 
diff --git a/uniapps/work-app/src/subPackages/voiceCallDetail/index.vue b/uniapps/work-app/src/subPackages/voiceCallDetail/index.vue
new file mode 100644
index 0000000..2340c32
--- /dev/null
+++ b/uniapps/work-app/src/subPackages/voiceCallDetail/index.vue
@@ -0,0 +1,51 @@
+<!-- 登录页 -->
+<template>
+   <!-- <web-view :src="`${viewUrl}`" @message="viewMessage" :allow="allow"/> -->
+   <WebViewPlus
+     ref="sWebViewRef"
+     :src="`${viewUrl}`"
+     @webMessage="onPostMessage"
+   />
+</template>
+
+<script setup>
+import { getWebViewUrl } from "@/utils/index.js";
+import { onLoad } from "@dcloudio/uni-app";
+const sWebViewRef = ref(null);
+const viewUrl = ref("");
+onLoad((options) => {
+	  // const currentItem = options.currentItem;
+	  // viewUrl.value = getWebViewUrl("/mapWork", { currentItem: currentItem });
+	  	  viewUrl.value = getWebViewUrl("/voiceCallDetail");
+	});
+// #ifdef APP-PLUS
+function requestAndroidMicPermission() {
+  return new Promise((resolve) => {
+    const main = plus.android.runtimeMainActivity()
+    const Build = plus.android.importClass('android.os.Build')
+    if (Build.VERSION.SDK_INT < 23) return resolve(true)
+
+    const Manifest = plus.android.importClass('android.Manifest')
+    const ActivityCompat = plus.android.importClass('androidx.core.app.ActivityCompat')
+
+    const permission = Manifest.permission.RECORD_AUDIO
+    ActivityCompat.requestPermissions(main, [permission], 1001)
+
+    // 简化:延迟检查一次(更严谨可写原生回调插件)
+    setTimeout(() => {
+      const PackageManager = plus.android.importClass('android.content.pm.PackageManager')
+      const granted = ActivityCompat.checkSelfPermission(main, permission) === PackageManager.PERMISSION_GRANTED
+      resolve(granted)
+    }, 800)
+  })
+}
+
+
+requestAndroidMicPermission()
+// #endif
+
+</script>
+
+<style lang="scss" scoped>
+
+</style>

--
Gitblit v1.9.3