Ver Fonte

V1.0.10 未读消息接口对接

liuq há 2 semanas atrás
pai
commit
6c273b6352

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yunzhu-im",
-  "version": "1.0.8",
+  "version": "1.0.10",
   "main": "./out/main/index.js",
   "author": "example.com",
   "license": "MIT",

+ 92 - 728
src/main/index.ts

@@ -1,12 +1,10 @@
 import { app, shell, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, IpcMainEvent, dialog, screen } from 'electron'
-import { join } from 'path'
+import { basename, extname, join } from 'path'
+import { fileURLToPath } from 'url'
 import { autoUpdater } from 'electron-updater'
 import { electronApp, optimizer, is } from '@electron-toolkit/utils'
 import { mkdirSync, appendFileSync, existsSync, writeFileSync } from 'fs'
 import { writeFile } from 'fs/promises'
-import https from 'https'
-import http from 'http'
-
 // --- 日志管理 ---
 let logDir: string = ''
 
@@ -40,8 +38,6 @@ let mainWindow: BrowserWindow | null = null
 let tray: Tray | null = null
 let isQuitting = false
 let blinkInterval: NodeJS.Timeout | null = null
-let previewWindow: BrowserWindow | null = null
-let videoPlayerWindow: BrowserWindow | null = null
 let trayPopupWindow: BrowserWindow | null = null
 let trayPopupHideTimer: NodeJS.Timeout | null = null
 let trayHoverCheckTimer: NodeJS.Timeout | null = null
@@ -53,6 +49,9 @@ interface UnreadInfo {
 }
 const unreadMap = new Map<number, UnreadInfo>()
 
