This commit is contained in:
oneao committed 2025-12-30 17:30:48 +08:00
1 parent 28678fb4df
commit a6dbe24dd4
43 files changed
+2285 -206

No files matched your search

@@ -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
);
}
@@ -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
);
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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;
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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>
@@ -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
@@ -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 &lt; #{beforeTimestamp}
OR (gm.b_send_time = #{beforeTimestamp} AND gm.b_id &lt; #{beforeId})
)
</if>
ORDER BY gm.b_send_time DESC, gm.b_id DESC
</select>
</mapper>
@@ -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>
@@ -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 &lt; #{beforeTimestamp}
OR (b_send_time = #{beforeTimestamp} AND b_id &lt; #{beforeId})
)
</if>
ORDER BY b_send_time DESC, b_id DESC
</select>
</mapper>
@@ -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} &lt; 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 {
+228 -100
View File
@@ -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>