Bladeren bron

更新消息通知逻辑

liuq 1 maand geleden
bovenliggende
commit
4a7215e891

+ 298 - 20
src/main/index.ts

@@ -1,4 +1,4 @@
-import { app, shell, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, IpcMainEvent, dialog } from 'electron'
+import { app, shell, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, IpcMainEvent, dialog, screen } from 'electron'
 import { join } from 'path'
 import { electronApp, optimizer, is } from '@electron-toolkit/utils'
 import { mkdirSync, appendFileSync, existsSync, writeFileSync } from 'fs'
@@ -41,7 +41,16 @@ let isQuitting = false
 let blinkInterval: NodeJS.Timeout | null = null
 let previewWindow: BrowserWindow | null = null
 let videoPlayerWindow: BrowserWindow | null = null
-const unreadMap = new Map<number, string>() // id -> "Name: Message"
+let trayPopupWindow: BrowserWindow | null = null
+let trayPopupHideTimer: NodeJS.Timeout | null = null
+let trayHoverCheckTimer: NodeJS.Timeout | null = null
+
+interface UnreadInfo {
+  name: string
+  content: string
+  count: number
+}
+const unreadMap = new Map<number, UnreadInfo>()
 
 // --- 浏览器视图管理器 ---
 interface TabInfo {
@@ -217,45 +226,259 @@ function getIconPath(): string {
   return getResourcePath('logo.png')
 }
 
+function getTotalUnreadCount(): number {
+  let total = 0
+  for (const info of unreadMap.values()) {
+    total += info.count
+  }
+  return total
+}
+
 function updateTrayMenu(): void {
   if (!tray) return
+  const totalUnread = getTotalUnreadCount()
   const contextMenu = Menu.buildFromTemplate([
-    { label: unreadMap.size > 0 ? `未读消息 (${unreadMap.size})` : '无未读消息', enabled: false },
+    { label: totalUnread > 0 ? `未读消息 (${totalUnread})` : '无未读消息', enabled: false },
     { type: 'separator' },
     { label: '显示主界面', click: () => mainWindow?.show() },
     { label: '退出', click: () => { isQuitting = true; app.quit() } }
   ])
   tray.setContextMenu(contextMenu)
-  if (unreadMap.size > 0) {
-    tray.setToolTip(`韫珠IM - ${unreadMap.size} 条未读消息`)
+  tray.setToolTip('韫珠IM')
+}
+
+// --- 托盘悬浮弹窗 ---
+
+function getTrayPopupHTML(): string {
+  return `<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<style>
+*{margin:0;padding:0;box-sizing:border-box;}
+body{font-family:'Microsoft YaHei','PingFang SC',sans-serif;background:transparent;overflow:hidden;user-select:none;}
+.popup{background:#fff;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,0.18);overflow:hidden;display:flex;flex-direction:column;max-height:100vh;}
+.list{overflow-y:auto;max-height:calc(100vh - 40px);flex:1;}
+.list::-webkit-scrollbar{width:4px;}
+.list::-webkit-scrollbar-thumb{background:#ccc;border-radius:2px;}
+.msg-item{display:flex;align-items:center;padding:10px 14px;cursor:pointer;border-bottom:1px solid #f0f0f0;transition:background 0.15s;}
+.msg-item:last-child{border-bottom:none;}
+.msg-item:hover{background:#f5f5f5;}
+.msg-avatar{width:40px;height:40px;border-radius:6px;background:#4a90d9;color:#fff;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:bold;flex-shrink:0;position:relative;overflow:visible;}
+.badge{position:absolute;top:-5px;right:-5px;background:#ff3b30;color:#fff;border-radius:10px;padding:0 5px;font-size:10px;min-width:16px;height:16px;line-height:16px;text-align:center;font-weight:normal;}
+.msg-info{margin-left:10px;overflow:hidden;flex:1;}
+.msg-name{font-size:14px;color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;}
+.msg-content{font-size:12px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px;}
+.footer{text-align:center;padding:10px;color:#576b95;font-size:13px;cursor:pointer;border-top:1px solid #eee;flex-shrink:0;transition:background 0.15s;}
+.footer:hover{background:#f5f5f5;}
+.empty{padding:20px;text-align:center;color:#999;font-size:13px;}
+</style>
+</head>
+<body>
+<div class="popup">
+  <div class="list" id="list"></div>
+  <div class="footer" id="dismiss">暂不处理</div>
+</div>
+<script>
+(function(){
+  const list = document.getElementById('list');
+  const dismiss = document.getElementById('dismiss');
+  const ipc = window.electron && window.electron.ipcRenderer;
+
+  if(ipc){
+    ipc.on('update-unread', function(_, items){
+      list.innerHTML = '';
+      if(!items || items.length === 0){
+        list.innerHTML = '<div class="empty">暂无未读消息</div>';
+        return;
+      }
+      items.forEach(function(item){
+        var div = document.createElement('div');
+        div.className = 'msg-item';
+        var initial = item.name ? item.name.charAt(0) : '?';
+        var badgeText = item.count > 99 ? '99+' : String(item.count);
+        div.innerHTML =
+          '<div class="msg-avatar">' + initial +
+            '<div class="badge">' + badgeText + '</div>' +
+          '</div>' +
+          '<div class="msg-info">' +
+            '<div class="msg-name">' + escapeHtml(item.name) + '</div>' +
+            '<div class="msg-content">' + escapeHtml(item.content) + '</div>' +
+          '</div>';
+        div.addEventListener('click', function(){ ipc.send('tray-popup-click', item.id); });
+        list.appendChild(div);
+      });
+    });
+  }
+
+  dismiss.addEventListener('click', function(){
+    if(ipc) ipc.send('tray-popup-dismiss');
+  });
+
+  document.addEventListener('mouseleave', function(){
+    if(ipc) ipc.send('tray-popup-mouse-leave');
+  });
+
+  document.addEventListener('mouseenter', function(){
+    if(ipc) ipc.send('tray-popup-mouse-enter');
+  });
+
+  function escapeHtml(str){
+    if(!str) return '';
+    return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+  }
+})();
+</script>
+</body>
+</html>`
+}
+
+function createTrayPopup(): void {
+  if (trayPopupWindow && !trayPopupWindow.isDestroyed()) return
+
+  trayPopupWindow = new BrowserWindow({
+    width: 320,
+    height: 200,
+    frame: false,
+    transparent: true,
+    alwaysOnTop: true,
+    skipTaskbar: true,
+    resizable: false,
+    show: false,
+    focusable: false,
+    webPreferences: {
+      preload: join(__dirname, '../preload/index.js'),
+      sandbox: false,
+      nodeIntegration: false,
+      contextIsolation: true,
+    }
+  })
+
+  trayPopupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(getTrayPopupHTML())}`)
+
+  trayPopupWindow.on('closed', () => {
+    trayPopupWindow = null
+  })
+}
+
+function getUnreadListForPopup(): { id: number, name: string, content: string, count: number }[] {
+  const list: { id: number, name: string, content: string, count: number }[] = []
+  for (const [id, info] of unreadMap.entries()) {
+    list.push({ id, name: info.name, content: info.content, count: info.count })
+  }
+  return list
+}
+
+function showTrayPopup(): void {
+  if (!tray || unreadMap.size === 0) return
+
+  if (!trayPopupWindow || trayPopupWindow.isDestroyed()) {
+    createTrayPopup()
+  }
+  if (!trayPopupWindow) return
+
+  if (trayPopupHideTimer) {
+    clearTimeout(trayPopupHideTimer)
+    trayPopupHideTimer = null
+  }
+
+  if (trayPopupWindow.isVisible()) {
+    trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
+    return
+  }
+
+  const trayBounds = tray.getBounds()
+  const itemHeight = 61
+  const footerHeight = 40
+  const maxItems = 5
+  const visibleItems = Math.min(unreadMap.size, maxItems)
+  const popupHeight = visibleItems * itemHeight + footerHeight + 2
+  const popupWidth = 320
+
+  const display = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y })
+  const workArea = display.workArea
+
+  let x = Math.round(trayBounds.x - popupWidth / 2 + trayBounds.width / 2)
+  let y: number
+
+  if (trayBounds.y < workArea.y + workArea.height / 2) {
+    y = trayBounds.y + trayBounds.height + 4
   } else {
-    tray.setToolTip('韫珠IM')
+    y = trayBounds.y - popupHeight - 4
+  }
+
+  if (x + popupWidth > workArea.x + workArea.width) {
+    x = workArea.x + workArea.width - popupWidth - 4
+  }
+  if (x < workArea.x) {
+    x = workArea.x + 4
+  }
+
+  trayPopupWindow.setSize(popupWidth, popupHeight)
+  trayPopupWindow.setPosition(x, y)
+
+  trayPopupWindow.webContents.once('did-finish-load', () => {
+    if (trayPopupWindow && !trayPopupWindow.isDestroyed()) {
+      trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
+    }
+  })
+
+  if (trayPopupWindow.webContents.isLoading()) {
+    // will send data in did-finish-load
+  } else {
+    trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
+  }
+
+  trayPopupWindow.showInactive()
+}
+
+function hideTrayPopup(): void {
+  if (trayPopupHideTimer) {
+    clearTimeout(trayPopupHideTimer)
+    trayPopupHideTimer = null
+  }
+  if (trayHoverCheckTimer) {
+    clearInterval(trayHoverCheckTimer)
+    trayHoverCheckTimer = null
+  }
+  if (trayPopupWindow && !trayPopupWindow.isDestroyed() && trayPopupWindow.isVisible()) {
+    trayPopupWindow.hide()
+  }
+}
+
+function scheduleTrayPopupHide(): void {
+  if (trayPopupHideTimer) clearTimeout(trayPopupHideTimer)
+  trayPopupHideTimer = setTimeout(() => {
+    hideTrayPopup()
+  }, 300)
+}
+
+function cancelTrayPopupHide(): void {
+  if (trayPopupHideTimer) {
+    clearTimeout(trayPopupHideTimer)
+    trayPopupHideTimer = null
   }
 }
 
-// 更新窗口标题显示未读消息数
 function updateWindowTitle(): void {
   if (!mainWindow) return
-  const unreadCount = unreadMap.size
-  if (unreadCount > 0) {
-    mainWindow.setTitle(`韫珠IM (${unreadCount} 条未读消息)`)
+  const totalUnread = getTotalUnreadCount()
+  if (totalUnread > 0) {
+    mainWindow.setTitle(`韫珠IM (${totalUnread} 条未读消息)`)
   } else {
     mainWindow.setTitle('韫珠IM')
   }
 }
 
-// 更新任务栏徽章(使用系统默认提示)
 function updateTaskbarOverlay(): void {
-  const unreadCount = unreadMap.size
-  // 使用系统默认的徽章提示
-  if (unreadCount > 0) {
-    app.setBadgeCount(unreadCount)
+  const totalUnread = getTotalUnreadCount()
+  if (totalUnread > 0) {
+    app.setBadgeCount(totalUnread)
   } else {
     app.setBadgeCount(0)
   }
 }
 
-// 更新未读消息状态(统一处理托盘和窗口)
 function updateUnreadStatus(): void {
   updateTrayMenu()
   updateWindowTitle()
@@ -264,6 +487,10 @@ function updateUnreadStatus(): void {
     startBlinking()
   } else {
     stopBlinking()
+    hideTrayPopup()
+  }
+  if (trayPopupWindow && !trayPopupWindow.isDestroyed() && trayPopupWindow.isVisible()) {
+    trayPopupWindow.webContents.send('update-unread', getUnreadListForPopup())
   }
 }
 
@@ -1048,12 +1275,14 @@ app.whenReady().then(() => {
     }
   })
 
-  // 处理未读消息通知
   ipcMain.on('start-notification', (_, data: { id: number, name: string, content: string }) => {
     const { id, name, content } = data
-    // 更新未读消息映射
-    unreadMap.set(id, `${name}: ${content}`)
-    // 更新托盘和窗口提示
+    const existing = unreadMap.get(id)
+    unreadMap.set(id, {
+      name,
+      content,
+      count: existing ? existing.count + 1 : 1
+    })
     updateUnreadStatus()
   })
 
@@ -1288,10 +1517,59 @@ app.whenReady().then(() => {
   tray = new Tray(trayIcon)
   tray.setToolTip('韫珠IM')
   tray.on('click', () => {
+    hideTrayPopup()
     mainWindow?.show()
   })
+  tray.on('mouse-move', () => {
+    if (unreadMap.size > 0) {
+      showTrayPopup()
+      cancelTrayPopupHide()
+
+      if (!trayHoverCheckTimer) {
+        trayHoverCheckTimer = setInterval(() => {
+          if (!tray) return
+          const cursor = screen.getCursorScreenPoint()
+          const tb = tray.getBounds()
+          const isOverTray = cursor.x >= tb.x && cursor.x <= tb.x + tb.width &&
+                            cursor.y >= tb.y && cursor.y <= tb.y + tb.height
+
+          let isOverPopup = false
+          if (trayPopupWindow && !trayPopupWindow.isDestroyed() && trayPopupWindow.isVisible()) {
+            const pb = trayPopupWindow.getBounds()
+            isOverPopup = cursor.x >= pb.x && cursor.x <= pb.x + pb.width &&
+                         cursor.y >= pb.y && cursor.y <= pb.y + pb.height
+          }
+
+          if (!isOverTray && !isOverPopup) {
+            hideTrayPopup()
+          }
+        }, 300)
+      }
+    }
+  })
   updateTrayMenu()
 
+  ipcMain.on('tray-popup-click', (_, contactId: number) => {
+    hideTrayPopup()
+    if (mainWindow) {
+      mainWindow.show()
+      mainWindow.focus()
+      mainWindow.webContents.send('switch-contact', contactId)
+    }
+  })
+
+  ipcMain.on('tray-popup-dismiss', () => {
+    hideTrayPopup()
+  })
+
+  ipcMain.on('tray-popup-mouse-leave', () => {
+    scheduleTrayPopupHide()
+  })
+
+  ipcMain.on('tray-popup-mouse-enter', () => {
+    cancelTrayPopupHide()
+  })
+
   createWindow()
 
   app.on('activate', function () {

+ 0 - 1
src/renderer/src/App.refactored.tsx

@@ -167,7 +167,6 @@ function App(): JSX.Element {
                 actionUrl: msg.action_url,
                 actionText: msg.action_text,
                 messageType: msg.type,
-                isRead: msg.is_read,
                 content_type: msg.content_type,
                 size: (msg as any).size
               }

+ 0 - 2
src/renderer/src/App.tsx

@@ -67,7 +67,6 @@ function App(): JSX.Element {
     isLoggedIn,
     token,
     currentUserId,
-    activeContactId,
     activeContactIdRef,
     contactsRef,
     setMessages,
@@ -170,7 +169,6 @@ function App(): JSX.Element {
                 actionUrl: msg.action_url,
                 actionText: msg.action_text,
                 messageType: msg.type,
-                isRead: msg.is_read,
                 content_type: msg.content_type,
                 size: (msg as any).size
               }

+ 4 - 9
src/renderer/src/components/AppMappingList.tsx

@@ -273,12 +273,7 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
       alert('该账号已禁用,无法登录')
       return
     }
-    
-    if (mapping.protocol_type !== 'SIMPLE_API') {
-      alert('仅支持简易 API 类型的应用')
-      return
-    }
-    
+
     setLoginLoading(prev => ({ ...prev, [mapping.app_id]: true }))
     
     try {
@@ -306,7 +301,7 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
   const handleRecentAppClick = useCallback(async (item: RecentAppItem) => {
     const mapping = mappings.find(m => m.app_id === item.app_id && m.mapped_key === item.mapped_key)
     if (mapping) {
-      if (!mapping.is_active || mapping.protocol_type !== 'SIMPLE_API') return
+      if (!mapping.is_active) return
       await handleSsoLogin(mapping)
       return
     }
@@ -530,7 +525,7 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
                     key={`${mapping.app_id}_${mapping.mapped_key}`}
                     className={`browser-card ${!mapping.is_active || loginLoading[mapping.app_id] ? 'disabled' : ''}`}
                     onClick={() => {
-                      if (mapping.is_active && mapping.protocol_type === 'SIMPLE_API' && !loginLoading[mapping.app_id]) {
+                      if (mapping.is_active && (mapping.protocol_type === 'SIMPLE_API' || mapping.protocol_type === 'OIDC') && !loginLoading[mapping.app_id]) {
                         handleSsoLogin(mapping)
                       }
                     }}
@@ -558,7 +553,7 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
                 key={`${mapping.app_id}_${mapping.mapped_key}`}
                 className={`browser-card ${!mapping.is_active || loginLoading[mapping.app_id] ? 'disabled' : ''}`}
                 onClick={() => {
-                  if (mapping.is_active && mapping.protocol_type === 'SIMPLE_API' && !loginLoading[mapping.app_id]) {
+                  if (mapping.is_active && (mapping.protocol_type === 'SIMPLE_API' || mapping.protocol_type === 'OIDC') && !loginLoading[mapping.app_id]) {
                     handleSsoLogin(mapping)
                   }
                 }}

+ 10 - 5
src/renderer/src/hooks/useContacts.ts

@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef } from 'react'
+import { useState, useEffect, useRef, useCallback } from 'react'
 import { Contact } from '../types'
 import { api } from '../services/api'
 import { logger } from '../utils/logger'
@@ -14,12 +14,17 @@ export function useContacts(
   const [contacts, setContacts] = useState<Contact[]>([])
   const [isLoadingContacts, setIsLoadingContacts] = useState(false)
   const contactsRef = useRef<Contact[]>([])
+  const activeContactIdRef = useRef(activeContactId)
 
   useEffect(() => {
     contactsRef.current = contacts
   }, [contacts])
 
-  const fetchContacts = async () => {
+  useEffect(() => {
+    activeContactIdRef.current = activeContactId
+  }, [activeContactId])
+
+  const fetchContacts = useCallback(async () => {
     if (!token) return
     
     setIsLoadingContacts(true)
@@ -38,7 +43,7 @@ export function useContacts(
       
       setContacts(formattedContacts)
       
-      if (formattedContacts.length > 0 && !activeContactId) {
+      if (formattedContacts.length > 0 && activeContactIdRef.current === null) {
         setActiveContactId(formattedContacts[0].id)
       }
     } catch (error: any) {
@@ -46,13 +51,13 @@ export function useContacts(
     } finally {
       setIsLoadingContacts(false)
     }
-  }
+  }, [token, unreadCountsRef, setActiveContactId])
 
   useEffect(() => {
     if (token) {
       fetchContacts()
     }
-  }, [token])
+  }, [token, fetchContacts])
 
   return {
     contacts,

+ 4 - 67
src/renderer/src/hooks/useMessages.ts

@@ -16,7 +16,6 @@ export function useMessages(
   const [isLoadingMore, setIsLoadingMore] = useState(false)
   const [hasMoreMessages, setHasMoreMessages] = useState<Record<number, boolean>>({})
   const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({})
-  const markedReadContactsRef = useRef<Set<number>>(new Set())
   const isLoadingMoreRef = useRef(false)
 
   // 获取指定联系人的消息列表
@@ -59,7 +58,6 @@ export function useMessages(
           actionUrl: msg.action_url,
           actionText: msg.action_text,
           messageType: msg.type,
-          isRead: msg.is_read,
           content_type: msg.content_type,
           size: (msg as any).size
         }
@@ -110,7 +108,6 @@ export function useMessages(
       actionUrl: msg.action_url,
       actionText: msg.action_text,
       messageType: msg.type,
-      isRead: msg.is_read,
       content_type: msg.content_type,
       size: (msg as any).size
     }
@@ -280,78 +277,18 @@ export function useMessages(
     }
   }, [token])
 
-  // 标记消息为已读
+  // 切换到某个会话时,清除该会话的本地未读计数
   useEffect(() => {
-    if (activeContactId && token && currentUserId && messages[activeContactId]) {
-      if (markedReadContactsRef.current.has(activeContactId)) {
-        return
-      }
-
-      const contactMessages = messages[activeContactId]
-      const messagesFromOthers = contactMessages.filter(msg => !msg.isSelf)
-
-      if (messagesFromOthers.length > 0) {
-        logger.info('App: Marking all messages as read when opening chat', {
-          contactId: activeContactId,
-          messageCount: messagesFromOthers.length
-        })
-
-        setMessages(prev => {
-          const updated = { ...prev }
-          if (updated[activeContactId]) {
-            updated[activeContactId] = updated[activeContactId].map(msg => 
-              !msg.isSelf ? { ...msg, isRead: true } : msg
-            )
-          }
-          return updated
-        })
-
-        markedReadContactsRef.current.add(activeContactId)
-
-        Promise.all(
-          messagesFromOthers.map(msg => 
-            api.markMessageRead(token, msg.id).catch(error => {
-              logger.error('App: Failed to mark message as read', { messageId: msg.id, error })
-              setMessages(prev => {
-                const updated = { ...prev }
-                if (updated[activeContactId]) {
-                  updated[activeContactId] = updated[activeContactId].map(m => 
-                    m.id === msg.id ? { ...m, isRead: false } : m
-                  )
-                }
-                return updated
-              })
-            })
-          )
-        ).then(() => {
-          logger.info('App: All messages marked as read', { contactId: activeContactId })
-          if (window.electron && window.electron.ipcRenderer) {
-            window.electron.ipcRenderer.send('clear-unread', activeContactId)
-          }
-        })
-      } else {
-        markedReadContactsRef.current.add(activeContactId)
-        if (window.electron && window.electron.ipcRenderer) {
-          window.electron.ipcRenderer.send('clear-unread', activeContactId)
-        }
-      }
-      
+    if (activeContactId) {
       unreadCountsRef.current[activeContactId] = 0
-      setContacts(prev => prev.map(c => 
+      setContacts(prev => prev.map(c =>
         c.id === activeContactId ? { ...c, unreadCount: 0 } : c
       ))
-    }
-  }, [activeContactId, token, currentUserId, messages, setContacts, unreadCountsRef])
-
-  // 切换联系人时清除已标记状态
-  useEffect(() => {
-    if (activeContactId) {
-      markedReadContactsRef.current.delete(activeContactId)
       if (window.electron && window.electron.ipcRenderer) {
         window.electron.ipcRenderer.send('clear-unread', activeContactId)
       }
     }
-  }, [activeContactId])
+  }, [activeContactId, setContacts, unreadCountsRef])
 
   // 切换联系人时获取消息
   useEffect(() => {

+ 49 - 58
src/renderer/src/hooks/useWebSocket.ts

@@ -1,6 +1,5 @@
 import { useEffect, useRef } from 'react'
 import { WebSocketService } from '../services/websocket'
-import { api } from '../services/api'
 import { logger } from '../utils/logger'
 import { Message, Contact } from '../types'
 
@@ -8,7 +7,6 @@ interface UseWebSocketProps {
   isLoggedIn: boolean
   token: string
   currentUserId: number | null
-  activeContactId: number | null
   activeContactIdRef: React.MutableRefObject<number | null>
   contactsRef: React.MutableRefObject<Contact[]>
   setMessages: React.Dispatch<React.SetStateAction<Record<number, Message[]>>>
@@ -21,7 +19,6 @@ export function useWebSocket({
   isLoggedIn,
   token,
   currentUserId,
-  activeContactId,
   activeContactIdRef,
   contactsRef,
   setMessages,
@@ -29,6 +26,11 @@ export function useWebSocket({
   unreadCountsRef,
   fetchContacts
 }: UseWebSocketProps) {
+  const fetchContactsRef = useRef(fetchContacts)
+  useEffect(() => {
+    fetchContactsRef.current = fetchContacts
+  }, [fetchContacts])
+
   useEffect(() => {
     let wsService: WebSocketService | null = null
 
@@ -46,13 +48,20 @@ export function useWebSocket({
           type: incomingMsg.type,
           sender_id: incomingMsg.sender_id,
           receiver_id: incomingMsg.receiver_id,
+          app_id: (incomingMsg as any).app_id,
           currentUserId: currentUserId
         })
         
         let targetContactId: number | null = null
         const isNotification = incomingMsg.type === 'NOTIFICATION'
+        const appId = (incomingMsg as any).app_id as number | undefined
+        const isBroadcast = Boolean((incomingMsg as any).is_broadcast)
         
-        if (isNotification) {
+        if (isNotification && appId) {
+          // 系统通知 / 广播:按照 Web 端约定,使用负的 app_id 作为会话 ID
+          targetContactId = -appId
+        } else if (isNotification) {
+          // 没有 app_id 的通知,回退到按 receiver_id 归入普通会话
           targetContactId = incomingMsg.receiver_id || 0
         } else {
           if (incomingMsg.receiver_id === currentUserId) {
@@ -104,15 +113,12 @@ export function useWebSocket({
           actionUrl: incomingMsg.action_url,
           actionText: incomingMsg.action_text || '立即处理',
           messageType: incomingMsg.type,
-          isRead: incomingMsg.is_read,
           content_type: incomingMsg.content_type,
           size: (incomingMsg as any).size
         }
 
         const currentChatId = activeContactIdRef.current
-        const isCurrentChat = 
-          (!isSelf && currentChatId === targetContactId) ||
-          (isSelf && currentChatId === targetContactId)
+        const isCurrentChat = currentChatId === targetContactId
         
         logger.info('App: Processing new message', {
           targetContactId,
@@ -121,60 +127,44 @@ export function useWebSocket({
           isSelf
         })
         
-        if (isCurrentChat) {
-          setMessages(prev => {
-            const currentMessages = prev[targetContactId!] || []
-            const exists = currentMessages.some(msg => msg.id === newMessage.id)
-            if (exists) {
-              logger.warn('App: Message already exists, skipping', { messageId: newMessage.id })
-              return prev
-            }
-            logger.info('App: Adding message to current chat window', { 
-              targetContactId, 
-              messageId: newMessage.id
-            })
-            return {
-              ...prev,
-              [targetContactId!]: [...currentMessages, newMessage]
-            }
-          })
-          
-          if (!isSelf && incomingMsg.id) {
-            api.markMessageRead(token, incomingMsg.id).then(() => {
-              setMessages(prev => {
-                const updated = { ...prev }
-                if (updated[targetContactId!]) {
-                  updated[targetContactId!] = updated[targetContactId!].map(msg => 
-                    msg.id === incomingMsg.id ? { ...msg, isRead: true } : msg
-                  )
-                }
-                return updated
-              })
-            }).catch(error => {
-              logger.error('App: Failed to mark message as read', error)
-            })
+        setMessages(prev => {
+          const currentMessages = prev[targetContactId!] || []
+          const exists = currentMessages.some(msg => msg.id === newMessage.id)
+          if (exists) {
+            logger.warn('App: Message already exists, skipping', { messageId: newMessage.id })
+            return prev
           }
-        } else {
-          setMessages(prev => {
-            const currentMessages = prev[targetContactId!] || []
-            const exists = currentMessages.some(msg => msg.id === newMessage.id)
+          return {
+            ...prev,
+            [targetContactId!]: [...currentMessages, newMessage]
+          }
+        })
+        
+        const currentContacts = contactsRef.current
+        let contact = currentContacts.find(c => c.id === targetContactId)
+        
+        // 对于系统通知会话,如果当前联系人列表中不存在,则在本地创建一个虚拟联系人
+        if (!contact && isNotification && appId) {
+          const systemContact: Contact = {
+            id: targetContactId,
+            name: (incomingMsg as any).app_name || '系统通知',
+            avatar: '📢',
+            lastMessage: undefined,
+            lastMessageTime: undefined,
+            unreadCount: 0
+          }
+          
+          contact = systemContact
+          
+          setContacts(prev => {
+            const exists = prev.some(c => c.id === targetContactId)
             if (exists) {
               return prev
             }
-            logger.info('App: Adding message to inactive conversation', {
-              targetContactId,
-              currentChatId
-            })
-            return {
-              ...prev,
-              [targetContactId!]: [...currentMessages, newMessage]
-            }
+            return [systemContact, ...prev]
           })
         }
         
-        const currentContacts = contactsRef.current
-        const contact = currentContacts.find(c => c.id === targetContactId)
-        
         if (contact) {
           const lastMessageText = isNotification 
             ? (incomingMsg.title || incomingMsg.content)
@@ -209,7 +199,7 @@ export function useWebSocket({
           })
         } else {
           logger.info('App: Contact not in list, refreshing contacts', { targetContactId })
-          fetchContacts().catch(error => {
+          fetchContactsRef.current().catch(error => {
             logger.error('App: Failed to refresh contacts after receiving message', error)
           })
         }
@@ -220,7 +210,7 @@ export function useWebSocket({
             : newMessage.content
           window.electron.ipcRenderer.send('start-notification', { 
             id: targetContactId, 
-            name: isNotification ? '系统通知' : '新消息', 
+            name: isNotification ? '系统通知' : (contact?.name || '新消息'), 
             content: notificationContent 
           })
         }
@@ -234,5 +224,6 @@ export function useWebSocket({
     return () => {
       wsService?.disconnect()
     }
-  }, [isLoggedIn, token, currentUserId, activeContactId, activeContactIdRef, contactsRef, setMessages, setContacts, unreadCountsRef, fetchContacts])
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isLoggedIn, token, currentUserId])
 }

+ 1 - 24
src/renderer/src/services/api.ts

@@ -32,8 +32,7 @@ export interface MessageResponse {
   sender_id?: number;
   receiver_id?: number;
   app_user_id?: string;
-  is_read?: boolean; // 消息是否已读
-  size?: number; // 文件大小(字节)
+  size?: number;
 }
 
 export interface ContactResponse {
@@ -746,28 +745,6 @@ export const api = {
     return response.json();
   },
 
-  /**
-   * 标记消息为已读
-   * @param token 用户 Token
-   * @param messageId 消息ID
-   */
-  markMessageRead: async (token: string, messageId: number): Promise<void> => {
-    logger.info('API: Mark message as read', { messageId });
-    
-    const response = await fetch(`${API_BASE_URL}/messages/${messageId}/read`, {
-      method: 'PUT',
-      headers: {
-        'Authorization': `Bearer ${token}`
-      }
-    });
-
-    if (!response.ok) {
-      const error = await response.json().catch(() => ({}));
-      logger.error('API: Mark message as read failed', { status: response.status, error });
-      throw new Error(error.detail || '标记消息已读失败');
-    }
-  },
-
   /**
    * 获取通知/广播消息的可跳转链接(带 ticket 的 callback_url,用于 SSO 跳转)
    * @param token 用户 Token

+ 1 - 2
src/renderer/src/services/websocket.ts

@@ -13,8 +13,7 @@ export interface WebSocketMessage {
     created_at: string;
     sender_id?: number;
     receiver_id?: number;
-    is_read?: boolean; // 消息是否已读
-    size?: number; // 文件大小(字节)
+    size?: number;
   };
 }
 

+ 3 - 5
src/renderer/src/types/index.ts

@@ -6,14 +6,12 @@ export interface Message {
   isSelf: boolean
   type: 'text' | 'image' | 'file' | 'video' | 'notification'
   timestamp: number
-  // 通知相关字段
   title?: string
   actionUrl?: string
   actionText?: string
-  messageType?: 'MESSAGE' | 'NOTIFICATION' // API 返回的消息类型
-  isRead?: boolean // 消息是否已读
-  content_type?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE'  // 内容类型
-  size?: number  // 文件大小(字节)
+  messageType?: 'MESSAGE' | 'NOTIFICATION'
+  content_type?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE'
+  size?: number
 }
 
 export interface Contact {