社交媒体应用开发案例研究
本案例研究展示了如何使用uni-app开发一个全面的社交媒体应用,涵盖用户资料、内容分享、实时消息、社交互动和社区功能等核心特性。
项目概述
应用功能
- 用户注册与资料管理
- 内容创建与分享(文本、图片、视频)
- 实时消息和聊天
- 社交互动(点赞、评论、分享)
- 好友/关注者系统
- 新闻流和时间线
- 故事和临时内容
- 群组社区和讨论
- 直播功能
- 推送通知
技术架构
- 前端:uni-app 与 Vue.js
- 后端:Node.js 与 Express
- 数据库:MongoDB 用于用户数据,Redis 用于缓存
- 实时通信:WebSocket 用于消息和直播功能
- 存储:云存储用于媒体文件
- CDN:内容分发网络用于媒体优化
- 认证:JWT 与 OAuth 集成
核心实现
1. 用户资料系统
vue
<template>
<view class="user-profile">
<view class="profile-header">
<view class="cover-image">
<image :src="userProfile.coverImage" mode="aspectFill" />
<button v-if="isOwnProfile" @click="changeCoverImage" class="change-cover-btn">
<uni-icons type="camera" size="20" />
</button>
</view>
<view class="profile-info">
<view class="avatar-section">
<image :src="userProfile.avatar" class="avatar" />
<button v-if="isOwnProfile" @click="changeAvatar" class="change-avatar-btn">
<uni-icons type="camera" size="16" />
</button>
</view>
<view class="user-details">
<text class="username">{{ userProfile.username }}</text>
<text class="display-name">{{ userProfile.displayName }}</text>
<text class="bio">{{ userProfile.bio }}</text>
<view class="stats">
<view class="stat-item" @click="showFollowers">
<text class="stat-number">{{ userProfile.followersCount }}</text>
<text class="stat-label">粉丝</text>
</view>
<view class="stat-item" @click="showFollowing">
<text class="stat-number">{{ userProfile.followingCount }}</text>
<text class="stat-label">关注</text>
</view>
<view class="stat-item" @click="showPosts">
<text class="stat-number">{{ userProfile.postsCount }}</text>
<text class="stat-label">帖子</text>
</view>
</view>
<view class="action-buttons" v-if="!isOwnProfile">
<button
:class="['follow-btn', { 'following': userProfile.isFollowing }]"
@click="toggleFollow"
>
{{ userProfile.isFollowing ? '已关注' : '关注' }}
</button>
<button class="message-btn" @click="sendMessage">
<uni-icons type="chat" size="16" />
发消息
</button>
</view>
<view class="edit-profile" v-else>
<button @click="editProfile" class="edit-btn">编辑资料</button>
</view>
</view>
</view>
</view>
<uni-segmented-control
:current="currentTab"
:values="tabItems"
@clickItem="onTabChange"
style-type="button"
/>
<swiper
:current="currentTab"
@change="onSwiperChange"
class="content-swiper"
>
<swiper-item>
<scroll-view scroll-y class="posts-container">
<view
v-for="post in userPosts"
:key="post.id"
class="post-item"
@click="viewPost(post)"
>
<image
v-if="post.type === 'image'"
:src="post.thumbnail"
mode="aspectFill"
class="post-thumbnail"
/>
<video
v-else-if="post.type === 'video'"
:src="post.videoUrl"
:poster="post.thumbnail"
class="post-video"
/>
<view v-else class="post-text">
<text>{{ post.content }}</text>
</view>
<view class="post-overlay">
<view class="post-stats">
<view class="stat">
<uni-icons type="heart" size="14" />
<text>{{ post.likesCount }}</text>
</view>
<view class="stat">
<uni-icons type="chat" size="14" />
<text>{{ post.commentsCount }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</swiper-item>
<swiper-item>
<scroll-view scroll-y class="media-container">
<view class="media-grid">
<view
v-for="media in userMedia"
:key="media.id"
class="media-item"
@click="viewMedia(media)"
>
<image :src="media.thumbnail" mode="aspectFill" />
<view v-if="media.type === 'video'" class="video-indicator">
<uni-icons type="play" size="20" />
</view>
</view>
</view>
</scroll-view>
</swiper-item>
<swiper-item>
<scroll-view scroll-y class="likes-container">
<uni-list>
<uni-list-item
v-for="likedPost in likedPosts"
:key="likedPost.id"
:title="likedPost.title"
:note="likedPost.author"
:thumb="likedPost.thumbnail"
clickable
@click="viewPost(likedPost)"
/>
</uni-list>
</scroll-view>
</swiper-item>
</swiper>
</view>
</template>
<script>
export default {
data() {
return {
userId: '',
userProfile: {
username: '',
displayName: '',
bio: '',
avatar: '',
coverImage: '',
followersCount: 0,
followingCount: 0,
postsCount: 0,
isFollowing: false
},
isOwnProfile: false,
currentTab: 0,
tabItems: ['帖子', '媒体', '喜欢'],
userPosts: [],
userMedia: [],
likedPosts: []
}
},
onLoad(options) {
this.userId = options.userId || uni.getStorageSync('currentUserId')
this.isOwnProfile = this.userId === uni.getStorageSync('currentUserId')
this.loadUserProfile()
this.loadUserContent()
},
methods: {
async loadUserProfile() {
try {
const response = await uni.request({
url: `https://api.social.com/users/${this.userId}/profile`,
method: 'GET',
header: {
'Authorization': `Bearer ${this.getToken()}`
}
})
this.userProfile = response.data.profile
} catch (error) {
console.error('加载用户资料失败:', error)
}
},
async toggleFollow() {
try {
const action = this.userProfile.isFollowing ? 'unfollow' : 'follow'
await uni.request({
url: `https://api.social.com/users/${this.userId}/${action}`,
method: 'POST',
header: {
'Authorization': `Bearer ${this.getToken()}`
}
})
this.userProfile.isFollowing = !this.userProfile.isFollowing
this.userProfile.followersCount += this.userProfile.isFollowing ? 1 : -1
uni.showToast({
title: this.userProfile.isFollowing ? '已关注' : '已取消关注',
icon: 'success'
})
} catch (error) {
uni.showToast({
title: '操作失败',
icon: 'error'
})
}
},
getToken() {
return uni.getStorageSync('authToken')
}
}
}
</script>
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
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
2. 新闻流和时间线
vue
<template>
<view class="news-feed">
<view class="feed-header">
<view class="logo">
<text class="app-name">社交应用</text>
</view>
<view class="header-actions">
<button @click="createPost" class="create-post-btn">
<uni-icons type="plus" size="20" />
</button>
<button @click="openMessages" class="messages-btn">
<uni-icons type="chat" size="20" />
<view v-if="unreadCount > 0" class="unread-badge">
<text>{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</view>
</button>
</view>
</view>
<scroll-view
scroll-y
class="feed-container"
@scrolltolower="loadMorePosts"
refresher-enabled
@refresherrefresh="refreshFeed"
:refresher-triggered="isRefreshing"
>
<view class="stories-section">
<scroll-view scroll-x class="stories-container">
<view class="story-item add-story" @click="createStory">
<view class="story-avatar">
<uni-icons type="plus" size="24" />
</view>
<text class="story-label">我的故事</text>
</view>
<view
v-for="story in stories"
:key="story.id"
class="story-item"
@click="viewStory(story)"
>
<view class="story-avatar">
<image :src="story.user.avatar" />
<view v-if="!story.viewed" class="story-ring"></view>
</view>
<text class="story-label">{{ story.user.name }}</text>
</view>
</scroll-view>
</view>
<view class="posts-section">
<view
v-for="post in posts"
:key="post.id"
class="post-card"
>
<view class="post-header">
<view class="user-info" @click="viewProfile(post.user)">
<image :src="post.user.avatar" class="user-avatar" />
<view class="user-details">
<text class="username">{{ post.user.name }}</text>
<text class="post-time">{{ formatTime(post.createdAt) }}</text>
<view v-if="post.location" class="post-location">
<uni-icons type="location" size="12" />
<text>{{ post.location.name }}</text>
</view>
</view>
</view>
<button @click="showPostOptions(post)" class="options-btn">
<uni-icons type="more" size="16" />
</button>
</view>
<view class="post-content">
<text v-if="post.content" class="post-text">{{ post.content }}</text>
<view v-if="post.media && post.media.length" class="post-media">
<swiper
v-if="post.media.length > 1"
class="media-swiper"
indicator-dots
indicator-color="rgba(255,255,255,0.5)"
indicator-active-color="#fff"
>
<swiper-item v-for="media in post.media" :key="media.id">
<image
v-if="media.type === 'image'"
:src="media.url"
mode="aspectFill"
@click="previewMedia(post.media, media)"
/>
<video
v-else-if="media.type === 'video'"
:src="media.url"
:poster="media.thumbnail"
controls
/>
</swiper-item>
</swiper>
<image
v-else-if="post.
### 2. 新闻流和时间线
```vue
<template>
<view class="news-feed">
<view class="feed-header">
<view class="logo">
<text class="app-name">社交应用</text>
</view>
<view class="header-actions">
<button @click="createPost" class="create-post-btn">
<uni-icons type="plus" size="20" />
</button>
<button @click="openMessages" class="messages-btn">
<uni-icons type="chat" size="20" />
<view v-if="unreadCount > 0" class="unread-badge">
<text>{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</view>
</button>
</view>
</view>
<scroll-view
scroll-y
class="feed-container"
@scrolltolower="loadMorePosts"
refresher-enabled
@refresherrefresh="refreshFeed"
:refresher-triggered="isRefreshing"
>
<view class="stories-section">
<scroll-view scroll-x class="stories-container">
<view class="story-item add-story" @click="createStory">
<view class="story-avatar">
<uni-icons type="plus" size="24" />
</view>
<text class="story-label">我的故事</text>
</view>
<view
v-for="story in stories"
:key="story.id"
class="story-item"
@click="viewStory(story)"
>
<view class="story-avatar">
<image :src="story.user.avatar" />
<view v-if="!story.viewed" class="story-ring"></view>
</view>
<text class="story-label">{{ story.user.name }}</text>
</view>
</scroll-view>
</view>
<view class="posts-section">
<view
v-for="post in posts"
:key="post.id"
class="post-card"
>
<view class="post-header">
<view class="user-info" @click="viewProfile(post.user)">
<image :src="post.user.avatar" class="user-avatar" />
<view class="user-details">
<text class="username">{{ post.user.name }}</text>
<text class="post-time">{{ formatTime(post.createdAt) }}</text>
<view v-if="post.location" class="post-location">
<uni-icons type="location" size="12" />
<text>{{ post.location.name }}</text>
</view>
</view>
</view>
<button @click="showPostOptions(post)" class="options-btn">
<uni-icons type="more" size="16" />
</button>
</view>
<view class="post-content">
<text v-if="post.content" class="post-text">{{ post.content }}</text>
<view v-if="post.media && post.media.length" class="post-media">
<swiper
v-if="post.media.length > 1"
class="media-swiper"
indicator-dots
indicator-color="rgba(255,255,255,0.5)"
indicator-active-color="#fff"
>
<swiper-item v-for="media in post.media" :key="media.id">
<image
v-if="media.type === 'image'"
:src="media.url"
mode="aspectFill"
@click="previewMedia(post.media, media)"
/>
<video
v-else-if="media.type === 'video'"
:src="media.url"
:poster="media.thumbnail"
controls
/>
</swiper-item>
</swiper>
<image
v-else-if="post.media[0].type === 'image'"
:src="post.media[0].url"
mode="aspectFill"
class="single-image"
@click="previewMedia(post.media, post.media[0])"
/>
<video
v-else-if="post.media[0].type === 'video'"
:src="post.media[0].url"
:poster="post.media[0].thumbnail"
controls
class="single-video"
/>
</view>
</view>
<view class="post-actions">
<view class="action-stats">
<text v-if="post.likesCount > 0">{{ post.likesCount }} 赞</text>
<text v-if="post.commentsCount > 0">{{ post.commentsCount }} 评论</text>
<text v-if="post.sharesCount > 0">{{ post.sharesCount }} 分享</text>
</view>
<view class="action-buttons">
<button
@click="toggleLike(post)"
:class="['action-btn', { 'liked': post.isLiked }]"
>
<uni-icons :type="post.isLiked ? 'heart-filled' : 'heart'" size="20" />
<text>点赞</text>
</button>
<button @click="showComments(post)" class="action-btn">
<uni-icons type="chat" size="20" />
<text>评论</text>
</button>
<button @click="sharePost(post)" class="action-btn">
<uni-icons type="redo" size="20" />
<text>分享</text>
</button>
</view>
</view>
</view>
</view>
<view v-if="isLoading" class="loading-indicator">
<uni-load-more status="loading" />
</view>
</scroll-view>
</view>
</template>
<script>
export default {
data() {
return {
posts: [],
stories: [],
unreadCount: 0,
isLoading: false,
isRefreshing: false,
page: 1,
hasMore: true
}
},
onLoad() {
this.loadFeed()
this.loadStories()
this.loadUnreadCount()
},
methods: {
async loadFeed() {
if (this.isLoading || !this.hasMore) return
this.isLoading = true
try {
const response = await uni.request({
url: 'https://api.social.com/feed',
method: 'GET',
data: {
page: this.page,
limit: 10
},
header: {
'Authorization': `Bearer ${this.getToken()}`
}
})
const newPosts = response.data.posts
if (this.page === 1) {
this.posts = newPosts
} else {
this.posts.push(...newPosts)
}
this.hasMore = newPosts.length === 10
this.page++
} catch (error) {
console.error('加载信息流失败:', error)
} finally {
this.isLoading = false
}
},
async refreshFeed() {
this.isRefreshing = true
this.page = 1
this.hasMore = true
await this.loadFeed()
await this.loadStories()
this.isRefreshing = false
},
async toggleLike(post) {
const wasLiked = post.isLiked
// 乐观更新
post.isLiked = !post.isLiked
post.likesCount += post.isLiked ? 1 : -1
try {
await uni.request({
url: `https://api.social.com/posts/${post.id}/like`,
method: post.isLiked ? 'POST' : 'DELETE',
header: {
'Authorization': `Bearer ${this.getToken()}`
}
})
} catch (error) {
// 错误时恢复
post.isLiked = wasLiked
post.likesCount += wasLiked ? 1 : -1
uni.showToast({
title: '操作失败',
icon: 'error'
})
}
},
createPost() {
uni.navigateTo({
url: '/pages/posts/create'
})
},
getToken() {
return uni.getStorageSync('authToken')
}
}
}
</script>
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
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
3. 实时消息系统
vue
<template>
<view class="chat-list">
<view class="chat-header">
<text class="title">消息</text>
<button @click="newChat" class="new-chat-btn">
<uni-icons type="compose" size="20" />
</button>
</view>
<view class="search-bar">
<uni-search-bar
v-model="searchQuery"
placeholder="搜索对话"
@input="searchConversations"
/>
</view>
<scroll-view scroll-y class="conversations-list">
<view
v-for="conversation in filteredConversations"
:key="conversation.id"
class="conversation-item"
@click="openConversation(conversation)"
>
<view class="conversation-avatar">
<image :src="conversation.contact.avatar" />
<view v-if="conversation.contact.isOnline" class="online-indicator"></view>
</view>
<view class="conversation-content">
<view class="conversation-header">
<text class="contact-name">{{ conversation.contact.name }}</text>
<text class="last-message-time">{{ formatTime(conversation.lastMessage.timestamp) }}</text>
</view>
<view class="last-message">
<text :class="{ 'unread': conversation.unreadCount > 0 }">
{{ getLastMessagePreview(conversation.lastMessage) }}
</text>
<view v-if="conversation.unreadCount > 0" class="unread-count">
<text>{{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
export default {
data() {
return {
conversations: [],
filteredConversations: [],
searchQuery: '',
socketConnection: null
}
},
onLoad() {
this.loadConversations()
this.initializeWebSocket()
},
methods: {
async loadConversations() {
try {
const response = await uni.request({
url: 'https://api.social.com/conversations',
method: 'GET',
header: {
'Authorization': `Bearer ${this.getToken()}`
}
})
this.conversations = response.data.conversations
this.filteredConversations = this.conversations
} catch (error) {
console.error('加载对话失败:', error)
}
},
initializeWebSocket() {
this.socketConnection = uni.connectSocket({
url: 'wss://api.social.com/messages',
header: {
'Authorization': `Bearer ${this.getToken()}`
}
})
this.socketConnection.onMessage((res) => {
const data = JSON.parse(res.data)
if (data.type === 'new_message') {
this.updateConversation(data.conversationId, data.message)
}
})
},
updateConversation(conversationId, message) {
const conversation = this.conversations.find(c => c.id === conversationId)
if (conversation) {
conversation.lastMessage = message
conversation.unreadCount++
// 移至顶部
const index = this.conversations.indexOf(conversation)
this.conversations.splice(index, 1)
this.conversations.unshift(conversation)
this.filteredConversations = [...this.conversations]
}
},
getToken() {
return uni.getStorageSync('authToken')
}
}
}
</script>
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
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
4. 直播功能
vue
<template>
<view class="live-stream">
<view class="stream-container">
<live-pusher
id="livePusher"
:url="streamUrl"
mode="RTC"
autopush
enable-camera
enable-mic
@statechange="onStreamStateChange"
@error="onStreamError"
class="live-pusher"
/>
<view class="stream-overlay">
<view class="stream-info">
<view class="viewer-count">
<uni-icons type="eye" size="16" />
<text>{{ viewerCount }} 人观看</text>
</view>
<view class="stream-duration">
<text>{{ formatDuration(streamDuration) }}</text>
</view>
</view>
<view class="stream-controls">
<button
@click="toggleMute"
:class="['control-btn', { 'muted': isMuted }]"
>
<uni-icons :type="isMuted ? 'mic-off' : 'mic'" size="20" />
</button>
<button
@click="switchCamera"
class="control-btn"
>
<uni-icons type="loop" size="20" />
</button>
<button
@click="toggleCamera"
:class="['control-btn', { 'camera-off': !cameraEnabled }]"
>
<uni-icons :type="cameraEnabled ? 'videocam' : 'videocam-off'" size="20" />
</button>
<button
@click="endStream"
class="control-btn end-stream"
>
<uni-icons type="close" size="20" />
</button>
</view>
</view>
</view>
<view class="chat-section">
<scroll-view
scroll-y
class="live-chat"
:scroll-top="chatScrollTop"
>
<view
v-for="message in chatMessages"
:key="message.id"
class="chat-message"
>
<text class="username">{{ message.user.name }}:</text>
<text class="message-text">{{ message.text }}</text>
</view>
</scroll-view>
<view class="chat-input">
<input
v-model="newMessage"
placeholder="说点什么..."
@confirm="sendChatMessage"
/>
<button @click="sendChatMessage" class="send-btn">
<uni-icons type="paperplane" size="16" />
</button>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
streamUrl: '',
streamId: '',
viewerCount: 0,
streamDuration: 0,
isMuted: false,
cameraEnabled: true,
chatMessages: [],
newMessage: '',
chatScrollTop: 0,
streamTimer: null,
socketConnection: null
}
},
onLoad() {
this.initializeStream()
},
onUnload() {
this.endStream()
},
methods: {
async initializeStream() {
try {
const response = await uni.request({
url: 'https://api.social.com/live/start',
method: 'POST',
header: {
'Authorization': `Bearer ${this.getToken()}`
}
})
this.streamUrl = response.data.streamUrl
this.streamId = response.data.streamId
this.initializeWebSocket()
this.startTimer()
} catch (error) {
uni.showToast({
title: '启动直播失败',
icon: 'error'
})
}
},
initializeWebSocket() {
this.socketConnection = uni.connectSocket({
url: `wss://api.social.com/live/${this.streamId}`,
header: {
'Authorization': `Bearer ${this.getToken()}`
}
})
this.socketConnection.onMessage((res) => {
const data = JSON.parse(res.data)
switch (data.type) {
case 'viewer_count':
this.viewerCount = data.count
break
case 'chat_message':
this.chatMessages.push(data.message)
this.scrollChatToBottom()
break
}
})
},
startTimer() {
this.streamTimer = setInterval(() => {
this.streamDuration++
}, 1000)
},
toggleMute() {
this.isMuted = !this.isMuted
const livePusher = uni.createLivePusherContext('livePusher', this)
if (this.isMuted) {
livePusher.mute()
} else {
livePusher.unmute()
}
},
switchCamera() {
const livePusher = uni.createLivePusherContext('livePusher', this)
livePusher.switchCamera()
},
toggleCamera() {
this.cameraEnabled = !this.cameraEnabled
const livePusher = uni.createLivePusherContext('livePusher', this)
if (this.cameraEnabled) {
livePusher.start()
} else {
livePusher.stop()
}
},
async endStream() {
try {
await uni.request({
url: `https://api.social.com/live/${this.streamId}/end`,
method: 'POST',
header: {
'Authorization': `Bearer ${this.getToken()}`
}
})
if (this.streamTimer) {
clearInterval(this.streamTimer)
}
if (this.socketConnection) {
uni.closeSocket()
}
uni.navigateBack()
} catch (error) {
console.error('结束直播失败:', error)
}
},
sendChatMessage() {
if (!this.newMessage.trim()) return
const message = {
id: Date.now(),
text: this.newMessage,
user: {
id: uni.getStorageSync('currentUserId'),
name: uni.getStorageSync('currentUser').name
},
timestamp: new Date()
}
if (this.socketConnection) {
uni.sendSocketMessage({
data: JSON.stringify({
type: 'chat_message',
message: message
})
})
}
this.newMessage = ''
},
scrollChatToBottom() {
this.$nextTick(() => {
this.chatScrollTop = 999999
})
},
formatDuration(seconds) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
},
getToken() {
return uni.getStorageSync('authToken')
}
}
}
</script>
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
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
平台特定优化
iOS 优化
javascript
// iOS特定社交分享
// #ifdef APP-PLUS-IOS
export default {
methods: {
async shareToiOS(content) {
const shareOptions = {
title: content.title,
summary: content.description,
href: content.url,
imageUrl: content.image
}
plus.share.sendWithSystem(shareOptions, (res) => {
console.log('分享成功:', res)
}, (error) => {
console.error('分享失败:', error)
})
}
}
}
// #endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Android 优化
javascript
// Android特定通知
// #ifdef APP-PLUS-ANDROID
export default {
methods: {
setupPushNotifications() {
const main = plus.android.runtimeMainActivity()
const Intent = plus.android.importClass('android.content.Intent')
const PendingIntent = plus.android.importClass('android.app.PendingIntent')
// 配置通知渠道
this.createNotificationChannel('messages', '消息', '新消息通知')
this.createNotificationChannel('likes', '点赞', '点赞通知')
}
}
}
// #endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
性能优化
图片懒加载
javascript
// utils/lazyLoad.js
export class LazyImageLoader {
constructor() {
this.imageCache = new Map()
this.loadingImages = new Set()
}
async loadImage(url, placeholder = '/static/placeholder.png') {
if (this.imageCache.has(url)) {
return this.imageCache.get(url)
}
if (this.loadingImages.has(url)) {
return placeholder
}
this.loadingImages.add(url)
try {
const compressedUrl = await this.compressImage(url)
this.imageCache.set(url, compressedUrl)
this.loadingImages.delete(url)
return compressedUrl
} catch (error) {
this.loadingImages.delete(url)
console.error('图片加载失败:', error)
return placeholder
}
}
async compressImage(url) {
// 实现图片压缩逻辑
return url
}
clearCache() {
this.imageCache.clear()
}
}
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
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
虚拟列表
javascript
// components/VirtualList.vue
export default {
props: {
items: {
type: Array,
required: true
},
itemHeight: {
type: Number,
default: 80
},
visibleItems: {
type: Number,
default: 10
}
},
data() {
return {
startIndex: 0,
scrollTop: 0
}
},
computed: {
visibleData() {
const start = this.startIndex
const end = Math.min(this.items.length, start + this.visibleItems + 4)
return this.items.slice(start, end)
},
totalHeight() {
return this.items.length * this.itemHeight
},
containerStyle() {
return {
height: `${this.visibleItems * this.itemHeight}px`,
position: 'relative',
overflow: 'auto'
}
},
contentStyle() {
return {
height: `${this.totalHeight}px`,
position: 'relative'
}
}
},
methods: {
onScroll(e) {
this.scrollTop = e.detail.scrollTop
this.updateStartIndex()
},
updateStartIndex() {
const newIndex = Math.floor(this.scrollTop / this.itemHeight)
this.startIndex = Math.max(0, newIndex - 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
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
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