u
This commit is contained in:
1 parent
28678fb4df
commit
a6dbe24dd4
43 files changed
+2285
-206
No files matched your search
+49
-12
@@ -3,19 +3,11 @@ package cn.link56.im.controller;
|
||||
import cn.link56.common.BasePageVO;
|
||||
import cn.link56.common.Result;
|
||||
import cn.link56.common.context.UserContext;
|
||||
import cn.link56.im.model.dto.CreateFriendDTO;
|
||||
import cn.link56.im.model.dto.QueryFriendDTO;
|
||||
import cn.link56.im.model.dto.QueryGroupDTO;
|
||||
import cn.link56.im.model.vo.QueryFriendVO;
|
||||
import cn.link56.im.model.vo.QueryGroupVO;
|
||||
import cn.link56.im.service.ImFriendService;
|
||||
import cn.link56.im.service.ImGroupService;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import cn.link56.im.model.dto.*;
|
||||
import cn.link56.im.model.vo.*;
|
||||
import cn.link56.im.service.*;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
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.annotation.Resource;
|
||||
import java.util.List;
|
||||
@@ -28,6 +20,12 @@ public class ImController {
|
||||
private ImFriendService imFriendService;
|
||||
@Resource
|
||||
private ImGroupService imGroupService;
|
||||
@Resource
|
||||
private ImPrivateMessageService imPrivateMessageService;
|
||||
@Resource
|
||||
private ImGroupUserService imGroupUserService;
|
||||
@Resource
|
||||
private ImGroupMessageService imGroupMessageService;
|
||||
|
||||
@PostMapping("/friend/create")
|
||||
public Result<Void> createFriend(@RequestBody @Validated CreateFriendDTO dto){
|
||||
@@ -52,4 +50,43 @@ public class ImController {
|
||||
BasePageVO<QueryGroupVO> basePageVO = imGroupService.queryGroup(dto);
|
||||
return Result.success(basePageVO);
|
||||
}
|
||||
|
||||
@PostMapping("/friend/message/query")
|
||||
public Result<List<QueryPrivateMessageVO>> queryPrivateMessage(@RequestBody QueryPrivateMessageDTO dto){
|
||||
List<QueryPrivateMessageVO> list = imPrivateMessageService.queryPrivateMessages(dto);
|
||||
return Result.success(list);
|
||||
}
|
||||
|
||||
@PostMapping("/group/message/query")
|
||||
public Result<List<QueryGroupMessageVO>> queryGroupMessage(@RequestBody QueryGroupMessageDTO dto){
|
||||
List<QueryGroupMessageVO> list = imGroupMessageService.queryGroupMessages(dto);
|
||||
return Result.success(list);
|
||||
}
|
||||
|
||||
@PostMapping("/group/user/query")
|
||||
public Result<List<QueryGroupUserVO>> queryGroupUser(@RequestBody QueryGroupUserDTO dto){
|
||||
List<QueryGroupUserVO> list = imGroupUserService.queryGroupUsers(dto);
|
||||
return Result.success(list);
|
||||
}
|
||||
|
||||
@PostMapping("/private/update/unread")
|
||||
public Result<Void> updatePrivateUnread(@RequestBody UpdatePrivateUnreadDTO dto){
|
||||
Long userId = dto.getUserId();
|
||||
Long friendId = dto.getFriendId();
|
||||
|
||||
imFriendService.decreaseUnreadCount(userId,friendId);
|
||||
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@PostMapping("/group/update/unread")
|
||||
public Result<Void> updateGroup(@RequestBody UpdateGroupUnreadDTO dto){
|
||||
Long userId = dto.getUserId();
|
||||
Long groupId = dto.getGroupId();
|
||||
|
||||
imGroupUserService.decreaseUnreadCount(groupId,userId);
|
||||
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,7 +13,8 @@ public enum MessageTypeEnums {
|
||||
OFFLINE(6),
|
||||
GROUP_OFFLINE(7),
|
||||
PRIVATE(8),
|
||||
GROUP(9);
|
||||
GROUP(9),
|
||||
FRIEND_UPDATE(10);
|
||||
|
||||
private final int code;
|
||||
|
||||
|
||||
@@ -2,12 +2,22 @@ package cn.link56.im.handler;
|
||||
|
||||
import cn.link56.auth.token.model.TokenPayload;
|
||||
import cn.link56.auth.token.service.AuthTokenService;
|
||||
import cn.link56.common.utils.TimeUtils;
|
||||
import cn.link56.common.utils.snowflake.idgen.IdGenerator;
|
||||
import cn.link56.company.model.po.CompanyUser;
|
||||
import cn.link56.company.service.CompanyUserService;
|
||||
import cn.link56.im.enums.MessageTypeEnums;
|
||||
import cn.link56.im.message.MessageRequest;
|
||||
import cn.link56.im.message.MessageResponse;
|
||||
import cn.link56.im.model.dto.GroupDTO;
|
||||
import cn.link56.im.model.dto.LoginDTO;
|
||||
import cn.link56.im.model.dto.PrivateDTO;
|
||||
import cn.link56.im.model.po.ImPrivateMessage;
|
||||
import cn.link56.im.model.vo.PrivateMessageVO;
|
||||
import cn.link56.im.model.vo.QueryFriendVO;
|
||||
import cn.link56.im.service.ImFriendService;
|
||||
import cn.link56.im.service.ImGroupUserService;
|
||||
import cn.link56.im.service.ImPrivateMessageService;
|
||||
import cn.link56.im.storage.SessionManager;
|
||||
import cn.link56.im.utils.MessageDataConverter;
|
||||
import cn.link56.im.utils.MessageWriter;
|
||||
@@ -26,6 +36,10 @@ public class MessageHandler {
|
||||
private ImFriendService imFriendService;
|
||||
@Resource
|
||||
private ImGroupUserService imGroupUserService;
|
||||
@Resource
|
||||
private ImPrivateMessageService imPrivateMessageService;
|
||||
@Resource
|
||||
private CompanyUserService companyUserService;
|
||||
|
||||
// 处理消息
|
||||
public void handleMessage(Channel channel, MessageRequest request) {
|
||||
@@ -56,7 +70,6 @@ public class MessageHandler {
|
||||
}
|
||||
|
||||
private void handleLogin(Channel channel, MessageRequest request) {
|
||||
System.out.println("===LOGIN===");
|
||||
LoginDTO dto = MessageDataConverter.convert(request.getData(), LoginDTO.class);
|
||||
TokenPayload tokenPayload = authTokenService.parsePayload(dto.getToken());
|
||||
|
||||
@@ -77,8 +90,6 @@ public class MessageHandler {
|
||||
}
|
||||
|
||||
private void handleLogout(Channel channel, MessageRequest request) {
|
||||
System.out.println("===LOGOUT===");
|
||||
|
||||
Long userId = SessionManager.getUserId(channel);
|
||||
if (ObjectUtils.isNotEmpty(userId)){
|
||||
handleOffline(channel , userId);
|
||||
@@ -110,9 +121,78 @@ public class MessageHandler {
|
||||
|
||||
// 私聊逻辑
|
||||
private void handlePrivate(Channel channel, MessageRequest request) {
|
||||
Long from = request.getFrom();
|
||||
Long to = request.getTo();
|
||||
|
||||
PrivateDTO dto = MessageDataConverter.convert(request.getData(), PrivateDTO.class);
|
||||
String content = dto.getContent();
|
||||
Integer type = dto.getType();
|
||||
String tempId = dto.getTempId();
|
||||
|
||||
// 好友关系建立
|
||||
imFriendService.createFriend(to,from);
|
||||
|
||||
// 通知建立了好友关系
|
||||
if (SessionManager.isOnline(to)) {
|
||||
CompanyUser companyUser = companyUserService.getById(from);
|
||||
if (ObjectUtils.isNotEmpty(companyUser)){
|
||||
QueryFriendVO queryFriendVO = new QueryFriendVO();
|
||||
queryFriendVO.setFriendId(from);
|
||||
queryFriendVO.setFriendName(companyUser.getName());
|
||||
queryFriendVO.setFriendAvatar(companyUser.getAvatar());
|
||||
queryFriendVO.setIsOnline(true);
|
||||
|
||||
queryFriendVO.setUnreadCount(0);
|
||||
|
||||
MessageWriter.writeToUser(to,MessageResponse.success(MessageTypeEnums.FRIEND_UPDATE,from).setData(queryFriendVO));
|
||||
}
|
||||
}
|
||||
|
||||
// 保存
|
||||
Long privateMessageID = IdGenerator.nextId();
|
||||
Long sendTime = TimeUtils.getCurrentTimestampMillis();
|
||||
|
||||
ImPrivateMessage imPrivateMessage = new ImPrivateMessage();
|
||||
imPrivateMessage.setId(privateMessageID);
|
||||
imPrivateMessage.setContent(content);
|
||||
imPrivateMessage.setType(type);
|
||||
imPrivateMessage.setSenderId(from);
|
||||
imPrivateMessage.setReceiverId(to);
|
||||
imPrivateMessage.setSendTime(sendTime);
|
||||
|
||||
imPrivateMessageService.save(imPrivateMessage);
|
||||
|
||||
// 更新状态表
|
||||
imFriendService.updateLastMessageId(from,to,privateMessageID);
|
||||
imFriendService.increaseUnreadCount(to,from,privateMessageID);
|
||||
|
||||
// 构建回复消息
|
||||
PrivateMessageVO privateMessageVO = new PrivateMessageVO();
|
||||
// 通知发送人
|
||||
privateMessageVO.setTempId(tempId);
|
||||
privateMessageVO.setId(privateMessageID);
|
||||
privateMessageVO.setSenderId(from);
|
||||
privateMessageVO.setReceiverId(to);
|
||||
privateMessageVO.setContent(content);
|
||||
privateMessageVO.setType(type);
|
||||
privateMessageVO.setSendTime(sendTime);
|
||||
|
||||
MessageWriter.writeToUser(from,MessageResponse.success(MessageTypeEnums.PRIVATE,from).setData(privateMessageVO));
|
||||
|
||||
// 通知接受人
|
||||
if (SessionManager.isOnline(to)){
|
||||
MessageWriter.writeToUser(to,MessageResponse.success(MessageTypeEnums.PRIVATE,from).setData(privateMessageVO));
|
||||
}
|
||||
}
|
||||
|
||||
// 群聊逻辑
|
||||
private void handleGroup(Channel channel, MessageRequest request) {
|
||||
Long from = request.getFrom();
|
||||
Long to = request.getTo();
|
||||
|
||||
GroupDTO dto = MessageDataConverter.convert(request.getData(), GroupDTO.class);
|
||||
String content = dto.getContent();
|
||||
Integer type = dto.getType();
|
||||
String tempId = dto.getTempId();
|
||||
}
|
||||
}
|
||||
@@ -22,5 +22,16 @@ public interface ImFriendMapper extends BaseMapper<ImFriend> {
|
||||
Long selectFriendTotal(
|
||||
@Param("userId") Long userId,
|
||||
@Param("name") String name
|
||||
);
|
||||
);
|
||||
|
||||
void incrementUnreadAndUpdateLastMessage(
|
||||
@Param("userId") Long userId,
|
||||
@Param("friendId") Long friendId,
|
||||
@Param("lastMessageId") Long lastMessageId
|
||||
);
|
||||
|
||||
void decreaseUnreadCount(
|
||||
@Param("userId") Long userId,
|
||||
@Param("friendId") Long friendId
|
||||
);
|
||||
}
|
||||
+10
@@ -1,9 +1,19 @@
|
||||
package cn.link56.im.mapper;
|
||||
|
||||
import cn.link56.im.model.po.ImGroupMessage;
|
||||
import cn.link56.im.model.vo.QueryGroupMessageVO;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ImGroupMessageMapper extends BaseMapper<ImGroupMessage> {
|
||||
List<QueryGroupMessageVO> selectGroupMessages(
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("pageSize") Integer pageSize,
|
||||
@Param("beforeId") Long beforeId,
|
||||
@Param("beforeTimestamp") Long beforeTimestamp
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
package cn.link56.im.mapper;
|
||||
|
||||
import cn.link56.im.model.po.ImGroupUser;
|
||||
import cn.link56.im.model.vo.QueryGroupUserVO;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ImGroupUserMapper extends BaseMapper<ImGroupUser> {
|
||||
List<QueryGroupUserVO> selectGroupUsers(
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("userName") String userName
|
||||
);
|
||||
}
|
||||
+11
@@ -1,9 +1,20 @@
|
||||
package cn.link56.im.mapper;
|
||||
|
||||
import cn.link56.im.model.po.ImPrivateMessage;
|
||||
import cn.link56.im.model.vo.QueryPrivateMessageVO;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ImPrivateMessageMapper extends BaseMapper<ImPrivateMessage> {
|
||||
List<QueryPrivateMessageVO> selectPrivateMessages(
|
||||
@Param("userId") Long userId,
|
||||
@Param("friendId") Long friendId,
|
||||
@Param("beforeId") Long beforeId,
|
||||
@Param("beforeTimestamp") Long beforeTimestamp,
|
||||
@Param("pageSize") Integer pageSize
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import lombok.ToString;
|
||||
public class MessageRequest {
|
||||
private Integer type;
|
||||
private Long from;
|
||||
private Object to;
|
||||
private Long to;
|
||||
private Long timestamp;
|
||||
private Object data;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.link56.im.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class GroupDTO {
|
||||
private String content;
|
||||
private Integer type;
|
||||
private String tempId;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.link56.im.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class PrivateDTO {
|
||||
private String content;
|
||||
private Integer type;
|
||||
private String tempId;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package cn.link56.im.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class QueryGroupMessageDTO {
|
||||
private Long groupId;
|
||||
private Long beforeTimestamp;
|
||||
private Long beforeId;
|
||||
private Integer pageSize;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package cn.link56.im.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class QueryGroupUserDTO {
|
||||
private Long groupId;
|
||||
private String userName;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package cn.link56.im.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class QueryPrivateMessageDTO {
|
||||
private Long userId;
|
||||
private Long friendId;
|
||||
private Long beforeTimestamp;
|
||||
private Long beforeId;
|
||||
private Integer pageSize;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package cn.link56.im.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class UpdateGroupUnreadDTO {
|
||||
private Long groupId;
|
||||
private Long userId;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package cn.link56.im.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class UpdatePrivateUnreadDTO {
|
||||
private Long userId;
|
||||
private Long friendId;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.link56.im.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class PrivateMessageVO extends QueryPrivateMessageVO {
|
||||
private String tempId;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public class QueryFriendVO {
|
||||
private Integer unreadCount;
|
||||
|
||||
private Long lastMessageId;
|
||||
private Long laseMessageTime;
|
||||
private Long lastMessageTime;
|
||||
private Integer lastMessageType;
|
||||
private String lastMessageContent;
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package cn.link56.im.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class QueryGroupMessageVO {
|
||||
private Long id;
|
||||
private Long groupId;
|
||||
|
||||
private Long senderId;
|
||||
private String senderAvatar;
|
||||
private String senderName;
|
||||
|
||||
private String content;
|
||||
private Integer type;
|
||||
|
||||
private Long sendTime;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cn.link56.im.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class QueryGroupUserVO {
|
||||
private Long groupId;
|
||||
private Long userId;
|
||||
private String userAvatar;
|
||||
private String userName;
|
||||
private Boolean isOnline;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public class QueryGroupVO {
|
||||
private Integer unreadCount;
|
||||
|
||||
private Long lastMessageId;
|
||||
private Long laseMessageTime;
|
||||
private Long lastMessageTime;
|
||||
private Integer lastMessageType;
|
||||
private String lastMessageContent;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package cn.link56.im.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class QueryPrivateMessageVO {
|
||||
private Long id;
|
||||
|
||||
private Long senderId;
|
||||
private Long receiverId;
|
||||
|
||||
private String content;
|
||||
private Integer type;
|
||||
|
||||
private Long sendTime;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import cn.link56.im.model.po.ImFriend;
|
||||
import cn.link56.im.model.vo.QueryFriendVO;
|
||||
import cn.link56.im.storage.SessionManager;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -33,7 +34,6 @@ public class ImFriendService extends ServiceImpl<ImFriendMapper, ImFriend> {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 不存在则插入
|
||||
ImFriend imFriend = new ImFriend();
|
||||
|
||||
@@ -87,4 +87,20 @@ public class ImFriendService extends ServiceImpl<ImFriendMapper, ImFriend> {
|
||||
}
|
||||
|
||||
|
||||
public void updateLastMessageId(Long userId,Long friendId,Long lastMessageId){
|
||||
LambdaUpdateWrapper<ImFriend> updateWrapper = new LambdaUpdateWrapper<>();
|
||||
updateWrapper.eq(ImFriend::getUserId,userId)
|
||||
.eq(ImFriend::getFriendId,friendId)
|
||||
.set(ImFriend::getLastMessageId,lastMessageId);
|
||||
|
||||
imFriendMapper.update(updateWrapper);
|
||||
}
|
||||
|
||||
public void increaseUnreadCount(Long userId,Long friendId,Long lastMessageId){
|
||||
imFriendMapper.incrementUnreadAndUpdateLastMessage(userId, friendId, lastMessageId);
|
||||
}
|
||||
|
||||
public void decreaseUnreadCount(Long userId,Long friendId){
|
||||
imFriendMapper.decreaseUnreadCount(userId, friendId);
|
||||
}
|
||||
}
|
||||
+16
@@ -1,11 +1,27 @@
|
||||
package cn.link56.im.service;
|
||||
|
||||
import cn.link56.im.mapper.ImGroupMessageMapper;
|
||||
import cn.link56.im.model.dto.QueryGroupMessageDTO;
|
||||
import cn.link56.im.model.po.ImGroupMessage;
|
||||
import cn.link56.im.model.vo.QueryGroupMessageVO;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ImGroupMessageService extends ServiceImpl<ImGroupMessageMapper, ImGroupMessage> {
|
||||
@Resource
|
||||
private ImGroupMessageMapper imGroupMessageMapper;
|
||||
|
||||
public List<QueryGroupMessageVO> queryGroupMessages(QueryGroupMessageDTO dto) {
|
||||
Long groupId = dto.getGroupId();
|
||||
Integer pageSize = dto.getPageSize();
|
||||
|
||||
Long beforeId = dto.getBeforeId();
|
||||
Long beforeTimestamp = dto.getBeforeTimestamp();
|
||||
|
||||
return imGroupMessageMapper.selectGroupMessages(groupId,pageSize,beforeId,beforeTimestamp);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package cn.link56.im.service;
|
||||
|
||||
import cn.link56.im.mapper.ImGroupUserMapper;
|
||||
import cn.link56.im.model.dto.QueryGroupUserDTO;
|
||||
import cn.link56.im.model.po.ImGroupUser;
|
||||
import cn.link56.im.model.vo.QueryGroupUserVO;
|
||||
import cn.link56.im.storage.SessionManager;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -44,4 +47,27 @@ public class ImGroupUserService extends ServiceImpl<ImGroupUserMapper, ImGroupUs
|
||||
.distinct() // 去重
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public void decreaseUnreadCount(Long groupId,Long userId){
|
||||
LambdaUpdateWrapper<ImGroupUser> updateWrapper = new LambdaUpdateWrapper<>();
|
||||
|
||||
updateWrapper.eq(ImGroupUser::getGroupId,groupId)
|
||||
.eq(ImGroupUser::getUserId,userId)
|
||||
.set(ImGroupUser::getUnreadCount,0);
|
||||
|
||||
imGroupUserMapper.update(updateWrapper);
|
||||
}
|
||||
|
||||
public List<QueryGroupUserVO> queryGroupUsers(QueryGroupUserDTO dto) {
|
||||
Long groupId = dto.getGroupId();
|
||||
String userName = dto.getUserName();
|
||||
|
||||
List<QueryGroupUserVO> queryGroupUserVOS = imGroupUserMapper.selectGroupUsers(groupId, userName);
|
||||
|
||||
queryGroupUserVOS.forEach(item -> {
|
||||
item.setIsOnline(SessionManager.isOnline(item.getUserId()));
|
||||
});
|
||||
|
||||
return queryGroupUserVOS;
|
||||
}
|
||||
}
|
||||
+18
@@ -1,10 +1,28 @@
|
||||
package cn.link56.im.service;
|
||||
|
||||
import cn.link56.im.mapper.ImPrivateMessageMapper;
|
||||
import cn.link56.im.model.dto.QueryPrivateMessageDTO;
|
||||
import cn.link56.im.model.po.ImPrivateMessage;
|
||||
import cn.link56.im.model.vo.QueryPrivateMessageVO;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ImPrivateMessageService extends ServiceImpl<ImPrivateMessageMapper, ImPrivateMessage> {
|
||||
@Resource
|
||||
private ImPrivateMessageMapper imPrivateMessageMapper;
|
||||
|
||||
public List<QueryPrivateMessageVO> queryPrivateMessages(QueryPrivateMessageDTO dto) {
|
||||
Long userId = dto.getUserId();
|
||||
Long friendId = dto.getFriendId();
|
||||
Long beforeId = dto.getBeforeId();
|
||||
Long beforeTimestamp = dto.getBeforeTimestamp();
|
||||
|
||||
Integer pageSize = dto.getPageSize();
|
||||
|
||||
return imPrivateMessageMapper.selectPrivateMessages(userId,friendId,beforeId,beforeTimestamp,pageSize);
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,21 @@
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<update id="incrementUnreadAndUpdateLastMessage">
|
||||
UPDATE b_im_friend
|
||||
SET
|
||||
b_last_message_id = #{lastMessageId},
|
||||
b_unread_count = b_unread_count + 1,
|
||||
b_update_time = GETDATE()
|
||||
WHERE b_user_id = #{userId} and b_friend_id = #{friendId}
|
||||
</update>
|
||||
|
||||
<update id="decreaseUnreadCount">
|
||||
UPDATE b_im_friend
|
||||
SET
|
||||
b_unread_count = 0,
|
||||
b_update_time = GETDATE()
|
||||
WHERE b_user_id = #{userId} and b_friend_id = #{friendId}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
+16
-18
@@ -8,28 +8,25 @@
|
||||
<select id="selectGroup" resultType="cn.link56.im.model.vo.QueryGroupVO">
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
g.b_id AS groupId,
|
||||
g.b_name AS groupName,
|
||||
g.b_avatar AS groupAvatar,
|
||||
gu.b_unread_count AS unreadCount,
|
||||
SELECT
|
||||
g.b_id AS groupId,
|
||||
g.b_name AS groupName,
|
||||
g.b_avatar AS groupAvatar,
|
||||
gu.b_unread_count AS unreadCount,
|
||||
|
||||
m.b_id AS lastMessageId,
|
||||
m.b_send_time AS lastMessageTime,
|
||||
m.b_type AS lastMessageType,
|
||||
m.b_content AS lastMessageContent,
|
||||
m.b_id AS lastMessageId,
|
||||
m.b_send_time AS lastMessageTime,
|
||||
m.b_type AS lastMessageType,
|
||||
m.b_content AS lastMessageContent,
|
||||
|
||||
u.b_id AS lastMessageSenderId,
|
||||
u.b_name AS lastMessageSenderName,
|
||||
u.b_avatar AS lastMessageSenderAvatar,
|
||||
u.b_id AS lastMessageSenderId,
|
||||
u.b_name AS lastMessageSenderName,
|
||||
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY
|
||||
<if test="id != null and id != 0">
|
||||
CASE WHEN g.b_id = #{id} THEN 0 ELSE 1 END,
|
||||
</if>
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY
|
||||
CASE WHEN #{id} IS NOT NULL AND #{id} != 0 AND g.b_id = #{id} THEN 0 ELSE 1 END,
|
||||
m.b_send_time DESC
|
||||
) AS rn
|
||||
) AS rn
|
||||
FROM b_im_group g
|
||||
INNER JOIN b_im_group_user gu ON g.b_id = gu.b_group_id
|
||||
LEFT JOIN b_im_group_message m ON gu.b_last_message_id = m.b_id
|
||||
@@ -43,6 +40,7 @@
|
||||
</select>
|
||||
|
||||
|
||||
|
||||
<select id="selectGroupTotal" resultType="java.lang.Long">
|
||||
SELECT COUNT(DISTINCT g.b_id)
|
||||
FROM b_im_group g
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<mapper namespace="cn.link56.im.mapper.ImGroupMessageMapper">
|
||||
|
||||
<select id="selectGroupMessages" resultType="cn.link56.im.model.vo.QueryGroupMessageVO">
|
||||
SELECT TOP (#{pageSize})
|
||||
gm.b_id AS id,
|
||||
gm.b_group_id AS groupId,
|
||||
gm.b_sender_id AS senderId,
|
||||
u.b_name AS senderName,
|
||||
u.b_avatar AS senderAvatar,
|
||||
gm.b_content AS content,
|
||||
gm.b_type AS type,
|
||||
gm.b_send_time AS sendTime
|
||||
FROM b_im_group_message gm
|
||||
LEFT JOIN b_company_user u ON gm.b_sender_id = u.b_id
|
||||
WHERE gm.b_group_id = #{groupId}
|
||||
<if test="beforeTimestamp != null and beforeId != null">
|
||||
AND (
|
||||
gm.b_send_time < #{beforeTimestamp}
|
||||
OR (gm.b_send_time = #{beforeTimestamp} AND gm.b_id < #{beforeId})
|
||||
)
|
||||
</if>
|
||||
ORDER BY gm.b_send_time DESC, gm.b_id DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<mapper namespace="cn.link56.im.mapper.ImGroupUserMapper">
|
||||
|
||||
<select id="selectGroupUsers" resultType="cn.link56.im.model.vo.QueryGroupUserVO" parameterType="map">
|
||||
SELECT
|
||||
gu.b_group_id AS groupId,
|
||||
cu.b_id AS userId,
|
||||
cu.b_avatar AS userAvatar,
|
||||
cu.b_name AS userName
|
||||
FROM
|
||||
b_im_group_user gu
|
||||
INNER JOIN
|
||||
b_company_user cu
|
||||
ON gu.b_user_id = cu.b_id
|
||||
WHERE
|
||||
gu.b_group_id = #{groupId}
|
||||
<if test="userName != null and userName != ''">
|
||||
AND cu.b_name LIKE '%' + #{userName} + '%'
|
||||
</if>
|
||||
</select>
|
||||
|
||||
|
||||
</mapper>
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<mapper namespace="cn.link56.im.mapper.ImPrivateMessageMapper">
|
||||
|
||||
<select id="selectPrivateMessages" resultType="cn.link56.im.model.vo.QueryPrivateMessageVO">
|
||||
SELECT TOP (#{pageSize})
|
||||
b_id AS id,
|
||||
b_sender_id AS senderId,
|
||||
b_receiver_id AS receiverId,
|
||||
b_content AS content,
|
||||
b_type AS type,
|
||||
b_send_time AS sendTime
|
||||
FROM b_im_private_message
|
||||
WHERE
|
||||
(
|
||||
(b_sender_id = #{userId} AND b_receiver_id = #{friendId})
|
||||
OR
|
||||
(b_sender_id = #{friendId} AND b_receiver_id = #{userId})
|
||||
)
|
||||
<if test="beforeTimestamp != null and beforeId != null">
|
||||
AND (
|
||||
b_send_time < #{beforeTimestamp}
|
||||
OR (b_send_time = #{beforeTimestamp} AND b_id < #{beforeId})
|
||||
)
|
||||
</if>
|
||||
ORDER BY b_send_time DESC, b_id DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
+5
-4
@@ -23,11 +23,12 @@
|
||||
<update id="decreaseCommentCount">
|
||||
UPDATE b_post_stat
|
||||
SET
|
||||
b_comment_count = b_comment_count - #{count},
|
||||
b_comment_count = CASE
|
||||
WHEN b_comment_count - #{count} < 0 THEN 0
|
||||
ELSE b_comment_count - #{count}
|
||||
END,
|
||||
b_update_time = GETDATE()
|
||||
WHERE b_post_id = #{postId}
|
||||
AND b_comment_count >= #{count}
|
||||
</update>
|
||||
|
||||
|
||||
</mapper>
|
||||
</mapper>
|
||||
@@ -15,5 +15,6 @@ export default antfu({
|
||||
'node/prefer-global/process': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'no-useless-catch': 'off',
|
||||
'@typescript-eslint/no-use-before-define': 'off',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
|
||||
interface UseScrollWatcherOptions {
|
||||
target: Ref<HTMLElement | null>
|
||||
thresholdPercent?: number
|
||||
delay?: number
|
||||
onReachTop?: () => void
|
||||
onReachBottom?: () => void
|
||||
}
|
||||
|
||||
export function useScrollWatcher({
|
||||
target,
|
||||
thresholdPercent = 0.05,
|
||||
delay = 0,
|
||||
onReachTop,
|
||||
onReachBottom,
|
||||
}: UseScrollWatcherOptions) {
|
||||
if (!target)
|
||||
throw new Error('target 必须传入一个 ref')
|
||||
|
||||
let lastScrollTop = 0
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const topTriggered = { value: false }
|
||||
const bottomTriggered = { value: false }
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
const el = e.target as HTMLElement
|
||||
const scrollTop = el.scrollTop
|
||||
const scrollHeight = el.scrollHeight
|
||||
const clientHeight = el.clientHeight
|
||||
const scrollRange = scrollHeight - clientHeight
|
||||
if (scrollRange <= 0)
|
||||
return
|
||||
|
||||
const isScrollingUp = scrollTop < lastScrollTop
|
||||
const isScrollingDown = scrollTop > lastScrollTop
|
||||
lastScrollTop = scrollTop
|
||||
|
||||
const topThreshold = scrollRange * thresholdPercent
|
||||
const bottomThreshold = scrollRange * thresholdPercent
|
||||
|
||||
if (timeout)
|
||||
clearTimeout(timeout)
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (isScrollingUp && scrollTop <= topThreshold && !topTriggered.value) {
|
||||
onReachTop?.()
|
||||
topTriggered.value = true
|
||||
}
|
||||
else if (scrollTop > topThreshold) {
|
||||
topTriggered.value = false
|
||||
}
|
||||
|
||||
if (isScrollingDown && scrollHeight - scrollTop - clientHeight <= bottomThreshold && !bottomTriggered.value) {
|
||||
onReachBottom?.()
|
||||
bottomTriggered.value = true
|
||||
}
|
||||
else if (scrollHeight - scrollTop - clientHeight > bottomThreshold) {
|
||||
bottomTriggered.value = false
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const bindScroll = (el: HTMLElement | null) => {
|
||||
if (!el)
|
||||
return
|
||||
el.addEventListener('scroll', handleScroll)
|
||||
lastScrollTop = el.scrollTop
|
||||
}
|
||||
|
||||
const unbindScroll = (el: HTMLElement | null) => {
|
||||
if (!el)
|
||||
return
|
||||
el.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
|
||||
// 监听 ref
|
||||
watch(target, (el, oldEl) => {
|
||||
if (oldEl)
|
||||
unbindScroll(oldEl)
|
||||
if (el)
|
||||
bindScroll(el)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (target.value)
|
||||
unbindScroll(target.value)
|
||||
if (timeout)
|
||||
clearTimeout(timeout)
|
||||
})
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (target.value)
|
||||
target.value.scrollTop = 0
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (target.value) {
|
||||
const el = target.value
|
||||
el.scrollTop = el.scrollHeight - el.clientHeight
|
||||
}
|
||||
}
|
||||
|
||||
return { scrollToTop, scrollToBottom }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CreateFriendParams, ImFriend, ImGroup, QueryFriendParams, QueryGroupParams } from '@/types/im'
|
||||
import type { CreateFriendParams, ImFriend, ImGroup, ImGroupMessage, ImGroupUser, ImPrivateMessage, QueryFriendParams, QueryGroupMessageParams, QueryGroupParams, QueryGroupUserParams, QueryPrivateMessageParams, UpdateGroupUnreadParams, UpdatePrivateUnreadParams } from '@/types/im'
|
||||
import { httpPostJson } from '@/request/utils'
|
||||
|
||||
export function createFriendApi(params: CreateFriendParams) {
|
||||
@@ -12,3 +12,22 @@ export function queryFriendApi(params: QueryFriendParams) {
|
||||
export function queryGroupApi(params: QueryGroupParams) {
|
||||
return httpPostJson<BasePageResult<ImGroup>>('/im/group/query', params)
|
||||
}
|
||||
|
||||
export function queryPrivateMessageApi(params: QueryPrivateMessageParams) {
|
||||
return httpPostJson<ImPrivateMessage[]>('/im/friend/message/query', params)
|
||||
}
|
||||
export function queryGroupMessageApi(params: QueryGroupMessageParams) {
|
||||
return httpPostJson<ImGroupMessage[]>('/im/group/message/query', params)
|
||||
}
|
||||
|
||||
export function updateGroupUnreadApi(params: UpdateGroupUnreadParams) {
|
||||
return httpPostJson<void>('/im/group/update/unread', params)
|
||||
}
|
||||
|
||||
export function updatePrivateUnreadApi(params: UpdatePrivateUnreadParams) {
|
||||
return httpPostJson<void>('/im/private/update/unread', params)
|
||||
}
|
||||
|
||||
export function queryGroupUserApi(params: QueryGroupUserParams) {
|
||||
return httpPostJson<ImGroupUser[]>('/im/group/user/query', params)
|
||||
}
|
||||
@@ -190,6 +190,9 @@ function createWebSocket(options?: Partial<UseWebSocketOptions>) {
|
||||
data: data ?? null,
|
||||
}
|
||||
|
||||
console.log('===发送消息===')
|
||||
console.log(msg)
|
||||
|
||||
ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
@@ -248,11 +251,11 @@ function createWebSocket(options?: Partial<UseWebSocketOptions>) {
|
||||
|
||||
/* ================== 业务 API ================== */
|
||||
|
||||
function sendPrivate(toUserId: number, content: string) {
|
||||
send(MessageType.PRIVATE, { content }, toUserId)
|
||||
function sendPrivate(toUserId: number, content: any) {
|
||||
send(MessageType.PRIVATE, content, toUserId)
|
||||
}
|
||||
|
||||
function sendGroup(groupId: number, content: string) {
|
||||
function sendGroup(groupId: number, content: any) {
|
||||
send(MessageType.GROUP, { groupId, content })
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum MessageType {
|
||||
|
||||
PRIVATE = 8,
|
||||
GROUP = 9,
|
||||
FRIEND_UPDATE = 10,
|
||||
}
|
||||
|
||||
export interface MessageRequest {
|
||||
@@ -82,4 +83,72 @@ export interface ImGroup {
|
||||
lastMessageTime: number
|
||||
lastMessageType: number
|
||||
lastMessageContent: string
|
||||
|
||||
lastMessageSenderId: number
|
||||
lastMessageSenderName: string
|
||||
}
|
||||
|
||||
export interface QueryPrivateMessageParams {
|
||||
userId: number
|
||||
friendId: number
|
||||
beforeId: number | null
|
||||
beforeTimestamp: number | null
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ImPrivateMessage {
|
||||
id: number
|
||||
senderId: number
|
||||
receiverId: number
|
||||
content: string
|
||||
type: number
|
||||
sendTime: number
|
||||
|
||||
_showTime?: boolean
|
||||
_tempId?: string
|
||||
_status?: 'sent' | 'sending' | 'failed'
|
||||
}
|
||||
|
||||
export interface QueryGroupMessageParams {
|
||||
groupId: number
|
||||
beforeId: number | null
|
||||
beforeTimestamp: number | null
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ImGroupMessage {
|
||||
id: number
|
||||
groupId: number
|
||||
senderId: number
|
||||
senderAvatar: string
|
||||
senderName: string
|
||||
content: string
|
||||
type: number
|
||||
sendTime: number
|
||||
|
||||
_showTime?: boolean
|
||||
_tempId?: string
|
||||
_status?: 'sent' | 'sending' | 'failed'
|
||||
}
|
||||
|
||||
export interface UpdatePrivateUnreadParams {
|
||||
userId: number
|
||||
friendId: number
|
||||
}
|
||||
export interface UpdateGroupUnreadParams {
|
||||
groupId: number
|
||||
userId: number
|
||||
}
|
||||
|
||||
export interface QueryGroupUserParams {
|
||||
groupId: number
|
||||
userName: string
|
||||
}
|
||||
|
||||
export interface ImGroupUser {
|
||||
groupId: number
|
||||
userId: number
|
||||
userAvatar: string
|
||||
userName: string
|
||||
isOnline: boolean
|
||||
}
|
||||
@@ -164,3 +164,4 @@ export function formatImTime(timestamp: number | null | undefined, mode: 'list'
|
||||
|
||||
return ymd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8)
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import type { ImGroup, ImGroupMessage, ImGroupUser } from '@/types/im'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useScrollWatcher } from '@/hooks/useScrollWatcher'
|
||||
import { queryGroupMessageApi, queryGroupUserApi } from '@/request/api/im'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useWebSocket } from '@/store/websocket'
|
||||
import { formatImTime } from '@/utils/time'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
|
||||
const props = defineProps({
|
||||
group: { type: Object as PropType<ImGroup>, required: true },
|
||||
})
|
||||
|
||||
// websocket
|
||||
const websocket = useWebSocket()
|
||||
|
||||
onMounted(() => {
|
||||
const userId = useUserStore().user.id
|
||||
|
||||
websocket.onMessage(async (msg) => {
|
||||
const { from, type, data } = msg
|
||||
})
|
||||
})
|
||||
|
||||
// 聊天框
|
||||
const chatInputRef = ref<HTMLDivElement | null>(null)
|
||||
const chatInputValue = ref('')
|
||||
const chatSendLoading = ref(false)
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 私聊消息列表
|
||||
const messages = ref<ImGroupMessage[]>([])
|
||||
const pagination = ref({
|
||||
pageSize: 10,
|
||||
loading: false,
|
||||
hasMore: true,
|
||||
})
|
||||
|
||||
const timeMessages = computed(() => processMessagesWithTime(messages.value))
|
||||
|
||||
const groupUsers = ref<ImGroupUser[]>([])
|
||||
|
||||
watch(
|
||||
() => props.group,
|
||||
(newVal) => {
|
||||
if (newVal !== null) {
|
||||
messages.value = []
|
||||
pagination.value = { pageSize: 10, loading: false, hasMore: true }
|
||||
loadMessages('down')
|
||||
|
||||
loadGroupUsers()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// ref
|
||||
const messageListRef = ref()
|
||||
const { scrollToBottom: scrollToLatestMessage } = useScrollWatcher({
|
||||
target: messageListRef,
|
||||
onReachTop: () => {
|
||||
let loading = false
|
||||
let hasMore = false
|
||||
|
||||
loading = pagination.value.loading
|
||||
hasMore = pagination.value.hasMore
|
||||
|
||||
if (loading)
|
||||
return
|
||||
|
||||
if (!hasMore) {
|
||||
ElMessage.warning('没有更多消息了')
|
||||
}
|
||||
|
||||
loadMessages('up')
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理聊天消息,生成 _showTime 字段
|
||||
* 如果与上一条消息相差超过 5 分钟,则显示时间
|
||||
*/
|
||||
function processMessagesWithTime(messages: ImGroupMessage[]) {
|
||||
let lastTimestamp = 0
|
||||
|
||||
return messages.map((msg: ImGroupMessage) => {
|
||||
if (!msg.sendTime)
|
||||
return { ...msg, _showTime: null }
|
||||
|
||||
const ts = msg.sendTime
|
||||
const showTime = !lastTimestamp || ts - lastTimestamp > 5 * 60 * 1000
|
||||
lastTimestamp = ts
|
||||
|
||||
return { ...msg, _showTime: showTime ? formatImTime(ts, 'chat') : null }
|
||||
})
|
||||
}
|
||||
|
||||
// 加载私聊消息
|
||||
async function loadMessages(direction = 'down') {
|
||||
if (direction === 'up' && !pagination.value.hasMore)
|
||||
return
|
||||
|
||||
pagination.value.loading = true
|
||||
|
||||
const userId = userStore.user.id
|
||||
|
||||
let beforeTimestamp: number | null = null
|
||||
let beforeId: number | null = null
|
||||
|
||||
if (direction === 'up' && messages.value.length > 0) {
|
||||
const firstMsg = messages.value[0]
|
||||
beforeTimestamp = firstMsg.sendTime
|
||||
beforeId = firstMsg.id
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await queryGroupMessageApi({
|
||||
groupId: props.group.groupId,
|
||||
pageSize: pagination.value.pageSize,
|
||||
beforeTimestamp,
|
||||
beforeId,
|
||||
})
|
||||
|
||||
if (res.isSuccess) {
|
||||
const records = res.data.reverse()
|
||||
|
||||
if (direction === 'down') {
|
||||
messages.value = records
|
||||
await nextTick()
|
||||
scrollToLatestMessage()
|
||||
}
|
||||
else if (direction === 'up') {
|
||||
if (records.length === 0) {
|
||||
pagination.value.hasMore = false
|
||||
ElMessage.warning('没有更多消息了')
|
||||
}
|
||||
else {
|
||||
messages.value.unshift(...records)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
ElMessage.error(res.message || '加载消息失败,请稍后再试')
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
ElMessage.error('加载消息失败,请稍后再试')
|
||||
}
|
||||
finally {
|
||||
pagination.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroupUsers() {
|
||||
queryGroupUserApi({
|
||||
groupId: props.group.groupId,
|
||||
userName: '',
|
||||
}).then((res) => {
|
||||
if (res.isSuccess) {
|
||||
groupUsers.value = res.data
|
||||
}
|
||||
else {
|
||||
ElMessage.error(res.message || '获取群组成员列表失败,请稍后再试')
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
ElMessage.error('获取群组成员失败,请稍后再试')
|
||||
})
|
||||
}
|
||||
|
||||
// 聊天框输入事件
|
||||
function handleChatInputChange(event: Event) {
|
||||
const target = event.target as HTMLElement
|
||||
// 去除首尾空格
|
||||
chatInputValue.value = (target.textContent || '').trim()
|
||||
}
|
||||
|
||||
// 聊天框回车事件
|
||||
function handleChatInputKeyDown(event: KeyboardEvent) {
|
||||
// 判断是否是回车键
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (!chatSendLoading.value) {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const content = chatInputValue.value?.trim()
|
||||
if (!content) {
|
||||
ElMessage.warning('发送消息不能为空')
|
||||
}
|
||||
|
||||
// const userId = userStore.user.id
|
||||
// const _tempId = generateUUID()
|
||||
|
||||
// const localMessage: ImPrivateMessage = {
|
||||
// id: -Date.now(),
|
||||
// senderId: userId,
|
||||
// receiverId: props.friend.friendId,
|
||||
// content,
|
||||
// type: 0,
|
||||
// sendTime: Date.now(),
|
||||
// _tempId,
|
||||
// _status: 'sending',
|
||||
// }
|
||||
|
||||
// messages.value.push(localMessage)
|
||||
|
||||
// // 清空输入框
|
||||
// if (chatInputRef.value) {
|
||||
// chatInputRef.value.innerHTML = ''
|
||||
// }
|
||||
|
||||
// const privateData: any = {
|
||||
// content,
|
||||
// type: 0,
|
||||
// tempId: _tempId,
|
||||
// }
|
||||
|
||||
// websocket.sendPrivate(props.friend.friendId, privateData)
|
||||
|
||||
// chatInputValue.value = ''
|
||||
// // 等待 DOM 更新再滚动到底部
|
||||
// await nextTick()
|
||||
// scrollToLatestMessage()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-splitter layout="vertical">
|
||||
<el-splitter-panel>
|
||||
<div ref="messageListRef" class="im__chat-main">
|
||||
<div v-if="pagination.loading" class="im__chat-message-loading">
|
||||
<SvgIcon icon="svg-spinners:180-ring" color="#1553f1" />
|
||||
</div>
|
||||
<div
|
||||
v-for="item in timeMessages"
|
||||
:key="item.id"
|
||||
class="im__chat-message-wrapper"
|
||||
>
|
||||
<div v-if="item._showTime" class="im__chat-message-time">
|
||||
{{ item._showTime }}
|
||||
</div>
|
||||
<div
|
||||
class="im__chat-message"
|
||||
:class="userStore.user.id === item.senderId
|
||||
? 'im__chat-message--from'
|
||||
: 'im__chat-message--to'"
|
||||
>
|
||||
<div class="im__chat-message-avatar">
|
||||
<SafeImage :url="item.senderAvatar" :size="40" round />
|
||||
</div>
|
||||
<div class="im__chat-message-body">
|
||||
<div class="im__chat-message-name">
|
||||
{{ item.senderName }}
|
||||
</div>
|
||||
<div class="im__chat-message-content">
|
||||
<div v-if="item._status && item._status !== 'sent'" class="im__chat-message-status">
|
||||
<SvgIcon
|
||||
v-if="item._status === 'sending'"
|
||||
icon="svg-spinners:180-ring"
|
||||
color="#1553f1"
|
||||
/>
|
||||
<SvgIcon
|
||||
v-else-if="item._status === 'failed'"
|
||||
icon="material-symbols:error"
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
<div class="im__chat-message-content--text">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-splitter-panel>
|
||||
<el-splitter-panel :min="100" :max="300" :size="150">
|
||||
<div class="im__chat-footer">
|
||||
<div class="im__chat-toolbar">
|
||||
<SvgIcon icon="proicons:emoji" :size="20" />
|
||||
|
||||
<div class="im__chat-toolbar-right">
|
||||
<SvgIcon icon="solar:history-broken" :size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="im__chat-editor">
|
||||
<div
|
||||
ref="chatInputRef"
|
||||
class="im__chat-editor-input"
|
||||
contenteditable="true"
|
||||
@input="handleChatInputChange"
|
||||
@keydown="handleChatInputKeyDown"
|
||||
/>
|
||||
</div>
|
||||
<div class="im__chat-sendarea">
|
||||
<el-button type="primary" size="small" class="im__chat-send">
|
||||
发送(回车)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-splitter-panel>
|
||||
</el-splitter>
|
||||
<div class="im__chat-group">
|
||||
<div class="im__group-section-header">
|
||||
<!-- <el-input placeholder="请根据名称搜索" :prefix-icon="Search" /> -->
|
||||
<div class="im__group-section-header-title">
|
||||
<SvgIcon icon="bi:people" :size="18" color="#666666" />
|
||||
<span class="im__group-section-header-text">群成员({{ groupUsers.length }})</span>
|
||||
</div>
|
||||
<div class="im__group-section-header-extra">
|
||||
<div class="im__group-section-header--search">
|
||||
<SvgIcon icon="lucide:search" :size="16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="im__group-section-body">
|
||||
<div v-for="item in groupUsers" :key="item.userId" class="im__group-item">
|
||||
<el-badge
|
||||
is-dot
|
||||
:color="item.isOnline ? '#24e68a' : '#bfbfbf'"
|
||||
:badge-style="{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
}"
|
||||
:offset="[-4, 34]"
|
||||
>
|
||||
<SafeImage :url="item.userAvatar" :size="40" round />
|
||||
</el-badge>
|
||||
<div class="im__group-item-info">
|
||||
<div class="im__group-item-name">
|
||||
{{ item.userName }}
|
||||
</div>
|
||||
<!-- <div class="im__group-item-desc">
|
||||
desc
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.im__chat-main {
|
||||
padding: 16px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
background-color: #ffffff;
|
||||
height: 100%;
|
||||
.im__chat-message-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.im__chat-message-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.im__chat-message-time {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.im__chat-message {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
.im__chat-message-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.im__chat-message-body {
|
||||
width: 100%;
|
||||
.im__chat-message-name {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.im__chat-message-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.im__chat-message-content--text {
|
||||
background-color: #ddd;
|
||||
color: #000000;
|
||||
word-break: break-all;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
.im__chat-message--from {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
.im__chat-message-body {
|
||||
margin-right: 8px;
|
||||
text-align: right;
|
||||
.im__chat-message-content--text {
|
||||
background-color: #3c73ff;
|
||||
color: #fff;
|
||||
}
|
||||
.im__chat-message-name {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.im__chat-message-status {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.im__chat-message--to {
|
||||
text-align: left;
|
||||
align-items: flex-start;
|
||||
.im__chat-message-body {
|
||||
margin-left: 8px;
|
||||
.im__chat-message-status {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__chat-group {
|
||||
width: 300px;
|
||||
border-left: 1px solid #e6e9ef;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
.im__group-section-header {
|
||||
height: 46px;
|
||||
border-bottom: 1px solid #e6e9ef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
justify-content: space-between;
|
||||
.im__group-section-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.im__group-section-header-text {
|
||||
margin-left: 4px;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.im__group-section-header--search {
|
||||
cursor: pointer;
|
||||
color: #666666;
|
||||
&:hover {
|
||||
color: #1553f1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__group-section-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
|
||||
.im__group-item {
|
||||
height: 64px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.im__group-item-info {
|
||||
margin-left: 8px;
|
||||
.im__group-item-name {
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
.im__group-item-desc {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__chat-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.im__chat-toolbar {
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
gap: 8px;
|
||||
.im__chat-toolbar-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.im__chat-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
|
||||
.im__chat-editor-input {
|
||||
padding: 4px;
|
||||
flex: 1;
|
||||
// overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
resize: none;
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
.im__chat-editor-input:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #aaa;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.im__chat-sendarea {
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
padding: 0px 16px;
|
||||
.im__chat-send {
|
||||
background-color: #1553f1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,570 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import type { ImFriend, ImPrivateMessage } from '@/types/im'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useScrollWatcher } from '@/hooks/useScrollWatcher'
|
||||
import { queryPrivateMessageApi } from '@/request/api/im'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useWebSocket } from '@/store/websocket'
|
||||
import { MessageType } from '@/types/im'
|
||||
import { formatImTime } from '@/utils/time'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
|
||||
const props = defineProps({
|
||||
friend: { type: Object as PropType<ImFriend>, required: true },
|
||||
})
|
||||
|
||||
// websocket
|
||||
const websocket = useWebSocket()
|
||||
|
||||
onMounted(() => {
|
||||
const userId = useUserStore().user.id
|
||||
const friendId = props.friend.friendId
|
||||
|
||||
websocket.onMessage(async (msg) => {
|
||||
const { from, type, data } = msg
|
||||
// 只处理私聊的
|
||||
if (type === MessageType.PRIVATE) {
|
||||
// 1.自己发的
|
||||
if (userId === from) {
|
||||
messages.value.forEach((item) => {
|
||||
if (item._tempId === data.tempId) {
|
||||
// 更新对象
|
||||
item._status = 'sent'
|
||||
item._tempId = ''
|
||||
|
||||
item.id = data.id
|
||||
}
|
||||
})
|
||||
}
|
||||
// 2.别人发的
|
||||
else {
|
||||
// 2.1 如果当前聊天的正好是这个
|
||||
if (friendId === from) {
|
||||
const newData: ImPrivateMessage = {
|
||||
id: data.id,
|
||||
senderId: data.senderId,
|
||||
receiverId: data.receiverId,
|
||||
content: data.content,
|
||||
sendTime: data.sendTime,
|
||||
type: data.type,
|
||||
}
|
||||
|
||||
messages.value.push(newData)
|
||||
// 更新未读数量,交给外部
|
||||
|
||||
// 等待 DOM 更新再滚动到底部
|
||||
await nextTick()
|
||||
scrollToLatestMessage()
|
||||
}
|
||||
else {
|
||||
// 2.2 如果不是当前这个的话,不做处理
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 聊天框
|
||||
const chatInputRef = ref<HTMLDivElement | null>(null)
|
||||
const chatInputValue = ref('')
|
||||
const chatSendLoading = ref(false)
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 私聊消息列表
|
||||
const messages = ref<ImPrivateMessage[]>([])
|
||||
const pagination = ref({
|
||||
pageSize: 10,
|
||||
loading: false,
|
||||
hasMore: true,
|
||||
})
|
||||
|
||||
const timeMessages = computed(() => processMessagesWithTime(messages.value))
|
||||
|
||||
watch(
|
||||
() => props.friend,
|
||||
(newVal) => {
|
||||
if (newVal !== null) {
|
||||
messages.value = []
|
||||
pagination.value = { pageSize: 10, loading: false, hasMore: true }
|
||||
loadMessages('down')
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// ref
|
||||
const messageListRef = ref()
|
||||
const { scrollToBottom: scrollToLatestMessage } = useScrollWatcher({
|
||||
target: messageListRef,
|
||||
onReachTop: () => {
|
||||
let loading = false
|
||||
let hasMore = false
|
||||
|
||||
loading = pagination.value.loading
|
||||
hasMore = pagination.value.hasMore
|
||||
|
||||
if (loading)
|
||||
return
|
||||
|
||||
if (!hasMore) {
|
||||
ElMessage.warning('没有更多消息了')
|
||||
}
|
||||
|
||||
loadMessages('up')
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理聊天消息,生成 _showTime 字段
|
||||
* 如果与上一条消息相差超过 5 分钟,则显示时间
|
||||
*/
|
||||
function processMessagesWithTime(messages: ImPrivateMessage[]) {
|
||||
let lastTimestamp = 0
|
||||
|
||||
return messages.map((msg: ImPrivateMessage) => {
|
||||
if (!msg.sendTime)
|
||||
return { ...msg, _showTime: null }
|
||||
|
||||
const ts = msg.sendTime
|
||||
const showTime = !lastTimestamp || ts - lastTimestamp > 5 * 60 * 1000
|
||||
lastTimestamp = ts
|
||||
|
||||
return { ...msg, _showTime: showTime ? formatImTime(ts, 'chat') : null }
|
||||
})
|
||||
}
|
||||
|
||||
// 加载私聊消息
|
||||
async function loadMessages(direction = 'down') {
|
||||
if (direction === 'up' && !pagination.value.hasMore)
|
||||
return
|
||||
|
||||
pagination.value.loading = true
|
||||
|
||||
const userId = userStore.user.id
|
||||
|
||||
let beforeTimestamp: number | null = null
|
||||
let beforeId: number | null = null
|
||||
|
||||
if (direction === 'up' && messages.value.length > 0) {
|
||||
const firstMsg = messages.value[0]
|
||||
beforeTimestamp = firstMsg.sendTime
|
||||
beforeId = firstMsg.id
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await queryPrivateMessageApi({
|
||||
userId,
|
||||
friendId: props.friend.friendId,
|
||||
pageSize: pagination.value.pageSize,
|
||||
beforeTimestamp,
|
||||
beforeId,
|
||||
})
|
||||
|
||||
if (res.isSuccess) {
|
||||
const records = res.data.reverse()
|
||||
|
||||
if (direction === 'down') {
|
||||
messages.value = records
|
||||
await nextTick()
|
||||
scrollToLatestMessage()
|
||||
}
|
||||
else if (direction === 'up') {
|
||||
if (records.length === 0) {
|
||||
pagination.value.hasMore = false
|
||||
ElMessage.warning('没有更多消息了')
|
||||
}
|
||||
else {
|
||||
messages.value.unshift(...records)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
ElMessage.error(res.message || '加载消息失败,请稍后再试')
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
ElMessage.error('加载消息失败,请稍后再试')
|
||||
}
|
||||
finally {
|
||||
pagination.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天框输入事件
|
||||
function handleChatInputChange(event: Event) {
|
||||
const target = event.target as HTMLElement
|
||||
// 去除首尾空格
|
||||
chatInputValue.value = (target.textContent || '').trim()
|
||||
}
|
||||
|
||||
// 聊天框回车事件
|
||||
function handleChatInputKeyDown(event: KeyboardEvent) {
|
||||
// 判断是否是回车键
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (!chatSendLoading.value) {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const content = chatInputValue.value?.trim()
|
||||
if (!content) {
|
||||
ElMessage.warning('发送消息不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
const userId = userStore.user.id
|
||||
const _tempId = generateUUID()
|
||||
|
||||
const localMessage: ImPrivateMessage = {
|
||||
id: -Date.now(),
|
||||
senderId: userId,
|
||||
receiverId: props.friend.friendId,
|
||||
content,
|
||||
type: 0,
|
||||
sendTime: Date.now(),
|
||||
_tempId,
|
||||
_status: 'sending',
|
||||
}
|
||||
|
||||
messages.value.push(localMessage)
|
||||
|
||||
// 清空输入框
|
||||
if (chatInputRef.value) {
|
||||
chatInputRef.value.innerHTML = ''
|
||||
}
|
||||
|
||||
const privateData: any = {
|
||||
content,
|
||||
type: 0,
|
||||
tempId: _tempId,
|
||||
}
|
||||
|
||||
websocket.sendPrivate(props.friend.friendId, privateData)
|
||||
|
||||
chatInputValue.value = ''
|
||||
// 等待 DOM 更新再滚动到底部
|
||||
await nextTick()
|
||||
scrollToLatestMessage()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-splitter layout="vertical">
|
||||
<el-splitter-panel>
|
||||
<div ref="messageListRef" class="im__chat-main">
|
||||
<div v-if="pagination.loading" class="im__chat-message-loading">
|
||||
<SvgIcon icon="svg-spinners:180-ring" color="#1553f1" />
|
||||
</div>
|
||||
<div
|
||||
v-for="item in timeMessages"
|
||||
:key="item.id"
|
||||
class="im__chat-message-wrapper"
|
||||
>
|
||||
<div v-if="item._showTime" class="im__chat-message-time">
|
||||
{{ item._showTime }}
|
||||
</div>
|
||||
<div
|
||||
class="im__chat-message"
|
||||
:class="userStore.user.id === item.senderId
|
||||
? 'im__chat-message--from'
|
||||
: 'im__chat-message--to'"
|
||||
>
|
||||
<div class="im__chat-message-avatar">
|
||||
<SafeImage :url=" userStore.user.id === item.senderId ? userStore.user.avatar : props.friend.friendAvatar" :size="40" round />
|
||||
</div>
|
||||
<div class="im__chat-message-body">
|
||||
<div class="im__chat-message-name">
|
||||
{{
|
||||
userStore.user.id === item.senderId ? userStore.user.name : props.friend.friendName
|
||||
}}
|
||||
</div>
|
||||
<div class="im__chat-message-content">
|
||||
<div v-if="item._status && item._status !== 'sent'" class="im__chat-message-status">
|
||||
<SvgIcon
|
||||
v-if="item._status === 'sending'"
|
||||
icon="svg-spinners:180-ring"
|
||||
color="#1553f1"
|
||||
/>
|
||||
<SvgIcon
|
||||
v-else-if="item._status === 'failed'"
|
||||
icon="material-symbols:error"
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
<div class="im__chat-message-content--text">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-splitter-panel>
|
||||
<el-splitter-panel :min="100" :max="300" :size="150">
|
||||
<div class="im__chat-footer">
|
||||
<div class="im__chat-toolbar">
|
||||
<SvgIcon icon="proicons:emoji" :size="20" />
|
||||
|
||||
<div class="im__chat-toolbar-right">
|
||||
<SvgIcon icon="solar:history-broken" :size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="im__chat-editor">
|
||||
<div
|
||||
ref="chatInputRef"
|
||||
class="im__chat-editor-input"
|
||||
contenteditable="true"
|
||||
data-placeholder="请输入内容..."
|
||||
@input="handleChatInputChange"
|
||||
@keydown="handleChatInputKeyDown"
|
||||
/>
|
||||
</div>
|
||||
<div class="im__chat-sendarea">
|
||||
<el-button type="primary" size="small" class="im__chat-send">
|
||||
发送(回车)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-splitter-panel>
|
||||
</el-splitter>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.im__chat-main {
|
||||
padding: 16px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
background-color: #ffffff;
|
||||
height: 100%;
|
||||
.im__chat-message-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.im__chat-message-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.im__chat-message-time {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.im__chat-message {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
.im__chat-message-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.im__chat-message-body {
|
||||
width: 100%;
|
||||
.im__chat-message-name {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.im__chat-message-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.im__chat-message-content--text {
|
||||
background-color: #ddd;
|
||||
color: #000000;
|
||||
word-break: break-all;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
.im__chat-message--from {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
.im__chat-message-body {
|
||||
margin-right: 8px;
|
||||
text-align: right;
|
||||
.im__chat-message-content--text {
|
||||
background-color: #3c73ff;
|
||||
color: #fff;
|
||||
}
|
||||
.im__chat-message-name {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.im__chat-message-status {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.im__chat-message--to {
|
||||
text-align: left;
|
||||
align-items: flex-start;
|
||||
.im__chat-message-body {
|
||||
margin-left: 8px;
|
||||
.im__chat-message-status {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__chat-group {
|
||||
width: 300px;
|
||||
border-left: 1px solid #e6e9ef;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
.im__group-section-header {
|
||||
height: 46px;
|
||||
border-bottom: 1px solid #e6e9ef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
justify-content: space-between;
|
||||
.im__group-section-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.im__group-section-header-text {
|
||||
margin-left: 4px;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.im__group-section-header--search {
|
||||
cursor: pointer;
|
||||
color: #666666;
|
||||
&:hover {
|
||||
color: #1553f1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__group-section-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
|
||||
.im__group-item {
|
||||
height: 64px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.im__group-item-info {
|
||||
margin-left: 8px;
|
||||
.im__group-item-name {
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
.im__group-item-desc {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__chat-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.im__chat-toolbar {
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
gap: 8px;
|
||||
.im__chat-toolbar-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.im__chat-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
|
||||
.im__chat-editor-input {
|
||||
padding: 4px;
|
||||
flex: 1;
|
||||
// overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
resize: none;
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
.im__chat-editor-input:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #aaa;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.im__chat-sendarea {
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
padding: 0px 16px;
|
||||
.im__chat-send {
|
||||
background-color: #1553f1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ImFriend, ImGroup } from '@/types/im'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { queryFriendApi, queryGroupApi } from '@/request/api/im'
|
||||
import { queryFriendApi, queryGroupApi, updateGroupUnreadApi, updatePrivateUnreadApi } from '@/request/api/im'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useWebSocket } from '@/store/websocket'
|
||||
import { MessageType } from '@/types/im'
|
||||
import { formatImTime } from '@/utils/time'
|
||||
|
||||
import GroupChatWindows from './components/GroupChatWindows.vue'
|
||||
import PrivateChatWindow from './components/PrivateChatWindow.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const websocket = useWebSocket()
|
||||
|
||||
// 当前聊天对象
|
||||
const searchName = ref('')
|
||||
|
||||
type ChatType = 'private' | 'group'
|
||||
type ChatType = 'private' | 'group' | ''
|
||||
|
||||
const initialChat = ref<{
|
||||
const selectedChat = ref<{
|
||||
type: ChatType
|
||||
id: number
|
||||
} | null>(null)
|
||||
id: number | null
|
||||
}>({
|
||||
type: 'group',
|
||||
id: null,
|
||||
})
|
||||
|
||||
type CurrentChat
|
||||
= | {
|
||||
type: 'private'
|
||||
id: number
|
||||
data?: ImFriend
|
||||
}
|
||||
| {
|
||||
type: 'group'
|
||||
id: number
|
||||
data?: ImGroup
|
||||
}
|
||||
const currentFriendChat = ref<ImFriend | null>(null)
|
||||
const currentGroupChat = ref<ImGroup | null>(null)
|
||||
|
||||
const currentChat = ref<CurrentChat | null>(null)
|
||||
|
||||
watch(
|
||||
() => initialChat.value,
|
||||
(chat) => {
|
||||
if (!chat)
|
||||
return
|
||||
|
||||
currentChat.value = {
|
||||
type: chat.type,
|
||||
id: chat.id,
|
||||
}
|
||||
|
||||
initialChat.value = null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const isGroupChat = computed(() => selectedChat.value.type === 'group')
|
||||
const isPrivateChat = computed(() => selectedChat.value.type === 'private')
|
||||
|
||||
// 聊天列表
|
||||
interface ImListState<T> {
|
||||
@@ -70,6 +53,7 @@ const friendState = ref<ImListState<ImFriend>>({
|
||||
loading: false,
|
||||
hasMore: true,
|
||||
})
|
||||
|
||||
// 群组列表
|
||||
const groupState = ref<ImListState<ImGroup>>({
|
||||
list: [],
|
||||
@@ -82,18 +66,20 @@ const groupState = ref<ImListState<ImGroup>>({
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
const userId = userStore.user.id
|
||||
|
||||
const type = route.params.type as ChatType | undefined
|
||||
const id = route.params.id ? Number(route.params.id) : null
|
||||
|
||||
if (type && id) {
|
||||
initialChat.value = { type, id }
|
||||
selectedChat.value = { type, id }
|
||||
}
|
||||
|
||||
loadGroups()
|
||||
loadFriends()
|
||||
|
||||
websocket.onMessage((msg) => {
|
||||
const { type, from } = msg
|
||||
const { type, from, data } = msg
|
||||
|
||||
// 更新在线状态
|
||||
const updateOnlineStatus = (isOnline: boolean) => {
|
||||
@@ -108,6 +94,56 @@ onMounted(() => {
|
||||
else if (type === MessageType.OFFLINE) {
|
||||
updateOnlineStatus(false)
|
||||
}
|
||||
else if (type === MessageType.FRIEND_UPDATE) {
|
||||
const updatedFriend = data as ImFriend
|
||||
|
||||
// 检查是否已经存在
|
||||
const exists = friendState.value.list.some(
|
||||
item => item.friendId === updatedFriend.friendId,
|
||||
)
|
||||
|
||||
// 如果不存在,放到第一个
|
||||
if (!exists) {
|
||||
friendState.value.list.unshift(updatedFriend)
|
||||
friendState.value.total += 1
|
||||
}
|
||||
}
|
||||
else if (type === MessageType.PRIVATE) {
|
||||
const chatFriendId = currentFriendChat.value?.friendId
|
||||
const { senderId, receiverId, type: contentType, content, sendTime } = data
|
||||
|
||||
// 遍历好友列表更新最近消息和未读
|
||||
friendState.value.list.forEach((item) => {
|
||||
if (item.friendId === senderId || item.friendId === receiverId) {
|
||||
// 更新最近消息
|
||||
item.lastMessageTime = sendTime
|
||||
item.lastMessageContent = content
|
||||
item.lastMessageType = contentType
|
||||
|
||||
// 如果是别人发的消息并且不是当前聊天好友,增加未读
|
||||
if (userId !== senderId && senderId !== chatFriendId && item.friendId === senderId) {
|
||||
item.unreadCount = (item.unreadCount || 0) + 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 更新当前聊天对象最近消息
|
||||
if (currentFriendChat.value && (senderId === chatFriendId || receiverId === chatFriendId)) {
|
||||
currentFriendChat.value.lastMessageTime = sendTime
|
||||
currentFriendChat.value.lastMessageContent = content
|
||||
currentFriendChat.value.lastMessageType = contentType
|
||||
|
||||
// 重置未读数量,如果是别人发的消息且当前聊天窗口打开
|
||||
if (userId !== senderId) {
|
||||
currentFriendChat.value.unreadCount = 0
|
||||
// 调用接口同步重置
|
||||
updatePrivateUnreadApi({
|
||||
userId,
|
||||
friendId: chatFriendId!,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,7 +159,7 @@ async function loadFriends() {
|
||||
currentPage: state.page,
|
||||
pageSize: state.pageSize,
|
||||
name: searchName.value,
|
||||
id: currentChat.value?.type === 'private' ? currentChat.value.id : null,
|
||||
id: isPrivateChat.value ? (selectedChat.value?.id ?? null) : null,
|
||||
})
|
||||
|
||||
if (!res.isSuccess)
|
||||
@@ -139,6 +175,8 @@ async function loadFriends() {
|
||||
state.total = total
|
||||
state.hasMore = state.list.length < total
|
||||
state.page++
|
||||
|
||||
selectedChat.value.id = null
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
@@ -160,7 +198,7 @@ async function loadGroups() {
|
||||
currentPage: state.page,
|
||||
pageSize: state.pageSize,
|
||||
name: searchName.value,
|
||||
id: currentChat.value?.type === 'group' ? currentChat.value.id : null,
|
||||
id: isGroupChat.value ? (selectedChat.value?.id ?? null) : null,
|
||||
})
|
||||
|
||||
if (!res.isSuccess)
|
||||
@@ -176,6 +214,8 @@ async function loadGroups() {
|
||||
state.total = total
|
||||
state.hasMore = state.list.length < total
|
||||
state.page++
|
||||
|
||||
selectedChat.value.id = null
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
@@ -201,29 +241,54 @@ function handleSearch() {
|
||||
}
|
||||
|
||||
function onPrivateChat(friend: ImFriend) {
|
||||
currentGroupChat.value = null
|
||||
|
||||
const userId = userStore.user.id
|
||||
// 将 friend 的未读置0
|
||||
const hadUnread = friend.unreadCount > 0
|
||||
friend.unreadCount = 0
|
||||
|
||||
// 设置当前聊天
|
||||
currentChat.value = {
|
||||
id: friend.friendId,
|
||||
selectedChat.value = {
|
||||
type: 'private',
|
||||
data: friend,
|
||||
id: friend.friendId,
|
||||
}
|
||||
|
||||
currentFriendChat.value = friend
|
||||
|
||||
if (hadUnread) {
|
||||
updatePrivateUnreadApi({
|
||||
userId,
|
||||
friendId: friend.friendId,
|
||||
})
|
||||
}
|
||||
// 初始化分页
|
||||
}
|
||||
|
||||
function onGroupChat(group: ImGroup) {
|
||||
// 将 friend 的未读置0
|
||||
currentFriendChat.value = null
|
||||
|
||||
const userId = userStore.user.id
|
||||
|
||||
// 将 group 的未读置0
|
||||
const hadUnread = group.unreadCount > 0
|
||||
group.unreadCount = 0
|
||||
|
||||
// 设置当前聊天
|
||||
currentChat.value = {
|
||||
id: group.groupId,
|
||||
selectedChat.value = {
|
||||
type: 'group',
|
||||
data: group,
|
||||
id: group.groupId,
|
||||
}
|
||||
// 初始化分页
|
||||
|
||||
currentGroupChat.value = group
|
||||
if (hadUnread) {
|
||||
updateGroupUnreadApi({
|
||||
groupId: group.groupId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(isGroupChat.value)
|
||||
console.log(currentGroupChat.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -280,7 +345,7 @@ function onGroupChat(group: ImGroup) {
|
||||
v-for="item in friendState.list" :key="item.id" class="im__chat-item"
|
||||
:class="{
|
||||
'im__chat-item--selected':
|
||||
currentChat?.type === 'private' && item.friendId === currentChat.id,
|
||||
isPrivateChat && item.friendId === currentFriendChat?.friendId,
|
||||
}"
|
||||
@click="onPrivateChat(item)"
|
||||
>
|
||||
@@ -324,7 +389,14 @@ function onGroupChat(group: ImGroup) {
|
||||
</div>
|
||||
<div class="im__list-body">
|
||||
<div
|
||||
v-for="item in groupState.list" :key="item.groupId" class="im__chat-item"
|
||||
v-for="item in groupState.list"
|
||||
:key="item.groupId"
|
||||
class="im__chat-item"
|
||||
:class="{
|
||||
'im__chat-item--selected':
|
||||
isGroupChat && item.groupId === currentGroupChat?.groupId,
|
||||
}"
|
||||
@click="onGroupChat(item)"
|
||||
>
|
||||
<SafeImage :size="40" :url="item.groupAvatar" round class="im__chat-item-avatar" />
|
||||
|
||||
@@ -340,9 +412,8 @@ function onGroupChat(group: ImGroup) {
|
||||
|
||||
<div class="im__chat-item-message">
|
||||
<div v-if="item.lastMessageId" class="im__chat-item-last-message">
|
||||
<!-- {{ item. }}: {{ item.lastMessageContent }} -->
|
||||
{{ item.lastMessageSenderName }}:{{ item.lastMessageContent }}
|
||||
</div>
|
||||
|
||||
<el-badge :value="item.unreadCount || 0" :show-zero="false" :max="99" class="im__chat-item-unread-count" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,21 +422,21 @@ function onGroupChat(group: ImGroup) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentChat" class="im__window">
|
||||
<div class="im__window">
|
||||
<div class="im__window-header">
|
||||
<div class="im__window-peer">
|
||||
<div class="im__window-peer-info">
|
||||
<div class="im__window-peer-name">
|
||||
{{ currentChat.type === 'private' ? currentChat.data?.friendName : currentChat.data?.groupName }}
|
||||
{{ isPrivateChat ? currentFriendChat?.friendName : currentGroupChat?.groupName }}
|
||||
</div>
|
||||
<div v-if="currentChat.type === 'private'" class="im__window-peer-desc">
|
||||
<div v-if="isPrivateChat" class="im__window-peer-desc">
|
||||
<div
|
||||
class="im__window-peer-status"
|
||||
:class="currentChat.data?.isOnline ? 'im__window-peer-status--online' : 'im__window-peer-status--offline'"
|
||||
:class="currentFriendChat?.isOnline ? 'im__window-peer-status--online' : 'im__window-peer-status--offline'"
|
||||
>
|
||||
<span>[ </span>
|
||||
<div class="im__window-peer-status-dot" />
|
||||
<span>{{ currentChat.data?.isOnline ? '在线' : '离线' }}</span>
|
||||
<span>{{ currentFriendChat?.isOnline ? '在线' : '离线' }}</span>
|
||||
<span> ]</span>
|
||||
</div>
|
||||
<span>
|
||||
@@ -376,6 +447,11 @@ function onGroupChat(group: ImGroup) {
|
||||
</div>
|
||||
<div class="im__window-actions" />
|
||||
</div>
|
||||
|
||||
<div class="im__window-body">
|
||||
<PrivateChatWindow v-if="isPrivateChat && currentFriendChat != null" :friend="currentFriendChat" />
|
||||
<GroupChatWindows v-if="isGroupChat && currentGroupChat != null" :group="currentGroupChat" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -583,7 +659,7 @@ function onGroupChat(group: ImGroup) {
|
||||
}
|
||||
|
||||
.im__chat-item-name {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.im__chat-item-last-time,
|
||||
@@ -707,7 +783,7 @@ function onGroupChat(group: ImGroup) {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
background-color: #f8fafc;
|
||||
background-color: #ffffff;
|
||||
height: 100%;
|
||||
.im__chat-message-loading {
|
||||
display: flex;
|
||||
@@ -908,6 +984,12 @@ function onGroupChat(group: ImGroup) {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
.im__chat-editor-input:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #aaa;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.im__chat-sendarea {
|
||||
|
||||
@@ -1,118 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>公司信息卡片</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>私聊界面示例</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background-color: #ffffff;
|
||||
/* 页面白色背景 */
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;
|
||||
color: #333;
|
||||
}
|
||||
/* 左侧聊天列表 */
|
||||
.chat-list {
|
||||
width: 280px;
|
||||
background-color: #fff;
|
||||
border-right: 1px solid #ddd;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.company-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
max-width: 520px;
|
||||
background-color: #fff;
|
||||
/* 卡片白色背景 */
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ddd;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.chat-list .user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.company-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.chat-list .user-card:hover,
|
||||
.chat-list .user-card.active {
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
|
||||
.company-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
margin-right: 16px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
.chat-list .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
object-fit: cover;
|
||||
border: 2px solid #4caf50;
|
||||
/* 默认在线状态绿色 */
|
||||
}
|
||||
|
||||
.company-info {
|
||||
flex: 1;
|
||||
}
|
||||
.chat-list .user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
.chat-list .user-name {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.company-meta {
|
||||
margin: 6px 0 10px;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
.chat-list .user-position {
|
||||
margin: 2px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.company-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chat-list .status {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.company-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-list .offline {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background-color: #eef5ff;
|
||||
color: #3a7afe;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 右侧聊天详情 */
|
||||
.chat-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.chat-header .avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
|
||||
.chat-header .info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-header .info .name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-header .info .position {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.chat-header .info .company-info {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-header .info .company-logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
border-radius: 2px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 12px;
|
||||
max-width: 70%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.message.self {
|
||||
background-color: #dcf8c6;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message.other {
|
||||
background-color: #fff;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #ddd;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 20px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background-color: #4caf50;
|
||||
color: #fff;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="company-card">
|
||||
<img class="company-logo" src="https://via.placeholder.com/150" alt="公司 Logo" />
|
||||
|
||||
<div class="company-info">
|
||||
<h3 class="company-name">远航国际物流有限公司</h3>
|
||||
|
||||
<div class="company-meta">
|
||||
成立于 2012 年 · 上海
|
||||
</div>
|
||||
|
||||
<div class="company-desc">
|
||||
专注于海运、空运及多式联运服务,为客户提供高效、稳定、
|
||||
可视化的国际物流解决方案,覆盖全球主要港口。
|
||||
</div>
|
||||
|
||||
<div class="company-tags">
|
||||
<span class="tag">海运</span>
|
||||
<span class="tag">国际物流</span>
|
||||
<span class="tag">货运代理</span>
|
||||
<span class="tag">跨境贸易</span>
|
||||
</div>
|
||||
<body>
|
||||
<!-- 左侧聊天列表 -->
|
||||
<div class="chat-list">
|
||||
<div class="user-card active">
|
||||
<img class="avatar" src="https://i.pravatar.cc/40?img=1" alt="头像" />
|
||||
<div class="user-info">
|
||||
<p class="user-name">张三</p>
|
||||
<p class="user-position">前端工程师</p>
|
||||
</div>
|
||||
<div class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="user-card">
|
||||
<img class="avatar" src="https://i.pravatar.cc/40?img=2" alt="头像" />
|
||||
<div class="user-info">
|
||||
<p class="user-name">李四</p>
|
||||
<p class="user-position">产品经理</p>
|
||||
</div>
|
||||
<div class="status offline"></div>
|
||||
</div>
|
||||
|
||||
<div class="user-card">
|
||||
<img class="avatar" src="https://i.pravatar.cc/40?img=3" alt="头像" />
|
||||
<div class="user-info">
|
||||
<p class="user-name">王五</p>
|
||||
<p class="user-position">设计师</p>
|
||||
</div>
|
||||
<div class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
<!-- 右侧聊天详情 -->
|
||||
<div class="chat-detail">
|
||||
<!-- 聊天头部 -->
|
||||
<div class="chat-header">
|
||||
<img class="avatar" src="https://i.pravatar.cc/50?img=1" alt="头像" />
|
||||
<div class="info">
|
||||
<p class="name">张三</p>
|
||||
<p class="position">前端工程师</p>
|
||||
<div class="company-info">
|
||||
<img class="company-logo" src="https://via.placeholder.com/16" alt="公司logo" />
|
||||
<span>ABC科技公司</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
<!-- 消息区域 -->
|
||||
<div class="messages">
|
||||
<div class="message other">你好!这是张三的消息。</div>
|
||||
<div class="message self">你好,我是自己发的消息。</div>
|
||||
<div class="message other">我们下午开会吗?</div>
|
||||
<div class="message self">好的,下午三点。</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<div class="chat-input">
|
||||
<input type="text" placeholder="输入消息..." />
|
||||
<button>发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user