新增IM聊天
This commit is contained in:
@@ -22,6 +22,7 @@ import java.lang.reflect.Array;
|
|||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.math.RoundingMode;
|
||||||
import java.net.*;
|
import java.net.*;
|
||||||
import java.sql.Date;
|
import java.sql.Date;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -1046,7 +1047,7 @@ public class oConvertUtils {
|
|||||||
BigDecimal bigDecimal = new BigDecimal(uploadCount);
|
BigDecimal bigDecimal = new BigDecimal(uploadCount);
|
||||||
//换算成MB
|
//换算成MB
|
||||||
BigDecimal divide = bigDecimal.divide(new BigDecimal(1048576));
|
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;
|
||||||
}
|
}
|
||||||
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
|
* 通过用户id集合获取用户id和部门code
|
||||||
*
|
|
||||||
* @param userIdList
|
|
||||||
* @return
|
|
||||||
*/
|
*/
|
||||||
List<SysUserSysDepPostModel> getUserDepPostByUserIds(@Param("userIdList") List<String> userIdList);
|
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}
|
#{userId}
|
||||||
</foreach>
|
</foreach>
|
||||||
</select>
|
</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>
|
</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 { unref } from 'vue';
|
||||||
import { useWebSocket, WebSocketResult } from '@vueuse/core';
|
import { useWebSocket, WebSocketResult } from '@vueuse/core';
|
||||||
|
import md5 from 'crypto-js/md5';
|
||||||
import { getToken } from '/@/utils/auth';
|
import { getToken } from '/@/utils/auth';
|
||||||
|
import { useGlobSetting } from '/@/hooks/setting';
|
||||||
|
import { useUserStore } from '/@/store/modules/user';
|
||||||
|
|
||||||
let result: WebSocketResult<any>;
|
let result: WebSocketResult<any>;
|
||||||
const listeners = new Map();
|
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 链接,全局只需执行一次
|
* 开启 WebSocket 链接,全局只需执行一次
|
||||||
* @param url
|
* @param url
|
||||||
*/
|
*/
|
||||||
export function connectWebSocket(url: string) {
|
export function connectWebSocket(url: string) {
|
||||||
|
connectedUrl = url;
|
||||||
// 代码逻辑说明: v2.4.6 的 websocket 服务端,存在性能和安全问题。 #3278
|
// 代码逻辑说明: v2.4.6 的 websocket 服务端,存在性能和安全问题。 #3278
|
||||||
const token = (getToken() || '') as string;
|
const token = (getToken() || '') as string;
|
||||||
result = useWebSocket(url, {
|
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 const LockScreen = createAsyncComponent(() => import('./LockScreen.vue'));
|
||||||
|
|
||||||
export { FullScreen };
|
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 { useDesign } from '/@/hooks/web/useDesign';
|
||||||
import { useGlobSetting } from '/@/hooks/setting';
|
import { useGlobSetting } from '/@/hooks/setting';
|
||||||
import { useUserStore } from '/@/store/modules/user';
|
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 { readAllMsg } from '/@/views/monitor/mynews/mynews.api';
|
||||||
import { getToken } from '/@/utils/auth';
|
|
||||||
import md5 from 'crypto-js/md5';
|
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import SysMessageModal from '/@/views/system/message/components/SysMessageModal.vue'
|
import SysMessageModal from '/@/views/system/message/components/SysMessageModal.vue'
|
||||||
@@ -148,12 +146,10 @@
|
|||||||
|
|
||||||
// 初始化 WebSocket
|
// 初始化 WebSocket
|
||||||
function initWebSocket() {
|
function initWebSocket() {
|
||||||
let token = getToken();
|
const url = buildSystemWebSocketUrl();
|
||||||
//将登录token生成一个短的标识
|
if (!url) {
|
||||||
let wsClientId = md5(token);
|
return;
|
||||||
let userId = unref(userStore.getUserInfo).id + "_" + wsClientId;
|
}
|
||||||
// WebSocket与普通的请求所用协议有所不同,ws等同于http,wss等同于https
|
|
||||||
let url = glob.domainUrl?.replace('https://', 'wss://').replace('http://', 'ws://') + '/websocket/' + userId;
|
|
||||||
connectWebSocket(url);
|
connectWebSocket(url);
|
||||||
onWebSocket(onWebSocketMessage);
|
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 { useI18n } from '/@/hooks/web/useI18n';
|
||||||
import { useDesign } from '/@/hooks/web/useDesign';
|
import { useDesign } from '/@/hooks/web/useDesign';
|
||||||
import { useModal } from '/@/components/Modal';
|
import { useModal } from '/@/components/Modal';
|
||||||
import { useMessage } from '/src/hooks/web/useMessage';
|
|
||||||
import { useGo } from '/@/hooks/web/usePage';
|
import { useGo } from '/@/hooks/web/usePage';
|
||||||
import headerImg from '/@/assets/images/header.jpg';
|
import headerImg from '/@/assets/images/header.jpg';
|
||||||
import { propTypes } from '/@/utils/propTypes';
|
import { propTypes } from '/@/utils/propTypes';
|
||||||
@@ -52,15 +51,11 @@
|
|||||||
|
|
||||||
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
|
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
|
||||||
|
|
||||||
import { refreshCache, queryAllDictItems } from '/@/views/system/dict/dict.api';
|
import { useRefreshCache } from '../useRefreshCache';
|
||||||
import { DB_DICT_DATA_KEY } from '/src/enums/cacheEnum';
|
|
||||||
import { removeAuthCache, setAuthCache } from '/src/utils/auth';
|
|
||||||
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
||||||
import { getRefPromise } from '/@/utils/index';
|
import { getRefPromise } from '/@/utils/index';
|
||||||
import { refreshDragCache } from "@/api/common/api";
|
|
||||||
|
|
||||||
type MenuEvent = 'logout' | 'doc' | 'lock' | 'cache' | 'depart' | 'defaultHomePage' | 'password' | 'account';
|
type MenuEvent = 'logout' | 'doc' | 'lock' | 'cache' | 'depart' | 'defaultHomePage' | 'password' | 'account';
|
||||||
const { createMessage } = useMessage();
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'UserDropdown',
|
name: 'UserDropdown',
|
||||||
components: {
|
components: {
|
||||||
@@ -84,6 +79,7 @@
|
|||||||
const passwordVisible = ref(false);
|
const passwordVisible = ref(false);
|
||||||
const lockActionVisible = ref(false);
|
const lockActionVisible = ref(false);
|
||||||
const lockActionRef = ref(null);
|
const lockActionRef = ref(null);
|
||||||
|
const { clearCache } = useRefreshCache();
|
||||||
|
|
||||||
const getUserInfo = computed(() => {
|
const getUserInfo = computed(() => {
|
||||||
const { realname = '', avatar, desc } = userStore.getUserInfo || {};
|
const { realname = '', avatar, desc } = userStore.getUserInfo || {};
|
||||||
@@ -119,22 +115,6 @@
|
|||||||
openWindow(SITE_URL);
|
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() {
|
function updateCurrentDepart() {
|
||||||
loginSelectRef.value.show();
|
loginSelectRef.value.show();
|
||||||
|
|||||||
@@ -29,10 +29,14 @@
|
|||||||
|
|
||||||
<Notify v-if="getShowNotice" :class="`${prefixCls}-action__item notify-item`" />
|
<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`" />
|
<FullScreen v-if="getShowFullScreen" :class="`${prefixCls}-action__item fullscreen-item`" />
|
||||||
|
|
||||||
<LockScreen v-if="getUseLockPage" />
|
<LockScreen v-if="getUseLockPage" />
|
||||||
|
|
||||||
|
<RefreshCache />
|
||||||
|
|
||||||
<AppLocalePicker v-if="getShowLocalePicker" :reload="true" :showText="false" :class="`${prefixCls}-action__item`" />
|
<AppLocalePicker v-if="getShowLocalePicker" :reload="true" :showText="false" :class="`${prefixCls}-action__item`" />
|
||||||
|
|
||||||
<UserDropDown :theme="getHeaderTheme" />
|
<UserDropDown :theme="getHeaderTheme" />
|
||||||
@@ -64,7 +68,7 @@
|
|||||||
import { SettingButtonPositionEnum } from '/@/enums/appEnum';
|
import { SettingButtonPositionEnum } from '/@/enums/appEnum';
|
||||||
import { AppLocalePicker } from '/@/components/Application';
|
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 { useAppInject } from '/@/hooks/web/useAppInject';
|
||||||
import { useDesign } from '/@/hooks/web/useDesign';
|
import { useDesign } from '/@/hooks/web/useDesign';
|
||||||
|
|
||||||
@@ -86,9 +90,11 @@
|
|||||||
LayoutBreadcrumb,
|
LayoutBreadcrumb,
|
||||||
LayoutMenu,
|
LayoutMenu,
|
||||||
UserDropDown,
|
UserDropDown,
|
||||||
|
RefreshCache,
|
||||||
AppLocalePicker,
|
AppLocalePicker,
|
||||||
FullScreen,
|
FullScreen,
|
||||||
Notify,
|
Notify,
|
||||||
|
ImChat,
|
||||||
AppSearch,
|
AppSearch,
|
||||||
ErrorAction,
|
ErrorAction,
|
||||||
LockScreen,
|
LockScreen,
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ export const useUserStore = defineStore({
|
|||||||
await this.setLoginInfo({ ...data, isLogin: true });
|
await this.setLoginInfo({ ...data, isLogin: true });
|
||||||
// 代码逻辑说明: 登录成功后缓存拖拽模块的接口前缀
|
// 代码逻辑说明: 登录成功后缓存拖拽模块的接口前缀
|
||||||
localStorage.setItem(JDragConfigEnum.DRAG_BASE_URL, useGlobSetting().domainUrl);
|
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;
|
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;
|
// let username:any = this.userInfo && this.userInfo.username;
|
||||||
// if(username){
|
// if(username){
|
||||||
// removeAuthCache(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