社交應用實戰案例
本文將介紹如何使用 uni-app 開發一個功能完善的社交應用,包括應用架構設計、核心功能實現和關鍵程式碼範例。
1. 應用概述
1.1 功能特點
社交應用是行動網路時代最受歡迎的應用類型之一,主要功能包括:
- 使用者認證:註冊、登入、第三方登入、找回密碼
- 個人資料:頭像、暱稱、個人簡介、興趣標籤
- 社交關係:好友新增、關注、粉絲管理
- 內容分享:動態發佈、圖文內容、話題討論
- 互動功能:按讚、評論、轉發、收藏
- 即時通訊:私聊、群聊、語音訊息、圖片分享
- 內容發現:個人化推薦、熱門話題、附近的人
1.2 技術架構
前端技術棧
- uni-app:跨平台開發框架,實現一次開發多端執行
- Vue.js:響應式資料綁定,提供元件化開發模式
- Vuex:狀態管理,處理複雜元件間通訊
- Socket.io:即時通訊,支援即時訊息收發
後端技術棧
- Node.js/Express:伺服器端框架,處理API請求
- MongoDB:資料儲存,適合社交應用的非結構化資料
- Redis:快取和會話管理,提高存取速度
- WebSocket:即時通訊支援,保持長連線
2. 專案結構
├── components // 自訂元件
│ ├── chat-item // 聊天列表項元件
│ ├── comment-item // 評論項元件
│ ├── post-card // 動態卡片元件
│ └── user-card // 使用者卡片元件
├── pages // 頁面資料夾
│ ├── auth // 認證相關頁面
│ ├── chat // 聊天相關頁面
│ ├── discover // 發現頁面
│ ├── post // 動態相關頁面
│ └── user // 使用者相關頁面
├── store // Vuex 狀態管理
│ ├── index.js // 組裝模組並匯出
│ ├── modules // 狀態模組
│ │ ├── user.js // 使用者狀態
│ │ ├── post.js // 動態狀態
│ │ └── chat.js // 聊天狀態
├── utils // 工具函數
│ ├── request.js // 請求封裝
│ ├── socket.js // WebSocket封裝
│ └── validator.js // 表單驗證
├── static // 靜態資源
├── App.vue // 應用入口
├── main.js // 主入口
├── manifest.json // 設定檔
└── 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
26
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
3. 核心功能實現
3.1 使用者認證系統
使用者認證是社交應用的基礎,包括註冊、登入、第三方登入等功能。
狀態管理設計
js
// store/modules/user.js
export default {
namespaced: true,
state: {
token: uni.getStorageSync('token') || '',
userInfo: uni.getStorageSync('userInfo') ? JSON.parse(uni.getStorageSync('userInfo')) : null,
isLogin: Boolean(uni.getStorageSync('token'))
},
mutations: {
SET_TOKEN(state, token) {
state.token = token;
state.isLogin = Boolean(token);
uni.setStorageSync('token', token);
},
SET_USER_INFO(state, userInfo) {
state.userInfo = userInfo;
uni.setStorageSync('userInfo', JSON.stringify(userInfo));
},
LOGOUT(state) {
state.token = '';
state.userInfo = null;
state.isLogin = false;
uni.removeStorageSync('token');
uni.removeStorageSync('userInfo');
}
},
actions: {
// 登入
async login({ commit }, params) {
try {
const res = await this._vm.$api.user.login(params);
commit('SET_TOKEN', res.data.token);
commit('SET_USER_INFO', res.data.userInfo);
return res.data;
} catch (error) {
throw error;
}
},
// 註冊
async register({ commit }, params) {
try {
const res = await this._vm.$api.user.register(params);
commit('SET_TOKEN', res.data.token);
commit('SET_USER_INFO', res.data.userInfo);
return res.data;
} catch (error) {
throw error;
}
},
// 取得使用者資訊
async getUserInfo({ commit }) {
try {
const res = await this._vm.$api.user.getUserInfo();
commit('SET_USER_INFO', res.data);
return res.data;
} catch (error) {
throw error;
}
},
// 登出
logout({ commit }) {
commit('LOGOUT');
}
}
};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
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
登入頁面實現
vue
<!-- pages/auth/login/login.vue -->
<template>
<view class="login-container">
<view class="logo-box">
<image src="/static/logo.png" class="logo"></image>
<text class="app-name">社交圈</text>
</view>
<view class="form-box">
<view class="input-group">
<text class="iconfont icon-user"></text>
<input
type="text"
v-model="form.username"
placeholder="使用者名稱/手機號/信箱"
class="input"
/>
</view>
<view class="input-group">
<text class="iconfont icon-lock"></text>
<input
:type="showPassword ? 'text' : 'password'"
v-model="form.password"
placeholder="請輸入密碼"
class="input"
/>
<text
class="iconfont"
:class="showPassword ? 'icon-eye' : 'icon-eye-close'"
@click="togglePasswordVisibility"
></text>
</view>
<button class="login-btn" @click="handleLogin" :loading="loading">登入</button>
<view class="action-links">
<navigator url="/pages/auth/register/register" class="link">註冊帳號</navigator>
<navigator url="/pages/auth/forgot-password/forgot-password" class="link">忘記密碼</navigator>
</view>
</view>
<view class="third-party-login">
<view class="divider">
<text class="divider-text">其他登入方式</text>
</view>
<view class="third-party-icons">
<view class="icon-item" @click="thirdPartyLogin('wechat')">
<text class="iconfont icon-wechat"></text>
</view>
<view class="icon-item" @click="thirdPartyLogin('qq')">
<text class="iconfont icon-qq"></text>
</view>
<view class="icon-item" @click="thirdPartyLogin('weibo')">
<text class="iconfont icon-weibo"></text>
</view>
</view>
</view>
</view>
</template>
<script>
import { mapActions } from 'vuex';
export default {
data() {
return {
form: {
username: '',
password: ''
},
showPassword: false,
loading: false
}
},
methods: {
...mapActions('user', ['login']),
// 切換密碼可見性
togglePasswordVisibility() {
this.showPassword = !this.showPassword;
},
// 處理登入
async handleLogin() {
// 表單驗證
if (!this.form.username.trim()) {
uni.showToast({ title: '請輸入使用者名稱', icon: 'none' });
return;
}
if (!this.form.password) {
uni.showToast({ title: '請輸入密碼', icon: 'none' });
return;
}
this.loading = true;
try {
// 呼叫登入介面
await this.login(this.form);
// 登入成功,跳轉到首頁
uni.switchTab({ url: '/pages/index/index' });
} catch (error) {
uni.showToast({
title: error.message || '登入失敗,請重試',
icon: 'none'
});
} finally {
this.loading = false;
}
},
// 第三方登入
thirdPartyLogin(type) {
// 根據不同平台呼叫不同的登入API
uni.login({
provider: type === 'wechat' ? 'weixin' : (type === 'qq' ? 'qq' : 'sinaweibo'),
success: (loginRes) => {
this.handleThirdPartyLoginSuccess(type, loginRes);
},
fail: (err) => {
uni.showToast({
title: `${type}登入失敗`,
icon: 'none'
});
}
});
},
// 處理第三方登入成功
async handleThirdPartyLoginSuccess(type, loginRes) {
try {
// 呼叫後端介面,驗證第三方登入憑證
await this.login({
type: type,
code: loginRes.code
});
// 登入成功,跳轉到首頁
uni.switchTab({ url: '/pages/index/index' });
} catch (error) {
uni.showToast({
title: error.message || '登入失敗,請重試',
icon: 'none'
});
}
}
}
}
</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
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
3.2 動態發佈功能
動態發佈是社交應用的核心功能,支援文字、圖片、影片等多媒體內容。
vue
<!-- pages/post/publish/publish.vue -->
<template>
<view class="publish-container">
<view class="header">
<text class="cancel-btn" @click="cancel">取消</text>
<text class="title">發佈動態</text>
<text class="publish-btn" :class="{ disabled: !canPublish }" @click="publish">發佈</text>
</view>
<view class="content-area">
<textarea
v-model="content"
placeholder="分享新鮮事..."
class="content-input"
:maxlength="500"
auto-height
/>
<view class="media-grid" v-if="mediaList.length > 0">
<view
class="media-item"
v-for="(item, index) in mediaList"
:key="index"
>
<image
v-if="item.type === 'image'"
:src="item.url"
mode="aspectFill"
class="media-preview"
/>
<video
v-if="item.type === 'video'"
:src="item.url"
class="media-preview"
controls
/>
<text class="remove-btn" @click="removeMedia(index)">×</text>
</view>
<view
class="add-media-btn"
v-if="mediaList.length < 9"
@click="chooseMedia"
>
<text class="iconfont icon-plus"></text>
</view>
</view>
<view class="add-media-area" v-else>
<view class="add-media-btn large" @click="chooseMedia">
<text class="iconfont icon-camera"></text>
<text class="add-text">新增照片/影片</text>
</view>
</view>
</view>
<view class="toolbar">
<view class="tool-item" @click="chooseMedia">
<text class="iconfont icon-image"></text>
<text>照片</text>
</view>
<view class="tool-item" @click="chooseLocation">
<text class="iconfont icon-location"></text>
<text>位置</text>
</view>
<view class="tool-item" @click="addTopic">
<text class="iconfont icon-topic"></text>
<text>話題</text>
</view>
<view class="tool-item" @click="setPrivacy">
<text class="iconfont icon-privacy"></text>
<text>隱私</text>
</view>
</view>
<!-- 位置選擇彈窗 -->
<uni-popup ref="locationPopup" type="bottom">
<view class="location-popup">
<view class="popup-header">
<text class="popup-title">選擇位置</text>
<text class="close-btn" @click="closeLocationPopup">×</text>
</view>
<view class="location-search">
<input
type="text"
v-model="locationKeyword"
placeholder="搜尋位置"
@input="searchLocation"
/>
</view>
<scroll-view scroll-y class="location-list">
<view
class="location-item"
v-for="(item, index) in locationList"
:key="index"
@click="selectLocation(item)"
>
<text class="location-name">{{item.name}}</text>
<text class="location-address">{{item.address}}</text>
</view>
</scroll-view>
</view>
</uni-popup>
<!-- 話題選擇彈窗 -->
<uni-popup ref="topicPopup" type="bottom">
<view class="topic-popup">
<view class="popup-header">
<text class="popup-title">選擇話題</text>
<text class="close-btn" @click="closeTopicPopup">×</text>
</view>
<view class="topic-search">
<input
type="text"
v-model="topicKeyword"
placeholder="搜尋或建立話題"
@input="searchTopic"
/>
</view>
<scroll-view scroll-y class="topic-list">
<view
class="topic-item"
v-for="(item, index) in topicList"
:key="index"
@click="selectTopic(item)"
>
<text class="topic-name">#{{item.name}}#</text>
<text class="topic-count">{{item.postCount}}則動態</text>
</view>
</scroll-view>
</view>
</uni-popup>
</view>
</template>
<script>
import uniPopup from '@/components/uni-popup/uni-popup.vue';
export default {
components: {
uniPopup
},
data() {
return {
content: '',
mediaList: [],
selectedLocation: null,
selectedTopics: [],
privacy: 'public', // public, friends, private
locationKeyword: '',
locationList: [],
topicKeyword: '',
topicList: [],
uploading: false
}
},
computed: {
canPublish() {
return (this.content.trim() || this.mediaList.length > 0) && !this.uploading;
}
},
methods: {
// 取消發佈
cancel() {
if (this.content || this.mediaList.length > 0) {
uni.showModal({
title: '確認取消',
content: '取消後內容將不會儲存,確定要離開嗎?',
success: (res) => {
if (res.confirm) {
uni.navigateBack();
}
}
});
} else {
uni.navigateBack();
}
},
// 選擇媒體檔案
chooseMedia() {
uni.showActionSheet({
itemList: ['拍照', '從相簿選擇', '錄影'],
success: (res) => {
switch (res.tapIndex) {
case 0:
this.takePhoto();
break;
case 1:
this.chooseImage();
break;
case 2:
this.recordVideo();
break;
}
}
});
},
// 拍照
takePhoto() {
uni.chooseImage({
count: 1,
sourceType: ['camera'],
success: (res) => {
this.uploadMedia(res.tempFilePaths[0], 'image');
}
});
},
// 選擇圖片
chooseImage() {
const remainCount = 9 - this.mediaList.length;
uni.chooseImage({
count: remainCount,
sourceType: ['album'],
success: (res) => {
res.tempFilePaths.forEach(filePath => {
this.uploadMedia(filePath, 'image');
});
}
});
},
// 錄影
recordVideo() {
uni.chooseVideo({
sourceType: ['camera'],
maxDuration: 60,
success: (res) => {
this.uploadMedia(res.tempFilePath, 'video');
}
});
},
// 上傳媒體檔案
async uploadMedia(filePath, type) {
this.uploading = true;
try {
// 顯示上傳進度
const uploadTask = uni.uploadFile({
url: this.$api.upload.uploadMedia,
filePath: filePath,
name: 'file',
header: {
'Authorization': `Bearer ${this.$store.state.user.token}`
},
success: (res) => {
const data = JSON.parse(res.data);
if (data.code === 200) {
this.mediaList.push({
type: type,
url: data.data.url,
localPath: filePath
});
} else {
uni.showToast({
title: '上傳失敗',
icon: 'none'
});
}
},
fail: (err) => {
uni.showToast({
title: '上傳失敗',
icon: 'none'
});
},
complete: () => {
this.uploading = false;
}
});
// 監聽上傳進度
uploadTask.onProgressUpdate((res) => {
console.log('上傳進度', res.progress);
});
} catch (error) {
this.uploading = false;
uni.showToast({
title: '上傳失敗',
icon: 'none'
});
}
},
// 移除媒體檔案
removeMedia(index) {
this.mediaList.splice(index, 1);
},
// 選擇位置
chooseLocation() {
this.getCurrentLocation();
this.$refs.locationPopup.open();
},
// 取得目前位置
getCurrentLocation() {
uni.getLocation({
type: 'gcj02',
success: (res) => {
this.searchNearbyLocations(res.latitude, res.longitude);
},
fail: (err) => {
uni.showToast({
title: '取得位置失敗',
icon: 'none'
});
}
});
},
// 搜尋附近位置
async searchNearbyLocations(lat, lng) {
try {
const res = await this.$api.location.searchNearby({
latitude: lat,
longitude: lng
});
this.locationList = res.data;
} catch (error) {
console.error('搜尋位置失敗', error);
}
},
// 搜尋位置
async searchLocation() {
if (!this.locationKeyword.trim()) return;
try {
const res = await this.$api.location.search({
keyword: this.locationKeyword
});
this.locationList = res.data;
} catch (error) {
console.error('搜尋位置失敗', error);
}
},
// 選擇位置
selectLocation(location) {
this.selectedLocation = location;
this.closeLocationPopup();
},
// 關閉位置彈窗
closeLocationPopup() {
this.$refs.locationPopup.close();
},
// 新增話題
addTopic() {
this.loadHotTopics();
this.$refs.topicPopup.open();
},
// 載入熱門話題
async loadHotTopics() {
try {
const res = await this.$api.topic.getHotTopics();
this.topicList = res.data;
} catch (error) {
console.error('載入話題失敗', error);
}
},
// 搜尋話題
async searchTopic() {
if (!this.topicKeyword.trim()) {
this.loadHotTopics();
return;
}
try {
const res = await this.$api.topic.search({
keyword: this.topicKeyword
});
this.topicList = res.data;
} catch (error) {
console.error('搜尋話題失敗', error);
}
},
// 選擇話題
selectTopic(topic) {
if (!this.selectedTopics.find(t => t.id === topic.id)) {
this.selectedTopics.push(topic);
this.content += ` #${topic.name}# `;
}
this.closeTopicPopup();
},
// 關閉話題彈窗
closeTopicPopup() {
this.$refs.topicPopup.close();
},
// 設定隱私
setPrivacy() {
uni.showActionSheet({
itemList: ['公開', '好友可見', '僅自己可見'],
success: (res) => {
const privacyMap = ['public', 'friends', 'private'];
this.privacy = privacyMap[res.tapIndex];
}
});
},
// 發佈動態
async publish() {
if (!this.canPublish) return;
try {
const postData = {
content: this.content.trim(),
mediaList: this.mediaList.map(item => ({
type: item.type,
url: item.url
})),
location: this.selectedLocation,
topics: this.selectedTopics.map(topic => topic.id),
privacy: this.privacy
};
await this.$api.post.create(postData);
uni.showToast({
title: '發佈成功',
icon: 'success'
});
// 返回上一頁並重新整理
uni.navigateBack();
} catch (error) {
uni.showToast({
title: error.message || '發佈失敗',
icon: 'none'
});
}
}
}
}
</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
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
440
441
442
443
444
445
446
447
448
449
450
451
452
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
440
441
442
443
444
445
446
447
448
449
450
451
452
3.3 即時聊天功能
即時聊天是社交應用的重要功能,需要實現訊息的即時收發和狀態同步。
js
// utils/socket.js - WebSocket 封裝
class SocketManager {
constructor() {
this.socket = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectInterval = 3000;
this.messageQueue = [];
this.eventHandlers = new Map();
}
// 連線
connect(token) {
if (this.socket && this.isConnected) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
try {
this.socket = uni.connectSocket({
url: `${process.env.VUE_APP_WS_URL}?token=${token}`,
success: () => {
console.log('WebSocket 連線成功');
},
fail: (err) => {
console.error('WebSocket 連線失敗', err);
reject(err);
}
});
this.socket.onOpen(() => {
this.isConnected = true;
this.reconnectAttempts = 0;
// 發送佇列中的訊息
this.flushMessageQueue();
resolve();
});
this.socket.onMessage((res) => {
this.handleMessage(JSON.parse(res.data));
});
this.socket.onClose(() => {
this.isConnected = false;
this.handleReconnect();
});
this.socket.onError((err) => {
console.error('WebSocket 錯誤', err);
this.isConnected = false;
});
} catch (error) {
reject(error);
}
});
}
// 斷線重連
handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(`嘗試重連 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => {
const token = uni.getStorageSync('token');
if (token) {
this.connect(token);
}
}, this.reconnectInterval);
}
}
// 處理收到的訊息
handleMessage(data) {
const { type, payload } = data;
// 觸發對應的事件處理器
if (this.eventHandlers.has(type)) {
const handlers = this.eventHandlers.get(type);
handlers.forEach(handler => handler(payload));
}
}
// 發送訊息
send(type, payload) {
const message = { type, payload };
if (this.isConnected) {
this.socket.send({
data: JSON.stringify(message)
});
} else {
// 連線斷開時,將訊息加入佇列
this.messageQueue.push(message);
}
}
// 清空訊息佇列
flushMessageQueue() {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
this.socket.send({
data: JSON.stringify(message)
});
}
}
// 註冊事件處理器
on(type, handler) {
if (!this.eventHandlers.has(type)) {
this.eventHandlers.set(type, []);
}
this.eventHandlers.get(type).push(handler);
}
// 移除事件處理器
off(type, handler) {
if (this.eventHandlers.has(type)) {
const handlers = this.eventHandlers.get(type);
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}
// 斷開連線
disconnect() {
if (this.socket) {
this.socket.close();
this.socket = null;
this.isConnected = false;
}
}
}
export default new SocketManager();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
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
vue
<!-- pages/chat/conversation/conversation.vue -->
<template>
<view class="conversation-container">
<!-- 聊天標題欄 -->
<view class="chat-header">
<view class="back-btn" @click="goBack">
<text class="iconfont icon-arrow-left"></text>
</view>
<view class="chat-info">
<text class="chat-title">{{chatInfo.name}}</text>
<text class="online-status" v-if="chatInfo.type === 'private'">
{{chatInfo.isOnline ? '線上' : '離線'}}
</text>
</view>
<view class="more-btn" @click="showMoreActions">
<text class="iconfont icon-more"></text>
</view>
</view>
<!-- 訊息列表 -->
<scroll-view
scroll-y
class="message-list"
:scroll-top="scrollTop"
:scroll-with-animation="true"
@scrolltoupper="loadMoreMessages"
>
<view class="message-item" v-for="(message, index) in messages" :key="message.id">
<!-- 時間分隔線 -->
<view class="time-divider" v-if="shouldShowTime(message, index)">
<text class="time-text">{{formatMessageTime(message.timestamp)}}</text>
</view>
<!-- 訊息內容 -->
<view class="message-wrapper" :class="{ 'own-message': message.senderId === currentUserId }">
<!-- 頭像 -->
<image
:src="message.senderAvatar"
class="avatar"
v-if="message.senderId !== currentUserId"
/>
<!-- 訊息氣泡 -->
<view class="message-bubble" :class="getMessageBubbleClass(message)">
<!-- 文字訊息 -->
<text v-if="message.type === 'text'" class="message-text">{{message.content}}</text>
<!-- 圖片訊息 -->
<image
v-if="message.type === 'image'"
:src="message.content"
mode="widthFix"
class="message-image"
@click="previewImage(message.content)"
/>
<!-- 語音訊息 -->
<view v-if="message.type === 'voice'" class="voice-message" @click="playVoice(message)">
<text class="iconfont icon-voice"></text>
<text class="voice-duration">{{message.duration}}''</text>
</view>
<!-- 檔案訊息 -->
<view v-if="message.type === 'file'" class="file-message" @click="downloadFile(message)">
<text class="iconfont icon-file"></text>
<view class="file-info">
<text class="file-name">{{message.fileName}}</text>
<text class="file-size">{{formatFileSize(message.fileSize)}}</text>
</view>
</view>
</view>
<!-- 訊息狀態 -->
<view class="message-status" v-if="message.senderId === currentUserId">
<text class="iconfont icon-loading" v-if="message.status === 'sending'"></text>
<text class="iconfont icon-check" v-if="message.status === 'sent'"></text>
<text class="iconfont icon-check-double" v-if="message.status === 'delivered'"></text>
<text class="iconfont icon-check-double read" v-if="message.status === 'read'"></text>
<text class="iconfont icon-error" v-if="message.status === 'failed'" @click="resendMessage(message)"></text>
</view>
</view>
</view>
<!-- 載入更多指示器 -->
<view class="loading-more" v-if="loadingMore">
<text>載入中...</text>
</view>
</scroll-view>
<!-- 輸入區域 -->
<view class="input-area">
<view class="input-toolbar">
<text class="tool-btn iconfont icon-voice" @click="toggleVoiceInput"></text>
<text class="tool-btn iconfont icon-emoji" @click="showEmojiPanel"></text>
<text class="tool-btn iconfont icon-plus" @click="showMoreOptions"></text>
</view>
<!-- 文字輸入 -->
<view class="text-input-wrapper" v-if="!isVoiceMode">
<textarea
v-model="inputText"
placeholder="輸入訊息..."
class="text-input"
:auto-height="true"
:maxlength="1000"
@input="onInputChange"
@focus="onInputFocus"
@blur="onInputBlur"
/>
<button
class="send-btn"
:class="{ disabled: !canSend }"
@click="sendTextMessage"
>發送</button>
</view>
<!-- 語音輸入 -->
<view class="voice-input-wrapper" v-else>
<button
class="voice-btn"
:class="{ recording: isRecording }"
@touchstart="startRecording"
@touchend="stopRecording"
@touchcancel="cancelRecording"
>
{{isRecording ? '鬆開發送' : '按住說話'}}
</button>
</view>
</view>
<!-- 表情符號面板 -->
<view class="emoji-panel" v-if="showEmoji">
<scroll-view scroll-x class="emoji-categories">
<view
class="emoji-category"
:class="{ active: currentEmojiCategory === category.key }"
v-for="category in emojiCategories"
:key="category.key"
@click="switchEmojiCategory(category.key)"
>
{{category.name}}
</view>
</scroll-view>
<scroll-view scroll-y class="emoji-list">
<view
class="emoji-item"
v-for="emoji in currentEmojis"
:key="emoji.code"
@click="insertEmoji(emoji)"
>
{{emoji.char}}
</view>
</scroll-view>
</view>
<!-- 更多選項面板 -->
<view class="more-options-panel" v-if="showMore">
<view class="option-grid">
<view class="option-item" @click="chooseImage">
<text class="iconfont icon-image"></text>
<text>照片</text>
</view>
<view class="option-item" @click="takePhoto">
<text class="iconfont icon-camera"></text>
<text>拍照</text>
</view>
<view class="option-item" @click="chooseFile">
<text class="iconfont icon-file"></text>
<text>檔案</text>
</view>
<view class="option-item" @click="shareLocation">
<text class="iconfont icon-location"></text>
<text>位置</text>
</view>
</view>
</view>
</view>
</template>
<script>
import { mapState } from 'vuex';
import socketManager from '@/utils/socket.js';
export default {
data() {
return {
chatId: '',
chatInfo: {},
messages: [],
inputText: '',
isVoiceMode: false,
isRecording: false,
showEmoji: false,
showMore: false,
scrollTop: 0,
loadingMore: false,
hasMoreMessages: true,
currentEmojiCategory: 'recent',
emojiCategories: [
{ key: 'recent', name: '最近' },
{ key: 'people', name: '表情' },
{ key: 'nature', name: '自然' },
{ key: 'objects', name: '物品' }
],
recordingTimer: null,
recordingStartTime: 0
}
},
computed: {
...mapState('user', ['userInfo']),
currentUserId() {
return this.userInfo?.id;
},
canSend() {
return this.inputText.trim().length > 0;
},
currentEmojis() {
// 根據當前分類返回表情符號列表
return this.getEmojisByCategory(this.currentEmojiCategory);
}
},
onLoad(options) {
this.chatId = options.id;
this.initChat();
},
onShow() {
this.scrollToBottom();
},
onUnload() {
this.cleanup();
},
methods: {
// 初始化聊天
async initChat() {
try {
// 載入聊天資訊
await this.loadChatInfo();
// 載入歷史訊息
await this.loadMessages();
// 建立 WebSocket 連線
await this.connectSocket();
// 標記訊息為已讀
this.markMessagesAsRead();
} catch (error) {
console.error('初始化聊天失敗', error);
uni.showToast({
title: '載入失敗',
icon: 'none'
});
}
},
// 載入聊天資訊
async loadChatInfo() {
const res = await this.$api.chat.getChatInfo(this.chatId);
this.chatInfo = res.data;
},
// 載入訊息
async loadMessages(loadMore = false) {
if (loadMore && !this.hasMoreMessages) return;
this.loadingMore = loadMore;
try {
const params = {
chatId: this.chatId,
limit: 20
};
if (loadMore && this.messages.length > 0) {
params.before = this.messages[0].timestamp;
}
const res = await this.$api.chat.getMessages(params);
const newMessages = res.data.messages;
if (loadMore) {
this.messages = [...newMessages, ...this.messages];
} else {
this.messages = newMessages;
this.$nextTick(() => {
this.scrollToBottom();
});
}
this.hasMoreMessages = newMessages.length === params.limit;
} catch (error) {
console.error('載入訊息失敗', error);
} finally {
this.loadingMore = false;
}
},
// 建立 Socket 連線
async connectSocket() {
const token = uni.getStorageSync('token');
await socketManager.connect(token);
// 監聽新訊息
socketManager.on('newMessage', this.handleNewMessage);
// 監聽訊息狀態更新
socketManager.on('messageStatusUpdate', this.handleMessageStatusUpdate);
// 監聽使用者線上狀態
socketManager.on('userOnlineStatus', this.handleUserOnlineStatus);
},
// 處理新訊息
handleNewMessage(message) {
if (message.chatId === this.chatId) {
this.messages.push(message);
this.$nextTick(() => {
this.scrollToBottom();
});
// 標記為已讀
this.markMessageAsRead(message.id);
}
},
// 處理訊息狀態更新
handleMessageStatusUpdate(data) {
const { messageId, status } = data;
const message = this.messages.find(m => m.id === messageId);
if (message) {
message.status = status;
}
},
// 處理使用者線上狀態
handleUserOnlineStatus(data) {
if (this.chatInfo.type === 'private' && data.userId === this.chatInfo.userId) {
this.chatInfo.isOnline = data.isOnline;
}
},
// 發送文字訊息
async sendTextMessage() {
if (!this.canSend) return;
const messageId = this.generateMessageId();
const message = {
id: messageId,
chatId: this.chatId,
senderId: this.currentUserId,
senderAvatar: this.userInfo.avatar,
type: 'text',
content: this.inputText.trim(),
timestamp: Date.now(),
status: 'sending'
};
// 新增到訊息列表
this.messages.push(message);
this.inputText = '';
// 滾動到底部
this.$nextTick(() => {
this.scrollToBottom();
});
try {
// 發送到伺服器
socketManager.send('sendMessage', message);
// 更新狀態為已發送
message.status = 'sent';
} catch (error) {
console.error('發送訊息失敗', error);
message.status = 'failed';
}
},
// 重新發送訊息
resendMessage(message) {
message.status = 'sending';
socketManager.send('sendMessage', message);
},
// 開始錄音
startRecording() {
this.isRecording = true;
this.recordingStartTime = Date.now();
uni.startRecord({
success: (res) => {
console.log('開始錄音');
},
fail: (err) => {
console.error('錄音失敗', err);
this.isRecording = false;
}
});
},
// 停止錄音
stopRecording() {
if (!this.isRecording) return;
this.isRecording = false;
const duration = Math.floor((Date.now() - this.recordingStartTime) / 1000);
if (duration < 1) {
uni.showToast({
title: '錄音時間太短',
icon: 'none'
});
return;
}
uni.stopRecord({
success: (res) => {
this.sendVoiceMessage(res.tempFilePath, duration);
},
fail: (err) => {
console.error('停止錄音失敗', err);
}
});
},
// 取消錄音
cancelRecording() {
if (this.isRecording) {
this.isRecording = false;
uni.stopRecord();
}
},
// 發送語音訊息
async sendVoiceMessage(filePath, duration) {
try {
// 上傳語音檔案
const uploadRes = await this.uploadFile(filePath);
const messageId = this.generateMessageId();
const message = {
id: messageId,
chatId: this.chatId,
senderId: this.currentUserId,
senderAvatar: this.userInfo.avatar,
type: 'voice',
content: uploadRes.url,
duration: duration,
timestamp: Date.now(),
status: 'sending'
};
this.messages.push(message);
this.$nextTick(() => {
this.scrollToBottom();
});
socketManager.send('sendMessage', message);
message.status = 'sent';
} catch (error) {
console.error('發送語音訊息失敗', error);
uni.showToast({
title: '發送失敗',
icon: 'none'
});
}
},
// 選擇圖片
chooseImage() {
uni.chooseImage({
count: 9,
success: (res) => {
res.tempFilePaths.forEach(filePath => {
this.sendImageMessage(filePath);
});
}
});
this.showMore = false;
},
// 發送圖片訊息
async sendImageMessage(filePath) {
try {
const uploadRes = await this.uploadFile(filePath);
const messageId = this.generateMessageId();
const message = {
id: messageId,
chatId: this.chatId,
senderId: this.currentUserId,
senderAvatar: this.userInfo.avatar,
type: 'image',
content: uploadRes.url,
timestamp: Date.now(),
status: 'sending'
};
this.messages.push(message);
this.$nextTick(() => {
this.scrollToBottom();
});
socketManager.send('sendMessage', message);
message.status = 'sent';
} catch (error) {
console.error('發送圖片失敗', error);
uni.showToast({
title: '發送失敗',
icon: 'none'
});
}
},
// 上傳檔案
uploadFile(filePath) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: this.$api.upload.uploadFile,
filePath: filePath,
name: 'file',
header: {
'Authorization': `Bearer ${this.$store.state.user.token}`
},
success: (res) => {
const data = JSON.parse(res.data);
if (data.code === 200) {
resolve(data.data);
} else {
reject(new Error(data.message));
}
},
fail: reject
});
});
},
// 標記訊息為已讀
markMessagesAsRead() {
const unreadMessages = this.messages.filter(m =>
m.senderId !== this.currentUserId && m.status !== 'read'
);
if (unreadMessages.length > 0) {
socketManager.send('markAsRead', {
chatId: this.chatId,
messageIds: unreadMessages.map(m => m.id)
});
}
},
// 標記單個訊息為已讀
markMessageAsRead(messageId) {
socketManager.send('markAsRead', {
chatId: this.chatId,
messageIds: [messageId]
});
},
// 滾動到底部
scrollToBottom() {
this.$nextTick(() => {
this.scrollTop = 999999;
});
},
// 載入更多訊息
loadMoreMessages() {
if (!this.loadingMore && this.hasMoreMessages) {
this.loadMessages(true);
}
},
// 生成訊息ID
generateMessageId() {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
},
// 格式化訊息時間
formatMessageTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('zh-TW', {
hour: '2-digit',
minute: '2-digit'
});
} else {
return date.toLocaleDateString('zh-TW', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
},
// 是否顯示時間
shouldShowTime(message, index) {
if (index === 0) return true;
const prevMessage = this.messages[index - 1];
const timeDiff = message.timestamp - prevMessage.timestamp;
// 超過5分鐘顯示時間
return timeDiff > 5 * 60 * 1000;
},
// 取得訊息氣泡樣式
getMessageBubbleClass(message) {
const classes = [];
if (message.senderId === this.currentUserId) {
classes.push('own-bubble');
} else {
classes.push('other-bubble');
}
if (message.type === 'image') {
classes.push('image-bubble');
}
return classes;
},
// 清理資源
cleanup() {
socketManager.off('newMessage', this.handleNewMessage);
socketManager.off('messageStatusUpdate', this.handleMessageStatusUpdate);
socketManager.off('userOnlineStatus', this.handleUserOnlineStatus);
},
// 返回
goBack() {
uni.navigateBack();
}
}
}
</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
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
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
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
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
4. 最佳實踐
4.1 效能最佳化
良好的效能是社交應用成功的關鍵:
- 圖片載入最佳化:使用適當的圖片格式和尺寸,實現懶載入
- 訊息分頁載入:避免一次載入過多歷史訊息
- 虛擬列表:對於長列表使用虛擬滾動技術
- 快取策略:合理使用本地儲存快取使用者資料和聊天記錄
js
// 圖片懶載入範例
export default {
data() {
return {
imageObserver: null
}
},
mounted() {
this.initImageLazyLoad();
},
methods: {
initImageLazyLoad() {
// 建立 Intersection Observer
this.imageObserver = uni.createIntersectionObserver(this);
this.imageObserver.relativeToViewport().observe('.lazy-image', (res) => {
if (res.intersectionRatio > 0) {
// 圖片進入可視區域,開始載入
const target = res.target;
const dataSrc = target.dataset.src;
if (dataSrc) {
target.src = dataSrc;
target.removeAttribute('data-src');
this.imageObserver.unobserve(target);
}
}
});
}
}
}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
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
4.2 資料安全
社交應用涉及大量使用者隱私資料,必須重視資料安全:
- 資料加密:敏感資料傳輸和儲存加密
- 權限控制:細粒度的使用者權限管理
- 內容審核:自動化內容審核機制
- 隱私設定:完善的隱私設定選項
js
// 表單驗證範例
const validator = {
// 使用者名稱驗證
username(value) {
if (!value) return '使用者名稱不能為空';
if (value.length < 3) return '使用者名稱至少3個字元';
if (value.length > 20) return '使用者名稱不能超過20個字元';
if (!/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/.test(value)) return '使用者名稱只能包含字母、數字、底線和中文';
return '';
},
// 密碼驗證
password(value) {
if (!value) return '密碼不能為空';
if (value.length < 6) return '密碼至少6個字元';
if (value.length > 20) return '密碼不能超過20個字元';
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) return '密碼必須包含大小寫字母和數字';
return '';
},
// 手機號驗證
phone(value) {
if (!value) return '手機號不能為空';
if (!/^1[3-9]\d{9}$/.test(value)) return '手機號格式不正確';
return '';
},
// 信箱驗證
email(value) {
if (!value) return '信箱不能為空';
if (!/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(value)) return '信箱格式不正確';
return '';
}
};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
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
4.3 使用者體驗最佳化
良好的使用者體驗是社交應用成功的關鍵:
- 響應式設計:適配不同螢幕尺寸和裝置
- 骨架屏:在內容載入過程中顯示骨架屏,減少使用者等待感
- 下拉重新整理和上拉載入:提供流暢的列表互動體驗
- 狀態回饋:操作後給予及時的狀態回饋
- 離線支援:支援基本的離線瀏覽功能
vue
<!-- 骨架屏範例 -->
<template>
<view class="skeleton-container" v-if="loading">
<view class="skeleton-item" v-for="i in 5" :key="i">
<view class="skeleton-avatar"></view>
<view class="skeleton-content">
<view class="skeleton-title"></view>
<view class="skeleton-text"></view>
<view class="skeleton-text short"></view>
</view>
</view>
</view>
<view v-else>
<!-- 實際內容 -->
</view>
</template>
<style lang="scss">
.skeleton-container {
padding: 20rpx;
.skeleton-item {
display: flex;
padding: 20rpx 0;
border-bottom: 1rpx solid #f5f5f5;
.skeleton-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
}
.skeleton-content {
flex: 1;
margin-left: 20rpx;
.skeleton-title {
height: 32rpx;
width: 40%;
background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
margin-bottom: 16rpx;
}
.skeleton-text {
height: 24rpx;
width: 100%;
background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
margin-bottom: 16rpx;
&.short {
width: 60%;
}
}
}
}
}
@keyframes skeleton-loading {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
</style>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
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
5. 總結與拓展
5.1 開發要點總結
- 模組化設計:將應用拆分為多個功能模組,提高程式碼可維護性
- 狀態管理:使用Vuex集中管理應用狀態,處理複雜的資料流
- 即時通訊:基於WebSocket實現即時訊息功能,提供良好的聊天體驗
- 效能最佳化:針對大量資料和頻繁網路請求進行最佳化,提高應用回應速度
- 安全措施:重視使用者資料安全和隱私保護,實施必要的安全措施
5.2 功能拓展方向
基於社交應用的基礎功能,可以考慮以下拓展方向:
- 社群功能:新增興趣小組、話題討論等社群功能
- 內容推薦:基於使用者興趣和行為的個人化內容推薦
- 直播功能:支援使用者發起和觀看直播
- 電商功能:整合電商功能,支援商品展示和交易
- AR互動:新增AR濾鏡、特效等互動功能
5.3 商業化思路
社交應用的商業化路徑通常包括:
- 廣告變現:基於使用者畫像的精準廣告投放
- 會員訂閱:提供進階功能的付費會員服務
- 虛擬商品:販售表情包、主題、裝飾等虛擬商品
- 電商佣金:透過商品推薦獲得佣金收入
- 資料服務:為企業提供匿名化的使用者行為資料分析
5.4 參考資源
透過本案例的學習,您可以掌握使用 uni-app 開發功能完善的社交應用的核心技術和最佳實踐。在實際開發過程中,建議根據具體需求調整架構設計和功能實現,持續最佳化使用者體驗和應用效能。