web聊天室数据同步修改

This commit is contained in:
2025-12-24 15:02:23 +08:00
parent 1fd26dcf1a
commit 898da3a2c6
16 changed files with 691 additions and 53 deletions

View File

@@ -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消息管理 ======================
/**

View File

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

View File

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

View File

@@ -68,13 +68,15 @@
<div class="room-name">{{ room.roomName }}</div>
<div class="room-time">{{ formatTime(room.lastMessageTime) }}</div>
</div>
<div class="last-message">{{ room.lastMessage || '暂无消息' }}</div>
<div class="last-message-row">
<div class="last-message">{{ room.lastMessage || '暂无消息' }}</div>
<!-- 未读红点 -->
<div v-if="(room.unreadCount ?? 0) > 0" class="unread-badge">
{{ (room.unreadCount ?? 0) > 99 ? '99+' : room.unreadCount }}
</div>
</div>
</div>
<!-- 未读红点 -->
<div v-if="(room.unreadCount ?? 0) > 0" class="unread-badge">
{{ (room.unreadCount ?? 0) > 99 ? '99+' : room.unreadCount }}
</div>
</div>
</div>
</div>
@@ -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()
}

View File

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

View File

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

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