web聊天室数据同步修改
This commit is contained in:
@@ -64,12 +64,13 @@ public interface ChatRoomService {
|
||||
ResultDomain<TbChatRoomDTO> getChatRoomById(String roomId);
|
||||
|
||||
/**
|
||||
* @description 获取聊天室列表/分页
|
||||
* @param pageRequest 分页请求
|
||||
* @description 分页查询聊天室(含当前用户未读数)
|
||||
* @param pageRequest 分页请求参数
|
||||
* @param userId 当前用户ID
|
||||
* @author cascade
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
ResultDomain<ChatRoomVO> getChatRoomPage(PageRequest<TbChatRoomDTO> pageRequest);
|
||||
ResultDomain<ChatRoomVO> getChatRoomPage(PageRequest<TbChatRoomDTO> pageRequest, String userId);
|
||||
|
||||
// ========================= 聊天室成员管理 ==========================
|
||||
|
||||
@@ -114,6 +115,15 @@ public interface ChatRoomService {
|
||||
*/
|
||||
ResultDomain<Boolean> updateMemberReadStatus(String memberId, String lastReadMsgId);
|
||||
|
||||
/**
|
||||
* @description 获取当前用户在指定聊天室的未读消息数
|
||||
* @param roomId 聊天室ID
|
||||
* @param userId 用户ID
|
||||
* @author cascade
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
ResultDomain<Integer> getUnreadCount(String roomId, String userId);
|
||||
|
||||
// ========================= 聊天消息管理 ==========================
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,7 +106,9 @@ public class WorkcaseChatContorller {
|
||||
@Operation(summary = "分页查询聊天室")
|
||||
@PreAuthorize("hasAuthority('workcase:room:view')")
|
||||
@PostMapping("/room/page")
|
||||
public ResultDomain<ChatRoomVO> getChatRoomPage(@RequestBody PageRequest<TbChatRoomDTO> pageRequest) {
|
||||
public ResultDomain<ChatRoomVO> getChatRoomPage(
|
||||
@RequestBody PageRequest<TbChatRoomDTO> pageRequest,
|
||||
@RequestParam(value = "userId", required = true) String userId) {
|
||||
ValidationResult vr = ValidationUtils.validate(pageRequest, Arrays.asList(
|
||||
ValidationUtils.requiredNumber("pageParam.page", "页码", 1, null),
|
||||
ValidationUtils.requiredNumber("pageParam.pageSize", "每页数量", 1, 100)
|
||||
@@ -114,7 +116,7 @@ public class WorkcaseChatContorller {
|
||||
if (!vr.isValid()) {
|
||||
return ResultDomain.failure(vr.getAllErrors());
|
||||
}
|
||||
return chatRoomService.getChatRoomPage(pageRequest);
|
||||
return chatRoomService.getChatRoomPage(pageRequest, userId);
|
||||
}
|
||||
|
||||
// ========================= ChatRoom成员管理 =========================
|
||||
@@ -147,6 +149,15 @@ public class WorkcaseChatContorller {
|
||||
return chatRoomService.getChatRoomMemberList(roomId);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取当前用户在指定聊天室的未读消息数")
|
||||
@PreAuthorize("hasAuthority('workcase:room:member')")
|
||||
@GetMapping("/room/{roomId}/unread")
|
||||
public ResultDomain<Integer> getUnreadCount(
|
||||
@PathVariable(value = "roomId") String roomId,
|
||||
@RequestParam(value = "userId") String userId) {
|
||||
return chatRoomService.getUnreadCount(roomId, userId);
|
||||
}
|
||||
|
||||
// ========================= ChatRoom消息管理 =========================
|
||||
|
||||
@Operation(summary = "发送聊天室消息")
|
||||
@@ -156,7 +167,8 @@ public class WorkcaseChatContorller {
|
||||
ValidationResult vr = ValidationUtils.validate(message, Arrays.asList(
|
||||
ValidationUtils.requiredString("roomId", "聊天室ID"),
|
||||
ValidationUtils.requiredString("senderId", "发送者ID"),
|
||||
ValidationUtils.requiredString("content", "消息内容")
|
||||
ValidationUtils.requiredString("content", "消息内容"),
|
||||
ValidationUtils.requiredString("senderName", "发送者名称")
|
||||
));
|
||||
if (!vr.isValid()) {
|
||||
return ResultDomain.failure(vr.getAllErrors());
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.alibaba.fastjson2.JSON;
|
||||
|
||||
import org.xyzh.api.workcase.constant.WorkcaseConstant;
|
||||
import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO;
|
||||
import org.xyzh.api.workcase.vo.ChatRoomMessageVO;
|
||||
import org.xyzh.workcase.mapper.TbChatMessageMapper;
|
||||
|
||||
/**
|
||||
* @description 聊天消息Redis监听器,接收Pub/Sub消息并通过STOMP转发到WebSocket客户端
|
||||
@@ -27,6 +29,9 @@ public class ChatMessageListener implements MessageListener {
|
||||
@Autowired(required = false)
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@Autowired
|
||||
private TbChatMessageMapper chatMessageMapper;
|
||||
|
||||
@Override
|
||||
public void onMessage(Message message, byte[] pattern) {
|
||||
try {
|
||||
@@ -46,9 +51,16 @@ public class ChatMessageListener implements MessageListener {
|
||||
// 处理聊天室消息频道: chat:room:{roomId}
|
||||
if (channel.startsWith(WorkcaseConstant.REDIS_CHAT_PREFIX)) {
|
||||
String roomId = channel.substring(WorkcaseConstant.REDIS_CHAT_PREFIX.length());
|
||||
// 转发到聊天窗口订阅者
|
||||
messagingTemplate.convertAndSend("/topic/chat/" + roomId, chatMessage);
|
||||
|
||||
// 查询完整的VO数据(包含senderAvatar等额外字段)
|
||||
ChatRoomMessageVO messageVO = chatMessageMapper.selectChatMessageVOById(chatMessage.getMessageId());
|
||||
if (messageVO != null) {
|
||||
// 转发完整VO到聊天窗口订阅者
|
||||
messagingTemplate.convertAndSend("/topic/chat/" + roomId, messageVO);
|
||||
logger.debug("消息已转发到STOMP: /topic/chat/{}", roomId);
|
||||
} else {
|
||||
logger.warn("未找到消息VO: messageId={}", chatMessage.getMessageId());
|
||||
}
|
||||
}
|
||||
// 处理列表更新频道: chat:list:update
|
||||
else if (WorkcaseConstant.REDIS_CHAT_LIST_UPDATE.equals(channel)) {
|
||||
|
||||
@@ -53,4 +53,9 @@ public interface TbChatMessageMapper {
|
||||
*/
|
||||
long countChatMessages(@Param("filter") TbChatRoomMessageDTO filter);
|
||||
|
||||
/**
|
||||
* 根据消息ID查询完整VO
|
||||
*/
|
||||
ChatRoomMessageVO selectChatMessageVOById(@Param("messageId") String messageId);
|
||||
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ public interface TbChatRoomMapper {
|
||||
/**
|
||||
* 分页查询聊天室
|
||||
*/
|
||||
List<ChatRoomVO> selectChatRoomPage(@Param("filter") TbChatRoomDTO filter, @Param("pageParam") PageParam pageParam);
|
||||
List<ChatRoomVO> selectChatRoomPage(@Param("filter") TbChatRoomDTO filter, @Param("pageParam") PageParam pageParam, @Param("userId") String userId);
|
||||
|
||||
/**
|
||||
* 统计聊天室数量
|
||||
|
||||
@@ -200,14 +200,14 @@ public class ChatRoomServiceImpl implements ChatRoomService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<ChatRoomVO> getChatRoomPage(PageRequest<TbChatRoomDTO> pageRequest) {
|
||||
public ResultDomain<ChatRoomVO> getChatRoomPage(PageRequest<TbChatRoomDTO> pageRequest, String userId) {
|
||||
TbChatRoomDTO filter = pageRequest.getFilter();
|
||||
if (filter == null) {
|
||||
filter = new TbChatRoomDTO();
|
||||
}
|
||||
|
||||
PageParam pageParam = pageRequest.getPageParam();
|
||||
List<ChatRoomVO> list = chatRoomMapper.selectChatRoomPage(filter, pageParam);
|
||||
List<ChatRoomVO> list = chatRoomMapper.selectChatRoomPage(filter, pageParam, userId);
|
||||
long total = chatRoomMapper.countChatRooms(filter);
|
||||
pageParam.setTotal((int)total);
|
||||
|
||||
@@ -246,6 +246,7 @@ public class ChatRoomServiceImpl implements ChatRoomService {
|
||||
member.setStatus("active");
|
||||
}
|
||||
member.setJoinTime(new Date());
|
||||
member.setCreator(member.getUserId());
|
||||
|
||||
int rows = chatRoomMemberMapper.insertChatRoomMember(member);
|
||||
if (rows > 0) {
|
||||
@@ -332,6 +333,23 @@ public class ChatRoomServiceImpl implements ChatRoomService {
|
||||
return ResultDomain.failure("更新失败");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<Integer> getUnreadCount(String roomId, String userId) {
|
||||
logger.info("查询未读消息数: roomId={}, userId={}", roomId, userId);
|
||||
|
||||
TbChatRoomMemberDTO filter = new TbChatRoomMemberDTO();
|
||||
filter.setRoomId(roomId);
|
||||
filter.setUserId(userId);
|
||||
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(filter);
|
||||
|
||||
if (members.isEmpty()) {
|
||||
return ResultDomain.success("查询成功", 0);
|
||||
}
|
||||
|
||||
Integer unreadCount = members.get(0).getUnreadCount();
|
||||
return ResultDomain.success("查询成功", unreadCount != null ? unreadCount : 0);
|
||||
}
|
||||
|
||||
// ========================= 聊天消息管理 ==========================
|
||||
|
||||
@Override
|
||||
@@ -357,6 +375,7 @@ public class ChatRoomServiceImpl implements ChatRoomService {
|
||||
if (message.getStatus() == null || message.getStatus().isEmpty()) {
|
||||
message.setStatus("sent");
|
||||
}
|
||||
message.setCreator(message.getSenderId());
|
||||
|
||||
// 使用Redis保证消息时间戳递增,避免并发乱序
|
||||
String lockKey = WorkcaseConstant.REDIS_CHAT_LOCK + message.getRoomId();
|
||||
@@ -402,6 +421,9 @@ public class ChatRoomServiceImpl implements ChatRoomService {
|
||||
updateRoom.setMessageCount(room.getMessageCount() != null ? room.getMessageCount() + 1 : 1);
|
||||
chatRoomMapper.updateChatRoom(updateRoom);
|
||||
|
||||
// 更新聊天室成员的未读数(除发送者外的所有成员 +1)
|
||||
updateMembersUnreadCount(message.getRoomId(), message.getSenderId());
|
||||
|
||||
// 发布消息到Redis Pub/Sub(聊天窗口)
|
||||
publishMessageToRedis(message);
|
||||
|
||||
@@ -693,6 +715,29 @@ public class ChatRoomServiceImpl implements ChatRoomService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新聊天室成员的未读数(除发送者外的所有成员 +1)
|
||||
*/
|
||||
private void updateMembersUnreadCount(String roomId, String senderId) {
|
||||
try {
|
||||
TbChatRoomMemberDTO filter = new TbChatRoomMemberDTO();
|
||||
filter.setRoomId(roomId);
|
||||
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(filter);
|
||||
|
||||
for (ChatMemberVO member : members) {
|
||||
if (!senderId.equals(member.getUserId())) {
|
||||
TbChatRoomMemberDTO updateMember = new TbChatRoomMemberDTO();
|
||||
updateMember.setMemberId(member.getMemberId());
|
||||
updateMember.setUnreadCount((member.getUnreadCount() != null ? member.getUnreadCount() : 0) + 1);
|
||||
chatRoomMemberMapper.updateChatRoomMember(updateMember);
|
||||
}
|
||||
}
|
||||
logger.debug("已更新聊天室成员未读数: roomId={}, 更新成员数={}", roomId, members.size() - 1);
|
||||
} catch (Exception e) {
|
||||
logger.error("更新聊天室成员未读数失败: roomId={}", roomId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void publishMessageToRedis(TbChatRoomMessageDTO message) {
|
||||
try {
|
||||
String channel = WorkcaseConstant.REDIS_CHAT_PREFIX + message.getRoomId();
|
||||
|
||||
@@ -141,4 +141,10 @@
|
||||
</where>
|
||||
</select>
|
||||
|
||||
<select id="selectChatMessageVOById" resultMap="VOResultMap">
|
||||
SELECT <include refid="Base_Column_List"/>
|
||||
FROM workcase.tb_chat_room_message
|
||||
WHERE message_id = #{messageId}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<result column="guest_name" property="guestName" jdbcType="VARCHAR"/>
|
||||
<result column="ai_session_id" property="aiSessionId" jdbcType="VARCHAR"/>
|
||||
<result column="message_count" property="messageCount" jdbcType="INTEGER"/>
|
||||
<result column="unread_count" property="unreadCount" jdbcType="INTEGER"/>
|
||||
<result column="last_message_time" property="lastMessageTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="last_message" property="lastMessage" jdbcType="VARCHAR"/>
|
||||
<result column="closed_by" property="closedBy" jdbcType="VARCHAR"/>
|
||||
@@ -118,19 +119,24 @@
|
||||
</select>
|
||||
|
||||
<select id="selectChatRoomPage" resultMap="VOResultMap">
|
||||
SELECT <include refid="Base_Column_List"/>
|
||||
FROM workcase.tb_chat_room
|
||||
SELECT r.room_id, r.optsn, r.workcase_id, r.room_name, r.room_type, r.status,
|
||||
r.guest_id, r.guest_name, r.ai_session_id, r.message_count,
|
||||
r.last_message_time, r.last_message, r.closed_by, r.closed_time,
|
||||
r.creator, r.create_time, r.update_time, r.delete_time, r.deleted,
|
||||
COALESCE(m.unread_count, 0) as unread_count
|
||||
FROM workcase.tb_chat_room r
|
||||
LEFT JOIN workcase.tb_chat_room_member m ON r.room_id = m.room_id AND m.user_id = #{userId}
|
||||
<where>
|
||||
<if test="filter.roomId != null and filter.roomId != ''">AND room_id = #{filter.roomId}</if>
|
||||
<if test="filter.workcaseId != null and filter.workcaseId != ''">AND workcase_id = #{filter.workcaseId}</if>
|
||||
<if test="filter.roomName != null and filter.roomName != ''">AND room_name LIKE CONCAT('%', #{filter.roomName}, '%')</if>
|
||||
<if test="filter.roomType != null and filter.roomType != ''">AND room_type = #{filter.roomType}</if>
|
||||
<if test="filter.status != null and filter.status != ''">AND status = #{filter.status}</if>
|
||||
<if test="filter.guestId != null and filter.guestId != ''">AND guest_id = #{filter.guestId}</if>
|
||||
<if test="filter.guestName != null and filter.guestName != ''">AND guest_name LIKE CONCAT('%', #{filter.guestName}, '%')</if>
|
||||
AND deleted = false
|
||||
<if test="filter.roomId != null and filter.roomId != ''">AND r.room_id = #{filter.roomId}</if>
|
||||
<if test="filter.workcaseId != null and filter.workcaseId != ''">AND r.workcase_id = #{filter.workcaseId}</if>
|
||||
<if test="filter.roomName != null and filter.roomName != ''">AND r.room_name LIKE CONCAT('%', #{filter.roomName}, '%')</if>
|
||||
<if test="filter.roomType != null and filter.roomType != ''">AND r.room_type = #{filter.roomType}</if>
|
||||
<if test="filter.status != null and filter.status != ''">AND r.status = #{filter.status}</if>
|
||||
<if test="filter.guestId != null and filter.guestId != ''">AND r.guest_id = #{filter.guestId}</if>
|
||||
<if test="filter.guestName != null and filter.guestName != ''">AND r.guest_name LIKE CONCAT('%', #{filter.guestName}, '%')</if>
|
||||
AND r.deleted = false
|
||||
</where>
|
||||
ORDER BY create_time DESC
|
||||
ORDER BY r.create_time DESC
|
||||
LIMIT #{pageParam.pageSize} OFFSET #{pageParam.offset}
|
||||
</select>
|
||||
|
||||
|
||||
@@ -59,10 +59,12 @@ export const workcaseChatAPI = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 分页查询聊天室
|
||||
* 分页查询聊天室(含当前用户未读数)
|
||||
*/
|
||||
async getChatRoomPage(pageRequest: PageRequest<TbChatRoomDTO>): Promise<ResultDomain<ChatRoomVO>> {
|
||||
const response = await api.post<ChatRoomVO>(`${this.baseUrl}/room/page`, pageRequest)
|
||||
async getChatRoomPage(pageRequest: PageRequest<TbChatRoomDTO>, userId: string): Promise<ResultDomain<ChatRoomVO>> {
|
||||
const response = await api.post<ChatRoomVO>(`${this.baseUrl}/room/page`, pageRequest, {
|
||||
params: { userId }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -92,6 +94,16 @@ export const workcaseChatAPI = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前用户在指定聊天室的未读消息数
|
||||
*/
|
||||
async getUnreadCount(roomId: string, userId: string): Promise<ResultDomain<number>> {
|
||||
const response = await api.get<number>(`${this.baseUrl}/room/${roomId}/unread`,
|
||||
{ userId }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// ====================== ChatRoom消息管理 ======================
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,7 +52,7 @@ declare module 'shared/api' {
|
||||
import type { AxiosResponse, AxiosRequestConfig } from 'axios'
|
||||
|
||||
interface ApiInstance {
|
||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>
|
||||
get<T = any>(url: string,data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>
|
||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>
|
||||
|
||||
@@ -235,7 +235,14 @@ $brand-color-hover: #004488;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.last-message-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
@@ -244,9 +251,10 @@ $brand-color-hover: #004488;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
@@ -255,9 +263,6 @@ $brand-color-hover: #004488;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,15 +68,17 @@
|
||||
<div class="room-name">{{ room.roomName }}</div>
|
||||
<div class="room-time">{{ formatTime(room.lastMessageTime) }}</div>
|
||||
</div>
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">{{ room.lastMessage || '暂无消息' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 未读红点 -->
|
||||
<div v-if="(room.unreadCount ?? 0) > 0" class="unread-badge">
|
||||
{{ (room.unreadCount ?? 0) > 99 ? '99+' : room.unreadCount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -86,7 +88,7 @@
|
||||
<ChatRoom
|
||||
ref="chatRoomRef"
|
||||
:messages="messages"
|
||||
:current-user-id="userId"
|
||||
:current-user-id="loginDomain.user.userId"
|
||||
:room-name="currentRoom?.roomName"
|
||||
:meeting-url="currentMeetingUrl"
|
||||
:show-meeting="showMeetingIframe"
|
||||
@@ -146,7 +148,6 @@
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus'
|
||||
@@ -156,7 +157,7 @@ import WorkcaseDetail from '@/views/public/workcase/WorkcaseDetail/WorkcaseDetai
|
||||
import { workcaseChatAPI } from '@/api/workcase'
|
||||
import { fileAPI } from 'shared/api/file'
|
||||
import { FILE_DOWNLOAD_URL } from '@/config'
|
||||
import type { ChatRoomVO, ChatRoomMessageVO, TbChatRoomMessageDTO } from '@/types/workcase'
|
||||
import type { ChatRoomVO, ChatRoomMessageVO, TbChatRoomMessageDTO, TbChatRoomMemberDTO } from '@/types/workcase'
|
||||
import SockJS from 'sockjs-client'
|
||||
import { Client } from '@stomp/stompjs'
|
||||
|
||||
@@ -175,7 +176,7 @@ let roomSubscription: any = null
|
||||
let listSubscription: any = null
|
||||
|
||||
// 当前用户ID(从登录状态获取)
|
||||
const userId = ref(localStorage.getItem('userId') || '')
|
||||
const loginDomain = JSON.parse(localStorage.getItem('loginDomain')!)
|
||||
|
||||
// 侧边栏展开状态
|
||||
const isSidebarOpen = ref(false)
|
||||
@@ -243,7 +244,7 @@ const fetchChatRooms = async () => {
|
||||
const result = await workcaseChatAPI.getChatRoomPage({
|
||||
filter: { status: 'active' },
|
||||
pageParam: { page: 1, pageSize: 100, total: 0 }
|
||||
})
|
||||
}, loginDomain.user.userId)
|
||||
if (result.success && result.pageDomain) {
|
||||
chatRooms.value = result.pageDomain.dataList || []
|
||||
}
|
||||
@@ -257,8 +258,23 @@ const fetchChatRooms = async () => {
|
||||
}
|
||||
|
||||
// 选择聊天室
|
||||
const selectRoom = (roomId: string) => {
|
||||
const selectRoom = async (roomId: string) => {
|
||||
currentRoomId.value = roomId
|
||||
|
||||
// 自动加入聊天室成员表(如果不存在)
|
||||
try {
|
||||
const memberData: TbChatRoomMemberDTO = {
|
||||
roomId: roomId,
|
||||
userId: loginDomain.user.userId,
|
||||
userName: loginDomain.userInfo.username,
|
||||
userType: 'staff'
|
||||
}
|
||||
await workcaseChatAPI.addChatRoomMember(memberData)
|
||||
} catch (error) {
|
||||
// 已存在成员或其他错误,忽略
|
||||
console.debug('加入聊天室:', error)
|
||||
}
|
||||
|
||||
loadMessages(roomId)
|
||||
}
|
||||
|
||||
@@ -341,7 +357,8 @@ const handleSendMessage = async (content: string, files: File[]) => {
|
||||
// 构造消息
|
||||
const messageData: TbChatRoomMessageDTO = {
|
||||
roomId: currentRoomId.value,
|
||||
senderId: userId.value,
|
||||
senderId: loginDomain.user.userId,
|
||||
senderName: loginDomain.userInfo.username,
|
||||
senderType: 'agent',
|
||||
content,
|
||||
files: fileIds,
|
||||
@@ -443,20 +460,40 @@ const initWebSocket = () => {
|
||||
stompClient.activate()
|
||||
}
|
||||
|
||||
// 订阅聊天室列表更新 (用于更新列表中的lastMessage)
|
||||
// 订阅聊天室列表更新 (用于更新列表中的lastMessage和未读数)
|
||||
const subscribeToListUpdate = () => {
|
||||
if (!stompClient || !stompClient.connected) return
|
||||
|
||||
listSubscription = stompClient.subscribe('/topic/chat/list-update', (message: any) => {
|
||||
listSubscription = stompClient.subscribe('/topic/chat/list-update', async (message: any) => {
|
||||
const chatMessage = JSON.parse(message.body)
|
||||
// 更新对应聊天室的lastMessage和lastMessageTime
|
||||
const roomIndex = chatRooms.value.findIndex((r: ChatRoomVO) => r.roomId === chatMessage.roomId)
|
||||
if (roomIndex !== -1) {
|
||||
// 查询当前用户在该聊天室的未读数
|
||||
let unreadCount = 0
|
||||
try {
|
||||
const unreadResult = await workcaseChatAPI.getUnreadCount(
|
||||
chatMessage.roomId,
|
||||
loginDomain.user.userId
|
||||
)
|
||||
if (unreadResult.success && unreadResult.data !== undefined) {
|
||||
unreadCount = unreadResult.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询未读数失败:', error)
|
||||
}
|
||||
|
||||
chatRooms.value[roomIndex] = {
|
||||
...chatRooms.value[roomIndex],
|
||||
lastMessage: chatMessage.content,
|
||||
lastMessageTime: chatMessage.sendTime
|
||||
lastMessageTime: chatMessage.sendTime,
|
||||
unreadCount: unreadCount
|
||||
}
|
||||
|
||||
// 将更新的聊天室移到列表顶部
|
||||
const updatedRoom = chatRooms.value[roomIndex]
|
||||
chatRooms.value.splice(roomIndex, 1)
|
||||
chatRooms.value.unshift(updatedRoom)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -474,7 +511,7 @@ const subscribeToRoom = (roomId: string) => {
|
||||
roomSubscription = stompClient.subscribe(`/topic/chat/${roomId}`, (message: any) => {
|
||||
const chatMessage = JSON.parse(message.body) as ChatRoomMessageVO
|
||||
// 避免重复添加自己发送的消息
|
||||
if (chatMessage.senderId !== userId.value) {
|
||||
if (chatMessage.senderId !== loginDomain.user.userId) {
|
||||
messages.value.push(chatMessage)
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
@@ -108,10 +108,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, nextTick, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import WorkcaseCreator from '@/components/WorkcaseCreator/WorkcaseCreator.uvue'
|
||||
import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO } from '@/types/workcase'
|
||||
import { workcaseChatAPI } from '@/api/workcase'
|
||||
import { wsClient } from '@/utils/websocket'
|
||||
|
||||
// 响应式数据
|
||||
const headerPaddingTop = ref<number>(44)
|
||||
@@ -232,6 +233,24 @@ onMounted(() => {
|
||||
loadChatRoom()
|
||||
loadDefaultWorkers()
|
||||
loadChatMembers()
|
||||
initWebSocket()
|
||||
})
|
||||
|
||||
// 组件卸载时断开WebSocket
|
||||
onUnmounted(() => {
|
||||
disconnectWebSocket()
|
||||
})
|
||||
|
||||
// 监听roomId变化,切换聊天室时重新订阅
|
||||
watch(roomId, (newRoomId, oldRoomId) => {
|
||||
if (oldRoomId && newRoomId !== oldRoomId) {
|
||||
// 取消旧聊天室订阅
|
||||
wsClient.unsubscribe(`/topic/chat/${oldRoomId}`)
|
||||
}
|
||||
if (newRoomId && wsClient.isConnected()) {
|
||||
// 订阅新聊天室
|
||||
wsClient.subscribe(`/topic/chat/${newRoomId}`, handleNewMessage)
|
||||
}
|
||||
})
|
||||
|
||||
// 加载聊天室
|
||||
@@ -443,6 +462,73 @@ function startMeeting() {
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// ==================== WebSocket连接管理 ====================
|
||||
|
||||
// 初始化WebSocket连接
|
||||
async function initWebSocket() {
|
||||
try {
|
||||
const token = uni.getStorageSync('token') || ''
|
||||
if (!token) {
|
||||
console.warn('[chatRoom] 未找到token,跳过WebSocket连接')
|
||||
return
|
||||
}
|
||||
|
||||
// 构建WebSocket URL
|
||||
const protocol = 'wss:' // 生产环境使用wss
|
||||
const host = 'your-domain.com' // 需要替换为实际域名
|
||||
const wsUrl = `${protocol}//${host}/api/urban-lifeline/workcase/ws/chat-sockjs?token=${encodeURIComponent(token)}`
|
||||
|
||||
console.log('[chatRoom] 开始连接WebSocket')
|
||||
await wsClient.connect(wsUrl, token)
|
||||
|
||||
// 订阅当前聊天室消息频道
|
||||
if (roomId.value) {
|
||||
wsClient.subscribe(`/topic/chat/${roomId.value}`, handleNewMessage)
|
||||
console.log('[chatRoom] WebSocket连接成功,已订阅聊天室:', roomId.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[chatRoom] WebSocket连接失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 断开WebSocket连接
|
||||
function disconnectWebSocket() {
|
||||
try {
|
||||
if (roomId.value) {
|
||||
wsClient.unsubscribe(`/topic/chat/${roomId.value}`)
|
||||
}
|
||||
wsClient.disconnect()
|
||||
console.log('[chatRoom] WebSocket已断开')
|
||||
} catch (error) {
|
||||
console.error('[chatRoom] 断开WebSocket失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理接收到的新消息
|
||||
function handleNewMessage(message: ChatRoomMessageVO) {
|
||||
console.log('[chatRoom] 收到新消息:', message)
|
||||
|
||||
// 避免重复添加自己发送的消息(自己发送的消息已经通过sendMessage添加到列表)
|
||||
if (message.senderId === currentUserId.value) {
|
||||
console.log('[chatRoom] 跳过自己发送的消息')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查消息是否已存在(避免重复)
|
||||
const exists = messages.some(m => m.messageId === message.messageId)
|
||||
if (exists) {
|
||||
console.log('[chatRoom] 消息已存在,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加新消息到列表
|
||||
messages.push(message)
|
||||
nextTick(() => scrollToBottom())
|
||||
|
||||
// 可以添加消息提示音或震动
|
||||
// uni.vibrateShort()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -50,9 +50,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { workcaseChatAPI } from '@/api'
|
||||
import type { ChatRoomVO, TbChatRoomDTO, PageRequest } from '@/types'
|
||||
import type { ChatRoomVO, TbChatRoomDTO, PageRequest, ChatRoomMessageVO } from '@/types'
|
||||
import { wsClient } from '@/utils/websocket'
|
||||
|
||||
// 导航栏
|
||||
const navPaddingTop = ref<number>(0)
|
||||
@@ -86,6 +87,12 @@ onMounted(() => {
|
||||
// #endif
|
||||
|
||||
loadChatRooms()
|
||||
initWebSocket()
|
||||
})
|
||||
|
||||
// 组件卸载时断开WebSocket
|
||||
onUnmounted(() => {
|
||||
disconnectWebSocket()
|
||||
})
|
||||
|
||||
// 加载聊天室列表
|
||||
@@ -170,6 +177,63 @@ function enterRoom(room: ChatRoomVO) {
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// ==================== WebSocket连接管理 ====================
|
||||
|
||||
// 初始化WebSocket连接
|
||||
async function initWebSocket() {
|
||||
try {
|
||||
const token = uni.getStorageSync('token') || ''
|
||||
if (!token) {
|
||||
console.warn('[chatRoomList] 未找到token,跳过WebSocket连接')
|
||||
return
|
||||
}
|
||||
|
||||
// 构建WebSocket URL
|
||||
const protocol = 'wss:' // 生产环境使用wss
|
||||
const host = 'your-domain.com' // 需要替换为实际域名
|
||||
const wsUrl = `${protocol}//${host}/api/urban-lifeline/workcase/ws/chat-sockjs?token=${encodeURIComponent(token)}`
|
||||
|
||||
console.log('[chatRoomList] 开始连接WebSocket')
|
||||
await wsClient.connect(wsUrl, token)
|
||||
|
||||
// 订阅聊天室列表更新频道
|
||||
wsClient.subscribe('/topic/chat/list-update', handleListUpdate)
|
||||
console.log('[chatRoomList] WebSocket连接成功,已订阅列表更新频道')
|
||||
} catch (error) {
|
||||
console.error('[chatRoomList] WebSocket连接失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 断开WebSocket连接
|
||||
function disconnectWebSocket() {
|
||||
try {
|
||||
wsClient.disconnect()
|
||||
console.log('[chatRoomList] WebSocket已断开')
|
||||
} catch (error) {
|
||||
console.error('[chatRoomList] 断开WebSocket失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理列表更新消息
|
||||
function handleListUpdate(message: ChatRoomMessageVO) {
|
||||
console.log('[chatRoomList] 收到列表更新消息:', message)
|
||||
|
||||
// 更新对应聊天室的lastMessage和lastMessageTime
|
||||
const roomIndex = chatRooms.value.findIndex((r: ChatRoomVO) => r.roomId === message.roomId)
|
||||
if (roomIndex !== -1) {
|
||||
chatRooms.value[roomIndex] = {
|
||||
...chatRooms.value[roomIndex],
|
||||
lastMessage: message.content || '',
|
||||
lastMessageTime: message.sendTime || ''
|
||||
}
|
||||
|
||||
// 将更新的聊天室移到列表顶部
|
||||
const updatedRoom = chatRooms.value[roomIndex]
|
||||
chatRooms.value.splice(roomIndex, 1)
|
||||
chatRooms.value.unshift(updatedRoom)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
File diff suppressed because one or more lines are too long
339
urbanLifelineWeb/packages/workcase_wechat/utils/websocket.ts
Normal file
339
urbanLifelineWeb/packages/workcase_wechat/utils/websocket.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* WebSocket工具类
|
||||
* 支持STOMP协议和uni.connectSocket API
|
||||
*/
|
||||
|
||||
interface StompFrame {
|
||||
command: string
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
}
|
||||
|
||||
interface SubscriptionCallback {
|
||||
(message: any): void
|
||||
}
|
||||
|
||||
export class WebSocketClient {
|
||||
private socketTask: any | null = null
|
||||
private connected: boolean = false
|
||||
private subscriptions: Map<string, SubscriptionCallback> = new Map()
|
||||
private messageQueue: string[] = []
|
||||
private heartbeatTimer: number | null = null
|
||||
private reconnectTimer: number | null = null
|
||||
private reconnectAttempts: number = 0
|
||||
private maxReconnectAttempts: number = 5
|
||||
|
||||
private url: string = ''
|
||||
private token: string = ''
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* 连接WebSocket
|
||||
*/
|
||||
connect(url: string, token: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.url = url
|
||||
this.token = token
|
||||
|
||||
console.log('[WebSocket] 开始连接:', url)
|
||||
|
||||
this.socketTask = uni.connectSocket({
|
||||
url: url,
|
||||
success: () => {
|
||||
console.log('[WebSocket] 连接请求已发送')
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('[WebSocket] 连接失败:', err)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.socketTask) {
|
||||
reject(new Error('创建WebSocket失败'))
|
||||
return
|
||||
}
|
||||
|
||||
// 监听打开
|
||||
this.socketTask.onOpen(() => {
|
||||
console.log('[WebSocket] 连接已建立')
|
||||
this.connected = true
|
||||
this.reconnectAttempts = 0
|
||||
|
||||
// 发送STOMP CONNECT帧
|
||||
this.sendStompFrame({
|
||||
command: 'CONNECT',
|
||||
headers: {
|
||||
'accept-version': '1.2',
|
||||
'heart-beat': '10000,10000',
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
},
|
||||
body: ''
|
||||
})
|
||||
|
||||
// 启动心跳
|
||||
this.startHeartbeat()
|
||||
|
||||
resolve()
|
||||
})
|
||||
|
||||
// 监听消息
|
||||
this.socketTask.onMessage((res: any) => {
|
||||
const data = res.data as string
|
||||
this.handleMessage(data)
|
||||
})
|
||||
|
||||
// 监听关闭
|
||||
this.socketTask.onClose(() => {
|
||||
console.log('[WebSocket] 连接已关闭')
|
||||
this.connected = false
|
||||
this.stopHeartbeat()
|
||||
this.handleReconnect()
|
||||
})
|
||||
|
||||
// 监听错误
|
||||
this.socketTask.onError((err: any) => {
|
||||
console.error('[WebSocket] 连接错误:', err)
|
||||
this.connected = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
console.log('[WebSocket] 主动断开连接')
|
||||
this.stopHeartbeat()
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = this.maxReconnectAttempts // 阻止自动重连
|
||||
|
||||
if (this.socketTask) {
|
||||
this.socketTask.close({
|
||||
success: () => {
|
||||
console.log('[WebSocket] 断开成功')
|
||||
}
|
||||
})
|
||||
this.socketTask = null
|
||||
}
|
||||
|
||||
this.connected = false
|
||||
this.subscriptions.clear()
|
||||
this.messageQueue = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅主题
|
||||
*/
|
||||
subscribe(destination: string, callback: SubscriptionCallback): string {
|
||||
const id = `sub-${Date.now()}-${Math.random()}`
|
||||
|
||||
console.log('[WebSocket] 订阅主题:', destination, 'id:', id)
|
||||
|
||||
this.subscriptions.set(destination, callback)
|
||||
|
||||
if (this.connected) {
|
||||
this.sendStompFrame({
|
||||
command: 'SUBSCRIBE',
|
||||
headers: {
|
||||
'id': id,
|
||||
'destination': destination
|
||||
},
|
||||
body: ''
|
||||
})
|
||||
} else {
|
||||
console.warn('[WebSocket] 未连接,订阅已加入队列')
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
unsubscribe(destination: string) {
|
||||
console.log('[WebSocket] 取消订阅:', destination)
|
||||
this.subscriptions.delete(destination)
|
||||
|
||||
if (this.connected) {
|
||||
this.sendStompFrame({
|
||||
command: 'UNSUBSCRIBE',
|
||||
headers: {
|
||||
'destination': destination
|
||||
},
|
||||
body: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送STOMP帧
|
||||
*/
|
||||
private sendStompFrame(frame: StompFrame) {
|
||||
let message = frame.command + '\n'
|
||||
|
||||
for (const key in frame.headers) {
|
||||
message += `${key}:${frame.headers[key]}\n`
|
||||
}
|
||||
|
||||
message += '\n' + frame.body + '\x00'
|
||||
|
||||
if (this.connected && this.socketTask) {
|
||||
this.socketTask.send({
|
||||
data: message,
|
||||
success: () => {
|
||||
console.log('[WebSocket] 发送成功:', frame.command)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[WebSocket] 发送失败:', err)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.warn('[WebSocket] 未连接,消息已加入队列')
|
||||
this.messageQueue.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的消息
|
||||
*/
|
||||
private handleMessage(data: string) {
|
||||
console.log('[WebSocket] 收到消息:', data.substring(0, 200))
|
||||
|
||||
const frame = this.parseStompFrame(data)
|
||||
|
||||
if (frame.command === 'CONNECTED') {
|
||||
console.log('[WebSocket] STOMP连接成功')
|
||||
// 处理队列中的订阅
|
||||
this.subscriptions.forEach((callback, destination) => {
|
||||
const id = `sub-${Date.now()}-${Math.random()}`
|
||||
this.sendStompFrame({
|
||||
command: 'SUBSCRIBE',
|
||||
headers: {
|
||||
'id': id,
|
||||
'destination': destination
|
||||
},
|
||||
body: ''
|
||||
})
|
||||
})
|
||||
// 发送队列中的消息
|
||||
while (this.messageQueue.length > 0) {
|
||||
const msg = this.messageQueue.shift()
|
||||
if (msg && this.socketTask) {
|
||||
this.socketTask.send({ data: msg })
|
||||
}
|
||||
}
|
||||
} else if (frame.command === 'MESSAGE') {
|
||||
const destination = frame.headers['destination']
|
||||
const callback = this.subscriptions.get(destination)
|
||||
|
||||
if (callback) {
|
||||
try {
|
||||
const message = JSON.parse(frame.body)
|
||||
callback(message)
|
||||
} catch (e) {
|
||||
console.error('[WebSocket] 解析消息失败:', e)
|
||||
}
|
||||
}
|
||||
} else if (frame.command === 'ERROR') {
|
||||
console.error('[WebSocket] 服务器错误:', frame.body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析STOMP帧
|
||||
*/
|
||||
private parseStompFrame(data: string): StompFrame {
|
||||
const lines = data.split('\n')
|
||||
const command = lines[0]
|
||||
const headers: Record<string, string> = {}
|
||||
let bodyStart = 0
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === '') {
|
||||
bodyStart = i + 1
|
||||
break
|
||||
}
|
||||
const colonIndex = line.indexOf(':')
|
||||
if (colonIndex > 0) {
|
||||
const key = line.substring(0, colonIndex)
|
||||
const value = line.substring(colonIndex + 1)
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const body = lines.slice(bodyStart).join('\n').replace(/\x00$/, '')
|
||||
|
||||
return { command, headers, body }
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动心跳
|
||||
*/
|
||||
private startHeartbeat() {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.connected && this.socketTask) {
|
||||
this.socketTask.send({
|
||||
data: '\n',
|
||||
fail: () => {
|
||||
console.warn('[WebSocket] 心跳发送失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 10000) as unknown as number
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
private stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重连
|
||||
*/
|
||||
private handleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.log('[WebSocket] 达到最大重连次数,停止重连')
|
||||
return
|
||||
}
|
||||
|
||||
this.clearReconnectTimer()
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000)
|
||||
console.log(`[WebSocket] ${delay}ms后尝试重连 (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`)
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectAttempts++
|
||||
this.connect(this.url, this.token).catch((err: any) => {
|
||||
console.error('[WebSocket] 重连失败:', err)
|
||||
})
|
||||
}, delay) as unknown as number
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除重连定时器
|
||||
*/
|
||||
private clearReconnectTimer() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const wsClient = new WebSocketClient()
|
||||
Reference in New Issue
Block a user