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. 105
      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. 9
      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. 12
      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. 128
      im-ui/src/components/chat/ChatBox.vue
  33. 1
      im-ui/src/components/chat/ChatVoice.vue
  34. 425
      im-ui/src/components/chat/MessageItem.vue
  35. 63
      im-ui/src/components/common/RightMenu.vue
  36. 5
      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;
// 在线状态过期时间 600s
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 {
UNREAD(0,"未读"),
ALREADY_READ(1,"已读");
ALREADY_READ(1,"已读"),
RECALL(2,"已撤回");
private Integer code;

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

@ -6,7 +6,8 @@ public enum MessageTypeEnum {
TEXT(0,"文字"),
FILE(1,"文件"),
IMAGE(2,"图片"),
VIDEO(3,"视频");
VIDEO(3,"视频"),
TIP(10,"系统提示");
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="解散群聊")
@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);
return ResultUtils.success();
}
@ -72,14 +72,14 @@ public class GroupController {
@ApiOperation(value = "退出群聊",notes="退出群聊")
@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);
return ResultUtils.success();
}
@ApiOperation(value = "踢出群聊",notes="将用户踢出群聊")
@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){
groupService.kickGroup(groupId,userId);
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.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@Api(tags = "群聊消息")
@ -28,11 +25,16 @@ public class GroupMessageController {
@PostMapping("/send")
@ApiOperation(value = "发送群聊消息",notes="发送群聊消息")
public Result register(@Valid @RequestBody GroupMessageVO vo){
groupMessageService.sendMessage(vo);
return ResultUtils.success();
public Result<Long> sendMessage(@Valid @RequestBody GroupMessageVO vo){
return ResultUtils.success(groupMessageService.sendMessage(vo));
}
@DeleteMapping("/recall/{id}")
@ApiOperation(value = "撤回消息",notes="撤回群聊消息")
public Result<Long> recallMessage(@NotNull(message = "消息id不能为空") @PathVariable Long id){
groupMessageService.recallMessage(id);
return ResultUtils.success();
}
@PostMapping("/pullUnreadMessage")
@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.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@Api(tags = "私聊消息")
@RestController
@ -24,12 +22,20 @@ public class PrivateMessageController {
private IPrivateMessageService privateMessageService;
@PostMapping("/send")
@ApiOperation(value = "发送消息",notes="发送单人消息")
public Result register(@Valid @RequestBody PrivateMessageVO vo){
privateMessageService.sendMessage(vo);
@ApiOperation(value = "发送消息",notes="发送私聊消息")
public Result<Long> sendMessage(@Valid @RequestBody PrivateMessageVO 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();
}
@PostMapping("/pullUnreadMessage")
@ApiOperation(value = "拉取未读消息",notes="拉取未读消息")
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")
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;
/**
* 消息类型
* 消息类型 0:文字 1:图片 2:文件 3:语音 10:撤回消息
*/
@TableField("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> {
void sendMessage(GroupMessageVO vo);
Long sendMessage(GroupMessageVO vo);
void recallMessage(Long id);
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> {
void sendMessage(PrivateMessageVO vo);
Long sendMessage(PrivateMessageVO vo);
void recallMessage(Long id);
void pullUnreadMessage();

105
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.extension.service.impl.ServiceImpl;
import com.bx.common.contant.Constant;
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.model.im.GroupMessageInfo;
import com.bx.common.util.BeanUtils;
@ -46,7 +49,7 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
* @return
*/
@Override
public void sendMessage(GroupMessageVO vo) {
public Long sendMessage(GroupMessageVO vo) {
Long userId = SessionContext.getSession().getId();
Group group = groupService.getById(vo.getGroupId());
if(group == null){
@ -65,31 +68,54 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
msg.setSendId(userId);
msg.setSendTime(new Date());
this.save(msg);
// 根据群聊每个成员所连的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<Long> list = Collections.synchronizedList(new LinkedList<Long>());
list.add(id);
serverMap.put(serverId,list);
}
}
});
// 逐个server发送
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);
// 群发
GroupMessageInfo msgInfo = BeanUtils.copyProperties(msg, GroupMessageInfo.class);
this.sendMessage(userIds,msgInfo);
log.info("发送群聊消息,发送id:{},群聊id:{},内容:{}",userId,vo.getGroupId(),vo.getContent());
return msg.getId();
}
/**
* 撤回消息
*
* @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,"消息不存在");
}
log.info("发送群聊消息,发送id:{},群聊id:{}",userId,vo.getGroupId());
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分钟,无法撤回");
}
// 判断是否在群里
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异步推送
*
@ -111,7 +137,9 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
key = RedisKey.IM_GROUP_READED_POSITION + member.getGroupId()+":"+userId;
Integer maxReadedId = (Integer)redisTemplate.opsForValue().get(key);
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){
wrapper.lambda().gt(GroupMessage::getId,maxReadedId);
}
@ -128,6 +156,35 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
}).collect(Collectors.toList());
key = RedisKey.IM_UNREAD_GROUP_MESSAGE + serverId;
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 java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@ -273,6 +274,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
groupMember.setAliasName(f.getFriendNickName());
groupMember.setRemark(group.getName());
groupMember.setHeadImage(f.getFriendHeadImage());
groupMember.setCreatedTime(new Date());
groupMember.setQuit(false);
return groupMember;
}).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.extension.service.impl.ServiceImpl;
import com.bx.common.contant.Constant;
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.model.im.PrivateMessageInfo;
import com.bx.common.util.BeanUtils;
@ -36,10 +38,10 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
* 发送私聊消息
*
* @param vo 私聊消息vo
* @return
* @return 消息id
*/
@Override
public void sendMessage(PrivateMessageVO vo) {
public Long sendMessage(PrivateMessageVO vo) {
Long userId = SessionContext.getSession().getId();
Boolean isFriends = friendService.isFriend(userId,vo.getRecvId());
if(!isFriends){
@ -60,7 +62,44 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
PrivateMessageInfo msgInfo = BeanUtils.copyProperties(msg, PrivateMessageInfo.class);
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());
String sendKey = RedisKey.IM_UNREAD_PRIVATE_MESSAGE + serverId;
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){
UpdateWrapper<PrivateMessage> updateWrapper = new UpdateWrapper<>();
updateWrapper.lambda().eq(PrivateMessage::getId,msgId)
.eq(PrivateMessage::getStatus,MessageStatusEnum.UNREAD.getCode())
.set(PrivateMessage::getStatus, MessageStatusEnum.ALREADY_READ.getCode());
privateMessageService.update(updateWrapper);
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 = "发送内容")
private String content;
@NotNull(message="发送内容不可为空")
@NotNull(message="消息类型不可为空")
@ApiModelProperty(value = "消息类型")
private Integer type;
}

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

@ -24,7 +24,7 @@ public class PrivateMessageVO {
@ApiModelProperty(value = "发送内容")
private String content;
@NotNull(message="发送内容不可为空")
@NotNull(message="消息类型不可为空")
@ApiModelProperty(value = "消息类型")
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',
`recv_id` bigint not null comment '接收用户id',
`content` text comment '发送内容',
`type` tinyint(1) NOT NULL comment '消息类型 0:文字 1:图片 2:文件',
`status` tinyint(1) NOT NULL comment '状态 0:未读 1:已读 ',
`type` tinyint(1) NOT NULL comment '消息类型 0:文字 1:图片 2:文件 3:语音 10:系统提示',
`status` tinyint(1) NOT NULL comment '状态 0:未读 1:已读 2:撤回',
`send_time` datetime DEFAULT CURRENT_TIMESTAMP comment '发送时间',
key `idx_send_recv_id` (`send_id`,`recv_id`)
)ENGINE=InnoDB CHARSET=utf8mb3 comment '私聊消息';
@ -67,7 +67,8 @@ create table `im_group_message`(
`group_id` bigint not null comment '群id',
`send_id` bigint not null comment '发送用户id',
`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 '发送时间',
key `idx_group_id` (group_id)
)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 java.util.*;
import java.util.Map;
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;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.bx.common.model.im.SendInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageDecoder;
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;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.bx.common.model.im.SendInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageEncoder;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;

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

@ -1,6 +1,7 @@
package com.bx.imserver.websocket.processor;
import com.bx.common.contant.RedisKey;
import com.bx.common.enums.MessageTypeEnum;
import com.bx.common.enums.WSCmdEnum;
import com.bx.common.model.im.GroupMessageInfo;
import com.bx.common.model.im.SendInfo;
@ -39,9 +40,11 @@ public class GroupMessageProcessor extends MessageProcessor<GroupMessageInfo> {
sendInfo.setData(data);
channelCtx.channel().writeAndFlush(sendInfo);
}
// 设置已读最大id
String key = RedisKey.IM_GROUP_READED_POSITION + data.getGroupId()+":"+recvId;
redisTemplate.opsForValue().set(key,data.getId());
if(data.getType() != MessageTypeEnum.TIP.getCode()){
// 设置已读最大id
String key = RedisKey.IM_GROUP_READED_POSITION + data.getGroupId()+":"+recvId;
redisTemplate.opsForValue().set(key,data.getId());
}
}else {
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.imserver.websocket.WebsocketServer;
import io.netty.channel.ChannelHandlerContext;
import io.netty.util.Attribute;
import io.netty.util.AttributeKey;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;

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

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

5
im-ui/.env.development

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

4
im-ui/.env.production

@ -1,6 +1,8 @@
ENV = 'production'
# app名称
VUE_APP_NAME = "盒子IM"
# 接口地址
VUE_APP_BASE_API = 'https://8.134.92.70:443/api'
# ws地址
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 name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
<title>盒子IM</title>
</head>
<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>
<!-- built files will be auto injected -->
</body>
</html>

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

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

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

@ -13,7 +13,9 @@
<ul>
<li v-for="(msgInfo,idx) in chat.messages" :key="idx">
<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>
</li>
</ul>
@ -21,7 +23,7 @@
</el-main>
<el-footer height="200px" class="im-chat-footer">
<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 title="发送图片">
<file-upload :action="imageAction" :maxSize="5*1024*1024"
@ -95,38 +97,23 @@
}
},
methods: {
handleImageSuccess(res, file) {
let msgInfo = {
recvId: file.raw.targetId,
content: JSON.stringify(res.data),
type: 1
}
// id
this.setTargetId(msgInfo, this.chat.targetId);
let msgInfo = JSON.parse(JSON.stringify(file.raw.msgInfo));
msgInfo.content = JSON.stringify(res.data);
this.$http({
url: this.messageAction,
method: 'post',
data: msgInfo
}).then((data) => {
let info = {
type: this.chat.type,
targetId: file.raw.targetId,
fileId: file.raw.uid,
content: JSON.stringify(res.data),
loadStatus: "ok"
}
this.$store.commit("handleFileUpload", info);
}).then((id) => {
msgInfo.loadStatus = 'ok';
msgInfo.id = id;
this.$store.commit("insertMessage", msgInfo);
})
},
handleImageFail(res, file) {
let info = {
type: this.chat.type,
targetId: file.raw.targetId,
fileId: file.raw.uid,
loadStatus: "fail"
}
this.$store.commit("handleFileUpload", info);
let msgInfo = JSON.parse(JSON.stringify(file.raw.msgInfo));
msgInfo.loadStatus = 'fail';
this.$store.commit("insertMessage", msgInfo);
},
handleImageBefore(file) {
let url = URL.createObjectURL(file);
@ -135,6 +122,7 @@
thumbUrl: url
}
let msgInfo = {
id:0,
fileId: file.uid,
sendId: this.mine.id,
content: JSON.stringify(data),
@ -149,8 +137,8 @@
this.$store.commit("insertMessage", msgInfo);
//
this.scrollToBottom();
// fileid
file.targetId = this.chat.targetId;
// file
file.msgInfo = msgInfo;
},
handleFileSuccess(res, file) {
let data = {
@ -158,35 +146,22 @@
size: file.size,
url: res.data
}
let msgInfo = {
content: JSON.stringify(data),
type: 2
}
// id
this.setTargetId(msgInfo, this.chat.targetId);
let msgInfo = JSON.parse(JSON.stringify(file.raw.msgInfo));
msgInfo.content = JSON.stringify(data);
this.$http({
url: this.messageAction,
method: 'post',
data: msgInfo
}).then(() => {
let info = {
type: this.chat.type,
targetId: file.raw.targetId,
fileId: file.raw.uid,
content: JSON.stringify(data),
loadStatus: "ok"
}
this.$store.commit("handleFileUpload", info);
}).then((id) => {
msgInfo.loadStatus = 'ok';
msgInfo.id = id;
this.$store.commit("insertMessage", msgInfo);
})
},
handleFileFail(res, file) {
let info = {
type: this.chat.type,
targetId: file.raw.targetId,
fileId: file.raw.uid,
loadStatus: "fail"
}
this.$store.commit("handleFileUpload", info);
let msgInfo = JSON.parse(JSON.stringify(file.raw.msgInfo));
msgInfo.loadStatus = 'fail';
this.$store.commit("insertMessage", msgInfo);
},
handleFileBefore(file) {
let url = URL.createObjectURL(file);
@ -196,7 +171,7 @@
url: url
}
let msgInfo = {
fileId: file.uid,
id: 0,
sendId: this.mine.id,
content: JSON.stringify(data),
sendTime: new Date().getTime(),
@ -210,8 +185,8 @@
this.$store.commit("insertMessage", msgInfo);
//
this.scrollToBottom();
// fileid
file.targetId = this.chat.targetId;
// file
file.msgInfo = msgInfo;
},
handleCloseSide() {
this.showSide = false;
@ -232,7 +207,6 @@
},
showVoiceBox() {
this.showVoice = true;
},
closeVoiceBox() {
this.showVoice = false;
@ -248,8 +222,9 @@
url: this.messageAction,
method: 'post',
data: msgInfo
}).then(() => {
}).then((id) => {
this.$message.success("发送成功");
msgInfo.id = id;
msgInfo.sendTime = new Date().getTime();
msgInfo.sendId = this.$store.state.userStore.userInfo.id;
msgInfo.selfSend = true;
@ -270,7 +245,6 @@
}
},
sendTextMessage() {
if (!this.sendText.trim()) {
this.$message.error("不能发送空白信息");
return
@ -285,9 +259,10 @@
url: this.messageAction,
method: 'post',
data: msgInfo
}).then((data) => {
}).then((id) => {
this.$message.success("发送成功");
this.sendText = "";
msgInfo.id = id;
msgInfo.sendTime = new Date().getTime();
msgInfo.sendId = this.$store.state.userStore.userInfo.id;
msgInfo.selfSend = true;
@ -304,6 +279,35 @@
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) {
this.$http({
url: `/group/find/${groupId}`,
@ -382,7 +386,7 @@
watch: {
chat: {
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") {
this.loadGroup(this.chat.targetId);
} else {
@ -391,10 +395,12 @@
this.scrollToBottom();
this.sendText = "";
//
this.$refs.sendBox.focus();
this.$nextTick(() => {
this.$refs.sendBox.focus();
})
}
},
deep: true
immediate: true
}
}
}
@ -431,7 +437,7 @@
border: #dddddd solid 1px;
.im-chat-box {
ul {
>ul {
padding: 20px;
li {

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

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

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

@ -1,58 +1,62 @@
<template>
<div class="im-msg-item" :class="{'im-chat-mine':mine}">
<div class="head-image">
<head-image :url="headImage" :id="msgInfo.sendId"></head-image>
</div>
<div class="im-msg-content">
<div class="im-msg-top">
<span>{{showName}}</span>
<chat-time :time="msgInfo.sendTime"></chat-time>
<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">
<head-image :url="headImage" :id="msgInfo.sendId"></head-image>
</div>
<div class="im-msg-bottom">
<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="img-load-box" v-loading="loading"
element-loading-text="上传中.."
element-loading-background="rgba(0, 0, 0, 0.4)">
<img class="send-image"
:src="JSON.parse(msgInfo.content).thumbUrl"
@click="showFullImageBox()"/>
</div>
<span title="发送失败" v-show="loadFail" @click="handleSendFail" class="send-fail el-icon-warning"></span>
<div class="im-msg-content">
<div class="im-msg-top">
<span>{{showName}}</span>
<chat-time :time="msgInfo.sendTime"></chat-time>
</div>
<div class="im-msg-file" v-if="msgInfo.type==2">
<div class="im-file-box" v-loading="loading">
<div class="im-file-info">
<el-link class="im-file-name" :underline="true" target="_blank" type="primary" :href="data.url">{{data.name}}</el-link>
<div class="im-file-size">{{fileSize}}</div>
<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>
<div class="im-msg-image" v-if="msgInfo.type==1">
<div class="img-load-box" v-loading="loading" element-loading-text="上传中.." element-loading-background="rgba(0, 0, 0, 0.4)">
<img class="send-image" :src="JSON.parse(msgInfo.content).thumbUrl" @click="showFullImageBox()" />
</div>
<div class="im-file-icon">
<span type="primary" class="el-icon-document"></span>
<span title="发送失败" v-show="loadFail" @click="handleSendFail" class="send-fail el-icon-warning"></span>
</div>
<div class="im-msg-file" v-if="msgInfo.type==2">
<div class="im-file-box" v-loading="loading">
<div class="im-file-info">
<el-link class="im-file-name" :underline="true" target="_blank" type="primary" :href="data.url">{{data.name}}</el-link>
<div class="im-file-size">{{fileSize}}</div>
</div>
<div class="im-file-icon">
<span type="primary" class="el-icon-document"></span>
</div>
</div>
<span title="发送失败" v-show="loadFail" @click="handleSendFail" class="send-fail el-icon-warning"></span>
</div>
<div class="im-msg-voice" v-if="msgInfo.type==3" @click="handlePlayVoice()">
<audio controls :src="JSON.parse(msgInfo.content).url"></audio>
</div>
<span title="发送失败" v-show="loadFail" @click="handleSendFail" class="send-fail el-icon-warning"></span>
</div>
<div class="im-msg-voice" v-if="msgInfo.type==3" @click="handlePlayVoice()">
<audio controls :src="JSON.parse(msgInfo.content).url"></audio>
</div>
</div>
</div>
<right-menu v-show="rightMenu.show" :pos="rightMenu.pos" :items="menuItems" @close="rightMenu.show=false"
@select="handleSelectMenu"></right-menu>
</div>
</template>
<script>
import ChatTime from "./ChatTime.vue";
import HeadImage from "../common/HeadImage.vue";
import RightMenu from '../common/RightMenu.vue';
export default {
name: "messageItem",
components: {
ChatTime,
HeadImage
HeadImage,
RightMenu
},
props: {
mine:{
type:Boolean,
mine: {
type: Boolean,
required: true
},
headImage: {
@ -68,42 +72,59 @@
required: true
}
},
data(){
data() {
return {
audioPlayState: 'STOP',
rightMenu: {
show: false,
pos: {
x: 0,
y: 0
}
}
}
},
methods:{
handleSendFail(){
methods: {
handleSendFail() {
this.$message.error("该文件已发送失败,目前不支持自动重新发送,建议手动重新发送")
},
showFullImageBox(){
showFullImageBox() {
let imageUrl = JSON.parse(this.msgInfo.content).originUrl;
if(imageUrl){
this.$store.commit('showFullImageBox',imageUrl);
if (imageUrl) {
this.$store.commit('showFullImageBox', imageUrl);
}
},
handlePlayVoice(){
if(!this.audio){
handlePlayVoice() {
if (!this.audio) {
this.audio = new Audio();
}
this.audio.src = JSON.parse(this.msgInfo.content).url;
this.audio.play();
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:{
loading(){
computed: {
loading() {
return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "loading";
},
loadFail(){
loadFail() {
return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "fail";
},
data(){
data() {
return JSON.parse(this.msgInfo.content)
},
fileSize(){
fileSize() {
let size = this.data.size;
if (size > 1024 * 1024) {
return Math.round(size / 1024 / 1024) + "M";
@ -113,6 +134,22 @@
}
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() {
//console.log(this.msgInfo);
@ -122,193 +159,207 @@
<style lang="scss">
.im-msg-item {
position: relative;
font-size: 0;
margin-bottom: 10px;
padding-left: 60px;
min-height: 68px;
.head-image {
position: absolute;
width: 40px;
height: 40px;
top: 0;
left: 0;
}
.im-msg-content {
display: flex;
flex-direction: column;
.im-msg-tip {
line-height: 50px;
}
.im-msg-top {
display: flex;
flex-wrap: nowrap;
color: #333;
font-size: 14px;
line-height: 20px;
.im-msg-normal {
position: relative;
font-size: 0;
margin-bottom: 10px;
padding-left: 60px;
min-height: 68px;
span {
margin-right: 12px;
}
.head-image {
position: absolute;
width: 40px;
height: 40px;
top: 0;
left: 0;
}
.im-msg-bottom {
text-align: left;
.im-msg-content {
display: flex;
flex-direction: column;
.im-msg-text {
position: relative;
line-height: 22px;
margin-top: 10px;
padding: 10px;
background-color: #eeeeee;
border-radius: 3px;
.im-msg-top {
display: flex;
flex-wrap: nowrap;
color: #333;
display: inline-block;
font-size: 14px;
line-height: 20px;
&:after {
content: "";
position: absolute;
left: -10px;
top: 13px;
width: 0;
height: 0;
border-style: solid dashed dashed;
border-color: #eeeeee transparent transparent;
overflow: hidden;
border-width: 10px;
span {
margin-right: 12px;
}
}
.im-msg-image{
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
.send-image{
min-width: 300px;
min-height: 200px;
max-width: 600px;
max-height: 400px;
border: #dddddd solid 1px;
cursor: pointer;
}
.im-msg-bottom {
text-align: left;
.send-fail{
color: #e60c0c;
font-size: 30px;
cursor: pointer;
margin: 0 20px;
}
}
.im-msg-text {
position: relative;
line-height: 22px;
margin-top: 10px;
padding: 10px;
background-color: #eeeeee;
border-radius: 3px;
color: #333;
display: inline-block;
font-size: 14px;
.im-msg-file{
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
cursor: pointer;
&:after {
content: "";
position: absolute;
left: -10px;
top: 13px;
width: 0;
height: 0;
border-style: solid dashed dashed;
border-color: #eeeeee transparent transparent;
overflow: hidden;
border-width: 10px;
}
}
.im-file-box{
.im-msg-image {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
width: 20%;
min-height: 80px;
border: #dddddd solid 1px;
border-radius: 3px;
background-color: #eeeeee;
padding: 10px 15px;
.im-file-info{
flex:1;
height: 100%;
text-align: left;
font-size: 14px;
.im-file-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 15px;
}
.send-image {
min-width: 300px;
min-height: 200px;
max-width: 600px;
max-height: 400px;
border: #dddddd solid 1px;
cursor: pointer;
}
.im-file-icon{
font-size: 50px;
color: #d42e07;
.send-fail {
color: #e60c0c;
font-size: 30px;
cursor: pointer;
margin: 0 20px;
}
}
.send-fail{
color: #e60c0c;
font-size: 30px;
.im-msg-file {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
cursor: pointer;
margin: 0 20px;
.im-file-box {
display: flex;
flex-wrap: nowrap;
align-items: center;
width: 20%;
min-height: 80px;
border: #dddddd solid 1px;
border-radius: 3px;
background-color: #eeeeee;
padding: 10px 15px;
.im-file-info {
flex: 1;
height: 100%;
text-align: left;
font-size: 14px;
.im-file-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 15px;
}
}
.im-file-icon {
font-size: 50px;
color: #d42e07;
}
}
.send-fail {
color: #e60c0c;
font-size: 30px;
cursor: pointer;
margin: 0 20px;
}
}
}
.im-msg-voice {
font-size: 14px;
cursor: pointer;
.im-msg-voice {
font-size: 14px;
cursor: pointer;
audio {
height: 45px;
padding: 5px 0;
}
}
}
}
}
&.im-chat-mine {
text-align: right;
padding-left: 0;
padding-right: 60px;
&.im-chat-mine {
text-align: right;
padding-left: 0;
padding-right: 60px;
.head-image {
left: auto;
right: 0;
}
.head-image {
left: auto;
right: 0;
}
.im-msg-content {
.im-msg-content {
.im-msg-top {
flex-direction: row-reverse;
.im-msg-top {
flex-direction: row-reverse;
span {
margin-left: 12px;
margin-right: 0;
span {
margin-left: 12px;
margin-right: 0;
}
}
}
.im-msg-bottom {
text-align: right;
.im-msg-bottom {
text-align: right;
.im-msg-text {
margin-left: 10px;
background-color: #5fb878;
color: #fff;
display: inline-block;
vertical-align: top;
font-size: 14px;
.im-msg-text {
margin-left: 10px;
background-color: #5fb878;
color: #fff;
display: inline-block;
vertical-align: top;
font-size: 14px;
&:after {
left: auto;
right: -10px;
border-top-color: #5fb878;
&:after {
left: auto;
right: -10px;
border-top-color: #5fb878;
}
}
}
.im-msg-image {
flex-direction: row-reverse;
}
.im-msg-image {
flex-direction: row-reverse;
}
.im-msg-file {
flex-direction: row-reverse;
.im-msg-file {
flex-direction: row-reverse;
}
}
}
}
.message-info {
right: 60px !important;
display: inline-block;
.message-info {
right: 60px !important;
display: inline-block;
}
}
}
}
}
</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>

5
im-ui/src/main.js

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

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

@ -7,7 +7,7 @@ export default {
mutations: {
initChatStore(state) {
state.activeIndex = -1;
//state.activeIndex = -1;
},
openChat(state, chatInfo) {
let chat = null;
@ -84,7 +84,7 @@ export default {
break;
}
}
console.log(msgInfo.type)
// 插入新的数据
if(msgInfo.type == 1){
chat.lastContent = "[图片]";
}else if(msgInfo.type == 2){
@ -95,20 +95,53 @@ export default {
chat.lastContent = msgInfo.content;
}
chat.lastSendTime = msgInfo.sendTime;
chat.messages.push(msgInfo);
// 如果不是当前会话,未读加1
chat.unreadCount++;
if(msgInfo.selfSend){
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) {
// 文件上传后数据更新
let chat = state.chats.find((c) => c.type==info.type && c.targetId === info.targetId);
let msg = chat.messages.find((m) => info.fileId == m.fileId);
msg.loadStatus = info.loadStatus;
if (info.content) {
msg.content = info.content;
deleteMessage(state, msgInfo){
// 获取对方id或群id
let type = msgInfo.groupId ? 'GROUP' : 'PRIVATE';
let targetId = msgInfo.groupId ? msgInfo.groupId : msgInfo.selfSend ? msgInfo.recvId : msgInfo.sendId;
let chat = null;
for (let idx in state.chats) {
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) {

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

@ -25,7 +25,7 @@ export default {
state.friends.forEach((f,index)=>{
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)=>{
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,
url: ""
}
},
mutations: {
showUserInfoBox(state,user){

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

@ -1,15 +1,16 @@
<template>
<el-container >
<el-container>
<el-aside width="80px" class="navi-bar">
<div class="user-head-image">
<head-image :url="$store.state.userStore.userInfo.headImageThumb" :size="60"
@click.native="showSettingDialog=true"> </head-image>
<head-image :url="$store.state.userStore.userInfo.headImageThumb" :size="60" @click.native="showSettingDialog=true">
</head-image>
</div>
<el-menu background-color="#333333" text-color="#ddd" style="margin-top: 30px;">
<el-menu-item title="聊天">
<router-link v-bind:to="'/home/chat'">
<span class="el-icon-chat-dot-round"></span>
<div v-show="unreadCount>0" class="unread-text">{{unreadCount}}</div>
</router-link>
</el-menu-item>
<el-menu-item title="好友">
@ -19,7 +20,7 @@
</el-menu-item>
<el-menu-item title="群聊">
<router-link v-bind:to="'/home/group'">
<span class="el-icon-s-check"></span>
<span class="icon iconfont icon-group_fill"></span>
</router-link>
</el-menu-item>
@ -35,14 +36,8 @@
<router-view></router-view>
</el-main>
<setting :visible="showSettingDialog" @close="closeSetting()"></setting>
<user-info v-show="uiStore.userInfo.show"
:pos="uiStore.userInfo.pos"
: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>
<user-info v-show="uiStore.userInfo.show" :pos="uiStore.userInfo.pos" :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>
</template>
@ -173,9 +168,26 @@
this.showSettingDialog = false;
}
},
computed:{
uiStore(){
computed: {
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() {
@ -209,12 +221,14 @@
flex: 1;
.el-menu-item {
margin-top: 20px;
margin: 25px 0;
.router-link-exact-active span {
color: white !important;
}
span {
font-size: 24px !important;
color: #aaaaaa;
@ -223,6 +237,21 @@
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>
<div class="login-view">
<el-form :model="loginForm" status-icon :rules="rules" ref="loginForm" label-width="60px" class="web-ruleForm">
<div class="login-view" >
<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>
<el-form-item label="用户名" prop="username">
<el-input type="username" v-model="loginForm.username" autocomplete="off"></el-input>
@ -16,7 +16,6 @@
<div class="register">
<router-link to="/register">没有账号,前往注册</router-link>
</div>
</el-form>
</div>

Loading…
Cancel
Save