Browse Source

优化卡片通知

liuq 3 weeks ago
parent
commit
1c5c47eb26

+ 2 - 2
README.md

@@ -204,7 +204,7 @@ npm run dist -- --linux
 ```json
 "publish": {
   "provider": "generic",
-  "url": "https://api.hnyunzhu.com:9004/app-updates/1772806127/windows/update/"
+  "url": "https://api.hnyunzhu.com:9004/app-updates/1773848909/windows/update/"
 }
 ```
 
@@ -226,7 +226,7 @@ npm run dist -- --linux
    ```
 
    单行可写为:`releaseNotes: "1. 修复若干问题;2. 新增 xxx 功能"`
-4. **上传**:将 `dist/latest.yml` 和 `dist/韫珠IM Setup x.x.x.exe`(及同名的 `.exe.blockmap`,若有)上传到上述 `publish.url` 对应目录,保证可通过 `https://api.hnyunzhu.com:9004/app-updates/1772806127/windows/update/latest.yml` 和同目录下的 exe 被访问。
+4. **上传**:将 `dist/latest.yml` 和 `dist/韫珠IM Setup x.x.x.exe`(及同名的 `.exe.blockmap`,若有)上传到上述 `publish.url` 对应目录,保证可通过 `https://api.hnyunzhu.com:9004/app-updates/1773848909/windows/update/latest.yml` 和同目录下的 exe 被访问。
 
 ### 服务端要求
 

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yunzhu-im",
-  "version": "1.0.7",
+  "version": "1.0.8",
   "main": "./out/main/index.js",
   "author": "example.com",
   "license": "MIT",
