Browse Source

!157 细节优化

Merge pull request !157 from blue/v_3.0.0
master
blue 8 months ago
committed by Gitee
parent
commit
ce7870f4fe
No known key found for this signature in database GPG Key ID: 173E9B9CA92EEF8F
  1. 4
      db/im-platform.sql
  2. 11
      im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java
  3. 3
      im-platform/src/main/java/com/bx/implatform/controller/FriendController.java
  4. 3
      im-platform/src/main/java/com/bx/implatform/dto/GroupMessageDTO.java
  5. 4
      im-platform/src/main/java/com/bx/implatform/dto/PrivateMessageDTO.java
  6. 6
      im-platform/src/main/java/com/bx/implatform/entity/GroupMessage.java
  7. 6
      im-platform/src/main/java/com/bx/implatform/entity/PrivateMessage.java
  8. 8
      im-platform/src/main/java/com/bx/implatform/enums/MessageStatus.java
  9. 4
      im-platform/src/main/java/com/bx/implatform/listener/PrivateMessageListener.java
  10. 3
      im-platform/src/main/java/com/bx/implatform/service/FriendService.java
  11. 18
      im-platform/src/main/java/com/bx/implatform/service/impl/FileServiceImpl.java
  12. 9
      im-platform/src/main/java/com/bx/implatform/service/impl/FriendServiceImpl.java
  13. 5
      im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java
  14. 4
      im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java
  15. 6
      im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java
  16. 6
      im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcPrivateServiceImpl.java
  17. 2
      im-platform/src/main/java/com/bx/implatform/task/consumer/GroupBannedConsumerTask.java
  18. 2
      im-platform/src/main/java/com/bx/implatform/task/consumer/GroupUnbanConsumerTask.java
  19. 3
      im-platform/src/main/java/com/bx/implatform/vo/GroupMessageVO.java
  20. 3
      im-platform/src/main/java/com/bx/implatform/vo/PrivateMessageVO.java
  21. 6
      im-platform/src/main/java/com/bx/implatform/vo/UploadImageVO.java
  22. 21
      im-uniapp/App.vue
  23. 10
      im-uniapp/common/enums.js
  24. 4
      im-uniapp/common/wssocket.js
  25. 14
      im-uniapp/components/chat-item/chat-item.vue
  26. 331
      im-uniapp/components/chat-message-item/chat-message-item.vue
  27. 17
      im-uniapp/components/head-image/head-image.vue
  28. 13
      im-uniapp/components/loading/loading.vue
  29. 16
      im-uniapp/components/long-press-menu/long-press-menu.vue
  30. 140
      im-uniapp/pages/chat/chat-box.vue
  31. 2
      im-uniapp/pages/chat/chat.vue
  32. 63
      im-uniapp/store/chatStore.js
  33. 4
      im-uniapp/store/configStore.js
  34. 10
      im-web/src/api/enums.js
  35. 10
      im-web/src/api/wssocket.js
  36. BIN
      im-web/src/assets/audio/tip.mp3
  37. BIN
      im-web/src/assets/audio/tip.wav
  38. 149
      im-web/src/components/chat/ChatBox.vue
  39. 12
      im-web/src/components/chat/ChatItem.vue
  40. 149
      im-web/src/components/chat/ChatMessageItem.vue
  41. 24
      im-web/src/components/common/HeadImage.vue
  42. 100
      im-web/src/store/chatStore.js
  43. 4
      im-web/src/store/configStore.js
  44. 7
      im-web/src/view/Home.vue
  45. 16
      im-web/src/view/Login.vue

4
db/im-platform.sql

