新增IM聊天
This commit is contained in:
@@ -22,6 +22,7 @@ import java.lang.reflect.Array;
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.*;
|
||||
import java.sql.Date;
|
||||
import java.util.*;
|
||||
@@ -1046,7 +1047,7 @@ public class oConvertUtils {
|
||||
BigDecimal bigDecimal = new BigDecimal(uploadCount);
|
||||
//换算成MB
|
||||
BigDecimal divide = bigDecimal.divide(new BigDecimal(1048576));
|
||||
count = divide.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
|
||||
count = divide.setScale(2, RoundingMode.HALF_UP).doubleValue();
|
||||
return count;
|
||||
}
|
||||
return count;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package org.jeecg.modules.im.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.jeecg.modules.im.vo.SysImMessageVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.vo.LoginUser;
|
||||
import org.jeecg.common.util.TokenUtils;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.im.dto.SysImSendMessageDTO;
|
||||
import org.jeecg.modules.im.service.ISysImChatService;
|
||||
import org.jeecg.modules.im.vo.SysImContactVO;
|
||||
import org.jeecg.modules.im.vo.SysImConversationVO;
|
||||
import org.jeecg.modules.im.vo.SysImMessageVO;
|
||||
import org.jeecg.modules.system.entity.SysUser;
|
||||
import org.jeecg.modules.system.service.ISysUserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 租户 IM 聊天
|
||||
*/
|
||||
@Tag(name = "IM聊天")
|
||||
@RestController
|
||||
@RequestMapping("/sys/im/chat")
|
||||
@Slf4j
|
||||
public class SysImChatController {
|
||||
|
||||
@Autowired
|
||||
private ISysImChatService imChatService;
|
||||
@Autowired
|
||||
private ISysUserService sysUserService;
|
||||
|
||||
private LoginUser currentUser() {
|
||||
return (LoginUser) SecurityUtils.getSubject().getPrincipal();
|
||||
}
|
||||
|
||||
private Integer resolveTenantId(LoginUser user, HttpServletRequest request) {
|
||||
Integer tenantId = oConvertUtils.getInt(TokenUtils.getTenantIdByRequest(request), 0);
|
||||
if (tenantId != null && tenantId > 0) {
|
||||
return tenantId;
|
||||
}
|
||||
SysUser sysUser = sysUserService.getById(user.getId());
|
||||
if (sysUser != null && sysUser.getLoginTenantId() != null && sysUser.getLoginTenantId() > 0) {
|
||||
return sysUser.getLoginTenantId();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】会话列表接口-----------
|
||||
@Operation(summary = "IM聊天-会话列表")
|
||||
@RequiresPermissions("sys:im:chat:list")
|
||||
@GetMapping("/conversations")
|
||||
public Result<List<SysImConversationVO>> conversations(HttpServletRequest request) {
|
||||
LoginUser user = currentUser();
|
||||
return Result.OK(imChatService.listConversations(user.getId(), resolveTenantId(user, request)));
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】会话列表接口-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】打开单聊接口-----------
|
||||
@Operation(summary = "IM聊天-打开单聊")
|
||||
@RequiresPermissions("sys:im:chat:list")
|
||||
@PostMapping("/open")
|
||||
public Result<SysImConversationVO> open(@RequestParam(name = "targetUserId") String targetUserId, HttpServletRequest request) {
|
||||
LoginUser user = currentUser();
|
||||
return Result.OK(imChatService.openSingleConversation(user.getId(), resolveTenantId(user, request), user.getOrgCode(), targetUserId));
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】打开单聊接口-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】消息列表接口-----------
|
||||
@Operation(summary = "IM聊天-消息列表")
|
||||
@RequiresPermissions("sys:im:chat:list")
|
||||
@GetMapping("/messages")
|
||||
public Result<IPage<SysImMessageVO>> messages(
|
||||
@RequestParam(name = "conversationId") String conversationId,
|
||||
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize,
|
||||
@RequestParam(name = "startTime", required = false) String startTime,
|
||||
@RequestParam(name = "beforeTime", required = false) String beforeTime) {
|
||||
LoginUser user = currentUser();
|
||||
return Result.OK(imChatService.listMessages(user.getId(), conversationId, pageNo, pageSize, startTime, beforeTime));
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】消息列表接口-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】发送消息接口-----------
|
||||
@Operation(summary = "IM聊天-发送消息")
|
||||
@RequiresPermissions("sys:im:chat:send")
|
||||
@PostMapping("/send")
|
||||
public Result<SysImMessageVO> send(@RequestBody SysImSendMessageDTO dto, HttpServletRequest request) {
|
||||
LoginUser user = currentUser();
|
||||
return Result.OK(imChatService.sendMessage(user.getId(), resolveTenantId(user, request), dto));
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】发送消息接口-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读接口-----------
|
||||
@Operation(summary = "IM聊天-标记已读")
|
||||
@RequiresPermissions("sys:im:chat:list")
|
||||
@PutMapping("/read")
|
||||
public Result<String> read(@RequestParam(name = "conversationId") String conversationId) {
|
||||
LoginUser user = currentUser();
|
||||
imChatService.markRead(user.getId(), conversationId);
|
||||
return Result.OK();
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读接口-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员接口-----------
|
||||
@Operation(summary = "IM聊天-本部门成员")
|
||||
@RequiresPermissions("sys:im:chat:list")
|
||||
@GetMapping("/deptMembers")
|
||||
public Result<List<SysImContactVO>> deptMembers(@RequestParam(name = "keyword", required = false) String keyword, HttpServletRequest request) {
|
||||
LoginUser user = currentUser();
|
||||
return Result.OK(imChatService.listDeptMembers(user.getId(), resolveTenantId(user, request), user.getOrgCode(), keyword));
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员接口-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】联系人接口(兼容保留,同本部门)-----------
|
||||
@Operation(summary = "IM聊天-租户联系人")
|
||||
@RequiresPermissions("sys:im:chat:list")
|
||||
@GetMapping("/contacts")
|
||||
public Result<List<SysImContactVO>> contacts(@RequestParam(name = "keyword", required = false) String keyword, HttpServletRequest request) {
|
||||
LoginUser user = currentUser();
|
||||
return Result.OK(imChatService.listDeptMembers(user.getId(), resolveTenantId(user, request), user.getOrgCode(), keyword));
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】联系人接口(兼容保留,同本部门)-----------
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.jeecg.modules.im.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 发送 IM 消息 DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "发送IM消息")
|
||||
public class SysImSendMessageDTO {
|
||||
|
||||
@Schema(description = "会话ID")
|
||||
private String conversationId;
|
||||
@Schema(description = "消息内容")
|
||||
private String content;
|
||||
@Schema(description = "消息类型 text/image/file")
|
||||
private String msgType;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.jeecg.modules.im.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* IM 会话
|
||||
*/
|
||||
@Data
|
||||
@TableName("sys_im_conversation")
|
||||
public class SysImConversation implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private String id;
|
||||
/** 会话类型 single单聊 */
|
||||
private String convType;
|
||||
/** 单聊唯一键 */
|
||||
private String userPairKey;
|
||||
/** 租户ID */
|
||||
private Integer tenantId;
|
||||
/** 最后一条消息摘要 */
|
||||
private String lastContent;
|
||||
/** 最后消息时间 */
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date lastTime;
|
||||
private String createBy;
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createTime;
|
||||
private String updateBy;
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.jeecg.modules.im.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* IM 会话成员
|
||||
*/
|
||||
@Data
|
||||
@TableName("sys_im_conversation_member")
|
||||
public class SysImConversationMember implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private String id;
|
||||
private String conversationId;
|
||||
private String userId;
|
||||
private Integer unreadCount;
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date lastReadTime;
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createTime;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.jeecg.modules.im.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* IM 消息
|
||||
*/
|
||||
@Data
|
||||
@TableName("sys_im_message")
|
||||
public class SysImMessage implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private String id;
|
||||
private String conversationId;
|
||||
private String senderId;
|
||||
private String content;
|
||||
/** 消息类型 text/image/file */
|
||||
private String msgType;
|
||||
private Integer tenantId;
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createTime;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.jeecg.modules.im.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.jeecg.modules.im.entity.SysImConversation;
|
||||
import org.jeecg.modules.im.vo.SysImConversationVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IM 会话 Mapper
|
||||
*/
|
||||
public interface SysImConversationMapper extends BaseMapper<SysImConversation> {
|
||||
|
||||
/**
|
||||
* 查询当前用户的会话列表
|
||||
*/
|
||||
List<SysImConversationVO> listMyConversations(@Param("userId") String userId, @Param("tenantId") Integer tenantId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.jeecg.modules.im.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.jeecg.modules.im.entity.SysImConversationMember;
|
||||
|
||||
/**
|
||||
* IM 会话成员 Mapper
|
||||
*/
|
||||
public interface SysImConversationMemberMapper extends BaseMapper<SysImConversationMember> {
|
||||
|
||||
/**
|
||||
* 未读数 +1(排除发送人)
|
||||
*/
|
||||
int incrementUnreadExceptSender(@Param("conversationId") String conversationId, @Param("senderId") String senderId);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.jeecg.modules.im.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.jeecg.modules.im.entity.SysImMessage;
|
||||
|
||||
/**
|
||||
* IM 消息 Mapper
|
||||
*/
|
||||
public interface SysImMessageMapper extends BaseMapper<SysImMessage> {
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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="org.jeecg.modules.im.mapper.SysImConversationMapper">
|
||||
|
||||
<select id="listMyConversations" resultType="org.jeecg.modules.im.vo.SysImConversationVO">
|
||||
SELECT
|
||||
c.id AS conversationId,
|
||||
c.conv_type AS convType,
|
||||
c.last_content AS lastContent,
|
||||
c.last_time AS lastTime,
|
||||
m.unread_count AS unreadCount,
|
||||
u.id AS targetUserId,
|
||||
u.realname AS targetRealname,
|
||||
u.username AS targetUsername,
|
||||
u.avatar AS targetAvatar
|
||||
FROM sys_im_conversation_member m
|
||||
INNER JOIN sys_im_conversation c ON c.id = m.conversation_id
|
||||
INNER JOIN sys_im_conversation_member om ON om.conversation_id = c.id AND om.user_id != m.user_id
|
||||
INNER JOIN sys_user u ON u.id = om.user_id
|
||||
WHERE m.user_id = #{userId}
|
||||
AND c.tenant_id = #{tenantId}
|
||||
AND c.conv_type = 'single'
|
||||
ORDER BY c.last_time DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?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="org.jeecg.modules.im.mapper.SysImConversationMemberMapper">
|
||||
|
||||
<update id="incrementUnreadExceptSender">
|
||||
UPDATE sys_im_conversation_member
|
||||
SET unread_count = unread_count + 1
|
||||
WHERE conversation_id = #{conversationId}
|
||||
AND user_id != #{senderId}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.jeecg.modules.im.service;
|
||||
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import org.jeecg.modules.im.dto.SysImSendMessageDTO;
|
||||
|
||||
import org.jeecg.modules.im.vo.SysImContactVO;
|
||||
|
||||
import org.jeecg.modules.im.vo.SysImConversationVO;
|
||||
|
||||
import org.jeecg.modules.im.vo.SysImMessageVO;
|
||||
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* IM 聊天服务
|
||||
|
||||
*/
|
||||
|
||||
public interface ISysImChatService {
|
||||
|
||||
|
||||
|
||||
List<SysImConversationVO> listConversations(String userId, Integer tenantId);
|
||||
|
||||
|
||||
|
||||
SysImConversationVO openSingleConversation(String userId, Integer tenantId, String orgCode, String targetUserId);
|
||||
|
||||
|
||||
|
||||
IPage<SysImMessageVO> listMessages(String userId, String conversationId, Integer pageNo, Integer pageSize, String startTime, String beforeTime);
|
||||
|
||||
|
||||
|
||||
SysImMessageVO sendMessage(String userId, Integer tenantId, SysImSendMessageDTO dto);
|
||||
|
||||
|
||||
|
||||
void markRead(String userId, String conversationId);
|
||||
|
||||
|
||||
|
||||
List<SysImContactVO> listDeptMembers(String userId, Integer tenantId, String orgCode, String keyword);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
package org.jeecg.modules.im.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.constant.WebsocketConst;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.DateUtils;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.im.dto.SysImSendMessageDTO;
|
||||
import org.jeecg.modules.im.entity.SysImConversation;
|
||||
import org.jeecg.modules.im.entity.SysImConversationMember;
|
||||
import org.jeecg.modules.im.entity.SysImMessage;
|
||||
import org.jeecg.modules.im.mapper.SysImConversationMapper;
|
||||
import org.jeecg.modules.im.mapper.SysImConversationMemberMapper;
|
||||
import org.jeecg.modules.im.mapper.SysImMessageMapper;
|
||||
import org.jeecg.modules.im.service.ISysImChatService;
|
||||
import org.jeecg.modules.im.vo.SysImContactVO;
|
||||
import org.jeecg.modules.im.vo.SysImConversationVO;
|
||||
import org.jeecg.modules.im.vo.SysImMessageVO;
|
||||
import org.jeecg.modules.message.websocket.WebSocket;
|
||||
import org.jeecg.modules.system.entity.SysDepart;
|
||||
import org.jeecg.modules.system.entity.SysUser;
|
||||
import org.jeecg.modules.system.entity.SysUserDepart;
|
||||
import org.jeecg.modules.system.mapper.SysDepartMapper;
|
||||
import org.jeecg.modules.system.mapper.SysUserDepartMapper;
|
||||
import org.jeecg.modules.system.mapper.SysUserMapper;
|
||||
import org.jeecg.modules.system.mapper.SysUserTenantMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* IM 聊天服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SysImChatServiceImpl implements ISysImChatService {
|
||||
|
||||
private static final String CONV_TYPE_SINGLE = "single";
|
||||
private static final String MSG_TYPE_TEXT = "text";
|
||||
|
||||
@Autowired
|
||||
private SysImConversationMapper conversationMapper;
|
||||
@Autowired
|
||||
private SysImConversationMemberMapper memberMapper;
|
||||
@Autowired
|
||||
private SysImMessageMapper messageMapper;
|
||||
@Autowired
|
||||
private SysUserMapper userMapper;
|
||||
@Autowired
|
||||
private SysUserTenantMapper userTenantMapper;
|
||||
@Autowired
|
||||
private SysUserDepartMapper userDepartMapper;
|
||||
@Autowired
|
||||
private SysDepartMapper departMapper;
|
||||
@Autowired
|
||||
private WebSocket webSocket;
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】会话列表-----------
|
||||
@Override
|
||||
public List<SysImConversationVO> listConversations(String userId, Integer tenantId) {
|
||||
if (oConvertUtils.isEmpty(userId) || tenantId == null || tenantId <= 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return conversationMapper.listMyConversations(userId, tenantId);
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】会话列表-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】打开单聊会话-----------
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SysImConversationVO openSingleConversation(String userId, Integer tenantId, String orgCode, String targetUserId) {
|
||||
String pairKey = buildPairKey(userId, targetUserId);
|
||||
SysImConversation conversation = conversationMapper.selectOne(new LambdaQueryWrapper<SysImConversation>()
|
||||
.eq(SysImConversation::getTenantId, tenantId)
|
||||
.eq(SysImConversation::getUserPairKey, pairKey));
|
||||
Date now = new Date();
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】已有会话快速打开-----------
|
||||
if (conversation != null) {
|
||||
return buildConversationVo(conversation, userId, targetUserId);
|
||||
}
|
||||
validateTenantChat(userId, tenantId, orgCode, targetUserId);
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】已有会话快速打开-----------
|
||||
conversation = new SysImConversation();
|
||||
conversation.setConvType(CONV_TYPE_SINGLE);
|
||||
conversation.setUserPairKey(pairKey);
|
||||
conversation.setTenantId(tenantId);
|
||||
conversation.setCreateBy(userId);
|
||||
conversation.setCreateTime(now);
|
||||
conversation.setUpdateTime(now);
|
||||
conversationMapper.insert(conversation);
|
||||
createMember(conversation.getId(), userId, now);
|
||||
createMember(conversation.getId(), targetUserId, now);
|
||||
return buildConversationVo(conversation.getId(), userId, tenantId, targetUserId);
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】打开单聊会话-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】消息分页-----------
|
||||
@Override
|
||||
public IPage<SysImMessageVO> listMessages(String userId, String conversationId, Integer pageNo, Integer pageSize, String startTime, String beforeTime) {
|
||||
assertMember(userId, conversationId);
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】默认本周消息+向上翻页加载-----------
|
||||
Date start = parseMessageTime(startTime);
|
||||
Date before = parseMessageTime(beforeTime);
|
||||
int currentPage = (start != null || before != null) ? 1 : (pageNo == null || pageNo < 1 ? 1 : pageNo);
|
||||
int size = pageSize == null || pageSize < 1 ? 20 : pageSize;
|
||||
Page<SysImMessage> page = new Page<>(currentPage, size);
|
||||
if (currentPage == 1) {
|
||||
page.setSearchCount(false);
|
||||
}
|
||||
LambdaQueryWrapper<SysImMessage> wrapper = new LambdaQueryWrapper<SysImMessage>()
|
||||
.eq(SysImMessage::getConversationId, conversationId);
|
||||
if (start != null) {
|
||||
wrapper.ge(SysImMessage::getCreateTime, start);
|
||||
}
|
||||
if (before != null) {
|
||||
wrapper.lt(SysImMessage::getCreateTime, before);
|
||||
}
|
||||
wrapper.orderByDesc(SysImMessage::getCreateTime);
|
||||
IPage<SysImMessage> messagePage = messageMapper.selectPage(page, wrapper);
|
||||
Page<SysImMessageVO> voPage = new Page<>(currentPage, size, messagePage.getTotal());
|
||||
List<SysImMessageVO> records = toMessageVoList(messagePage.getRecords(), userId);
|
||||
Collections.reverse(records);
|
||||
voPage.setRecords(records);
|
||||
return voPage;
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】默认本周消息+向上翻页加载-----------
|
||||
}
|
||||
|
||||
private Date parseMessageTime(String timeText) {
|
||||
if (oConvertUtils.isEmpty(timeText)) {
|
||||
return null;
|
||||
}
|
||||
Date date = DateUtils.parseDatetime(timeText);
|
||||
if (date == null) {
|
||||
date = DateUtils.str2Date(timeText, DateUtils.date_sdf.get());
|
||||
}
|
||||
return date;
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】消息分页-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】发送消息-----------
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SysImMessageVO sendMessage(String userId, Integer tenantId, SysImSendMessageDTO dto) {
|
||||
if (dto == null || oConvertUtils.isEmpty(dto.getConversationId()) || oConvertUtils.isEmpty(dto.getContent())) {
|
||||
throw new JeecgBootException("消息内容不能为空");
|
||||
}
|
||||
assertMember(userId, dto.getConversationId());
|
||||
Date now = new Date();
|
||||
SysImMessage message = new SysImMessage();
|
||||
message.setConversationId(dto.getConversationId());
|
||||
message.setSenderId(userId);
|
||||
message.setContent(dto.getContent().trim());
|
||||
message.setMsgType(oConvertUtils.isEmpty(dto.getMsgType()) ? MSG_TYPE_TEXT : dto.getMsgType());
|
||||
message.setTenantId(tenantId);
|
||||
message.setCreateTime(now);
|
||||
messageMapper.insert(message);
|
||||
|
||||
SysImConversation conversation = conversationMapper.selectById(dto.getConversationId());
|
||||
conversation.setLastContent(truncate(message.getContent(), 200));
|
||||
conversation.setLastTime(now);
|
||||
conversation.setUpdateTime(now);
|
||||
conversationMapper.updateById(conversation);
|
||||
|
||||
memberMapper.incrementUnreadExceptSender(dto.getConversationId(), userId);
|
||||
SysImMessageVO messageVo = toMessageVo(message, userId);
|
||||
pushChatMessage(dto.getConversationId(), userId, messageVo);
|
||||
return messageVo;
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】发送消息-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读-----------
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markRead(String userId, String conversationId) {
|
||||
SysImConversationMember member = getMember(userId, conversationId);
|
||||
if (member == null) {
|
||||
return;
|
||||
}
|
||||
member.setUnreadCount(0);
|
||||
member.setLastReadTime(new Date());
|
||||
memberMapper.updateById(member);
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员列表(含会话摘要)-----------
|
||||
@Override
|
||||
public List<SysImContactVO> listDeptMembers(String userId, Integer tenantId, String orgCode, String keyword) {
|
||||
String resolvedOrgCode = resolveOrgCode(userId, tenantId, orgCode);
|
||||
if (oConvertUtils.isEmpty(resolvedOrgCode)) {
|
||||
throw new JeecgBootException("未获取到当前部门,请切换部门后重试");
|
||||
}
|
||||
List<SysUser> users = userDepartMapper.querySameDepartUserList(resolvedOrgCode, userId, tenantId, keyword);
|
||||
if (users == null || users.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Map<String, SysImConversationVO> convMap = new HashMap<>(16);
|
||||
if (tenantId != null && tenantId > 0) {
|
||||
for (SysImConversationVO conv : conversationMapper.listMyConversations(userId, tenantId)) {
|
||||
if (oConvertUtils.isNotEmpty(conv.getTargetUserId())) {
|
||||
convMap.put(conv.getTargetUserId(), conv);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<SysImContactVO> result = users.stream().map(user -> {
|
||||
SysImContactVO vo = toContactVo(user);
|
||||
SysImConversationVO conv = convMap.get(user.getId());
|
||||
if (conv != null) {
|
||||
vo.setConversationId(conv.getConversationId());
|
||||
vo.setLastContent(conv.getLastContent());
|
||||
vo.setLastTime(conv.getLastTime());
|
||||
vo.setUnreadCount(conv.getUnreadCount());
|
||||
}
|
||||
return vo;
|
||||
}).collect(Collectors.toList());
|
||||
result.sort(Comparator
|
||||
.comparing(SysImContactVO::getLastTime, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(item -> oConvertUtils.getString(item.getRealname(), item.getUsername())));
|
||||
return result;
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员列表(含会话摘要)-----------
|
||||
|
||||
private SysImContactVO toContactVo(SysUser user) {
|
||||
SysImContactVO vo = new SysImContactVO();
|
||||
vo.setId(user.getId());
|
||||
vo.setUsername(user.getUsername());
|
||||
vo.setRealname(user.getRealname());
|
||||
vo.setAvatar(user.getAvatar());
|
||||
vo.setOrgCodeTxt(user.getOrgCodeTxt());
|
||||
vo.setUnreadCount(0);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String resolveOrgCode(String userId, Integer tenantId, String orgCode) {
|
||||
if (oConvertUtils.isNotEmpty(orgCode)) {
|
||||
return orgCode;
|
||||
}
|
||||
if (tenantId != null && tenantId > 0) {
|
||||
List<SysUserDepart> departs = userDepartMapper.getTenantUserDepart(userId, String.valueOf(tenantId));
|
||||
if (departs != null && !departs.isEmpty()) {
|
||||
SysDepart depart = departMapper.selectById(departs.get(0).getDepId());
|
||||
if (depart != null && oConvertUtils.isNotEmpty(depart.getOrgCode())) {
|
||||
return depart.getOrgCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
List<SysUserDepart> departs = userDepartMapper.getUserDepartByUid(userId);
|
||||
if (departs != null && !departs.isEmpty()) {
|
||||
SysDepart depart = departMapper.selectById(departs.get(0).getDepId());
|
||||
if (depart != null) {
|
||||
return depart.getOrgCode();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void createMember(String conversationId, String userId, Date now) {
|
||||
SysImConversationMember member = new SysImConversationMember();
|
||||
member.setConversationId(conversationId);
|
||||
member.setUserId(userId);
|
||||
member.setUnreadCount(0);
|
||||
member.setCreateTime(now);
|
||||
memberMapper.insert(member);
|
||||
}
|
||||
|
||||
private SysImConversationVO buildConversationVo(String conversationId, String userId, Integer tenantId, String targetUserId) {
|
||||
SysImConversation conversation = conversationMapper.selectById(conversationId);
|
||||
if (conversation == null) {
|
||||
return new SysImConversationVO();
|
||||
}
|
||||
return buildConversationVo(conversation, userId, targetUserId);
|
||||
}
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】已有会话快速打开-----------
|
||||
private SysImConversationVO buildConversationVo(SysImConversation conversation, String userId, String targetUserId) {
|
||||
SysUser target = userMapper.selectById(targetUserId);
|
||||
SysImConversationMember member = getMember(userId, conversation.getId());
|
||||
SysImConversationVO vo = new SysImConversationVO();
|
||||
vo.setConversationId(conversation.getId());
|
||||
vo.setConvType(CONV_TYPE_SINGLE);
|
||||
vo.setLastContent(conversation.getLastContent());
|
||||
vo.setLastTime(conversation.getLastTime());
|
||||
vo.setUnreadCount(member == null ? 0 : member.getUnreadCount());
|
||||
if (target != null) {
|
||||
vo.setTargetUserId(target.getId());
|
||||
vo.setTargetRealname(target.getRealname());
|
||||
vo.setTargetUsername(target.getUsername());
|
||||
vo.setTargetAvatar(target.getAvatar());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】已有会话快速打开-----------
|
||||
|
||||
private void validateTenantChat(String userId, Integer tenantId, String orgCode, String targetUserId) {
|
||||
if (oConvertUtils.isEmpty(targetUserId)) {
|
||||
throw new JeecgBootException("请选择聊天对象");
|
||||
}
|
||||
if (userId.equals(targetUserId)) {
|
||||
throw new JeecgBootException("不能与自己聊天");
|
||||
}
|
||||
if (tenantId == null || tenantId <= 0) {
|
||||
throw new JeecgBootException("请先选择租户");
|
||||
}
|
||||
Integer selfExist = userTenantMapper.userTenantIzExist(userId, tenantId);
|
||||
Integer targetExist = userTenantMapper.userTenantIzExist(targetUserId, tenantId);
|
||||
if (selfExist == null || selfExist <= 0 || targetExist == null || targetExist <= 0) {
|
||||
throw new JeecgBootException("仅支持与当前租户内用户聊天");
|
||||
}
|
||||
SysUser target = userMapper.selectById(targetUserId);
|
||||
if (target == null) {
|
||||
throw new JeecgBootException("聊天对象不存在");
|
||||
}
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】限制同部门聊天-----------
|
||||
String resolvedOrgCode = resolveOrgCode(userId, tenantId, orgCode);
|
||||
if (oConvertUtils.isEmpty(resolvedOrgCode)) {
|
||||
throw new JeecgBootException("未获取到当前部门,请切换部门后重试");
|
||||
}
|
||||
if (userDepartMapper.countUserInDepartOrgCode(targetUserId, resolvedOrgCode, tenantId) <= 0) {
|
||||
throw new JeecgBootException("仅支持与同部门用户聊天");
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】限制同部门聊天-----------
|
||||
}
|
||||
|
||||
private String buildPairKey(String userId1, String userId2) {
|
||||
if (userId1.compareTo(userId2) <= 0) {
|
||||
return userId1 + "_" + userId2;
|
||||
}
|
||||
return userId2 + "_" + userId1;
|
||||
}
|
||||
|
||||
private SysImConversationMember getMember(String userId, String conversationId) {
|
||||
return memberMapper.selectOne(new LambdaQueryWrapper<SysImConversationMember>()
|
||||
.eq(SysImConversationMember::getConversationId, conversationId)
|
||||
.eq(SysImConversationMember::getUserId, userId));
|
||||
}
|
||||
|
||||
private void assertMember(String userId, String conversationId) {
|
||||
if (getMember(userId, conversationId) == null) {
|
||||
throw new JeecgBootException("无权访问该会话");
|
||||
}
|
||||
}
|
||||
|
||||
private SysImMessageVO toMessageVo(SysImMessage message, String currentUserId) {
|
||||
return toMessageVo(message, currentUserId, null);
|
||||
}
|
||||
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】消息列表批量填充发送人-----------
|
||||
private List<SysImMessageVO> toMessageVoList(List<SysImMessage> messages, String currentUserId) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> senderIds = messages.stream()
|
||||
.map(SysImMessage::getSenderId)
|
||||
.filter(oConvertUtils::isNotEmpty)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<String, SysUser> userMap = new HashMap<>(senderIds.size());
|
||||
if (!senderIds.isEmpty()) {
|
||||
List<SysUser> users = userMapper.selectBatchIds(senderIds);
|
||||
if (users != null) {
|
||||
for (SysUser user : users) {
|
||||
userMap.put(user.getId(), user);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<SysImMessageVO> result = new ArrayList<>(messages.size());
|
||||
for (SysImMessage message : messages) {
|
||||
result.add(toMessageVo(message, currentUserId, userMap));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private SysImMessageVO toMessageVo(SysImMessage message, String currentUserId, Map<String, SysUser> userMap) {
|
||||
SysImMessageVO vo = new SysImMessageVO();
|
||||
vo.setId(message.getId());
|
||||
vo.setConversationId(message.getConversationId());
|
||||
vo.setSenderId(message.getSenderId());
|
||||
vo.setContent(message.getContent());
|
||||
vo.setMsgType(message.getMsgType());
|
||||
vo.setCreateTime(message.getCreateTime());
|
||||
vo.setMine(currentUserId.equals(message.getSenderId()));
|
||||
SysUser sender = userMap == null ? null : userMap.get(message.getSenderId());
|
||||
if (sender == null && userMap == null) {
|
||||
sender = userMapper.selectById(message.getSenderId());
|
||||
}
|
||||
if (sender != null) {
|
||||
vo.setSenderName(sender.getRealname());
|
||||
vo.setSenderAvatar(sender.getAvatar());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】消息列表批量填充发送人-----------
|
||||
|
||||
private void pushChatMessage(String conversationId, String senderId, SysImMessageVO messageVo) {
|
||||
List<SysImConversationMember> members = memberMapper.selectList(new LambdaQueryWrapper<SysImConversationMember>()
|
||||
.eq(SysImConversationMember::getConversationId, conversationId));
|
||||
for (SysImConversationMember member : members) {
|
||||
if (senderId.equals(member.getUserId())) {
|
||||
continue;
|
||||
}
|
||||
JSONObject obj = new JSONObject();
|
||||
obj.put(WebsocketConst.MSG_CMD, WebsocketConst.MSG_CHAT);
|
||||
obj.put(WebsocketConst.MSG_USER_ID, member.getUserId());
|
||||
obj.put("conversationId", conversationId);
|
||||
obj.put("messageId", messageVo.getId());
|
||||
obj.put("senderId", messageVo.getSenderId());
|
||||
//update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】WebSocket推送补全头像字段-----------
|
||||
obj.put("senderName", messageVo.getSenderName());
|
||||
obj.put("senderAvatar", messageVo.getSenderAvatar());
|
||||
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】WebSocket推送补全头像字段-----------
|
||||
obj.put("content", messageVo.getContent());
|
||||
obj.put("msgType", messageVo.getMsgType());
|
||||
obj.put("createTime", messageVo.getCreateTime());
|
||||
webSocket.sendMessage(member.getUserId(), obj.toJSONString());
|
||||
}
|
||||
}
|
||||
|
||||
private String truncate(String content, int maxLen) {
|
||||
if (content == null) {
|
||||
return null;
|
||||
}
|
||||
return content.length() <= maxLen ? content : content.substring(0, maxLen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.jeecg.modules.im.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IM 联系人 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "IM联系人")
|
||||
public class SysImContactVO {
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private String id;
|
||||
@Schema(description = "账号")
|
||||
private String username;
|
||||
@Schema(description = "姓名")
|
||||
private String realname;
|
||||
@Schema(description = "头像")
|
||||
private String avatar;
|
||||
@Schema(description = "部门")
|
||||
private String orgCodeTxt;
|
||||
@Schema(description = "会话ID")
|
||||
private String conversationId;
|
||||
@Schema(description = "最后消息摘要")
|
||||
private String lastContent;
|
||||
@Schema(description = "最后消息时间")
|
||||
private java.util.Date lastTime;
|
||||
@Schema(description = "未读数")
|
||||
private Integer unreadCount;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.jeecg.modules.im.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* IM 会话列表 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "IM会话")
|
||||
public class SysImConversationVO {
|
||||
|
||||
@Schema(description = "会话ID")
|
||||
private String conversationId;
|
||||
@Schema(description = "会话类型")
|
||||
private String convType;
|
||||
@Schema(description = "最后消息摘要")
|
||||
private String lastContent;
|
||||
@Schema(description = "最后消息时间")
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date lastTime;
|
||||
@Schema(description = "未读数")
|
||||
private Integer unreadCount;
|
||||
@Schema(description = "对方用户ID")
|
||||
private String targetUserId;
|
||||
@Schema(description = "对方姓名")
|
||||
private String targetRealname;
|
||||
@Schema(description = "对方账号")
|
||||
private String targetUsername;
|
||||
@Schema(description = "对方头像")
|
||||
private String targetAvatar;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.jeecg.modules.im.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* IM 消息 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "IM消息")
|
||||
public class SysImMessageVO {
|
||||
|
||||
@Schema(description = "消息ID")
|
||||
private String id;
|
||||
@Schema(description = "会话ID")
|
||||
private String conversationId;
|
||||
@Schema(description = "发送人ID")
|
||||
private String senderId;
|
||||
@Schema(description = "发送人姓名")
|
||||
private String senderName;
|
||||
@Schema(description = "发送人头像")
|
||||
private String senderAvatar;
|
||||
@Schema(description = "消息内容")
|
||||
private String content;
|
||||
@Schema(description = "消息类型")
|
||||
private String msgType;
|
||||
@Schema(description = "是否本人发送")
|
||||
private Boolean mine;
|
||||
@Schema(description = "发送时间")
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createTime;
|
||||
}
|
||||
@@ -102,9 +102,17 @@ public interface SysUserDepartMapper extends BaseMapper<SysUserDepart>{
|
||||
|
||||
/**
|
||||
* 通过用户id集合获取用户id和部门code
|
||||
*
|
||||
* @param userIdList
|
||||
* @return
|
||||
*/
|
||||
List<SysUserSysDepPostModel> getUserDepPostByUserIds(@Param("userIdList") List<String> userIdList);
|
||||
|
||||
/**
|
||||
* 查询同部门用户(精确 orgCode,不含下级部门)
|
||||
*/
|
||||
List<SysUser> querySameDepartUserList(@Param("orgCode") String orgCode, @Param("excludeUserId") String excludeUserId,
|
||||
@Param("tenantId") Integer tenantId, @Param("keyword") String keyword);
|
||||
|
||||
/**
|
||||
* 用户是否属于指定 orgCode 部门
|
||||
*/
|
||||
int countUserInDepartOrgCode(@Param("userId") String userId, @Param("orgCode") String orgCode, @Param("tenantId") Integer tenantId);
|
||||
}
|
||||
|
||||
@@ -142,4 +142,40 @@
|
||||
#{userId}
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
<!-- IM聊天:同部门用户(精确 orgCode) -->
|
||||
<select id="querySameDepartUserList" resultType="org.jeecg.modules.system.entity.SysUser">
|
||||
select distinct a.* from sys_user a
|
||||
inner join sys_user_depart b on b.user_id = a.id
|
||||
inner join sys_depart c on b.dep_id = c.id
|
||||
where a.del_flag = 0 and a.status = 1
|
||||
and c.org_code = #{orgCode}
|
||||
and a.username != '_reserve_user_external'
|
||||
<if test="excludeUserId != null and excludeUserId != ''">
|
||||
and a.id != #{excludeUserId}
|
||||
</if>
|
||||
<if test="tenantId != null and tenantId > 0">
|
||||
and a.id in (
|
||||
select user_id from sys_user_tenant where tenant_id = #{tenantId} and status = '1'
|
||||
)
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
<bind name="bindKeyword" value="'%' + keyword + '%'"/>
|
||||
and (a.username like #{bindKeyword} or a.realname like #{bindKeyword})
|
||||
</if>
|
||||
order by a.sort, a.realname
|
||||
</select>
|
||||
|
||||
<select id="countUserInDepartOrgCode" resultType="int">
|
||||
select count(1) from sys_user a
|
||||
inner join sys_user_depart b on b.user_id = a.id
|
||||
inner join sys_depart c on b.dep_id = c.id
|
||||
where a.del_flag = 0 and a.id = #{userId}
|
||||
and c.org_code = #{orgCode}
|
||||
<if test="tenantId != null and tenantId > 0">
|
||||
and a.id in (
|
||||
select user_id from sys_user_tenant where tenant_id = #{tenantId} and status = '1'
|
||||
)
|
||||
</if>
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -0,0 +1,70 @@
|
||||
-- 租户 IM 聊天:表结构 + 我的租户菜单
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sys_im_conversation` (
|
||||
`id` varchar(32) NOT NULL COMMENT '主键',
|
||||
`conv_type` varchar(10) NOT NULL DEFAULT 'single' COMMENT '会话类型 single单聊',
|
||||
`user_pair_key` varchar(80) DEFAULT NULL COMMENT '单聊唯一键(较小userId_较大userId)',
|
||||
`tenant_id` int DEFAULT NULL COMMENT '租户ID',
|
||||
`last_content` varchar(500) DEFAULT NULL COMMENT '最后一条消息摘要',
|
||||
`last_time` datetime DEFAULT NULL COMMENT '最后消息时间',
|
||||
`create_by` varchar(50) DEFAULT NULL COMMENT '创建人',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(50) DEFAULT NULL COMMENT '更新人',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_im_conv_pair` (`tenant_id`, `user_pair_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='IM会话表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sys_im_conversation_member` (
|
||||
`id` varchar(32) NOT NULL COMMENT '主键',
|
||||
`conversation_id` varchar(32) NOT NULL COMMENT '会话ID',
|
||||
`user_id` varchar(32) NOT NULL COMMENT '用户ID',
|
||||
`unread_count` int NOT NULL DEFAULT 0 COMMENT '未读数',
|
||||
`last_read_time` datetime DEFAULT NULL COMMENT '最后已读时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_im_member` (`conversation_id`, `user_id`),
|
||||
KEY `idx_im_member_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='IM会话成员表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sys_im_message` (
|
||||
`id` varchar(32) NOT NULL COMMENT '主键',
|
||||
`conversation_id` varchar(32) NOT NULL COMMENT '会话ID',
|
||||
`sender_id` varchar(32) NOT NULL COMMENT '发送人ID',
|
||||
`content` text COMMENT '消息内容',
|
||||
`msg_type` varchar(20) NOT NULL DEFAULT 'text' COMMENT '消息类型 text/image/file',
|
||||
`tenant_id` int DEFAULT NULL COMMENT '租户ID',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '发送时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_im_msg_conv_time` (`conversation_id`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='IM消息表';
|
||||
|
||||
INSERT IGNORE INTO `sys_permission` (
|
||||
`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`,
|
||||
`menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`,
|
||||
`hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`,
|
||||
`del_flag`, `rule_flag`, `status`, `internal_or_external`
|
||||
) VALUES (
|
||||
'1995000000000000110', '1674708136602542082', 'IM聊天', '/my/ImChat',
|
||||
'system/im/ImChat', 1, 'ImChat', NULL,
|
||||
1, NULL, '0', 1.10, 0, 'ant-design:message-outlined', 0, 1,
|
||||
0, 0, '租户内用户即时聊天', 'admin', NOW(), 'admin', NOW(),
|
||||
0, 0, '1', 0
|
||||
);
|
||||
|
||||
INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`)
|
||||
VALUES ('1995000000000000111', '1995000000000000110', '查询', 2, 'sys:im:chat:list', '1', 1.00, 0, 1, 0, '1', 0, 'admin', NOW());
|
||||
|
||||
INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`)
|
||||
VALUES ('1995000000000000112', '1995000000000000110', '发送', 2, 'sys:im:chat:send', '1', 2.00, 0, 1, 0, '1', 0, 'admin', NOW());
|
||||
|
||||
INSERT INTO `sys_role_permission` (`id`, `role_id`, `permission_id`, `data_rule_ids`, `operate_date`, `operate_ip`)
|
||||
SELECT REPLACE(UUID(), '-', ''), r.id, p.id, NULL, NOW(), '127.0.0.1'
|
||||
FROM `sys_role` r
|
||||
CROSS JOIN `sys_permission` p
|
||||
WHERE r.`role_code` = 'admin'
|
||||
AND p.`id` IN ('1995000000000000110', '1995000000000000111', '1995000000000000112')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_role_permission` rp WHERE rp.`role_id` = r.`id` AND rp.`permission_id` = p.`id`
|
||||
);
|
||||
@@ -2,16 +2,55 @@
|
||||
|
||||
import { unref } from 'vue';
|
||||
import { useWebSocket, WebSocketResult } from '@vueuse/core';
|
||||
import md5 from 'crypto-js/md5';
|
||||
import { getToken } from '/@/utils/auth';
|
||||
import { useGlobSetting } from '/@/hooks/setting';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
|
||||
let result: WebSocketResult<any>;
|
||||
const listeners = new Map();
|
||||
let connectedUrl = '';
|
||||
|
||||
/**
|
||||
* 构建系统 WebSocket 地址(含 context-path,如 /jeecg-boot)
|
||||
*/
|
||||
export function buildSystemWebSocketUrl(): string {
|
||||
const glob = useGlobSetting();
|
||||
const userStore = useUserStore();
|
||||
const userInfo = unref(userStore.getUserInfo);
|
||||
if (!userInfo?.id) {
|
||||
return '';
|
||||
}
|
||||
const token = getToken() || '';
|
||||
const wsClientId = md5(token).toString();
|
||||
const wsUserId = `${userInfo.id}_${wsClientId}`;
|
||||
let base = (glob.domainUrl || '').replace('https://', 'wss://').replace('http://', 'ws://');
|
||||
base = base.replace(/\/$/, '');
|
||||
const apiPath = (glob.apiUrl || '/jeecg-boot').replace(/\/$/, '');
|
||||
const prefix = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
|
||||
return `${base}${prefix}/websocket/${wsUserId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 WebSocket 已连接(聊天页等场景可主动调用)
|
||||
*/
|
||||
export function ensureWebSocketConnected(): void {
|
||||
const url = buildSystemWebSocketUrl();
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
if (result?.status?.value === 'OPEN' && connectedUrl === url) {
|
||||
return;
|
||||
}
|
||||
connectWebSocket(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启 WebSocket 链接,全局只需执行一次
|
||||
* @param url
|
||||
*/
|
||||
export function connectWebSocket(url: string) {
|
||||
connectedUrl = url;
|
||||
// 代码逻辑说明: v2.4.6 的 websocket 服务端,存在性能和安全问题。 #3278
|
||||
const token = (getToken() || '') as string;
|
||||
result = useWebSocket(url, {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Tooltip :title="tooltipTitle" placement="bottom" :mouseEnterDelay="0.5">
|
||||
<span :class="`${prefixCls}-action__item refresh-cache-item`" class="refresh-cache-btn" @click="clearCache">
|
||||
<SyncOutlined :spin="loading" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { useRefreshCache } from './useRefreshCache';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RefreshCache',
|
||||
components: { Tooltip, SyncOutlined },
|
||||
setup() {
|
||||
const { prefixCls } = useDesign('layout-header');
|
||||
const { loading, clearCache, tooltipTitle } = useRefreshCache();
|
||||
|
||||
return {
|
||||
prefixCls,
|
||||
loading,
|
||||
clearCache,
|
||||
tooltipTitle,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.refresh-cache-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div :class="prefixCls">
|
||||
<Badge :count="totalUnread" :overflowCount="99" :offset="[-4, 18]" :numberStyle="numberStyle" @click="openChat">
|
||||
<MessageOutlined />
|
||||
</Badge>
|
||||
<ImChatModal @register="registerModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, onUnmounted } from 'vue';
|
||||
import { Badge } from 'ant-design-vue';
|
||||
import { MessageOutlined } from '@ant-design/icons-vue';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { ensureWebSocketConnected, onWebSocket, offWebSocket } from '/@/hooks/web/useWebSocket';
|
||||
import ImChatModal from '/@/views/system/im/ImChatModal.vue';
|
||||
import { prefetchImChatData, handleImChatSocket } from '/@/views/system/im/imCache';
|
||||
import { useImUnread } from '/@/views/system/im/useImUnread';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'HeaderImChat',
|
||||
components: {
|
||||
Badge,
|
||||
MessageOutlined,
|
||||
ImChatModal,
|
||||
},
|
||||
setup() {
|
||||
const { prefixCls } = useDesign('header-im-chat');
|
||||
const { totalUnread } = useImUnread();
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
|
||||
const numberStyle = {
|
||||
fontSize: '12px',
|
||||
height: '16px',
|
||||
minWidth: '16px',
|
||||
lineHeight: '16px',
|
||||
padding: '0 4px',
|
||||
};
|
||||
|
||||
function openChat() {
|
||||
openModal(true, {});
|
||||
}
|
||||
|
||||
function onImSocket(data: Record<string, any>) {
|
||||
handleImChatSocket(data);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ensureWebSocketConnected();
|
||||
prefetchImChatData();
|
||||
onWebSocket(onImSocket);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
offWebSocket(onImSocket);
|
||||
});
|
||||
|
||||
return {
|
||||
prefixCls,
|
||||
totalUnread,
|
||||
numberStyle,
|
||||
openChat,
|
||||
registerModal,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-header-im-chat';
|
||||
|
||||
.@{prefix-cls} {
|
||||
cursor: pointer;
|
||||
padding: 0 10px;
|
||||
|
||||
.ant-badge {
|
||||
font-size: 18px;
|
||||
|
||||
.ant-badge-count {
|
||||
@badget-size: 16px;
|
||||
width: @badget-size;
|
||||
height: @badget-size;
|
||||
min-width: @badget-size;
|
||||
line-height: @badget-size;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,3 +14,7 @@ export const ErrorAction = createAsyncComponent(() => import('./ErrorAction.vue'
|
||||
export const LockScreen = createAsyncComponent(() => import('./LockScreen.vue'));
|
||||
|
||||
export { FullScreen };
|
||||
|
||||
export { default as RefreshCache } from './RefreshCache.vue';
|
||||
|
||||
export const ImChat = createAsyncComponent(() => import('./im-chat/index.vue'));
|
||||
|
||||
@@ -25,10 +25,8 @@
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { useGlobSetting } from '/@/hooks/setting';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import { connectWebSocket, onWebSocket } from '/@/hooks/web/useWebSocket';
|
||||
import { connectWebSocket, onWebSocket, buildSystemWebSocketUrl } from '/@/hooks/web/useWebSocket';
|
||||
import { readAllMsg } from '/@/views/monitor/mynews/mynews.api';
|
||||
import { getToken } from '/@/utils/auth';
|
||||
import md5 from 'crypto-js/md5';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import SysMessageModal from '/@/views/system/message/components/SysMessageModal.vue'
|
||||
@@ -148,12 +146,10 @@
|
||||
|
||||
// 初始化 WebSocket
|
||||
function initWebSocket() {
|
||||
let token = getToken();
|
||||
//将登录token生成一个短的标识
|
||||
let wsClientId = md5(token);
|
||||
let userId = unref(userStore.getUserInfo).id + "_" + wsClientId;
|
||||
// WebSocket与普通的请求所用协议有所不同,ws等同于http,wss等同于https
|
||||
let url = glob.domainUrl?.replace('https://', 'wss://').replace('http://', 'ws://') + '/websocket/' + userId;
|
||||
const url = buildSystemWebSocketUrl();
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
connectWebSocket(url);
|
||||
onWebSocket(onWebSocketMessage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from '/@/hooks/web/useI18n';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import { refreshCache, queryAllDictItems } from '/@/views/system/dict/dict.api';
|
||||
import { refreshDragCache } from '/@/api/common/api';
|
||||
import { DB_DICT_DATA_KEY } from '/@/enums/cacheEnum';
|
||||
import { removeAuthCache, setAuthCache } from '/@/utils/auth';
|
||||
|
||||
/** 顶部/用户菜单共用的刷新缓存逻辑 */
|
||||
export function useRefreshCache() {
|
||||
const { t } = useI18n();
|
||||
const { createMessage } = useMessage();
|
||||
const userStore = useUserStore();
|
||||
const loading = ref(false);
|
||||
|
||||
async function clearCache() {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await refreshCache();
|
||||
await refreshDragCache();
|
||||
if (result.success) {
|
||||
const res = await queryAllDictItems();
|
||||
removeAuthCache(DB_DICT_DATA_KEY);
|
||||
setAuthCache(DB_DICT_DATA_KEY, res.result);
|
||||
createMessage.success(t('layout.header.refreshCacheComplete'));
|
||||
userStore.setAllDictItems(res.result);
|
||||
} else {
|
||||
createMessage.error(t('layout.header.refreshCacheFailure'));
|
||||
}
|
||||
} catch {
|
||||
createMessage.error(t('layout.header.refreshCacheFailure'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
clearCache,
|
||||
tooltipTitle: t('layout.header.dropdownItemRefreshCache'),
|
||||
};
|
||||
}
|
||||
@@ -44,7 +44,6 @@
|
||||
import { useI18n } from '/@/hooks/web/useI18n';
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { useMessage } from '/src/hooks/web/useMessage';
|
||||
import { useGo } from '/@/hooks/web/usePage';
|
||||
import headerImg from '/@/assets/images/header.jpg';
|
||||
import { propTypes } from '/@/utils/propTypes';
|
||||
@@ -52,15 +51,11 @@
|
||||
|
||||
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
|
||||
|
||||
import { refreshCache, queryAllDictItems } from '/@/views/system/dict/dict.api';
|
||||
import { DB_DICT_DATA_KEY } from '/src/enums/cacheEnum';
|
||||
import { removeAuthCache, setAuthCache } from '/src/utils/auth';
|
||||
import { useRefreshCache } from '../useRefreshCache';
|
||||
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
||||
import { getRefPromise } from '/@/utils/index';
|
||||
import { refreshDragCache } from "@/api/common/api";
|
||||
|
||||
type MenuEvent = 'logout' | 'doc' | 'lock' | 'cache' | 'depart' | 'defaultHomePage' | 'password' | 'account';
|
||||
const { createMessage } = useMessage();
|
||||
export default defineComponent({
|
||||
name: 'UserDropdown',
|
||||
components: {
|
||||
@@ -84,6 +79,7 @@
|
||||
const passwordVisible = ref(false);
|
||||
const lockActionVisible = ref(false);
|
||||
const lockActionRef = ref(null);
|
||||
const { clearCache } = useRefreshCache();
|
||||
|
||||
const getUserInfo = computed(() => {
|
||||
const { realname = '', avatar, desc } = userStore.getUserInfo || {};
|
||||
@@ -119,22 +115,6 @@
|
||||
openWindow(SITE_URL);
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
async function clearCache() {
|
||||
const result = await refreshCache();
|
||||
const dragRes = await refreshDragCache();
|
||||
console.log('dragRes', dragRes);
|
||||
if (result.success) {
|
||||
const res = await queryAllDictItems();
|
||||
removeAuthCache(DB_DICT_DATA_KEY);
|
||||
setAuthCache(DB_DICT_DATA_KEY, res.result);
|
||||
createMessage.success(t('layout.header.refreshCacheComplete'));
|
||||
// 代码逻辑说明: 【issues/7433】vue3 数据字典优化建议
|
||||
userStore.setAllDictItems(res.result);
|
||||
} else {
|
||||
createMessage.error(t('layout.header.refreshCacheFailure'));
|
||||
}
|
||||
}
|
||||
// 切换部门
|
||||
function updateCurrentDepart() {
|
||||
loginSelectRef.value.show();
|
||||
|
||||
@@ -29,10 +29,14 @@
|
||||
|
||||
<Notify v-if="getShowNotice" :class="`${prefixCls}-action__item notify-item`" />
|
||||
|
||||
<ImChat :class="`${prefixCls}-action__item im-chat-item`" />
|
||||
|
||||
<FullScreen v-if="getShowFullScreen" :class="`${prefixCls}-action__item fullscreen-item`" />
|
||||
|
||||
<LockScreen v-if="getUseLockPage" />
|
||||
|
||||
<RefreshCache />
|
||||
|
||||
<AppLocalePicker v-if="getShowLocalePicker" :reload="true" :showText="false" :class="`${prefixCls}-action__item`" />
|
||||
|
||||
<UserDropDown :theme="getHeaderTheme" />
|
||||
@@ -64,7 +68,7 @@
|
||||
import { SettingButtonPositionEnum } from '/@/enums/appEnum';
|
||||
import { AppLocalePicker } from '/@/components/Application';
|
||||
|
||||
import { UserDropDown, LayoutBreadcrumb, FullScreen, Notify, ErrorAction, LockScreen } from './components';
|
||||
import { UserDropDown, LayoutBreadcrumb, FullScreen, Notify, ImChat, ErrorAction, LockScreen, RefreshCache } from './components';
|
||||
import { useAppInject } from '/@/hooks/web/useAppInject';
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
|
||||
@@ -86,9 +90,11 @@
|
||||
LayoutBreadcrumb,
|
||||
LayoutMenu,
|
||||
UserDropDown,
|
||||
RefreshCache,
|
||||
AppLocalePicker,
|
||||
FullScreen,
|
||||
Notify,
|
||||
ImChat,
|
||||
AppSearch,
|
||||
ErrorAction,
|
||||
LockScreen,
|
||||
|
||||
@@ -198,6 +198,8 @@ export const useUserStore = defineStore({
|
||||
await this.setLoginInfo({ ...data, isLogin: true });
|
||||
// 代码逻辑说明: 登录成功后缓存拖拽模块的接口前缀
|
||||
localStorage.setItem(JDragConfigEnum.DRAG_BASE_URL, useGlobSetting().domainUrl);
|
||||
// 登录后异步预取 IM 聊天数据,减少打开聊天时的等待
|
||||
import('/@/views/system/im/imCache').then(({ prefetchImChatData }) => prefetchImChatData());
|
||||
|
||||
// 代码逻辑说明: 修复登录成功后,没有正确重定向的问题
|
||||
let redirect = router.currentRoute.value?.query?.redirect as string;
|
||||
@@ -284,6 +286,9 @@ export const useUserStore = defineStore({
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录前清除 IM 聊天缓存
|
||||
import('/@/views/system/im/imCache').then(({ clearImCache }) => clearImCache());
|
||||
|
||||
// let username:any = this.userInfo && this.userInfo.username;
|
||||
// if(username){
|
||||
// removeAuthCache(username)
|
||||
|
||||
993
jeecgboot-vue3/src/views/system/im/ImChat.vue
Normal file
993
jeecgboot-vue3/src/views/system/im/ImChat.vue
Normal file
@@ -0,0 +1,993 @@
|
||||
<template>
|
||||
<div :class="['im-chat-page', { 'im-chat-page--embedded': embedded }]">
|
||||
<div ref="chatRowRef" class="im-chat-row">
|
||||
<aside :class="['im-chat-left', { collapsed: leftCollapsed }]" :style="leftPanelStyle">
|
||||
<div class="im-chat-left-header">
|
||||
<div v-show="!leftCollapsed" class="header-main">
|
||||
<span class="title">IM聊天</span>
|
||||
<div class="dept-tip">{{ deptLabel }}</div>
|
||||
</div>
|
||||
<a-tooltip :title="leftCollapsed ? '展开列表' : '折叠列表'">
|
||||
<button type="button" class="collapse-btn" @click="toggleLeftCollapse">
|
||||
<Icon :icon="leftCollapsed ? 'ant-design:menu-unfold-outlined' : 'ant-design:menu-fold-outlined'" />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div v-show="!leftCollapsed" class="im-search-wrap">
|
||||
<a-input
|
||||
v-model:value="memberKeyword"
|
||||
placeholder="搜索同事"
|
||||
allow-clear
|
||||
size="small"
|
||||
class="im-search"
|
||||
@pressEnter="loadDeptMembers"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon icon="ant-design:search-outlined" class="im-search-icon" @click="loadDeptMembers" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
<a-spin :spinning="memberLoading" class="left-spin">
|
||||
<div class="conv-list">
|
||||
<a-tooltip
|
||||
v-for="item in deptMembers"
|
||||
:key="item.id"
|
||||
:title="leftCollapsed ? item.realname || item.username : ''"
|
||||
placement="right"
|
||||
>
|
||||
<div
|
||||
:class="['conv-item', activeTargetUserId === item.id ? 'active' : '']"
|
||||
@click="selectMember(item)"
|
||||
>
|
||||
<a-badge :count="shouldShowUnread(item) ? item.unreadCount : 0" :offset="[-2, 2]">
|
||||
<a-avatar :size="leftCollapsed ? 36 : 40" :src="getAvatarUrl(item.avatar)">
|
||||
{{ (item.realname || item.username || '?').slice(0, 1) }}
|
||||
</a-avatar>
|
||||
</a-badge>
|
||||
<div v-show="!leftCollapsed" class="conv-meta">
|
||||
<div class="conv-top">
|
||||
<span class="conv-name">{{ item.realname || item.username }}</span>
|
||||
<span class="conv-time">{{ formatTime(item.lastTime) }}</span>
|
||||
</div>
|
||||
<div class="conv-bottom">
|
||||
<span class="conv-preview">{{ item.lastContent || '点击开始聊天' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-empty v-if="!deptMembers.length && !leftCollapsed" description="本部门暂无其他同事" />
|
||||
</div>
|
||||
</a-spin>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
v-show="!leftCollapsed"
|
||||
class="im-resize-handle"
|
||||
:class="{ dragging: isResizing }"
|
||||
@mousedown="startResize"
|
||||
/>
|
||||
|
||||
<main class="im-chat-right">
|
||||
<div class="im-chat-right-header">
|
||||
<span class="chat-peer-name">
|
||||
{{ activeMember ? activeMember.realname || activeMember.username : 'IM聊天' }}
|
||||
</span>
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<button type="button" class="chat-settings-btn" @click.prevent>
|
||||
<Icon icon="ant-design:setting-outlined" />
|
||||
</button>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleSettingsMenuClick">
|
||||
<a-menu-item key="chatSettings">聊天设置</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
<template v-if="activeMember">
|
||||
<div ref="messageBoxRef" class="message-box" @scroll="handleMessageBoxScroll">
|
||||
<div v-if="msgLoading && messageList.length" class="load-more-hint">
|
||||
<a-spin size="small" />
|
||||
<span>加载更早的消息...</span>
|
||||
</div>
|
||||
<div v-else-if="hasMore && !messageList.length" class="load-more">
|
||||
<a-button type="link" size="small" :loading="msgLoading" @click="loadMoreMessages">查看更早的消息</a-button>
|
||||
</div>
|
||||
<div v-for="msg in messageList" :key="msg.id" :class="['message-row', msg.mine ? 'mine' : 'other']">
|
||||
<a-avatar :size="32" :src="getAvatarUrl(msg.senderAvatar)">
|
||||
{{ (msg.senderName || '?').slice(0, 1) }}
|
||||
</a-avatar>
|
||||
<div class="message-bubble">
|
||||
<div class="message-name" v-if="!msg.mine">{{ msg.senderName }}</div>
|
||||
<div class="message-content">{{ msg.content }}</div>
|
||||
<div class="message-time">{{ formatTime(msg.createTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-input">
|
||||
<a-textarea
|
||||
v-model:value="draft"
|
||||
:rows="3"
|
||||
placeholder="输入消息,Enter发送,Shift+Enter换行"
|
||||
@pressEnter="handlePressEnter"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<a-button type="primary" :loading="sending" @click="handleSend">发送</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-empty v-else class="empty-chat" description="请从左侧选择本部门同事开始聊天" />
|
||||
</main>
|
||||
</div>
|
||||
<ImChatSettingsModal @register="registerSettingsModal" @saved="onChatSettingsSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { ensureWebSocketConnected, onWebSocket, offWebSocket } from '/@/hooks/web/useWebSocket';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
||||
import { fetchDeptMembers, openConversation, fetchMessages, sendMessage, markRead } from './im.api';
|
||||
import { getImDefaultHistoryDays, getImDefaultStartTime, isWithinDefaultHistoryRange } from './imSettings';
|
||||
import ImChatSettingsModal from './ImChatSettingsModal.vue';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { syncImUnreadFromMembers } from './useImUnread';
|
||||
import {
|
||||
type ImMemberItem,
|
||||
type ImMessageItem,
|
||||
getCachedMembers,
|
||||
isMembersCacheStale,
|
||||
setCachedMembers,
|
||||
getCachedMessages,
|
||||
isMessagesCacheStale,
|
||||
setCachedMessages,
|
||||
appendCachedMessage,
|
||||
patchCachedMember,
|
||||
prefetchImChatData,
|
||||
setImActiveConversationId,
|
||||
} from './imCache';
|
||||
|
||||
defineOptions({ name: 'ImChat' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 嵌入弹窗模式,高度自适应容器 */
|
||||
embedded?: boolean;
|
||||
}>(),
|
||||
{
|
||||
embedded: false,
|
||||
},
|
||||
);
|
||||
|
||||
interface DeptMemberItem extends ImMemberItem {}
|
||||
|
||||
interface MessageItem extends ImMessageItem {}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const [registerSettingsModal, { openModal: openSettingsModal }] = useModal();
|
||||
const defaultHistoryDays = ref(getImDefaultHistoryDays());
|
||||
const currentUserId = computed(() => userStore.getUserInfo?.id || '');
|
||||
const deptLabel = computed(() => {
|
||||
const info = userStore.getUserInfo;
|
||||
return info?.orgCodeTxt || info?.orgCode || '本部门同事';
|
||||
});
|
||||
|
||||
const memberLoading = ref(false);
|
||||
const msgLoading = ref(false);
|
||||
const sending = ref(false);
|
||||
const memberKeyword = ref('');
|
||||
const draft = ref('');
|
||||
const deptMembers = ref<DeptMemberItem[]>([]);
|
||||
const messageList = ref<MessageItem[]>([]);
|
||||
const activeTargetUserId = ref('');
|
||||
const activeMember = ref<DeptMemberItem | null>(null);
|
||||
const activeConversationId = ref('');
|
||||
const messageBoxRef = ref<HTMLElement>();
|
||||
const pageSize = 20;
|
||||
const hasMore = ref(false);
|
||||
let loadMessagesSeq = 0;
|
||||
let scrollLoading = false;
|
||||
|
||||
const chatRowRef = ref<HTMLElement>();
|
||||
const LEFT_WIDTH_KEY = 'im-chat-left-width';
|
||||
const LEFT_COLLAPSED_KEY = 'im-chat-left-collapsed';
|
||||
const COLLAPSED_WIDTH = 56;
|
||||
const MIN_LEFT_WIDTH = 220;
|
||||
const MAX_LEFT_WIDTH = 420;
|
||||
const DEFAULT_LEFT_WIDTH = 280;
|
||||
|
||||
const leftWidth = ref(DEFAULT_LEFT_WIDTH);
|
||||
const leftCollapsed = ref(false);
|
||||
const isResizing = ref(false);
|
||||
let resizeStartX = 0;
|
||||
let resizeStartWidth = 0;
|
||||
|
||||
const leftPanelStyle = computed(() => ({
|
||||
width: `${leftCollapsed.value ? COLLAPSED_WIDTH : leftWidth.value}px`,
|
||||
}));
|
||||
|
||||
function loadLeftPanelPreference() {
|
||||
const savedWidth = Number(localStorage.getItem(LEFT_WIDTH_KEY));
|
||||
if (!Number.isNaN(savedWidth) && savedWidth >= MIN_LEFT_WIDTH && savedWidth <= MAX_LEFT_WIDTH) {
|
||||
leftWidth.value = savedWidth;
|
||||
}
|
||||
leftCollapsed.value = localStorage.getItem(LEFT_COLLAPSED_KEY) === '1';
|
||||
}
|
||||
|
||||
function saveLeftWidth() {
|
||||
localStorage.setItem(LEFT_WIDTH_KEY, String(leftWidth.value));
|
||||
}
|
||||
|
||||
function toggleLeftCollapse() {
|
||||
leftCollapsed.value = !leftCollapsed.value;
|
||||
localStorage.setItem(LEFT_COLLAPSED_KEY, leftCollapsed.value ? '1' : '0');
|
||||
}
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
if (leftCollapsed.value) {
|
||||
return;
|
||||
}
|
||||
isResizing.value = true;
|
||||
resizeStartX = e.clientX;
|
||||
resizeStartWidth = leftWidth.value;
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.addEventListener('mousemove', onResizeMove);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
}
|
||||
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
if (!isResizing.value) {
|
||||
return;
|
||||
}
|
||||
const delta = e.clientX - resizeStartX;
|
||||
leftWidth.value = Math.min(MAX_LEFT_WIDTH, Math.max(MIN_LEFT_WIDTH, resizeStartWidth + delta));
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (!isResizing.value) {
|
||||
return;
|
||||
}
|
||||
isResizing.value = false;
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', onResizeMove);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
saveLeftWidth();
|
||||
}
|
||||
|
||||
function getAvatarUrl(avatar?: string) {
|
||||
return avatar ? getFileAccessHttpUrl(avatar) : '';
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const d = dayjs(value);
|
||||
if (!d.isValid()) {
|
||||
return '';
|
||||
}
|
||||
if (d.isSame(dayjs(), 'day')) {
|
||||
return d.format('HH:mm');
|
||||
}
|
||||
return d.format('MM-DD HH:mm');
|
||||
}
|
||||
|
||||
/** 仅保留默认天数范围内的消息 */
|
||||
function filterDefaultRangeMessages(records: MessageItem[]) {
|
||||
return records.filter((item) => isWithinDefaultHistoryRange(item.createTime));
|
||||
}
|
||||
|
||||
function handleSettingsMenuClick({ key }: { key: string }) {
|
||||
if (key === 'chatSettings') {
|
||||
openSettingsModal(true, {});
|
||||
}
|
||||
}
|
||||
|
||||
function onChatSettingsSaved() {
|
||||
defaultHistoryDays.value = getImDefaultHistoryDays();
|
||||
if (activeConversationId.value) {
|
||||
loadMessages(true);
|
||||
}
|
||||
prefetchImChatData(true);
|
||||
}
|
||||
|
||||
async function loadDeptMembers(silent = false, force = false) {
|
||||
const keyword = memberKeyword.value.trim();
|
||||
let usedCache = false;
|
||||
|
||||
if (!keyword && !force) {
|
||||
const cached = getCachedMembers();
|
||||
if (cached?.length) {
|
||||
deptMembers.value = cached;
|
||||
syncImUnreadFromMembers(cached);
|
||||
syncActiveMember();
|
||||
usedCache = true;
|
||||
if (!isMembersCacheStale()) {
|
||||
return;
|
||||
}
|
||||
silent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent && !usedCache) {
|
||||
memberLoading.value = true;
|
||||
}
|
||||
try {
|
||||
deptMembers.value = (await fetchDeptMembers(keyword || undefined)) || [];
|
||||
if (!keyword) {
|
||||
setCachedMembers(deptMembers.value);
|
||||
}
|
||||
syncImUnreadFromMembers(deptMembers.value);
|
||||
syncActiveMember();
|
||||
} finally {
|
||||
if (!silent && !usedCache) {
|
||||
memberLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前会话不展示未读角标 */
|
||||
function shouldShowUnread(item: DeptMemberItem) {
|
||||
return (item.unreadCount || 0) > 0 && activeTargetUserId.value !== item.id;
|
||||
}
|
||||
|
||||
/** 无感更新左侧列表项(不请求接口、不触发 loading) */
|
||||
function patchDeptMember(
|
||||
userId: string,
|
||||
patch: Partial<DeptMemberItem>,
|
||||
options?: { moveToTop?: boolean; unreadIncrement?: number },
|
||||
) {
|
||||
const index = deptMembers.value.findIndex((item) => item.id === userId);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const current = deptMembers.value[index];
|
||||
const updated: DeptMemberItem = {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
if (options?.unreadIncrement) {
|
||||
updated.unreadCount = (current.unreadCount || 0) + options.unreadIncrement;
|
||||
}
|
||||
const list = deptMembers.value.slice();
|
||||
list.splice(index, 1);
|
||||
if (options?.moveToTop !== false) {
|
||||
list.unshift(updated);
|
||||
} else {
|
||||
list.splice(index, 0, updated);
|
||||
}
|
||||
deptMembers.value = list;
|
||||
patchCachedMember(userId, patch, options);
|
||||
syncImUnreadFromMembers(deptMembers.value);
|
||||
if (activeTargetUserId.value === userId) {
|
||||
activeMember.value = { ...(activeMember.value || updated), ...updated };
|
||||
}
|
||||
}
|
||||
|
||||
function syncActiveMember() {
|
||||
if (!activeTargetUserId.value) {
|
||||
return;
|
||||
}
|
||||
const found = deptMembers.value.find((item) => item.id === activeTargetUserId.value);
|
||||
if (found) {
|
||||
activeMember.value = found;
|
||||
if (found.conversationId) {
|
||||
activeConversationId.value = found.conversationId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否还有更早的消息可加载 */
|
||||
function updateHasMore(latestBatch: MessageItem[]) {
|
||||
if (latestBatch.length >= pageSize) {
|
||||
hasMore.value = true;
|
||||
return;
|
||||
}
|
||||
const lastTime = activeMember.value?.lastTime;
|
||||
if (!lastTime) {
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
const oldestLoaded = messageList.value[0]?.createTime;
|
||||
if (!oldestLoaded) {
|
||||
hasMore.value = dayjs(lastTime).isBefore(dayjs().subtract(defaultHistoryDays.value, 'day'));
|
||||
return;
|
||||
}
|
||||
hasMore.value = dayjs(lastTime).isBefore(dayjs(oldestLoaded));
|
||||
}
|
||||
|
||||
async function loadMessages(reset = true) {
|
||||
if (!activeConversationId.value) {
|
||||
messageList.value = [];
|
||||
return;
|
||||
}
|
||||
const conversationId = activeConversationId.value;
|
||||
const requestSeq = ++loadMessagesSeq;
|
||||
let displayedFromCache = false;
|
||||
|
||||
if (reset) {
|
||||
const cached = getCachedMessages(conversationId);
|
||||
if (cached?.records.length) {
|
||||
messageList.value = filterDefaultRangeMessages(cached.records);
|
||||
updateHasMore(messageList.value);
|
||||
displayedFromCache = true;
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
markRead(conversationId).catch(() => {});
|
||||
if (activeTargetUserId.value) {
|
||||
patchDeptMember(activeTargetUserId.value, { unreadCount: 0 }, { moveToTop: false });
|
||||
}
|
||||
if (!isMessagesCacheStale(conversationId)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
messageList.value = [];
|
||||
updateHasMore([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayedFromCache) {
|
||||
msgLoading.value = true;
|
||||
}
|
||||
try {
|
||||
const page = await fetchMessages(
|
||||
reset
|
||||
? { conversationId, pageSize, startTime: getImDefaultStartTime() }
|
||||
: {
|
||||
conversationId,
|
||||
pageSize,
|
||||
beforeTime: messageList.value.length > 0 ? messageList.value[0].createTime : getImDefaultStartTime(),
|
||||
},
|
||||
);
|
||||
if (requestSeq !== loadMessagesSeq || conversationId !== activeConversationId.value) {
|
||||
return;
|
||||
}
|
||||
const records: MessageItem[] = page?.records || [];
|
||||
if (reset) {
|
||||
if (records.length > 0 || !displayedFromCache) {
|
||||
messageList.value = records;
|
||||
setCachedMessages(conversationId, records, records.length >= pageSize, {
|
||||
allowEmpty: records.length === 0,
|
||||
});
|
||||
}
|
||||
updateHasMore(records.length > 0 ? records : messageList.value);
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
markRead(conversationId).catch(() => {});
|
||||
if (activeTargetUserId.value) {
|
||||
patchDeptMember(activeTargetUserId.value, { unreadCount: 0 }, { moveToTop: false });
|
||||
}
|
||||
} else if (records.length > 0) {
|
||||
messageList.value = [...records, ...messageList.value];
|
||||
updateHasMore(records);
|
||||
} else {
|
||||
hasMore.value = false;
|
||||
}
|
||||
} catch {
|
||||
// 请求失败时保留已展示的内容
|
||||
} finally {
|
||||
if (requestSeq === loadMessagesSeq) {
|
||||
msgLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreMessages() {
|
||||
if (!activeConversationId.value || msgLoading.value || !hasMore.value) {
|
||||
return;
|
||||
}
|
||||
const box = messageBoxRef.value;
|
||||
const prevScrollHeight = box?.scrollHeight || 0;
|
||||
await loadMessages(false);
|
||||
await nextTick();
|
||||
if (box) {
|
||||
box.scrollTop = box.scrollHeight - prevScrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessageBoxScroll() {
|
||||
const box = messageBoxRef.value;
|
||||
if (!box || msgLoading.value || !hasMore.value || scrollLoading) {
|
||||
return;
|
||||
}
|
||||
if (box.scrollTop > 80) {
|
||||
return;
|
||||
}
|
||||
scrollLoading = true;
|
||||
try {
|
||||
await loadMoreMessages();
|
||||
} finally {
|
||||
scrollLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectMember(item: DeptMemberItem) {
|
||||
if (activeTargetUserId.value === item.id && activeConversationId.value) {
|
||||
return;
|
||||
}
|
||||
activeTargetUserId.value = item.id;
|
||||
activeMember.value = item;
|
||||
|
||||
if (item.conversationId) {
|
||||
activeConversationId.value = item.conversationId;
|
||||
setImActiveConversationId(item.conversationId);
|
||||
await loadMessages(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const conv = await openConversation(item.id);
|
||||
activeConversationId.value = conv.conversationId;
|
||||
setImActiveConversationId(conv.conversationId);
|
||||
activeMember.value = {
|
||||
...item,
|
||||
conversationId: conv.conversationId,
|
||||
lastContent: conv.lastContent,
|
||||
lastTime: conv.lastTime,
|
||||
unreadCount: 0,
|
||||
};
|
||||
patchDeptMember(item.id, {
|
||||
conversationId: conv.conversationId,
|
||||
lastContent: conv.lastContent,
|
||||
lastTime: conv.lastTime,
|
||||
unreadCount: 0,
|
||||
}, { moveToTop: false });
|
||||
await loadMessages(true);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = messageBoxRef.value;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const content = draft.value.trim();
|
||||
if (!content || !activeConversationId.value) {
|
||||
return;
|
||||
}
|
||||
sending.value = true;
|
||||
try {
|
||||
const msg = await sendMessage({ conversationId: activeConversationId.value, content, msgType: 'text' });
|
||||
messageList.value.push(msg);
|
||||
appendCachedMessage(activeConversationId.value, msg);
|
||||
draft.value = '';
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
patchDeptMember(activeTargetUserId.value, {
|
||||
conversationId: activeConversationId.value,
|
||||
lastContent: msg.content,
|
||||
lastTime: msg.createTime,
|
||||
unreadCount: 0,
|
||||
});
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePressEnter(e: KeyboardEvent) {
|
||||
if (e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
|
||||
function onChatSocket(data: Record<string, any>) {
|
||||
if (data.cmd !== 'chat') {
|
||||
return;
|
||||
}
|
||||
const conversationId = data.conversationId as string;
|
||||
const senderId = data.senderId as string;
|
||||
const isActiveConversation = !!conversationId && conversationId === activeConversationId.value;
|
||||
|
||||
if (isActiveConversation) {
|
||||
const exists = messageList.value.some((item) => item.id === data.messageId);
|
||||
if (!exists) {
|
||||
messageList.value.push({
|
||||
id: data.messageId,
|
||||
conversationId,
|
||||
senderId,
|
||||
senderName: data.senderName,
|
||||
senderAvatar: data.senderAvatar,
|
||||
content: data.content,
|
||||
msgType: data.msgType,
|
||||
mine: senderId === currentUserId.value,
|
||||
createTime: data.createTime,
|
||||
});
|
||||
appendCachedMessage(conversationId, messageList.value[messageList.value.length - 1]);
|
||||
nextTick(() => scrollToBottom());
|
||||
}
|
||||
markRead(conversationId);
|
||||
if (activeTargetUserId.value) {
|
||||
patchDeptMember(activeTargetUserId.value, {
|
||||
conversationId,
|
||||
lastContent: data.content,
|
||||
lastTime: data.createTime,
|
||||
unreadCount: 0,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 非当前会话:仅本地更新摘要与未读,不整表刷新
|
||||
patchDeptMember(
|
||||
senderId,
|
||||
{
|
||||
conversationId,
|
||||
lastContent: data.content,
|
||||
lastTime: data.createTime,
|
||||
},
|
||||
{ unreadIncrement: 1 },
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLeftPanelPreference();
|
||||
ensureWebSocketConnected();
|
||||
onWebSocket(onChatSocket);
|
||||
loadDeptMembers();
|
||||
prefetchImChatData();
|
||||
});
|
||||
|
||||
/** 弹窗再次打开时,若消息被并发请求清空则从缓存恢复 */
|
||||
function restoreSessionIfNeeded() {
|
||||
if (!activeConversationId.value || messageList.value.length > 0) {
|
||||
return;
|
||||
}
|
||||
const cached = getCachedMessages(activeConversationId.value);
|
||||
if (cached?.records.length) {
|
||||
messageList.value = filterDefaultRangeMessages(cached.records);
|
||||
updateHasMore(messageList.value);
|
||||
nextTick(() => scrollToBottom());
|
||||
return;
|
||||
}
|
||||
loadMessages(true);
|
||||
}
|
||||
|
||||
defineExpose({ restoreSessionIfNeeded });
|
||||
|
||||
onUnmounted(() => {
|
||||
offWebSocket(onChatSocket);
|
||||
setImActiveConversationId('');
|
||||
stopResize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-chat-page {
|
||||
height: calc(100vh - 120px);
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
|
||||
&--embedded {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.im-chat-row {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.im-chat-left {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
&.collapsed {
|
||||
.im-chat-left-header {
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
justify-content: center;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im-resize-handle {
|
||||
flex-shrink: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging {
|
||||
background: rgba(22, 119, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.im-chat-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-spin {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.ant-spin-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.im-chat-left-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 12px 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
|
||||
.header-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.dept-tip {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.im-search-wrap {
|
||||
padding: 8px 10px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.im-search {
|
||||
width: 100%;
|
||||
|
||||
:deep(.ant-input) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.im-search-icon {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0 8px;
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: #eef4ff;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conv-top,
|
||||
.conv-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conv-top {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-time,
|
||||
.conv-preview,
|
||||
.message-time {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.conv-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.im-chat-right-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-weight: 600;
|
||||
|
||||
.chat-peer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-settings-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.message-box {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.load-more,
|
||||
.load-more-hint {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.load-more-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.mine {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-bubble {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 60%;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.message-name {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
margin-top: 120px;
|
||||
}
|
||||
</style>
|
||||
62
jeecgboot-vue3/src/views/system/im/ImChatModal.vue
Normal file
62
jeecgboot-vue3/src/views/system/im/ImChatModal.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-bind="$attrs"
|
||||
title="IM聊天"
|
||||
:width="980"
|
||||
:footer="null"
|
||||
:canFullscreen="true"
|
||||
:destroyOnClose="false"
|
||||
wrapClassName="im-chat-modal-wrap"
|
||||
@register="registerModal"
|
||||
@open-change="handleOpenChange"
|
||||
>
|
||||
<div class="im-chat-modal-body">
|
||||
<ImChat ref="imChatRef" embedded />
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import ImChat from './ImChat.vue';
|
||||
|
||||
defineOptions({ name: 'ImChatModal' });
|
||||
|
||||
const imChatRef = ref<InstanceType<typeof ImChat>>();
|
||||
|
||||
function restoreChatSession() {
|
||||
nextTick(() => {
|
||||
imChatRef.value?.restoreSessionIfNeeded?.();
|
||||
});
|
||||
}
|
||||
|
||||
const [registerModal] = useModalInner(() => {
|
||||
restoreChatSession();
|
||||
});
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
restoreChatSession();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.im-chat-modal-wrap {
|
||||
.im-chat-modal-body {
|
||||
height: 70vh;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.im-chat-page--embedded {
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.im-chat-row {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
jeecgboot-vue3/src/views/system/im/ImChatSettingsModal.vue
Normal file
76
jeecgboot-vue3/src/views/system/im/ImChatSettingsModal.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-bind="$attrs"
|
||||
title="聊天设置"
|
||||
:width="480"
|
||||
@register="registerModal"
|
||||
@ok="handleSubmit"
|
||||
>
|
||||
<a-form layout="vertical" class="im-chat-settings-form">
|
||||
<a-form-item label="聊天记录默认天数">
|
||||
<a-input-number
|
||||
v-model:value="historyDays"
|
||||
:min="IM_HISTORY_DAYS_MIN"
|
||||
:max="IM_HISTORY_DAYS_MAX"
|
||||
:step="0.1"
|
||||
:precision="1"
|
||||
style="width: 100%"
|
||||
placeholder="0.1 ~ 7"
|
||||
/>
|
||||
<div class="form-tip">聊天页面默认展示最近 {{ displayDays }} 天的记录,向上滚动可加载更早消息</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, unref } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import {
|
||||
clampImHistoryDays,
|
||||
getImDefaultHistoryDays,
|
||||
IM_HISTORY_DAYS_MAX,
|
||||
IM_HISTORY_DAYS_MIN,
|
||||
setImDefaultHistoryDays,
|
||||
} from './imSettings';
|
||||
|
||||
defineOptions({ name: 'ImChatSettingsModal' });
|
||||
|
||||
const emit = defineEmits<{ saved: [] }>();
|
||||
|
||||
const historyDays = ref(getImDefaultHistoryDays());
|
||||
|
||||
const displayDays = computed(() => clampImHistoryDays(unref(historyDays)));
|
||||
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(() => {
|
||||
historyDays.value = getImDefaultHistoryDays();
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const days = clampImHistoryDays(unref(historyDays));
|
||||
if (days < IM_HISTORY_DAYS_MIN || days > IM_HISTORY_DAYS_MAX) {
|
||||
return;
|
||||
}
|
||||
setModalProps({ confirmLoading: true });
|
||||
try {
|
||||
setImDefaultHistoryDays(days);
|
||||
emit('saved');
|
||||
closeModal();
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-chat-settings-form {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
41
jeecgboot-vue3/src/views/system/im/im.api.ts
Normal file
41
jeecgboot-vue3/src/views/system/im/im.api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
deptMembers = '/sys/im/chat/deptMembers',
|
||||
open = '/sys/im/chat/open',
|
||||
messages = '/sys/im/chat/messages',
|
||||
send = '/sys/im/chat/send',
|
||||
read = '/sys/im/chat/read',
|
||||
}
|
||||
|
||||
export const fetchDeptMembers = (keyword?: string) => defHttp.get({ url: Api.deptMembers, params: { keyword } });
|
||||
|
||||
export const openConversation = (targetUserId: string) =>
|
||||
defHttp.post({ url: Api.open, params: { targetUserId } }, { joinParamsToUrl: true });
|
||||
|
||||
export interface FetchMessagesParams {
|
||||
conversationId: string;
|
||||
pageSize?: number;
|
||||
/** 起始时间(含),默认首屏传用户配置的默认天数 */
|
||||
startTime?: string;
|
||||
/** 加载更早消息:取该时间之前的记录 */
|
||||
beforeTime?: string;
|
||||
}
|
||||
|
||||
export const fetchMessages = (params: FetchMessagesParams) =>
|
||||
defHttp.get({
|
||||
url: Api.messages,
|
||||
params: {
|
||||
pageNo: 1,
|
||||
pageSize: params.pageSize ?? 20,
|
||||
conversationId: params.conversationId,
|
||||
startTime: params.startTime,
|
||||
beforeTime: params.beforeTime,
|
||||
},
|
||||
});
|
||||
|
||||
export const sendMessage = (data: { conversationId: string; content: string; msgType?: string }) =>
|
||||
defHttp.post({ url: Api.send, data });
|
||||
|
||||
export const markRead = (conversationId: string) =>
|
||||
defHttp.put({ url: Api.read, params: { conversationId } }, { joinParamsToUrl: true, successMessageMode: 'none' });
|
||||
299
jeecgboot-vue3/src/views/system/im/imCache.ts
Normal file
299
jeecgboot-vue3/src/views/system/im/imCache.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { createSessionStorage } from '/@/utils/cache';
|
||||
import { useUserStoreWithOut } from '/@/store/modules/user';
|
||||
import { fetchDeptMembers, fetchMessages } from './im.api';
|
||||
import { getImDefaultStartTime } from './imSettings';
|
||||
import { syncImUnreadFromMembers } from './useImUnread';
|
||||
|
||||
export interface ImMemberItem {
|
||||
id: string;
|
||||
username: string;
|
||||
realname?: string;
|
||||
avatar?: string;
|
||||
orgCodeTxt?: string;
|
||||
conversationId?: string;
|
||||
lastContent?: string;
|
||||
lastTime?: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
export interface ImMessageItem {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
senderAvatar?: string;
|
||||
content: string;
|
||||
msgType?: string;
|
||||
mine?: boolean;
|
||||
createTime?: string;
|
||||
}
|
||||
|
||||
export interface ImMessageCacheEntry {
|
||||
records: ImMessageItem[];
|
||||
hasMore: boolean;
|
||||
loadedAt: number;
|
||||
}
|
||||
|
||||
interface ImCacheSnapshot {
|
||||
members: ImMemberItem[];
|
||||
membersLoadedAt: number;
|
||||
messages: Record<string, ImMessageCacheEntry>;
|
||||
}
|
||||
|
||||
const MEMBERS_STALE_MS = 60_000;
|
||||
const MESSAGES_STALE_MS = 30_000;
|
||||
const PREFETCH_CONV_LIMIT = 5;
|
||||
const MESSAGE_PAGE_SIZE = 20;
|
||||
const CACHE_KEY = 'im-chat-data';
|
||||
|
||||
const sessionCache = createSessionStorage({ timeout: 60 * 60 });
|
||||
|
||||
let memorySnapshot: ImCacheSnapshot | null = null;
|
||||
let prefetchPromise: Promise<void> | null = null;
|
||||
let activeConversationId = '';
|
||||
|
||||
function getCacheScopeKey(): string | null {
|
||||
const userStore = useUserStoreWithOut();
|
||||
const userId = userStore.getUserInfo?.id;
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
const tenantId = userStore.getTenant;
|
||||
return `${tenantId || '0'}:${userId}`;
|
||||
}
|
||||
|
||||
function loadFromSession(): ImCacheSnapshot | null {
|
||||
const scope = getCacheScopeKey();
|
||||
if (!scope) {
|
||||
return null;
|
||||
}
|
||||
return sessionCache.get(`${CACHE_KEY}:${scope}`) || null;
|
||||
}
|
||||
|
||||
function saveToSession(snapshot: ImCacheSnapshot) {
|
||||
const scope = getCacheScopeKey();
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
sessionCache.set(`${CACHE_KEY}:${scope}`, snapshot);
|
||||
}
|
||||
|
||||
function ensureMemory(): ImCacheSnapshot {
|
||||
if (!memorySnapshot) {
|
||||
memorySnapshot = loadFromSession() || {
|
||||
members: [],
|
||||
membersLoadedAt: 0,
|
||||
messages: {},
|
||||
};
|
||||
}
|
||||
return memorySnapshot;
|
||||
}
|
||||
|
||||
export function setImActiveConversationId(conversationId: string) {
|
||||
activeConversationId = conversationId || '';
|
||||
}
|
||||
|
||||
export function clearImCache() {
|
||||
const scope = getCacheScopeKey();
|
||||
memorySnapshot = null;
|
||||
prefetchPromise = null;
|
||||
activeConversationId = '';
|
||||
if (scope) {
|
||||
sessionCache.remove(`${CACHE_KEY}:${scope}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedMembers(): ImMemberItem[] | null {
|
||||
const snap = ensureMemory();
|
||||
return snap.members.length ? snap.members : null;
|
||||
}
|
||||
|
||||
export function isMembersCacheStale(): boolean {
|
||||
const snap = ensureMemory();
|
||||
if (!snap.membersLoadedAt || !snap.members.length) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - snap.membersLoadedAt > MEMBERS_STALE_MS;
|
||||
}
|
||||
|
||||
export function setCachedMembers(members: ImMemberItem[]) {
|
||||
const snap = ensureMemory();
|
||||
snap.members = members;
|
||||
snap.membersLoadedAt = Date.now();
|
||||
saveToSession(snap);
|
||||
}
|
||||
|
||||
export function getCachedMessages(conversationId: string): ImMessageCacheEntry | null {
|
||||
return ensureMemory().messages[conversationId] || null;
|
||||
}
|
||||
|
||||
export function isMessagesCacheStale(conversationId: string): boolean {
|
||||
const entry = getCachedMessages(conversationId);
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - entry.loadedAt > MESSAGES_STALE_MS;
|
||||
}
|
||||
|
||||
export function setCachedMessages(
|
||||
conversationId: string,
|
||||
records: ImMessageItem[],
|
||||
hasMore: boolean,
|
||||
options?: { allowEmpty?: boolean },
|
||||
) {
|
||||
const existing = getCachedMessages(conversationId);
|
||||
if (!options?.allowEmpty && records.length === 0 && existing?.records?.length) {
|
||||
return;
|
||||
}
|
||||
const snap = ensureMemory();
|
||||
snap.messages[conversationId] = {
|
||||
records: [...records],
|
||||
hasMore,
|
||||
loadedAt: Date.now(),
|
||||
};
|
||||
saveToSession(snap);
|
||||
}
|
||||
|
||||
export function appendCachedMessage(conversationId: string, msg: ImMessageItem) {
|
||||
const snap = ensureMemory();
|
||||
if (!snap.messages[conversationId]) {
|
||||
snap.messages[conversationId] = {
|
||||
records: [],
|
||||
hasMore: false,
|
||||
loadedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
const entry = snap.messages[conversationId];
|
||||
if (entry.records.some((item) => item.id === msg.id)) {
|
||||
return;
|
||||
}
|
||||
entry.records.push(msg);
|
||||
entry.loadedAt = Date.now();
|
||||
saveToSession(snap);
|
||||
}
|
||||
|
||||
export function patchCachedMember(
|
||||
userId: string,
|
||||
patch: Partial<ImMemberItem>,
|
||||
options?: { moveToTop?: boolean; unreadIncrement?: number },
|
||||
) {
|
||||
const snap = ensureMemory();
|
||||
const index = snap.members.findIndex((item) => item.id === userId);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const current = snap.members[index];
|
||||
const updated: ImMemberItem = {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
if (options?.unreadIncrement) {
|
||||
updated.unreadCount = (current.unreadCount || 0) + options.unreadIncrement;
|
||||
}
|
||||
const list = snap.members.slice();
|
||||
list.splice(index, 1);
|
||||
if (options?.moveToTop !== false) {
|
||||
list.unshift(updated);
|
||||
} else {
|
||||
list.splice(index, 0, updated);
|
||||
}
|
||||
snap.members = list;
|
||||
saveToSession(snap);
|
||||
}
|
||||
|
||||
async function prefetchConversationMessages(conversationId: string) {
|
||||
const cached = getCachedMessages(conversationId);
|
||||
if (cached && !isMessagesCacheStale(conversationId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const page = await fetchMessages({
|
||||
conversationId,
|
||||
pageSize: MESSAGE_PAGE_SIZE,
|
||||
startTime: getImDefaultStartTime(),
|
||||
});
|
||||
const records: ImMessageItem[] = page?.records || [];
|
||||
if (records.length > 0) {
|
||||
setCachedMessages(conversationId, records, records.length >= MESSAGE_PAGE_SIZE);
|
||||
}
|
||||
} catch {
|
||||
// 预取失败不影响主流程,保留已有缓存
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录后/进入系统后异步预取 IM 数据(同事列表 + 最近会话首屏消息) */
|
||||
export async function prefetchImChatData(force = false): Promise<void> {
|
||||
if (prefetchPromise && !force) {
|
||||
return prefetchPromise;
|
||||
}
|
||||
prefetchPromise = (async () => {
|
||||
try {
|
||||
const members = ((await fetchDeptMembers()) || []) as ImMemberItem[];
|
||||
setCachedMembers(members);
|
||||
syncImUnreadFromMembers(members);
|
||||
|
||||
const candidates = members
|
||||
.filter((item) => item.conversationId && (item.unreadCount || item.lastTime))
|
||||
.sort((a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf())
|
||||
.slice(0, PREFETCH_CONV_LIMIT);
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 3) {
|
||||
const batch = candidates.slice(i, i + 3);
|
||||
await Promise.all(batch.map((item) => prefetchConversationMessages(item.conversationId!)));
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,进入聊天页时会重新拉取
|
||||
} finally {
|
||||
prefetchPromise = null;
|
||||
}
|
||||
})();
|
||||
return prefetchPromise;
|
||||
}
|
||||
|
||||
/** 顶部角标:收到新消息时更新缓存未读数 */
|
||||
export function handleImChatSocket(data: Record<string, any>) {
|
||||
if (data.cmd !== 'chat') {
|
||||
return;
|
||||
}
|
||||
const conversationId = data.conversationId as string;
|
||||
const senderId = data.senderId as string;
|
||||
const isActiveConversation = !!conversationId && conversationId === activeConversationId;
|
||||
|
||||
if (isActiveConversation) {
|
||||
const userStore = useUserStoreWithOut();
|
||||
const currentUserId = userStore.getUserInfo?.id || '';
|
||||
appendCachedMessage(conversationId, {
|
||||
id: data.messageId,
|
||||
conversationId,
|
||||
senderId,
|
||||
senderName: data.senderName,
|
||||
senderAvatar: data.senderAvatar,
|
||||
content: data.content,
|
||||
msgType: data.msgType,
|
||||
mine: senderId === currentUserId,
|
||||
createTime: data.createTime,
|
||||
});
|
||||
patchCachedMember(
|
||||
senderId,
|
||||
{
|
||||
conversationId,
|
||||
lastContent: data.content,
|
||||
lastTime: data.createTime,
|
||||
unreadCount: 0,
|
||||
},
|
||||
{ moveToTop: true },
|
||||
);
|
||||
} else {
|
||||
patchCachedMember(
|
||||
senderId,
|
||||
{
|
||||
conversationId,
|
||||
lastContent: data.content,
|
||||
lastTime: data.createTime,
|
||||
},
|
||||
{ unreadIncrement: 1 },
|
||||
);
|
||||
}
|
||||
syncImUnreadFromMembers(getCachedMembers() || []);
|
||||
}
|
||||
48
jeecgboot-vue3/src/views/system/im/imSettings.ts
Normal file
48
jeecgboot-vue3/src/views/system/im/imSettings.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { useUserStoreWithOut } from '/@/store/modules/user';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'im-chat-default-days';
|
||||
const DEFAULT_DAYS = 7;
|
||||
|
||||
export const IM_HISTORY_DAYS_MIN = 0.1;
|
||||
export const IM_HISTORY_DAYS_MAX = 7;
|
||||
|
||||
export function clampImHistoryDays(days: number): number {
|
||||
const value = Number(days);
|
||||
if (Number.isNaN(value)) {
|
||||
return DEFAULT_DAYS;
|
||||
}
|
||||
return Math.min(IM_HISTORY_DAYS_MAX, Math.max(IM_HISTORY_DAYS_MIN, Math.round(value * 10) / 10));
|
||||
}
|
||||
|
||||
function getStorageKey(): string {
|
||||
const userStore = useUserStoreWithOut();
|
||||
const userId = userStore.getUserInfo?.id || 'anonymous';
|
||||
const tenantId = userStore.getTenant || '0';
|
||||
return `${STORAGE_KEY_PREFIX}:${tenantId}:${userId}`;
|
||||
}
|
||||
|
||||
/** 聊天记录默认展示天数(0.1~7) */
|
||||
export function getImDefaultHistoryDays(): number {
|
||||
const raw = localStorage.getItem(getStorageKey());
|
||||
if (!raw) {
|
||||
return DEFAULT_DAYS;
|
||||
}
|
||||
return clampImHistoryDays(Number(raw));
|
||||
}
|
||||
|
||||
export function setImDefaultHistoryDays(days: number) {
|
||||
localStorage.setItem(getStorageKey(), String(clampImHistoryDays(days)));
|
||||
}
|
||||
|
||||
/** 默认展示范围的起始时间 */
|
||||
export function getImDefaultStartTime(): string {
|
||||
return dayjs().subtract(getImDefaultHistoryDays(), 'day').format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
export function isWithinDefaultHistoryRange(time?: string): boolean {
|
||||
if (!time) {
|
||||
return false;
|
||||
}
|
||||
return !dayjs(time).isBefore(dayjs().subtract(getImDefaultHistoryDays(), 'day'));
|
||||
}
|
||||
39
jeecgboot-vue3/src/views/system/im/useImUnread.ts
Normal file
39
jeecgboot-vue3/src/views/system/im/useImUnread.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ref } from 'vue';
|
||||
import { fetchDeptMembers } from './im.api';
|
||||
import { getCachedMembers, isMembersCacheStale, setCachedMembers } from './imCache';
|
||||
|
||||
const totalUnread = ref(0);
|
||||
let refreshing = false;
|
||||
|
||||
export function syncImUnreadFromMembers(members: Array<{ unreadCount?: number }>) {
|
||||
totalUnread.value = (members || []).reduce((sum, item) => sum + (item.unreadCount || 0), 0);
|
||||
}
|
||||
|
||||
export async function refreshImUnread(force = false) {
|
||||
if (refreshing) {
|
||||
return;
|
||||
}
|
||||
if (!force) {
|
||||
const cached = getCachedMembers();
|
||||
if (cached && !isMembersCacheStale()) {
|
||||
syncImUnreadFromMembers(cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
refreshing = true;
|
||||
try {
|
||||
const members = await fetchDeptMembers();
|
||||
setCachedMembers(members || []);
|
||||
syncImUnreadFromMembers(members || []);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useImUnread() {
|
||||
return {
|
||||
totalUnread,
|
||||
refreshImUnread,
|
||||
syncImUnreadFromMembers,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user