Browse Source

消息中心的消息提醒更新

liuq 1 month ago
parent
commit
f371a3d2fd
3 changed files with 108 additions and 31 deletions
  1. 32 27
      frontend/src/store/message.ts
  2. 1 0
      frontend/src/views/Dashboard.vue
  3. 75 4
      frontend/src/views/message/index.vue

+ 32 - 27
frontend/src/store/message.ts

@@ -1,6 +1,6 @@
 import { defineStore } from 'pinia'
-import { ref, reactive, computed } from 'vue'
-import { useAuthStore } from './auth'
+import { ref, computed } from 'vue'
+import api from '@/utils/request'
 
 type MessageCallback = (msg: any) => void
 const listeners = new Set<MessageCallback>()
@@ -11,17 +11,36 @@ export function onNewMessage(cb: MessageCallback): () => void {
 }
 
 export const useMessageStore = defineStore('message', () => {
-  const unreadMap = reactive<Record<number, number>>({})
   const currentOpenChatId = ref<number | null>(null)
   const wsConnected = ref(false)
+  /** 来自 GET /messages/unread-count,供侧边栏角标使用 */
+  const serverUnreadTotal = ref(0)
 
   let ws: WebSocket | null = null
   let heartbeatTimer: ReturnType<typeof setInterval> | null = null
   let reconnectTimer: ReturnType<typeof setTimeout> | null = null
+  let unreadFetchTimer: ReturnType<typeof setTimeout> | null = null
 
-  const totalUnread = computed(() =>
-    Object.values(unreadMap).reduce((sum, n) => sum + n, 0)
-  )
+  const totalUnread = computed(() => serverUnreadTotal.value)
+
+  async function fetchUnreadCount() {
+    try {
+      const res = await api.get('/messages/unread-count')
+      const n = res.data
+      serverUnreadTotal.value = typeof n === 'number' ? n : Number(n)
+    } catch (e) {
+      console.error(e)
+    }
+  }
+
+  /** WS 推送可能连发,合并为一次 HTTP 查询 */
+  function scheduleFetchUnreadCount() {
+    if (unreadFetchTimer) clearTimeout(unreadFetchTimer)
+    unreadFetchTimer = setTimeout(() => {
+      unreadFetchTimer = null
+      fetchUnreadCount()
+    }, 300)
+  }
 
   function initWebSocket() {
     if (ws && ws.readyState <= WebSocket.OPEN) return
@@ -41,6 +60,7 @@ export const useMessageStore = defineStore('message', () => {
 
     ws.onopen = () => {
       wsConnected.value = true
+      fetchUnreadCount()
       heartbeatTimer = setInterval(() => {
         if (ws && ws.readyState === WebSocket.OPEN) ws.send('ping')
       }, 30000)
@@ -70,30 +90,12 @@ export const useMessageStore = defineStore('message', () => {
   }
 
   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
-    }
-
+    scheduleFetchUnreadCount()
     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() {
@@ -101,6 +103,10 @@ export const useMessageStore = defineStore('message', () => {
       clearInterval(heartbeatTimer)
       heartbeatTimer = null
     }
+    if (unreadFetchTimer) {
+      clearTimeout(unreadFetchTimer)
+      unreadFetchTimer = null
+    }
   }
 
   function disconnect() {
@@ -118,13 +124,12 @@ export const useMessageStore = defineStore('message', () => {
   }
 
   return {
-    unreadMap,
     currentOpenChatId,
     wsConnected,
     totalUnread,
+    fetchUnreadCount,
     initWebSocket,
     setCurrentChat,
-    clearChat,
     disconnect,
   }
 })

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

@@ -239,6 +239,7 @@ onMounted(() => {
     authStore.fetchUser()
   }
   messageStore.initWebSocket()
+  messageStore.fetchUnreadCount()
 })
 
 onUnmounted(() => {

+ 75 - 4
frontend/src/views/message/index.vue

@@ -12,6 +12,18 @@
         <el-button icon="Plus" circle class="add-btn" @click="showUserSelector = true" />
       </div>
 
+      <div class="conversation-toolbar">
+        <el-button
+          type="primary"
+          link
+          :loading="markingAllRead"
+          :disabled="conversations.length === 0"
+          @click="markAllRead"
+        >
+          全部已读
+        </el-button>
+      </div>
+
       <div class="conversation-list" v-loading="loadingConversations">
         <div 
           v-for="chat in filteredConversations" 
@@ -43,8 +55,8 @@
             </div>
           </div>
           
-          <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 v-if="(chat.unread_count || 0) > 0" class="unread-badge">
+            {{ (chat.unread_count || 0) > 99 ? '99+' : chat.unread_count }}
           </div>
         </div>
         
@@ -265,7 +277,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue'
+import { ref, onMounted, onUnmounted, onActivated, 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'
@@ -293,6 +305,7 @@ const showUserSelector = ref(false)
 const selectedUserId = ref<number | null>(null)
 const userOptions = ref<any[]>([])
 const searchingUsers = ref(false)
+const markingAllRead = ref(false)
 
 // 应用名称缓存
 const appNameCache = ref<Record<number, string>>({})
@@ -307,6 +320,16 @@ const filteredConversations = computed(() => {
   )
 })
 
+// WS 推送到来时同步会话列表上的 unread_count(防抖)
+let convRefreshTimer: ReturnType<typeof setTimeout> | null = null
+const scheduleRefreshConversations = () => {
+  if (convRefreshTimer) clearTimeout(convRefreshTimer)
+  convRefreshTimer = setTimeout(() => {
+    convRefreshTimer = null
+    fetchConversations()
+  }, 400)
+}
+
 // WS message handler (called by store when a new message arrives)
 const handleWsMessage = (newMessage: any) => {
   const isSystemNotification = newMessage.type === 'NOTIFICATION' || newMessage.sender_id === null
@@ -338,18 +361,33 @@ const handleWsMessage = (newMessage: any) => {
       newMessage.content_type === 'USER_NOTIFICATION' ? { title: newMessage.title } : undefined
     )
   }
+
+  scheduleRefreshConversations()
+}
+
+const refreshMessagePage = () => {
+  fetchConversations()
+  messageStore.fetchUnreadCount()
 }
 
 // Lifecycle
 let unsubscribeWs: (() => void) | null = null
 
 onMounted(() => {
-  fetchConversations()
+  refreshMessagePage()
   if (!currentUser.value) authStore.fetchUser()
   unsubscribeWs = onNewMessage(handleWsMessage)
 })
 
+onActivated(() => {
+  refreshMessagePage()
+})
+
 onUnmounted(() => {
+  if (convRefreshTimer) {
+    clearTimeout(convRefreshTimer)
+    convRefreshTimer = null
+  }
   messageStore.setCurrentChat(null)
   if (unsubscribeWs) unsubscribeWs()
 })
@@ -371,9 +409,33 @@ const selectChat = async (chat: any) => {
   currentChatId.value = chat.user_id
   currentChatUser.value = chat
   messageStore.setCurrentChat(chat.user_id)
+  try {
+    await api.put(`/messages/history/${chat.user_id}/read-all`)
+    const conv = conversations.value.find(c => c.user_id === chat.user_id)
+    if (conv) conv.unread_count = 0
+    await messageStore.fetchUnreadCount()
+  } catch (e) {
+    console.error(e)
+  }
   await loadHistory(chat.user_id)
 }
 
+const markAllRead = async () => {
+  markingAllRead.value = true
+  try {
+    await api.put('/messages/read-all')
+    conversations.value.forEach(c => {
+      c.unread_count = 0
+    })
+    await messageStore.fetchUnreadCount()
+    ElMessage.success('已全部标为已读')
+  } catch (e) {
+    console.error(e)
+  } finally {
+    markingAllRead.value = false
+  }
+}
+
 const loadHistory = async (userId: number) => {
   try {
     const res = await api.get(`/messages/history/${userId}`)
@@ -623,6 +685,15 @@ const handleNotificationAction = async (msg: any) => {
   border-bottom: 1px solid #e6e6e6;
 }
 
+.conversation-toolbar {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  padding: 6px 15px;
+  background: #f0f0f0;
+  border-bottom: 1px solid #e6e6e6;
+}
+
 .conversation-list {
   flex: 1;
   overflow-y: auto;