+/** 渲染进程通过 GET /messages/unread-count 同步的总未读数(标题/任务栏角标);未同步时回退为 unreadMap 累加 */
+let appUnreadTotalFromApi: number | null = null
+
 function isHttpUrl(url: string): boolean {
   try {
     const u = new URL(url.trim())
@@ -62,6 +61,71 @@ function isHttpUrl(url: string): boolean {
   }
 }
 
+function sanitizeFilename(name: string): string {
+  return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 120) || 'file'
+}
+
+function extFromUrlOrData(url: string, fallback: string): string {
+  if (url.startsWith('data:')) {
+    const mime = /^data:([^;]+);/i.exec(url)?.[1]?.toLowerCase() || ''
+    if (mime.includes('jpeg')) return '.jpg'
+    if (mime.includes('png')) return '.png'
+    if (mime.includes('gif')) return '.gif'
+    if (mime.includes('webp')) return '.webp'
+    if (mime.includes('mp4')) return '.mp4'
+    if (mime.includes('webm')) return '.webm'
+    return fallback
+  }
+  try {
+    const pathname = new URL(url.trim()).pathname
+    const e = extname(pathname).toLowerCase()
+    if (e && e.length <= 8) return e
+  } catch {
+    // ignore
+  }
+  return fallback
+}
+
+/** 下载媒体到临时目录后用系统默认应用打开(避免内嵌 data: 预览阻塞主进程) */
+async function downloadMediaToTempAndOpen(url: string, suggestedName: string): Promise<void> {
+  const base = sanitizeFilename(basename(suggestedName || 'media'))
+  let ext = extname(base)
+  if (!ext) {
+    ext = extFromUrlOrData(url, '.bin')
+  }
+  const nameWithoutExt = base.replace(/\.[^.]+$/, '') || 'media'
+  const fileName = `${nameWithoutExt}-${Date.now()}${ext}`
+
+  const dir = join(app.getPath('temp'), 'yunzhu-im-open')
+  mkdirSync(dir, { recursive: true })
+  const filePath = join(dir, fileName)
+
+  if (url.startsWith('data:')) {
+    const comma = url.indexOf(',')
+    if (comma === -1) throw new Error('无效的媒体数据')
+    const header = url.slice(0, comma)
+    const payload = url.slice(comma + 1)
+    const isBase64 = /;base64/i.test(header)
+    const buffer = isBase64 ? Buffer.from(payload, 'base64') : Buffer.from(decodeURIComponent(payload), 'utf-8')
+    await writeFile(filePath, buffer)
+  } else if (url.startsWith('file://')) {
+    const localPath = fileURLToPath(url)
+    const err = await shell.openPath(localPath)
+    if (err) throw new Error(err)
+    return
+  } else if (isHttpUrl(url)) {
+    const res = await fetch(url)
+    if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`)
+    const buf = Buffer.from(await res.arrayBuffer())
+    await writeFile(filePath, buf)
+  } else {
+    throw new Error('不支持的媒体地址')
+  }
+
+  const err = await shell.openPath(filePath)
+  if (err) throw new Error(err)
+}
+
 // --- 浏览器视图管理器 ---
 interface TabInfo {
   id: string
@@ -248,6 +312,9 @@ function getIconPath(): string {
 }
 
 function getTotalUnreadCount(): number {
+  if (appUnreadTotalFromApi !== null) {
+    return appUnreadTotalFromApi
+  }
   let total = 0
   for (const info of unreadMap.values()) {
     total += info.count
@@ -753,549 +820,6 @@ function createInternalBrowserWindow(targetUrl: string): void {
   })
 }
 
-// 创建图片预览窗口
-function createImagePreviewWindow(imageUrl: string): void {
-  // 如果窗口已存在,先关闭
-  if (previewWindow && !previewWindow.isDestroyed()) {
-    previewWindow.close()
-  }
-
-  const iconPath = getIconPath()
-  
-  // 获取主窗口的位置和大小,使预览窗口覆盖主窗口
-  let x = 0, y = 0, width = 1000, height = 700
-  if (mainWindow && !mainWindow.isDestroyed()) {
-    const bounds = mainWindow.getBounds()
-    x = bounds.x
-    y = bounds.y
-    width = bounds.width
-    height = bounds.height
-  }
-
-  previewWindow = new BrowserWindow({
-    width: width,
-    height: height,
-    x: x,
-    y: y,
-    frame: false, // 无边框窗口
-    alwaysOnTop: true, // 始终置顶
-    transparent: false,
-    backgroundColor: '#000000',
-    show: false,
-    resizable: true,
-    minimizable: false,
-    maximizable: false,
-    closable: true,
-    skipTaskbar: false,
-    webPreferences: {
-      preload: join(__dirname, '../preload/index.js'),
-      sandbox: false,
-      nodeIntegration: false,
-      contextIsolation: true
-    }
-  })
-
-  // 创建预览页面的 HTML 内容
-  const htmlContent = `
-<!DOCTYPE html>
-<html>
-<head>
-  <meta charset="UTF-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  <title>图片预览</title>
-  <style>
-    * {
-      margin: 0;
-      padding: 0;
-      box-sizing: border-box;
-    }
-    body {
-      width: 100vw;
-      height: 100vh;
-      background-color: rgba(0, 0, 0, 0.95);
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      overflow: hidden;
-      cursor: pointer;
-    }
-    .image-container {
-      position: relative;
-      max-width: 90%;
-      max-height: 90%;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-    }
-    img {
-      max-width: 100%;
-      max-height: 100%;
-      object-fit: contain;
-      cursor: default;
-    }
-    .toolbar {
-      position: fixed;
-      top: 20px;
-      right: 20px;
-      display: flex;
-      gap: 10px;
-      z-index: 1000;
-    }
-    .toolbar-btn {
-      padding: 10px 20px;
-      background-color: rgba(255, 255, 255, 0.2);
-      color: white;
-      border: none;
-      border-radius: 4px;
-      cursor: pointer;
-      font-size: 16px;
-      transition: background-color 0.2s;
-    }
-    .toolbar-btn:hover {
-      background-color: rgba(255, 255, 255, 0.3);
-    }
-    .toolbar-btn:active {
-      background-color: rgba(255, 255, 255, 0.4);
-    }
-    .toast {
-      position: fixed;
-      top: 50%;
-      left: 50%;
-      transform: translate(-50%, -50%);
-      background-color: rgba(0, 0, 0, 0.8);
-      color: white;
-      padding: 15px 30px;
-      border-radius: 8px;
-      font-size: 16px;
-      z-index: 10000;
-      display: none;
-      pointer-events: none;
-    }
-    .toast.show {
-      display: block;
-      animation: fadeInOut 2s ease-in-out;
-    }
-    @keyframes fadeInOut {
-      0%, 100% { opacity: 0; }
-      10%, 90% { opacity: 1; }
-    }
-  </style>
-</head>
-<body>
-  <div class="image-container">
-    <img id="preview-image" src="${imageUrl.replace(/"/g, '&quot;').replace(/'/g, '&#39;')}" alt="预览图片" />
-    <div id="toast" class="toast"></div>
-  </div>
-  <div class="toolbar">
-    <button id="save-btn" class="toolbar-btn">💾 保存</button>
-    <button id="close-btn" class="toolbar-btn">✕ 关闭</button>
-  </div>
-  <script>
-    (function() {
-      const imageUrl = ${JSON.stringify(imageUrl)};
-      
-      // 保存按钮点击事件
-      document.getElementById('save-btn').addEventListener('click', (e) => {
-        e.stopPropagation();
-        if (window.electron && window.electron.ipcRenderer) {
-          window.electron.ipcRenderer.send('save-image-preview', imageUrl);
-        }
-      });
-      
-      // 关闭按钮点击事件
-      document.getElementById('close-btn').addEventListener('click', (e) => {
-        e.stopPropagation();
-        if (window.electron && window.electron.ipcRenderer) {
-          window.electron.ipcRenderer.send('close-image-preview');
-        }
-      });
-      // 点击背景关闭
-      document.body.addEventListener('click', (e) => {
-        if (e.target === document.body || e.target.classList.contains('image-container')) {
-          if (window.electron && window.electron.ipcRenderer) {
-            window.electron.ipcRenderer.send('close-image-preview')
-          }
-        }
-      })
-      
-      // 按 ESC 键关闭
-      document.addEventListener('keydown', (e) => {
-        if (e.key === 'Escape') {
-          if (window.electron && window.electron.ipcRenderer) {
-            window.electron.ipcRenderer.send('close-image-preview')
-          }
-        }
-      })
-      
-      // 阻止图片点击事件冒泡
-      const img = document.querySelector('img')
-      if (img) {
-        img.addEventListener('click', (e) => {
-          e.stopPropagation()
-        })
-      }
-
-      // 显示提示消息
-      function showToast(message) {
-        const toast = document.getElementById('toast')
-        if (toast) {
-          toast.textContent = message
-          toast.classList.add('show')
-          setTimeout(() => {
-            toast.classList.remove('show')
-          }, 2000)
-        }
-      }
-
-      // 监听保存成功/失败消息
-      if (window.electron && window.electron.ipcRenderer) {
-        window.electron.ipcRenderer.on('save-image-success', (_, filePath) => {
-          const fileName = filePath.split(/[/\\\\]/).pop()
-          showToast('保存成功: ' + fileName)
-        })
-        
-        window.electron.ipcRenderer.on('save-image-error', (_, error) => {
-          showToast('保存失败: ' + error)
-        })
-      }
-    })()
-  </script>
-</body>
-</html>
-  `
-
-  previewWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`)
-
-  previewWindow.once('ready-to-show', () => {
-    previewWindow?.show()
-    previewWindow?.focus()
-  })
-
-  previewWindow.on('closed', () => {
-    previewWindow = null
-  })
-}
-
-// 创建视频播放器窗口
-function createVideoPlayerWindow(videoUrl: string, videoTitle?: string): void {
-  // 如果窗口已存在,先关闭
-  if (videoPlayerWindow && !videoPlayerWindow.isDestroyed()) {
-    videoPlayerWindow.close()
-  }
-
-  const iconPath = getIconPath()
-  
-  // 获取主窗口的位置和大小,使播放器窗口覆盖主窗口
-  let x = 0, y = 0, width = 1000, height = 700
-  if (mainWindow && !mainWindow.isDestroyed()) {
-    const bounds = mainWindow.getBounds()
-    x = bounds.x
-    y = bounds.y
-    width = bounds.width
-    height = bounds.height
-  }
-
-  videoPlayerWindow = new BrowserWindow({
-    width: width,
-    height: height,
-    x: x,
-    y: y,
-    frame: false, // 无边框窗口
-    alwaysOnTop: false, // 不置顶,允许用户切换窗口
-    transparent: false,
-    backgroundColor: '#000000',
-    show: false,
-    resizable: true,
-    minimizable: true,
-    maximizable: true,
-    closable: true,
-    skipTaskbar: false,
-    webPreferences: {
-      preload: join(__dirname, '../preload/index.js'),
-      sandbox: false,
-      nodeIntegration: false,
-      contextIsolation: true,
-      webSecurity: true // 启用 Web 安全,支持跨域视频
-    }
-  })
-
-  // 创建视频播放器页面的 HTML 内容
-  const htmlContent = `
-<!DOCTYPE html>
-<html>
-<head>
-  <meta charset="UTF-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  <title>${videoTitle || '视频播放'}</title>
-  <style>
-    * {
-      margin: 0;
-      padding: 0;
-      box-sizing: border-box;
-    }
-    body {
-      width: 100vw;
-      height: 100vh;
-      background-color: #000000;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      overflow: hidden;
-      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-    }
-    .video-container {
-      position: relative;
-      width: 100%;
-      height: 100%;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-    }
-    video {
-      max-width: 100%;
-      max-height: 100%;
-      width: auto;
-      height: auto;
-      outline: none;
-    }
-    .toolbar {
-      position: fixed;
-      top: 20px;
-      right: 20px;
-      display: flex;
-      gap: 10px;
-      z-index: 1000;
-      opacity: 0;
-      transition: opacity 0.3s;
-    }
-    .toolbar.visible {
-      opacity: 1;
-    }
-    .toolbar-btn {
-      padding: 10px 20px;
-      background-color: rgba(255, 255, 255, 0.2);
-      color: white;
-      border: none;
-      border-radius: 4px;
-      cursor: pointer;
-      font-size: 16px;
-      transition: background-color 0.2s;
-      backdrop-filter: blur(10px);
-    }
-    .toolbar-btn:hover {
-      background-color: rgba(255, 255, 255, 0.3);
-    }
-    .toolbar-btn:active {
-      background-color: rgba(255, 255, 255, 0.4);
-    }
-    .toast {
-      position: fixed;
-      top: 50%;
-      left: 50%;
-      transform: translate(-50%, -50%);
-      background-color: rgba(0, 0, 0, 0.8);
-      color: white;
-      padding: 15px 30px;
-      border-radius: 8px;
-      font-size: 16px;
-      z-index: 10000;
-      display: none;
-      pointer-events: none;
-    }
-    .toast.show {
-      display: block;
-      animation: fadeInOut 2s ease-in-out;
-    }
-    @keyframes fadeInOut {
-      0%, 100% { opacity: 0; }
-      10%, 90% { opacity: 1; }
-    }
-    .loading {
-      position: fixed;
-      top: 50%;
-      left: 50%;
-      transform: translate(-50%, -50%);
-      color: white;
-      font-size: 18px;
-      z-index: 999;
-    }
-    .error {
-      position: fixed;
-      top: 50%;
-      left: 50%;
-      transform: translate(-50%, -50%);
-      color: #ff4444;
-      font-size: 18px;
-      text-align: center;
-      z-index: 999;
-    }
-  </style>
-</head>
-<body>
-  <div class="video-container">
-    <video 
-      id="video-player" 
-      controls 
-      preload="auto"
-      playsinline
-      webkit-playsinline
-      style="width: 100%; height: 100%; object-fit: contain;"
-    >
-      您的浏览器不支持视频播放
-    </video>
-    <div id="loading" class="loading">加载中...</div>
-    <div id="error" class="error" style="display: none;"></div>
-    <div id="toast" class="toast"></div>
-  </div>
-  <div class="toolbar" id="toolbar">
-    <button id="download-btn" class="toolbar-btn">⬇️ 下载</button>
-    <button id="fullscreen-btn" class="toolbar-btn">⛶ 全屏</button>
-    <button id="close-btn" class="toolbar-btn">✕ 关闭</button>
-  </div>
-  <script>
-    (function() {
-      const videoUrl = ${JSON.stringify(videoUrl)};
-      const videoTitle = ${JSON.stringify(videoTitle || 'video.mp4')};
-      const video = document.getElementById('video-player');
-      const loading = document.getElementById('loading');
-      const error = document.getElementById('error');
-      const toolbar = document.getElementById('toolbar');
-      
-      // 设置视频源
-      video.src = videoUrl;
-      
-      // 显示工具栏
-      function showToolbar() {
-        toolbar.classList.add('visible');
-      }
-      
-      function hideToolbar() {
-        toolbar.classList.remove('visible');
-      }
-      
-      // 鼠标移动时显示/隐藏工具栏
-      let toolbarTimeout;
-      document.addEventListener('mousemove', () => {
-        showToolbar();
-        clearTimeout(toolbarTimeout);
-        toolbarTimeout = setTimeout(hideToolbar, 3000);
-      });
-      
-      // 视频加载事件
-      video.addEventListener('loadedmetadata', () => {
-        loading.style.display = 'none';
-        showToolbar();
-        setTimeout(hideToolbar, 3000);
-      });
-      
-      video.addEventListener('loadstart', () => {
-        loading.style.display = 'block';
-        error.style.display = 'none';
-      });
-      
-      video.addEventListener('error', (e) => {
-        loading.style.display = 'none';
-        error.style.display = 'block';
-        error.textContent = '视频加载失败,请检查网络连接或视频链接是否有效';
-        console.error('Video error:', e);
-      });
-      
-      // 下载按钮
-      document.getElementById('download-btn').addEventListener('click', (e) => {
-        e.stopPropagation();
-        if (window.electron && window.electron.ipcRenderer) {
-          window.electron.ipcRenderer.send('download-video', { url: videoUrl, filename: videoTitle });
-        }
-      });
-      
-      // 全屏按钮
-      document.getElementById('fullscreen-btn').addEventListener('click', (e) => {
-        e.stopPropagation();
-        if (video.requestFullscreen) {
-          video.requestFullscreen();
-        } else if (video.webkitRequestFullscreen) {
-          video.webkitRequestFullscreen();
-        } else if (video.mozRequestFullScreen) {
-          video.mozRequestFullScreen();
-        } else if (video.msRequestFullscreen) {
-          video.msRequestFullscreen();
-        }
-      });
-      
-      // 关闭按钮
-      document.getElementById('close-btn').addEventListener('click', (e) => {
-        e.stopPropagation();
-        if (window.electron && window.electron.ipcRenderer) {
-          window.electron.ipcRenderer.send('close-video-player');
-        }
-      });
-      
-      // 点击背景关闭(仅在工具栏可见时)
-      document.body.addEventListener('click', (e) => {
-        if (e.target === document.body || e.target.classList.contains('video-container')) {
-          if (toolbar.classList.contains('visible')) {
-            if (window.electron && window.electron.ipcRenderer) {
-              window.electron.ipcRenderer.send('close-video-player');
-            }
-          }
-        }
-      });
-      
-      // 按 ESC 键关闭
-      document.addEventListener('keydown', (e) => {
-        if (e.key === 'Escape') {
-          if (window.electron && window.electron.ipcRenderer) {
-            window.electron.ipcRenderer.send('close-video-player');
-          }
-        }
-      });
-      
-      // 阻止视频点击事件冒泡
-      video.addEventListener('click', (e) => {
-        e.stopPropagation();
-      });
-      
-      // 显示提示消息
-      function showToast(message) {
-        const toast = document.getElementById('toast');
-        if (toast) {
-          toast.textContent = message;
-          toast.classList.add('show');
-          setTimeout(() => {
-            toast.classList.remove('show');
-          }, 2000);
-        }
-      }
-      
-      // 监听下载成功/失败消息
-      if (window.electron && window.electron.ipcRenderer) {
-        window.electron.ipcRenderer.on('download-video-success', (_, filePath) => {
-          const fileName = filePath.split(/[/\\\\]/).pop();
-          showToast('下载成功: ' + fileName);
-        });
-        
-        window.electron.ipcRenderer.on('download-video-error', (_, error) => {
-          showToast('下载失败: ' + error);
-        });
-      }
-    })();
-  </script>
-</body>
-</html>
-  `
-
-  videoPlayerWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`)
-
-  videoPlayerWindow.once('ready-to-show', () => {
-    videoPlayerWindow?.show()
-    videoPlayerWindow?.focus()
-  })
-
-  videoPlayerWindow.on('closed', () => {
-    videoPlayerWindow = null
-  })
-}
-
 // 统一处理来自渲染进程的 Browser Action
 ipcMain.on('browser-action', (event, action) => {
     if (!viewManager || !browserWindow || browserWindow.isDestroyed()) return
@@ -1395,9 +919,15 @@ app.whenReady().then(() => {
     autoUpdater.quitAndInstall(false, true)
   })
 
+  ipcMain.on('sync-unread-total', (_, total: number) => {
+    appUnreadTotalFromApi = typeof total === 'number' && !Number.isNaN(total) ? Math.max(0, Math.floor(total)) : 0
+    updateUnreadStatus()
+  })
+
   ipcMain.on('login-success', () => {
     // 登录成功后清空消息中心未读,避免切换账号展示上个账号的残留
     unreadMap.clear()
+    appUnreadTotalFromApi = null
     updateUnreadStatus()
     if (mainWindow) {
       mainWindow.setSize(1000, 700)
@@ -1433,6 +963,7 @@ app.whenReady().then(() => {
   // 清除所有未读消息
   ipcMain.on('clear-all-unread', () => {
     unreadMap.clear()
+    appUnreadTotalFromApi = null
     updateUnreadStatus()
   })
 
@@ -1456,188 +987,19 @@ app.whenReady().then(() => {
     }
   })
 
-  // 处理打开图片预览窗口
+  // 图片/视频:下载到临时目录后用系统默认应用打开(不再使用内嵌预览窗口)
   ipcMain.on('open-image-preview', (_, imageUrl: string) => {
-    createImagePreviewWindow(imageUrl)
-  })
-
-  // 处理关闭图片预览窗口
-  ipcMain.on('close-image-preview', () => {
-    if (previewWindow && !previewWindow.isDestroyed()) {
-      previewWindow.close()
-    }
+    void downloadMediaToTempAndOpen(imageUrl, 'image.png').catch((e) => {
+      console.error(e)
+      dialog.showErrorBox('打开失败', e instanceof Error ? e.message : String(e))
+    })
   })
 
-  // 处理打开视频播放器窗口
   ipcMain.on('open-video-player', (_, videoUrl: string, videoTitle?: string) => {
-    createVideoPlayerWindow(videoUrl, videoTitle)
-  })
-
-  // 处理关闭视频播放器窗口
-  ipcMain.on('close-video-player', () => {
-    if (videoPlayerWindow && !videoPlayerWindow.isDestroyed()) {
-      videoPlayerWindow.close()
-    }
-  })
-
-  // 处理视频下载(使用 Electron 下载 API,不会被拦截)
-  ipcMain.on('download-video', async (event, { url, filename }) => {
-    try {
-      if (videoPlayerWindow && !videoPlayerWindow.isDestroyed()) {
-        // 使用视频播放器窗口的 webContents 来下载
-        videoPlayerWindow.webContents.downloadURL(url)
-        
-        videoPlayerWindow.webContents.session.once('will-download', (event, item) => {
-          const filePath = join(app.getPath('downloads'), filename)
-          item.setSavePath(filePath)
-          
-          item.once('done', (event, state) => {
-            if (state === 'completed') {
-              if (videoPlayerWindow && !videoPlayerWindow.isDestroyed()) {
-                videoPlayerWindow.webContents.send('download-video-success', filePath)
-              }
-            } else {
-              if (videoPlayerWindow && !videoPlayerWindow.isDestroyed()) {
-                videoPlayerWindow.webContents.send('download-video-error', '下载失败')
-              }
-            }
-          })
-        })
-      } else if (mainWindow && !mainWindow.isDestroyed()) {
-        // 如果视频播放器窗口不存在,使用主窗口
-        mainWindow.webContents.downloadURL(url)
-        
-        mainWindow.webContents.session.once('will-download', (event, item) => {
-          const filePath = join(app.getPath('downloads'), filename)
-          item.setSavePath(filePath)
-        })
-      }
-    } catch (error: any) {
-      console.error('Failed to download video:', error)
-      if (videoPlayerWindow && !videoPlayerWindow.isDestroyed()) {
-        videoPlayerWindow.webContents.send('download-video-error', error.message || '下载失败')
-      }
-    }
-  })
-
-
-  // 处理保存图片
-  ipcMain.on('save-image-preview', async (_, imageUrl: string) => {
-    try {
-      // 临时取消预览窗口的置顶状态,以便显示保存对话框
-      if (previewWindow && !previewWindow.isDestroyed()) {
-        previewWindow.setAlwaysOnTop(false)
-      }
-
-      // 推断文件扩展名
-      let defaultExt = 'png'
-      try {
-        const urlObj = new URL(imageUrl)
-        const pathname = urlObj.pathname.toLowerCase()
-        if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) {
-          defaultExt = 'jpg'
-        } else if (pathname.endsWith('.gif')) {
-          defaultExt = 'gif'
-        } else if (pathname.endsWith('.webp')) {
-          defaultExt = 'webp'
-        } else if (pathname.endsWith('.png')) {
-          defaultExt = 'png'
-        }
-      } catch (e) {
-        // URL 解析失败,使用默认扩展名
-      }
-
-      // 显示保存对话框
-      const result = await dialog.showSaveDialog({
-        title: '保存图片',
-        defaultPath: `image.${defaultExt}`,
-        filters: [
-          { name: '图片文件', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
-          { name: '所有文件', extensions: ['*'] }
-        ]
-      })
-
-      // 恢复预览窗口的置顶状态
-      if (previewWindow && !previewWindow.isDestroyed()) {
-        previewWindow.setAlwaysOnTop(true)
-      }
-
-      if (result.canceled || !result.filePath) {
-        return
-      }
-
-      const filePath = result.filePath
-
-      // 处理不同类型的图片 URL
-      if (imageUrl.startsWith('data:')) {
-        // Data URL
-        const base64Data = imageUrl.split(',')[1]
-        const buffer = Buffer.from(base64Data, 'base64')
-        await writeFile(filePath, buffer)
-      } else if (imageUrl.startsWith('blob:') || imageUrl.startsWith('file://')) {
-        // Blob URL 或 file:// URL,需要通过预览窗口的 webContents 获取
-        if (previewWindow && !previewWindow.isDestroyed()) {
-          const image = await previewWindow.webContents.downloadURL(imageUrl)
-          // 对于 blob URL,我们需要通过其他方式获取
-          // 这里我们尝试从预览窗口的 webContents 获取图片数据
-          const dataUrl = await previewWindow.webContents.executeJavaScript(`
-            new Promise((resolve) => {
-              const img = document.getElementById('preview-image');
-              const canvas = document.createElement('canvas');
-              canvas.width = img.naturalWidth;
-              canvas.height = img.naturalHeight;
-              const ctx = canvas.getContext('2d');
-              ctx.drawImage(img, 0, 0);
-              resolve(canvas.toDataURL('image/png'));
-            })
-          `)
-          const base64Data = dataUrl.split(',')[1]
-          const buffer = Buffer.from(base64Data, 'base64')
-          await writeFile(filePath, buffer)
-        }
-      } else if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
-        // HTTP/HTTPS URL
-        const protocol = imageUrl.startsWith('https') ? https : http
-        
-        await new Promise<void>((resolve, reject) => {
-          protocol.get(imageUrl, (response) => {
-            if (response.statusCode !== 200) {
-              reject(new Error(`Failed to download image: ${response.statusCode}`))
-              return
-            }
-
-            const chunks: Buffer[] = []
-            response.on('data', (chunk: Buffer) => {
-              chunks.push(chunk)
-            })
-            response.on('end', async () => {
-              try {
-                const buffer = Buffer.concat(chunks)
-                await writeFile(filePath, buffer)
-                resolve()
-              } catch (error) {
-                reject(error)
-              }
-            })
-            response.on('error', reject)
-          }).on('error', reject)
-        })
-      } else {
-        throw new Error('不支持的图片 URL 格式')
-      }
-
-      // 显示成功提示
-      if (previewWindow && !previewWindow.isDestroyed()) {
-        previewWindow.webContents.send('save-image-success', filePath)
-      }
-    } catch (error: any) {
-      console.error('Failed to save image:', error)
-      // 恢复预览窗口的置顶状态(即使出错也要恢复)
-      if (previewWindow && !previewWindow.isDestroyed()) {
-        previewWindow.setAlwaysOnTop(true)
-        previewWindow.webContents.send('save-image-error', error.message || '保存失败')
-      }
-    }
+    void downloadMediaToTempAndOpen(videoUrl, videoTitle || 'video.mp4').catch((e) => {
+      console.error(e)
+      dialog.showErrorBox('打开失败', e instanceof Error ? e.message : String(e))
+    })
   })
 
   // Create tray
@@ -1695,7 +1057,9 @@ app.whenReady().then(() => {
   })
 
   ipcMain.on('tray-popup-dismiss', () => {
-    hideTrayPopup()
+    // 「暂不处理」:清空主进程托盘未读 Map,停止闪烁;不改动 appUnreadTotalFromApi,避免干扰主界面 API 未读总数
+    unreadMap.clear()
+    updateUnreadStatus()
   })
 
   ipcMain.on('tray-popup-mouse-leave', () => {

+ 88 - 28
src/renderer/src/App.tsx

@@ -42,8 +42,8 @@ function App(): JSX.Element {
   const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
   const [chatSearchQuery, setChatSearchQuery] = useState('')
   const [chatSearchResults, setChatSearchResults] = useState<Message[]>([])
-  
-  const unreadCountsRef = useRef<Record<number, number>>({})
+  /** 侧边栏「消息」角标:来自 GET /messages/unread-count,与 WS 本地计数无关 */
+  const [sidebarTotalUnread, setSidebarTotalUnread] = useState(0)
 
   // 退出登录时清空聊天相关 UI 状态(同进程内切账号,避免沿用上一账号)
   useEffect(() => {
@@ -58,7 +58,7 @@ function App(): JSX.Element {
       setChatSearchResults([])
       setInputValue('')
       setContextMenu(null)
-      unreadCountsRef.current = {}
+      setSidebarTotalUnread(0)
     }
   }, [isLoggedIn])
   
@@ -72,16 +72,56 @@ function App(): JSX.Element {
     contactsRef,
     isLoadingContacts,
     fetchContacts
-  } = useContacts(token, unreadCountsRef, activeContactId, setActiveContactId)
+  } = useContacts(token, activeContactId, setActiveContactId)
+
+  const refreshUnreadCount = useCallback(async () => {
+    if (!token) return
+    try {
+      const n = await api.getUnreadCount(token)
+      setSidebarTotalUnread(n)
+      window.electron?.ipcRenderer?.send('sync-unread-total', n)
+    } catch (error: unknown) {
+      logger.error('App: refreshUnreadCount failed', {
+        error: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }, [token])
+
+  const activateContact = useCallback(
+    async (contactId: number) => {
+      if (!token) return
+      // 每次进入会话(含重复点同一会话)都清主进程 unreadMap 中该会话,不依赖 activeContactId 是否变化
+      window.electron?.ipcRenderer?.send('clear-unread', contactId)
+      try {
+        await api.markConversationReadAll(token, contactId)
+      } catch (error: unknown) {
+        logger.error('App: markConversationReadAll failed', {
+          contactId,
+          error: error instanceof Error ? error.message : String(error)
+        })
+      }
+      setActiveContactId(contactId)
+      setContacts(prev => prev.map(c => (c.id === contactId ? { ...c, unreadCount: 0 } : c)))
+      await refreshUnreadCount()
+    },
+    [token, refreshUnreadCount, setContacts]
+  )
+
+  // 登录后拉取未读总数(与侧边栏角标一致)
+  useEffect(() => {
+    if (token && isLoggedIn) {
+      void refreshUnreadCount()
+    }
+  }, [token, isLoggedIn, refreshUnreadCount])
 
-  // 从其他侧边栏 tab 切回消息中心时重新拉取会话列表(多客户端同步)
+  // 从其他侧边栏 tab 切回消息中心时重新拉取会话列表(含每会话 unread_count)并刷新总未读
   useEffect(() => {
     const prev = prevActiveTabRef.current
     if (activeTab === 'chat' && prev !== 'chat' && token) {
-      void fetchContacts()
+      void fetchContacts().then(() => refreshUnreadCount())
     }
     prevActiveTabRef.current = activeTab
-  }, [activeTab, token, fetchContacts])
+  }, [activeTab, token, fetchContacts, refreshUnreadCount])
 
   const {
     messages,
@@ -92,9 +132,39 @@ function App(): JSX.Element {
     uploadProgress,
     sendMessage: sendMessageHook,
     sendFileMessage,
+    fetchMessages,
     fetchMoreMessages,
     loadedContacts
-  } = useMessages(token, currentUserId, activeContactId, setContacts, unreadCountsRef)
+  } = useMessages(token, currentUserId, activeContactId, setContacts)
+
+  // 主窗口从后台/其他应用切回前台并聚焦时:拉会话列表(每行未读)+ 总未读,并刷新当前会话消息
+  const hadWindowBlurRef = useRef(false)
+  const focusRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  useEffect(() => {
+    const onBlur = () => {
+      hadWindowBlurRef.current = true
+    }
+    const onFocus = () => {
+      if (!token || !isLoggedIn || !hadWindowBlurRef.current) return
+      if (focusRefreshTimerRef.current) clearTimeout(focusRefreshTimerRef.current)
+      focusRefreshTimerRef.current = setTimeout(() => {
+        focusRefreshTimerRef.current = null
+        void fetchContacts().then(() => refreshUnreadCount())
+        const cid = activeContactIdRef.current
+        if (cid != null) void fetchMessages(cid)
+      }, 300)
+    }
+    window.addEventListener('blur', onBlur)
+    window.addEventListener('focus', onFocus)
+    return () => {
+      if (focusRefreshTimerRef.current) {
+        clearTimeout(focusRefreshTimerRef.current)
+        focusRefreshTimerRef.current = null
+      }
+      window.removeEventListener('blur', onBlur)
+      window.removeEventListener('focus', onFocus)
+    }
+  }, [token, isLoggedIn, fetchContacts, refreshUnreadCount, fetchMessages])
 
   useWebSocket({
     isLoggedIn,
@@ -104,7 +174,7 @@ function App(): JSX.Element {
     contactsRef,
     setMessages,
     setContacts,
-    unreadCountsRef,
+    refreshUnreadCount,
     fetchContacts
   })
 
@@ -151,13 +221,11 @@ function App(): JSX.Element {
     }
   }, [])
 
-  // 监听切换联系人事件
+  // 监听切换联系人事件(托盘等)
   useEffect(() => {
     const handleSwitchContact = (_: any, contactId: number) => {
       setActiveTab('chat')
-      setActiveContactId(contactId)
-      unreadCountsRef.current[contactId] = 0
-      setContacts(prev => prev.map(c => c.id === contactId ? { ...c, unreadCount: 0 } : c))
+      void activateContact(contactId)
     }
 
     if (window.electron && window.electron.ipcRenderer) {
@@ -169,7 +237,7 @@ function App(): JSX.Element {
         window.electron.ipcRenderer.removeAllListeners('switch-contact')
       }
     }
-  }, [setContacts])
+  }, [activateContact])
 
   // 切换标签页时,如果离开通讯录,清除选中的联系人详情
   useEffect(() => {
@@ -318,14 +386,8 @@ function App(): JSX.Element {
     const existingContact = contacts.find(c => c.id === contact.id)
     
     if (existingContact) {
-      setActiveContactId(contact.id)
-      unreadCountsRef.current[contact.id] = 0
-      setContacts(prev => prev.map(c => 
-        c.id === contact.id ? { ...c, unreadCount: 0 } : c
-      ))
+      void activateContact(contact.id)
     } else {
-      unreadCountsRef.current[contact.id] = 0
-      
       const newContact: Contact = {
         id: contact.id,
         name: contact.name || contact.english_name || `用户${contact.id}`,
@@ -334,9 +396,9 @@ function App(): JSX.Element {
         lastMessageTime: undefined,
         unreadCount: 0
       }
-      
+
       setContacts(prev => [newContact, ...prev])
-      setActiveContactId(contact.id)
+      void activateContact(contact.id)
     }
   }
 
@@ -353,7 +415,7 @@ function App(): JSX.Element {
   }, [])
 
   const activeContact = contacts.find(c => c.id === activeContactId)
-  const totalUnread = contacts.reduce((sum, c) => sum + c.unreadCount, 0)
+  const totalUnread = sidebarTotalUnread
 
   // 渲染中间列表区域
   const renderListPanel = () => {
@@ -434,8 +496,8 @@ function App(): JSX.Element {
                     onClick={(e) => {
                       e.preventDefault()
                       e.stopPropagation()
-                      setActiveContactId(result.contactId)
                       setActiveTab('chat')
+                      void activateContact(result.contactId)
                       setSearchQuery('')
                       setTimeout(() => {
                         const messageElement = document.querySelector(`[data-message-id="${result.message.id}"]`)
@@ -511,9 +573,7 @@ function App(): JSX.Element {
                 key={contact.id}
                 className={`contact-item ${contact.id === activeContactId ? 'active' : ''}`}
                 onClick={() => {
-                  setActiveContactId(contact.id)
-                  unreadCountsRef.current[contact.id] = 0
-                  setContacts(prev => prev.map(c => c.id === contact.id ? { ...c, unreadCount: 0 } : c))
+                  void activateContact(contact.id)
                 }}
                 style={{
                   display: 'flex',

+ 2 - 3
src/renderer/src/hooks/useContacts.ts

@@ -7,7 +7,6 @@ import { getSessionAvatar } from '../utils/avatarUtils'
 
 export function useContacts(
   token: string,
-  unreadCountsRef: React.MutableRefObject<Record<number, number>>,
   activeContactId: number | null,
   setActiveContactId: (id: number | null) => void
 ) {
@@ -54,7 +53,7 @@ export function useContacts(
         avatar: getSessionAvatar(contact.id, contact.name, 40),
         lastMessage: contact.last_message,
         lastMessageTime: contact.last_message_time ? formatMessageTime(contact.last_message_time) : undefined,
-        unreadCount: unreadCountsRef.current[contact.id] || 0
+        unreadCount: contact.unread_count ?? 0
       }))
       
       setContacts(formattedContacts)
@@ -67,7 +66,7 @@ export function useContacts(
     } finally {
       setIsLoadingContacts(false)
     }
-  }, [token, unreadCountsRef, setActiveContactId])
+  }, [token, setActiveContactId])
 
   useEffect(() => {
     if (token) {

+ 5 - 12
src/renderer/src/hooks/useMessages.ts

@@ -9,8 +9,7 @@ export function useMessages(
   token: string,
   currentUserId: number | null,
   activeContactId: number | null,
-  setContacts: React.Dispatch<React.SetStateAction<any[]>>,
-  unreadCountsRef: React.MutableRefObject<Record<number, number>>
+  setContacts: React.Dispatch<React.SetStateAction<any[]>>
 ) {
   const [messages, setMessages] = useState<Record<number, Message[]>>({})
   const [loadedContacts, setLoadedContacts] = useState<Set<number>>(new Set())
@@ -239,18 +238,12 @@ export function useMessages(
     }
   }, [token])
 
-  // 切换到某个会话时,清除该会话的本地未读计数
+  // 切换到某个会话时同步托盘:清除该会话在托盘未读列表中的展示(服务端已读由点击会话时 read-all 处理)
   useEffect(() => {
-    if (activeContactId) {
-      unreadCountsRef.current[activeContactId] = 0
-      setContacts(prev => prev.map(c =>
-        c.id === activeContactId ? { ...c, unreadCount: 0 } : c
-      ))
-      if (window.electron && window.electron.ipcRenderer) {
-        window.electron.ipcRenderer.send('clear-unread', activeContactId)
-      }
+    if (activeContactId && window.electron?.ipcRenderer) {
+      window.electron.ipcRenderer.send('clear-unread', activeContactId)
     }
-  }, [activeContactId, setContacts, unreadCountsRef])
+  }, [activeContactId])
 
   // 切换联系人时重新拉取该会话最新消息(含再次进入已打开过的会话)
   useEffect(() => {

+ 55 - 15
src/renderer/src/hooks/useWebSocket.ts

@@ -14,7 +14,8 @@ interface UseWebSocketProps {
   contactsRef: React.MutableRefObject<Contact[]>
   setMessages: React.Dispatch<React.SetStateAction<Record<number, Message[]>>>
   setContacts: React.Dispatch<React.SetStateAction<Contact[]>>
-  unreadCountsRef: React.MutableRefObject<Record<number, number>>
+  /** 收到消息后刷新服务端未读总数(防抖,不作为会话内计数) */
+  refreshUnreadCount: () => void
   fetchContacts: () => Promise<void>
 }
 
@@ -26,20 +27,48 @@ export function useWebSocket({
   contactsRef,
   setMessages,
   setContacts,
-  unreadCountsRef,
+  refreshUnreadCount,
   fetchContacts
 }: UseWebSocketProps) {
   const fetchContactsRef = useRef(fetchContacts)
+  const refreshUnreadCountRef = useRef(refreshUnreadCount)
+  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  /** 主窗口是否在前台且可见(用于:停在当前会话但窗口在后台时仍推托盘提醒) */
+  const mainWindowFocusedRef = useRef(
+    typeof document !== 'undefined' &&
+      document.visibilityState === 'visible' &&
+      document.hasFocus()
+  )
+
+  useEffect(() => {
+    const syncMainWindowFocus = () => {
+      mainWindowFocusedRef.current =
+        document.visibilityState === 'visible' && document.hasFocus()
+    }
+    syncMainWindowFocus()
+    window.addEventListener('focus', syncMainWindowFocus)
+    window.addEventListener('blur', syncMainWindowFocus)
+    document.addEventListener('visibilitychange', syncMainWindowFocus)
+    return () => {
+      window.removeEventListener('focus', syncMainWindowFocus)
+      window.removeEventListener('blur', syncMainWindowFocus)
+      document.removeEventListener('visibilitychange', syncMainWindowFocus)
+    }
+  }, [])
+
   useEffect(() => {
     fetchContactsRef.current = fetchContacts
   }, [fetchContacts])
 
+  useEffect(() => {
+    refreshUnreadCountRef.current = refreshUnreadCount
+  }, [refreshUnreadCount])
+
   useEffect(() => {
     let wsService: WebSocketService | null = null
 
     if (isLoggedIn && token && currentUserId !== null) {
-      unreadCountsRef.current = {}
-      
       wsService = new WebSocketService(token, async (incomingMsg) => {
         if (!incomingMsg) {
           logger.warn('App: Received null or undefined message')
@@ -100,10 +129,14 @@ export function useWebSocket({
         const currentChatId = activeContactIdRef.current
         const isCurrentChat = currentChatId === targetContactId
         
+        const mainFocused = mainWindowFocusedRef.current
+        const suppressTrayForForegroundChat = isCurrentChat && mainFocused
+
         logger.info('App: Processing new message', {
           targetContactId,
           currentChatId,
           isCurrentChat,
+          mainWindowFocused: mainFocused,
           isSelf
         })
         
@@ -150,21 +183,15 @@ export function useWebSocket({
           const lastMessageText = notificationLike
             ? (incomingMsg.title || incomingMsg.content)
             : (newMessage.type === 'image' ? '[图片]' : newMessage.content)
-          
-          if (!isCurrentChat && !isSelf) {
-            unreadCountsRef.current[targetContactId] = (unreadCountsRef.current[targetContactId] || 0) + 1
-          } else if (isCurrentChat) {
-            unreadCountsRef.current[targetContactId] = 0
-          }
-          
+
+          // 未读数以服务端 /messages/unread-count 为准;此处只更新预览与时间,不修改 unreadCount
           setContacts(prev => {
             const updated = prev.map(c => {
               if (c.id === targetContactId) {
                 return {
                   ...c,
                   lastMessage: lastMessageText,
-                  lastMessageTime: '刚刚',
-                  unreadCount: unreadCountsRef.current[targetContactId] || 0
+                  lastMessageTime: '刚刚'
                 }
               }
               return c
@@ -184,8 +211,17 @@ export function useWebSocket({
             logger.error('App: Failed to refresh contacts after receiving message', error)
           })
         }
-        
-        if (window.electron && window.electron.ipcRenderer) {
+
+        if (debounceTimerRef.current) {
+          clearTimeout(debounceTimerRef.current)
+        }
+        debounceTimerRef.current = setTimeout(() => {
+          debounceTimerRef.current = null
+          refreshUnreadCountRef.current()
+        }, 400)
+
+        // 仅当「当前会话且主窗口在前台」时抑制托盘;否则(含同会话但窗口在后台)推 unreadMap
+        if (!suppressTrayForForegroundChat && window.electron && window.electron.ipcRenderer) {
           const notificationContent = notificationLike
             ? `${incomingMsg.title || ''}\n${incomingMsg.content || ''}`
             : newMessage.content
@@ -205,6 +241,10 @@ export function useWebSocket({
     }
 
     return () => {
+      if (debounceTimerRef.current) {
+        clearTimeout(debounceTimerRef.current)
+        debounceTimerRef.current = null
+      }
       wsService?.disconnect()
     }
   // eslint-disable-next-line react-hooks/exhaustive-deps

+ 55 - 0
src/renderer/src/services/api.ts

@@ -833,6 +833,61 @@ export const api = {
     return response.json();
   },
 
+  /**
+   * 获取当前用户未读消息总数
+   */
+  getUnreadCount: async (token: string): Promise<number> => {
+    const response = await fetch(`${API_BASE_URL}/messages/unread-count`, {
+      method: 'GET',
+      headers: {
+        'Authorization': `Bearer ${token}`
+      }
+    });
+
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      logger.error('API: Get unread count failed', { status: response.status, error });
+      throw new Error(error.detail || '获取未读消息数失败');
+    }
+
+    const text = await response.text();
+    const trimmed = text.trim();
+    try {
+      const parsed = JSON.parse(trimmed) as unknown;
+      if (typeof parsed === 'number' && !Number.isNaN(parsed)) return parsed;
+      if (typeof parsed === 'object' && parsed !== null && 'count' in (parsed as any)) {
+        const n = Number((parsed as { count: unknown }).count);
+        if (!Number.isNaN(n)) return n;
+      }
+    } catch {
+      const n = parseInt(trimmed, 10);
+      if (!Number.isNaN(n)) return n;
+    }
+    const n = parseInt(trimmed, 10);
+    if (!Number.isNaN(n)) return n;
+    throw new Error('无效的未读消息数响应');
+  },
+
+  /**
+   * 按会话将当前用户作为接收方的未读消息全部标为已读
+   */
+  markConversationReadAll: async (token: string, otherUserId: number): Promise<{ updated_count: number }> => {
+    const response = await fetch(`${API_BASE_URL}/messages/history/${otherUserId}/read-all`, {
+      method: 'PUT',
+      headers: {
+        'Authorization': `Bearer ${token}`
+      }
+    });
+
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      logger.error('API: Mark conversation read-all failed', { otherUserId, status: response.status, error });
+      throw new Error(error.detail || '标记会话已读失败');
+    }
+
+    return response.json();
+  },
+
   /**
    * 获取联系人列表(聊天会话列表)
    * @param token 用户 Token