Browse Source

!8 支持撤回消息

Merge pull request !8 from blue/v_1.1.0
master
blue 3 years ago
committed by Gitee
parent
commit
e123ffb4f9
No known key found for this signature in database GPG Key ID: 173E9B9CA92EEF8F
  1. 2
      commom/src/main/java/com/bx/common/contant/Constant.java
  2. 4
      commom/src/main/java/com/bx/common/enums/MessageStatusEnum.java
  3. 3
      commom/src/main/java/com/bx/common/enums/MessageTypeEnum.java
  4. 6
      im-platform/src/main/java/com/bx/implatform/controller/GroupController.java
  5. 18
      im-platform/src/main/java/com/bx/implatform/controller/GroupMessageController.java
  6. 20
      im-platform/src/main/java/com/bx/implatform/controller/PrivateMessageController.java
  7. 6
      im-platform/src/main/java/com/bx/implatform/entity/GroupMessage.java
  8. 2
      im-platform/src/main/java/com/bx/implatform/entity/PrivateMessage.java
  9. 4
      im-platform/src/main/java/com/bx/implatform/service/IGroupMessageService.java
  10. 4
      im-platform/src/main/java/com/bx/implatform/service/IPrivateMessageService.java
  11. 101
      im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java
  12. 2
      im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java
  13. 46
      im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java
  14. 1
      im-platform/src/main/java/com/bx/implatform/task/PullAlreadyReadMessageTask.java
  15. 2
      im-platform/src/main/java/com/bx/implatform/vo/GroupMessageVO.java
  16. 2
      im-platform/src/main/java/com/bx/implatform/vo/PrivateMessageVO.java
  17. 7
      im-platform/src/main/resources/db/db.sql
  18. 2
      im-server/src/main/java/com/bx/imserver/websocket/WebsocketChannelCtxHolder.java
  19. 2
      im-server/src/main/java/com/bx/imserver/websocket/endecode/MessageProtocolDecoder.java
  20. 2
      im-server/src/main/java/com/bx/imserver/websocket/endecode/MessageProtocolEncoder.java
  21. 3
      im-server/src/main/java/com/bx/imserver/websocket/processor/GroupMessageProcessor.java
  22. 1
      im-server/src/main/java/com/bx/imserver/websocket/processor/HeartbeatProcessor.java
  23. 8
      im-server/src/main/java/com/bx/imserver/websocket/processor/PrivateMessageProcessor.java
  24. 5
      im-ui/.env.development
  25. 4
      im-ui/.env.production
  26. 6
      im-ui/public/index.html
  27. 12
      im-ui/src/api/element.js
  28. 43
      im-ui/src/assets/iconfont/iconfont.css
  29. BIN
      im-ui/src/assets/iconfont/iconfont.ttf
  30. BIN
      im-ui/src/assets/iconfont/iconfont.woff
  31. BIN
      im-ui/src/assets/iconfont/iconfont.woff2
  32. 126
      im-ui/src/components/chat/ChatBox.vue
  33. 1
      im-ui/src/components/chat/ChatVoice.vue
  34. 117
      im-ui/src/components/chat/MessageItem.vue
  35. 63
      im-ui/src/components/common/RightMenu.vue
  36. 1
      im-ui/src/main.js
  37. 53
      im-ui/src/store/chatStore.js
  38. 2
      im-ui/src/store/friendStore.js
  39. 2
      im-ui/src/store/groupStore.js
  40. 1
      im-ui/src/store/uiStore.js
  41. 59
      im-ui/src/view/Home.vue
  42. 5
      im-ui/src/view/Login.vue

2
commom/src/main/java/com/bx/common/contant/Constant.java

@ -12,4 +12,6 @@ public class Constant {
public static final long MAX_GROUP_MEMBER = 500; public static final long MAX_GROUP_MEMBER = 500;
// 在线状态过期时间 600s // 在线状态过期时间 600s
public static final long ONLINE_TIMEOUT_SECOND = 600; public static final long ONLINE_TIMEOUT_SECOND = 600;
// 消息允许撤回时间 300s
public static final long ALLOW_RECALL_SECOND = 300;
} }

4
commom/src/main/java/com/bx/common/enums/MessageStatusEnum.java

