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