xsx 11 месяцев назад
Родитель
Сommit
39f0523557

+ 4 - 4
im-platform/src/main/java/com/bx/implatform/contant/Constant.java

@@ -16,13 +16,13 @@ public final class Constant {
     public static final Long MAX_FILE_SIZE = 20 * 1024 * 1024L;
 
     /**
-     * 群聊最大人数
+     * 大人数上限
      */
-    public static final Long MAX_GROUP_MEMBER = 10000L;
+    public static final Long MAX_LARGE_GROUP_MEMBER = 10000L;
 
     /**
-     * 回执消息限制最大人数
+     * 普通群人数上限
      */
-    public static final Long LARGE_GROUP_MEMBER = 500L;
+    public static final Long MAX_NORMAL_GROUP_MEMBER = 500L;
 
 }

+ 2 - 2
im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java

@@ -65,9 +65,9 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
         }
         // 群聊成员列表
         List<Long> userIds = groupMemberService.findUserIdsByGroupId(group.getId());
-        if (dto.getReceipt() && userIds.size() > Constant.LARGE_GROUP_MEMBER) {
+        if (dto.getReceipt() && userIds.size() > Constant.MAX_LARGE_GROUP_MEMBER) {
             // 大群的回执消息过于消耗资源,不允许发送
-            throw new GlobalException(String.format("当前群聊大于%s人,不支持发送回执消息", Constant.LARGE_GROUP_MEMBER));
+            throw new GlobalException(String.format("当前群聊大于%s人,不支持发送回执消息", Constant.MAX_LARGE_GROUP_MEMBER));
         }
         // 不用发给自己
         userIds = userIds.stream().filter(id -> !session.getUserId().equals(id)).collect(Collectors.toList());

+ 9 - 8
im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java

@@ -123,7 +123,8 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
         String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, groupId);
         redisTemplate.delete(key);
         // 推送解散群聊提示
-        this.sendTipMessage(groupId, userIds, String.format("'%s'解散了群聊", session.getNickName()));
+        String content = String.format("'%s'解散了群聊", session.getNickName());
+        this.sendTipMessage(groupId, userIds, content, true);
         // 推送同步消息
         this.sendDelGroupMessage(groupId, userIds, false);
         log.info("删除群聊,群聊id:{},群聊名称:{}", group.getId(), group.getName());
@@ -142,7 +143,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
         String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, groupId);
         redisTemplate.opsForHash().delete(key, userId.toString());
         // 推送退出群聊提示
-        this.sendTipMessage(groupId, List.of(userId), "您已退出群聊");
+        this.sendTipMessage(groupId, List.of(userId), "您已退出群聊", false);
         // 推送同步消息
         this.sendDelGroupMessage(groupId, Lists.newArrayList(), true);
         log.info("退出群聊,群聊id:{},群聊名称:{},用户id:{}", group.getId(), group.getName(), userId);
@@ -164,7 +165,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
         String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, groupId);
         redisTemplate.opsForHash().delete(key, userId.toString());
         // 推送踢出群聊提示
-        this.sendTipMessage(groupId, List.of(userId), "您已被移出群聊");
+        this.sendTipMessage(groupId, List.of(userId), "您已被移出群聊", false);
         // 推送同步消息
         this.sendDelGroupMessage(groupId, List.of(userId), false);
         log.info("踢出群聊,群聊id:{},群聊名称:{},用户id:{}", group.getId(), group.getName(), userId);
@@ -234,8 +235,8 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
         // 群聊人数校验
         List<GroupMember> members = groupMemberService.findByGroupId(vo.getGroupId());
         long size = members.stream().filter(m -> !m.getQuit()).count();
-        if (vo.getFriendIds().size() + size > Constant.MAX_GROUP_MEMBER) {
-            throw new GlobalException("群聊人数不能大于" + Constant.MAX_GROUP_MEMBER + "人");
+        if (vo.getFriendIds().size() + size > Constant.MAX_LARGE_GROUP_MEMBER) {
+            throw new GlobalException("群聊人数不能大于" + Constant.MAX_LARGE_GROUP_MEMBER + "人");
         }
         // 找出好友信息
         List<Friend> friends = friendsService.findByFriendIds(vo.getFriendIds());
@@ -267,7 +268,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
         List<Long> userIds = groupMemberService.findUserIdsByGroupId(vo.getGroupId());
         String memberNames = groupMembers.stream().map(GroupMember::getShowNickName).collect(Collectors.joining(","));
         String content = String.format("'%s'邀请'%s'加入了群聊", session.getNickName(), memberNames);
-        this.sendTipMessage(vo.getGroupId(), userIds, content);
+        this.sendTipMessage(vo.getGroupId(), userIds, content, true);
         log.info("邀请进入群聊,群聊id:{},群聊名称:{},被邀请用户id:{}", group.getId(), group.getName(),
             vo.getFriendIds());
     }
@@ -287,7 +288,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
         }).sorted((m1, m2) -> m2.getOnline().compareTo(m1.getOnline())).collect(Collectors.toList());
     }
 
