Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

uni-app集成声网实现用户间视频语音通话完整源码

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
project/
├── common/
│ └── agora.config.js
├── components/
│ ├── Agora-RTC-JS/
│ ├── CallScreen.vue
│ └── CallInvitation.vue
├── utils/
│ ├── CallManager.js
│ ├── SignalingService.js
│ └── TokenManager.js
├── pages/
│ ├── index/index.vue
│ └── call/call.vue
└── manifest.json

源码文件

common/agora.config.js

1
2
3
4
5
6
7
8
9
module.exports = {
// Get your own App ID at https://dashboard.agora.io/
"appId": '你的声网AppID',
// Please refer to https://docs.agora.io/en/Agora%20Platform/token
"token": '',
"channelId": '',
"uid": 0,
"stringUid": ''
}

utils/CallManager.js

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
import RtcEngine from '@/components/Agora-RTC-JS/index'
import { ClientRole, ChannelProfile } from '@/components/Agora-RTC-JS/common/Enums'
import config from '@/common/agora.config'
import permision from "@/js_sdk/wa-permission/permission"

class CallManager {
constructor() {
this.engine = null
this.isInCall = false
this.currentCallId = null
this.remoteUsers = []
this.isVideo = false
this.localMuted = false
this.localVideoEnabled = true
}

async initEngine() {
if (this.engine) return this.engine

try {
this.engine = await RtcEngine.create(config.appId)
this.addEngineListeners()

await this.engine.setChannelProfile(ChannelProfile.Communication)
await this.engine.setClientRole(ClientRole.Broadcaster)

console.log('声网引擎初始化成功')
return this.engine
} catch (error) {
console.error('声网引擎初始化失败:', error)
throw error
}
}

addEngineListeners() {
this.engine.addListener('JoinChannelSuccess', (channel, uid, elapsed) => {
console.log('加入频道成功:', channel, uid)
this.isInCall = true
uni.$emit('callStatusChanged', { status: 'connected', uid })
})

this.engine.addListener('UserJoined', (uid, elapsed) => {
console.log('远端用户加入:', uid)
this.remoteUsers.push(uid)
uni.$emit('remoteUserJoined', { uid })
})

this.engine.addListener('UserOffline', (uid, reason) => {
console.log('远端用户离开:', uid, reason)
this.remoteUsers = this.remoteUsers.filter(id => id !== uid)
uni.$emit('remoteUserLeft', { uid, reason })

if (this.remoteUsers.length === 0) {
this.endCall()
}
})

this.engine.addListener('LeaveChannel', (stats) => {
console.log('离开频道:', stats)
this.isInCall = false
this.currentCallId = null
this.remoteUsers = []
uni.$emit('callStatusChanged', { status: 'ended' })
})

this.engine.addListener('ConnectionStateChanged', (state, reason) => {
console.log('网络状态变化:', state, reason)
uni.$emit('networkStateChanged', { state, reason })
})
}

async startCall(targetUserId, isVideo = false) {
try {
if (!this.engine) {
await this.initEngine()
}

this.isVideo = isVideo

await this.requestPermissions(isVideo)

const callId = `call_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
this.currentCallId = callId

await this.engine.enableAudio()
if (isVideo) {
await this.engine.enableVideo()
await this.engine.startPreview()
}

await this.sendCallInvitation(targetUserId, callId, isVideo)
await this.joinChannel(callId)

return { success: true, callId }
} catch (error) {
console.error('发起通话失败:', error)
return { success: false, error: error.message }
}
}

async acceptCall(callId, isVideo = false) {
try {
if (!this.engine) {
await this.initEngine()
}

this.isVideo = isVideo
this.currentCallId = callId

await this.requestPermissions(isVideo)

await this.engine.enableAudio()
if (isVideo) {
await this.engine.enableVideo()
await this.engine.startPreview()
}

await this.joinChannel(callId)
await this.sendCallResponse(callId, 'accepted')

return { success: true }
} catch (error) {
console.error('接受通话失败:', error)
return { success: false, error: error.message }
}
}

async rejectCall(callId) {
await this.sendCallResponse(callId, 'rejected')
}

async endCall() {
try {
if (this.engine && this.isInCall) {
await this.engine.leaveChannel()
}

if (this.currentCallId) {
await this.sendCallResponse(this.currentCallId, 'ended')
}

this.isInCall = false
this.currentCallId = null
this.remoteUsers = []

return { success: true }
} catch (error) {
console.error('结束通话失败:', error)
return { success: false, error: error.message }
}
}

async joinChannel(channelId) {
const uid = 0
await this.engine.joinChannel(config.token, channelId, null, uid)
}

async requestPermissions(needCamera = false) {
if (uni.getSystemInfoSync().platform === 'android') {
await permision.requestAndroidPermission('android.permission.RECORD_AUDIO')
if (needCamera) {
await permision.requestAndroidPermission('android.permission.CAMERA')
}
}
}

async switchCamera() {
if (this.engine && this.isVideo) {
await this.engine.switchCamera()
}
}

async toggleMute() {
if (this.engine) {
this.localMuted = !this.localMuted
await this.engine.enableLocalAudio(!this.localMuted)
return this.localMuted
}
return false
}

async toggleVideo() {
if (this.engine) {
this.localVideoEnabled = !this.localVideoEnabled
await this.engine.enableLocalVideo(this.localVideoEnabled)
return this.localVideoEnabled
}
return false
}

async sendCallInvitation(targetUserId, callId, isVideo) {
const SignalingService = require('./SignalingService.js').default
const message = {
type: 'call_invitation',
from: getCurrentUserId(),
to: targetUserId,
callId: callId,
isVideo: isVideo,
timestamp: Date.now()
}

await SignalingService.sendMessage(message)
}

async sendCallResponse(callId, response) {
const SignalingService = require('./SignalingService.js').default
const message = {
type: 'call_response',
callId: callId,
response: response,
timestamp: Date.now()
}

await SignalingService.sendMessage(message)
}

async destroy() {
if (this.engine) {
await this.engine.destroy()
this.engine = null
}
}
}

function getCurrentUserId() {
return uni.getStorageSync('userId') || 'user_' + Date.now()
}

export default new CallManager()

utils/SignalingService.js

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
class SignalingService {
constructor() {
this.ws = null
this.isConnected = false
this.reconnectTimer = null
this.userId = null
}

connect(userId, token) {
return new Promise((resolve, reject) => {
this.userId = userId

this.ws = uni.connectSocket({
url: 'wss://your-signaling-server.com/ws',
header: {
'Authorization': `Bearer ${token}`
},
success: () => {
console.log('WebSocket连接成功')
},
fail: (error) => {
console.error('WebSocket连接失败:', error)
reject(error)
}
})

this.ws.onOpen(() => {
this.isConnected = true
this.sendMessage({
type: 'register',
userId: userId
})
resolve()
})

this.ws.onMessage((event) => {
this.handleMessage(JSON.parse(event.data))
})

this.ws.onClose(() => {
this.isConnected = false
this.scheduleReconnect()
})

this.ws.onError((error) => {
console.error('WebSocket错误:', error)
this.isConnected = false
})
})
}

handleMessage(message) {
switch(message.type) {
case 'call_invitation':
this.handleCallInvitation(message)
break
case 'call_response':
this.handleCallResponse(message)
break
case 'call_ended':
this.handleCallEnded(message)
break
}
}

handleCallInvitation(message) {
uni.$emit('callInvitation', {
callId: message.callId,
callerId: message.from,
callerName: message.callerName,
callerAvatar: message.callerAvatar,
isVideo: message.isVideo
})
}

handleCallResponse(message) {
uni.$emit('callResponse', {
callId: message.callId,
response: message.response
})
}

handleCallEnded(message) {
uni.$emit('callEnded', {
callId: message.callId
})
}

sendMessage(message) {
if (this.isConnected && this.ws) {
this.ws.send({
data: JSON.stringify(message)
})
}
}

scheduleReconnect() {
if (this.reconnectTimer) return

this.reconnectTimer = setTimeout(() => {
if (this.userId) {
this.connect(this.userId)
}
this.reconnectTimer = null
}, 3000)
}

disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}

if (this.ws) {
this.ws.close()
this.ws = null
}

this.isConnected = false
}
}

export default new SignalingService()

utils/TokenManager.js

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
class TokenManager {
async getCallToken(channelId, uid) {
try {
const response = await uni.request({
url: 'https://your-api.com/agora/token',
method: 'POST',
data: {
channelId,
uid,
role: 'publisher'
},
header: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
})

return response.data.token
} catch (error) {
throw new Error('获取Token失败')
}
}

getAuthToken() {
return uni.getStorageSync('authToken')
}
}

export default new TokenManager()

components/CallScreen.vue

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
<template>
<view class="call-screen">
<!-- 视频通话界面 -->
<view v-if="isVideo" class="video-container">
<!-- 远端视频 -->
<rtc-surface-view
v-if="remoteUid"
class="remote-video"
:uid="remoteUid"
:zOrderMediaOverlay="false">
</rtc-surface-view>

<!-- 本地视频(小窗) -->
<rtc-surface-view
class="local-video"
:uid="0"
:zOrderMediaOverlay="true">
</rtc-surface-view>
</view>

<!-- 语音通话界面 -->
<view v-else class="audio-container">
<view class="user-avatar">
<image :src="remoteUserAvatar" class="avatar"></image>
<text class="user-name">{{ remoteUserName }}</text>
<text class="call-status">{{ callStatusText }}</text>
</view>
</view>

<!-- 通话状态显示 -->
<view class="call-info">
<text class="duration">{{ formattedDuration }}</text>
<text class="network-status" :class="networkClass">{{ networkStatus }}</text>
</view>

<!-- 控制按钮 -->
<view class="controls">
<button
class="control-btn mute-btn"
:class="{ active: isMuted }"
@click="toggleMute">
<text class="icon">{{ isMuted ? '🔇' : '🎤' }}</text>
</button>

<button v-if="isVideo"
class="control-btn camera-btn"
:class="{ active: !videoEnabled }"
@click="toggleVideo">
<text class="icon">{{ videoEnabled ? '📹' : '📷' }}</text>
</button>

<button v-if="isVideo"
class="control-btn switch-btn"
@click="switchCamera">
<text class="icon">🔄</text>
</button>

<button
class="control-btn end-btn"
@click="endCall">
<text class="icon">📞</text>
</button>
</view>
</view>
</template>

<script>
import CallManager from '@/utils/CallManager'

export default {
name: 'CallScreen',
props: {
callId: String,
isVideo: Boolean,
remoteUserId: String,
remoteUserName: String,
remoteUserAvatar: String
},
data() {
return {
remoteUid: null,
isMuted: false,
videoEnabled: true,
callDuration: 0,
networkStatus: '连接中...',
networkClass: 'connecting',
timer: null
}
},
computed: {
formattedDuration() {
const minutes = Math.floor(this.callDuration / 60)
const seconds = this.callDuration % 60
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
},
callStatusText() {
if (this.remoteUid) {
return '通话中'
}
return '等待对方接听...'
}
},
mounted() {
this.initCallListeners()
this.startDurationTimer()
},
beforeDestroy() {
this.cleanup()
},
methods: {
initCallListeners() {
uni.$on('remoteUserJoined', this.handleRemoteUserJoined)
uni.$on('remoteUserLeft', this.handleRemoteUserLeft)
uni.$on('callStatusChanged', this.handleCallStatusChanged)
uni.$on('networkStateChanged', this.handleNetworkStateChanged)
},

handleRemoteUserJoined(data) {
this.remoteUid = data.uid
this.networkStatus = '通话中'
this.networkClass = 'connected'
},

handleRemoteUserLeft(data) {
this.remoteUid = null
this.endCall()
},

handleCallStatusChanged(data) {
if (data.status === 'ended') {
this.goBack()
}
},

handleNetworkStateChanged(data) {
switch(data.state) {
case 1:
this.networkStatus = '通话中'
this.networkClass = 'connected'
break
case 3:
this.networkStatus = '重连中...'
this.networkClass = 'reconnecting'
break
case 5:
this.networkStatus = '连接失败'
this.networkClass = 'failed'
break
}
},

async toggleMute() {
this.isMuted = await CallManager.toggleMute()
},

async toggleVideo() {
this.videoEnabled = await CallManager.toggleVideo()
},

async switchCamera() {
await CallManager.switchCamera()
},

async endCall() {
await CallManager.endCall()
this.goBack()
},

startDurationTimer() {
this.timer = setInterval(() => {
if (this.remoteUid) {
this.callDuration++
}
}, 1000)
},

goBack() {
uni.navigateBack({
fail: () => {
uni.reLaunch({ url: '/pages/index/index' })
}
})
},

cleanup() {
uni.$off('remoteUserJoined', this.handleRemoteUserJoined)
uni.$off('remoteUserLeft', this.handleRemoteUserLeft)
uni.$off('callStatusChanged', this.handleCallStatusChanged)
uni.$off('networkStateChanged', this.handleNetworkStateChanged)

if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
}
}
}
</script>

<style scoped>
.call-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: #000;
display: flex;
flex-direction: column;
}

.video-container {
flex: 1;
position: relative;
}

.remote-video {
width: 100%;
height: 100%;
}

.local-video {
position: absolute;
top: 60rpx;
right: 40rpx;
width: 240rpx;
height: 320rpx;
border-radius: 20rpx;
overflow: hidden;
border: 4rpx solid rgba(255,255,255,0.3);
}

.audio-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.user-avatar {
text-align: center;
color: white;
}

.avatar {
width: 200rpx;
height: 200rpx;
border-radius: 100rpx;
margin-bottom: 40rpx;
}

.user-name {
display: block;
font-size: 36rpx;
font-weight: bold;
margin-bottom: 20rpx;
}

.call-status {
display: block;
font-size: 28rpx;
opacity: 0.8;
}

.call-info {
position: absolute;
top: 80rpx;
left: 50%;
transform: translateX(-50%);
text-align: center;
color: white;
z-index: 10;
}

.duration {
display: block;
font-size: 32rpx;
font-weight: bold;
margin-bottom: 10rpx;
}

.network-status {
font-size: 24rpx;
}

.network-status.connected { color: #4CAF50; }
.network-status.connecting { color: #FF9800; }
.network-status.reconnecting { color: #FF9800; }
.network-status.failed { color: #F44336; }

.controls {
position: absolute;
bottom: 100rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 40rpx;
z-index: 10;
}

.control-btn {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
border: none;
background: rgba(255,255,255,0.2);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}

.control-btn.active {
background: rgba(244,67,54,0.8);
}

.end-btn {
background: #F44336 !important;
}

.icon {
font-size: 48rpx;
}
</style>

components/CallInvitation.vue

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
<template>
<view class="call-invitation" v-if="show">
<view class="invitation-card">
<view class="caller-info">
<image :src="callerAvatar" class="caller-avatar"></image>
<text class="caller-name">{{ callerName }}</text>
<text class="call-type">{{ isVideo ? '视频通话' : '语音通话' }}</text>
</view>

<view class="invitation-actions">
<button class="action-btn reject-btn" @click="rejectCall">
<text class="icon">❌</text>
</button>
<button class="action-btn accept-btn" @click="acceptCall">
<text class="icon">{{ isVideo ? '📹' : '📞' }}</text>
</button>
</view>
</view>

<view class="invitation-overlay"></view>
</view>
</template>

<script>
import CallManager from '@/utils/CallManager'

export default {
name: 'CallInvitation',
props: {
show: Boolean,
callId: String,
callerId: String,
callerName: String,
callerAvatar: String,
isVideo: Boolean
},
methods: {
async acceptCall() {
const result = await CallManager.acceptCall(this.callId, this.isVideo)
if (result.success) {
uni.navigateTo({
url: `/pages/call/call?callId=${this.callId}&isVideo=${this.isVideo}&remoteUserId=${this.callerId}&remoteUserName=${this.callerName}&remoteUserAvatar=${this.callerAvatar}`
})
} else {
uni.showToast({
title: '接听失败',
icon: 'error'
})
}
this.$emit('close')
},

async rejectCall() {
await CallManager.rejectCall(this.callId)
this.$emit('close')
}
}
}
</script>

<style scoped>
.call-invitation {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}

.invitation-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(5px);
}

.invitation-card {
position: relative;
background: white;
border-radius: 40rpx;
padding: 80rpx;
text-align: center;
box-shadow: 0 20rpx 60rpx rgba(0,0,0,0.3);
}

.caller-info {
margin-bottom: 60rpx;
}

.caller-avatar {
width: 160rpx;
height: 160rpx;
border-radius: 80rpx;
margin-bottom: 30rpx;
}

.caller-name {
display: block;
font-size: 36rpx;
font-weight: bold;
margin-bottom: 15rpx;
}

.call-type {
display: block;
font-size: 28rpx;
color: #666;
}

.invitation-actions {
display: flex;
justify-content: space-between;
gap: 80rpx;
}

.action-btn {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}

.reject-btn {
background: #F44336;
}

.accept-btn {
background: #4CAF50;
}

.icon {
font-size: 48rpx;
color: white;
}
</style>

pages/index/index.vue

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
<template>
<view class="container">
<scroll-view class="contacts" scroll-y>
<view
v-for="contact in contacts"
:key="contact.id"
class="contact-item"
@click="showCallOptions(contact)">
<image :src="contact.avatar" class="contact-avatar"></image>
<view class="contact-info">
<text class="contact-name">{{ contact.name }}</text>
<text class="contact-status">{{ contact.status }}</text>
</view>
<view class="contact-actions">
<button
class="action-btn audio-btn"
@click.stop="startAudioCall(contact)">
🎤
</button>
<button
class="action-btn video-btn"
@click.stop="startVideoCall(contact)">
📹
</button>
</view>
</view>
</scroll-view>

<CallInvitation
:show="showInvitation"
:callId="invitationData.callId"
:callerId="invitationData.callerId"
:callerName="invitationData.callerName"
:callerAvatar="invitationData.callerAvatar"
:isVideo="invitationData.isVideo"
@close="hideInvitation">
</CallInvitation>
</view>
</template>

<script>
import CallManager from '@/utils/CallManager'
import SignalingService from '@/utils/SignalingService'
import CallInvitation from '@/components/CallInvitation.vue'

export default {
components: {
CallInvitation
},
data() {
return {
contacts: [
{
id: 'user1',
name: '张三',
avatar: '/static/avatar1.png',
status: '在线'
},
{
id: 'user2',
name: '李四',
avatar: '/static/avatar2.png',
status: '离开'
}
],
showInvitation: false,
invitationData: {},
currentCallIsVideo: false
}
},
onLoad() {
this.initServices()
},
onUnload() {
this.cleanup()
},
methods: {
async initServices() {
try {
const userId = this.getCurrentUserId()
const token = this.getAuthToken()

await SignalingService.connect(userId, token)
uni.$on('callInvitation', this.handleCallInvitation)
uni.$on('callResponse', this.handleCallResponse)

await CallManager.initEngine()
} catch (error) {
console.error('服务初始化失败:', error)
uni.showToast({
title: '初始化失败',
icon: 'error'
})
}
},

handleCallInvitation(data) {
this.invitationData = data
this.showInvitation = true
this.playRingtone()
},

handleCallResponse(data) {
if (data.response === 'accepted') {
uni.navigateTo({
url: `/pages/call/call?callId=${data.callId}&isVideo=${this.currentCallIsVideo}`
})
} else if (data.response === 'rejected') {
uni.showToast({
title: '对方拒绝了通话',
icon: 'none'
})
}
},

async startAudioCall(contact) {
const result = await CallManager.startCall(contact.id, false)
if (result.success) {
this.currentCallIsVideo = false
uni.showLoading({ title: '呼叫中...' })
} else {
uni.showToast({
title: '发起通话失败',
icon: 'error'
})
}
},

async startVideoCall(contact) {
const result = await CallManager.startCall(contact.id, true)
if (result.success) {
this.currentCallIsVideo = true
uni.navigateTo({
url: `/pages/call/call?callId=${result.callId}&isVideo=true&remoteUserId=${contact.id}&remoteUserName=${contact.name}&remoteUserAvatar=${contact.avatar}`
})
} else {
uni.showToast({
title: '发起通话失败',
icon: 'error'
})
}
},

hideInvitation() {
this.showInvitation = false
this.stopRingtone()
},

playRingtone() {
uni.createInnerAudioContext().play()
},

stopRingtone() {

},

getCurrentUserId() {
return uni.getStorageSync('userId') || 'user_' + Date.now()
},

getAuthToken() {
return uni.getStorageSync('authToken') || 'demo_token'
},

cleanup() {
uni.$off('callInvitation', this.handleCallInvitation)
uni.$off('callResponse', this.handleCallResponse)
SignalingService.disconnect()
}
}
}
</script>

<style scoped>
.container {
padding: 20rpx;
}

.contacts {
height: 100vh;
}

.contact-item {
display: flex;
align-items: center;
padding: 30rpx 20rpx;
border-bottom: 1rpx solid #eee;
}

.contact-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50rpx;
margin-right: 30rpx;
}

.contact-info {
flex: 1;
}

.contact-name {
display: block;
font-size: 32rpx;
font-weight: bold;
margin-bottom: 10rpx;
}

.contact-status {
font-size: 24rpx;
color: #999;
}

.contact-actions {
display: flex;
gap: 20rpx;
}

.action-btn {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
border: none;
background: #007AFF;
color: white;
font-size: 32rpx;
}

.audio-btn {
background: #34C759;
}

.video-btn {
background: #007AFF;
}
</style>

pages/call/call.vue

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
<template>
<CallScreen
:callId="callId"
:isVideo="isVideo"
:remoteUserId="remoteUserId"
:remoteUserName="remoteUserName"
:remoteUserAvatar="remoteUserAvatar">
</CallScreen>
</template>

<script>
import CallScreen from '@/components/CallScreen.vue'

export default {
components: {
CallScreen
},
data() {
return {
callId: '',
isVideo: false,
remoteUserId: '',
remoteUserName: '',
remoteUserAvatar: ''
}
},
onLoad(options) {
this.callId = options.callId || ''
this.isVideo = options.isVideo === 'true'
this.remoteUserId = options.remoteUserId || ''
this.remoteUserName = options.remoteUserName || ''
this.remoteUserAvatar = options.remoteUserAvatar || ''
}
}
</script>

manifest.json

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
{
"name": "声网通话应用",
"appid": "__UNI__XXXXXXX",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"modules": {
"Camera": {
"description": "用于视频通话拍摄"
},
"AudioPlayer": {
"description": "用于音频通话播放"
}
},
"permissions": {
"Record": {
"description": "用于录制音频进行语音通话"
},
"Camera": {
"description": "用于视频通话拍摄"
}
},
"nativePlugins": {
"Agora-RTC": {
"hooksClass": "",
"plugins": [
{
"type": "module",
"name": "Agora-RTC-EngineModule",
"class": "io.agora.rtc.uni.AgoraRtcEngineModule"
},
{
"type": "module",
"name": "Agora-RTC-ChannelModule",
"class": "io.agora.rtc.uni.AgoraRtcChannelModule"
},
{
"type": "component",
"name": "Agora-RTC-SurfaceView",
"class": "io.agora.rtc.uni.AgoraRtcSurfaceView"
},
{
"type": "component",
"name": "Agora-RTC-TextureView",
"class": "io.agora.rtc.uni.AgoraRtcTextureView"
}
]
}
}
},
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\" />",
"<uses-permission android:name=\"android.permission.CAMERA\" />",
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\" />",
"<uses-permission android:name=\"android.permission.INTERNET\" />",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\" />",
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\" />"
]
},
"ios": {
"privacyDescription": {
"NSCameraUsageDescription": "此应用需要访问摄像头进行视频通话",
"NSMicrophoneUsageDescription": "此应用需要访问麦克风进行语音通话"
}
}
}

pages.json

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
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "通话列表"
}
},
{
"path": "pages/call/call",
"style": {
"navigationStyle": "custom",
"app-plus": {
"titleNView": false
}
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
}
}

package.json

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
{
"name": "agora-uniapp-call",
"version": "1.0.0",
"description": "基于uni-app和声网的通话应用",
"main": "main.js",
"scripts": {
"serve": "npm run dev:h5",
"build": "npm run build:h5",
"dev:app-plus": "uni-app",
"dev:custom": "uni-app",
"dev:h5": "uni-app",
"dev:mp-alipay": "uni-app",
"dev:mp-baidu": "uni-app",
"dev:mp-weixin": "uni-app",
"build:app-plus": "uni-app",
"build:custom": "uni-app",
"build:h5": "uni-app",
"build:mp-alipay": "uni-app",
"build:mp-baidu": "uni-app",
"build:mp-weixin": "uni-app"
},
"dependencies": {},
"devDependencies": {},
"browserslist": [
"Android >= 4.4",
"ios >= 9"
]
}

使用说明

  1. 配置声网AppID: 在 common/agora.config.js 中填入你的声网AppID
  2. 权限配置: manifest.json中已配置好必要权限
  3. 信令服务: 需要自己实现WebSocket信令服务器
  4. 插件安装: 需要在HBuilderX插件市场安装Agora-RTC插件
  5. 真机测试: 必须在真机上测试,模拟器可能无法正常使用摄像头和麦克风

主要功能

  • 一对一语音通话
  • 一对一视频通话
  • 来电提醒界面
  • 通话控制(静音、开关摄像头、切换摄像头)
  • 自动权限申请
  • 网络状态监控
  • 通话时长统计

注意事项

  1. 需要在声网控制台创建项目获取AppID
  2. 生产环境需要实现Token服务器
  3. 需要部署WebSocket信令服务器
  4. iOS需要在Info.plist中添加摄像头和麦克风使用说明
  5. Android需要动态申请权限

补充

agora.config.js 生产环境配置说明

问题解答

关于 @agora.config.js 文件在生产环境的使用,你的理解是正确的。这个文件确实不应该随着用户的不同生成不同的字段信息。

生产环境中 agora.config.js 的正确用法

1. 文件作用定位

1
2
3
4
5
6
7
8
9
10
11
// common/agora.config.js (生产环境版本)
module.exports = {
// ✅ 这个是全局固定配置,所有用户共用
"appId": '你的声网AppID',

// ✅ 这些在生产环境应该为空,运行时动态获取
"token": '', // 不写死,每次从服务器获取
"channelId": '', // 不写死,每次通话动态生成
"uid": 0, // 不写死,使用当前登录用户ID
"stringUid": '' // 基本不用
}

2. 为什么不能随用户变化

❌ 错误理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 错误:为每个用户生成不同的配置文件
// 用户A的配置
{
"appId": 'xxx',
"token": 'tokenA',
"channelId": 'channelA',
"uid": 'userA'
}

// 用户B的配置
{
"appId": 'xxx',
"token": 'tokenB',
"channelId": 'channelB',
"uid": 'userB'
}

✅ 正确做法

1
2
3
4
5
6
7
8
// 正确:配置文件保持不变,参数运行时动态获取
// 所有用户共用同一个配置文件
{
"appId": 'xxx', // 固定
"token": '', // 空,运行时获取
"channelId": '', // 空,运行时生成
"uid": 0 // 空,运行时使用真实用户ID
}

实际使用方式

1. 配置文件保持静态

1
2
3
4
5
6
7
8
// common/agora.config.js - 永远不变
module.exports = {
"appId": '9fc5d27f71ec42c78ab04733e5bc12e4', // 从声网控制台获取,固定不变
"token": '', // 留空
"channelId": '', // 留空
"uid": 0, // 留空
"stringUid": '' // 留空
}

2. 运行时动态获取参数

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
// utils/CallManager.js
import config from '@/common/agora.config'

class CallManager {
async startCall(targetUserId, isVideo = false) {
// 1. 使用固定的 appId
const appId = config.appId // 始终是固定值

// 2. 动态生成 channelId
const channelId = this.generateChannelId(getCurrentUserId(), targetUserId)

// 3. 动态获取 token
const token = await this.getTokenFromServer(channelId, getCurrentUserId())

// 4. 使用真实用户ID
const uid = getCurrentUserId()

// 5. 加入频道
await this.engine.joinChannel(token, channelId, null, uid)
}

generateChannelId(userA, userB) {
// 每次通话都生成新的频道ID
const ids = [userA, userB].sort()
return `call_${ids[0]}_${ids[1]}_${Date.now()}`
}

async getTokenFromServer(channelId, userId) {
// 从你的服务器动态获取Token
const response = await uni.request({
url: 'https://your-api.com/agora/token',
method: 'POST',
data: { channelId, userId }
})
return response.data.token
}
}

为什么要这样设计?

1. 安全性

1
2
3
4
5
// ❌ 如果把 token 写在配置文件里
"token": "007eJxTYDj732Hl..." // 这个token会暴露给所有用户

// ✅ 运行时动态获取
const token = await getTokenFromServer() // 每个用户都有专属token

2. 隔离性

1
2
3
4
5
// ❌ 如果把 channelId 写死
"channelId": "111" // 所有用户都会进入同一个频道!

// ✅ 动态生成频道
const channelId = `call_${userA}_${userB}_${timestamp}` // 每次通话都是独立频道

3. 可维护性

1
2
3
4
5
// ✅ 配置文件简单明了
module.exports = {
"appId": "固定值",
// 其他参数都是动态的,不写在这里
}

完整的调用流程示例

用户A给用户B打电话

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 读取固定配置
const appId = config.appId // '9fc5d27f71ec42c78ab04733e5bc12e4'

// 2. 生成本次通话的参数
const userA = 'user123'
const userB = 'user456'
const channelId = `call_${userA}_${userB}_${Date.now()}` // call_user123_user456_1703123456789

// 3. 为用户A获取token
const tokenA = await getTokenFromServer(channelId, userA)

// 4. 用户A加入频道
await engine.joinChannel(tokenA, channelId, null, userA)

用户B接听电话

1
2
3
4
5
6
7
8
9
10
11
// 1. 使用相同的固定配置
const appId = config.appId // 相同的appId

// 2. 使用相同的频道ID(从通话邀请中获取)
const channelId = 'call_user123_user456_1703123456789' // 相同频道

// 3. 为用户B获取token
const tokenB = await getTokenFromServer(channelId, userB)

// 4. 用户B加入频道
await engine.joinChannel(tokenB, channelId, null, userB)

总结

agora.config.js 在生产环境的作用:

  1. 只存储全局固定配置 - 主要是 appId
  2. 不存储用户相关信息 - tokenchannelIduid 都要动态生成
  3. 保持文件不变 - 所有用户共用同一个配置文件
  4. 提供默认值 - 为动态参数提供空的默认值

核心原则:

  • 配置文件 = 静态全局设置
  • 用户参数 = 运行时动态获取
  • 安全信息 = 绝不写死在客户端

这样既保证了安全性,又实现了用户隔离,还便于维护!

评论