Răsfoiți Sursa

优化在线聊天

jialuyu 3 săptămâni în urmă
părinte
comite
fad833d815

+ 16 - 1
src/utils/chatSocket.ts

@@ -5,6 +5,8 @@ import { getToken } from '@/utils/auth'
 let stompClient: Client | null = null
 let onMessageCallback: ((data: any) => void) | null = null
 let onNotifyCallback: ((data: any) => void) | null = null
+// 标记当前是否正在主动断开,防止 deactivate 异步期间新旧连接竞争
+let isDisconnecting = false
 
 export interface ChatMessage {
   type: string
@@ -31,6 +33,12 @@ export function connectChat(
   sessionId?: number,
   onMessage?: (data: ChatMessage) => void
 ) {
+  // 若前一次断开还未完成,则延迟 300ms 后再建立新连接,防止竞争
+  if (isDisconnecting) {
+    setTimeout(() => connectChat(onNotify, sessionId, onMessage), 300)
+    return
+  }
+
   onNotifyCallback = onNotify || null
   onMessageCallback = onMessage || null
 
@@ -107,9 +115,16 @@ export function sendTextByWs(sessionId: number, content: string) {
 
 export function disconnectChat() {
   if (stompClient) {
-    stompClient.deactivate()
+    isDisconnecting = true
+    // deactivate 是异步的,使用 finally 确保标志位被重置
+    stompClient.deactivate().finally(() => {
+      isDisconnecting = false
+    })
     stompClient = null
   }
+  // 清理回调,防止已卸载组件的函数被后续新连接意外调用(野引用/内存泄漏)
+  onNotifyCallback = null
+  onMessageCallback = null
 }
 
 function handleNotify(data: ChatMessage) {

+ 30 - 12
src/views/system/customer/chat/index.vue

@@ -836,19 +836,35 @@ async function selectSession(session) {
   // 断开之前的连接并连接新的会话
   disconnectChat();
   if (session.id) {
-    connectChat(session.id, (data) => {
-      // 收到新消息的处理逻辑
-      if (data.sessionId === activeSessionId.value) {
-        messageList.value.push({
-          sender: data.senderType === 2 ? 'waiter' : 'customer',
-          type: data.msgType || 'text',
-          content: data.content,
-          time: data.sendTime,
-          payload: data.payload ? (typeof data.payload === 'string' ? JSON.parse(data.payload) : data.payload) : null
-        });
-        scrollToBottom();
+    connectChat(
+      () => {}, // onNotify 回调(全局通知,当前页面不需要单独处理)
+      session.id,
+      (data) => {
+        // 收到当前会话消息的处理逻辑
+        if (data.sessionId === activeSessionId.value) {
+          // 优先使用 senderRole 判断发送者,fallback 到 senderType
+          const sender = (data.senderRole === 'waiter' || data.senderRole === 'system')
+            ? 'waiter'
+            : (data.senderRole === 'user' ? 'customer' : (data.senderType === 2 ? 'waiter' : 'customer'));
+          const existIdx = messageList.value.findIndex(m => m.msgNo === data.msgNo);
+          const newMsg = {
+            msgId: data.msgId,
+            msgNo: data.msgNo,
+            sender,
+            type: data.msgType || 'text',
+            content: data.content,
+            time: data.sendTime,
+            payload: data.payload ? (typeof data.payload === 'string' ? JSON.parse(data.payload) : data.payload) : null
+          };
+          if (existIdx > -1) {
+            messageList.value[existIdx] = newMsg;
+          } else {
+            messageList.value.push(newMsg);
+          }
+          scrollToBottom();
+        }
       }
-    });
+    );
   }
   
   // 获取详细档案
@@ -1062,6 +1078,8 @@ onBeforeUnmount(() => {
   stopResizing();
   if (countdownTimer) clearInterval(countdownTimer);
   if (pollTimer) clearInterval(pollTimer);
+  // 断开 WebSocket 连接,防止连接泄漏
+  disconnectChat();
 });
 </script>
 

+ 96 - 5
src/views/system/custservice/index.vue

@@ -705,6 +705,8 @@ const msgContainerRef = ref(null);
 const fileInput = ref(null);
 const imageInput = ref(null);
 let countdownTimer = null;
+let pollTimer = null; // 轮询兜底定时器
+const POLL_INTERVAL = 5000; // 轮询间隔 5 秒
 
 // 高度调整逻辑
 const inputHeight = ref(200); // 默认最小高度
@@ -1028,23 +1030,23 @@ async function handleSend() {
       content: text
     };
     
-    // 如果WebSocket连接正常,优先由WS广播,这里本地不直接Push,等待WS回传
-    // 但为了UI即时反馈,可以先Push一个带加载状态的对象,通过msgNo去重
+    // 为了 UI 即时反馈,先 Push 一个带 loading 状态的占位消息,通过 msgNo 去重
     messageList.value.push({
       msgNo: msgNo,
       sender: 'waiter', 
       type: 'text', 
       content: text,
       time: getFullTime(),
-      loading: true // 增加加载状态
+      loading: true // 等待服务端确认后会由 WS 或主动刷新替换
     });
     scrollToBottom();
 
-    // 调用REST API发送消息(后端会通过WS给所有人广播)
+    // 调用 REST API 发送消息(后端会通过 WS 给所有人广播)
     const response = await sendTextMessage(messageData);
     
     if (response.code === 200 || response.code === 0) {
-      // 发送成功,等待WS推送真实消息回来替换本地Loading状态
+      // 发送成功后主动拉取一次最新消息,确保即便 WS 推送延迟也能立即显示
+      await loadMessagesSilent();
     } else {
       ElMessage.error('发送错误: ' + (response.msg || '未知错误'));
     }
@@ -1145,6 +1147,7 @@ async function selectSession(session) {
         }
         return {
           msgId: msg.msgId,
+          msgNo: msg.msgNo, // 补充 msgNo,用于 WS 推送的去重
           sender: sender,
           senderAvatar: msg.senderAvatar,
           type: msg.msgType || 'text',
@@ -1350,6 +1353,87 @@ function scrollToBottom() {
   nextTick(() => { if (msgContainerRef.value) msgContainerRef.value.scrollTop = msgContainerRef.value.scrollHeight; });
 }
 
+/**
+ * 静默拉取当前会话最新消息,与 WebSocket 推送互为兜底
+ * 使用消息指纹进行去重,只有内容变化时才更新 DOM
+ */
+async function loadMessagesSilent() {
+  if (!activeSessionId.value) return;
+  try {
+    const response = await getMessageHistory({
+      sessionId: activeSessionId.value,
+      pageNum: 1,
+      pageSize: 50
+    });
+    if ((response.code === 200 || response.rows)) {
+      const rows = response.rows || response.data?.rows || [];
+      const newMessages = rows.map(msg => {
+        let parsedPayload = null;
+        if ((msg.msgType === 'job_card' || msg.msgType === 'order_card') && msg.payload) {
+          parsedPayload = typeof msg.payload === 'string' ? JSON.parse(msg.payload) : msg.payload;
+        } else if (msg.msgType === 'file') {
+          parsedPayload = { name: msg.fileName || '未知文件', url: msg.fileUrl, size: '未知大小' };
+        } else if (msg.msgType === 'image') {
+          parsedPayload = { url: msg.fileUrl || msg.content };
+        }
+        let sender;
+        if (msg.senderRole === 'waiter' || msg.senderRole === 'system') {
+          sender = 'waiter';
+        } else if (msg.senderRole === 'user') {
+          sender = 'customer';
+        } else {
+          sender = msg.senderType === 2 ? 'waiter' : 'customer';
+        }
+        return {
+          msgId: msg.msgId,
+          msgNo: msg.msgNo,
+          sender,
+          senderAvatar: msg.senderAvatar,
+          type: msg.msgType || 'text',
+          content: msg.content,
+          time: msg.sendTime,
+          payload: parsedPayload,
+          fileUrl: msg.fileUrl,
+          fileName: msg.fileName
+        };
+      }).reverse();
+      
+      // 使用消息指纹去重,只在内容有变化时更新(避免不必要的 DOM 重绘)
+      const sig1 = newMessages.map(m => String(m.msgId) + String(m.content)).join('|');
+      const sig2 = messageList.value
+        .filter(m => m.msgId) // 过滤掉仅本地的 loading 消息
+        .map(m => String(m.msgId) + String(m.content)).join('|');
+      
+      if (sig1 !== sig2) {
+        messageList.value = newMessages;
+        scrollToBottom();
+      }
+    }
+  } catch (error) {
+    console.error('[CustService] 静默拉取消息失败:', error);
+  }
+}
+
+/**
+ * 启动轮询兜底:每 POLL_INTERVAL 毫秒自动刷新当前会话消息
+ * 确保 WebSocket 断开或推送延迟时消息仍然能自动显示
+ */
+function startPolling() {
+  stopPolling();
+  pollTimer = window.setInterval(async () => {
+    if (!activeSessionId.value) return;
+    await loadMessagesSilent();
+  }, POLL_INTERVAL);
+}
+
+/** 停止轮询 */
+function stopPolling() {
+  if (pollTimer) {
+    window.clearInterval(pollTimer);
+    pollTimer = null;
+  }
+}
+
 // 监听档案标签页切换,如果切到订单页则加载数据
 watch(archiveActiveTab, (val) => {
   if (val === 'order') {
@@ -1498,11 +1582,18 @@ onMounted(() => {
 
   // 初始化 WebSocket 连接(监听全局通知)
   reconnectChat();
+  
+  // 启动轮询兜底,防止 WebSocket 断开后消息停止更新
+  startPolling();
 });
 
 onBeforeUnmount(() => {
   stopResizing();
   if (countdownTimer) clearInterval(countdownTimer);
+  // 断开 WebSocket 防止连接泄漏
+  disconnectChat();
+  // 停止轮询
+  stopPolling();
 });
 </script>