-    private void sendTipMessage(Long groupId, List<Long> recvIds, String content) {
+    private void sendTipMessage(Long groupId, List<Long> recvIds, String content, Boolean sendToAll) {
         UserSession session = SessionContext.getSession();
         // 消息入库
         GroupMessage message = new GroupMessage();
@@ -298,7 +299,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
         message.setSendNickName(session.getNickName());
         message.setGroupId(groupId);
         message.setSendId(session.getUserId());
-        message.setRecvIds(CommaTextUtils.asText(recvIds));
+        message.setRecvIds(sendToAll ? "" : CommaTextUtils.asText(recvIds));
         groupMessageMapper.insert(message);
         // 推送
         GroupMessageVO msgInfo = BeanUtils.copyProperties(message, GroupMessageVO.class);

+ 2 - 0
im-platform/src/main/java/com/bx/implatform/vo/GroupInviteVO.java

@@ -3,6 +3,7 @@ package com.bx.implatform.vo;
 import io.swagger.v3.oas.annotations.media.Schema;
 import jakarta.validation.constraints.NotEmpty;
 import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
 import lombok.Data;
 
 import java.util.List;
@@ -15,6 +16,7 @@ public class GroupInviteVO {
     @Schema(description = "群id")
     private Long groupId;
 
+    @Size(max = 50, message = "一次最多只能邀请50位用户")
     @NotEmpty(message = "群id不可为空")
     @Schema(description = "好友id列表不可为空")
     private List<Long> friendIds;

+ 27 - 17
im-uniapp/App.vue

@@ -17,6 +17,7 @@ export default {
 	},
 	methods: {
 		init() {
+			this.reconnecting = false;
 			this.isExit = false;
 			// 加载数据
 			this.loadStore().then(() => {
@@ -30,20 +31,17 @@ export default {
 		},
 		initWebSocket() {
 			let loginInfo = uni.getStorageSync("loginInfo")
-			wsApi.init();
 			wsApi.connect(UNI_APP.WS_URL, loginInfo.accessToken);
 			wsApi.onConnect(() => {
-				// 重连成功提示
 				if (this.reconnecting) {
-					this.reconnecting = false;
-					uni.showToast({
-						title: "已重新连接",
-						icon: 'none'
-					})
+					// 重连成功
+					this.onReconnectWs();
+				} else {
+					// 加载离线消息
+					this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId);
+					this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId);
+
 				}
-				// 加载离线消息
-				this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId);
-				this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId);
 			});
 			wsApi.onMessage((cmd, msgInfo) => {
 				if (cmd == 2) {
@@ -371,12 +369,11 @@ export default {
 			// 记录标志
 			this.reconnecting = true;
 			// 重新加载一次个人信息,目的是为了保证网络已经正常且token有效
-			this.reloadUserInfo().then((userInfo) => {
+			this.userStore.loadUser().then((userInfo) => {
 				uni.showToast({
 					title: '连接已断开,尝试重新连接...',
-					icon: 'none',
+					icon: 'none'
 				})
-				this.userStore.setUserInfo(userInfo);
 				// 重新连接
 				let loginInfo = uni.getStorageSync("loginInfo")
 				wsApi.reconnect(UNI_APP.WS_URL, loginInfo.accessToken);
@@ -387,10 +384,23 @@ export default {
 				}, 5000)
 			})
 		},
-		reloadUserInfo() {
-			return http({
-				url: '/user/self',
-				method: 'GET'
+		onReconnectWs() {
+			this.reconnecting = false;
+			// 重新加载好友和群聊
+			const promises = [];
+			promises.push(this.friendStore.loadFriend());
+			promises.push(this.groupStore.loadGroup());
+			Promise.all(promises).then(() => {
+				uni.showToast({
+					title: "已重新连接",
+					icon: 'none'
+				})
+				// 加载离线消息
+				this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId);
+				this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId);
+			}).catch((e) => {
+				console.log(e);
+				this.exit();
 			})
 		},
 		closeSplashscreen(delay) {

+ 39 - 61
im-uniapp/common/wssocket.js

@@ -1,20 +1,33 @@
-let wsurl = "";
 let accessToken = "";
 let messageCallBack = null;
 let closeCallBack = null;
 let connectCallBack = null;
 let isConnect = false; //连接标识 避免重复连接
 let rec = null;
-let isInit = false;
 let lastConnectTime = new Date(); // 最后一次连接时间
+let socketTask = null;
 
-let init = () => {
-	// 防止重复初始化
-	if (isInit) {
+let connect = (wsurl, token) => {
+	accessToken = token;
+	if (isConnect) {
 		return;
 	}
-	isInit = true;
-	uni.onSocketOpen((res) => {
+	lastConnectTime = new Date();
+	socketTask = uni.connectSocket({
+		url: wsurl,
+		success: (res) => {
+			console.log("websocket连接成功");
+		},
+		fail: (e) => {
+			console.log(e);
+			console.log("websocket连接失败,10s后重连");
+			setTimeout(() => {
+				connect();
+			}, 10000)
+		}
+	});
+
+	socketTask.onOpen((res) => {
 		console.log("WebSocket连接已打开");
 		isConnect = true;
 		// 发送登录命令
@@ -24,12 +37,12 @@ let init = () => {
 				accessToken: accessToken
 			}
 		};
-		uni.sendSocketMessage({
+		socketTask.send({
 			data: JSON.stringify(loginInfo)
 		});
 	})
 
-	uni.onSocketMessage((res) => {
+	socketTask.onMessage((res) => {
 		let sendInfo = JSON.parse(res.data)
 		if (sendInfo.cmd == 0) {
 			heartCheck.start()
@@ -45,54 +58,31 @@ let init = () => {
 		}
 	})
 
-	uni.onSocketClose((res) => {
+	socketTask.onClose((res) => {
 		console.log('WebSocket连接关闭')
 		isConnect = false;
 		closeCallBack && closeCallBack(res);
 	})
 
-	uni.onSocketError((e) => {
+	socketTask.onError((e) => {
 		console.log(e)
 		isConnect = false;
 		// APP 应用切出超过一定时间(约1分钟)会触发报错,此处回调给应用进行重连
 		closeCallBack && closeCallBack({ code: 1006 });
 	})
-};
-
-let connect = (url, token) => {
-	wsurl = url;
-	accessToken = token;
-	if (isConnect) {
-		return;
-	}
-	lastConnectTime = new Date();
-	uni.connectSocket({
-		url: wsurl,
-		success: (res) => {
-			console.log("websocket连接成功");
-		},
-		fail: (e) => {
-			console.log(e);
-			console.log("websocket连接失败,10s后重连");
-			setTimeout(() => {
-				connect();
-			}, 10000)
-		}
-	});
 }
 
 //定义重连函数
 let reconnect = (wsurl, accessToken) => {
 	console.log("尝试重新连接");
 	if (isConnect) {
-		//如果已经连上就不在重连了
 		return;
 	}
 	// 延迟10秒重连  避免过多次过频繁请求重连
 	let timeDiff = new Date().getTime() - lastConnectTime.getTime()
 	let delay = timeDiff < 10000 ? 10000 - timeDiff : 0;
 	rec && clearTimeout(rec);
-	rec = setTimeout(function () {
+	rec = setTimeout(function() {
 		connect(wsurl, accessToken);
 	}, delay);
 };
@@ -102,7 +92,7 @@ let close = (code) => {
 	if (!isConnect) {
 		return;
 	}
-	uni.closeSocket({
+	socketTask.close({
 		code: code,
 		complete: (res) => {
 			console.log("关闭websocket连接");
@@ -115,39 +105,28 @@ let close = (code) => {
 };
 
 
-//心跳设置
-var heartCheck = {
-	timeout: 10000, //每段时间发送一次心跳包 这里设置为30s
-	timeoutObj: null, //延时发送消息对象(启动心跳新建这个对象,收到消息后重置对象)
-	start: function () {
+// 心跳设置
+let heartCheck = {
+	timeout: 20000, // 每段时间发送一次心跳包 这里设置为20s
+	timeoutObj: null, // 延时发送消息对象(启动心跳新建这个对象,收到消息后重置对象)
+	start: function() {
 		if (isConnect) {
 			console.log('发送WebSocket心跳')
 			let heartBeat = {
 				cmd: 1,
 				data: {}
 			};
-			uni.sendSocketMessage({
-				data: JSON.stringify(heartBeat),
-				fail(res) {
-					console.log(res);
-				}
-			})
+			sendMessage(JSON.stringify(heartBeat))
 		}
 	},
-	reset: function () {
+	reset: function() {
 		clearTimeout(this.timeoutObj);
-		this.timeoutObj = setTimeout(function () {
-			heartCheck.start();
-		}, this.timeout);
+		this.timeoutObj = setTimeout(() => heartCheck.start(), this.timeout);
 	}
+};
 
-}
-
-// 实际调用的方法
-function sendMessage(agentData) {
-	uni.sendSocketMessage({
-		data: agentData
-	})
+let sendMessage = (message) => {
+	socketTask.send({ data: message })
 }
 
 let onConnect = (callback) => {
@@ -155,19 +134,18 @@ let onConnect = (callback) => {
 }
 
 
-function onMessage(callback) {
+let onMessage = (callback) => {
 	messageCallBack = callback;
 }
 
 
-function onClose(callback) {
+let onClose = (callback) => {
 	closeCallBack = callback;
 }
 
 
 // 将方法暴露出去
 export {
-	init,
 	connect,
 	reconnect,
 	close,

+ 2 - 2
im-uniapp/manifest.json

@@ -2,8 +2,8 @@
     "name" : "盒子IM",
     "appid" : "__UNI__69DD57A",
     "description" : "",
-    "versionName" : "3.1.0",
-    "versionCode" : 3100,
+    "versionName" : "3.4.0",
+    "versionCode" : 3400,
     "transformPx" : false,
     /* 5+App特有相关 */
     "app-plus" : {

+ 6 - 1
im-uniapp/pages/chat/chat.vue

@@ -76,6 +76,12 @@ export default {
 		moveToTop(chatIdx) {
 			this.chatStore.moveTop(chatIdx);
 		},
+		isShowChat(chat) {
+			if (chat.delete) {
+				return false;
+			}
+			return !this.searchText || chat.showName.includes(this.searchText)
+		},
 		onSearch() {
 			this.showSearch = !this.showSearch;
 			this.searchText = "";
@@ -146,7 +152,6 @@ export default {
 		width: 100%;
 		height: 120rpx;
 		background: white;
-
 		color: $im-text-color-lighter;
 
 		.loading-box {

+ 14 - 12
im-uniapp/pages/login/login.vue

@@ -1,15 +1,17 @@
 <template>
 	<view class="login">
 		<view class="title">欢迎登录</view>
-		<uni-forms :modelValue="loginForm" :rules="rules" validate-trigger="bind">
-			<uni-forms-item name="userName">
-				<uni-easyinput type="text" v-model="loginForm.userName" prefix-icon="person" placeholder="用户名" />
-			</uni-forms-item>
-			<uni-forms-item name="password">
-				<uni-easyinput type="password" v-model="loginForm.password" prefix-icon="locked" placeholder="密码" />
-			</uni-forms-item>
-			<button class="btn-submit" @click="submit" type="primary">登录</button>
-		</uni-forms>
+		<view class="form">
+			<uni-forms :modelValue="loginForm" :rules="rules" validate-trigger="bind">
+				<uni-forms-item name="userName">
+					<uni-easyinput type="text" v-model="loginForm.userName" prefix-icon="person" placeholder="用户名" />
+				</uni-forms-item>
+				<uni-forms-item name="password">
+					<uni-easyinput type="password" v-model="loginForm.password" prefix-icon="locked" placeholder="密码" />
+				</uni-forms-item>
+				<button class="btn-submit" @click="submit" type="primary">登录</button>
+			</uni-forms>
+		</view>
 		<navigator class="nav-register" url="/pages/register/register">
 			没有账号,前往注册
 		</navigator>
@@ -69,10 +71,10 @@ export default {
 }
 </script>
 
-<style lang="scss">
+<style lang="scss" scoped>
 .login {
 	.title {
-		padding-top: 150rpx;
+		padding-top: 250rpx;
 		padding-bottom: 50rpx;
 		color: $im-color-primary;
 		text-align: center;
@@ -80,7 +82,7 @@ export default {
 		font-weight: bold;
 	}
 
-	.uni-forms {
+	.form {
 		padding: 50rpx;
 
 		.btn-submit {

+ 20 - 18
im-uniapp/pages/register/register.vue

@@ -1,21 +1,23 @@
 <template>
 	<view class="register">
 		<view class="title">欢迎注册</view>
-		<uni-forms  ref="form" :modelValue="dataForm" :rules="rules" validate-trigger="bind" label-width="80px">
-			<uni-forms-item name="userName" label="用户名">
-				<uni-easyinput type="text" v-model="dataForm.userName" placeholder="用户名" />
-			</uni-forms-item>
-			<uni-forms-item name="nickName" label="昵称">
-				<uni-easyinput type="text" v-model="dataForm.nickName" placeholder="昵称" />
-			</uni-forms-item>
-			<uni-forms-item name="password" label="密码">
-				<uni-easyinput type="password" v-model="dataForm.password" placeholder="密码" />
-			</uni-forms-item>
-			<uni-forms-item name="corfirmPassword" label="确认密码">
-				<uni-easyinput type="password" v-model="dataForm.corfirmPassword" placeholder="确认密码" />
-			</uni-forms-item>
-			<button class="btn-submit" @click="submit" type="primary">注册并登录</button>
-		</uni-forms>
+		<view class="form">
+			<uni-forms  ref="form" :modelValue="dataForm" :rules="rules" validate-trigger="bind" label-width="80px">
+				<uni-forms-item name="userName" label="用户名">
+					<uni-easyinput type="text" v-model="dataForm.userName" placeholder="用户名" />
+				</uni-forms-item>
+				<uni-forms-item name="nickName" label="昵称">
+					<uni-easyinput type="text" v-model="dataForm.nickName" placeholder="昵称" />
+				</uni-forms-item>
+				<uni-forms-item name="password" label="密码">
+					<uni-easyinput type="password" v-model="dataForm.password" placeholder="密码" />
+				</uni-forms-item>
+				<uni-forms-item name="corfirmPassword" label="确认密码">
+					<uni-easyinput type="password" v-model="dataForm.corfirmPassword" placeholder="确认密码" />
+				</uni-forms-item>
+				<button class="btn-submit" @click="submit" type="primary">注册并登录</button>
+			</uni-forms>
+		</view>
 		<navigator class="nav-login" url="/pages/login/login">
 			返回登录页面
 		</navigator>
@@ -111,10 +113,10 @@ export default {
 }
 </script>
 
-<style lang="scss">
+<style lang="scss" scoped>
 .register {
 	.title {
-		padding-top: 150rpx;
+		padding-top: 250rpx;
 		padding-bottom: 50rpx;
 		color: $im-color-primary;
 		text-align: center;
@@ -122,7 +124,7 @@ export default {
 		font-weight: 600;
 	}
 
-	.uni-forms {
+	.form {
 		padding: 50rpx;
 
 		.btn-submit {

+ 4 - 4
im-web/src/api/emotion.js

@@ -7,19 +7,19 @@ const emoTextList = ['憨笑', '媚眼', '开心', '坏笑', '可怜', '爱心',
 ];
 
 
-let transform = (content) => {
-	return content.replace(/\#[\u4E00-\u9FA5]{1,3}\;/gi, textToImg);
+let transform = (content, extClass) => {
+	return content.replace(/\#[\u4E00-\u9FA5]{1,3}\;/gi, (text) => textToImg(text, extClass));
 }
 
 // 将匹配结果替换表情图片
-let textToImg = (emoText) => {
+let textToImg = (emoText, extClass) => {
 	let word = emoText.replace(/\#|\;/gi, '');
 	let idx = emoTextList.indexOf(word);
 	if (idx == -1) {
 		return emoText;
 	}
 	let url = require(`@/assets/emoji/${idx}.gif`);
-	return `<img src="${url}" style="width:32px;height:32px;vertical-align:bottom;"/>`
+	return `<img src="${url}" class="${extClass}" />`
 }
 
 let textToUrl = (emoText) => {

+ 18 - 0
im-web/src/assets/style/im.scss

@@ -89,3 +89,21 @@ section {
   }
 
 }
+
+.emoji-large {
+  width: 32px;
+  height: 32px;
+  vertical-align: bottom;
+}
+
+.emoji-normal {
+  width: 26px;
+  height: 26px;
+  vertical-align: bottom;
+}
+
+.emoji-small {
+  width: 20px;
+  height: 20px;
+  vertical-align: bottom;
+}

+ 0 - 66
im-web/src/components/chat/ChatBox.vue

@@ -819,72 +819,6 @@ export default {
 			height: 100%;
 			background-color: white !important;
 
-			.send-text-area {
-				box-sizing: border-box;
-				padding: 5px;
-				width: 100%;
-				flex: 1;
-				resize: none;
-				font-size: 16px;
-				outline: none;
-
-				text-align: left;
-				line-height: 30px;
-
-				&:before {
-					content: attr(placeholder);
-					color: gray;
-				}
-
-				.at {
-					color: blue;
-					font-weight: 600;
-				}
-
-				.receipt {
-					color: darkblue;
-					font-size: 15px;
-					font-weight: 600;
-				}
-
-				.emo {
-					width: 30px;
-					height: 30px;
-					vertical-align: bottom;
-				}
-			}
-
-			.send-image-area {
-				text-align: left;
-				border: #53a0e7 solid 1px;
-
-				.send-image-box {
-					position: relative;
-					display: inline-block;
-
-					.send-image {
-						max-height: 180px;
-						border: 1px solid #ccc;
-						border-radius: 2%;
-						margin: 2px;
-					}
-
-					.send-image-close {
-						position: absolute;
-						padding: 3px;
-						right: 7px;
-						top: 7px;
-						color: white;
-						cursor: pointer;
-						font-size: 15px;
-						font-weight: 600;
-						background-color: #aaa;
-						border-radius: 50%;
-						border: 1px solid #ccc;
-					}
-				}
-			}
-
 			.send-btn-area {
 				padding: 10px;
 				position: absolute;

+ 2 - 9
im-web/src/components/chat/ChatInput.vue

@@ -249,7 +249,7 @@ export default {
 		},
 		insertEmoji(emojiText) {
 			let emojiElement = document.createElement('img');
-			emojiElement.className = 'chat-emoji no-text';
+			emojiElement.className = 'emoji-normal no-text';
 			emojiElement.dataset.emojiCode = emojiText;
 			emojiElement.src = this.$emo.textToUrl(emojiText);
 
@@ -482,7 +482,7 @@ export default {
 		bottom: 0;
 		outline: none;
 		padding: 5px;
-		line-height: 1.5;
+		line-height: 26px;
 		font-size: var(--im-font-size);
 		text-align: left;
 		overflow-y: auto;
@@ -504,13 +504,6 @@ export default {
 			cursor: pointer;
 		}
 
-		.chat-emoji {
-			width: 30px;
-			height: 30px;
-			vertical-align: top;
-			cursor: pointer;
-		}
-
 		.chat-file-container {
 			max-width: 65%;
 			padding: 10px;

+ 1 - 6
im-web/src/components/chat/ChatItem.vue

@@ -16,7 +16,7 @@
 			<div class="chat-content">
 				<div class="chat-at-text">{{ atText }}</div>
 				<div class="chat-send-name" v-show="isShowSendName">{{ chat.sendNickName + ':&nbsp;' }}</div>
-				<div class="chat-content-text" v-html="$emo.transform(chat.lastContent)"></div>
+				<div class="chat-content-text" v-html="$emo.transform(chat.lastContent,'emoji-small')"></div>
 			</div>
 		</div>
 		<right-menu v-show="rightMenu.show" :pos="rightMenu.pos" :items="rightMenu.items"
@@ -216,11 +216,6 @@ export default {
 				font-size: var(--im-font-size-small);
 				color: var(--im-text-color-light);
 
-				img {
-					width: 20px !important;
-					height: 20px !important;
-					vertical-align: bottom;
-				}
 			}
 
 		}

+ 1 - 1
im-web/src/components/chat/ChatMessageItem.vue

@@ -207,7 +207,7 @@ export default {
 		htmlText() {
 			let color = this.msgInfo.selfSend ? 'white' : '';
 			let text = this.$url.replaceURLWithHTMLLinks(this.msgInfo.content, color)
-			return this.$emo.transform(text)
+			return this.$emo.transform(text,'emoji-normal')
 		}
 	}
 }

+ 1 - 1
im-web/src/components/common/Emotion.vue

@@ -4,7 +4,7 @@
 			<el-scrollbar style="height: 220px">
 				<div class="emotion-item-list">
 					<div class="emotion-item" v-for="(emoText, i) in $emo.emoTextList" :key="i"
-						@click="onClickEmo(emoText)" v-html="$emo.textToImg(emoText)">
+						@click="onClickEmo(emoText)" v-html="$emo.textToImg(emoText,'emoji-large')">
 					</div>
 				</div>
 			</el-scrollbar>

+ 12 - 4
im-web/src/view/Friend.vue

@@ -13,8 +13,9 @@
 				<div v-for="(friends, i) in friendValues" :key="i">
 					<div class="index-title">{{ friendKeys[i] }}</div>
 					<div v-for="(friend) in friends" :key="friend.id">
-						<friend-item :friend="friend" :active="friend.id === activeFriend.id" @chat="onSendMessage(friend)"
-							@delete="onDelFriend(friend)" @click.native="onActiveItem(friend)">
+						<friend-item :friend="friend" :active="friend.id === activeFriend.id"
+							@chat="onSendMessage(friend)" @delete="onDelFriend(friend)"
+							@click.native="onActiveItem(friend)">
 						</friend-item>
 					</div>
 					<div v-if="i < friendValues.length - 1" class="divider"></div>
@@ -184,10 +185,10 @@ export default {
 			// 按首字母分组
 			let map = new Map();
 			this.friendStore.friends.forEach((f) => {
-				if (f.deleted || (this.searchText && !f.showNickName.includes(this.searchText))) {
+				if (f.deleted || (this.searchText && !f.nickName.includes(this.searchText))) {
 					return;
 				}
-				let letter = this.firstLetter(f.showNickName).toUpperCase();
+				let letter = this.firstLetter(f.nickName).toUpperCase();
 				// 非英文一律为#组
 				if (!this.isEnglish(letter)) {
 					letter = "#"
@@ -246,6 +247,13 @@ export default {
 
 		.friend-list-items {
 			flex: 1;
+
+			.index-title {
+				text-align: left;
+				font-size: var(--im-larger-size-larger);
+				padding: 5px 15px;
+				color: var(--im-text-color-light);
+			}
 		}
 	}
 

+ 452 - 441
im-web/src/view/Group.vue

@@ -1,92 +1,96 @@
 <template>
-  <el-container class="group-page">
-    <el-aside width="260px" class="group-list-box">
-      <div class="group-list-header">
-        <el-input class="search-text" size="small" placeholder="搜索" v-model="searchText">
-          <i class="el-icon-search el-input__icon" slot="prefix"> </i>
-        </el-input>
-        <el-button plain class="add-btn" icon="el-icon-plus" title="创建群聊" @click="onCreateGroup()"></el-button>
-      </div>
-      <el-scrollbar class="group-list-items">
-        <div v-for="(groups, i) in groupValues" :key="i">
-          <div class="index-title">{{ groupKeys[i] }}</div>
-          <div v-for="group in groups" :key="group.id">
-            <group-item :group="group" :active="group.id == activeGroup.id" @click.native="onActiveItem(group)">
-            </group-item>
-          </div>
-          <div v-if="i < groupValues.length - 1" class="divider"></div>
-        </div>
-      </el-scrollbar>
-    </el-aside>
-    <el-container class="group-box">
-      <div class="group-header" v-show="activeGroup.id">
-        {{ activeGroup.showGroupName }}({{ groupMembers.length }})
-      </div>
-      <div class="group-container">
-        <div v-show="activeGroup.id">
-          <div class="group-info">
-            <div>
-              <file-upload v-show="isOwner" class="avatar-uploader" :action="imageAction" :showLoading="true"
-                :maxSize="maxSize" @success="onUploadSuccess"
-                :fileTypes="['image/jpeg', 'image/png', 'image/jpg', 'image/webp']">
-                <img v-if="activeGroup.headImage" :src="activeGroup.headImage" class="avatar">
-                <i v-else class="el-icon-plus avatar-uploader-icon"></i>
-              </file-upload>
-              <head-image v-show="!isOwner" class="avatar" :size="160" :url="activeGroup.headImage"
-                :name="activeGroup.showGroupName" radius="10%">
-              </head-image>
-              <el-button class="send-btn" icon="el-icon-position" type="primary" @click="onSendMessage()">发消息
-              </el-button>
-            </div>
-            <el-form class="group-form" label-width="130px" :model="activeGroup" :rules="rules" size="small"
-              ref="groupForm">
-              <el-form-item label="群聊名称" prop="name">
-                <el-input v-model="activeGroup.name" :disabled="!isOwner" maxlength="20"></el-input>
-              </el-form-item>
-              <el-form-item label="群主">
-                <el-input :value="ownerName" disabled></el-input>
-              </el-form-item>
-              <el-form-item label="群名备注">
-                <el-input v-model="activeGroup.remarkGroupName" :placeholder="activeGroup.name"
-                  maxlength="20"></el-input>
-              </el-form-item>
-              <el-form-item label="我在本群的昵称">
-                <el-input v-model="activeGroup.remarkNickName" maxlength="20"
-                  :placeholder="$store.state.userStore.userInfo.nickName"></el-input>
-              </el-form-item>
-              <el-form-item label="群公告">
-                <el-input v-model="activeGroup.notice" :disabled="!isOwner" type="textarea" :rows="3" maxlength="1024"
-                  placeholder="群主未设置"></el-input>
-              </el-form-item>
-              <div>
-                <el-button type="warning" v-show="isOwner" @click="onInviteMember()">邀请</el-button>
-                <el-button type="success" @click="onSaveGroup()">保存</el-button>
-                <el-button type="danger" v-show="!isOwner" @click="onQuit()">退出</el-button>
-                <el-button type="danger" v-show="isOwner" @click="onDissolve()">解散</el-button>
-              </div>
-            </el-form>
-          </div>
-          <el-divider content-position="center"></el-divider>
-          <el-scrollbar ref="scrollbar" :style="'height: ' + scrollHeight + 'px'">
-            <div class="group-member-list">
-              <div class="group-invite">
-                <div class="invite-member-btn" title="邀请好友进群聊" @click="onInviteMember()">
-                  <i class="el-icon-plus"></i>
-                </div>
-                <div class="invite-member-text">邀请</div>
-                <add-group-member :visible="showAddGroupMember" :groupId="activeGroup.id" :members="groupMembers"
-                  @reload="loadGroupMembers" @close="onCloseAddGroupMember"></add-group-member>
-              </div>
-              <div v-for="(member, idx) in showMembers" :key="member.id">
-                <group-member v-if="idx < showMaxIdx" class="group-member" :member="member"
-                  :showDel="isOwner && member.userId != activeGroup.ownerId" @del="onKick"></group-member>
-              </div>
-            </div>
-          </el-scrollbar>
-        </div>
-      </div>
-    </el-container>
-  </el-container>
+	<el-container class="group-page">
+		<el-aside width="260px" class="group-list-box">
+			<div class="group-list-header">
+				<el-input class="search-text" size="small" placeholder="搜索" v-model="searchText">
+					<i class="el-icon-search el-input__icon" slot="prefix"> </i>
+				</el-input>
+				<el-button plain class="add-btn" icon="el-icon-plus" title="创建群聊" @click="onCreateGroup()"></el-button>
+			</div>
+			<el-scrollbar class="group-list-items">
+				<div v-for="(groups, i) in groupValues" :key="i">
+					<div class="index-title">{{ groupKeys[i] }}</div>
+					<div v-for="group in groups" :key="group.id">
+						<group-item :group="group" :active="group.id == activeGroup.id"
+							@click.native="onActiveItem(group)">
+						</group-item>
+					</div>
+					<div v-if="i < groupValues.length - 1" class="divider"></div>
+				</div>
+			</el-scrollbar>
+		</el-aside>
+		<el-container class="group-box">
+			<div class="group-header" v-show="activeGroup.id">
+				{{ activeGroup.showGroupName }}({{ groupMembers.length }})
+			</div>
+			<div class="group-container">
+				<div v-show="activeGroup.id">
+					<div class="group-info">
+						<div>
+							<file-upload v-show="isOwner" class="avatar-uploader" :action="imageAction"
+								:showLoading="true" :maxSize="maxSize" @success="onUploadSuccess"
+								:fileTypes="['image/jpeg', 'image/png', 'image/jpg', 'image/webp']">
+								<img v-if="activeGroup.headImage" :src="activeGroup.headImage" class="avatar">
+								<i v-else class="el-icon-plus avatar-uploader-icon"></i>
+							</file-upload>
+							<head-image v-show="!isOwner" class="avatar" :size="160" :url="activeGroup.headImage"
+								:name="activeGroup.showGroupName" radius="10%">
+							</head-image>
+							<el-button class="send-btn" icon="el-icon-position" type="primary"
+								@click="onSendMessage()">发消息
+							</el-button>
+						</div>
+						<el-form class="group-form" label-width="130px" :model="activeGroup" :rules="rules" size="small"
+							ref="groupForm">
+							<el-form-item label="群聊名称" prop="name">
+								<el-input v-model="activeGroup.name" :disabled="!isOwner" maxlength="20"></el-input>
+							</el-form-item>
+							<el-form-item label="群主">
+								<el-input :value="ownerName" disabled></el-input>
+							</el-form-item>
+							<el-form-item label="群名备注">
+								<el-input v-model="activeGroup.remarkGroupName" :placeholder="activeGroup.name"
+									maxlength="20"></el-input>
+							</el-form-item>
+							<el-form-item label="我在本群的昵称">
+								<el-input v-model="activeGroup.remarkNickName" maxlength="20"
+									:placeholder="$store.state.userStore.userInfo.nickName"></el-input>
+							</el-form-item>
+							<el-form-item label="群公告">
+								<el-input v-model="activeGroup.notice" :disabled="!isOwner" type="textarea" :rows="3"
+									maxlength="1024" placeholder="群主未设置"></el-input>
+							</el-form-item>
+							<div>
+								<el-button type="warning" v-show="isOwner" @click="onInviteMember()">邀请</el-button>
+								<el-button type="success" @click="onSaveGroup()">保存</el-button>
+								<el-button type="danger" v-show="!isOwner" @click="onQuit()">退出</el-button>
+								<el-button type="danger" v-show="isOwner" @click="onDissolve()">解散</el-button>
+							</div>
+						</el-form>
+					</div>
+					<el-divider content-position="center"></el-divider>
+					<el-scrollbar ref="scrollbar" :style="'height: ' + scrollHeight + 'px'">
+						<div class="group-member-list">
+							<div class="group-invite">
+								<div class="invite-member-btn" title="邀请好友进群聊" @click="onInviteMember()">
+									<i class="el-icon-plus"></i>
+								</div>
+								<div class="invite-member-text">邀请</div>
+								<add-group-member :visible="showAddGroupMember" :groupId="activeGroup.id"
+									:members="groupMembers" @reload="loadGroupMembers"
+									@close="onCloseAddGroupMember"></add-group-member>
+							</div>
+							<div v-for="(member, idx) in showMembers" :key="member.id">
+								<group-member v-if="idx < showMaxIdx" class="group-member" :member="member"
+									:showDel="isOwner && member.userId != activeGroup.ownerId"
+									@del="onKick"></group-member>
+							</div>
+						</div>
+					</el-scrollbar>
+				</div>
+			</div>
+		</el-container>
+	</el-container>
 </template>
 
 
@@ -99,387 +103,394 @@ import HeadImage from '../components/common/HeadImage.vue';
 import { pinyin } from 'pinyin-pro';
 
 export default {
-  name: "group",
-  components: {
-    GroupItem,
-    GroupMember,
-    FileUpload,
-    AddGroupMember,
-    HeadImage
-  },
-  data() {
-    return {
-      searchText: "",
-      maxSize: 5 * 1024 * 1024,
-      activeGroup: {},
-      groupMembers: [],
-      showAddGroupMember: false,
-      showMaxIdx: 150,
-      rules: {
-        name: [{
-          required: true,
-          message: '请输入群聊名称',
-          trigger: 'blur'
-        }]
-      }
-    };
-  },
-  methods: {
-    onCreateGroup() {
-      this.$prompt('请输入群聊名称', '创建群聊', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        inputPattern: /\S/,
-        inputErrorMessage: '请输入群聊名称'
-      }).then((o) => {
-        let userInfo = this.$store.state.userStore.userInfo;
-        let data = {
-          name: o.value
-        }
-        this.$http({
-          url: `/group/create?groupName=${o.value}`,
-          method: 'post',
-          data: data
-        }).then((group) => {
-          this.$store.commit("addGroup", group);
-        })
-      })
-    },
-    onActiveItem(group) {
-      this.showMaxIdx = 150;
-      // store数据不能直接修改,所以深拷贝一份内存
-      this.activeGroup = JSON.parse(JSON.stringify(group));
-      // 重新加载群成员
-      this.loadGroupMembers();
-    },
-    onInviteMember() {
-      this.showAddGroupMember = true;
-    },
-    onCloseAddGroupMember() {
-      this.showAddGroupMember = false;
-    },
-    onUploadSuccess(data) {
-      this.activeGroup.headImage = data.originUrl;
-      this.activeGroup.headImageThumb = data.thumbUrl;
-    },
-    onSaveGroup() {
-      this.$refs['groupForm'].validate((valid) => {
-        if (valid) {
-          let vo = this.activeGroup;
-          this.$http({
-            url: "/group/modify",
-            method: "put",
-            data: vo
-          }).then((group) => {
-            this.$store.commit("updateGroup", group);
-            this.$message.success("修改成功");
-          })
-        }
-      });
-    },
-    onDissolve() {
-      this.$confirm(`确认要解散'${this.activeGroup.name}'吗?`, '确认解散?', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
-        this.$http({
-          url: `/group/delete/${this.activeGroup.id}`,
-          method: 'delete'
-        }).then(() => {
-          this.$message.success(`群聊'${this.activeGroup.name}'已解散`);
-          this.$store.commit("removeGroup", this.activeGroup.id);
-          this.reset();
-        });
-      })
-    },
-    onKick(member) {
-      this.$confirm(`确定将成员'${member.showNickName}'移出群聊吗?`, '确认移出?', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
-        this.$http({
-          url: `/group/kick/${this.activeGroup.id}`,
-          method: 'delete',
-          params: {
-            userId: member.userId
-          }
-        }).then(() => {
-          this.$message.success(`已将${member.showNickName}移出群聊`);
-          member.quit = true;
-        });
-      })
+	name: "group",
+	components: {
+		GroupItem,
+		GroupMember,
+		FileUpload,
+		AddGroupMember,
+		HeadImage
+	},
+	data() {
+		return {
+			searchText: "",
+			maxSize: 5 * 1024 * 1024,
+			activeGroup: {},
+			groupMembers: [],
+			showAddGroupMember: false,
+			showMaxIdx: 150,
+			rules: {
+				name: [{
+					required: true,
+					message: '请输入群聊名称',
+					trigger: 'blur'
+				}]
+			}
+		};
+	},
+	methods: {
+		onCreateGroup() {
+			this.$prompt('请输入群聊名称', '创建群聊', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				inputPattern: /\S/,
+				inputErrorMessage: '请输入群聊名称'
+			}).then((o) => {
+				let userInfo = this.$store.state.userStore.userInfo;
+				let data = {
+					name: o.value
+				}
+				this.$http({
+					url: `/group/create?groupName=${o.value}`,
+					method: 'post',
+					data: data
+				}).then((group) => {
+					this.$store.commit("addGroup", group);
+				})
+			})
+		},
+		onActiveItem(group) {
+			this.showMaxIdx = 150;
+			// store数据不能直接修改,所以深拷贝一份内存
+			this.activeGroup = JSON.parse(JSON.stringify(group));
+			// 重新加载群成员
+			this.loadGroupMembers();
+		},
+		onInviteMember() {
+			this.showAddGroupMember = true;
+		},
+		onCloseAddGroupMember() {
+			this.showAddGroupMember = false;
+		},
+		onUploadSuccess(data) {
+			this.activeGroup.headImage = data.originUrl;
+			this.activeGroup.headImageThumb = data.thumbUrl;
+		},
+		onSaveGroup() {
+			this.$refs['groupForm'].validate((valid) => {
+				if (valid) {
+					let vo = this.activeGroup;
+					this.$http({
+						url: "/group/modify",
+						method: "put",
+						data: vo
+					}).then((group) => {
+						this.$store.commit("updateGroup", group);
+						this.$message.success("修改成功");
+					})
+				}
+			});
+		},
+		onDissolve() {
+			this.$confirm(`确认要解散'${this.activeGroup.name}'吗?`, '确认解散?', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.$http({
+					url: `/group/delete/${this.activeGroup.id}`,
+					method: 'delete'
+				}).then(() => {
+					this.$message.success(`群聊'${this.activeGroup.name}'已解散`);
+					this.$store.commit("removeGroup", this.activeGroup.id);
+					this.reset();
+				});
+			})
+		},
+		onKick(member) {
+			this.$confirm(`确定将成员'${member.showNickName}'移出群聊吗?`, '确认移出?', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.$http({
+					url: `/group/kick/${this.activeGroup.id}`,
+					method: 'delete',
+					params: {
+						userId: member.userId
+					}
+				}).then(() => {
+					this.$message.success(`已将${member.showNickName}移出群聊`);
+					member.quit = true;
+				});
+			})
 
-    },
-    onQuit() {
-      this.$confirm(`确认退出'${this.activeGroup.showGroupName}',并清空聊天记录吗?`, '确认退出?', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
-        this.$http({
-          url: `/group/quit/${this.activeGroup.id}`,
-          method: 'delete'
-        }).then(() => {
-          this.$message.success(`您已退出'${this.activeGroup.name}'`);
-          this.$store.commit("removeGroup", this.activeGroup.id);
-          this.$store.commit("removeGroupChat", this.activeGroup.id);
-          this.reset();
-        });
-      })
-    },
-    onSendMessage() {
-      let chat = {
-        type: 'GROUP',
-        targetId: this.activeGroup.id,
-        showName: this.activeGroup.showGroupName,
-        headImage: this.activeGroup.headImage,
-      };
-      this.$store.commit("openChat", chat);
-      this.$store.commit("activeChat", 0);
-      this.$router.push("/home/chat");
-    },
-    onScroll(e) {
-      const scrollbar = e.target;
-      // 滚到底部
-      if (scrollbar.scrollTop + scrollbar.clientHeight >= scrollbar.scrollHeight - 30) {
-        if (this.showMaxIdx < this.showMembers.length) {
-          this.showMaxIdx += 50;
-        }
-      }
-    },
-    loadGroupMembers() {
-      this.$http({
-        url: `/group/members/${this.activeGroup.id}`,
-        method: "get"
-      }).then((members) => {
-        this.groupMembers = members;
-      })
-    },
-    reset() {
-      this.activeGroup = {};
-      this.groupMembers = [];
-    },
-    firstLetter(strText) {
-      // 使用pinyin-pro库将中文转换为拼音
-      let pinyinOptions = {
-        toneType: 'none', // 无声调
-        type: 'normal' // 普通拼音
-      };
-      let pyText = pinyin(strText, pinyinOptions);
-      return pyText[0];
-    },
-    isEnglish(character) {
-      return /^[A-Za-z]+$/.test(character);
-    }
-  },
-  computed: {
-    groupStore() {
-      return this.$store.state.groupStore;
-    },
-    ownerName() {
-      let member = this.groupMembers.find((m) => m.userId == this.activeGroup.ownerId);
-      return member && member.showNickName;
-    },
-    isOwner() {
-      return this.activeGroup.ownerId == this.$store.state.userStore.userInfo.id;
-    },
-    imageAction() {
-      return `/image/upload`;
-    },
-    groupMap() {
-      // 按首字母分组
-      let map = new Map();
-      this.groupStore.groups.forEach((g) => {
-        if (g.quit || (this.searchText && !g.showGroupName.includes(this.searchText))) {
-          return;
-        }
-        let letter = this.firstLetter(g.showGroupName).toUpperCase();
-        // 非英文一律为#组
-        if (!this.isEnglish(letter)) {
-          letter = "#"
-        }
-        if (map.has(letter)) {
-          map.get(letter).push(g);
-        } else {
-          map.set(letter, [g]);
-        }
-      })
-      // 排序
-      let arrayObj = Array.from(map);
-      arrayObj.sort((a, b) => {
-        // #组在最后面
-        if (a[0] == '#' || b[0] == '#') {
-          return b[0].localeCompare(a[0])
-        }
-        return a[0].localeCompare(b[0])
-      })
-      map = new Map(arrayObj.map(i => [i[0], i[1]]));
-      return map;
-    },
-    groupKeys() {
-      return Array.from(this.groupMap.keys());
-    },
-    groupValues() {
-      return Array.from(this.groupMap.values());
-    },
-    showMembers() {
-      return this.groupMembers.filter((m) => !m.quit)
-    },
-    scrollHeight() {
-      return Math.min(300, 80 + this.showMembers.length / 10 * 80);
-    }
-  },
-  mounted() {
-    let scrollWrap = this.$refs.scrollbar.$el.querySelector('.el-scrollbar__wrap');
-    scrollWrap.addEventListener('scroll', this.onScroll);
-  }
+		},
+		onQuit() {
+			this.$confirm(`确认退出'${this.activeGroup.showGroupName}',并清空聊天记录吗?`, '确认退出?', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.$http({
+					url: `/group/quit/${this.activeGroup.id}`,
+					method: 'delete'
+				}).then(() => {
+					this.$message.success(`您已退出'${this.activeGroup.name}'`);
+					this.$store.commit("removeGroup", this.activeGroup.id);
+					this.$store.commit("removeGroupChat", this.activeGroup.id);
+					this.reset();
+				});
+			})
+		},
+		onSendMessage() {
+			let chat = {
+				type: 'GROUP',
+				targetId: this.activeGroup.id,
+				showName: this.activeGroup.showGroupName,
+				headImage: this.activeGroup.headImage,
+			};
+			this.$store.commit("openChat", chat);
+			this.$store.commit("activeChat", 0);
+			this.$router.push("/home/chat");
+		},
+		onScroll(e) {
+			const scrollbar = e.target;
+			// 滚到底部
+			if (scrollbar.scrollTop + scrollbar.clientHeight >= scrollbar.scrollHeight - 30) {
+				if (this.showMaxIdx < this.showMembers.length) {
+					this.showMaxIdx += 50;
+				}
+			}
+		},
+		loadGroupMembers() {
+			this.$http({
+				url: `/group/members/${this.activeGroup.id}`,
+				method: "get"
+			}).then((members) => {
+				this.groupMembers = members;
+			})
+		},
+		reset() {
+			this.activeGroup = {};
+			this.groupMembers = [];
+		},
+		firstLetter(strText) {
+			// 使用pinyin-pro库将中文转换为拼音
+			let pinyinOptions = {
+				toneType: 'none', // 无声调
+				type: 'normal' // 普通拼音
+			};
+			let pyText = pinyin(strText, pinyinOptions);
+			return pyText[0];
+		},
+		isEnglish(character) {
+			return /^[A-Za-z]+$/.test(character);
+		}
+	},
+	computed: {
+		groupStore() {
+			return this.$store.state.groupStore;
+		},
+		ownerName() {
+			let member = this.groupMembers.find((m) => m.userId == this.activeGroup.ownerId);
+			return member && member.showNickName;
+		},
+		isOwner() {
+			return this.activeGroup.ownerId == this.$store.state.userStore.userInfo.id;
+		},
+		imageAction() {
+			return `/image/upload`;
+		},
+		groupMap() {
+			// 按首字母分组
+			let map = new Map();
+			this.groupStore.groups.forEach((g) => {
+				if (g.quit || (this.searchText && !g.showGroupName.includes(this.searchText))) {
+					return;
+				}
+				let letter = this.firstLetter(g.showGroupName).toUpperCase();
+				// 非英文一律为#组
+				if (!this.isEnglish(letter)) {
+					letter = "#"
+				}
+				if (map.has(letter)) {
+					map.get(letter).push(g);
+				} else {
+					map.set(letter, [g]);
+				}
+			})
+			// 排序
+			let arrayObj = Array.from(map);
+			arrayObj.sort((a, b) => {
+				// #组在最后面
+				if (a[0] == '#' || b[0] == '#') {
+					return b[0].localeCompare(a[0])
+				}
+				return a[0].localeCompare(b[0])
+			})
+			map = new Map(arrayObj.map(i => [i[0], i[1]]));
+			return map;
+		},
+		groupKeys() {
+			return Array.from(this.groupMap.keys());
+		},
+		groupValues() {
+			return Array.from(this.groupMap.values());
+		},
+		showMembers() {
+			return this.groupMembers.filter((m) => !m.quit)
+		},
+		scrollHeight() {
+			return Math.min(300, 80 + this.showMembers.length / 10 * 80);
+		}
+	},
+	mounted() {
+		let scrollWrap = this.$refs.scrollbar.$el.querySelector('.el-scrollbar__wrap');
+		scrollWrap.addEventListener('scroll', this.onScroll);
+	}
 }
 </script>
 
 <style lang="scss">
 .group-page {
-  .group-list-box {
-    display: flex;
-    flex-direction: column;
-    background: var(--im-background);
+	.group-list-box {
+		display: flex;
+		flex-direction: column;
+		background: var(--im-background);
 
-    .group-list-header {
-      height: 50px;
-      display: flex;
-      align-items: center;
-      padding: 0 8px;
+		.group-list-header {
+			height: 50px;
+			display: flex;
+			align-items: center;
+			padding: 0 8px;
 
-      .add-btn {
-        padding: 5px !important;
-        margin: 5px;
-        font-size: 16px;
-        border-radius: 50%;
-      }
-    }
+			.add-btn {
+				padding: 5px !important;
+				margin: 5px;
+				font-size: 16px;
+				border-radius: 50%;
+			}
+		}
 
-    .group-list-items {
-      flex: 1;
-    }
-  }
+		.group-list-items {
+			flex: 1;
 
-  .group-box {
-    display: flex;
-    flex-direction: column;
+			.index-title {
+				text-align: left;
+				font-size: var(--im-larger-size-larger);
+				padding: 5px 15px;
+				color: var(--im-text-color-light);
+			}
+		}
+	}
 
-    .group-header {
-      display: flex;
-      justify-content: space-between;
-      padding: 0 12px;
-      line-height: 50px;
-      font-size: var(--im-font-size-larger);
-      border-bottom: var(--im-border);
-    }
+	.group-box {
+		display: flex;
+		flex-direction: column;
 
-    .el-divider--horizontal {
-      margin: 16px 0;
-    }
+		.group-header {
+			display: flex;
+			justify-content: space-between;
+			padding: 0 12px;
+			line-height: 50px;
+			font-size: var(--im-font-size-larger);
+			border-bottom: var(--im-border);
+		}
 
-    .group-container {
-      overflow: auto;
-      padding: 20px;
-      flex: 1;
+		.el-divider--horizontal {
+			margin: 16px 0;
+		}
 
-      .group-info {
-        display: flex;
-        padding: 5px 20px;
+		.group-container {
+			overflow: auto;
+			padding: 20px;
+			flex: 1;
 
-        .group-form {
-          flex: 1;
-          padding-left: 40px;
-          max-width: 700px;
-        }
+			.group-info {
+				display: flex;
+				padding: 5px 20px;
 
-        .avatar-uploader {
-          --width: 160px;
-          text-align: left;
+				.group-form {
+					flex: 1;
+					padding-left: 40px;
+					max-width: 700px;
+				}
 
-          .el-upload {
-            border: 1px dashed #d9d9d9 !important;
-            border-radius: 6px;
-            cursor: pointer;
-            position: relative;
-            overflow: hidden;
-          }
+				.avatar-uploader {
+					--width: 160px;
+					text-align: left;
 
-          .el-upload:hover {
-            border-color: #409EFF;
-          }
+					.el-upload {
+						border: 1px dashed #d9d9d9 !important;
+						border-radius: 6px;
+						cursor: pointer;
+						position: relative;
+						overflow: hidden;
+					}
 
-          .avatar-uploader-icon {
-            font-size: 28px;
-            color: #8c939d;
-            width: var(--width);
-            height: var(--width);
-            line-height: var(--width);
-            text-align: center;
-          }
+					.el-upload:hover {
+						border-color: #409EFF;
+					}
 
-          .avatar {
-            width: var(--width);
-            height: var(--width);
-            display: block;
-          }
-        }
+					.avatar-uploader-icon {
+						font-size: 28px;
+						color: #8c939d;
+						width: var(--width);
+						height: var(--width);
+						line-height: var(--width);
+						text-align: center;
+					}
 
-        .send-btn {
-          margin-top: 12px;
-        }
-      }
+					.avatar {
+						width: var(--width);
+						height: var(--width);
+						display: block;
+					}
+				}
 
-      .group-member-list {
-        padding: 0 12px;
-        display: flex;
-        align-items: center;
-        flex-wrap: wrap;
-        text-align: center;
+				.send-btn {
+					margin-top: 12px;
+				}
+			}
 
-        .group-member {
-          margin-right: 5px;
-        }
+			.group-member-list {
+				padding: 0 12px;
+				display: flex;
+				align-items: center;
+				flex-wrap: wrap;
+				text-align: center;
 
-        .group-invite {
-          display: flex;
-          flex-direction: column;
-          align-items: center;
-          width: 60px;
+				.group-member {
+					margin-right: 5px;
+				}
 
-          .invite-member-btn {
-            width: 38px;
-            height: 38px;
-            line-height: 38px;
-            border: var(--im-border);
-            font-size: 14px;
-            cursor: pointer;
-            box-sizing: border-box;
+				.group-invite {
+					display: flex;
+					flex-direction: column;
+					align-items: center;
+					width: 60px;
 
-            &:hover {
-              border: #aaaaaa solid 1px;
-            }
-          }
+					.invite-member-btn {
+						width: 38px;
+						height: 38px;
+						line-height: 38px;
+						border: var(--im-border);
+						font-size: 14px;
+						cursor: pointer;
+						box-sizing: border-box;
 
-          .invite-member-text {
-            font-size: var(--im-font-size-smaller);
-            text-align: center;
-            width: 100%;
-            height: 30px;
-            line-height: 30px;
-            white-space: nowrap;
-            text-overflow: ellipsis;
-            overflow: hidden
-          }
-        }
+						&:hover {
+							border: #aaaaaa solid 1px;
+						}
+					}
 
-      }
-    }
+					.invite-member-text {
+						font-size: var(--im-font-size-smaller);
+						text-align: center;
+						width: 100%;
+						height: 30px;
+						line-height: 30px;
+						white-space: nowrap;
+						text-overflow: ellipsis;
+						overflow: hidden
+					}
+				}
 
+			}
+		}
 
-  }
+
+	}
 }
-</style>
+</style>

+ 41 - 7
im-web/src/view/Home.vue

@@ -81,7 +81,8 @@ export default {
 		return {
 			showSettingDialog: false,
 			lastPlayAudioTime: new Date().getTime() - 1000,
-			isFullscreen: true
+			isFullscreen: true,
+			reconnecting: false
 		}
 	},
 	methods: {
@@ -99,9 +100,13 @@ export default {
 				// ws初始化
 				this.$wsApi.connect(process.env.VUE_APP_WS_URL, sessionStorage.getItem("accessToken"));
 				this.$wsApi.onConnect(() => {
-					// 加载离线消息
-					this.pullPrivateOfflineMessage(this.$store.state.chatStore.privateMsgMaxId);
-					this.pullGroupOfflineMessage(this.$store.state.chatStore.groupMsgMaxId);
+					if (this.reconnecting) {
+						this.onReconnectWs();
+					} else {
+						// 加载离线消息
+						this.pullPrivateOfflineMessage(this.$store.state.chatStore.privateMsgMaxId);
+						this.pullGroupOfflineMessage(this.$store.state.chatStore.groupMsgMaxId);
+					}
 				});
 				this.$wsApi.onMessage((cmd, msgInfo) => {
 					if (cmd == 2) {
@@ -130,15 +135,44 @@ export default {
 					console.log(e);
 					if (e.code != 3000) {
 						// 断线重连
-						this.$message.error("连接断开,正在尝试重新连接...");
-						this.$wsApi.reconnect(process.env.VUE_APP_WS_URL, sessionStorage.getItem(
-							"accessToken"));
+						this.reconnectWs();
 					}
 				});
 			}).catch((e) => {
 				console.log("初始化失败", e);
 			})
 		},
+		reconnectWs() {
+			// 记录标志
+			this.reconnecting = true;
+			// 重新加载一次个人信息,目的是为了保证网络已经正常且token有效
+			this.$store.dispatch("loadUser").then(() => {
+				// 断线重连
+				this.$message.error("连接断开,正在尝试重新连接...");
+				this.$wsApi.reconnect(process.env.VUE_APP_WS_URL, sessionStorage.getItem(
+					"accessToken"));
+			}).catch(() => {
+				// 10s后重试
+				setTimeout(() => this.reconnectWs(), 10000)
+			})
+		},
+		onReconnectWs() {
+			// 重连成功
+			this.reconnecting = false;
+			// 重新加载群和好友
+			const promises = [];
+			promises.push(this.$store.dispatch("loadFriend"));
+			promises.push(this.$store.dispatch("loadGroup"));
+			Promise.all(promises).then(() => {
+				// 加载离线消息
+				this.pullPrivateOfflineMessage(this.$store.state.chatStore.privateMsgMaxId);
+				this.pullGroupOfflineMessage(this.$store.state.chatStore.groupMsgMaxId);
+				this.$message.success("重新连接成功");
+			}).catch(() => {
+				this.$message.error("初始化失败");
+				this.onExit();
+			})
+		},
 		pullPrivateOfflineMessage(minId) {
 			this.$store.commit("loadingPrivateMsg", true)
 			this.$http({