Browse Source

已读未读消息过期

liuq 1 month ago
parent
commit
7200934efd

+ 4 - 4
backend/app/api/v1/endpoints/messages.py

@@ -266,7 +266,7 @@ def read_messages(
     db: Session = Depends(get_db),
     skip: int = 0,
     limit: int = 100,
-    unread_only: bool = False,
+    unread_only: bool = Query(False, deprecated=True),
     current_user: User = Depends(deps.get_current_active_user),
 ) -> Any:
     """
@@ -423,7 +423,7 @@ def get_chat_history(
     
     return [_process_message_content(msg) for msg in messages]
 
-@router.get("/unread-count", response_model=int)
+@router.get("/unread-count", response_model=int, deprecated=True)
 def get_unread_count(
     db: Session = Depends(get_db),
     current_user: User = Depends(deps.get_current_active_user),
@@ -434,7 +434,7 @@ def get_unread_count(
     ).count()
     return count
 
-@router.put("/{message_id}/read", response_model=MessageResponse)
+@router.put("/{message_id}/read", response_model=MessageResponse, deprecated=True)
 def mark_as_read(
     message_id: int,
     db: Session = Depends(get_db),
@@ -456,7 +456,7 @@ def mark_as_read(
         
     return _process_message_content(message)
 
-@router.put("/read-all", response_model=dict)
+@router.put("/read-all", response_model=dict, deprecated=True)
 def mark_all_read(
     db: Session = Depends(get_db),
     current_user: User = Depends(deps.get_current_active_user),

+ 130 - 0
frontend/src/store/message.ts

@@ -0,0 +1,130 @@
+import { defineStore } from 'pinia'
+import { ref, reactive, computed } from 'vue'
+import { useAuthStore } from './auth'
+
+type MessageCallback = (msg: any) => void
+const listeners = new Set<MessageCallback>()
+
+export function onNewMessage(cb: MessageCallback): () => void {
+  listeners.add(cb)
+  return () => { listeners.delete(cb) }
+}
+
+export const useMessageStore = defineStore('message', () => {
+  const unreadMap = reactive<Record<number, number>>({})
+  const currentOpenChatId = ref<number | null>(null)
+  const wsConnected = ref(false)
+
+  let ws: WebSocket | null = null
+  let heartbeatTimer: ReturnType<typeof setInterval> | null = null
+  let reconnectTimer: ReturnType<typeof setTimeout> | null = null
+
+  const totalUnread = computed(() =>
+    Object.values(unreadMap).reduce((sum, n) => sum + n, 0)
+  )
+
+  function initWebSocket() {
+    if (ws && ws.readyState <= WebSocket.OPEN) return
+
+    if (reconnectTimer) {
+      clearTimeout(reconnectTimer)
+      reconnectTimer = null
+    }
+
+    const token = localStorage.getItem('token')
+    if (!token) return
+
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/messages?token=${token}`
+
+    ws = new WebSocket(wsUrl)
+
+    ws.onopen = () => {
+      wsConnected.value = true
+      heartbeatTimer = setInterval(() => {
+        if (ws && ws.readyState === WebSocket.OPEN) ws.send('ping')
+      }, 30000)
+    }
+
+    ws.onmessage = (event) => {
+      if (event.data === 'pong') return
+      try {
+        const parsed = JSON.parse(event.data)
+        if (parsed.type === 'NEW_MESSAGE') {
+          handleNewMessage(parsed.data)
+        }
+      } catch (e) {
+        console.error('WS parse error', e)
+      }
+    }
+
+    ws.onclose = () => {
+      wsConnected.value = false
+      cleanupTimers()
+      reconnectTimer = setTimeout(() => initWebSocket(), 5000)
+    }
+
+    ws.onerror = () => {
+      ws?.close()
+    }
+  }
+
+  function handleNewMessage(newMessage: any) {
+    const authStore = useAuthStore()
+    const myId = authStore.user?.id
+
+    const isSystem = newMessage.type === 'NOTIFICATION' || newMessage.sender_id === null
+    const convId = isSystem
+      ? (newMessage.app_id ? -newMessage.app_id : 0)
+      : (newMessage.sender_id === myId ? newMessage.receiver_id : newMessage.sender_id)
+
+    if (convId !== currentOpenChatId.value && newMessage.sender_id !== myId) {
+      unreadMap[convId] = (unreadMap[convId] || 0) + 1
+    }
+
+    listeners.forEach(cb => cb(newMessage))
+  }
+
+  function setCurrentChat(id: number | null) {
+    currentOpenChatId.value = id
+    if (id !== null) {
+      unreadMap[id] = 0
+    }
+  }
+
+  function clearChat(id: number) {
+    unreadMap[id] = 0
+  }
+
+  function cleanupTimers() {
+    if (heartbeatTimer) {
+      clearInterval(heartbeatTimer)
+      heartbeatTimer = null
+    }
+  }
+
+  function disconnect() {
+    if (reconnectTimer) {
+      clearTimeout(reconnectTimer)
+      reconnectTimer = null
+    }
+    cleanupTimers()
+    if (ws) {
+      ws.onclose = null
+      ws.close()
+      ws = null
+    }
+    wsConnected.value = false
+  }
+
+  return {
+    unreadMap,
+    currentOpenChatId,
+    wsConnected,
+    totalUnread,
+    initWebSocket,
+    setCurrentChat,
+    clearChat,
+    disconnect,
+  }
+})

+ 22 - 1
frontend/src/views/Dashboard.vue

@@ -19,6 +19,9 @@
           <el-menu-item index="/dashboard/messages">
             <el-icon><ChatDotRound /></el-icon>
             <span>消息中心</span>
+            <span v-if="messageStore.totalUnread > 0" class="menu-unread-badge">
+              {{ messageStore.totalUnread > 99 ? '99+' : messageStore.totalUnread }}
+            </span>
           </el-menu-item>
 
           <el-menu-item 
@@ -146,15 +149,17 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, ref, reactive } from 'vue'
+import { computed, onMounted, onUnmounted, ref, reactive } from 'vue'
 import { useRouter } from 'vue-router'
 import { useAuthStore } from '../store/auth'
 import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock, Setting, ChatDotRound, Folder, Menu, Upload } from '@element-plus/icons-vue'
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import api from '../utils/request'
+import { useMessageStore } from '../store/message'
 
 const router = useRouter()
 const authStore = useAuthStore()
+const messageStore = useMessageStore()
 const user = computed(() => authStore.user)
 
 // Logout & Menu Command
@@ -233,6 +238,11 @@ onMounted(() => {
   if (!user.value) {
     authStore.fetchUser()
   }
+  messageStore.initWebSocket()
+})
+
+onUnmounted(() => {
+  messageStore.disconnect()
 })
 </script>
 
@@ -303,6 +313,17 @@ onMounted(() => {
   background-color: #001528 !important;
 }
 
+.menu-unread-badge {
+  background: #ff4d4f;
+  color: #fff;
+  border-radius: 10px;
+  padding: 0 6px;
+  font-size: 12px;
+  height: 18px;
+  line-height: 18px;
+  margin-left: auto;
+}
+
 .el-header {
   padding: 0;
 }

+ 45 - 104
frontend/src/views/message/index.vue

@@ -43,9 +43,8 @@
             </div>
           </div>
           
-          <!-- 未读红点 -->
-          <div v-if="chat.unread_count > 0" class="unread-badge">
-            {{ chat.unread_count > 99 ? '99+' : chat.unread_count }}
+          <div v-if="(messageStore.unreadMap[chat.user_id] || 0) > 0" class="unread-badge">
+            {{ messageStore.unreadMap[chat.user_id] > 99 ? '99+' : messageStore.unreadMap[chat.user_id] }}
           </div>
         </div>
         
@@ -231,14 +230,16 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, computed, nextTick } from 'vue'
+import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue'
 import UserAvatar from '@/components/UserAvatar.vue'
 import { Picture, Document, House, ArrowRight } from '@element-plus/icons-vue'
 import api from '@/utils/request'
 import { useAuthStore } from '@/store/auth'
+import { useMessageStore, onNewMessage } from '@/store/message'
 import { ElMessage } from 'element-plus'
 
 const authStore = useAuthStore()
+const messageStore = useMessageStore()
 const currentUser = computed(() => authStore.user)
 const currentUserId = computed(() => authStore.user?.id)
 
@@ -271,95 +272,57 @@ const filteredConversations = computed(() => {
   )
 })
 
+// WS message handler (called by store when a new message arrives)
+const handleWsMessage = (newMessage: any) => {
+  const isSystemNotification = newMessage.type === 'NOTIFICATION' || newMessage.sender_id === null
+  const systemConvId = isSystemNotification && newMessage.app_id ? -newMessage.app_id : 0
+
+  if (isSystemNotification && currentChatId.value === systemConvId) {
+    messages.value.push(newMessage)
+    scrollToBottom()
+  } else if (
+    currentChatId.value === newMessage.sender_id ||
+    (newMessage.sender_id === currentUserId.value && currentChatId.value === newMessage.receiver_id)
+  ) {
+    messages.value.push(newMessage)
+    scrollToBottom()
+  }
+
+  if (isSystemNotification) {
+    updateConversationPreview(systemConvId, newMessage.content, newMessage.content_type, {
+      is_system: true,
+      app_id: newMessage.app_id,
+      app_name: newMessage.app_name
+    })
+  } else {
+    updateConversationPreview(
+      newMessage.sender_id === currentUserId.value ? newMessage.receiver_id : newMessage.sender_id,
+      newMessage.content,
+      newMessage.content_type
+    )
+  }
+}
+
 // Lifecycle
+let unsubscribeWs: (() => void) | null = null
+
 onMounted(() => {
   fetchConversations()
   if (!currentUser.value) authStore.fetchUser()
+  unsubscribeWs = onNewMessage(handleWsMessage)
 })
 
-// Methods
-const initWebSocket = () => {
-  if (!currentUser.value) return
-  
-  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-  // Use relative path or configured base URL
-  const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/messages?token=${localStorage.getItem('token')}`
-  
-  const ws = new WebSocket(wsUrl)
-  
-  ws.onmessage = (event) => {
-    if (event.data === 'pong') return
-    try {
-      const msg = JSON.parse(event.data)
-      if (msg.type === 'NEW_MESSAGE') {
-        const newMessage = msg.data
-        const isSystemNotification = newMessage.type === 'NOTIFICATION' || newMessage.sender_id === null
-
-        // 系统会话 ID:与后端约定一致,按 app 拆分
-        const systemConvId = isSystemNotification && newMessage.app_id ? -newMessage.app_id : 0
-        
-        // 如果当前会话打开,直接追加到消息流
-        if (isSystemNotification && currentChatId.value === systemConvId) {
-          messages.value.push(newMessage)
-          scrollToBottom()
-        } else if (
-          currentChatId.value === newMessage.sender_id ||
-          (newMessage.sender_id === currentUserId.value && currentChatId.value === newMessage.receiver_id)
-        ) {
-          // 私信:当前窗口就是发送者或接收者
-          messages.value.push(newMessage)
-          scrollToBottom()
-        } else if (newMessage.sender_id === currentUserId.value && currentChatId.value === newMessage.receiver_id) {
-          // 我在其他标签发送的消息
-          messages.value.push(newMessage)
-          scrollToBottom()
-        }
-        
-        // 更新左侧会话预览
-        if (isSystemNotification) {
-          updateConversationPreview(systemConvId, newMessage.content, newMessage.content_type, {
-            is_system: true,
-            app_id: newMessage.app_id,
-            app_name: newMessage.app_name
-          })
-        } else {
-          updateConversationPreview(
-            newMessage.sender_id === currentUserId.value ? newMessage.receiver_id : newMessage.sender_id,
-            newMessage.content,
-            newMessage.content_type
-          )
-        }
-        
-        // 未读计数(当前未打开该会话时)
-        if (isSystemNotification) {
-          if (currentChatId.value !== systemConvId) {
-            const conv = conversations.value.find(c => c.user_id === systemConvId)
-            if (conv) conv.unread_count = (conv.unread_count || 0) + 1
-          }
-        } else if (newMessage.sender_id !== currentUserId.value && currentChatId.value !== newMessage.sender_id) {
-          const conv = conversations.value.find(c => c.user_id === newMessage.sender_id)
-          if (conv) conv.unread_count = (conv.unread_count || 0) + 1
-        }
-      }
-    } catch (e) {
-      console.error('WS parse error', e)
-    }
-  }
-  
-  ws.onopen = () => {
-    // Start heartbeat
-    setInterval(() => {
-      if (ws.readyState === WebSocket.OPEN) ws.send('ping')
-    }, 30000)
-  }
-}
+onUnmounted(() => {
+  messageStore.setCurrentChat(null)
+  if (unsubscribeWs) unsubscribeWs()
+})
 
+// Methods
 const fetchConversations = async () => {
   loadingConversations.value = true
   try {
     const res = await api.get('/messages/conversations')
     conversations.value = res.data
-    initWebSocket() // Start WS after data loaded
   } catch (e) {
     console.error(e)
   } finally {
@@ -370,37 +333,15 @@ const fetchConversations = async () => {
 const selectChat = async (chat: any) => {
   currentChatId.value = chat.user_id
   currentChatUser.value = chat
-  
-  // Mark as read locally (API call typically happens here or on scroll)
-  // For simplicity, we just load history
+  messageStore.setCurrentChat(chat.user_id)
   await loadHistory(chat.user_id)
-  
-  // Clear unread count locally
-  const conv = conversations.value.find(c => c.user_id === chat.user_id)
-  if (conv) conv.unread_count = 0
 }
 
 const loadHistory = async (userId: number) => {
   try {
     const res = await api.get(`/messages/history/${userId}`)
-    // API returns newest first, reverse for display
     messages.value = res.data.reverse()
     scrollToBottom()
-    
-    // Mark messages as read
-    // Iterate and find unread... or just call a "read all from this user" endpoint?
-    const unreadIds = messages.value.filter(m => !m.is_read && m.receiver_id === currentUserId.value).map(m => m.id)
-    if (unreadIds.length > 0) {
-        // Implement batch read
-        // For efficiency, we mark one by one for now, or ideally backend supports batch
-        // Using Promise.all to parallelize
-        await Promise.all(unreadIds.map(id => api.put(`/messages/${id}/read`)))
-        
-        // Update local state for unread count
-        const conv = conversations.value.find(c => c.user_id === userId)
-        if (conv) conv.unread_count = 0
-    }
-    
   } catch (e) {
     console.error(e)
   }