@@ -43,7 +43,7 @@
     "productName": "韫珠IM",
     "publish": {
       "provider": "generic",
-      "url": "https://api.hnyunzhu.com:9004/app-updates/1772806127/windows/update/"
+      "url": "https://api.hnyunzhu.com:9004/app-updates/1773848909/windows/update/"
     },
     "directories": {
       "output": "dist"

+ 52 - 7
src/main/index.ts

@@ -53,6 +53,15 @@ interface UnreadInfo {
 }
 const unreadMap = new Map<number, UnreadInfo>()
 
+function isHttpUrl(url: string): boolean {
+  try {
+    const u = new URL(url.trim())
+    return u.protocol === 'http:' || u.protocol === 'https:'
+  } catch {
+    return false
+  }
+}
+
 // --- 浏览器视图管理器 ---
 interface TabInfo {
   id: string
@@ -113,6 +122,9 @@ class ViewManager {
     })
 
     view.webContents.setWindowOpenHandler((details) => {
+      if (!isHttpUrl(details.url)) {
+        return { action: 'allow' }
+      }
       const newId = this.createTab(details.url, true)
       if (!this.window.isDestroyed()) {
         this.window.webContents.send('tab-created', { id: newId, url: details.url, title: 'Loading...' })
@@ -196,6 +208,14 @@ class ViewManager {
     }
   }
 
+  getTabSnapshotsForRenderer(): Array<{ id: string; url: string; title: string }> {
+    return Array.from(this.tabs.values()).map((t) => ({
+      id: t.id,
+      url: t.view.webContents.getURL(),
+      title: t.view.webContents.getTitle() || t.title || 'Loading...'
+    }))
+  }
+
   destroy() {
     this.tabs.forEach(tab => {
       try {
@@ -576,7 +596,7 @@ function createWindow(): void {
 
   mainWindow.webContents.setWindowOpenHandler((details) => {
     // Intercept target="_blank" and open custom browser window
-    if (details.url.startsWith('http')) {
+    if (isHttpUrl(details.url)) {
       createInternalBrowserWindow(details.url)
       return { action: 'deny' }
     }
@@ -594,6 +614,22 @@ function createWindow(): void {
 
 let browserWindow: BrowserWindow | null = null
 let viewManager: ViewManager | null = null
+/** 新建内置浏览器窗口时,首标签在渲染进程就绪后再同步,避免 tab-created 早于 listener */
+let pendingBrowserInitialUrl: string | null = null
+
+function flushPendingBrowserInitialTab(): void {
+  if (!pendingBrowserInitialUrl || !viewManager || !browserWindow || browserWindow.isDestroyed()) return
+  const url = pendingBrowserInitialUrl
+  pendingBrowserInitialUrl = null
+  viewManager.createTab(url, true)
+}
+
+function syncBrowserTabsToRenderer(): void {
+  if (!viewManager || !browserWindow || browserWindow.isDestroyed()) return
+  const snapshots = viewManager.getTabSnapshotsForRenderer()
+  if (snapshots.length === 0) return
+  browserWindow.webContents.send('browser-tabs-sync', snapshots)
+}
 
 // Function to create the custom browser window
 function createInternalBrowserWindow(targetUrl: string): void {
@@ -634,6 +670,7 @@ function createInternalBrowserWindow(targetUrl: string): void {
   browserWindow.maximize()
 
   viewManager = new ViewManager(browserWindow)
+  pendingBrowserInitialUrl = targetUrl
 
   // Load the browser route with initial URL
   // We use hash parameter to avoid Vite dev server 403 error with query params on root
@@ -646,18 +683,19 @@ function createInternalBrowserWindow(targetUrl: string): void {
   }
 
   browserWindow.on('closed', () => {
+    pendingBrowserInitialUrl = null
     viewManager?.destroy()
     viewManager = null
     browserWindow = null
   })
 
-  browserWindow.webContents.on('did-finish-load', () => {
+  browserWindow.webContents.once('did-finish-load', () => {
     browserWindow?.setTitle('应用中心')
-    // 初始打开第一个标签
-    if (viewManager) {
-        const id = viewManager.createTab(targetUrl, true)
-        browserWindow?.webContents.send('tab-created', { id, url: targetUrl, title: 'Loading...' })
-    }
+    // 渲染进程未就绪时的兜底:稍后再创建首标签并同步 UI
+    setTimeout(() => {
+      flushPendingBrowserInitialTab()
+      syncBrowserTabsToRenderer()
+    }, 400)
   })
 }
 
@@ -1211,6 +1249,10 @@ ipcMain.on('browser-action', (event, action) => {
     if (event.sender.id !== browserWindow.webContents.id) return
 
     switch (action.type) {
+      case 'browser-ui-ready':
+        flushPendingBrowserInitialTab()
+        syncBrowserTabsToRenderer()
+        break
       case 'create-tab':
         const id = viewManager.createTab(action.url || 'about:blank', true)
         event.reply('tab-created', { id, url: action.url, title: 'Loading...' })
@@ -1300,6 +1342,9 @@ app.whenReady().then(() => {
   })
 
   ipcMain.on('login-success', () => {
+    // 登录成功后清空消息中心未读,避免切换账号展示上个账号的残留
+    unreadMap.clear()
+    updateUnreadStatus()
     if (mainWindow) {
       mainWindow.setSize(1000, 700)
       mainWindow.setResizable(true)

+ 29 - 6
src/renderer/src/App.refactored.tsx

@@ -14,6 +14,7 @@ import { useWebSocket } from './hooks/useWebSocket'
 import { UserContact, api } from './services/api'
 import { logger } from './utils/logger'
 import { formatFullDateTime } from './utils/timeUtils'
+import { isNotificationLikeMessage, isMessageFromSelf } from './utils/messageTypes'
 import { getDefaultAvatar } from './utils/avatarUtils'
 import { Contact, Message, SearchResult } from './types'
 
@@ -37,6 +38,22 @@ function App(): JSX.Element {
   const [chatSearchResults, setChatSearchResults] = useState<Message[]>([])
   
   const unreadCountsRef = useRef<Record<number, number>>({})
+
+  useEffect(() => {
+    if (!isLoggedIn) {
+      setActiveContactId(null)
+      setSelectedContactDetail(null)
+      setActiveTab('chat')
+      setSearchQuery('')
+      setSearchResults([])
+      setIsSearching(false)
+      setChatSearchQuery('')
+      setChatSearchResults([])
+      setInputValue('')
+      setContextMenu(null)
+      unreadCountsRef.current = {}
+    }
+  }, [isLoggedIn])
   
   useEffect(() => {
     activeContactIdRef.current = activeContactId
@@ -143,11 +160,11 @@ function App(): JSX.Element {
             })
             
             const formattedMessages: Message[] = messagesData.map(msg => {
-              const isNotification = msg.type === 'NOTIFICATION'
-              const isSelf = !isNotification && currentUserId !== null && msg.sender_id === currentUserId
+              const notificationLike = isNotificationLikeMessage(msg.type, msg.content_type)
+              const isSelf = isMessageFromSelf(msg, currentUserId)
               
               let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-              if (isNotification) {
+              if (notificationLike) {
                 messageType = 'notification'
               } else if (msg.content_type === 'IMAGE') {
                 messageType = 'image'
@@ -195,7 +212,10 @@ function App(): JSX.Element {
       
       contactMessages.forEach(msg => {
         let matched = false
-        if (msg.type === 'text' && msg.content.toLowerCase().includes(queryLower)) {
+        if (
+          (msg.type === 'text' || msg.type === 'notification') &&
+          msg.content.toLowerCase().includes(queryLower)
+        ) {
           matched = true
         }
         if (msg.title && msg.title.toLowerCase().includes(queryLower)) {
@@ -227,7 +247,10 @@ function App(): JSX.Element {
     const queryLower = query.toLowerCase()
     const contactMessages = messages[activeContactId] || []
     const results = contactMessages.filter(msg => {
-      if (msg.type === 'text' && msg.content.toLowerCase().includes(queryLower)) {
+      if (
+        (msg.type === 'text' || msg.type === 'notification') &&
+        msg.content.toLowerCase().includes(queryLower)
+      ) {
         return true
       }
       if (msg.title && msg.title.toLowerCase().includes(queryLower)) {
@@ -567,7 +590,7 @@ function App(): JSX.Element {
           )
         ) : activeTab === 'browser' ? (
           token ? (
-            <AppMappingList token={token} />
+            <AppMappingList token={token} currentUserId={currentUserId} />
           ) : (
             <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
               请先登录

+ 40 - 6
src/renderer/src/App.tsx

@@ -14,12 +14,14 @@ import { useWebSocket } from './hooks/useWebSocket'
 import { UserContact, api } from './services/api'
 import { logger } from './utils/logger'
 import { formatFullDateTime } from './utils/timeUtils'
+import { isNotificationLikeMessage, isMessageFromSelf } from './utils/messageTypes'
 import { getDefaultAvatar } from './utils/avatarUtils'
 import { Contact, Message, SearchResult } from './types'
 
 function App(): JSX.Element {
   const { isLoggedIn, token, currentUserId, currentUserName, handleLogin, handleLogout } = useAuth()
   const [activeTab, setActiveTab] = useState<'chat' | 'contact' | 'browser'>('chat')
+  const prevActiveTabRef = useRef<'chat' | 'contact' | 'browser'>(activeTab)
   const [activeContactId, setActiveContactId] = useState<number | null>(null)
   const activeContactIdRef = useRef(activeContactId)
   const [selectedContactDetail, setSelectedContactDetail] = useState<UserContact | null>(null)
@@ -42,6 +44,23 @@ function App(): JSX.Element {
   const [chatSearchResults, setChatSearchResults] = useState<Message[]>([])
   
   const unreadCountsRef = useRef<Record<number, number>>({})
+
+  // 退出登录时清空聊天相关 UI 状态(同进程内切账号,避免沿用上一账号)
+  useEffect(() => {
+    if (!isLoggedIn) {
+      setActiveContactId(null)
+      setSelectedContactDetail(null)
+      setActiveTab('chat')
+      setSearchQuery('')
+      setSearchResults([])
+      setIsSearching(false)
+      setChatSearchQuery('')
+      setChatSearchResults([])
+      setInputValue('')
+      setContextMenu(null)
+      unreadCountsRef.current = {}
+    }
+  }, [isLoggedIn])
   
   useEffect(() => {
     activeContactIdRef.current = activeContactId
@@ -55,6 +74,15 @@ function App(): JSX.Element {
     fetchContacts
   } = useContacts(token, unreadCountsRef, activeContactId, setActiveContactId)
 
+  // 从其他侧边栏 tab 切回消息中心时重新拉取会话列表(多客户端同步)
+  useEffect(() => {
+    const prev = prevActiveTabRef.current
+    if (activeTab === 'chat' && prev !== 'chat' && token) {
+      void fetchContacts()
+    }
+    prevActiveTabRef.current = activeTab
+  }, [activeTab, token, fetchContacts])
+
   const {
     messages,
     setMessages,
@@ -200,11 +228,11 @@ function App(): JSX.Element {
             })
             
             const formattedMessages: Message[] = messagesData.map(msg => {
-              const isNotification = msg.type === 'NOTIFICATION'
-              const isSelf = !isNotification && currentUserId !== null && msg.sender_id === currentUserId
+              const notificationLike = isNotificationLikeMessage(msg.type, msg.content_type)
+              const isSelf = isMessageFromSelf(msg, currentUserId)
               
               let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-              if (isNotification) {
+              if (notificationLike) {
                 messageType = 'notification'
               } else if (msg.content_type === 'IMAGE') {
                 messageType = 'image'
@@ -252,7 +280,10 @@ function App(): JSX.Element {
       
       contactMessages.forEach(msg => {
         let matched = false
-        if (msg.type === 'text' && msg.content.toLowerCase().includes(queryLower)) {
+        if (
+          (msg.type === 'text' || msg.type === 'notification') &&
+          msg.content.toLowerCase().includes(queryLower)
+        ) {
           matched = true
         }
         if (msg.title && msg.title.toLowerCase().includes(queryLower)) {
@@ -284,7 +315,10 @@ function App(): JSX.Element {
     const queryLower = query.toLowerCase()
     const contactMessages = messages[activeContactId] || []
     const results = contactMessages.filter(msg => {
-      if (msg.type === 'text' && msg.content.toLowerCase().includes(queryLower)) {
+      if (
+        (msg.type === 'text' || msg.type === 'notification') &&
+        msg.content.toLowerCase().includes(queryLower)
+      ) {
         return true
       }
       if (msg.title && msg.title.toLowerCase().includes(queryLower)) {
@@ -628,7 +662,7 @@ function App(): JSX.Element {
           )
         ) : activeTab === 'browser' ? (
           token ? (
-            <AppMappingList token={token} />
+            <AppMappingList token={token} currentUserId={currentUserId} />
           ) : (
             <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
               请先登录

+ 36 - 21
src/renderer/src/BrowserWindow.tsx

@@ -15,6 +15,31 @@ const BrowserWindow: React.FC = () => {
   const [activeTabId, setActiveTabId] = useState<string | null>(null)
   const contentRef = useRef<HTMLDivElement>(null)
 
+  const handleTabCreated = useCallback((_event: unknown, tab: { id: string; url: string; title: string }) => {
+    setTabs((prev) => [...prev, { ...tab, isLoading: true, canGoBack: false, canGoForward: false }])
+    setActiveTabId(tab.id)
+  }, [])
+
+  const handleTabUpdate = useCallback((_event: unknown, id: string, updates: Partial<Tab>) => {
+    setTabs((prev) => prev.map((t) => (t.id === id ? { ...t, ...updates } : t)))
+  }, [])
+
+  const handleTabsSync = useCallback(
+    (_event: unknown, snapshots: Array<{ id: string; url: string; title: string }>) => {
+      if (!snapshots?.length) return
+      setTabs(
+        snapshots.map((s) => ({
+          ...s,
+          isLoading: true,
+          canGoBack: false,
+          canGoForward: false
+        }))
+      )
+      setActiveTabId(snapshots[snapshots.length - 1].id)
+    },
+    []
+  )
+
   // 更新 BrowserView 的位置
   const updateViewBounds = useCallback(() => {
     if (!contentRef.current) return
@@ -34,36 +59,26 @@ const BrowserWindow: React.FC = () => {
     }
   }, [])
 
-  // 监听来自主进程的事件
+  // 监听来自主进程的事件(先注册再通知主进程,避免首标签 tab-created 丢失)
   useEffect(() => {
-    const handleTabCreated = (_event: any, tab: { id: string, url: string, title: string }) => {
-        setTabs(prev => [...prev, { ...tab, isLoading: true, canGoBack: false, canGoForward: false }])
-        setActiveTabId(tab.id)
-    }
-
-    const handleTabUpdate = (_event: any, id: string, updates: Partial<Tab>) => {
-        setTabs(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t))
-    }
+    const ipc = window.electron?.ipcRenderer
+    if (!ipc) return
 
-    if (window.electron && window.electron.ipcRenderer) {
-      window.electron.ipcRenderer.removeAllListeners('tab-created')
-      window.electron.ipcRenderer.removeAllListeners('tab-update')
-      
-      window.electron.ipcRenderer.on('tab-created', handleTabCreated)
-      window.electron.ipcRenderer.on('tab-update', handleTabUpdate)
-    }
+    ipc.on('tab-created', handleTabCreated)
+    ipc.on('tab-update', handleTabUpdate)
+    ipc.on('browser-tabs-sync', handleTabsSync)
+    ipc.send('browser-action', { type: 'browser-ui-ready' })
 
-    // 监听窗口大小变化
     window.addEventListener('resize', updateViewBounds)
-    // 初始调整一次
     setTimeout(updateViewBounds, 100)
 
     return () => {
-      window.electron.ipcRenderer.removeAllListeners('tab-created')
-      window.electron.ipcRenderer.removeAllListeners('tab-update')
+      ipc.removeListener('tab-created', handleTabCreated)
+      ipc.removeListener('tab-update', handleTabUpdate)
+      ipc.removeListener('browser-tabs-sync', handleTabsSync)
       window.removeEventListener('resize', updateViewBounds)
     }
-  }, [updateViewBounds])
+  }, [handleTabCreated, handleTabUpdate, handleTabsSync, updateViewBounds])
   
   // 当 tabs 或 activeTabId 变化导致布局变化时,调整 BrowserView 的 bounds
   useEffect(() => {

+ 39 - 13
src/renderer/src/components/AppMappingList.tsx

@@ -2,11 +2,14 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
 import { BsSearch, BsGrid3X3Gap, BsGlobe } from 'react-icons/bs'
 import { api, AppMapping } from '../services/api'
 import { logger } from '../utils/logger'
+import { normalizeExternalUrl } from '../utils/urlUtils'
 import { stringToColor, calculateFontSize } from '../utils/iconGradientUtils'
 import '../index.css'
 
 interface AppMappingListProps {
   token: string
+  /** 用于隔离「最近打开」的 localStorage,与登录用户一致 */
+  currentUserId: number | null
 }
 
 /** 最近打开本地存储项 */
@@ -16,7 +19,7 @@ export interface RecentAppItem {
   app_name: string
 }
 
-const RECENT_APPS_KEY = 'launchpad-recent-apps'
+const RECENT_APPS_KEY_PREFIX = 'launchpad-recent-apps'
 const RECENT_APPS_MAX = 5
 const SHOW_CATEGORY_KEY = 'launchpad-show-category'
 const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
@@ -63,7 +66,7 @@ const getAppIcon = (appName: string, containerSize: number = 40): React.ReactNod
   );
 };
 
-const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
+const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId }) => {
   const [mappings, setMappings] = useState<AppMapping[]>([])
   const [searchKeyword, setSearchKeyword] = useState('')
   const [isLoading, setIsLoading] = useState(false)
@@ -90,6 +93,11 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
   })
   const [recentApps, setRecentApps] = useState<RecentAppItem[]>([])
 
+  const recentStorageKey = useMemo(
+    () => (currentUserId != null ? `${RECENT_APPS_KEY_PREFIX}:${currentUserId}` : null),
+    [currentUserId]
+  )
+
   // 分页状态
   const [skip, setSkip] = useState(0)
   const skipRef = useRef(0)
@@ -109,20 +117,26 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
     skipRef.current = skip
   }, [skip])
 
-  // 从本地加载最近打开(最多 5 条,越近期越靠前)
+  // 从本地加载最近打开(按用户隔离;最多 5 条,越近期越靠前)
   useEffect(() => {
+    if (!recentStorageKey) {
+      setRecentApps([])
+      return
+    }
     try {
-      const raw = localStorage.getItem(RECENT_APPS_KEY)
+      const raw = localStorage.getItem(recentStorageKey)
       if (raw) {
         const list = JSON.parse(raw) as RecentAppItem[]
         if (Array.isArray(list)) {
           setRecentApps(list.slice(0, RECENT_APPS_MAX))
+          return
         }
       }
+      setRecentApps([])
     } catch (_) {
       setRecentApps([])
     }
-  }, [])
+  }, [recentStorageKey])
 
   const saveRecentApp = useCallback((item: { app_id: string; mapped_key: string; app_name: string }) => {
     setRecentApps(prev => {
@@ -130,12 +144,14 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
         item,
         ...prev.filter(x => !(x.app_id === item.app_id && x.mapped_key === item.mapped_key))
       ].slice(0, RECENT_APPS_MAX)
-      try {
-        localStorage.setItem(RECENT_APPS_KEY, JSON.stringify(next))
-      } catch (_) {}
+      if (recentStorageKey) {
+        try {
+          localStorage.setItem(recentStorageKey, JSON.stringify(next))
+        } catch (_) {}
+      }
       return next
     })
-  }, [])
+  }, [recentStorageKey])
 
   // 加载账号映射列表
   const loadMappings = useCallback(async (isRefresh: boolean = false) => {
@@ -298,10 +314,15 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
       const result = await api.ssoLogin(token, mapping.app_id)
       
       if (result.redirect_url) {
+        const normalized = normalizeExternalUrl(result.redirect_url)
+        if (!normalized) {
+          alert('无效的跳转地址')
+          return
+        }
         if (openInSystemBrowser && window.electron?.ipcRenderer) {
-          window.electron.ipcRenderer.send('open-url-external', result.redirect_url)
+          window.electron.ipcRenderer.send('open-url-external', normalized)
         } else {
-          window.open(result.redirect_url, '_blank')
+          window.open(normalized, '_blank')
         }
         saveRecentApp({ app_id: mapping.app_id, mapped_key: mapping.mapped_key, app_name: mapping.app_name })
       } else {
@@ -327,10 +348,15 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
     try {
       const result = await api.ssoLogin(token, item.app_id)
       if (result.redirect_url) {
+        const normalized = normalizeExternalUrl(result.redirect_url)
+        if (!normalized) {
+          alert('无效的跳转地址')
+          return
+        }
         if (openInSystemBrowser && window.electron?.ipcRenderer) {
-          window.electron.ipcRenderer.send('open-url-external', result.redirect_url)
+          window.electron.ipcRenderer.send('open-url-external', normalized)
         } else {
-          window.open(result.redirect_url, '_blank')
+          window.open(normalized, '_blank')
         }
         saveRecentApp(item)
       } else {

+ 69 - 30
src/renderer/src/components/ChatWindow/MessageBubble.tsx

@@ -3,6 +3,8 @@ import { Message } from '../../types'
 import { downloadFile, getFileIcon, formatFileSize, refreshMediaUrl } from '../../utils/messageUtils'
 import { logger } from '../../utils/logger'
 import { api } from '../../services/api'
+import { normalizeExternalUrl } from '../../utils/urlUtils'
+import { isNotificationLikeMessage } from '../../utils/messageTypes'
 
 const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
 
@@ -24,7 +26,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
   const [actionLoading, setActionLoading] = useState(false)
 
   // 通知类型消息(带跳转:先请求 callback-url 再打开,打开方式与应用中心一致)
-  if (msg.type === 'notification') {
+  if (msg.type === 'notification' || isNotificationLikeMessage(msg.messageType, msg.content_type)) {
     const openInSystemBrowser = (() => {
       try {
         return localStorage.getItem(OPEN_IN_SYSTEM_BROWSER_KEY) === 'true'
@@ -44,12 +46,17 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
           urlToOpen = res.callback_url
         } catch (err) {
           logger.warn('MessageBubble: callback-url failed, fallback to actionUrl', err)
-          urlToOpen = msg.actionUrl
+          urlToOpen = msg.actionUrl ?? ''
+        }
+        const normalized = normalizeExternalUrl(urlToOpen)
+        if (!normalized) {
+          logger.warn('MessageBubble: invalid URL for open', { urlToOpen })
+          return
         }
         if (openInSystemBrowser && window.electron?.ipcRenderer) {
-          window.electron.ipcRenderer.send('open-url-external', urlToOpen)
+          window.electron.ipcRenderer.send('open-url-external', normalized)
         } else {
-          window.open(urlToOpen, '_blank')
+          window.open(normalized, '_blank')
         }
       } finally {
         setActionLoading(false)
@@ -57,39 +64,71 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
     }
 
     return (
-      <div style={{ minWidth: '250px' }}>
+      <div
+        style={{
+          width: '100%',
+          boxSizing: 'border-box',
+          minWidth: 0,
+          border: '1px solid #e0e0e0',
+          borderRadius: '8px',
+          overflow: 'hidden',
+          backgroundColor: '#fff'
+        }}
+      >
         {msg.title && (
-          <div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '8px', color: '#333' }}>
+          <div
+            style={{
+              fontSize: '15px',
+              fontWeight: 600,
+              color: '#2e7d32',
+              backgroundColor: '#e8f5e9',
+              padding: '10px 12px',
+              lineHeight: 1.4
+            }}
+          >
             {msg.title}
           </div>
         )}
-        <div style={{ fontSize: '14px', color: '#666', marginBottom: '12px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
+        <div
+          style={{
+            fontSize: '14px',
+            color: '#333',
+            padding: '12px',
+            whiteSpace: 'pre-wrap',
+            wordBreak: 'break-word',
+            backgroundColor: '#ffffff'
+          }}
+        >
           {msg.content}
         </div>
         {msg.actionUrl && (
-          <button
-            onClick={(e) => handleNotificationAction(e)}
-            disabled={actionLoading}
-            style={{
-              padding: '6px 16px',
-              backgroundColor: actionLoading ? '#999' : '#1aad19',
-              color: 'white',
-              border: 'none',
-              borderRadius: '4px',
-              cursor: actionLoading ? 'not-allowed' : 'pointer',
-              fontSize: '13px',
-              fontWeight: '500',
-              transition: 'background-color 0.2s'
-            }}
-            onMouseEnter={(e) => {
-              if (!actionLoading) e.currentTarget.style.backgroundColor = '#179b16'
-            }}
-            onMouseLeave={(e) => {
-              if (!actionLoading) e.currentTarget.style.backgroundColor = '#1aad19'
-            }}
-          >
-            {actionLoading ? '跳转中...' : (msg.actionText || '立即处理')}
-          </button>
+          <div style={{ padding: '0 12px 12px', backgroundColor: '#ffffff' }}>
+            <button
+              type="button"
+              onClick={(e) => handleNotificationAction(e)}
+              disabled={actionLoading}
+              style={{
+                padding: '5px 16px',
+                backgroundColor: 'transparent',
+                color: actionLoading ? '#999' : '#1890ff',
+                border: `1px solid ${actionLoading ? '#d9d9d9' : '#1890ff'}`,
+                borderRadius: '4px',
+                cursor: actionLoading ? 'not-allowed' : 'pointer',
+                fontSize: '13px',
+                fontWeight: 500,
+                transition: 'color 0.2s, border-color 0.2s, background-color 0.2s'
+              }}
+              onMouseEnter={(e) => {
+                if (actionLoading) return
+                e.currentTarget.style.backgroundColor = 'rgba(24, 144, 255, 0.06)'
+              }}
+              onMouseLeave={(e) => {
+                e.currentTarget.style.backgroundColor = 'transparent'
+              }}
+            >
+              {actionLoading ? '跳转中...' : (msg.actionText || '立即处理')}
+            </button>
+          </div>
         )}
       </div>
     )

+ 4 - 0
src/renderer/src/hooks/useAuth.ts

@@ -10,6 +10,10 @@ export function useAuth() {
 
   // 退出登录函数
   const handleLogout = useCallback(() => {
+    // 退出登录时清空消息中心未读,避免切换账号后仍显示上个账号的未读
+    if (window.electron && window.electron.ipcRenderer) {
+      window.electron.ipcRenderer.send('clear-all-unread')
+    }
     localStorage.removeItem('auth_token')
     localStorage.removeItem('user_id')
     localStorage.removeItem('user_name')

+ 16 - 0
src/renderer/src/hooks/useContacts.ts

@@ -15,6 +15,7 @@ export function useContacts(
   const [isLoadingContacts, setIsLoadingContacts] = useState(false)
   const contactsRef = useRef<Contact[]>([])
   const activeContactIdRef = useRef(activeContactId)
+  const prevTokenRef = useRef<string | null>(null)
 
   useEffect(() => {
     contactsRef.current = contacts
@@ -24,6 +25,21 @@ export function useContacts(
     activeContactIdRef.current = activeContactId
   }, [activeContactId])
 
+  // 退出登录或切换账号(token 变化)时清空联系人列表与当前会话,避免沿用上一账号的选中会话 id
+  useEffect(() => {
+    if (!token) {
+      setContacts([])
+      setActiveContactId(null)
+      prevTokenRef.current = null
+      return
+    }
+    if (prevTokenRef.current !== token) {
+      prevTokenRef.current = token
+      setContacts([])
+      setActiveContactId(null)
+    }
+  }, [token, setActiveContactId])
+
   const fetchContacts = useCallback(async () => {
     if (!token) return
     

+ 80 - 73
src/renderer/src/hooks/useMessages.ts

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
 import { Message } from '../types'
 import { api } from '../services/api'
 import { logger } from '../utils/logger'
+import { isNotificationLikeMessage, isMessageFromSelf } from '../utils/messageTypes'
 
 export function useMessages(
   token: string,
@@ -18,77 +19,32 @@ export function useMessages(
   const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({})
   const isLoadingMoreRef = useRef(false)
 
-  // 获取指定联系人的消息列表
-  const fetchMessages = useCallback(async (contactId: number) => {
-    if (!token || loadedContacts.has(contactId)) {
-      return
-    }
-    
-    setIsLoadingMessages(true)
-    try {
-      const messagesData = await api.getMessages(token, contactId, {
-        skip: 0,
-        limit: 50
-      })
-      
-      logger.info('App: Fetched messages', { contactId, count: messagesData.length })
-      
-      const formattedMessages: Message[] = messagesData.map(msg => {
-        const isNotification = msg.type === 'NOTIFICATION'
-        const isSelf = !isNotification && currentUserId !== null && msg.sender_id === currentUserId
-        
-        let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-        if (isNotification) {
-          messageType = 'notification'
-        } else if (msg.content_type === 'IMAGE') {
-          messageType = 'image'
-        } else if (msg.content_type === 'VIDEO') {
-          messageType = 'video'
-        } else if (msg.content_type === 'FILE') {
-          messageType = 'file'
-        }
-        
-        return {
-          id: msg.id,
-          content: msg.content || msg.title || '',
-          isSelf: isSelf,
-          type: messageType,
-          timestamp: new Date(msg.created_at).getTime(),
-          title: msg.title,
-          actionUrl: msg.action_url,
-          actionText: msg.action_text,
-          messageType: msg.type,
-          content_type: msg.content_type,
-          size: (msg as any).size
-        }
-      })
-      
-      formattedMessages.sort((a, b) => a.timestamp - b.timestamp)
-      
-      setMessages(prev => ({
-        ...prev,
-        [contactId]: formattedMessages
-      }))
-
-      setHasMoreMessages(prev => ({
-        ...prev,
-        [contactId]: messagesData.length >= 50
-      }))
-      
-      setLoadedContacts(prev => new Set(prev).add(contactId))
-    } catch (error: any) {
-      logger.error('App: Failed to fetch messages', { contactId, error: error.message })
-    } finally {
+  // 退出登录或切换账号时清空内存中的会话消息,避免同一 contactId 跨账号串数据
+  useEffect(() => {
+    if (!token) {
+      setMessages({})
+      setLoadedContacts(new Set())
+      setHasMoreMessages({})
+      setUploadProgress({})
+      isLoadingMoreRef.current = false
       setIsLoadingMessages(false)
+      setIsLoadingMore(false)
+      return
     }
-  }, [token, currentUserId, loadedContacts])
+    setMessages({})
+    setLoadedContacts(new Set())
+    setHasMoreMessages({})
+    setUploadProgress({})
+    isLoadingMoreRef.current = false
+    setIsLoadingMore(false)
+  }, [token, currentUserId])
 
   const formatMessageData = useCallback((msg: any): Message => {
-    const isNotification = msg.type === 'NOTIFICATION'
-    const isSelf = !isNotification && currentUserId !== null && msg.sender_id === currentUserId
+    const notificationLike = isNotificationLikeMessage(msg.type, msg.content_type)
+    const isSelf = isMessageFromSelf(msg, currentUserId)
 
     let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-    if (isNotification) {
+    if (notificationLike) {
       messageType = 'notification'
     } else if (msg.content_type === 'IMAGE') {
       messageType = 'image'
@@ -113,6 +69,54 @@ export function useMessages(
     }
   }, [currentUserId])
 
+  // 拉取该会话最新一页;切换会话时也会调用,并与本地已加载的更早历史、未入库的本地消息合并
+  const refreshMessages = useCallback(async (contactId: number) => {
+    if (!token) return
+
+    setIsLoadingMessages(true)
+    try {
+      const messagesData = await api.getMessages(token, contactId, {
+        skip: 0,
+        limit: 50
+      })
+
+      logger.info('App: Refreshed messages', { contactId, count: messagesData.length })
+
+      const formattedMessages: Message[] = messagesData.map(formatMessageData)
+      formattedMessages.sort((a, b) => a.timestamp - b.timestamp)
+
+      setMessages(prev => {
+        const existing = prev[contactId] || []
+        if (formattedMessages.length === 0) {
+          return { ...prev, [contactId]: existing }
+        }
+        const serverIds = new Set(formattedMessages.map(m => m.id))
+        const oldestFetched = formattedMessages[0].timestamp
+        const preservedOlder = existing.filter(
+          m => !serverIds.has(m.id) && m.timestamp < oldestFetched
+        )
+        const pendingLocal = existing.filter(
+          m => !serverIds.has(m.id) && m.timestamp >= oldestFetched
+        )
+        const merged = [...preservedOlder, ...formattedMessages, ...pendingLocal].sort(
+          (a, b) => a.timestamp - b.timestamp
+        )
+        return { ...prev, [contactId]: merged }
+      })
+
+      setHasMoreMessages(prev => ({
+        ...prev,
+        [contactId]: messagesData.length >= 50
+      }))
+
+      setLoadedContacts(prev => new Set(prev).add(contactId))
+    } catch (error: any) {
+      logger.error('App: Failed to refresh messages', { contactId, error: error.message })
+    } finally {
+      setIsLoadingMessages(false)
+    }
+  }, [token, formatMessageData])
+
   const fetchMoreMessages = useCallback(async (contactId: number) => {
     if (!token || isLoadingMoreRef.current || !hasMoreMessages[contactId]) return
 
@@ -183,7 +187,9 @@ export function useMessages(
       logger.info('App: Message sent successfully', { content, receiverId: contactId, messageId: response.id })
       
       let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-      if (response.content_type === 'IMAGE') {
+      if (isNotificationLikeMessage(response.type, response.content_type)) {
+        messageType = 'notification'
+      } else if (response.content_type === 'IMAGE') {
         messageType = 'image'
       } else if (response.content_type === 'VIDEO') {
         messageType = 'video'
@@ -199,7 +205,9 @@ export function useMessages(
         timestamp: new Date(response.created_at).getTime(),
         messageType: response.type,
         content_type: response.content_type,
-        title: response.title
+        title: response.title,
+        actionUrl: response.action_url,
+        actionText: response.action_text
       }
       
       setMessages(prev => ({
@@ -290,13 +298,12 @@ export function useMessages(
     }
   }, [activeContactId, setContacts, unreadCountsRef])
 
-  // 切换联系人时获取消息
+  // 切换联系人时重新拉取该会话最新消息(含再次进入已打开过的会话)
   useEffect(() => {
-    if (activeContactId && token && !loadedContacts.has(activeContactId)) {
-      fetchMessages(activeContactId)
+    if (activeContactId && token) {
+      void refreshMessages(activeContactId)
     }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [activeContactId, token])
+  }, [activeContactId, token, refreshMessages])
 
   return {
     messages,
@@ -307,7 +314,7 @@ export function useMessages(
     uploadProgress,
     sendMessage,
     sendFileMessage,
-    fetchMessages,
+    fetchMessages: refreshMessages,
     fetchMoreMessages,
     loadedContacts
   }

+ 9 - 4
src/renderer/src/hooks/useWebSocket.ts

@@ -1,6 +1,7 @@
 import { useEffect, useRef } from 'react'
 import { WebSocketService } from '../services/websocket'
 import { logger } from '../utils/logger'
+import { isNotificationLikeMessage, isMessageFromSelf } from '../utils/messageTypes'
 import { Message, Contact } from '../types'
 
 interface UseWebSocketProps {
@@ -54,6 +55,10 @@ export function useWebSocket({
         
         let targetContactId: number | null = null
         const isNotification = incomingMsg.type === 'NOTIFICATION'
+        const notificationLike = isNotificationLikeMessage(
+          incomingMsg.type,
+          incomingMsg.content_type
+        )
         const appId = (incomingMsg as any).app_id as number | undefined
         const isBroadcast = Boolean((incomingMsg as any).is_broadcast)
         
@@ -87,10 +92,10 @@ export function useWebSocket({
           return
         }
         
-        const isSelf = currentUserId !== null && incomingMsg.sender_id === currentUserId
+        const isSelf = isMessageFromSelf(incomingMsg, currentUserId)
         
         let messageType: 'text' | 'image' | 'file' | 'video' | 'notification' = 'text'
-        if (isNotification) {
+        if (notificationLike) {
           messageType = 'notification'
         } else {
           const contentType = incomingMsg.content_type
@@ -166,7 +171,7 @@ export function useWebSocket({
         }
         
         if (contact) {
-          const lastMessageText = isNotification 
+          const lastMessageText = notificationLike
             ? (incomingMsg.title || incomingMsg.content)
             : (newMessage.type === 'image' ? '[图片]' : newMessage.content)
           
@@ -205,7 +210,7 @@ export function useWebSocket({
         }
         
         if (window.electron && window.electron.ipcRenderer) {
-          const notificationContent = isNotification 
+          const notificationContent = notificationLike
             ? `${incomingMsg.title || ''}\n${incomingMsg.content || ''}`
             : newMessage.content
           window.electron.ipcRenderer.send('start-notification', { 

+ 98 - 37
src/renderer/src/pages/ChatPage.tsx

@@ -3,6 +3,7 @@ import { BsFolder2, BsClockHistory } from 'react-icons/bs'
 import { Contact, Message } from '../types'
 import { MessageBubble } from '../components/ChatWindow/MessageBubble'
 import { formatFullDateTime } from '../utils/timeUtils'
+import { isNotificationLikeMessage } from '../utils/messageTypes'
 import { getDefaultAvatar } from '../utils/avatarUtils'
 
 interface ChatPageProps {
@@ -181,6 +182,9 @@ export const ChatPage: React.FC<ChatPageProps> = ({
             {(messages[activeContactId] || []).map((msg) => {
             const isMediaMessage = msg.type === 'image' || msg.type === 'video' || msg.type === 'file' || 
                                  msg.content_type === 'IMAGE' || msg.content_type === 'VIDEO' || msg.content_type === 'FILE'
+            const isNotificationMessage =
+              msg.type === 'notification' ||
+              isNotificationLikeMessage(msg.messageType, msg.content_type)
             
             return (
               <div
@@ -209,51 +213,108 @@ export const ChatPage: React.FC<ChatPageProps> = ({
                   </div>
                 </div>
 
-                <div style={{
-                  display: 'flex',
-                  justifyContent: msg.isSelf ? 'flex-end' : 'flex-start',
-                  alignItems: 'flex-start',
-                  width: '100%'
-                }}>
-                  {!msg.isSelf && (
-                    <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginRight: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px', flexShrink: 0 }}>
-                      {activeContact.avatar}
-                    </div>
-                  )}
-                  
-                  <div style={{ maxWidth: '70%', position: 'relative', minWidth: 0, overflow: 'hidden' }}>
+                {isNotificationMessage ? (
+                  <div
+                    style={{
+                      display: 'flex',
+                      justifyContent: msg.isSelf ? 'flex-end' : 'flex-start',
+                      alignItems: 'flex-start',
+                      width: '100%'
+                    }}
+                  >
+                    {!msg.isSelf && (
+                      <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginRight: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px', flexShrink: 0 }}>
+                        {activeContact.avatar}
+                      </div>
+                    )}
+
                     <div
-                      onContextMenu={(e) => onContextMenu(e, msg.id)}
-                      className={`${msg.isSelf ? 'bubble-self' : 'bubble-other'} ${isMediaMessage ? 'bubble-media' : ''}`}
                       style={{
-                        padding: isMediaMessage ? '0' : '10px',
-                        borderRadius: '4px',
-                        backgroundColor: msg.isSelf ? '#95ec69' : '#ffffff',
-                        border: '1px solid #e7e7e7',
+                        flex: 1,
+                        maxWidth: '80%',
+                        minWidth: 0,
                         position: 'relative',
-                        cursor: 'default',
-                        color: '#000',
-                        fontSize: '14px',
-                        boxShadow: '0 1px 1px rgba(0,0,0,0.05)',
-                        display: isMediaMessage ? 'inline-block' : undefined
+                        overflow: 'hidden'
                       }}
                     >
-                      <MessageBubble
-                        msg={msg}
-                        uploadProgress={uploadProgress}
-                        activeContactId={activeContactId}
-                        token={token}
-                        setMessages={setMessages}
-                      />
+                      <div
+                        onContextMenu={(e) => onContextMenu(e, msg.id)}
+                        className={msg.isSelf ? 'bubble-self' : 'bubble-other'}
+                        style={{
+                          padding: '0',
+                          borderRadius: '4px',
+                          backgroundColor: 'transparent',
+                          border: 'none',
+                          position: 'relative',
+                          cursor: 'default',
+                          color: '#000',
+                          fontSize: '14px',
+                          boxShadow: 'none'
+                        }}
+                      >
+                        <MessageBubble
+                          msg={msg}
+                          uploadProgress={uploadProgress}
+                          activeContactId={activeContactId}
+                          token={token}
+                          setMessages={setMessages}
+                        />
+                      </div>
                     </div>
-                  </div>
 
-                  {msg.isSelf && (
-                    <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginLeft: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px', flexShrink: 0 }}>
-                      {getDefaultAvatar(currentUserId || 0, currentUserName)}
+                    {msg.isSelf && (
+                      <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginLeft: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px', flexShrink: 0 }}>
+                        {getDefaultAvatar(currentUserId || 0, currentUserName)}
+                      </div>
+                    )}
+                  </div>
+                ) : (
+                  <div style={{
+                    display: 'flex',
+                    justifyContent: msg.isSelf ? 'flex-end' : 'flex-start',
+                    alignItems: 'flex-start',
+                    width: '100%'
+                  }}>
+                    {!msg.isSelf && (
+                      <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginRight: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px', flexShrink: 0 }}>
+                        {activeContact.avatar}
+                      </div>
+                    )}
+                    
+                    <div style={{ maxWidth: '70%', position: 'relative', minWidth: 0, overflow: 'hidden' }}>
+                      <div
+                        onContextMenu={(e) => onContextMenu(e, msg.id)}
+                        className={`${msg.isSelf ? 'bubble-self' : 'bubble-other'} ${isMediaMessage ? 'bubble-media' : ''}`}
+                        style={{
+                          padding: isMediaMessage ? '0' : '10px',
+                          borderRadius: '4px',
+                          backgroundColor: msg.isSelf ? '#95ec69' : '#ffffff',
+                          border: '1px solid #e7e7e7',
+                          position: 'relative',
+                          cursor: 'default',
+                          color: '#000',
+                          fontSize: '14px',
+                          boxShadow: '0 1px 1px rgba(0,0,0,0.05)',
+                          display: isMediaMessage ? 'inline-block' : undefined
+                        }}
+                      >
+                        <MessageBubble
+                          msg={msg}
+                          uploadProgress={uploadProgress}
+                          activeContactId={activeContactId}
+                          token={token}
+                          setMessages={setMessages}
+                        />
+                      </div>
                     </div>
-                  )}
-                </div>
+
+                    {msg.isSelf && (
+                      <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginLeft: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px', flexShrink: 0 }}>
+                        {getDefaultAvatar(currentUserId || 0, currentUserName)}
+                      </div>
+                    )}
+                  </div>
+                )}
               </div>
             )
           })}

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

@@ -23,7 +23,7 @@ export interface LoginResponse {
 export interface MessageResponse {
   id: number;
   type: 'MESSAGE' | 'NOTIFICATION';
-  content_type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE';
+  content_type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE' | 'USER_NOTIFICATION';
   content: string;
   title?: string;
   action_url?: string;

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

@@ -5,7 +5,7 @@ export interface WebSocketMessage {
   data?: {
     id: number;
     type: 'MESSAGE' | 'NOTIFICATION';
-    content_type?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE';
+    content_type?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE' | 'USER_NOTIFICATION';
     title?: string;
     content: string;
     action_url?: string;

+ 1 - 1
src/renderer/src/types/index.ts

@@ -10,7 +10,7 @@ export interface Message {
   actionUrl?: string
   actionText?: string
   messageType?: 'MESSAGE' | 'NOTIFICATION'
-  content_type?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE'
+  content_type?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE' | 'USER_NOTIFICATION'
   size?: number
 }
 

+ 21 - 0
src/renderer/src/utils/messageTypes.ts

@@ -0,0 +1,21 @@
+/** 与通知同套 UI:NOTIFICATION,或 MESSAGE + USER_NOTIFICATION */
+export function isNotificationLikeMessage(
+  serverType: string | undefined,
+  contentType: string | undefined
+): boolean {
+  if (serverType === 'NOTIFICATION') return true
+  return contentType === 'USER_NOTIFICATION'
+}
+
+/**
+ * 是否为自己发送:系统通知 type=NOTIFICATION 恒为 false;
+ * 私信中的 USER_NOTIFICATION(type=MESSAGE)与普通消息一样按 sender_id 判断。
+ */
+export function isMessageFromSelf(
+  msg: { type?: string; sender_id?: number },
+  currentUserId: number | null
+): boolean {
+  if (currentUserId === null) return false
+  if (msg.type === 'NOTIFICATION') return false
+  return msg.sender_id === currentUserId
+}

+ 21 - 0
src/renderer/src/utils/urlUtils.ts

@@ -0,0 +1,21 @@
+/**
+ * Normalize URLs for window.open / shell so Electron can intercept with http(s).
+ * Handles whitespace, protocol-relative //, and path-only relative URLs.
+ */
+export function normalizeExternalUrl(raw: string): string | null {
+  const s = raw.trim()
+  if (!s) return null
+  if (/^https?:\/\//i.test(s)) return s
+  if (s.startsWith('//')) return `https:${s}`
+  try {
+    const base =
+      typeof window !== 'undefined' && window.location?.origin
+        ? window.location.origin
+        : import.meta.env.DEV
+          ? 'http://127.0.0.1:3000'
+          : 'https://api.hnyunzhu.com'
+    return new URL(s, base).href
+  } catch {
+    return null
+  }
+}