新增IM聊天

This commit is contained in:
geht
2026-05-28 14:37:05 +08:00
parent 99e574f600
commit 3539eab924
35 changed files with 2864 additions and 36 deletions

View File

@@ -22,6 +22,7 @@ import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.net.*;
import java.sql.Date;
import java.util.*;
@@ -1046,7 +1047,7 @@ public class oConvertUtils {
BigDecimal bigDecimal = new BigDecimal(uploadCount);
//换算成MB
BigDecimal divide = bigDecimal.divide(new BigDecimal(1048576));
count = divide.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
count = divide.setScale(2, RoundingMode.HALF_UP).doubleValue();
return count;
}
return count;

View File

@@ -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】联系人接口兼容保留同本部门-----------
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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> {
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -102,9 +102,17 @@ public interface SysUserDepartMapper extends BaseMapper<SysUserDepart>{
/**
* 通过用户id集合获取用户id和部门code
*
* @param userIdList
* @return
*/
List<SysUserSysDepPostModel> getUserDepPostByUserIds(@Param("userIdList") List<String> userIdList);
/**
* 查询同部门用户(精确 orgCode不含下级部门
*/
List<SysUser> querySameDepartUserList(@Param("orgCode") String orgCode, @Param("excludeUserId") String excludeUserId,
@Param("tenantId") Integer tenantId, @Param("keyword") String keyword);
/**
* 用户是否属于指定 orgCode 部门
*/
int countUserInDepartOrgCode(@Param("userId") String userId, @Param("orgCode") String orgCode, @Param("tenantId") Integer tenantId);
}

View File

@@ -142,4 +142,40 @@
#{userId}
</foreach>
</select>
<!-- IM聊天同部门用户精确 orgCode -->
<select id="querySameDepartUserList" resultType="org.jeecg.modules.system.entity.SysUser">
select distinct a.* from sys_user a
inner join sys_user_depart b on b.user_id = a.id
inner join sys_depart c on b.dep_id = c.id
where a.del_flag = 0 and a.status = 1
and c.org_code = #{orgCode}
and a.username != '_reserve_user_external'
<if test="excludeUserId != null and excludeUserId != ''">
and a.id != #{excludeUserId}
</if>
<if test="tenantId != null and tenantId > 0">
and a.id in (
select user_id from sys_user_tenant where tenant_id = #{tenantId} and status = '1'
)
</if>
<if test="keyword != null and keyword != ''">
<bind name="bindKeyword" value="'%' + keyword + '%'"/>
and (a.username like #{bindKeyword} or a.realname like #{bindKeyword})
</if>
order by a.sort, a.realname
</select>
<select id="countUserInDepartOrgCode" resultType="int">
select count(1) from sys_user a
inner join sys_user_depart b on b.user_id = a.id
inner join sys_depart c on b.dep_id = c.id
where a.del_flag = 0 and a.id = #{userId}
and c.org_code = #{orgCode}
<if test="tenantId != null and tenantId > 0">
and a.id in (
select user_id from sys_user_tenant where tenant_id = #{tenantId} and status = '1'
)
</if>
</select>
</mapper>

View File

@@ -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`
);

View File

@@ -2,16 +2,55 @@
import { unref } from 'vue';
import { useWebSocket, WebSocketResult } from '@vueuse/core';
import md5 from 'crypto-js/md5';
import { getToken } from '/@/utils/auth';
import { useGlobSetting } from '/@/hooks/setting';
import { useUserStore } from '/@/store/modules/user';
let result: WebSocketResult<any>;
const listeners = new Map();
let connectedUrl = '';
/**
* 构建系统 WebSocket 地址(含 context-path如 /jeecg-boot
*/
export function buildSystemWebSocketUrl(): string {
const glob = useGlobSetting();
const userStore = useUserStore();
const userInfo = unref(userStore.getUserInfo);
if (!userInfo?.id) {
return '';
}
const token = getToken() || '';
const wsClientId = md5(token).toString();
const wsUserId = `${userInfo.id}_${wsClientId}`;
let base = (glob.domainUrl || '').replace('https://', 'wss://').replace('http://', 'ws://');
base = base.replace(/\/$/, '');
const apiPath = (glob.apiUrl || '/jeecg-boot').replace(/\/$/, '');
const prefix = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
return `${base}${prefix}/websocket/${wsUserId}`;
}
/**
* 确保 WebSocket 已连接(聊天页等场景可主动调用)
*/
export function ensureWebSocketConnected(): void {
const url = buildSystemWebSocketUrl();
if (!url) {
return;
}
if (result?.status?.value === 'OPEN' && connectedUrl === url) {
return;
}
connectWebSocket(url);
}
/**
* 开启 WebSocket 链接,全局只需执行一次
* @param url
*/
export function connectWebSocket(url: string) {
connectedUrl = url;
// 代码逻辑说明: v2.4.6 的 websocket 服务端,存在性能和安全问题。 #3278
const token = (getToken() || '') as string;
result = useWebSocket(url, {

View File

@@ -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>

View File

@@ -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>

View File

@@ -14,3 +14,7 @@ export const ErrorAction = createAsyncComponent(() => import('./ErrorAction.vue'
export const LockScreen = createAsyncComponent(() => import('./LockScreen.vue'));
export { FullScreen };
export { default as RefreshCache } from './RefreshCache.vue';
export const ImChat = createAsyncComponent(() => import('./im-chat/index.vue'));

View File

@@ -25,10 +25,8 @@
import { useDesign } from '/@/hooks/web/useDesign';
import { useGlobSetting } from '/@/hooks/setting';
import { useUserStore } from '/@/store/modules/user';
import { connectWebSocket, onWebSocket } from '/@/hooks/web/useWebSocket';
import { connectWebSocket, onWebSocket, buildSystemWebSocketUrl } from '/@/hooks/web/useWebSocket';
import { readAllMsg } from '/@/views/monitor/mynews/mynews.api';
import { getToken } from '/@/utils/auth';
import md5 from 'crypto-js/md5';
import { useRouter } from 'vue-router';
import SysMessageModal from '/@/views/system/message/components/SysMessageModal.vue'
@@ -148,12 +146,10 @@
// 初始化 WebSocket
function initWebSocket() {
let token = getToken();
//将登录token生成一个短的标识
let wsClientId = md5(token);
let userId = unref(userStore.getUserInfo).id + "_" + wsClientId;
// WebSocket与普通的请求所用协议有所不同ws等同于httpwss等同于https
let url = glob.domainUrl?.replace('https://', 'wss://').replace('http://', 'ws://') + '/websocket/' + userId;
const url = buildSystemWebSocketUrl();
if (!url) {
return;
}
connectWebSocket(url);
onWebSocket(onWebSocketMessage);
}

View File

@@ -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'),
};
}

View File

@@ -44,7 +44,6 @@
import { useI18n } from '/@/hooks/web/useI18n';
import { useDesign } from '/@/hooks/web/useDesign';
import { useModal } from '/@/components/Modal';
import { useMessage } from '/src/hooks/web/useMessage';
import { useGo } from '/@/hooks/web/usePage';
import headerImg from '/@/assets/images/header.jpg';
import { propTypes } from '/@/utils/propTypes';
@@ -52,15 +51,11 @@
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
import { refreshCache, queryAllDictItems } from '/@/views/system/dict/dict.api';
import { DB_DICT_DATA_KEY } from '/src/enums/cacheEnum';
import { removeAuthCache, setAuthCache } from '/src/utils/auth';
import { useRefreshCache } from '../useRefreshCache';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { getRefPromise } from '/@/utils/index';
import { refreshDragCache } from "@/api/common/api";
type MenuEvent = 'logout' | 'doc' | 'lock' | 'cache' | 'depart' | 'defaultHomePage' | 'password' | 'account';
const { createMessage } = useMessage();
export default defineComponent({
name: 'UserDropdown',
components: {
@@ -84,6 +79,7 @@
const passwordVisible = ref(false);
const lockActionVisible = ref(false);
const lockActionRef = ref(null);
const { clearCache } = useRefreshCache();
const getUserInfo = computed(() => {
const { realname = '', avatar, desc } = userStore.getUserInfo || {};
@@ -119,22 +115,6 @@
openWindow(SITE_URL);
}
// 清除缓存
async function clearCache() {
const result = await refreshCache();
const dragRes = await refreshDragCache();
console.log('dragRes', dragRes);
if (result.success) {
const res = await queryAllDictItems();
removeAuthCache(DB_DICT_DATA_KEY);
setAuthCache(DB_DICT_DATA_KEY, res.result);
createMessage.success(t('layout.header.refreshCacheComplete'));
// 代码逻辑说明: 【issues/7433】vue3 数据字典优化建议
userStore.setAllDictItems(res.result);
} else {
createMessage.error(t('layout.header.refreshCacheFailure'));
}
}
// 切换部门
function updateCurrentDepart() {
loginSelectRef.value.show();

View File

@@ -29,10 +29,14 @@
<Notify v-if="getShowNotice" :class="`${prefixCls}-action__item notify-item`" />
<ImChat :class="`${prefixCls}-action__item im-chat-item`" />
<FullScreen v-if="getShowFullScreen" :class="`${prefixCls}-action__item fullscreen-item`" />
<LockScreen v-if="getUseLockPage" />
<RefreshCache />
<AppLocalePicker v-if="getShowLocalePicker" :reload="true" :showText="false" :class="`${prefixCls}-action__item`" />
<UserDropDown :theme="getHeaderTheme" />
@@ -64,7 +68,7 @@
import { SettingButtonPositionEnum } from '/@/enums/appEnum';
import { AppLocalePicker } from '/@/components/Application';
import { UserDropDown, LayoutBreadcrumb, FullScreen, Notify, ErrorAction, LockScreen } from './components';
import { UserDropDown, LayoutBreadcrumb, FullScreen, Notify, ImChat, ErrorAction, LockScreen, RefreshCache } from './components';
import { useAppInject } from '/@/hooks/web/useAppInject';
import { useDesign } from '/@/hooks/web/useDesign';
@@ -86,9 +90,11 @@
LayoutBreadcrumb,
LayoutMenu,
UserDropDown,
RefreshCache,
AppLocalePicker,
FullScreen,
Notify,
ImChat,
AppSearch,
ErrorAction,
LockScreen,

View File

@@ -198,6 +198,8 @@ export const useUserStore = defineStore({
await this.setLoginInfo({ ...data, isLogin: true });
// 代码逻辑说明: 登录成功后缓存拖拽模块的接口前缀
localStorage.setItem(JDragConfigEnum.DRAG_BASE_URL, useGlobSetting().domainUrl);
// 登录后异步预取 IM 聊天数据,减少打开聊天时的等待
import('/@/views/system/im/imCache').then(({ prefetchImChatData }) => prefetchImChatData());
// 代码逻辑说明: 修复登录成功后,没有正确重定向的问题
let redirect = router.currentRoute.value?.query?.redirect as string;
@@ -284,6 +286,9 @@ export const useUserStore = defineStore({
}
}
// 退出登录前清除 IM 聊天缓存
import('/@/views/system/im/imCache').then(({ clearImCache }) => clearImCache());
// let username:any = this.userInfo && this.userInfo.username;
// if(username){
// removeAuthCache(username)

View 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>

View 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>

View 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>

View 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' });

View 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() || []);
}

View 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'));
}

View 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,
};
}