吉安感知网项目-前端
shuishen
2026-02-03 89380e6260a75d1d3b94de687ebcc2f50d50659d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
<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 } from 'vue'
 
/** ✅ 你的 WS 地址前缀(后面拼 userId) */
const WS_BASE = 'wss://域名/ws/chat?userId='
 
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'
}
 
onBeforeUnmount(() => {
    cleanup(false)
    stopPing()
    if (ws) ws.close()
})
</script>