@ -31,6 +31,7 @@ create table `im_friend`(
create table `im_private_message`(
`id` bigint not null auto_increment primary key comment 'id',
`tmp_id` varchar(32) comment '临时id,由前端生成',
`send_id` bigint not null comment '发送用户id',
`recv_id` bigint not null comment '接收用户id',
`content` text comment '发送内容',
@ -73,6 +74,7 @@ create table `im_group_member`(
create table `im_group_message`(
`id` bigint not null auto_increment primary key comment 'id',
`tmp_id` varchar(32) comment '临时id,由前端生成',
`group_id` bigint not null comment '群id',
`send_id` bigint not null comment '发送用户id',
`send_nick_name` varchar(255) DEFAULT '' comment '发送用户昵称',
@ -106,5 +108,5 @@ CREATE TABLE `im_file_info` (
`upload_time` datetime DEFAULT CURRENT_TIMESTAMP comment '上传时间',
`is_permanent` tinyint DEFAULT 0 comment '是否永久文件',
`md5` VARCHAR(64) NOT NULL comment '文件md5',
UNIQUE KEY `idx_md5` (md5)
KEY `idx_md5` (md5)
) ENGINE = InnoDB CHARSET = utf8mb4 comment '文件';

11
im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java

@ -53,6 +53,17 @@ public final class RedisKey {
*/
public static final String IM_REPEAT_SUBMIT = "im:repeat:submit";
/**
* 分布式锁-添加好友
*/
public static final String IM_LOCK_FRIEND_ADD = "im:lock:friend:add";
/**
* 分布式锁-进入群聊
*/
public static final String IM_LOCK_GROUP_ENTER = "im:lock:group:enter";
/**
* 分布式锁-清理过期文件
*/

3
im-platform/src/main/java/com/bx/implatform/controller/FriendController.java

@ -5,6 +5,7 @@ import com.bx.implatform.dto.FriendDndDTO;
import com.bx.implatform.result.Result;
import com.bx.implatform.result.ResultUtils;
import com.bx.implatform.service.FriendService;
import com.bx.implatform.session.SessionContext;
import com.bx.implatform.vo.FriendVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -34,7 +35,7 @@ public class FriendController {
@PostMapping("/add")
@Operation(summary = "添加好友", description = "双方建立好友关系")
public Result addFriend(@NotNull(message = "好友id不可为空") @RequestParam Long friendId) {
friendService.addFriend(friendId);
friendService.addFriend(SessionContext.getSession().getUserId(),friendId);
return ResultUtils.success();
}

3
im-platform/src/main/java/com/bx/implatform/dto/GroupMessageDTO.java

@ -13,6 +13,9 @@ import java.util.List;
@Schema(description = "群聊消息DTO")
public class GroupMessageDTO {
@Schema(description = "临时id")
private String tmpId;
@NotNull(message = "群聊id不可为空")
@Schema(description = "群聊id")
private Long groupId;

4
im-platform/src/main/java/com/bx/implatform/dto/PrivateMessageDTO.java

@ -10,11 +10,13 @@ import org.hibernate.validator.constraints.Length;
@Schema(description = "私聊消息DTO")
public class PrivateMessageDTO {
@Schema(description = "临时id")
private String tmpId;
@NotNull(message = "接收用户id不可为空")
@Schema(description = "接收用户id")
private Long recvId;
@Length(max = 1024, message = "内容长度不得大于1024")
@NotEmpty(message = "发送内容不可为空")
@Schema(description = "发送内容")

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

@ -25,6 +25,12 @@ public class GroupMessage {
@TableId
private Long id;
/**
* 临时id,由前端生成
* 作用:如果用户正在发送消息时掉线了可以通过此字段获取该消息的实际发送状态
*/
private String tmpId;
/**
* 群id
*/

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

@ -22,6 +22,12 @@ public class PrivateMessage {
*/
private Long id;
/**
* 临时id,由前端生成
* 作用:如果用户正在发送消息时掉线了可以通过此字段获取该消息的实际发送状态
*/
private String tmpId;
/**
* 发送用户id
*/

8
im-platform/src/main/java/com/bx/implatform/enums/MessageStatus.java

@ -6,13 +6,13 @@ import lombok.AllArgsConstructor;
public enum MessageStatus {
/**
* 文件
* 等待推送(未送达)
*/
UNSEND(0, "未送达"),
PENDING(0, "等待推送"),
/**
* 文件
* 已送达(未读)
*/
SENDED(1, "送达"),
DELIVERED(1, "送达"),
/**
* 撤回
*/

4
im-platform/src/main/java/com/bx/implatform/listener/PrivateMessageListener.java

@ -41,8 +41,8 @@ public class PrivateMessageListener implements MessageListener<PrivateMessageVO>
if(CollUtil.isNotEmpty(messageIds)){
UpdateWrapper<PrivateMessage> updateWrapper = new UpdateWrapper<>();
updateWrapper.lambda().in(PrivateMessage::getId, messageIds)
.eq(PrivateMessage::getStatus, MessageStatus.UNSEND.code())
.set(PrivateMessage::getStatus, MessageStatus.SENDED.code());
.eq(PrivateMessage::getStatus, MessageStatus.PENDING.code())
.set(PrivateMessage::getStatus, MessageStatus.DELIVERED.code());
privateMessageService.update(updateWrapper);
}
}

3
im-platform/src/main/java/com/bx/implatform/service/FriendService.java

@ -43,9 +43,10 @@ public interface FriendService extends IService<Friend> {
/**
* 添加好友互相建立好友关系
*
* @param userId 用户id
* @param friendId 好友的用户id
*/
void addFriend(Long friendId);
void addFriend(Long userId,Long friendId);
/**
* 删除好友双方都会解除好友关系

18
im-platform/src/main/java/com/bx/implatform/service/impl/FileServiceImpl.java

@ -25,6 +25,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.DigestUtils;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Date;
import java.util.Objects;
@ -63,7 +66,7 @@ public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> imple
}
// 如果文件已存在,直接复用
String md5 = DigestUtils.md5DigestAsHex(file.getInputStream());
FileInfo fileInfo = findByMd5(md5);
FileInfo fileInfo = findByMd5(md5, FileType.FILE.code());
if (!Objects.isNull(fileInfo)) {
// 更新上传时间
fileInfo.setUploadTime(new Date());
@ -101,9 +104,15 @@ public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> imple
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片格式不合法");
}
UploadImageVO vo = new UploadImageVO();
// 获取图片长度和宽度
BufferedImage bufferedImage = ImageIO.read(file.getInputStream());
if(!Objects.isNull(bufferedImage)){
vo.setWidth(bufferedImage.getWidth());
vo.setHeight(bufferedImage.getHeight());
}
// 如果文件已存在,直接复用
String md5 = DigestUtils.md5DigestAsHex(file.getInputStream());
FileInfo fileInfo = findByMd5(md5);
FileInfo fileInfo = findByMd5(md5, FileType.IMAGE.code());
if (!Objects.isNull(fileInfo)) {
// 更新上传时间和持久化标记
fileInfo.setIsPermanent(isPermanent || fileInfo.getIsPermanent());
@ -131,7 +140,7 @@ public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> imple
vo.setThumbUrl(generUrl(FileType.IMAGE, thumbFileName));
// 保存文件信息
saveImageFileInfo(file, md5, vo.getOriginUrl(), vo.getThumbUrl(), isPermanent);
}else{
} else {
// 小于50k,用原图充当缩略图
vo.setThumbUrl(generUrl(FileType.IMAGE, fileName));
// 保存文件信息,由于缩略图不允许删除,此时原图也不允许删除
@ -157,9 +166,10 @@ public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> imple
};
}
private FileInfo findByMd5(String md5) {
private FileInfo findByMd5(String md5, Integer fileType) {
LambdaQueryWrapper<FileInfo> wrapper = Wrappers.lambdaQuery();
wrapper.eq(FileInfo::getMd5, md5);
wrapper.eq(FileInfo::getFileType, fileType);
return getOne(wrapper);
}

9
im-platform/src/main/java/com/bx/implatform/service/impl/FriendServiceImpl.java

@ -10,6 +10,7 @@ import com.bx.imclient.IMClient;
import com.bx.imcommon.enums.IMTerminalType;
import com.bx.imcommon.model.IMPrivateMessage;
import com.bx.imcommon.model.IMUserInfo;
import com.bx.implatform.annotation.RedisLock;
import com.bx.implatform.contant.RedisKey;
import com.bx.implatform.dto.FriendDndDTO;
import com.bx.implatform.entity.Friend;
@ -75,10 +76,10 @@ public class FriendServiceImpl extends ServiceImpl<FriendMapper, Friend> impleme
return friends.stream().map(this::conver).collect(Collectors.toList());
}
@RedisLock(prefixKey = RedisKey.IM_LOCK_FRIEND_ADD, key = "#userId+':'+#friendId")
@Transactional(rollbackFor = Exception.class)
@Override
public void addFriend(Long friendId) {
long userId = SessionContext.getSession().getUserId();
public void addFriend(Long userId, Long friendId) {
if (friendId.equals(userId)) {
throw new GlobalException("不允许添加自己为好友");
}
@ -233,7 +234,7 @@ public class FriendServiceImpl extends ServiceImpl<FriendMapper, Friend> impleme
msg.setRecvId(friendId);
msg.setContent("你们已成为好友,现在可以开始聊天了");
msg.setSendTime(new Date());
msg.setStatus(MessageStatus.UNSEND.code());
msg.setStatus(MessageStatus.PENDING.code());
msg.setType(MessageType.TIP_TEXT.code());
privateMessageMapper.insert(msg);
// 推给对方
@ -257,7 +258,7 @@ public class FriendServiceImpl extends ServiceImpl<FriendMapper, Friend> impleme
msg.setRecvId(friendId);
msg.setSendTime(new Date());
msg.setType(MessageType.TIP_TEXT.code());
msg.setStatus(MessageStatus.UNSEND.code());
msg.setStatus(MessageStatus.PENDING.code());
msg.setContent("你们的好友关系已被解除");
privateMessageMapper.insert(msg);
// 推送

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

@ -42,7 +42,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
@Slf4j
@ -123,7 +122,7 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
this.updateById(msg);
// 生成一条撤回消息
GroupMessage recallMsg = new GroupMessage();
recallMsg.setStatus(MessageStatus.UNSEND.code());
recallMsg.setStatus(MessageStatus.PENDING.code());
recallMsg.setType(MessageType.RECALL.code());
recallMsg.setGroupId(msg.getGroupId());
recallMsg.setSendId(session.getUserId());
@ -228,7 +227,7 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
List<String> atIds = CommaTextUtils.asList(m.getAtUserIds());
vo.setAtUserIds(atIds.stream().map(Long::parseLong).collect(Collectors.toList()));
// 填充状态
vo.setStatus(readedMaxId >= m.getId() ? MessageStatus.READED.code() : MessageStatus.UNSEND.code());
vo.setStatus(readedMaxId >= m.getId() ? MessageStatus.READED.code() : MessageStatus.PENDING.code());
// 针对回执消息填充已读人数
if (m.getReceipt()) {
if (Objects.isNull(maxIdMap)) {

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

@ -10,6 +10,7 @@ import com.bx.imclient.IMClient;
import com.bx.imcommon.model.IMGroupMessage;
import com.bx.imcommon.model.IMUserInfo;
import com.bx.imcommon.util.CommaTextUtils;
import com.bx.implatform.annotation.RedisLock;
import com.bx.implatform.contant.Constant;
import com.bx.implatform.contant.RedisKey;
import com.bx.implatform.dto.GroupDndDTO;
@ -251,6 +252,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
}).collect(Collectors.toList());
}
@RedisLock(prefixKey = RedisKey.IM_LOCK_GROUP_ENTER,key = "#dto.getGroupId()")
@Override
public void invite(GroupInviteDTO dto) {
UserSession session = SessionContext.getSession();
@ -330,7 +332,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
GroupMessage message = new GroupMessage();
message.setContent(content);
message.setType(MessageType.TIP_TEXT.code());
message.setStatus(MessageStatus.UNSEND.code());
message.setStatus(MessageStatus.PENDING.code());
message.setSendTime(new Date());
message.setSendNickName(session.getNickName());
message.setGroupId(groupId);

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

@ -57,7 +57,7 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
// 保存消息
PrivateMessage msg = BeanUtils.copyProperties(dto, PrivateMessage.class);
msg.setSendId(session.getUserId());
msg.setStatus(MessageStatus.UNSEND.code());
msg.setStatus(MessageStatus.PENDING.code());
msg.setSendTime(new Date());
// 过滤内容中的敏感词
if (MessageType.TEXT.code().equals(dto.getType())) {
@ -97,7 +97,7 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
// 生成一条撤回消息
PrivateMessage recallMsg = new PrivateMessage();
recallMsg.setSendId(session.getUserId());
recallMsg.setStatus(MessageStatus.UNSEND.code());
recallMsg.setStatus(MessageStatus.PENDING.code());
recallMsg.setSendTime(new Date());
recallMsg.setRecvId(msg.getRecvId());
recallMsg.setType(MessageType.RECALL.code());
@ -205,7 +205,7 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
// 修改消息状态为已读
LambdaUpdateWrapper<PrivateMessage> updateWrapper = Wrappers.lambdaUpdate();
updateWrapper.eq(PrivateMessage::getSendId, friendId).eq(PrivateMessage::getRecvId, session.getUserId())
.eq(PrivateMessage::getStatus, MessageStatus.SENDED.code())
.eq(PrivateMessage::getStatus, MessageStatus.DELIVERED.code())
.set(PrivateMessage::getStatus, MessageStatus.READED.code());
this.update(updateWrapper);
log.info("消息已读,接收方id:{},发送方id:{}", session.getUserId(), friendId);

6
im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcPrivateServiceImpl.java

@ -51,12 +51,12 @@ public class WebrtcPrivateServiceImpl implements WebrtcPrivateService {
webrtcSession.setMode(mode);
// 校验
if (!imClient.isOnline(uid)) {
this.sendActMessage(webrtcSession, MessageStatus.UNSEND, "未接通");
this.sendActMessage(webrtcSession, MessageStatus.PENDING, "未接通");
log.info("对方不在线,uid:{}", uid);
throw new GlobalException("对方目前不在线");
}
if (userStateUtils.isBusy(uid)) {
this.sendActMessage(webrtcSession, MessageStatus.UNSEND, "未接通");
this.sendActMessage(webrtcSession, MessageStatus.PENDING, "未接通");
log.info("对方正忙,uid:{}", uid);
throw new GlobalException("对方正忙");
}
@ -171,7 +171,7 @@ public class WebrtcPrivateServiceImpl implements WebrtcPrivateService {
// 通知对方取消会话
imClient.sendPrivateMessage(sendMessage);
// 生成通话消息
sendActMessage(webrtcSession, MessageStatus.UNSEND, "已取消");
sendActMessage(webrtcSession, MessageStatus.PENDING, "已取消");
}
@Override

2
im-platform/src/main/java/com/bx/implatform/task/consumer/GroupBannedConsumerTask.java

@ -52,7 +52,7 @@ public class GroupBannedConsumerTask extends RedisMQConsumer<GroupBanDTO> {
msg.setContent(tip);
msg.setSendId(Constant.SYS_USER_ID);
msg.setSendTime(new Date());
msg.setStatus(MessageStatus.UNSEND.code());
msg.setStatus(MessageStatus.PENDING.code());
msg.setSendNickName("系统管理员");
msg.setType(MessageType.TIP_TEXT.code());
groupMessageService.save(msg);

2
im-platform/src/main/java/com/bx/implatform/task/consumer/GroupUnbanConsumerTask.java

@ -51,7 +51,7 @@ public class GroupUnbanConsumerTask extends RedisMQConsumer<GroupUnbanDTO> {
msg.setContent("已解除封禁");
msg.setSendId(Constant.SYS_USER_ID);
msg.setSendTime(new Date());
msg.setStatus(MessageStatus.UNSEND.code());
msg.setStatus(MessageStatus.PENDING.code());
msg.setSendNickName("系统管理员");
msg.setType(MessageType.TIP_TEXT.code());
groupMessageService.save(msg);

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

@ -14,6 +14,9 @@ public class GroupMessageVO {
@Schema(description = "消息id")
private Long id;
@Schema(description = "临时id")
private String tmpId;
@Schema(description = "群聊id")
private Long groupId;

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

@ -14,6 +14,9 @@ public class PrivateMessageVO {
@Schema(description = " 消息id")
private Long id;
@Schema(description = "临时id")
private String tmpId;
@Schema(description = " 发送者id")
private Long sendId;

6
im-platform/src/main/java/com/bx/implatform/vo/UploadImageVO.java

@ -12,4 +12,10 @@ public class UploadImageVO {
@Schema(description = "缩略图")
private String thumbUrl;
@Schema(description = "图片宽度")
private int width;
@Schema(description = "图片高度")
private int height;
}

21
im-uniapp/App.vue

@ -9,7 +9,6 @@ import UNI_APP from '@/.env.js'
export default {
data() {
return {
isInit: false, //
isExit: false, // 退
audioTip: null,
reconnecting: false //
@ -23,8 +22,7 @@ export default {
this.loadStore().then(() => {
// websocket
this.initWebSocket();
this.isInit = true;
}).catch((e) => {
}).catch(e => {
console.log(e);
this.exit();
})
@ -40,7 +38,7 @@ export default {
// 线
this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId);
this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId);
this.configStore.setAppInit(true);
}
});
wsApi.onMessage((cmd, msgInfo) => {
@ -66,7 +64,7 @@ export default {
console.log("ws断开", res);
//
this.reconnectWs();
this.configStore.setAppInit(false);
})
},
loadStore() {
@ -92,7 +90,11 @@ export default {
url: "/message/private/pullOfflineMessage?minId=" + minId,
method: 'GET'
}).catch(() => {
this.chatStore.setLoadingPrivateMsg(false)
uni.showToast({
title: "消息拉取失败,请重新登陆",
icon: 'none'
})
this.exit()
})
},
pullGroupOfflineMessage(minId) {
@ -101,7 +103,11 @@ export default {
url: "/message/group/pullOfflineMessage?minId=" + minId,
method: 'GET'
}).catch(() => {
this.chatStore.setLoadingGroupMsg(false)
uni.showToast({
title: "消息拉取失败,请重新登陆",
icon: 'none'
})
this.exit()
})
},
handlePrivateMessage(msg) {
@ -421,6 +427,7 @@ export default {
// 线
this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId);
this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId);
this.configStore.setAppInit(true);
}).catch((e) => {
console.log(e);
this.exit();

10
im-uniapp/common/enums.js

@ -54,10 +54,12 @@ const TERMINAL_TYPE = {
}
const MESSAGE_STATUS = {
UNSEND: 0,
SENDED: 1,
RECALL: 2,
READED: 3
FAILED: -2, // 发送失败
SENDING: -1, // 发送中(消息没到服务器)
PENDING: 0, // 未送达(消息已到服务器,但对方没收到)
DELIVERED: 1, // 已送达(对方已收到,但是未读消息)
RECALL: 2, // 已撤回
READED: 3, // 消息已读
}
export {

4
im-uniapp/common/wssocket.js

@ -65,9 +65,9 @@ let connect = (wsurl, token) => {
})
socketTask.onError((e) => {
console.log(e)
console.log("ws错误:",e)
close();
isConnect = false;
// APP 应用切出超过一定时间(约1分钟)会触发报错,此处回调给应用进行重连
closeCallBack && closeCallBack({ code: 1006 });
})
}

14
im-uniapp/components/chat-item/chat-item.vue

@ -3,7 +3,7 @@
<!--rich-text中的表情包会屏蔽事件所以这里用一个遮罩层捕获点击事件 -->
<view class="mask" @tap="showChatBox()"></view>
<view class="left">
<head-image :url="chat.headImage" :name="chat.showName"></head-image>
<head-image :url="chat.headImage" :name="chat.showName" :online="online"></head-image>
</view>
<view class="chat-right">
<view class="chat-name">
@ -45,7 +45,7 @@ export default {
methods: {
showChatBox() {
//
if (!getApp().$vm.isInit || this.chatStore.isLoading()) {
if (!this.configStore.appInit || this.chatStore.isLoading()) {
uni.showToast({
title: "正在初始化页面,请稍后...",
icon: 'none'
@ -79,6 +79,9 @@ export default {
return "";
},
isTextMessage() {
if (this.chat.messages.length == 0) {
return false;
}
let idx = this.chat.messages.length - 1;
let messageType = this.chat.messages[idx].type;
return messageType == this.$enums.MESSAGE_TYPE.TEXT;
@ -86,6 +89,13 @@ export default {
nodesText() {
let text = this.$str.html2Escape(this.chat.lastContent);
return this.$emo.transform(text, 'emoji-small')
},
online() {
if (this.chat.type == 'PRIVATE') {
let friend = this.friendStore.findFriend(this.chat.targetId);
return friend && friend.online;
}
return false;
}
}
}

331
im-uniapp/components/chat-message-item/chat-message-item.vue

@ -14,51 +14,54 @@
<text>{{ showName }}</text>
</view>
<view class="bottom">
<view v-if="msgInfo.type == $enums.MESSAGE_TYPE.TEXT">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<!-- up-parse支持点击a标签,但是不支持显示emo表情也不支持换行 -->
<up-parse v-if="$url.containUrl(msgInfo.content)&&!$emo.containEmoji(msgInfo.content)"
class="message-text" :showImgMenu="false" :content="nodesText"></up-parse>
<!-- rich-text支持显示emo表情以及消息换行但是不支持点击a标签 -->
<rich-text v-else class="message-text" :nodes="nodesText"></rich-text>
</long-press-menu>
</view>
<view class="message-image" v-if="msgInfo.type == $enums.MESSAGE_TYPE.IMAGE">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<view class="image-box">
<image class="send-image" mode="heightFix" :src="JSON.parse(msgInfo.content).thumbUrl"
lazy-load="true" @click.stop="onShowFullImage()">
</image>
<loading v-if="loading"></loading>
</view>
</long-press-menu>
<text title="发送失败" v-if="loadFail" @click="onSendFail"
class="send-fail iconfont icon-warning-circle-fill"></text>
</view>
<view class="message-file" v-if="msgInfo.type == $enums.MESSAGE_TYPE.FILE">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<view class="file-box">
<view class="file-info">
<uni-link class="file-name" :text="data.name" showUnderLine="true" color="#007BFF"
:href="data.url"></uni-link>
<view class="file-size">{{ fileSize }}</view>
<view class="message-content-wrapper">
<view v-if="msgInfo.type == $enums.MESSAGE_TYPE.TEXT">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<!-- up-parse支持点击a标签,但是不支持显示emo表情也不支持换行 -->
<up-parse v-if="$url.containUrl(msgInfo.content)&&!$emo.containEmoji(msgInfo.content)"
class="message-text" :showImgMenu="false" :content="nodesText"></up-parse>
<!-- rich-text支持显示emo表情以及消息换行但是不支持点击a标签 -->
<rich-text v-else class="message-text" :nodes="nodesText"></rich-text>
</long-press-menu>
</view>
<view class="message-image" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.IMAGE">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<view class="image-box">
<image class="send-image" :style="imageStyle" mode="aspectFill"
:src="contentData.thumbUrl" lazy-load="true" @click.stop="onShowFullImage()">
</image>
<loading v-if="sending"></loading>
</view>
<view class="file-icon iconfont icon-file"></view>
<loading v-if="loading"></loading>
</long-press-menu>
</view>
<view class="message-file" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.FILE">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<view class="file-box">
<view class="file-info">
<uni-link class="file-name" :text="contentData.name" showUnderLine="true"
color="#007BFF" :href="contentData.url"></uni-link>
<view class="file-size">{{ fileSize }}</view>
</view>
<view class="file-icon iconfont icon-file"></view>
<loading v-if="sending"></loading>
</view>
</long-press-menu>
</view>
<long-press-menu v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.AUDIO" :items="menuItems"
@select="onSelectMenu">
<view class="message-audio message-text" @click="onPlayAudio()">
<text class="iconfont icon-voice-play"></text>
<text class="chat-audio-text">{{ contentData.duration + '"' }}</text>
<text v-if="audioPlayState == 'PAUSE'" class="iconfont icon-play"></text>
<text v-if="audioPlayState == 'PLAYING'" class="iconfont icon-pause"></text>
</view>
</long-press-menu>
<text title="发送失败" v-if="loadFail" @click="onSendFail"
class="send-fail iconfont icon-warning-circle-fill"></text>
</view>
<long-press-menu v-if="msgInfo.type == $enums.MESSAGE_TYPE.AUDIO" :items="menuItems"
@select="onSelectMenu">
<view class="message-audio message-text" @click="onPlayAudio()">
<text class="iconfont icon-voice-play"></text>
<text class="chat-audio-text">{{ JSON.parse(msgInfo.content).duration + '"' }}</text>
<text v-if="audioPlayState == 'PAUSE'" class="iconfont icon-play"></text>
<text v-if="audioPlayState == 'PLAYING'" class="iconfont icon-pause"></text>
<view v-if="sending&&isTextMessage" class="sending">
<loading :size="40" icon-color="#656adf" :mask="false"></loading>
</view>
</long-press-menu>
<view v-else-if="sendFail" @click="onSendFail"
class="send-fail iconfont icon-warning-circle-fill"></view>
</view>
<long-press-menu v-if="isAction" :items="menuItems" @select="onSelectMenu">
<view class="chat-realtime message-text" @click="$emit('call')">
<text v-if="msgInfo.type == $enums.MESSAGE_TYPE.ACT_RT_VOICE"
@ -68,11 +71,9 @@
<text>{{ msgInfo.content }}</text>
</view>
</long-press-menu>
<view class="message-status" v-if="!isAction">
<text class="chat-readed" v-if="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status == $enums.MESSAGE_STATUS.READED">已读</text>
<text class="chat-unread" v-if="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status != $enums.MESSAGE_STATUS.READED">未读</text>
<view class="message-status" v-if="!isAction && msgInfo.selfSend && !msgInfo.groupId">
<text class="chat-readed" v-if="msgInfo.status == $enums.MESSAGE_STATUS.READED">已读</text>
<text class="chat-unread" v-else>未读</text>
</view>
<view class="chat-receipt" v-if="msgInfo.receipt" @click="onShowReadedBox">
<text v-if="msgInfo.receiptOk" class="tool-icon iconfont icon-ok"></text>
@ -117,16 +118,13 @@ export default {
},
methods: {
onSendFail() {
uni.showToast({
title: "该文件已发送失败,目前不支持自动重新发送,建议手动重新发送",
icon: "none"
})
this.$emit("resend", this.msgInfo);
},
onPlayAudio() {
//
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext();
let url = JSON.parse(this.msgInfo.content).url;
let url = this.contentData.url;
this.innerAudioContext.src = url;
this.innerAudioContext.onEnded((e) => {
console.log('停止')
@ -157,7 +155,7 @@ export default {
this.menu.show = false;
},
onShowFullImage() {
let imageUrl = JSON.parse(this.msgInfo.content).originUrl;
let imageUrl = this.contentData.originUrl;
uni.previewImage({
urls: [imageUrl]
})
@ -177,17 +175,17 @@ export default {
}
},
computed: {
loading() {
return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "loading";
sending() {
return this.msgInfo.status == this.$enums.MESSAGE_STATUS.SENDING;
},
loadFail() {
return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "fail";
sendFail() {
return this.msgInfo.status == this.$enums.MESSAGE_STATUS.FAILED;
},
data() {
contentData() {
return JSON.parse(this.msgInfo.content)
},
fileSize() {
let size = this.data.size;
let size = this.contentData.size;
if (size > 1024 * 1024) {
return Math.round(size / 1024 / 1024) + "M";
}
@ -227,6 +225,9 @@ export default {
}
return items;
},
isTextMessage() {
return this.msgInfo.type == this.$enums.MESSAGE_TYPE.TEXT
},
isAction() {
return this.$msgType.isAction(this.msgInfo.type);
},
@ -239,6 +240,22 @@ export default {
let text = this.$str.html2Escape(this.msgInfo.content)
text = this.$url.replaceURLWithHTMLLinks(text, color)
return this.$emo.transform(text, 'emoji-normal')
},
imageStyle() {
console.log(uni.getSystemInfo())
let maxSize = uni.getSystemInfoSync().windowWidth * 0.6;
let minSize = uni.getSystemInfoSync().windowWidth * 0.2;
let width = this.contentData.width;
let height = this.contentData.height;
if (width && height) {
let ratio = Math.min(width, height) / Math.max(width, height);
let w = Math.max(width > height ? maxSize : ratio * maxSize, minSize);
let h = Math.max(width > height ? ratio * maxSize : maxSize, minSize);
return `width: ${w}px;height:${h}px;`
} else {
//
return `max-width: ${maxSize}px;min-width:100px;max-height: ${maxSize}px;min-height:100px;`
}
}
}
}
@ -285,116 +302,136 @@ export default {
padding-right: 80rpx;
margin-top: 5rpx;
.message-text {
.message-content-wrapper {
position: relative;
line-height: 1.6;
margin-top: 10rpx;
padding: 16rpx 24rpx;
background-color: $im-bg;
border-radius: 20rpx;
color: $im-text-color;
font-size: $im-font-size;
text-align: left;
display: block;
word-break: break-all;
white-space: pre-line;
&:after {
content: "";
position: absolute;
left: -20rpx;
top: 26rpx;
width: 6rpx;
height: 6rpx;
border-style: solid dashed dashed;
border-color: $im-bg transparent transparent;
overflow: hidden;
border-width: 18rpx;
}
}
.message-image {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
.image-box {
.sending {
position: relative;
margin: 0 6rpx;
.send-image {
min-width: 200rpx;
max-width: 420rpx;
height: 350rpx;
cursor: pointer;
border-radius: 4px;
.icon-loading {
color: $im-color-primary;
}
}
.send-fail {
color: $im-color-danger;
font-size: $im-font-size;
cursor: pointer;
margin: 0 20px;
color: #e60c0c;
font-size: 50rpx;
margin: 0 5rpx;
}
}
.message-file {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
cursor: pointer;
.file-box {
.message-text {
position: relative;
line-height: 1.6;
margin-top: 10rpx;
padding: 16rpx 24rpx;
background-color: $im-bg;
border-radius: 20rpx;
color: $im-text-color;
font-size: $im-font-size;
text-align: left;
display: block;
word-break: break-word;
white-space: pre-line;
&:after {
content: "";
position: absolute;
left: -20rpx;
top: 26rpx;
width: 6rpx;
height: 6rpx;
border-style: solid dashed dashed;
border-color: $im-bg transparent transparent;
overflow: hidden;
border-width: 18rpx;
}
}
.message-image {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
min-height: 60px;
border-radius: 4px;
padding: 10px 15px;
box-shadow: $im-box-shadow-dark;
.file-info {
flex: 1;
height: 100%;
text-align: left;
font-size: 14px;
width: 300rpx;
.file-name {
font-weight: 600;
margin-bottom: 15px;
word-break: break-all;
.image-box {
position: relative;
.send-image {
cursor: pointer;
border-radius: 10rpx;
background: $im-bg;
border: 6rpx solid $im-color-primary-light-5;
}
}
.file-icon {
font-size: 80rpx;
color: #d42e07;
.send-fail {
color: $im-color-danger;
font-size: $im-font-size;
cursor: pointer;
margin: 0 20px;
}
}
.send-fail {
color: #e60c0c;
font-size: 50rpx;
.message-file {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
cursor: pointer;
margin: 0 20rpx;
}
}
.message-audio {
display: flex;
align-items: center;
.file-box {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: center;
min-height: 60px;
border-radius: 4px;
padding: 10px 15px;
box-shadow: $im-box-shadow-dark;
.file-info {
flex: 1;
height: 100%;
text-align: left;
font-size: 14px;
width: 300rpx;
.file-name {
font-weight: 600;
margin-bottom: 15px;
word-break: break-all;
}
}
.chat-audio-text {
padding-right: 8px;
.file-icon {
font-size: 80rpx;
color: #d42e07;
}
}
.send-fail {
color: #e60c0c;
font-size: 50rpx;
cursor: pointer;
margin: 0 20rpx;
}
}
.icon-voice-play {
font-size: 18px;
padding-right: 8px;
.message-audio {
display: flex;
align-items: center;
.chat-audio-text {
padding-right: 8px;
}
.icon-voice-play {
font-size: 18px;
padding-right: 8px;
}
}
}
@ -454,6 +491,10 @@ export default {
padding-left: 80rpx;
padding-right: 0;
.message-content-wrapper {
flex-direction: row-reverse;
}
.message-text {
margin-left: 10px;
background-color: $im-color-primary-light-2;
@ -466,14 +507,6 @@ export default {
}
}
.message-image {
flex-direction: row-reverse;
}
.message-file {
flex-direction: row-reverse;
}
.message-audio {
flex-direction: row-reverse;

17
im-uniapp/components/head-image/head-image.vue

@ -16,7 +16,8 @@ export default {
data() {
return {
colors: ["#5daa31", "#c7515a", "#e03697", "#85029b",
"#c9b455", "#326eb6"]
"#c9b455", "#326eb6"
]
}
},
props: {
@ -34,6 +35,10 @@ export default {
type: String,
default: null
},
radius: {
type: String,
default: "50%"
},
online: {
type: Boolean,
default: false
@ -61,7 +66,7 @@ export default {
'minier': 48,
'lage': 108,
'lager': 120,
}[this.size]
} [this.size]
}
},
avatarImageStyle() {
@ -71,11 +76,15 @@ export default {
avatarTextStyle() {
return `width: ${this._size}rpx;
height:${this._size}rpx;
background-color:${this.name ? this.textColor : '#fff'};
font-size:${this._size * 0.5}rpx;
background: linear-gradient(145deg,#ffffff20 25%,#00000060),${this.textColor};
font-size:${this._size * 0.45}rpx;
border-radius: ${this.radius};
`
},
textColor() {
if (!this.name) {
return '#fff';
}
let hash = 0;
for (var i = 0; i < this.name.length; i++) {
hash += this.name.charCodeAt(i);

13
im-uniapp/components/loading/loading.vue

@ -6,7 +6,6 @@
</template>
<script>
export default {
data() {
return {}
@ -16,6 +15,10 @@ export default {
type: Number,
default: 100
},
iconColor: {
type: String,
default: ''
},
mask: {
type: Boolean,
default: true
@ -23,7 +26,13 @@ export default {
},
computed: {
icontStyle() {
return `font-size:${this.size}rpx`;
let style = `font-size:${this.size}rpx;`;
if(this.iconColor){
style += `color: ${this.iconColor};`
}else if(this.mask){
style += 'color: #eee;'
}
return style;
},
loadingStyle() {
return this.mask ? "background: rgba(0, 0, 0, 0.3);" : "";

16
im-uniapp/components/long-press-menu/long-press-menu.vue

@ -1,6 +1,6 @@
<template>
<view class="long-press-menu none-pointer-events">
<view @longpress.stop="onLongPress($event)" @touchmove="onTouchMove" @touchend="onTouchEnd">
<view @longpress.prevent.stop="onLongPress($event)" @touchmove="onTouchMove" @touchend="onTouchEnd">
<slot></slot>
</view>
<view v-if="isShowMenu" class="menu-mask" @touchstart="onClose()" @contextmenu.prevent=""></view>
@ -39,9 +39,9 @@ export default {
let style = "";
/* 因 非H5端不兼容 style 属性绑定 Object ,所以拼接字符 */
if (touches.clientY > (res.windowHeight / 2)) {
style = `bottom:${res.windowHeight - touches.clientY}px;`;
style = `bottom:${res.windowHeight - touches.clientY + 20}px;`;
} else {
style = `top:${touches.clientY}px;`;
style = `top:${touches.clientY + 20}px;`;
}
if (touches.clientX > (res.windowWidth / 2)) {
style += `right:${res.windowWidth - touches.clientX}px;`;
@ -53,12 +53,18 @@ export default {
this.$nextTick(() => {
this.isShowMenu = true;
});
this.menuTouch = touches
}
})
},
onTouchMove() {
this.onClose();
onTouchMove(e) {
this.isTouchMove = true;
let touches = e.touches[0];
// 50px
if (Math.abs(touches.clientX - this.menuTouch.clientX) > 50 ||
Math.abs(touches.clientY - this.menuTouch.clientY) > 50) {
this.onClose();
}
},
onTouchEnd() {
this.isTouchMove = false;

140
im-uniapp/pages/chat/chat-box.vue

@ -9,10 +9,10 @@
<view v-for="(msgInfo, idx) in chat.messages" :key="idx">
<chat-message-item :ref="'message'+msgInfo.id" v-if="idx >= showMinIdx"
:headImage="headImage(msgInfo)" @call="onRtCall(msgInfo)" :showName="showName(msgInfo)"
@recall="onRecallMessage" @delete="onDeleteMessage" @copy="onCopyMessage"
@longPressHead="onLongPressHead(msgInfo)" @download="onDownloadFile"
@audioStateChange="onAudioStateChange" :id="'chat-item-' + idx" :msgInfo="msgInfo"
:groupMembers="groupMembers">
@resend="onResendMessage" @recall="onRecallMessage" @delete="onDeleteMessage"
@copy="onCopyMessage" @longPressHead="onLongPressHead(msgInfo)"
@download="onDownloadFile" @audioStateChange="onAudioStateChange"
:id="'chat-item-' + idx" :msgInfo="msgInfo" :groupMembers="groupMembers">
</chat-message-item>
</view>
</view>
@ -167,15 +167,24 @@ export default {
return;
}
let msgInfo = {
tmpId: this.generateId(),
content: JSON.stringify(data),
type: this.$enums.MESSAGE_TYPE.AUDIO,
receipt: this.isReceipt
}
// id
this.fillTargetId(msgInfo, this.chat.targetId);
//
const chat = this.chat;
//
let tmpMessage = this.buildTmpMessage(msgInfo);
this.chatStore.insertMessage(tmpMessage, chat);
this.moveChatToTop();
this.sendMessageRequest(msgInfo).then((m) => {
m.selfSend = true;
this.chatStore.insertMessage(m, this.chat);
//
tmpMessage.id = m.id;
tmpMessage.status = m.status;
this.chatStore.insertMessage(tmpMessage, chat);
//
this.moveChatToTop();
//
@ -299,10 +308,11 @@ export default {
let receiptText = this.isReceipt ? "【回执消息】" : "";
let atText = this.createAtText();
let msgInfo = {
tmpId: this.generateId(),
content: receiptText + sendText + atText,
atUserIds: this.atUserIds,
receipt: this.isReceipt,
type: 0
type: this.$enums.MESSAGE_TYPE.TEXT
}
// @
this.atUserIds = [];
@ -311,15 +321,24 @@ export default {
this.fillTargetId(msgInfo, this.chat.targetId);
//
const chat = this.chat;
//
let tmpMessage = this.buildTmpMessage(msgInfo);
this.chatStore.insertMessage(tmpMessage, chat);
this.moveChatToTop();
this.sendMessageRequest(msgInfo).then((m) => {
m.selfSend = true;
this.chatStore.insertMessage(m, chat);
//
this.moveChatToTop();
}).finally(() => {
//
this.scrollToBottom();
});
//
tmpMessage = JSON.parse(JSON.stringify(tmpMessage));
tmpMessage.id = m.id;
tmpMessage.status = m.status;
tmpMessage.content = m.content;
this.chatStore.insertMessage(tmpMessage, chat);
}).catch(() => {
//
tmpMessage = JSON.parse(JSON.stringify(tmpMessage));
tmpMessage.status = this.$enums.MESSAGE_STATUS.FAILED;
this.chatStore.insertMessage(tmpMessage, chat);
})
}
})
@ -410,7 +429,6 @@ export default {
thumbUrl: file.path
}
let msgInfo = {
id: 0,
tmpId: this.generateId(),
fileId: file.uid,
sendId: this.mine.id,
@ -419,8 +437,7 @@ export default {
selfSend: true,
type: this.$enums.MESSAGE_TYPE.IMAGE,
readedCount: 0,
loadStatus: "loading",
status: this.$enums.MESSAGE_STATUS.UNSEND
status: this.$enums.MESSAGE_STATUS.SENDING
}
// id
this.fillTargetId(msgInfo, this.chat.targetId);
@ -431,8 +448,16 @@ export default {
// file
file.msgInfo = msgInfo;
file.chat = this.chat;
//
this.scrollToBottom();
//
let chat = this.chat;
this.getImageSize(file).then(size => {
msgInfo = JSON.parse(JSON.stringify(msgInfo))
data.width = size.width;
data.height = size.height;
msgInfo.content = JSON.stringify(data)
this.chatStore.insertMessage(msgInfo, chat);
this.scrollToBottom();
})
return true;
},
onUploadImageSuccess(file, res) {
@ -440,15 +465,15 @@ export default {
msgInfo.content = JSON.stringify(res.data);
msgInfo.receipt = this.isReceipt
this.sendMessageRequest(msgInfo).then((m) => {
msgInfo.loadStatus = 'ok';
msgInfo.id = m.id;
msgInfo.status = m.status;
this.isReceipt = false;
this.chatStore.insertMessage(msgInfo, file.chat);
})
},
onUploadImageFail(file, err) {
let msgInfo = JSON.parse(JSON.stringify(file.msgInfo));
msgInfo.loadStatus = 'fail';
msgInfo.status = this.$enums.MESSAGE_STATUS.FAILED;
this.chatStore.insertMessage(msgInfo, file.chat);
},
onUploadFileBefore(file) {
@ -463,7 +488,6 @@ export default {
url: file.path
}
let msgInfo = {
id: 0,
tmpId: this.generateId(),
sendId: this.mine.id,
content: JSON.stringify(data),
@ -471,8 +495,7 @@ export default {
selfSend: true,
type: this.$enums.MESSAGE_TYPE.FILE,
readedCount: 0,
loadStatus: "loading",
status: this.$enums.MESSAGE_STATUS.UNSEND
status: this.$enums.MESSAGE_STATUS.SENDING
}
// id
this.fillTargetId(msgInfo, this.chat.targetId);
@ -483,8 +506,6 @@ export default {
// file
file.msgInfo = msgInfo;
file.chat = this.chat;
//
this.scrollToBottom();
return true;
},
onUploadFileSuccess(file, res) {
@ -497,17 +518,48 @@ export default {
msgInfo.content = JSON.stringify(data);
msgInfo.receipt = this.isReceipt
this.sendMessageRequest(msgInfo).then((m) => {
msgInfo.loadStatus = 'ok';
msgInfo.id = m.id;
msgInfo.status = m.status;
this.isReceipt = false;
this.chatStore.insertMessage(msgInfo, file.chat);
})
},
onUploadFileFail(file, res) {
let msgInfo = JSON.parse(JSON.stringify(file.msgInfo));
msgInfo.loadStatus = 'fail';
msgInfo.status = this.$enums.MESSAGE_STATUS.FAILED;
this.chatStore.insertMessage(msgInfo, file.chat);
},
onResendMessage(msgInfo) {
if (msgInfo.type != this.$enums.MESSAGE_TYPE.TEXT) {
uni.showToast({
title: "该消息不支持自动重新发送,建议手动重新发送",
icon: "none"
})
return;
}
//
const chat = this.chat;
//
this.chatStore.deleteMessage(msgInfo, chat);
//
msgInfo.temId = this.generateId();
let tmpMessage = this.buildTmpMessage(msgInfo);
this.chatStore.insertMessage(tmpMessage, chat);
this.moveChatToTop();
this.sendMessageRequest(msgInfo).then(m => {
//
tmpMessage = JSON.parse(JSON.stringify(tmpMessage));
tmpMessage.id = m.id;
tmpMessage.status = m.status;
tmpMessage.content = m.content;
this.chatStore.insertMessage(tmpMessage, chat);
}).catch(() => {
//
tmpMessage = JSON.parse(JSON.stringify(tmpMessage));
tmpMessage.status = this.$enums.MESSAGE_STATUS.FAILED;
this.chatStore.insertMessage(tmpMessage, chat);
})
},
onDeleteMessage(msgInfo) {
uni.showModal({
title: '删除消息',
@ -594,7 +646,9 @@ export default {
// #endif
// #ifdef H5
// h5scroll-top
this.holdingScrollBar(this.scrollViewHeight);
if (uni.getSystemInfoSync().platform == 'ios') {
this.holdingScrollBar(this.scrollViewHeight);
}
// #endif
// 20
this.showMinIdx = this.showMinIdx > 20 ? this.showMinIdx - 20 : 0;
@ -875,6 +929,30 @@ export default {
}
this.chatStore.insertMessage(msgInfo, this.chat);
},
buildTmpMessage(msgInfo) {
let message = JSON.parse(JSON.stringify(msgInfo));
message.sendId = this.mine.id;
message.sendTime = new Date().getTime();
message.status = this.$enums.MESSAGE_STATUS.SENDING;
message.selfSend = true;
if (this.isGroup) {
message.readedCount = 0;
}
return message;
},
getImageSize(file) {
return new Promise((resolve) => {
uni.getImageInfo({
src: file.path,
success: (res) => {
resolve(res);
},
fail: (err) => {
console.error('获取图片信息失败', err);
}
});
});
},
generateId() {
// id
return String(new Date().getTime()) + String(Math.floor(Math.random() * 1000));
@ -944,7 +1022,7 @@ export default {
if (newSize > oldSize && oldSize > 0) {
let lastMessage = this.chat.messages[newSize - 1];
if (this.$msgType.isNormal(lastMessage.type)) {
if (this.isInBottom) {
if (this.isInBottom || lastMessage.selfSend) {
// ,
this.scrollToBottom();
} else {

2
im-uniapp/pages/chat/chat.vue

@ -114,7 +114,7 @@ export default {
return this.chatStore.isLoading();
},
initializing() {
return !getApp().$vm.isInit;
return !this.configStore.appInit;
},
showChats() {
this.chatStore.chats.filter((chat) => !chat.delete && chat.showName && chat.showName.includes(this

63
im-uniapp/store/chatStore.js

@ -3,6 +3,7 @@ import { MESSAGE_TYPE, MESSAGE_STATUS } from '@/common/enums.js';
import useFriendStore from './friendStore.js';
import useGroupStore from './groupStore.js';
import useUserStore from './userStore';
import useConfigStore from './configStore.js';
let cacheChats = [];
export default defineStore('chatStore', {
@ -30,14 +31,6 @@ export default defineStore('chatStore', {
}
this.privateMsgMaxId = chatsData.privateMsgMaxId || 0;
this.groupMsgMaxId = chatsData.groupMsgMaxId || 0;
// 防止图片一直处在加载中状态
cacheChats.forEach((chat) => {
chat.messages.forEach((msg) => {
if (msg.loadStatus == "loading") {
msg.loadStatus = "fail"
}
})
})
},
openChat(chatInfo) {
let chats = this.curChats;
@ -63,6 +56,7 @@ export default defineStore('chatStore', {
lastSendTime: new Date().getTime(),
unreadCount: 0,
hotMinIdx: 0,
readedMessageIdx: 0,
messages: [],
atMe: false,
atAll: false,
@ -95,15 +89,17 @@ export default defineStore('chatStore', {
readedMessage(pos) {
let chat = this.findChatByFriend(pos.friendId);
if (!chat) return;
chat.messages.forEach((m) => {
for (let idx = chat.readedMessageIdx; idx < chat.messages.length; idx++) {
let m = chat.messages[idx];
if (m.id && m.selfSend && m.status < MESSAGE_STATUS.RECALL) {
// pos.maxId为空表示整个会话已读
if (!pos.maxId || m.id <= pos.maxId) {
m.status = MESSAGE_STATUS.READED
chat.readedMessageIdx = idx;
chat.stored = false;
}
}
})
}
if (!chat.stored) {
this.saveToStorage();
}
@ -160,12 +156,15 @@ export default defineStore('chatStore', {
insertMessage(msgInfo, chatInfo) {
// 获取对方id或群id
let type = chatInfo.type;
// 记录消息的最大id
if (msgInfo.id && type == "PRIVATE" && msgInfo.id > this.privateMsgMaxId) {
this.privateMsgMaxId = msgInfo.id;
}
if (msgInfo.id && type == "GROUP" && msgInfo.id > this.groupMsgMaxId) {
this.groupMsgMaxId = msgInfo.id;
// 完成初始化之前不能修改消息最大id,否则可能导致拉不到离线消息
if (useConfigStore().appInit) {
// 记录消息的最大id
if (msgInfo.id && type == "PRIVATE" && msgInfo.id > this.privateMsgMaxId) {
this.privateMsgMaxId = msgInfo.id;
}
if (msgInfo.id && type == "GROUP" && msgInfo.id > this.groupMsgMaxId) {
this.groupMsgMaxId = msgInfo.id;
}
}
// 如果是已存在消息,则覆盖旧的消息数据
let chat = this.findChat(chatInfo);
@ -252,25 +251,33 @@ export default defineStore('chatStore', {
}
},
deleteMessage(msgInfo, chatInfo) {
// 获取对方id或群id
let chat = this.findChat(chatInfo);
let isColdMessage = false;
let chat = this.findChat(chatInfo);
let delIdx = -1;
for (let idx in chat.messages) {
// 已经发送成功的,根据id删除
if (chat.messages[idx].id && chat.messages[idx].id == msgInfo.id) {
chat.messages.splice(idx, 1);
isColdMessage = idx < chat.hotMinIdx;
delIdx = idx;
break;
}
// 正在发送中的消息可能没有id,只有临时id
if (chat.messages[idx].tmpId && chat.messages[idx].tmpId == msgInfo.tmpId) {
chat.messages.splice(idx, 1);
isColdMessage = idx < chat.hotMinIdx;
delIdx = idx;
break;
}
}
chat.stored = false;
this.saveToStorage(isColdMessage);
if (delIdx >= 0) {
chat.messages.splice(delIdx, 1);
if (delIdx < chat.hotMinIdx) {
isColdMessage = true;
chat.hotMinIdx--;
}
if (delIdx < chat.readedMessageIdx) {
chat.readedMessageIdx--;
}
chat.stored = false;
this.saveToStorage(isColdMessage);
}
},
recallMessage(msgInfo, chatInfo) {
let chat = this.findChat(chatInfo);
@ -463,11 +470,19 @@ export default defineStore('chatStore', {
if (!coldChat && !hotChat) {
return;
}
// 防止消息一直处在发送中状态
hotChat && hotChat.messages.forEach(msg => {
if (msg.status == MESSAGE_STATUS.SENDING) {
msg.status = MESSAGE_STATUS.FAILED
}
})
// 冷热消息合并
let chat = Object.assign({}, coldChat, hotChat);
if (hotChat && coldChat) {
chat.messages = coldChat.messages.concat(hotChat.messages)
}
// 历史版本没有readedMessageIdx字段,做兼容一下
chat.readedMessageIdx = chat.readedMessageIdx || 0;
chatsData.chats.push(chat);
})
}

4
im-uniapp/store/configStore.js

@ -4,6 +4,7 @@ import http from '../common/request'
export default defineStore('configStore', {
state: () => {
return {
appInit: false,
webrtc: {}
}
},
@ -11,6 +12,9 @@ export default defineStore('configStore', {
setConfig(config) {
this.webrtc = config.webrtc;
},
setAppInit(appInit) {
this.appInit = appInit;
},
clear() {
this.webrtc = {};
},

10
im-web/src/api/enums.js

@ -55,10 +55,12 @@ const TERMINAL_TYPE = {
}
const MESSAGE_STATUS = {
UNSEND: 0,
SENDED: 1,
RECALL: 2,
READED: 3
FAILED: -2, // 发送失败
SENDING: -1, // 发送中(消息没到服务器)
PENDING: 0, // 未送达(消息已到服务器,但对方没收到)
DELIVERED: 1, // 已送达(对方已收到,但是未读消息)
RECALL: 2, // 已撤回
READED: 3, // 消息已读
}

10
im-web/src/api/wssocket.js

@ -48,10 +48,11 @@ let connect = (wsurl, accessToken) => {
}
// 连接发生错误的回调方法
websock.onerror = function () {
console.log('WebSocket连接发生错误')
isConnect = false; //连接断开修改标识
reconnect(wsurl, accessToken);
websock.onerror = function (e) {
console.log('WebSocket连接发生错误:{}', e)
close(3000);
isConnect = false;
closeCallBack && closeCallBack(e);
}
} catch (e) {
console.log("尝试创建连接失败");
@ -91,7 +92,6 @@ let heartCheck = {
websock.send(JSON.stringify(heartBeat))
}
},
reset: function () {
clearTimeout(this.timeoutObj);
this.timeoutObj = setTimeout(function () {

BIN
im-web/src/assets/audio/tip.mp3

Binary file not shown.

BIN
im-web/src/assets/audio/tip.wav

Binary file not shown.

149
im-web/src/components/chat/ChatBox.vue

@ -13,7 +13,8 @@
<div v-for="(msgInfo, idx) in showMessages" :key="showMinIdx + idx">
<chat-message-item @call="onCall(msgInfo.type)" :mine="msgInfo.sendId == mine.id"
:headImage="headImage(msgInfo)" :showName="showName(msgInfo)" :msgInfo="msgInfo"
:groupMembers="groupMembers" @delete="deleteMessage" @recall="recallMessage">
:groupMembers="groupMembers" @resend="onResendMessage" @delete="deleteMessage"
@recall="recallMessage">
</chat-message-item>
</div>
</div>
@ -159,15 +160,15 @@ export default {
msgInfo.content = JSON.stringify(data);
msgInfo.receipt = this.isReceipt;
this.sendMessageRequest(msgInfo).then((m) => {
msgInfo.loadStatus = 'ok';
msgInfo.id = m.id;
msgInfo.status = m.status;
this.isReceipt = false;
this.chatStore.insertMessage(msgInfo, file.chat);
})
},
onImageFail(e, file) {
let msgInfo = JSON.parse(JSON.stringify(file.msgInfo));
msgInfo.loadStatus = 'fail';
msgInfo.status = this.$enums.MESSAGE_STATUS.FAILED;
this.chatStore.insertMessage(msgInfo, file.chat);
},
onImageBefore(file) {
@ -182,17 +183,15 @@ export default {
thumbUrl: url
}
let msgInfo = {
id: 0,
tmpId: this.generateId(),
fileId: file.uid,
sendId: this.mine.id,
content: JSON.stringify(data),
sendTime: new Date().getTime(),
selfSend: true,
type: 1,
type: this.$enums.MESSAGE_TYPE.IMAGE,
readedCount: 0,
loadStatus: "loading",
status: this.$enums.MESSAGE_STATUS.UNSEND
status: this.$enums.MESSAGE_STATUS.SENDING
}
// id
this.fillTargetId(msgInfo, this.chat.targetId);
@ -200,11 +199,18 @@ export default {
this.chatStore.insertMessage(msgInfo, this.chat);
//
this.moveChatToTop();
//
this.scrollToBottom();
// file
file.msgInfo = msgInfo;
file.chat = this.chat;
//
let chat = this.chat;
this.getImageSize(file).then(size => {
data.width = size.width;
data.height = size.height;
msgInfo.content = JSON.stringify(data)
this.chatStore.insertMessage(msgInfo, chat);
this.scrollToBottom();
})
},
onFileSuccess(url, file) {
let data = {
@ -216,8 +222,8 @@ export default {
msgInfo.content = JSON.stringify(data);
msgInfo.receipt = this.isReceipt
this.sendMessageRequest(msgInfo).then((m) => {
msgInfo.loadStatus = 'ok';
msgInfo.id = m.id;
msgInfo.status = m.status;
this.isReceipt = false;
this.refreshPlaceHolder();
this.chatStore.insertMessage(msgInfo, file.chat);
@ -225,7 +231,7 @@ export default {
},
onFileFail(e, file) {
let msgInfo = JSON.parse(JSON.stringify(file.msgInfo));
msgInfo.loadStatus = 'fail';
msgInfo.status = this.$enums.MESSAGE_STATUS.FAILED;
this.chatStore.insertMessage(msgInfo, file.chat);
},
onFileBefore(file) {
@ -241,16 +247,14 @@ export default {
url: url
}
let msgInfo = {
id: 0,
tmpId: this.generateId(),
sendId: this.mine.id,
content: JSON.stringify(data),
sendTime: new Date().getTime(),
selfSend: true,
type: 2,
loadStatus: "loading",
type: this.$enums.MESSAGE_TYPE.FILE,
readedCount: 0,
status: this.$enums.MESSAGE_STATUS.UNSEND
status: this.$enums.MESSAGE_STATUS.SENDING
}
// id
this.fillTargetId(msgInfo, this.chat.targetId);
@ -258,8 +262,6 @@ export default {
this.chatStore.insertMessage(msgInfo, this.chat);
//
this.moveChatToTop();
//
this.scrollToBottom();
// file
file.msgInfo = msgInfo;
file.chat = this.chat;
@ -365,15 +367,24 @@ export default {
return;
}
let msgInfo = {
tmpId: this.generateId(),
content: JSON.stringify(data),
type: 3,
type: this.$enums.MESSAGE_TYPE.AUDIO,
receipt: this.isReceipt
}
// id
this.fillTargetId(msgInfo, this.chat.targetId);
//
const chat = this.chat;
//
let tmpMessage = this.buildTmpMessage(msgInfo);
this.chatStore.insertMessage(tmpMessage, chat);
this.moveChatToTop();
this.sendMessageRequest(msgInfo).then(m => {
m.selfSend = true;
this.chatStore.insertMessage(m, this.chat);
//
tmpMessage.id = m.id;
tmpMessage.status = m.status;
this.chatStore.insertMessage(tmpMessage, chat);
//
this.moveChatToTop();
//
@ -384,6 +395,9 @@ export default {
this.showRecord = false;
this.isReceipt = false;
this.refreshPlaceHolder();
}).catch(() => {
tmpMessage.status = this.$enums.MESSAGE_STATUS.FAILED;
this.chatStore.insertMessage(tmpMessage, this.chat);
})
},
fillTargetId(msgInfo, targetId) {
@ -445,8 +459,9 @@ export default {
reject();
}
let msgInfo = {
tmpId: this.generateId(),
content: sendText,
type: 0
type: this.$enums.MESSAGE_TYPE.TEXT
}
// id
this.fillTargetId(msgInfo, this.chat.targetId);
@ -456,13 +471,24 @@ export default {
msgInfo.receipt = this.isReceipt;
}
this.lockMessage = true;
//
const chat = this.chat;
//
let tmpMessage = this.buildTmpMessage(msgInfo);
this.chatStore.insertMessage(tmpMessage, chat);
this.moveChatToTop();
//
this.sendMessageRequest(msgInfo).then((m) => {
m.selfSend = true;
this.chatStore.insertMessage(m, chat);
this.moveChatToTop();
//
tmpMessage.id = m.id;
tmpMessage.status = m.status;
tmpMessage.content = m.content;
this.chatStore.insertMessage(tmpMessage, chat);
}).catch(() => {
//
tmpMessage.status = this.$enums.MESSAGE_STATUS.FAILED;
this.chatStore.insertMessage(tmpMessage, chat);
}).finally(() => {
this.scrollToBottom();
this.isReceipt = false;
resolve();
});
@ -476,6 +502,35 @@ export default {
}
})
},
onResendMessage(msgInfo) {
if (msgInfo.type != this.$enums.MESSAGE_TYPE.TEXT) {
this.$message.error("该消息不支持自动重新发送,建议手动重新发送")
return;
}
//
const chat = this.chat;
//
this.chatStore.deleteMessage(msgInfo, chat);
//
msgInfo.temId = this.generateId();
let tmpMessage = this.buildTmpMessage(msgInfo);
this.chatStore.insertMessage(tmpMessage, chat);
this.moveChatToTop();
//
this.sendMessageRequest(msgInfo).then(m => {
//
tmpMessage.id = m.id;
tmpMessage.status = m.status;
tmpMessage.content = m.content;
this.chatStore.insertMessage(tmpMessage, chat);
}).catch(() => {
//
tmpMessage.status = this.$enums.MESSAGE_STATUS.FAILED;
this.chatStore.insertMessage(tmpMessage, chat);
}).finally(() => {
this.scrollToBottom();
});
},
deleteMessage(msgInfo) {
this.$confirm('确认删除消息?', '删除消息', {
confirmButtonText: '确定',
@ -649,6 +704,36 @@ export default {
}
this.chatStore.insertMessage(msgInfo, this.chat);
},
buildTmpMessage(msgInfo) {
let message = JSON.parse(JSON.stringify(msgInfo));
message.sendId = this.mine.id;
message.sendTime = new Date().getTime();
message.status = this.$enums.MESSAGE_STATUS.SENDING;
message.selfSend = true;
if (this.isGroup) {
message.readedCount = 0;
}
return message;
},
getImageSize(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (event) {
const img = new Image();
img.onload = function () {
resolve({ width: img.width, height: img.height });
};
img.onerror = function () {
reject(new Error('无法加载图片'));
};
img.src = event.target.result;
};
reader.onerror = function () {
reject(new Error('无法读取文件'));
};
reader.readAsDataURL(file);
});
},
generateId() {
// id
return String(new Date().getTime()) + String(Math.floor(Math.random() * 1000));
@ -737,12 +822,14 @@ export default {
messageSize: {
handler(newSize, oldSize) {
if (newSize > oldSize) {
if (this.isInBottom) {
//
this.scrollToBottom();
} else {
//
this.newMessageSize++;
// ,
let lastMessage = this.chat.messages[newSize - 1];
if (lastMessage && this.$msgType.isNormal(lastMessage.type)) {
if (this.isInBottom || lastMessage.selfSend) {
this.scrollToBottom();
} else {
this.newMessageSize++;
}
}
}
}

12
im-web/src/components/chat/ChatItem.vue

@ -2,7 +2,7 @@
<div class="chat-item" :class="active ? 'active' : ''" @contextmenu.prevent="showRightMenu($event)">
<div class="chat-left">
<head-image :url="chat.headImage" :name="chat.showName" :size="42"
:id="chat.type == 'PRIVATE' ? chat.targetId : 0" :isShowUserInfo="false"></head-image>
:id="chat.type == 'PRIVATE' ? chat.targetId : 0" :isShowUserInfo="false" :online="online"></head-image>
<div v-show="!chat.isDnd && chat.unreadCount > 0" class="unread-text">{{ chat.unreadCount }}</div>
</div>
<div class="chat-right">
@ -16,7 +16,8 @@
<div class="chat-content">
<div class="chat-at-text">{{ atText }}</div>
<div class="chat-send-name" v-show="isShowSendName">{{ chat.sendNickName + ':&nbsp;' }}</div>
<div class="chat-content-text" v-html="$emo.transform($str.html2Escape(chat.lastContent), 'emoji-small')"></div>
<div class="chat-content-text"
v-html="$emo.transform($str.html2Escape(chat.lastContent), 'emoji-small')"></div>
<div class="icon iconfont icon-dnd" v-if="chat.isDnd"></div>
</div>
</div>
@ -102,6 +103,13 @@ export default {
color: '#F56C6C'
})
return items;
},
online() {
if (this.chat.type == 'PRIVATE') {
let friend = this.friendStore.findFriend(this.chat.targetId);
return friend && friend.online;
}
return false;
}
}
}

149
im-web/src/components/chat/ChatMessageItem.vue

@ -19,35 +19,35 @@
<span>{{ $date.toTimeText(msgInfo.sendTime) }}</span>
</div>
<div class="message-bottom" @contextmenu.prevent="showRightMenu($event)">
<div ref="chatMsgBox">
<span class="message-text" v-if="msgInfo.type == $enums.MESSAGE_TYPE.TEXT"
v-html="htmlText"></span>
<div class="message-image" v-if="msgInfo.type == $enums.MESSAGE_TYPE.IMAGE">
<div class="img-load-box" v-loading="loading" element-loading-text="上传中.."
<div ref="chatMsgBox" class="message-content-wrapper">
<span class="message-text" v-if="isTextMessage" v-html="htmlText"></span>
<div class="message-image" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.IMAGE"
@click="showFullImageBox()">
<div v-loading="sending" element-loading-text="发送中.."
element-loading-background="rgba(0, 0, 0, 0.4)">
<img class="send-image" :src="JSON.parse(msgInfo.content).thumbUrl"
@click="showFullImageBox()" loading="lazy" />
<img :style="imageStyle" :src="contentData.thumbUrl" loading="lazy" />
</div>
<span title="发送失败" v-show="loadFail" @click="onSendFail"
class="send-fail el-icon-warning"></span>
</div>
<div class="message-file" v-if="msgInfo.type == $enums.MESSAGE_TYPE.FILE">
<div class="chat-file-box" v-loading="loading">
<div class="message-file" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.FILE">
<div class="chat-file-box" v-loading="sending">
<div class="chat-file-info">
<el-link class="chat-file-name" :underline="true" target="_blank" type="primary"
:href="data.url" :download="data.name">{{ data.name }}</el-link>
:href="contentData.url" :download="contentData.name">{{ contentData.name
}}</el-link>
<div class="chat-file-size">{{ fileSize }}</div>
</div>
<div class="chat-file-icon">
<span type="primary" class="el-icon-document"></span>
</div>
</div>
<span title="发送失败" v-show="loadFail" @click="onSendFail"
class="send-fail el-icon-warning"></span>
</div>
</div>
<div class="message-voice" v-if="msgInfo.type == $enums.MESSAGE_TYPE.AUDIO" @click="onPlayVoice()">
<audio controls :src="JSON.parse(msgInfo.content).url"></audio>
<div class="message-voice" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.AUDIO"
@click="onPlayVoice()">
<audio controls :src="JSON.parse(msgInfo.content).url"></audio>
</div>
<div title="发送中" v-if="sending" class="sending" v-loading="'true'"></div>
<div title="发送失败" v-else-if="sendFail" @click="onSendFail" class="send-fail el-icon-warning">
</div>
</div>
<div class="chat-action message-text" v-if="isAction">
<span v-if="msgInfo.type == $enums.MESSAGE_TYPE.ACT_RT_VOICE" title="重新呼叫"
@ -56,11 +56,9 @@
@click="$emit('call')" class="iconfont icon-chat-video"></span>
<span>{{ msgInfo.content }}</span>
</div>
<div class="message-status" v-if="!isAction">
<span class="chat-readed" v-show="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status == $enums.MESSAGE_STATUS.READED">已读</span>
<span class="chat-unread" v-show="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status != $enums.MESSAGE_STATUS.READED">未读</span>
<div class="message-status" v-if="!isAction && msgInfo.selfSend && !isGroupMessage">
<span class="chat-readed" v-if="msgInfo.status == $enums.MESSAGE_STATUS.READED">已读</span>
<span class="chat-unread" v-else>未读</span>
</div>
<div class="chat-receipt" v-show="msgInfo.receipt" @click="onShowReadedBox">
<span v-if="msgInfo.receiptOk" class="icon iconfont icon-ok" title="全体已读"></span>
@ -121,7 +119,7 @@ export default {
},
methods: {
onSendFail() {
this.$message.error("该文件已发送失败,目前不支持自动重新发送,建议手动重新发送")
this.$emit("resend",this.msgInfo);
},
showFullImageBox() {
let imageUrl = JSON.parse(this.msgInfo.content).originUrl;
@ -149,17 +147,17 @@ export default {
}
},
computed: {
loading() {
return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "loading";
sending() {
return this.msgInfo.status == this.$enums.MESSAGE_STATUS.SENDING;
},
loadFail() {
return this.msgInfo.loadStatus && this.msgInfo.loadStatus === "fail";
sendFail() {
return this.msgInfo.status == this.$enums.MESSAGE_STATUS.FAILED;
},
data() {
contentData() {
return JSON.parse(this.msgInfo.content)
},
fileSize() {
let size = this.data.size;
let size = this.contentData.size;
if (size > 1024 * 1024) {
return Math.round(size / 1024 / 1024) + "M";
}
@ -184,6 +182,9 @@ export default {
}
return items;
},
isTextMessage() {
return this.msgInfo.type == this.$enums.MESSAGE_TYPE.TEXT
},
isAction() {
return this.$msgType.isAction(this.msgInfo.type);
},
@ -196,13 +197,34 @@ export default {
let text = this.$str.html2Escape(this.msgInfo.content)
text = this.$url.replaceURLWithHTMLLinks(text, color)
return this.$emo.transform(text, 'emoji-normal')
},
isGroupMessage() {
return !!this.msgInfo.groupId;
},
imageStyle() {
// 360px,60px,
let maxSize = this.configStore.fullScreen ? 360 : 240;
let minSize = 60;
let width = this.contentData.width;
let height = this.contentData.height;
if (width && height) {
let ratio = Math.min(width, height) / Math.max(width, height);
let w = Math.max(Math.min(width > height ? maxSize : ratio * maxSize, width), minSize);
let h = Math.max(Math.min(width > height ? ratio * maxSize : maxSize, height), minSize);
return `width: ${w}px;height:${h}px;object-fit: cover;`
} else {
//
return `max-width: ${maxSize}px;min-width:60px;max-height: ${maxSize}px;min-height:60px;`
}
}
}
}
</script>
<style lang="scss" scoped>
<style lang="scss">
.chat-message-item {
padding: 3px 10px;
border-radius: 10px;
.message-tip {
line-height: 50px;
@ -228,13 +250,6 @@ export default {
.content {
text-align: left;
.send-fail {
color: #e60c0c;
font-size: 30px;
cursor: pointer;
margin: 0 20px;
}
.message-top {
display: flex;
flex-wrap: nowrap;
@ -252,18 +267,42 @@ export default {
padding-right: 300px;
padding-left: 5px;
.message-content-wrapper {
position: relative;
display: flex;
align-items: flex-end;
.sending {
width: 25px;
height: 25px;
.circular {
width: 25px;
height: 25px;
}
}
.send-fail {
color: #e45050;
font-size: 30px;
cursor: pointer;
margin: 0 5px;
}
}
.message-text {
flex: 1;
display: inline-block;
position: relative;
line-height: 26px;
//margin-top: 3px;
padding: 6px 10px;
background-color: var(--im-background);
border-radius: 10px;
font-size: var(--im-font-size);
text-align: left;
white-space: pre-wrap;
word-break: break-all;
word-break: break-word;
&:after {
content: "";
@ -280,20 +319,10 @@ export default {
}
.message-image {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
.send-image {
min-width: 200px;
min-height: 150px;
max-width: 400px;
max-height: 300px;
border-radius: 8px;
cursor: pointer;
}
border-radius: 8px;
border: 3px solid var(--im-color-primary-light-8);
overflow: hidden;
cursor: pointer;
}
.message-file {
@ -373,6 +402,7 @@ export default {
}
.message-status {
margin-top: 3px;
display: block;
.chat-readed {
@ -432,8 +462,11 @@ export default {
padding-left: 180px;
padding-right: 5px;
.message-content-wrapper {
flex-direction: row-reverse;
}
.message-text {
margin-left: 10px;
background-color: var(--im-color-primary-light-2);
color: #fff;
@ -444,14 +477,6 @@ export default {
}
}
.message-image {
flex-direction: row-reverse;
}
.message-file {
flex-direction: row-reverse;
}
.chat-action {
flex-direction: row-reverse;

24
im-web/src/components/common/HeadImage.vue

@ -2,7 +2,7 @@
<div class="head-image" @click="showUserInfo($event)" :style="{ cursor: isShowUserInfo ? 'pointer' : null }">
<img class="avatar-image" v-show="url" :src="url" :style="avatarImageStyle" loading="lazy" />
<div class="avatar-text" v-show="!url" :style="avatarTextStyle">
{{ name?.substring(0, 2).toUpperCase() }}</div>
{{ avaterText }}</div>
<div v-show="online" class="online" title="用户当前在线"></div>
<slot></slot>
</div>
@ -66,6 +66,9 @@ export default {
this.$eventBus.$emit("openUserInfo", user, pos);
})
}
},
isChinese(charCode) {
return charCode >= 0x4e00 && charCode <= 0x9fa5;
}
},
computed: {
@ -78,14 +81,21 @@ export default {
avatarTextStyle() {
let w = this.width ? this.width : this.size;
let h = this.height ? this.height : this.size;
return `
width: ${w}px;height:${h}px;
background-color: ${this.name ? this.textColor : '#fff'};
font-size:${w * 0.35}px;
border-radius: ${this.radius};
`
return `width: ${w}px;height:${h}px;
background: linear-gradient(145deg,#ffffff20 25%,#00000060),${this.textColor};
font-size:${w * 0.4}px;
border-radius: ${this.radius};`
},
avaterText() {
if (!this.name) return '';
if (this.isChinese(this.name.charCodeAt(0))) {
return this.name.charAt(0)
} else {
return this.name.charAt(0).toUpperCase() + this.name.charAt(1)
}
},
textColor() {
if (!this.name) return 'fff';
let hash = 0;
for (var i = 0; i < this.name.length; i++) {
hash += this.name.charCodeAt(i);

100
im-web/src/store/chatStore.js

@ -3,6 +3,7 @@ import { MESSAGE_TYPE, MESSAGE_STATUS } from "../api/enums.js"
import useFriendStore from './friendStore.js';
import useGroupStore from './groupStore.js';
import useUserStore from './userStore.js';
import useConfigStore from './configStore.js';
import localForage from 'localforage';
/**
@ -40,14 +41,6 @@ export default defineStore('chatStore', {
this.privateMsgMaxId = chatsData.privateMsgMaxId || 0;
this.groupMsgMaxId = chatsData.groupMsgMaxId || 0;
cacheChats = chatsData.chats || [];
// 防止图片一直处在加载中状态
cacheChats.forEach((chat) => {
chat.messages.forEach((msg) => {
if (msg.loadStatus == "loading") {
msg.loadStatus = "fail"
}
})
})
},
openChat(chatInfo) {
let chats = this.findChats()
@ -73,6 +66,7 @@ export default defineStore('chatStore', {
lastSendTime: new Date().getTime(),
unreadCount: 0,
hotMinIdx: 0,
readedMessageIdx: 0,
messages: [],
atMe: false,
atAll: false,
@ -103,15 +97,17 @@ export default defineStore('chatStore', {
readedMessage(pos) {
let chat = this.findChatByFriend(pos.friendId);
if (!chat) return;
chat.messages.forEach((m) => {
for (let idx = chat.readedMessageIdx; idx < chat.messages.length; idx++) {
let m = chat.messages[idx];
if (m.id && m.selfSend && m.status < MESSAGE_STATUS.RECALL) {
// pos.maxId为空表示整个会话已读
if (!pos.maxId || m.id <= pos.maxId) {
m.status = MESSAGE_STATUS.READED
chat.readedMessageIdx = idx;
chat.stored = false;
}
}
})
}
this.saveToStorage();
},
removeChat(idx) {
@ -159,14 +155,16 @@ export default defineStore('chatStore', {
}
},
insertMessage(msgInfo, chatInfo) {
let time = new Date().getTime()
let type = chatInfo.type;
// 记录消息的最大id
if (msgInfo.id && type == "PRIVATE" && msgInfo.id > this.privateMsgMaxId) {
this.privateMsgMaxId = msgInfo.id;
}
if (msgInfo.id && type == "GROUP" && msgInfo.id > this.groupMsgMaxId) {
this.groupMsgMaxId = msgInfo.id;
// 完成初始化之前不能修改消息最大id,否则可能导致拉不到离线消息
if (useConfigStore().appInit) {
// 记录消息的最大id
if (msgInfo.id && type == "PRIVATE" && msgInfo.id > this.privateMsgMaxId) {
this.privateMsgMaxId = msgInfo.id;
}
if (msgInfo.id && type == "GROUP" && msgInfo.id > this.groupMsgMaxId) {
this.groupMsgMaxId = msgInfo.id;
}
}
// 如果是已存在消息,则覆盖旧的消息数据
let chat = this.findChat(chatInfo);
@ -235,7 +233,6 @@ export default defineStore('chatStore', {
chat.messages.splice(insertPos, 0, msgInfo);
chat.stored = false;
this.saveToStorage();
console.log("耗时:", new Date().getTime() - time)
},
updateMessage(msgInfo, chatInfo) {
// 获取对方id或群id
@ -249,24 +246,33 @@ export default defineStore('chatStore', {
}
},
deleteMessage(msgInfo, chatInfo) {
let chat = this.findChat(chatInfo);
let isColdMessage = false;
let chat = this.findChat(chatInfo);
let delIdx = -1;
for (let idx in chat.messages) {
// 已经发送成功的,根据id删除
if (chat.messages[idx].id && chat.messages[idx].id == msgInfo.id) {
chat.messages.splice(idx, 1);
isColdMessage = idx < chat.hotMinIdx;
delIdx = idx;
break;
}
// 正在发送中的消息可能没有id,只有临时id
if (chat.messages[idx].tmpId && chat.messages[idx].tmpId == msgInfo.tmpId) {
chat.messages.splice(idx, 1);
isColdMessage = idx < chat.hotMinIdx;
delIdx = idx;
break;
}
}
chat.stored = false;
this.saveToStorage(isColdMessage);
if (delIdx >= 0) {
chat.messages.splice(delIdx, 1);
if (delIdx < chat.hotMinIdx) {
isColdMessage = true;
chat.hotMinIdx--;
}
if (delIdx < chat.readedMessageIdx) {
chat.readedMessageIdx--;
}
chat.stored = false;
this.saveToStorage(isColdMessage);
}
},
recallMessage(msgInfo, chatInfo) {
let chat = this.findChat(chatInfo);
@ -372,6 +378,16 @@ export default defineStore('chatStore', {
})
// 排序
cacheChats.sort((chat1, chat2) => chat2.lastSendTime - chat1.lastSendTime);
/**
* 由于部分浏览器不支持websql或indexdb只能使用localstorage而localstorage大小只有10m,可能会导致缓存空间溢出
* 解决办法:如果是使用localstorage的浏览器每个会话只保留1000条消息防止溢出
*/
cacheChats.forEach(chat => {
if (localForage.driver().includes("localStorage") && chat.messages.length > 1000) {
let idx = chat.messages.length - 1000;
chat.messages = chat.messages.slice(idx);
}
})
// 记录热数据索引位置
cacheChats.forEach(chat => chat.hotMinIdx = chat.messages.length);
// 将消息一次性装载回来
@ -386,43 +402,53 @@ export default defineStore('chatStore', {
if (this.isLoading()) {
return;
}
let userStore = useUserStore();
const userStore = useUserStore();
let userId = userStore.userInfo.id;
let key = "chats-" + userId;
let chatKeys = [];
// 按会话为单位存储,
this.chats.forEach((chat) => {
const promises = [];
// 按会话为单位存储
for (let idx in this.chats) {
let chat = this.chats[idx];
// 只存储有改动的会话
let chatKey = `${key}-${chat.type}-${chat.targetId}`
if (!chat.stored) {
if (chat.delete) {
localForage.removeItem(chatKey);
let hotKey = chatKey + '-hot';
promises.push(localForage.removeItem(chatKey))
promises.push(localForage.removeItem(hotKey))
} else {
// 存储冷数据
if (withColdMessage) {
let coldChat = Object.assign({}, chat);
coldChat.messages = chat.messages.slice(0, chat.hotMinIdx);
localForage.setItem(chatKey, coldChat)
promises.push(localForage.setItem(chatKey, coldChat))
}
// 存储热消息
let hotKey = chatKey + '-hot';
let hotChat = Object.assign({}, chat);
hotChat.messages = chat.messages.slice(chat.hotMinIdx)
localForage.setItem(hotKey, hotChat)
promises.push(localForage.setItem(hotKey, hotChat))
}
chat.stored = true;
}
if (!chat.delete) {
chatKeys.push(chatKey);
}
})
}
// 会话核心信息
let chatsData = {
privateMsgMaxId: this.privateMsgMaxId,
groupMsgMaxId: this.groupMsgMaxId,
systemMsgMaxSeqNo: this.systemMsgMaxSeqNo,
chatKeys: chatKeys
}
localForage.setItem(key, chatsData)
Promise.all(promises).then(() => {
localForage.setItem(key, chatsData)
}).catch(() => {
console.log("本地消息缓存存储失败")
})
// 清理已删除的会话
this.chats = this.chats.filter(chat => !chat.delete)
},
@ -454,11 +480,19 @@ export default defineStore('chatStore', {
}
let coldChat = chats[i];
let hotChat = chats[i + 1];
// 防止消息一直处在发送中状态
hotChat && hotChat.messages.forEach(msg => {
if (msg.status == MESSAGE_STATUS.SENDING) {
msg.status = MESSAGE_STATUS.FAILED
}
})
// 冷热消息合并
let chat = Object.assign({}, coldChat, hotChat);
if (hotChat && coldChat) {
chat.messages = coldChat.messages.concat(hotChat.messages)
}
// 历史版本没有readedMessageIdx字段,做兼容一下
chat.readedMessageIdx = chat.readedMessageIdx || 0;
chatsData.chats.push(chat);
}
this.initChats(chatsData);

4
im-web/src/store/configStore.js

@ -4,6 +4,7 @@ import http from '../api/httpRequest.js'
export default defineStore('configStore', {
state: () => {
return {
appInit: false, // 应用是否完成初始化
webrtc: {}
}
},
@ -11,6 +12,9 @@ export default defineStore('configStore', {
setConfig(config) {
this.webrtc = config.webrtc;
},
setAppInit(appInit) {
this.appInit = appInit;
},
loadConfig() {
return new Promise((resolve, reject) => {
http({

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

@ -99,6 +99,7 @@ export default {
//
this.$refs.fullImage.open(url);
});
this.configStore.setAppInit(false)
this.loadStore().then(() => {
// ws
this.$wsApi.connect(process.env.VUE_APP_WS_URL, sessionStorage.getItem("accessToken"));
@ -109,6 +110,7 @@ export default {
// 线
this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId);
this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId);
this.configStore.setAppInit(true);
}
});
this.$wsApi.onMessage((cmd, msgInfo) => {
@ -138,6 +140,7 @@ export default {
if (e.code != 3000) {
// 线
this.reconnectWs();
this.configStore.setAppInit(false)
}
});
}).catch((e) => {
@ -169,6 +172,7 @@ export default {
// 线
this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId);
this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId);
this.configStore.setAppInit(true)
this.$message.success("重新连接成功");
}).catch(() => {
this.$message.error("初始化失败");
@ -387,6 +391,7 @@ export default {
},
onExit() {
this.unloadStore();
this.configStore.setAppInit(false);
this.$wsApi.close(3000);
sessionStorage.removeItem("accessToken");
location.href = "/";
@ -396,7 +401,7 @@ export default {
if (new Date().getTime() - this.lastPlayAudioTime > 1000) {
this.lastPlayAudioTime = new Date().getTime();
let audio = new Audio();
let url = require(`@/assets/audio/tip.wav`);
let url = require(`@/assets/audio/tip.mp3`);
audio.src = url;
audio.play();
}

16
im-web/src/view/Login.vue

@ -1,22 +1,22 @@
<template>
<div class="login-view">
<div class="content">
<el-form class="form" :model="loginForm" status-icon :rules="rules" ref="loginForm" label-width="60px"
<el-form class="form" :model="loginForm" status-icon :rules="rules" ref="loginForm"
@keyup.enter.native="submitForm('loginForm')">
<div class="title">
<img class="logo" src="../../public/logo.png" />
<div>登录盒子IM</div>
</div>
<el-form-item label="终端" prop="userName" v-show="false">
<el-form-item prop="terminal" v-show="false">
<el-input type="terminal" v-model="loginForm.terminal" autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="用户名" prop="userName">
<el-input type="userName" v-model="loginForm.userName" autocomplete="off"
placeholder="用户名"></el-input>
<el-form-item prop="userName">
<el-input type="userName" v-model="loginForm.userName" autocomplete="off" placeholder="用户名"
prefix-icon="el-icon-user"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input type="password" v-model="loginForm.password" autocomplete="off"
placeholder="密码"></el-input>
<el-form-item prop="password">
<el-input type="password" v-model="loginForm.password" autocomplete="off" placeholder="密码"
prefix-icon="el-icon-lock"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('loginForm')">登录</el-button>

Loading…
Cancel
Save