@ -4,8 +4,8 @@ package com.bx.common.enums;
public enum MessageStatusEnum { public enum MessageStatusEnum {
UNREAD(0,"未读"), UNREAD(0,"未读"),
ALREADY_READ(1,"已读"); ALREADY_READ(1,"已读"),
RECALL(2,"已撤回");
private Integer code; private Integer code;

3
commom/src/main/java/com/bx/common/enums/MessageTypeEnum.java

@ -6,7 +6,8 @@ public enum MessageTypeEnum {
TEXT(0,"文字"), TEXT(0,"文字"),
FILE(1,"文件"), FILE(1,"文件"),
IMAGE(2,"图片"), IMAGE(2,"图片"),
VIDEO(3,"视频"); VIDEO(3,"视频"),
TIP(10,"系统提示");
private Integer code; private Integer code;

6
im-platform/src/main/java/com/bx/implatform/controller/GroupController.java

@ -40,7 +40,7 @@ public class GroupController {
@ApiOperation(value = "解散群聊",notes="解散群聊") @ApiOperation(value = "解散群聊",notes="解散群聊")
@DeleteMapping("/delete/{groupId}") @DeleteMapping("/delete/{groupId}")
public Result<GroupVO> deleteGroup(@NotNull(message = "群聊id不能为空") @PathVariable Long groupId){ public Result deleteGroup(@NotNull(message = "群聊id不能为空") @PathVariable Long groupId){
groupService.deleteGroup(groupId); groupService.deleteGroup(groupId);
return ResultUtils.success(); return ResultUtils.success();
} }
@ -72,14 +72,14 @@ public class GroupController {
@ApiOperation(value = "退出群聊",notes="退出群聊") @ApiOperation(value = "退出群聊",notes="退出群聊")
@DeleteMapping("/quit/{groupId}") @DeleteMapping("/quit/{groupId}")
public Result<GroupVO> quitGroup(@NotNull(message = "群聊id不能为空") @PathVariable Long groupId){ public Result quitGroup(@NotNull(message = "群聊id不能为空") @PathVariable Long groupId){
groupService.quitGroup(groupId); groupService.quitGroup(groupId);
return ResultUtils.success(); return ResultUtils.success();
} }
@ApiOperation(value = "踢出群聊",notes="将用户踢出群聊") @ApiOperation(value = "踢出群聊",notes="将用户踢出群聊")
@DeleteMapping("/kick/{groupId}") @DeleteMapping("/kick/{groupId}")
public Result<GroupVO> kickGroup(@NotNull(message = "群聊id不能为空") @PathVariable Long groupId, public Result kickGroup(@NotNull(message = "群聊id不能为空") @PathVariable Long groupId,
@NotNull(message = "用户id不能为空") @RequestParam Long userId){ @NotNull(message = "用户id不能为空") @RequestParam Long userId){
groupService.kickGroup(groupId,userId); groupService.kickGroup(groupId,userId);
return ResultUtils.success(); return ResultUtils.success();

18
im-platform/src/main/java/com/bx/implatform/controller/GroupMessageController.java

@ -8,13 +8,10 @@ import com.bx.implatform.vo.GroupMessageVO;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@Api(tags = "群聊消息") @Api(tags = "群聊消息")
@ -28,11 +25,16 @@ public class GroupMessageController {
@PostMapping("/send") @PostMapping("/send")
@ApiOperation(value = "发送群聊消息",notes="发送群聊消息") @ApiOperation(value = "发送群聊消息",notes="发送群聊消息")
public Result register(@Valid @RequestBody GroupMessageVO vo){ public Result<Long> sendMessage(@Valid @RequestBody GroupMessageVO vo){
groupMessageService.sendMessage(vo); return ResultUtils.success(groupMessageService.sendMessage(vo));
return ResultUtils.success();
} }
@DeleteMapping("/recall/{id}")
@ApiOperation(value = "撤回消息",notes="撤回群聊消息")
public Result<Long> recallMessage(@NotNull(message = "消息id不能为空") @PathVariable Long id){
groupMessageService.recallMessage(id);
return ResultUtils.success();
}
@PostMapping("/pullUnreadMessage") @PostMapping("/pullUnreadMessage")
@ApiOperation(value = "拉取未读消息",notes="拉取未读消息") @ApiOperation(value = "拉取未读消息",notes="拉取未读消息")

20
im-platform/src/main/java/com/bx/implatform/controller/PrivateMessageController.java

@ -8,12 +8,10 @@ import com.bx.implatform.vo.PrivateMessageVO;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@Api(tags = "私聊消息") @Api(tags = "私聊消息")
@RestController @RestController
@ -24,12 +22,20 @@ public class PrivateMessageController {
private IPrivateMessageService privateMessageService; private IPrivateMessageService privateMessageService;
@PostMapping("/send") @PostMapping("/send")
@ApiOperation(value = "发送消息",notes="发送单人消息") @ApiOperation(value = "发送消息",notes="发送私聊消息")
public Result register(@Valid @RequestBody PrivateMessageVO vo){ public Result<Long> sendMessage(@Valid @RequestBody PrivateMessageVO vo){
privateMessageService.sendMessage(vo); return ResultUtils.success(privateMessageService.sendMessage(vo));
}
@DeleteMapping("/recall/{id}")
@ApiOperation(value = "撤回消息",notes="撤回私聊消息")
public Result<Long> recallMessage(@NotNull(message = "消息id不能为空") @PathVariable Long id){
privateMessageService.recallMessage(id);
return ResultUtils.success(); return ResultUtils.success();
} }
@PostMapping("/pullUnreadMessage") @PostMapping("/pullUnreadMessage")
@ApiOperation(value = "拉取未读消息",notes="拉取未读消息") @ApiOperation(value = "拉取未读消息",notes="拉取未读消息")
public Result pullUnreadMessage(){ public Result pullUnreadMessage(){

6
im-platform/src/main/java/com/bx/implatform/entity/GroupMessage.java

@ -56,6 +56,12 @@ public class GroupMessage extends Model<GroupMessage> {
@TableField("type") @TableField("type")
private Integer type; private Integer type;
/**
* 状态
*/
@TableField("status")
private Integer status;
/** /**
* 发送时间 * 发送时间
*/ */

2
im-platform/src/main/java/com/bx/implatform/entity/PrivateMessage.java

@ -51,7 +51,7 @@ public class PrivateMessage extends Model<PrivateMessage> {
private String content; private String content;
/** /**
* 消息类型 * 消息类型 0:文字 1:图片 2:文件 3:语音 10:撤回消息
*/ */
@TableField("type") @TableField("type")
private Integer type; private Integer type;

4
im-platform/src/main/java/com/bx/implatform/service/IGroupMessageService.java

@ -8,7 +8,9 @@ import com.bx.implatform.vo.GroupMessageVO;
public interface IGroupMessageService extends IService<GroupMessage> { public interface IGroupMessageService extends IService<GroupMessage> {
void sendMessage(GroupMessageVO vo); Long sendMessage(GroupMessageVO vo);
void recallMessage(Long id);
void pullUnreadMessage(); void pullUnreadMessage();
} }

4
im-platform/src/main/java/com/bx/implatform/service/IPrivateMessageService.java

@ -7,7 +7,9 @@ import com.bx.implatform.vo.PrivateMessageVO;
public interface IPrivateMessageService extends IService<PrivateMessage> { public interface IPrivateMessageService extends IService<PrivateMessage> {
void sendMessage(PrivateMessageVO vo); Long sendMessage(PrivateMessageVO vo);
void recallMessage(Long id);
void pullUnreadMessage(); void pullUnreadMessage();

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

@ -2,7 +2,10 @@ package com.bx.implatform.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bx.common.contant.Constant;
import com.bx.common.contant.RedisKey; import com.bx.common.contant.RedisKey;
import com.bx.common.enums.MessageStatusEnum;
import com.bx.common.enums.MessageTypeEnum;
import com.bx.common.enums.ResultCode; import com.bx.common.enums.ResultCode;
import com.bx.common.model.im.GroupMessageInfo; import com.bx.common.model.im.GroupMessageInfo;
import com.bx.common.util.BeanUtils; import com.bx.common.util.BeanUtils;
@ -46,7 +49,7 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
* @return * @return
*/ */
@Override @Override
public void sendMessage(GroupMessageVO vo) { public Long sendMessage(GroupMessageVO vo) {
Long userId = SessionContext.getSession().getId(); Long userId = SessionContext.getSession().getId();
Group group = groupService.getById(vo.getGroupId()); Group group = groupService.getById(vo.getGroupId());
if(group == null){ if(group == null){
@ -65,30 +68,53 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
msg.setSendId(userId); msg.setSendId(userId);
msg.setSendTime(new Date()); msg.setSendTime(new Date());
this.save(msg); this.save(msg);
// 根据群聊每个成员所连的IM-server,进行分组 // 群发
Map<Integer,List<Long>> serverMap = new ConcurrentHashMap<>(); GroupMessageInfo msgInfo = BeanUtils.copyProperties(msg, GroupMessageInfo.class);
userIds.parallelStream().forEach(id->{ this.sendMessage(userIds,msgInfo);
String key = RedisKey.IM_USER_SERVER_ID + id; log.info("发送群聊消息,发送id:{},群聊id:{},内容:{}",userId,vo.getGroupId(),vo.getContent());
Integer serverId = (Integer)redisTemplate.opsForValue().get(key); return msg.getId();
if(serverId != null){
if(serverMap.containsKey(serverId)){
serverMap.get(serverId).add(id);
}else {
List<Long> list = Collections.synchronizedList(new LinkedList<Long>());
list.add(id);
serverMap.put(serverId,list);
} }
/**
* 撤回消息
*
* @param id 消息id
*/
@Override
public void recallMessage(Long id) {
Long userId = SessionContext.getSession().getId();
GroupMessage msg = this.getById(id);
if(msg == null){
throw new GlobalException(ResultCode.PROGRAM_ERROR,"消息不存在");
} }
}); if(msg.getSendId() != userId){
// 逐个server发送 throw new GlobalException(ResultCode.PROGRAM_ERROR,"这条消息不是由您发送,无法撤回");
for (Map.Entry<Integer,List<Long>> entry : serverMap.entrySet()) {
GroupMessageInfo msgInfo = BeanUtils.copyProperties(msg, GroupMessageInfo.class);
msgInfo.setRecvIds(new LinkedList<>(entry.getValue()));
String key = RedisKey.IM_UNREAD_GROUP_MESSAGE +entry.getKey();
redisTemplate.opsForList().rightPush(key,msgInfo);
} }
log.info("发送群聊消息,发送id:{},群聊id:{}",userId,vo.getGroupId()); if(System.currentTimeMillis() - msg.getSendTime().getTime() > Constant.ALLOW_RECALL_SECOND * 1000){
throw new GlobalException(ResultCode.PROGRAM_ERROR,"消息已发送超过5分钟,无法撤回");
} }
// 判断是否在群里
GroupMember member = groupMemberService.findByGroupAndUserId(msg.getGroupId(),userId);
if(member == null){
throw new GlobalException(ResultCode.PROGRAM_ERROR,"您已不在群聊里面,无法撤回消息");
}
// 修改数据库
msg.setStatus(MessageStatusEnum.RECALL.getCode());
this.updateById(msg);
// 群发
List<Long> userIds = groupMemberService.findUserIdsByGroupId(msg.getGroupId());
GroupMessageInfo msgInfo = BeanUtils.copyProperties(msg, GroupMessageInfo.class);
msgInfo.setType(MessageTypeEnum.TIP.getCode());
String content = String.format("'%s'撤回了一条消息",member.getAliasName());
msgInfo.setContent(content);
msgInfo.setSendTime(new Date());
this.sendMessage(userIds,msgInfo);
log.info("撤回群聊消息,发送id:{},群聊id:{},内容:{}",userId,msg.getGroupId(),msg.getContent());
}
/** /**
* 异步拉取群聊消息通过websocket异步推送 * 异步拉取群聊消息通过websocket异步推送
@ -111,7 +137,9 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
key = RedisKey.IM_GROUP_READED_POSITION + member.getGroupId()+":"+userId; key = RedisKey.IM_GROUP_READED_POSITION + member.getGroupId()+":"+userId;
Integer maxReadedId = (Integer)redisTemplate.opsForValue().get(key); Integer maxReadedId = (Integer)redisTemplate.opsForValue().get(key);
QueryWrapper<GroupMessage> wrapper = new QueryWrapper(); QueryWrapper<GroupMessage> wrapper = new QueryWrapper();
wrapper.lambda().eq(GroupMessage::getGroupId,member.getGroupId()); wrapper.lambda().eq(GroupMessage::getGroupId,member.getGroupId())
.gt(GroupMessage::getSendTime,member.getCreatedTime())
.ne(GroupMessage::getStatus,MessageStatusEnum.RECALL.getCode());
if(maxReadedId!=null){ if(maxReadedId!=null){
wrapper.lambda().gt(GroupMessage::getId,maxReadedId); wrapper.lambda().gt(GroupMessage::getId,maxReadedId);
} }
@ -128,6 +156,35 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
}).collect(Collectors.toList()); }).collect(Collectors.toList());
key = RedisKey.IM_UNREAD_GROUP_MESSAGE + serverId; key = RedisKey.IM_UNREAD_GROUP_MESSAGE + serverId;
redisTemplate.opsForList().rightPushAll(key,messageInfos.toArray()); redisTemplate.opsForList().rightPushAll(key,messageInfos.toArray());
log.info("拉取未读群聊消息,用户id:{},群聊id:{},数量:{}",userId,member.getGroupId(),messageInfos.size());
}
}
private void sendMessage(List<Long> userIds, GroupMessageInfo msgInfo){
// 根据群聊每个成员所连的IM-server,进行分组
Map<Integer,List<Long>> serverMap = new ConcurrentHashMap<>();
userIds.parallelStream().forEach(id->{
String key = RedisKey.IM_USER_SERVER_ID + id;
Integer serverId = (Integer)redisTemplate.opsForValue().get(key);
if(serverId != null){
if(serverMap.containsKey(serverId)){
serverMap.get(serverId).add(id);
}else {
// 此处需要加锁,否则list可以会被覆盖
synchronized(serverMap){
List<Long> list = Collections.synchronizedList(new LinkedList<Long>());
list.add(id);
serverMap.put(serverId,list);
}
}
}
});
// 逐个server发送
for (Map.Entry<Integer,List<Long>> entry : serverMap.entrySet()) {
msgInfo.setRecvIds(new LinkedList<>(entry.getValue()));
String key = RedisKey.IM_UNREAD_GROUP_MESSAGE +entry.getKey();
redisTemplate.opsForList().rightPush(key,msgInfo);
} }
} }
} }

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

@ -30,6 +30,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.Collections; import java.util.Collections;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -273,6 +274,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
groupMember.setAliasName(f.getFriendNickName()); groupMember.setAliasName(f.getFriendNickName());
groupMember.setRemark(group.getName()); groupMember.setRemark(group.getName());
groupMember.setHeadImage(f.getFriendHeadImage()); groupMember.setHeadImage(f.getFriendHeadImage());
groupMember.setCreatedTime(new Date());
groupMember.setQuit(false); groupMember.setQuit(false);
return groupMember; return groupMember;
}).collect(Collectors.toList()); }).collect(Collectors.toList());

46
im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java

@ -2,8 +2,10 @@ package com.bx.implatform.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bx.common.contant.Constant;
import com.bx.common.contant.RedisKey; import com.bx.common.contant.RedisKey;
import com.bx.common.enums.MessageStatusEnum; import com.bx.common.enums.MessageStatusEnum;
import com.bx.common.enums.MessageTypeEnum;
import com.bx.common.enums.ResultCode; import com.bx.common.enums.ResultCode;
import com.bx.common.model.im.PrivateMessageInfo; import com.bx.common.model.im.PrivateMessageInfo;
import com.bx.common.util.BeanUtils; import com.bx.common.util.BeanUtils;
@ -36,10 +38,10 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
* 发送私聊消息 * 发送私聊消息
* *
* @param vo 私聊消息vo * @param vo 私聊消息vo
* @return * @return 消息id
*/ */
@Override @Override
public void sendMessage(PrivateMessageVO vo) { public Long sendMessage(PrivateMessageVO vo) {
Long userId = SessionContext.getSession().getId(); Long userId = SessionContext.getSession().getId();
Boolean isFriends = friendService.isFriend(userId,vo.getRecvId()); Boolean isFriends = friendService.isFriend(userId,vo.getRecvId());
if(!isFriends){ if(!isFriends){
@ -60,7 +62,44 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
PrivateMessageInfo msgInfo = BeanUtils.copyProperties(msg, PrivateMessageInfo.class); PrivateMessageInfo msgInfo = BeanUtils.copyProperties(msg, PrivateMessageInfo.class);
redisTemplate.opsForList().rightPush(sendKey,msgInfo); redisTemplate.opsForList().rightPush(sendKey,msgInfo);
} }
log.info("发送私聊消息,发送id:{},接收id:{}",userId,vo.getRecvId()); log.info("发送私聊消息,发送id:{},接收id:{},内容:{}",userId,vo.getRecvId(),vo.getContent());
return msg.getId();
}
/**
* 撤回消息
*
* @param id 消息id
*/
@Override
public void recallMessage(Long id) {
Long userId = SessionContext.getSession().getId();
PrivateMessage msg = this.getById(id);
if(msg == null){
throw new GlobalException(ResultCode.PROGRAM_ERROR,"消息不存在");
}
if(msg.getSendId() != userId){
throw new GlobalException(ResultCode.PROGRAM_ERROR,"这条消息不是由您发送,无法撤回");
}
if(System.currentTimeMillis() - msg.getSendTime().getTime() > Constant.ALLOW_RECALL_SECOND * 1000){
throw new GlobalException(ResultCode.PROGRAM_ERROR,"消息已发送超过5分钟,无法撤回");
}
// 修改消息状态
msg.setStatus(MessageStatusEnum.RECALL.getCode());
this.updateById(msg);
// 获取对方连接的channelId
String key = RedisKey.IM_USER_SERVER_ID+msg.getRecvId();
Integer serverId = (Integer)redisTemplate.opsForValue().get(key);
// 如果对方在线,将数据存储至redis,等待拉取推送
if(serverId != null){
String sendKey = RedisKey.IM_UNREAD_PRIVATE_MESSAGE + serverId;
PrivateMessageInfo msgInfo = BeanUtils.copyProperties(msg, PrivateMessageInfo.class);
msgInfo.setType(MessageTypeEnum.TIP.getCode());
msgInfo.setSendTime(new Date());
msgInfo.setContent("对方撤回了一条消息");
redisTemplate.opsForList().rightPush(sendKey,msgInfo);
}
log.info("撤回私聊消息,发送id:{},接收id:{},内容:{}",msg.getSendId(),msg.getRecvId(),msg.getContent());
} }
/** /**
@ -90,6 +129,7 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
}).collect(Collectors.toList()); }).collect(Collectors.toList());
String sendKey = RedisKey.IM_UNREAD_PRIVATE_MESSAGE + serverId; String sendKey = RedisKey.IM_UNREAD_PRIVATE_MESSAGE + serverId;
redisTemplate.opsForList().rightPushAll(sendKey,infos.toArray()); redisTemplate.opsForList().rightPushAll(sendKey,infos.toArray());
log.info("拉取未读私聊消息,用户id:{},数量:{}",userId,infos.size());
} }
} }
} }

1
im-platform/src/main/java/com/bx/implatform/task/PullAlreadyReadMessageTask.java

@ -55,6 +55,7 @@ public class PullAlreadyReadMessageTask {
if(msgId!=null){ if(msgId!=null){
UpdateWrapper<PrivateMessage> updateWrapper = new UpdateWrapper<>(); UpdateWrapper<PrivateMessage> updateWrapper = new UpdateWrapper<>();
updateWrapper.lambda().eq(PrivateMessage::getId,msgId) updateWrapper.lambda().eq(PrivateMessage::getId,msgId)
.eq(PrivateMessage::getStatus,MessageStatusEnum.UNREAD.getCode())
.set(PrivateMessage::getStatus, MessageStatusEnum.ALREADY_READ.getCode()); .set(PrivateMessage::getStatus, MessageStatusEnum.ALREADY_READ.getCode());
privateMessageService.update(updateWrapper); privateMessageService.update(updateWrapper);
log.info("消息已读,id:{}",msgId); log.info("消息已读,id:{}",msgId);

2
im-platform/src/main/java/com/bx/implatform/vo/GroupMessageVO.java

@ -22,7 +22,7 @@ public class GroupMessageVO {
@ApiModelProperty(value = "发送内容") @ApiModelProperty(value = "发送内容")
private String content; private String content;
@NotNull(message="发送内容不可为空") @NotNull(message="消息类型不可为空")
@ApiModelProperty(value = "消息类型") @ApiModelProperty(value = "消息类型")
private Integer type; private Integer type;
} }

2
im-platform/src/main/java/com/bx/implatform/vo/PrivateMessageVO.java

@ -24,7 +24,7 @@ public class PrivateMessageVO {
@ApiModelProperty(value = "发送内容") @ApiModelProperty(value = "发送内容")
private String content; private String content;
@NotNull(message="发送内容不可为空") @NotNull(message="消息类型不可为空")
@ApiModelProperty(value = "消息类型") @ApiModelProperty(value = "消息类型")
private Integer type; private Integer type;

7
im-platform/src/main/resources/db/db.sql

@ -30,8 +30,8 @@ create table `im_private_message`(
`send_id` bigint not null comment '发送用户id', `send_id` bigint not null comment '发送用户id',
`recv_id` bigint not null comment '接收用户id', `recv_id` bigint not null comment '接收用户id',
`content` text comment '发送内容', `content` text comment '发送内容',
`type` tinyint(1) NOT NULL comment '消息类型 0:文字 1:图片 2:文件', `type` tinyint(1) NOT NULL comment '消息类型 0:文字 1:图片 2:文件 3:语音 10:系统提示',
`status` tinyint(1) NOT NULL comment '状态 0:未读 1:已读 ', `status` tinyint(1) NOT NULL comment '状态 0:未读 1:已读 2:撤回',
`send_time` datetime DEFAULT CURRENT_TIMESTAMP comment '发送时间', `send_time` datetime DEFAULT CURRENT_TIMESTAMP comment '发送时间',
key `idx_send_recv_id` (`send_id`,`recv_id`) key `idx_send_recv_id` (`send_id`,`recv_id`)
)ENGINE=InnoDB CHARSET=utf8mb3 comment '私聊消息'; )ENGINE=InnoDB CHARSET=utf8mb3 comment '私聊消息';
@ -67,7 +67,8 @@ create table `im_group_message`(
`group_id` bigint not null comment '群id', `group_id` bigint not null comment '群id',
`send_id` bigint not null comment '发送用户id', `send_id` bigint not null comment '发送用户id',
`content` text comment '发送内容', `content` text comment '发送内容',
`type` tinyint(1) NOT NULL comment '消息类型 0:文字 1:图片 2:文件', `type` tinyint(1) NOT NULL comment '消息类型 0:文字 1:图片 2:文件 3:语音 10:系统提示' ,
`status` tinyint(1) DEFAULT 0 comment '状态 0:正常 2:撤回',
`send_time` datetime DEFAULT CURRENT_TIMESTAMP comment '发送时间', `send_time` datetime DEFAULT CURRENT_TIMESTAMP comment '发送时间',
key `idx_group_id` (group_id) key `idx_group_id` (group_id)
)ENGINE=InnoDB CHARSET=utf8mb3 comment '群消息'; )ENGINE=InnoDB CHARSET=utf8mb3 comment '群消息';

2
im-server/src/main/java/com/bx/imserver/websocket/WebsocketChannelCtxHolder.java

@ -2,7 +2,7 @@ package com.bx.imserver.websocket;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
import java.util.*; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;

2
im-server/src/main/java/com/bx/imserver/websocket/endecode/MessageProtocolDecoder.java

@ -1,7 +1,7 @@
package com.bx.imserver.websocket.endecode; package com.bx.imserver.websocket.endecode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.bx.common.model.im.SendInfo; import com.bx.common.model.im.SendInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageDecoder; import io.netty.handler.codec.MessageToMessageDecoder;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;

2
im-server/src/main/java/com/bx/imserver/websocket/endecode/MessageProtocolEncoder.java

@ -1,7 +1,7 @@
package com.bx.imserver.websocket.endecode; package com.bx.imserver.websocket.endecode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.bx.common.model.im.SendInfo; import com.bx.common.model.im.SendInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageEncoder; import io.netty.handler.codec.MessageToMessageEncoder;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;

3
im-server/src/main/java/com/bx/imserver/websocket/processor/GroupMessageProcessor.java

@ -1,6 +1,7 @@
package com.bx.imserver.websocket.processor; package com.bx.imserver.websocket.processor;
import com.bx.common.contant.RedisKey; import com.bx.common.contant.RedisKey;
import com.bx.common.enums.MessageTypeEnum;
import com.bx.common.enums.WSCmdEnum; import com.bx.common.enums.WSCmdEnum;
import com.bx.common.model.im.GroupMessageInfo; import com.bx.common.model.im.GroupMessageInfo;
import com.bx.common.model.im.SendInfo; import com.bx.common.model.im.SendInfo;
@ -39,9 +40,11 @@ public class GroupMessageProcessor extends MessageProcessor<GroupMessageInfo> {
sendInfo.setData(data); sendInfo.setData(data);
channelCtx.channel().writeAndFlush(sendInfo); channelCtx.channel().writeAndFlush(sendInfo);
} }
if(data.getType() != MessageTypeEnum.TIP.getCode()){
// 设置已读最大id // 设置已读最大id
String key = RedisKey.IM_GROUP_READED_POSITION + data.getGroupId()+":"+recvId; String key = RedisKey.IM_GROUP_READED_POSITION + data.getGroupId()+":"+recvId;
redisTemplate.opsForValue().set(key,data.getId()); redisTemplate.opsForValue().set(key,data.getId());
}
}else { }else {
log.error("未找到WS连接,发送者:{},群id:{},接收id:{},内容:{}",data.getSendId(),data.getGroupId(),data.getRecvIds()); log.error("未找到WS连接,发送者:{},群id:{},接收id:{},内容:{}",data.getSendId(),data.getGroupId(),data.getRecvIds());
} }

1
im-server/src/main/java/com/bx/imserver/websocket/processor/HeartbeatProcessor.java

@ -8,7 +8,6 @@ import com.bx.common.model.im.HeartbeatInfo;
import com.bx.common.model.im.SendInfo; import com.bx.common.model.im.SendInfo;
import com.bx.imserver.websocket.WebsocketServer; import com.bx.imserver.websocket.WebsocketServer;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
import io.netty.util.Attribute;
import io.netty.util.AttributeKey; import io.netty.util.AttributeKey;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;

8
im-server/src/main/java/com/bx/imserver/websocket/processor/PrivateMessageProcessor.java

@ -1,9 +1,10 @@
package com.bx.imserver.websocket.processor; package com.bx.imserver.websocket.processor;
import com.bx.common.contant.RedisKey; import com.bx.common.contant.RedisKey;
import com.bx.common.enums.MessageTypeEnum;
import com.bx.common.enums.WSCmdEnum; import com.bx.common.enums.WSCmdEnum;
import com.bx.common.model.im.SendInfo;
import com.bx.common.model.im.PrivateMessageInfo; import com.bx.common.model.im.PrivateMessageInfo;
import com.bx.common.model.im.SendInfo;
import com.bx.imserver.websocket.WebsocketChannelCtxHolder; import com.bx.imserver.websocket.WebsocketChannelCtxHolder;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -29,9 +30,12 @@ public class PrivateMessageProcessor extends MessageProcessor<PrivateMessageInf
sendInfo.setCmd(WSCmdEnum.PRIVATE_MESSAGE.getCode()); sendInfo.setCmd(WSCmdEnum.PRIVATE_MESSAGE.getCode());
sendInfo.setData(data); sendInfo.setData(data);
channelCtx.channel().writeAndFlush(sendInfo); channelCtx.channel().writeAndFlush(sendInfo);
if(data.getType() != MessageTypeEnum.TIP.getCode()) {
// 已读消息推送至redis,等待更新数据库 // 已读消息推送至redis,等待更新数据库
String key = RedisKey.IM_READED_PRIVATE_MESSAGE_ID; String key = RedisKey.IM_READED_PRIVATE_MESSAGE_ID;
redisTemplate.opsForList().rightPush(key,data.getId()); redisTemplate.opsForList().rightPush(key, data.getId());
}
}else{ }else{
log.error("未找到WS连接,发送者:{},接收者:{},内容:{}",data.getSendId(),data.getRecvId(),data.getContent()); log.error("未找到WS连接,发送者:{},接收者:{},内容:{}",data.getSendId(),data.getRecvId(),data.getContent());
} }

5
im-ui/.env.development

@ -1,7 +1,8 @@
ENV = 'development' ENV = 'development'
# app名称
VUE_APP_NAME = "盒子IM"
// 接口请求地址 // 接口请求地址
VUE_APP_BASE_API = '/api' VUE_APP_BASE_API = '/api'
# ws地址
VUE_APP_WS_URL = 'ws://localhost:8878/im' VUE_APP_WS_URL = 'ws://localhost:8878/im'

4
im-ui/.env.production

@ -1,6 +1,8 @@
ENV = 'production' ENV = 'production'
# app名称
VUE_APP_NAME = "盒子IM"
# 接口地址 # 接口地址
VUE_APP_BASE_API = 'https://8.134.92.70:443/api' VUE_APP_BASE_API = 'https://8.134.92.70:443/api'
# ws地址
VUE_APP_WS_URL = 'wss://8.134.92.70:81/im' VUE_APP_WS_URL = 'wss://8.134.92.70:81/im'

6
im-ui/public/index.html

@ -5,13 +5,9 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title> <title>盒子IM</title>
</head> </head>
<body> <body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected -->
</body> </body>
</html> </html>

12
im-ui/src/api/element.js

@ -14,7 +14,17 @@ let fixLeft = (e) => {
return offset return offset
} }
let setTitleTip = (tip) => {
let title = process.env.VUE_APP_NAME;
if(tip){
title = `(${tip})${title}`;
}
document.title =title;
}
export default{ export default{
fixTop, fixTop,
fixLeft fixLeft,
setTitleTip
} }

43
im-ui/src/assets/iconfont/iconfont.css

@ -0,0 +1,43 @@
@font-face {
font-family: "iconfont"; /* Project id 3776657 */
src: url('iconfont.woff2?t=1668665799410') format('woff2'),
url('iconfont.woff?t=1668665799410') format('woff'),
url('iconfont.ttf?t=1668665799410') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-biaoqing:before {
content: "\e60c";
}
.icon-youyinpin:before {
content: "\e649";
}
.icon-audio:before {
content: "\e800";
}
.icon-group_fill:before {
content: "\e7f4";
}
.icon-yinpin:before {
content: "\e68a";
}
.icon-emoji:before {
content: "\e6f6";
}
.icon-voiceprint:before {
content: "\e953";
}

BIN
im-ui/src/assets/iconfont/iconfont.ttf

Binary file not shown.

BIN
im-ui/src/assets/iconfont/iconfont.woff

Binary file not shown.

BIN
im-ui/src/assets/iconfont/iconfont.woff2

Binary file not shown.

126
im-ui/src/components/chat/ChatBox.vue

@ -13,7 +13,9 @@
<ul> <ul>
<li v-for="(msgInfo,idx) in chat.messages" :key="idx"> <li v-for="(msgInfo,idx) in chat.messages" :key="idx">
<message-item :mine="msgInfo.sendId == mine.id" :headImage="headImage(msgInfo)" <message-item :mine="msgInfo.sendId == mine.id" :headImage="headImage(msgInfo)"
:showName="showName(msgInfo)" :msgInfo="msgInfo"> :showName="showName(msgInfo)" :msgInfo="msgInfo"
@delete="deleteMessage"
@recall="recallMessage">
</message-item> </message-item>
</li> </li>
</ul> </ul>
@ -21,7 +23,7 @@
</el-main> </el-main>
<el-footer height="200px" class="im-chat-footer"> <el-footer height="200px" class="im-chat-footer">
<div class="chat-tool-bar"> <div class="chat-tool-bar">
<div title="表情" class="el-icon-eleme" ref="emotion" @click="switchEmotionBox()"> <div title="表情" class="icon iconfont icon-biaoqing" ref="emotion" @click="switchEmotionBox()">
</div> </div>
<div title="发送图片"> <div title="发送图片">
<file-upload :action="imageAction" :maxSize="5*1024*1024" <file-upload :action="imageAction" :maxSize="5*1024*1024"
@ -95,38 +97,23 @@
} }
}, },
methods: { methods: {
handleImageSuccess(res, file) { handleImageSuccess(res, file) {
let msgInfo = { let msgInfo = JSON.parse(JSON.stringify(file.raw.msgInfo));
recvId: file.raw.targetId, msgInfo.content = JSON.stringify(res.data);
content: JSON.stringify(res.data),
type: 1
}
// id
this.setTargetId(msgInfo, this.chat.targetId);
this.$http({ this.$http({
url: this.messageAction, url: this.messageAction,
method: 'post', method: 'post',
data: msgInfo data: msgInfo
}).then((data) => { }).then((id) => {
let info = { msgInfo.loadStatus = 'ok';
type: this.chat.type, msgInfo.id = id;
targetId: file.raw.targetId, this.$store.commit("insertMessage", msgInfo);
fileId: file.raw.uid,
content: JSON.stringify(res.data),
loadStatus: "ok"
}
this.$store.commit("handleFileUpload", info);
}) })
}, },
handleImageFail(res, file) { handleImageFail(res, file) {
let info = { let msgInfo = JSON.parse(JSON.stringify(file.raw.msgInfo));
type: this.chat.type, msgInfo.loadStatus = 'fail';
targetId: file.raw.targetId, this.$store.commit("insertMessage", msgInfo);
fileId: file.raw.uid,
loadStatus: "fail"
}
this.$store.commit("handleFileUpload", info);
}, },
handleImageBefore(file) { handleImageBefore(file) {
let url = URL.createObjectURL(file); let url = URL.createObjectURL(file);
@ -135,6 +122,7 @@
thumbUrl: url thumbUrl: url
} }
let msgInfo = { let msgInfo = {
id:0,
fileId: file.uid, fileId: file.uid,
sendId: this.mine.id, sendId: this.mine.id,
content: JSON.stringify(data), content: JSON.stringify(data),
@ -149,8 +137,8 @@
this.$store.commit("insertMessage", msgInfo); this.$store.commit("insertMessage", msgInfo);
// //
this.scrollToBottom(); this.scrollToBottom();
// fileid // file
file.targetId = this.chat.targetId; file.msgInfo = msgInfo;
}, },
handleFileSuccess(res, file) { handleFileSuccess(res, file) {
let data = { let data = {
@ -158,35 +146,22 @@
size: file.size, size: file.size,
url: res.data url: res.data
} }
let msgInfo = { let msgInfo = JSON.parse(JSON.stringify(file.raw.msgInfo));
content: JSON.stringify(data), msgInfo.content = JSON.stringify(data);
type: 2
}
// id
this.setTargetId(msgInfo, this.chat.targetId);
this.$http({ this.$http({
url: this.messageAction, url: this.messageAction,
method: 'post', method: 'post',
data: msgInfo data: msgInfo
}).then(() => { }).then((id) => {
let info = { msgInfo.loadStatus = 'ok';
type: this.chat.type, msgInfo.id = id;
targetId: file.raw.targetId, this.$store.commit("insertMessage", msgInfo);
fileId: file.raw.uid,
content: JSON.stringify(data),
loadStatus: "ok"
}
this.$store.commit("handleFileUpload", info);
}) })
}, },
handleFileFail(res, file) { handleFileFail(res, file) {
let info = { let msgInfo = JSON.parse(JSON.stringify(file.raw.msgInfo));
type: this.chat.type, msgInfo.loadStatus = 'fail';
targetId: file.raw.targetId, this.$store.commit("insertMessage", msgInfo);
fileId: file.raw.uid,
loadStatus: "fail"
}
this.$store.commit("handleFileUpload", info);
}, },
handleFileBefore(file) { handleFileBefore(file) {
let url = URL.createObjectURL(file); let url = URL.createObjectURL(file);
@ -196,7 +171,7 @@
url: url url: url
} }
let msgInfo = { let msgInfo = {
fileId: file.uid, id: 0,
sendId: this.mine.id, sendId: this.mine.id,
content: JSON.stringify(data), content: JSON.stringify(data),
sendTime: new Date().getTime(), sendTime: new Date().getTime(),
@ -210,8 +185,8 @@
this.$store.commit("insertMessage", msgInfo); this.$store.commit("insertMessage", msgInfo);
// //
this.scrollToBottom(); this.scrollToBottom();
// fileid // file
file.targetId = this.chat.targetId; file.msgInfo = msgInfo;
}, },
handleCloseSide() { handleCloseSide() {
this.showSide = false; this.showSide = false;
@ -232,7 +207,6 @@
}, },
showVoiceBox() { showVoiceBox() {
this.showVoice = true; this.showVoice = true;
}, },
closeVoiceBox() { closeVoiceBox() {
this.showVoice = false; this.showVoice = false;
@ -248,8 +222,9 @@
url: this.messageAction, url: this.messageAction,
method: 'post', method: 'post',
data: msgInfo data: msgInfo
}).then(() => { }).then((id) => {
this.$message.success("发送成功"); this.$message.success("发送成功");
msgInfo.id = id;
msgInfo.sendTime = new Date().getTime(); msgInfo.sendTime = new Date().getTime();
msgInfo.sendId = this.$store.state.userStore.userInfo.id; msgInfo.sendId = this.$store.state.userStore.userInfo.id;
msgInfo.selfSend = true; msgInfo.selfSend = true;
@ -270,7 +245,6 @@
} }
}, },
sendTextMessage() { sendTextMessage() {
if (!this.sendText.trim()) { if (!this.sendText.trim()) {
this.$message.error("不能发送空白信息"); this.$message.error("不能发送空白信息");
return return
@ -285,9 +259,10 @@
url: this.messageAction, url: this.messageAction,
method: 'post', method: 'post',
data: msgInfo data: msgInfo
}).then((data) => { }).then((id) => {
this.$message.success("发送成功"); this.$message.success("发送成功");
this.sendText = ""; this.sendText = "";
msgInfo.id = id;
msgInfo.sendTime = new Date().getTime(); msgInfo.sendTime = new Date().getTime();
msgInfo.sendId = this.$store.state.userStore.userInfo.id; msgInfo.sendId = this.$store.state.userStore.userInfo.id;
msgInfo.selfSend = true; msgInfo.selfSend = true;
@ -304,6 +279,35 @@
return false; return false;
} }
}, },
deleteMessage(msgInfo){
this.$confirm( '确认删除消息?','删除消息', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$store.commit("deleteMessage",msgInfo);
});
},
recallMessage(msgInfo){
this.$confirm('确认撤回消息?','撤回消息', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
let url = `/message/${this.chat.type.toLowerCase()}/recall/${msgInfo.id}`
this.$http({
url: url,
method: 'delete'
}).then(() => {
this.$message.success("消息已撤回");
msgInfo = JSON.parse(JSON.stringify(msgInfo));
msgInfo.type = 10;
msgInfo.content = '你撤回了一条消息';
this.$store.commit("insertMessage",msgInfo);
})
});
},
loadGroup(groupId) { loadGroup(groupId) {
this.$http({ this.$http({
url: `/group/find/${groupId}`, url: `/group/find/${groupId}`,
@ -382,7 +386,7 @@
watch: { watch: {
chat: { chat: {
handler(newChat, oldChat) { handler(newChat, oldChat) {
if (newChat.targetId > 0 && (newChat.type != oldChat.type || newChat.targetId != oldChat.targetId)) { if (newChat.targetId > 0 && (!oldChat || newChat.type != oldChat.type || newChat.targetId != oldChat.targetId)) {
if (this.chat.type == "GROUP") { if (this.chat.type == "GROUP") {
this.loadGroup(this.chat.targetId); this.loadGroup(this.chat.targetId);
} else { } else {
@ -391,10 +395,12 @@
this.scrollToBottom(); this.scrollToBottom();
this.sendText = ""; this.sendText = "";
// //
this.$nextTick(() => {
this.$refs.sendBox.focus(); this.$refs.sendBox.focus();
})
} }
}, },
deep: true immediate: true
} }
} }
} }
@ -431,7 +437,7 @@
border: #dddddd solid 1px; border: #dddddd solid 1px;
.im-chat-box { .im-chat-box {
ul { >ul {
padding: 20px; padding: 20px;
li { li {

1
im-ui/src/components/chat/ChatVoice.vue

@ -96,6 +96,7 @@
}, },
handleRestartRecord() { handleRestartRecord() {
this.rc.destroy(); this.rc.destroy();
this.rc = new Recorder()
this.rc.start(); this.rc.start();
this.state = 'RUNNING'; this.state = 'RUNNING';
this.mode = 'RECORD'; this.mode = 'RECORD';

117
im-ui/src/components/chat/MessageItem.vue

@ -1,5 +1,7 @@
<template> <template>
<div class="im-msg-item" :class="{'im-chat-mine':mine}"> <div class="im-msg-item">
<div class="im-msg-tip" v-show="msgInfo.type==10">{{msgInfo.content}}</div>
<div class="im-msg-normal" v-show="msgInfo.type!=10" :class="{'im-chat-mine':mine}">
<div class="head-image"> <div class="head-image">
<head-image :url="headImage" :id="msgInfo.sendId"></head-image> <head-image :url="headImage" :id="msgInfo.sendId"></head-image>
</div> </div>
@ -8,15 +10,11 @@
<span>{{showName}}</span> <span>{{showName}}</span>
<chat-time :time="msgInfo.sendTime"></chat-time> <chat-time :time="msgInfo.sendTime"></chat-time>
</div> </div>
<div class="im-msg-bottom"> <div class="im-msg-bottom" @contextmenu.prevent="showRightMenu($event)">
<span class="im-msg-text" v-if="msgInfo.type==0" v-html="$emo.transform(msgInfo.content)"></span> <span class="im-msg-text" v-if="msgInfo.type==0" v-html="$emo.transform(msgInfo.content)"></span>
<div class="im-msg-image" v-if="msgInfo.type==1"> <div class="im-msg-image" v-if="msgInfo.type==1">
<div class="img-load-box" v-loading="loading" <div class="img-load-box" v-loading="loading" element-loading-text="上传中.." element-loading-background="rgba(0, 0, 0, 0.4)">
element-loading-text="上传中.." <img class="send-image" :src="JSON.parse(msgInfo.content).thumbUrl" @click="showFullImageBox()" />
element-loading-background="rgba(0, 0, 0, 0.4)">
<img class="send-image"
:src="JSON.parse(msgInfo.content).thumbUrl"
@click="showFullImageBox()"/>
</div> </div>
<span title="发送失败" v-show="loadFail" @click="handleSendFail" class="send-fail el-icon-warning"></span> <span title="发送失败" v-show="loadFail" @click="handleSendFail" class="send-fail el-icon-warning"></span>
</div> </div>
@ -37,22 +35,28 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<right-menu v-show="rightMenu.show" :pos="rightMenu.pos" :items="menuItems" @close="rightMenu.show=false"
@select="handleSelectMenu"></right-menu>
</div> </div>
</template> </template>
<script> <script>
import ChatTime from "./ChatTime.vue"; import ChatTime from "./ChatTime.vue";
import HeadImage from "../common/HeadImage.vue"; import HeadImage from "../common/HeadImage.vue";
import RightMenu from '../common/RightMenu.vue';
export default { export default {
name: "messageItem", name: "messageItem",
components: { components: {
ChatTime, ChatTime,
HeadImage HeadImage,
RightMenu
}, },
props: { props: {
mine:{ mine: {
type:Boolean, type: Boolean,
required: true required: true
}, },
headImage: { headImage: {
@ -68,42 +72,59 @@
required: true required: true
} }
}, },
data(){ data() {
return { return {
audioPlayState: 'STOP', audioPlayState: 'STOP',
rightMenu: {
show: false,
pos: {
x: 0,
y: 0
}
}
} }
}, },
methods:{ methods: {
handleSendFail(){ handleSendFail() {
this.$message.error("该文件已发送失败,目前不支持自动重新发送,建议手动重新发送") this.$message.error("该文件已发送失败,目前不支持自动重新发送,建议手动重新发送")
}, },
showFullImageBox(){ showFullImageBox() {
let imageUrl = JSON.parse(this.msgInfo.content).originUrl; let imageUrl = JSON.parse(this.msgInfo.content).originUrl;
if(imageUrl){ if (imageUrl) {
this.$store.commit('showFullImageBox',imageUrl); this.$store.commit('showFullImageBox', imageUrl);
} }
}, },
handlePlayVoice(){ handlePlayVoice() {
if(!this.audio){ if (!this.audio) {
this.audio = new Audio(); this.audio = new Audio();
} }
this.audio.src = JSON.parse(this.msgInfo.content).url; this.audio.src = JSON.parse(this.msgInfo.content).url;
this.audio.play(); this.audio.play();
this.handlePlayVoice = 'RUNNING'; this.handlePlayVoice = 'RUNNING';
},
showRightMenu(e) {
this.rightMenu.pos = {
x: e.x,
y: e.y
};
this.rightMenu.show = "true";
},
handleSelectMenu(item) {
this.$emit(item.key.toLowerCase(), this.msgInfo);
} }
}, },
computed:{ computed: {
loading(){ loading() {
return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "loading"; return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "loading";
}, },
loadFail(){ loadFail() {
return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "fail"; return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "fail";
}, },
data(){ data() {
return JSON.parse(this.msgInfo.content) return JSON.parse(this.msgInfo.content)
}, },
fileSize(){ fileSize() {
let size = this.data.size; let size = this.data.size;
if (size > 1024 * 1024) { if (size > 1024 * 1024) {
return Math.round(size / 1024 / 1024) + "M"; return Math.round(size / 1024 / 1024) + "M";
@ -113,6 +134,22 @@
} }
return size + "B"; return size + "B";
}, },
menuItems() {
let items = [];
items.push({
key: 'DELETE',
name: '删除',
icon: 'el-icon-delete'
});
if (this.msgInfo.selfSend && this.msgInfo.id > 0) {
items.push({
key: 'RECALL',
name: '撤回',
icon: 'el-icon-refresh-left'
});
}
return items;
}
}, },
mounted() { mounted() {
//console.log(this.msgInfo); //console.log(this.msgInfo);
@ -122,6 +159,12 @@
<style lang="scss"> <style lang="scss">
.im-msg-item { .im-msg-item {
.im-msg-tip {
line-height: 50px;
}
.im-msg-normal {
position: relative; position: relative;
font-size: 0; font-size: 0;
margin-bottom: 10px; margin-bottom: 10px;
@ -180,13 +223,13 @@
} }
} }
.im-msg-image{ .im-msg-image {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
.send-image{ .send-image {
min-width: 300px; min-width: 300px;
min-height: 200px; min-height: 200px;
max-width: 600px; max-width: 600px;
@ -195,7 +238,7 @@
cursor: pointer; cursor: pointer;
} }
.send-fail{ .send-fail {
color: #e60c0c; color: #e60c0c;
font-size: 30px; font-size: 30px;
cursor: pointer; cursor: pointer;
@ -203,14 +246,14 @@
} }
} }
.im-msg-file{ .im-msg-file {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
.im-file-box{ .im-file-box {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
@ -220,11 +263,13 @@
border-radius: 3px; border-radius: 3px;
background-color: #eeeeee; background-color: #eeeeee;
padding: 10px 15px; padding: 10px 15px;
.im-file-info{
flex:1; .im-file-info {
flex: 1;
height: 100%; height: 100%;
text-align: left; text-align: left;
font-size: 14px; font-size: 14px;
.im-file-name { .im-file-name {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@ -232,13 +277,13 @@
} }
} }
.im-file-icon{ .im-file-icon {
font-size: 50px; font-size: 50px;
color: #d42e07; color: #d42e07;
} }
} }
.send-fail{ .send-fail {
color: #e60c0c; color: #e60c0c;
font-size: 30px; font-size: 30px;
cursor: pointer; cursor: pointer;
@ -250,6 +295,11 @@
.im-msg-voice { .im-msg-voice {
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
audio {
height: 45px;
padding: 5px 0;
}
} }
} }
} }
@ -311,4 +361,5 @@
} }
} }
}
</style> </style>

63
im-ui/src/components/common/RightMenu.vue

@ -0,0 +1,63 @@
<template>
<div class="right-menu-mask" @click="close()" @contextmenu.prevent="close()">
<div class="right-menu" :style="{'left':pos.x+'px','top':pos.y+'px'}">
<el-menu background-color="#f5f5f5" text-color="#333333">
<el-menu-item v-for="(item) in items" :key="item.key" :title="item.name" @click="handleSelectMenu(item)">
<i :class="item.icon"></i>
<span>{{item.name}}</span>
</el-menu-item>
</el-menu>
</div>
</div>
</template>
<script>
export default {
name: "rightMenu",
data() {
return {}
},
props: {
pos: {
type: Object
},
items:{
type: Array
}
},
methods:{
close(){
this.$emit("close");
},
handleSelectMenu(item){
this.$emit("select",item);
}
}
}
</script>
<style lang="scss">
.right-menu-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 9999;
}
.right-menu {
position: fixed;
.el-menu-item {
height: 40px;
line-height: 40px;
i {
color: #333333;
}
}
}
</style>

1
im-ui/src/main.js

@ -3,6 +3,7 @@ import App from './App'
import router from './router' import router from './router'
import ElementUI from 'element-ui'; import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css'; import 'element-ui/lib/theme-chalk/index.css';
import './assets/iconfont/iconfont.css';
import httpRequest from './api/httpRequest'; import httpRequest from './api/httpRequest';
import * as socketApi from './api/wssocket'; import * as socketApi from './api/wssocket';
import emotion from './api/emotion.js'; import emotion from './api/emotion.js';

53
im-ui/src/store/chatStore.js

@ -7,7 +7,7 @@ export default {
mutations: { mutations: {
initChatStore(state) { initChatStore(state) {
state.activeIndex = -1; //state.activeIndex = -1;
}, },
openChat(state, chatInfo) { openChat(state, chatInfo) {
let chat = null; let chat = null;
@ -84,7 +84,7 @@ export default {
break; break;
} }
} }
console.log(msgInfo.type) // 插入新的数据
if(msgInfo.type == 1){ if(msgInfo.type == 1){
chat.lastContent = "[图片]"; chat.lastContent = "[图片]";
}else if(msgInfo.type == 2){ }else if(msgInfo.type == 2){
@ -95,20 +95,53 @@ export default {
chat.lastContent = msgInfo.content; chat.lastContent = msgInfo.content;
} }
chat.lastSendTime = msgInfo.sendTime; chat.lastSendTime = msgInfo.sendTime;
chat.messages.push(msgInfo);
// 如果不是当前会话,未读加1 // 如果不是当前会话,未读加1
chat.unreadCount++; chat.unreadCount++;
if(msgInfo.selfSend){ if(msgInfo.selfSend){
chat.unreadCount=0; chat.unreadCount=0;
} }
// 如果是已存在消息,则覆盖旧的消息数据
for (let idx in chat.messages) {
if(msgInfo.id && chat.messages[idx].id == msgInfo.id){
Object.assign(chat.messages[idx], msgInfo);
return;
}
// 正在发送中的消息可能没有id,通过发送时间判断
if(msgInfo.selfSend && chat.messages[idx].selfSend
&& chat.messages[idx].sendTime == msgInfo.sendTime){
Object.assign(chat.messages[idx], msgInfo);
return;
}
}
// 新的消息
chat.messages.push(msgInfo);
}, },
handleFileUpload(state, info) { deleteMessage(state, msgInfo){
// 文件上传后数据更新 // 获取对方id或群id
let chat = state.chats.find((c) => c.type==info.type && c.targetId === info.targetId); let type = msgInfo.groupId ? 'GROUP' : 'PRIVATE';
let msg = chat.messages.find((m) => info.fileId == m.fileId); let targetId = msgInfo.groupId ? msgInfo.groupId : msgInfo.selfSend ? msgInfo.recvId : msgInfo.sendId;
msg.loadStatus = info.loadStatus; let chat = null;
if (info.content) { for (let idx in state.chats) {
msg.content = info.content; if (state.chats[idx].type == type &&
state.chats[idx].targetId === targetId) {
chat = state.chats[idx];
break;
}
}
for (let idx in chat.messages) {
// 已经发送成功的,根据id删除
if(chat.messages[idx].id && chat.messages[idx].id == msgInfo.id){
chat.messages.splice(idx, 1);
break;
}
// 正在发送中的消息可能没有id,根据发送时间删除
if(msgInfo.selfSend && chat.messages[idx].selfSend
&&chat.messages[idx].sendTime == msgInfo.sendTime){
chat.messages.splice(idx, 1);
break;
}
} }
}, },
updateChatFromFriend(state, friend) { updateChatFromFriend(state, friend) {

2
im-ui/src/store/friendStore.js

@ -25,7 +25,7 @@ export default {
state.friends.forEach((f,index)=>{ state.friends.forEach((f,index)=>{
if(f.id==friend.id){ if(f.id==friend.id){
// 拷贝属性 // 拷贝属性
state.friends[index] = Object.assign(state.friends[index], friend); Object.assign(state.friends[index], friend);
} }
}) })
}, },

2
im-ui/src/store/groupStore.js

@ -39,7 +39,7 @@ export default {
state.groups.forEach((g,idx)=>{ state.groups.forEach((g,idx)=>{
if(g.id==group.id){ if(g.id==group.id){
// 拷贝属性 // 拷贝属性
state.groups[idx] = Object.assign(state.groups[idx], group); Object.assign(state.groups[idx], group);
} }
}) })
} }

1
im-ui/src/store/uiStore.js

@ -12,6 +12,7 @@ export default {
show: false, show: false,
url: "" url: ""
} }
}, },
mutations: { mutations: {
showUserInfoBox(state,user){ showUserInfoBox(state,user){

59
im-ui/src/view/Home.vue

@ -1,15 +1,16 @@
<template> <template>
<el-container > <el-container>
<el-aside width="80px" class="navi-bar"> <el-aside width="80px" class="navi-bar">
<div class="user-head-image"> <div class="user-head-image">
<head-image :url="$store.state.userStore.userInfo.headImageThumb" :size="60" <head-image :url="$store.state.userStore.userInfo.headImageThumb" :size="60" @click.native="showSettingDialog=true">
@click.native="showSettingDialog=true"> </head-image> </head-image>
</div> </div>
<el-menu background-color="#333333" text-color="#ddd" style="margin-top: 30px;"> <el-menu background-color="#333333" text-color="#ddd" style="margin-top: 30px;">
<el-menu-item title="聊天"> <el-menu-item title="聊天">
<router-link v-bind:to="'/home/chat'"> <router-link v-bind:to="'/home/chat'">
<span class="el-icon-chat-dot-round"></span> <span class="el-icon-chat-dot-round"></span>
<div v-show="unreadCount>0" class="unread-text">{{unreadCount}}</div>
</router-link> </router-link>
</el-menu-item> </el-menu-item>
<el-menu-item title="好友"> <el-menu-item title="好友">
@ -19,7 +20,7 @@
</el-menu-item> </el-menu-item>
<el-menu-item title="群聊"> <el-menu-item title="群聊">
<router-link v-bind:to="'/home/group'"> <router-link v-bind:to="'/home/group'">
<span class="el-icon-s-check"></span> <span class="icon iconfont icon-group_fill"></span>
</router-link> </router-link>
</el-menu-item> </el-menu-item>
@ -35,14 +36,8 @@
<router-view></router-view> <router-view></router-view>
</el-main> </el-main>
<setting :visible="showSettingDialog" @close="closeSetting()"></setting> <setting :visible="showSettingDialog" @close="closeSetting()"></setting>
<user-info v-show="uiStore.userInfo.show" <user-info v-show="uiStore.userInfo.show" :pos="uiStore.userInfo.pos" :user="uiStore.userInfo.user" @close="$store.commit('closeUserInfoBox')"></user-info>
:pos="uiStore.userInfo.pos" <full-image :visible="uiStore.fullImage.show" :url="uiStore.fullImage.url" @close="$store.commit('closeFullImageBox')"></full-image>
:user="uiStore.userInfo.user"
@close="$store.commit('closeUserInfoBox')"></user-info>
<full-image :visible="uiStore.fullImage.show"
:url="uiStore.fullImage.url"
@close="$store.commit('closeFullImageBox')"
></full-image>
</el-container> </el-container>
</template> </template>
@ -173,9 +168,26 @@
this.showSettingDialog = false; this.showSettingDialog = false;
} }
}, },
computed:{ computed: {
uiStore(){ uiStore() {
return this.$store.state.uiStore; return this.$store.state.uiStore;
},
unreadCount() {
let unreadCount = 0;
let chats = this.$store.state.chatStore.chats;
chats.forEach((chat) => {
unreadCount += chat.unreadCount
});
return unreadCount;
}
},
watch: {
unreadCount: {
handler(newCount, oldCount) {
let tip = newCount > 0 ? `${newCount}条未读` : "";
this.$elm.setTitleTip(tip);
},
immediate: true
} }
}, },
mounted() { mounted() {
@ -209,12 +221,14 @@
flex: 1; flex: 1;
.el-menu-item { .el-menu-item {
margin-top: 20px; margin: 25px 0;
.router-link-exact-active span { .router-link-exact-active span {
color: white !important; color: white !important;
} }
span { span {
font-size: 24px !important; font-size: 24px !important;
color: #aaaaaa; color: #aaaaaa;
@ -223,6 +237,21 @@
color: white !important; color: white !important;
} }
} }
.unread-text {
position: absolute;
line-height: 20px;
background-color: #f56c6c;
left: 36px;
top: 7px;
color: white;
border-radius: 30px;
padding: 0 5px;
font-size: 10px;
text-align: center;
white-space: nowrap;
border: 1px solid #f1e5e5;
}
} }
} }

5
im-ui/src/view/Login.vue

@ -1,6 +1,6 @@
<template> <template>
<div class="login-view"> <div class="login-view" >
<el-form :model="loginForm" status-icon :rules="rules" ref="loginForm" label-width="60px" class="web-ruleForm"> <el-form :model="loginForm" status-icon :rules="rules" ref="loginForm" label-width="60px" class="web-ruleForm" @keyup.enter.native="submitForm('loginForm')">
<div class="login-brand">欢迎登陆</div> <div class="login-brand">欢迎登陆</div>
<el-form-item label="用户名" prop="username"> <el-form-item label="用户名" prop="username">
<el-input type="username" v-model="loginForm.username" autocomplete="off"></el-input> <el-input type="username" v-model="loginForm.username" autocomplete="off"></el-input>
@ -16,7 +16,6 @@
<div class="register"> <div class="register">
<router-link to="/register">没有账号,前往注册</router-link> <router-link to="/register">没有账号,前往注册</router-link>
</div> </div>
</el-form> </el-form>
</div> </div>

Loading…
Cancel
Save