社交应用实战案例
本文将介绍如何使用 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 动态发布与互动
社交应用的核心功能是用户发布动态并进行互动,包括点赞、评论、转发等。
动态状态管理
js
// store/modules/post.js
export default {
namespaced: true,
state: {
postList: [],
currentPost: null,
loading: false,
refreshing: false,
hasMore: true,
page: 1,
pageSize: 10
},
mutations: {
SET_POST_LIST(state, list) {
state.postList = list;
},
ADD_POST_LIST(state, list) {
state.postList = [...state.postList, ...list];
},
SET_CURRENT_POST(state, post) {
state.currentPost = post;
},
SET_LOADING(state, status) {
state.loading = status;
},
SET_REFRESHING(state, status) {
state.refreshing = status;
},
SET_HAS_MORE(state, status) {
state.hasMore = status;
},
SET_PAGE(state, page) {
state.page = page;
},
ADD_POST(state, post) {
state.postList.unshift(post);
},
UPDATE_POST(state, { id, data }) {
const index = state.postList.findIndex(item => item.id === id);
if (index !== -1) {
state.postList[index] = { ...state.postList[index], ...data };
}
if (state.currentPost && state.currentPost.id === id) {
state.currentPost = { ...state.currentPost, ...data };
}
},
DELETE_POST(state, id) {
state.postList = state.postList.filter(item => item.id !== id);
if (state.currentPost && state.currentPost.id === id) {
state.currentPost = null;
}
}
},
actions: {
// 获取动态列表
async getPostList({ commit, state }, refresh = false) {
if (refresh) {
commit('SET_REFRESHING', true);
commit('SET_PAGE', 1);
commit('SET_HAS_MORE', true);
} else {
if (!state.hasMore || state.loading) return;
commit('SET_LOADING', true);
}
try {
const res = await this._vm.$api.post.getList({
page: refresh ? 1 : state.page,
pageSize: state.pageSize
});
const list = res.data.list || [];
if (refresh) {
commit('SET_POST_LIST', list);
} else {
commit('ADD_POST_LIST', list);
}
commit('SET_HAS_MORE', list.length === state.pageSize);
commit('SET_PAGE', refresh ? 2 : state.page + 1);
} catch (error) {
console.error('获取动态列表失败', error);
} finally {
commit('SET_LOADING', false);
commit('SET_REFRESHING', false);
}
},
// 获取动态详情
async getPostDetail({ commit }, id) {
try {
const res = await this._vm.$api.post.getDetail(id);
commit('SET_CURRENT_POST', res.data);
return res.data;
} catch (error) {
throw error;
}
},
// 发布动态
async createPost({ commit }, data) {
try {
const res = await this._vm.$api.post.create(data);
commit('ADD_POST', res.data);
return res.data;
} catch (error) {
throw error;
}
},
// 点赞/取消点赞
async toggleLike({ commit }, id) {
try {
const res = await this._vm.$api.post.toggleLike(id);
commit('UPDATE_POST', {
id,
data: {
isLiked: res.data.isLiked,
likeCount: res.data.likeCount
}
});
return res.data;
} catch (error) {
throw error;
}
},
// 发表评论
async addComment({ commit }, { postId, content }) {
try {
const res = await this._vm.$api.post.addComment(postId, { content });
commit('UPDATE_POST', {
id: postId,
data: {
commentCount: res.data.commentCount,
latestComments: res.data.latestComments
}
});
return res.data;
} catch (error) {
throw error;
}
}
}
};
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
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
动态发布页面
vue
<!-- pages/post/create/create.vue -->
<template>
<view class="post-container">
<view class="textarea-box">
<textarea
v-model="content"
placeholder="分享你的想法..."
maxlength="500"
class="content-textarea"
></textarea>
<text class="word-count">{{content.length}}/500</text>
</view>
<view class="image-list" v-if="images.length > 0">
<view
class="image-item"
v-for="(item, index) in images"
:key="index"
>
<image :src="item" mode="aspectFill" class="preview-image"></image>
<text class="delete-icon" @click="removeImage(index)">×</text>
</view>
<view class="add-image" v-if="images.length < 9" @click="chooseImage">
<text class="iconfont icon-add"></text>
</view>
</view>
<view class="add-image-btn" v-else @click="chooseImage">
<text class="iconfont icon-image"></text>
<text class="btn-text">添加图片</text>
</view>
<view class="location-box" @click="chooseLocation">
<text class="iconfont icon-location"></text>
<text class="location-text">{{location ? location : '所在位置'}}</text>
<text class="iconfont icon-right" v-if="!location"></text>
<text class="clear-icon" v-else @click.stop="clearLocation">×</text>
</view>
<view class="topic-box" @click="chooseTopic">
<text class="iconfont icon-topic"></text>
<text class="topic-text">{{topic ? `#${topic}#` : '添加话题'}}</text>
<text class="iconfont icon-right" v-if="!topic"></text>
<text class="clear-icon" v-else @click.stop="clearTopic">×</text>
</view>
<view class="privacy-box">
<text class="privacy-label">谁可以看</text>
<picker
@change="onPrivacyChange"
:value="privacyIndex"
:range="privacyOptions"
>
<view class="picker-value">
<text>{{privacyOptions[privacyIndex]}}</text>
<text class="iconfont icon-down"></text>
</view>
</picker>
</view>
<button
class="publish-btn"
:disabled="!canPublish"
:loading="publishing"
@click="publishPost"
>发布</button>
</view>
</template>
<script>
import { mapActions } from 'vuex';
export default {
data() {
return {
content: '',
images: [],
location: '',
topic: '',
privacyOptions: ['公开', '仅好友可见', '仅自己可见'],
privacyIndex: 0,
publishing: false
}
},
computed: {
canPublish() {
return this.content.trim() || this.images.length > 0;
}
},
methods: {
...mapActions('post', ['createPost']),
// 选择图片
chooseImage() {
uni.chooseImage({
count: 9 - this.images.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
this.images = [...this.images, ...res.tempFilePaths];
}
});
},
// 移除图片
removeImage(index) {
this.images.splice(index, 1);
},
// 选择位置
chooseLocation() {
uni.chooseLocation({
success: (res) => {
this.location = res.name;
}
});
},
// 清除位置
clearLocation() {
this.location = '';
},
// 选择话题
chooseTopic() {
// 这里可以跳转到话题选择页面
uni.navigateTo({
url: '/pages/topic/select/select',
events: {
// 监听选择话题页面返回的数据
selectTopic: (data) => {
this.topic = data.name;
}
}
});
},
// 清除话题
clearTopic() {
this.topic = '';
},
// 隐私设置变更
onPrivacyChange(e) {
this.privacyIndex = e.detail.value;
},
// 发布动态
async publishPost() {
if (!this.canPublish) {
return;
}
this.publishing = true;
try {
// 上传图片
let imageUrls = [];
if (this.images.length > 0) {
imageUrls = await this.uploadImages();
}
// 构建动态数据
const postData = {
content: this.content,
images: imageUrls,
location: this.location,
topic: this.topic,
privacy: this.privacyIndex
};
// 调用发布接口
await this.createPost(postData);
uni.showToast({
title: '发布成功',
icon: 'success'
});
// 返回上一页
setTimeout(() => {
uni.navigateBack();
}, 1500);
} catch (error) {
uni.showToast({
title: error.message || '发布失败,请重试',
icon: 'none'
});
} finally {
this.publishing = false;
}
},
// 上传图片
uploadImages() {
return new Promise((resolve, reject) => {
const uploadTasks = this.images.map(image => {
return new Promise((resolveUpload, rejectUpload) => {
uni.uploadFile({
url: this.$api.baseUrl + '/api/upload',
filePath: image,
name: 'file',
header: {
Authorization: 'Bearer ' + uni.getStorageSync('token')
},
success: (uploadRes) => {
const data = JSON.parse(uploadRes.data);
if (data.code === 0) {
resolveUpload(data.data.url);
} else {
rejectUpload(new Error(data.message || '上传失败'));
}
},
fail: (err) => {
rejectUpload(err);
}
});
});
});
Promise.all(uploadTasks)
.then(urls => {
resolve(urls);
})
.catch(err => {
reject(err);
});
});
}
}
}
</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
3.3 即时通讯功能
社交应用的即时通讯功能是用户交流的重要渠道,需要实现消息的发送、接收和存储。
WebSocket封装
js
// utils/socket.js
import io from 'socket.io-client';
import store from '@/store';
class SocketIO {
constructor() {
this.socket = null;
this.url = process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: 'https://api.example.com';
this.options = {
transports: ['websocket'],
autoConnect: false,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000
};
this.events = {};
}
// 初始化连接
init() {
if (this.socket) {
this.socket.disconnect();
}
const token = uni.getStorageSync('token');
if (!token) return;
this.options.query = { token };
this.socket = io(this.url, this.options);
// 注册内置事件
this.socket.on('connect', () => {
console.log('WebSocket连接成功');
this.onConnect();
});
this.socket.on('disconnect', (reason) => {
console.log('WebSocket断开连接', reason);
this.onDisconnect(reason);
});
this.socket.on('error', (error) => {
console.error('WebSocket错误', error);
});
this.socket.on('reconnect', (attemptNumber) => {
console.log('WebSocket重连成功', attemptNumber);
});
this.socket.on('reconnect_error', (error) => {
console.error('WebSocket重连失败', error);
});
this.socket.on('reconnect_failed', () => {
console.error('WebSocket重连失败次数超限');
uni.showToast({
title: '网络连接异常,请检查网络设置',
icon: 'none'
});
});
// 注册消息事件
this.socket.on('message', (data) => {
this.onMessage(data);
});
// 连接
this.socket.connect();
}
// 连接成功回调
onConnect() {
// 重新注册之前的事件
Object.keys(this.events).forEach(event => {
const callbacks = this.events[event];
callbacks.forEach(callback => {
this.socket.on(event, callback);
});
});
}
// 断开连接回调
onDisconnect(reason) {
if (reason === 'io server disconnect') {
// 服务器主动断开连接,可能是token过期
store.dispatch('user/logout');
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none'
});
setTimeout(() => {
uni.reLaunch({
url: '/pages/auth/login/login'
});
}, 1500);
}
}
// 接收消息回调
onMessage(data) {
// 处理接收到的消息
store.dispatch('chat/receiveMessage', data);
// 如果应用在后台,发送通知
if (uni.getSystemInfoSync().platform === 'android' || uni.getSystemInfoSync().platform === 'ios') {
uni.getBackgroundAudioManager().title = '新消息提醒';
uni.getBackgroundAudioManager().src = '/static/audio/message.mp3';
}
}
// 注册事件
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
if (this.socket) {
this.socket.on(event, callback);
}
}
// 发送消息
emit(event, data, callback) {
if (!this.socket || !this.socket.connected) {
uni.showToast({
title: '网络连接异常,请检查网络设置',
icon: 'none'
});
return;
}
this.socket.emit(event, data, callback);
}
// 断开连接
disconnect() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
}
export default new SocketIO();
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
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
聊天页面实现
vue
<!-- pages/chat/chat.vue -->
<template>
<view class="chat-container">
<!-- 消息列表 -->
<scroll-view
scroll-y
class="message-list"
:scroll-top="scrollTop"
:scroll-into-view="scrollIntoView"
@scrolltoupper="loadMoreMessages"
upper-threshold="50"
>
<view class="loading" v-if="loading">
<uni-load-more status="loading" :contentText="loadingText"></uni-load-more>
</view>
<view
class="message-item"
:class="{ 'message-self': item.senderId === userId }"
v-for="item in messages"
:key="item.id"
:id="'msg-' + item.id"
>
<image
:src="item.senderId === userId ? userAvatar : targetAvatar"
class="avatar"
></image>
<view class="message-content">
<view class="message-bubble" :class="{ 'self-bubble': item.senderId === userId }">
<!-- 文本消息 -->
<text v-if="item.type === 'text'">{{item.content}}</text>
<!-- 图片消息 -->
<image
v-else-if="item.type === 'image'"
:src="item.content"
mode="widthFix"
class="message-image"
@tap="previewImage(item.content)"
></image>
<!-- 语音消息 -->
<view
v-else-if="item.type === 'voice'"
class="voice-message"
:class="{ 'playing': playingVoiceId === item.id }"
@tap="playVoice(item)"
>
<text class="voice-duration">{{item.duration}}''</text>
<view class="voice-icon">
<text class="iconfont icon-voice"></text>
</view>
</view>
</view>
<text class="message-time">{{formatTime(item.createdAt)}}</text>
</view>
</view>
</scroll-view>
<!-- 输入区域 -->
<view class="input-area">
<view class="input-box">
<view class="input-tools">
<text
class="iconfont"
:class="isVoiceMode ? 'icon-keyboard' : 'icon-voice'"
@tap="toggleInputMode"
></text>
</view>
<input
v-if="!isVoiceMode"
type="text"
v-model="inputContent"
class="text-input"
placeholder="请输入消息..."
confirm-type="send"
@confirm="sendTextMessage"
/>
<view
v-else
class="voice-input"
@touchstart="startRecordVoice"
@touchend="stopRecordVoice"
@touchcancel="cancelRecordVoice"
>
{{recording ? '松开发送' : '按住说话'}}
</view>
</view>
<!-- 发送按钮 -->
<view
class="send-btn"
:class="{ 'active': inputContent.trim() }"
@tap="sendTextMessage"
>
<text>发送</text>
</view>
</view>
</view>
</template>
<script>
import { mapState } from 'vuex';
import socket from '@/utils/socket.js';
import { formatTime } from '@/utils/time.js';
export default {
data() {
return {
targetId: '', // 聊天对象ID
targetName: '', // 聊天对象名称
targetAvatar: '', // 聊天对象头像
messages: [], // 消息列表
inputContent: '', // 输入内容
isVoiceMode: false, // 是否为语音输入模式
recording: false, // 是否正在录音
recorderManager: null, // 录音管理器
innerAudioContext: null, // 音频播放器
playingVoiceId: '', // 正在播放的语音消息ID
scrollTop: 0, // 滚动位置
scrollIntoView: '', // 滚动到指定消息
loading: false, // 是否正在加载更多消息
loadingText: {
contentdown: '上拉加载更多',
contentrefresh: '正在加载...',
contentnomore: '没有更多了'
},
page: 1, // 当前页码
hasMore: true // 是否有更多消息
}
},
computed: {
...mapState('user', {
userId: state => state.userInfo ? state.userInfo.id : '',
userAvatar: state => state.userInfo ? state.userInfo.avatar : ''
})
},
onLoad(options) {
this.targetId = options.id;
this.targetName = options.name || '聊天';
this.targetAvatar = options.avatar || '/static/images/default-avatar.png';
// 设置导航栏标题
uni.setNavigationBarTitle({
title: this.targetName
});
// 初始化录音管理器
this.recorderManager = uni.getRecorderManager();
this.recorderManager.onStop(this.onRecordStop);
// 初始化音频播放器
this.innerAudioContext = uni.createInnerAudioContext();
this.innerAudioContext.onEnded(this.onVoicePlayEnd);
this.innerAudioContext.onError(this.onVoicePlayError);
// 加载消息列表
this.loadMessages();
// 监听新消息
socket.on('receiveMessage', this.onReceiveMessage);
},
onUnload() {
// 移除消息监听
socket.off('receiveMessage', this.onReceiveMessage);
// 释放资源
if (this.innerAudioContext) {
this.innerAudioContext.destroy();
}
},
methods: {
// 加载消息列表
async loadMessages() {
try {
const res = await this.$api.chat.getMessages({
targetId: this.targetId,
page: 1,
pageSize: 20
});
this.messages = res.data.list || [];
this.hasMore = res.data.hasMore;
this.page = 2;
// 滚动到最新消息
this.$nextTick(() => {
if (this.messages.length > 0) {
this.scrollIntoView = 'msg-' + this.messages[this.messages.length - 1].id;
}
});
} catch (error) {
uni.showToast({
title: '加载消息失败',
icon: 'none'
});
}
},
// 加载更多消息
async loadMoreMessages() {
if (!this.hasMore || this.loading) return;
this.loading = true;
try {
const res = await this.$api.chat.getMessages({
targetId: this.targetId,
page: this.page,
pageSize: 20
});
const list = res.data.list || [];
this.messages = [...list, ...this.messages];
this.hasMore = res.data.hasMore;
this.page++;
} catch (error) {
uni.showToast({
title: '加载更多消息失败',
icon: 'none'
});
} finally {
this.loading = false;
}
},
// 发送文本消息
async sendTextMessage() {
if (!this.inputContent.trim()) return;
const content = this.inputContent;
this.inputContent = '';
// 构建消息对象
const message = {
id: 'temp-' + Date.now(),
senderId: this.userId,
receiverId: this.targetId,
type: 'text',
content: content,
status: 'sending',
createdAt: new Date().toISOString()
};
// 添加到消息列表
this.messages.push(message);
// 滚动到最新消息
this.$nextTick(() => {
this.scrollIntoView = 'msg-' + message.id;
});
try {
// 发送消息
const res = await this.$api.chat.sendMessage({
receiverId: this.targetId,
type: 'text',
content: content
});
// 更新消息状态
const index = this.messages.findIndex(item => item.id === message.id);
if (index !== -1) {
this.messages[index] = {
...this.messages[index],
id: res.data.id,
status: 'sent'
};
}
} catch (error) {
// 更新消息状态为发送失败
const index = this.messages.findIndex(item => item.id === message.id);
if (index !== -1) {
this.messages[index] = {
...this.messages[index],
status: 'failed'
};
}
uni.showToast({
title: '发送失败,请重试',
icon: 'none'
});
}
},
// 切换输入模式
toggleInputMode() {
this.isVoiceMode = !this.isVoiceMode;
},
// 开始录音
startRecordVoice(e) {
this.recording = true;
this.recorderManager.start({
duration: 60000, // 最长录音时间,单位ms
sampleRate: 16000, // 采样率
numberOfChannels: 1, // 录音通道数
encodeBitRate: 64000, // 编码码率
format: 'mp3' // 音频格式
});
},
// 停止录音
stopRecordVoice(e) {
if (!this.recording) return;
this.recorderManager.stop();
this.recording = false;
},
// 取消录音
cancelRecordVoice(e) {
if (!this.recording) return;
this.recorderManager.stop();
this.recording = false;
uni.showToast({
title: '已取消',
icon: 'none'
});
},
// 录音结束回调
async onRecordStop(res) {
if (res.duration < 1000) {
uni.showToast({
title: '说话时间太短',
icon: 'none'
});
return;
}
try {
// 上传语音文件
const uploadRes = await new Promise((resolve, reject) => {
uni.uploadFile({
url: this.$api.baseUrl + '/api/upload',
filePath: res.tempFilePath,
name: 'file',
header: {
Authorization: 'Bearer ' + uni.getStorageSync('token')
},
success: (uploadRes) => {
const data = JSON.parse(uploadRes.data);
if (data.code === 0) {
resolve(data.data);
} else {
reject(new Error(data.message || '上传失败'));
}
},
fail: (err) => {
reject(err);
}
});
});
// 发送语音消息
await this.$api.chat.sendMessage({
receiverId: this.targetId,
type: 'voice',
content: uploadRes.url,
duration: Math.floor(res.duration / 1000)
});
} catch (error) {
uni.showToast({
title: '发送失败,请重试',
icon: 'none'
});
}
},
// 播放语音消息
playVoice(message) {
if (this.playingVoiceId === message.id) {
// 停止播放
this.innerAudioContext.stop();
this.playingVoiceId = '';
} else {
// 停止当前播放
if (this.playingVoiceId) {
this.innerAudioContext.stop();
}
// 播放新语音
this.playingVoiceId = message.id;
this.innerAudioContext.src = message.content;
this.innerAudioContext.play();
}
},
// 语音播放结束
onVoicePlayEnd() {
this.playingVoiceId = '';
},
// 语音播放错误
onVoicePlayError(err) {
console.error('语音播放错误', err);
this.playingVoiceId = '';
uni.showToast({
title: '语音播放失败',
icon: 'none'
});
},
// 预览图片
previewImage(url) {
// 获取所有图片消息的URL
const urls = this.messages
.filter(item => item.type === 'image')
.map(item => item.content);
uni.previewImage({
current: url,
urls: urls
});
},
// 接收新消息
onReceiveMessage(message) {
if (message.senderId === this.targetId || message.senderId === this.userId) {
this.messages.push(message);
// 滚动到最新消息
this.$nextTick(() => {
this.scrollIntoView = 'msg-' + message.id;
});
}
},
// 格式化时间
formatTime(timestamp) {
return formatTime(timestamp);
}
}
}
</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
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
4. 应用优化与最佳实践
4.1 性能优化
社交应用通常需要处理大量数据和频繁的网络请求,以下是一些性能优化措施:
- 列表虚拟化:使用虚拟列表技术处理长列表,只渲染可视区域内的元素
- 图片懒加载:只加载可视区域内的图片,减少初始加载时间
- 数据缓存:缓存已加载的数据,避免重复请求
- 请求合并:合并多个小请求为一个批量请求,减少网络开销
- 增量更新:只更新变化的部分,而不是整个列表
js
// 图片懒加载示例
export default {
data() {
return {
images: [],
observer: null
}
},
mounted() {
// 创建交叉观察器
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 元素进入可视区域,加载图片
const img = entry.target;
const realSrc = img.dataset.src;
if (realSrc) {
img.src = realSrc;
img.removeAttribute('data-src');
// 停止观察该元素
this.observer.unobserve(img);
}
}
});
});
// 开始观察所有图片元素
this.$nextTick(() => {
const imgs = document.querySelectorAll('img[data-src]');
imgs.forEach(img => {
this.observer.observe(img);
});
});
},
beforeDestroy() {
// 销毁观察器
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
}
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
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
4.2 安全措施
社交应用需要特别注重用户数据安全和隐私保护:
- 数据加密:敏感数据传输和存储时进行加密
- 输入验证:前后端都进行严格的输入验证,防止注入攻击
- 权限控制:严格控制用户权限,确保用户只能访问自己有权限的数据
- 防刷机制:限制API调用频率,防止恶意请求
- 敏感内容过滤:过滤违规内容,保护用户体验
js
// 输入验证示例
export const validator = {
// 验证用户名
username(value) {
if (!value) return '用户名不能为空';
if (value.length < 3) return '用户名不能少于3个字符';
if (value.length > 20) return '用户名不能超过20个字符';
if (!/^[a-zA-Z0-9_]+$/.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
35
36
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
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 商业化思路
社交应用的商业化路径通常包括:
- 广告变现:基于用户画像的精准广告投放
- 会员订阅:提供高级功能和特权的会员服务
- 虚拟物品:销售虚拟礼物、表情包等数字内容
- 电商佣金:通过社交电商获取交易佣金
- 数据服务:在合规前提下,提供匿名化的数据分析服务