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