弹窗通知模块
Electron弹窗通知模块实现(主进程+渲染进程IPC)
#tech / dev / pm
#type / snippet
#status / growing
代码实现
旧代码(ai 生成的代码)
一个主进程文件
创建窗口、注册 ipc
三个渲染进程文件
调用 ipc、生成页面样式
主进程代码
主进程的代码
import { BrowserWindow, ipcMain, screen } from "electron";
import path from "node:path";
const notificationWindows = new Map<string, BrowserWindow>();
const NOTIFICATION_WIDTH = 620;
const NOTIFICATION_HEIGHT = 920;
const NOTIFICATION_MARGIN = 10;
function getNotificationPosition(): { x: number; y: number } {
const primaryDisplay = screen.getPrimaryDisplay();
const { width: screenWidth } = primaryDisplay.workAreaSize;
const x = screenWidth - NOTIFICATION_WIDTH - NOTIFICATION_MARGIN;
const y =
NOTIFICATION_MARGIN +
notificationWindows.size * (NOTIFICATION_HEIGHT + NOTIFICATION_MARGIN);
return { x, y };
}
function reorderNotifications() {
let index = 0;
for (const [, window] of notificationWindows) {
const y =
NOTIFICATION_MARGIN + index * (NOTIFICATION_HEIGHT + NOTIFICATION_MARGIN);
window.setPosition(window.getPosition()[0], y);
index++;
}
}
export function setupNotificationService(
mainWindow: BrowserWindow,
MAIN_DIST: string,
RENDERER_DIST: string,
VITE_DEV_SERVER_URL: string | undefined
) {
ipcMain.handle(
"show-notification",
async (
_event,
options: {
id: string;
title: string;
body: string;
icon?: string;
urgency?: "normal" | "critical" | "low";
actions?: Array<{
text: string;
type: "confirm" | "cancel" | "action";
}>;
}
) => {
if (!mainWindow) {
return;
}
if (notificationWindows.has(options.id)) {
const existingWindow = notificationWindows.get(options.id);
existingWindow?.close();
notificationWindows.delete(options.id);
reorderNotifications();
}
const { x, y } = getNotificationPosition();
const notificationWindow = new BrowserWindow({
width: NOTIFICATION_WIDTH,
height: NOTIFICATION_HEIGHT,
x,
y,
frame: false,
transparent: true,
resizable: false,
skipTaskbar: true,
alwaysOnTop: true,
show: false,
backgroundColor: "#00000000",
webPreferences: {
preload: path.join(MAIN_DIST, "main_preload.mjs"),
contextIsolation: true,
nodeIntegration: true,
webSecurity: false,
},
});
notificationWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline';",
],
},
});
}
);
notificationWindows.set(options.id, notificationWindow);
notificationWindow.on("closed", () => {
notificationWindows.delete(options.id);
reorderNotifications();
});
const queryParams = new URLSearchParams({
id: options.id,
title: options.title,
body: options.body,
urgency: options.urgency || "normal",
});
if (options.icon) {
queryParams.append("icon", options.icon);
}
if (options.actions) {
queryParams.append(
"actions",
encodeURIComponent(JSON.stringify(options.actions))
);
}
const notificationUrl = VITE_DEV_SERVER_URL
? `${VITE_DEV_SERVER_URL}#/notification?${queryParams.toString()}`
: `file://${RENDERER_DIST}/index.html#/notification?${queryParams.toString()}`;
await notificationWindow.loadURL(notificationUrl);
notificationWindow.show();
return options.id;
}
);
ipcMain.on("close-notification", (_event, id: string) => {
const window = notificationWindows.get(id);
if (window && !window.isDestroyed()) {
window.close();
}
});
ipcMain.on(
"notification-action",
(_event, id: string, action: { text: string; type: string }) => {
const window = notificationWindows.get(id);
if (window && !window.isDestroyed()) {
const serializedAction = {
text: action.text,
type: action.type,
};
if (action.type === "confirm" || action.type === "cancel") {
window.close();
}
mainWindow.webContents.send(
"notification-action-received",
id,
serializedAction
);
}
}
);
}
前端的服务代码
interface NotificationOptions {
title: string;
body: string;
icon?: string;
urgency?: "normal" | "critical" | "low";
actions?: Array<{
text: string;
type: "confirm" | "cancel" | "action";
}>;
}
export class NotificationService {
private static instance: NotificationService;
private notificationCount = 0;
private constructor() {
// 添加空值检查
if (!window.shared?.ipcRenderer) {
console.error("Electron IPC Renderer is not available");
return;
}
// 监听通知动作
window.shared.ipcRenderer.on(
"notification-action",
(_event: any, id: string, action: any) => {}
);
}
public static getInstance(): NotificationService {
if (!NotificationService.instance) {
NotificationService.instance = new NotificationService();
}
return NotificationService.instance;
}
private generateId(): string {
const id = `notification-${Date.now()}-${this.notificationCount++}`;
return id;
}
/**
* 显示桌面通知,关键 show 函数
* @param options 通知选项
*/
public async show(options: NotificationOptions): Promise<string> {
const id = this.generateId();
try {
const result = await window.shared.ipcRenderer.invoke(
"show-notification",
{
id,
...options,
}
);
return result;
} catch (error) {
console.error("显示通知失败:", error);
return id;
}
}
/**
* 显示简单通知
* @param title 标题
* @param message 消息内容
*/
public async showSimple(title: string, message: string): Promise<string> {
return await this.show({
title,
body: message,
urgency: "normal",
});
}
/**
* 显示警告通知
* @param title 标题
* @param message 消息内容
*/
public async showWarning(title: string, message: string): Promise<string> {
return await this.show({
title,
body: message,
urgency: "critical",
actions: [{ text: "我知道了", type: "confirm" }],
});
}
}
// 导出单例实例
export const notification = NotificationService.getInstance();
上面的旧代码把 ipc 层、service 层、window 层的代码都放在一起了,不够干净
并且初始化时需要传入主进程路径、渲染进程路径等参数,但实际上可以直接从主 main.ts 文件中导入写好
渲染进程代码
NotificationPage 组件
<template>
<NotificationWindow
v-if="notificationData"
v-bind="notificationData"
@action="handleAction"
@close="handleClose"
/>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import NotificationWindow from './NotificationWindow.vue';
const route = useRoute();
const notificationData = ref<any>(null);
onMounted(() => {
// 从URL参数中获取通知数据
const params = Object.fromEntries(new URLSearchParams(route.query as any));
notificationData.value = params;
});
const handleAction = (action: any) => {
window.shared.send('notification-action', notificationData.value?.id, action);
};
const handleClose = () => {
window.shared.send('close-notification', notificationData.value?.id);
};
</script>
<style scoped>
</style>
NotificationWindow 组件
<template>
<div class="notification-window" :class="urgency">
<div class="notification-content">
<div class="notification-header">
<img v-if="icon" :src="icon" class="notification-icon" />
<span class="notification-title">{{ title }}</span>
<button class="close-btn" @click="close">×</button>
</div>
<div class="notification-body">{{ body }}</div>
<div v-if="actions && actions.length" class="notification-actions">
<button
v-for="action in actions"
:key="action.text"
@click="handleAction({ text: action.text, type: action.type })"
:class="action.type"
>
{{ action.text }}
</button>
</div>
</div>
<div class="progress-bar" :style="{ width: `${progressWidth}%` }"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { useRoute } from "vue-router";
// 从路由参数中获取数据
const route = useRoute();
const title = ref(route.query.title as string);
const body = ref(route.query.body as string);
const icon = ref(route.query.icon as string);
const urgency = ref(route.query.urgency as string);
const id = ref(route.query.id as string);
// 进度条宽度
const progressWidth = ref(100);
let progressInterval: NodeJS.Timeout | null = null;
// 解析 actions 参数
let actions = ref<Array<{ text: string; type: string }>>([]);
if (route.query.actions) {
try {
const actionsStr = route.query.actions as string;
actions.value = JSON.parse(decodeURIComponent(actionsStr));
} catch (e) {
console.error("Failed to parse actions:", e);
}
}
const close = () => {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
if (window.shared?.ipcRenderer) {
window.shared.ipcRenderer.send("close-notification", id.value);
}
};
const handleAction = (action: { text: string; type: string }) => {
const serializedAction = {
text: action.text,
type: action.type,
};
if (window.shared?.ipcRenderer) {
window.shared.ipcRenderer.send(
"notification-action",
id.value,
serializedAction
);
}
};
onMounted(() => {
console.log("通知窗口已挂载,参数:", {
title: title.value,
body: body.value,
urgency: urgency.value,
actions: actions.value,
});
// 如果不是 critical 级别,3秒后自动关闭
if (urgency.value !== "critical") {
// 设置进度条动画
const DURATION = 3000; // 3秒
const INTERVAL = 50; // 50毫秒更新一次
const STEP = (100 * INTERVAL) / DURATION;
progressInterval = setInterval(() => {
progressWidth.value -= STEP;
if (progressWidth.value <= 0) {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
close();
}
}, INTERVAL);
}
});
onUnmounted(() => {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
});
</script>
<style scoped>
.notification-window {
width: 100vw;
height: 100vh;
background: rgb(var(--v-theme-background));
color: rgb(var(--v-theme-on-surface));
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
animation: slide-in 0.3s ease-out;
}
.notification-content {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
}
.notification-header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.notification-title {
flex: 1;
font-weight: 600;
font-size: 16px;
}
.notification-body {
flex: 1;
font-size: 14px;
line-height: 1.6;
margin-bottom: 12px;
opacity: 0.9;
}
.notification-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-bottom: 8px;
}
/* Update progress bar positioning */
.progress-bar {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: #1890ff;
transition: width 0.05s linear;
}
/* Update border styles for different urgency levels */
.notification-window.critical {
border-top: 4px solid #ff4d4f;
}
.notification-window.normal {
border-top: 4px solid #1890ff;
}
.notification-window.low {
border-top: 4px solid #52c41a;
}
.notification-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.notification-icon {
width: 20px;
height: 20px;
margin-right: 8px;
}
.notification-title {
flex: 1;
font-weight: 600;
font-size: 14px;
}
.close-btn {
background: transparent;
border: none;
color: #ffffff;
font-size: 18px;
cursor: pointer;
padding: 4px;
line-height: 1;
opacity: 0.7;
transition: opacity 0.2s;
}
.close-btn:hover {
opacity: 1;
}
.notification-body {
font-size: 13px;
line-height: 1.5;
margin-bottom: 8px;
opacity: 0.9;
}
.notification-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.notification-actions button {
padding: 4px 12px;
border-radius: 4px;
border: none;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.notification-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.notification-actions button.confirm {
background: #1890ff;
}
.notification-actions button.confirm:hover {
background: #40a9ff;
}
.notification-actions button.cancel {
background: rgba(255, 255, 255, 0.1);
}
.notification-actions button.cancel:hover {
background: rgba(255, 255, 255, 0.2);
}
.notification-actions button.action {
background: #52c41a;
}
.notification-actions button.action:hover {
background: #73d13d;
}
@keyframes slide-in {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>
新代码
主进程代码
window层,管理窗口(创建窗口、构建URL 等)
import { BrowserWindow, screen } from "electron";
import path from "node:path";
import type { NotificationWindowOptions } from "@/modules/notification/types/notification";
import { MAIN_DIST, RENDERER_DIST, VITE_DEV_SERVER_URL } from "../../../main";
export class NotificationWindowManager {
private windows = new Map<string, BrowserWindow>();
private static readonly WINDOW_CONFIG = {
WIDTH: 620,
HEIGHT: 920,
MARGIN: 10,
};
constructor() {}
/**
* 创建通知窗口
*/
createWindow(options: NotificationWindowOptions): BrowserWindow {
// 如果窗口已存在,先关闭
this.closeWindow(options.id);
const position = this.calculatePosition();
const window = this.buildWindow(position);
this.setupWindowEvents(window, options.id);
this.windows.set(options.id, window);
console.log("NotificationWindowManager - Window created:", options.id);
return window;
}
/**
* 关闭指定窗口
*/
closeWindow(id: string): boolean {
const window = this.windows.get(id);
if (window && !window.isDestroyed()) {
window.close();
return true;
}
return false;
}
/**
* 获取窗口
*/
getWindow(id: string): BrowserWindow | undefined {
return this.windows.get(id);
}
/**
* 获取所有窗口
*/
getAllWindows(): Map<string, BrowserWindow> {
return new Map(this.windows);
}
/**
* 计算窗口位置
*/
private calculatePosition(): { x: number; y: number } {
const primaryDisplay = screen.getPrimaryDisplay();
const { width: screenWidth } = primaryDisplay.workAreaSize;
const x =
screenWidth -
NotificationWindowManager.WINDOW_CONFIG.WIDTH -
NotificationWindowManager.WINDOW_CONFIG.MARGIN;
const y =
NotificationWindowManager.WINDOW_CONFIG.MARGIN +
this.windows.size *
(NotificationWindowManager.WINDOW_CONFIG.HEIGHT +
NotificationWindowManager.WINDOW_CONFIG.MARGIN);
return { x, y };
}
/**
* 构建窗口
*/
private buildWindow(position: { x: number; y: number }): BrowserWindow {
return new BrowserWindow({
width: NotificationWindowManager.WINDOW_CONFIG.WIDTH,
height: NotificationWindowManager.WINDOW_CONFIG.HEIGHT,
x: position.x,
y: position.y,
frame: false,
transparent: true,
resizable: false,
skipTaskbar: true,
alwaysOnTop: true,
show: false,
backgroundColor: "#00000000",
webPreferences: {
preload: path.join(MAIN_DIST, "main_preload.mjs"),
contextIsolation: true,
nodeIntegration: true,
webSecurity: false,
},
});
}
/**
* 设置窗口事件
*/
private setupWindowEvents(window: BrowserWindow, id: string): void {
// CSP 设置
window.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline';",
],
},
});
}
);
// 窗口关闭事件
window.on("closed", () => {
this.windows.delete(id);
this.reorderWindows();
});
}
/**
* 重新排列窗口位置
*/
reorderWindows(): void {
let index = 0;
for (const [, window] of this.windows) {
if (!window.isDestroyed()) {
const y =
NotificationWindowManager.WINDOW_CONFIG.MARGIN +
index *
(NotificationWindowManager.WINDOW_CONFIG.HEIGHT +
NotificationWindowManager.WINDOW_CONFIG.MARGIN);
window.setPosition(window.getPosition()[0], y);
index++;
}
}
}
/**
* 构建通知URL
*/
buildNotificationUrl(options: NotificationWindowOptions): string {
const queryParams = new URLSearchParams({
id: options.id,
title: options.title,
body: options.body,
urgency: options.urgency || "normal",
});
if (options.icon) {
queryParams.append("icon", options.icon);
}
if (options.actions) {
queryParams.append(
"actions",
encodeURIComponent(JSON.stringify(options.actions))
);
}
return VITE_DEV_SERVER_URL
? `${VITE_DEV_SERVER_URL}#/notification?${queryParams.toString()}`
: `file://${RENDERER_DIST}/index.html#/notification?${queryParams.toString()}`;
}
}
service层
import { BrowserWindow } from "electron";
import { NotificationWindowManager } from "../windows/notification.window";
import type { NotificationWindowOptions } from "@/modules/notification/types/notification";
import type { TResponse } from "@/shared/types/response";
export interface NotificationServiceConfig {
mainWindow: BrowserWindow;
MAIN_DIST: string;
RENDERER_DIST: string;
VITE_DEV_SERVER_URL?: string;
}
/**
* 通知服务 - 统一管理通知功能
*/
class NotificationService {
private windowManagementService = new NotificationWindowManager();
constructor() {}
public async showNotification(
options: NotificationWindowOptions
): Promise<TResponse> {
try {
const window = this.windowManagementService.createWindow(options);
const url = this.windowManagementService.buildNotificationUrl(options);
// 加载 URL
await window.loadURL(url);
window.show();
return {
success: true,
message: "Notification displayed successfully",
};
} catch (error) {
console.error("NotificationService - showNotification Error:", error);
return {
success: false,
message: error instanceof Error ? error.message : "Unknown error",
};
}
}
public closeNotification(id: string): TResponse {
try {
const response = this.windowManagementService.closeWindow(id);
if (!response) {
throw new Error(
`Notification with ID ${id} not found or already closed`
);
}
return {
success: true,
message: "Notification closed successfully",
};
} catch (error) {
console.error("NotificationService - closeNotification Error:", error);
return {
success: false,
message: error instanceof Error ? error.message : "Unknown error",
};
}
}
public handleNotificationAction(
id: string,
action: { text: string; type: string }
): void {
const window = this.windowManagementService.getWindow(id);
if (window) {
if (action.type === "cancel" || action.type === "confirm") {
window.close();
}
window.webContents.send("notification-action", { id, action });
} else {
console.warn(`Notification with ID ${id} not found.`);
}
}
}
export const notificationService = new NotificationService();
ipc层
import { ipcMain } from "electron";
import { notificationService } from "../services/notification.service";
import type { NotificationWindowOptions } from "@/modules/notification/types/notification";
export function setupNotificationHandler() {
ipcMain.handle(
"show-notification",
async (_event, options: NotificationWindowOptions) => {
try {
return await notificationService.showNotification(options);
} catch (error) {
console.error("IPC Error - show-notification:", error);
throw error;
}
}
);
// 关闭通知
ipcMain.on("close-notification", (_event, id: string) => {
try {
return notificationService.closeNotification(id);
} catch (error) {
console.error("IPC Error - close-notification:", error);
}
});
// 通知操作
ipcMain.on(
"notification-action",
(_event, id: string, action: { text: string; type: string }) => {
try {
return notificationService.handleNotificationAction(id, action);
} catch (error) {
console.error("IPC Error - notification-action:", error);
}
}
);
}
渲染进程代码
ipc层
import { TResponse } from "@/shared/types/response";
import { NotificationWindowOptions } from "../types/notification";
export class NotificationIpc {
public static showNotification(
options: NotificationWindowOptions
): Promise<TResponse<void>> {
return window.shared.ipcRenderer.invoke("show-notification", options);
}
public static closeNotification(id: string): Promise<TResponse<void>> {
return window.shared.ipcRenderer.invoke("close-notification", id);
}
public static notificationAction(
id: string,
action: { text: string; type: string }
): Promise<TResponse<void>> {
return window.shared.ipcRenderer.invoke("notification-action", id, action);
}
public static onNotificationAction(
callback: (id: string, action: { text: string; type: string }) => void
): () => void {
const handler = (
_event: any,
id: string,
action: { text: string; type: string }
) => {
callback(id, action);
};
window.shared.ipcRenderer.on("notification-action-received", handler);
return () =>
window.shared.ipcRenderer.removeListener(
"notification-action-received",
handler
);
}
}
service层
import type { NotificationWindowOptions } from "../types/notification";
import type { TResponse } from "@/shared/types/response";
export class NotificationService {
private generateId(): string {
const id = `notification-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
return id;
}
/**
* 显示通知
* @param options 通知选项
* @returns Promise<TResponse>
*/
public async showNotification(
options: Omit<NotificationWindowOptions, 'id'>
): Promise<TResponse> {
const id = this.generateId(); // 为通知生成唯一ID
try {
const fullOptions: NotificationWindowOptions = {
id, // 将生成的ID添加到选项中
...options,
};
const response = await window.shared.ipcRenderer.invoke(
"show-notification",
fullOptions
);
return response;
} catch (error) {
console.error("NotificationService - showNotification Error:", error);
return {
success: false,
message: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 关闭通知
* @param id 通知ID
* @returns Promise<TResponse>
*/
public async closeNotification(id: string): Promise<TResponse> {
try {
const response = await window.shared.ipcRenderer.invoke(
"close-notification",
id
);
return response;
} catch (error) {
console.error("NotificationService - closeNotification Error:", error);
return {
success: false,
message: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* 发送通知操作
*/
public notificationAction(
id: string,
action: { text: string; type: string }
): TResponse {
try {
window.shared.ipcRenderer.send('notification-action', id, action);
return {
success: true,
message: 'Action sent successfully',
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* 监听通知操作
*/
public onNotificationAction(
callback: (id: string, action: { text: string; type: string }) => void
): () => void {
const handler = (_event: any, id: string, action: { text: string; type: string }) => {
callback(id, action);
};
window.shared.ipcRenderer.on('notification-action-received', handler);
// 返回清理函数
return () => {
window.shared.ipcRenderer.removeListener('notification-action-received', handler);
};
}
/**
* 显示简单通知
* @param title 标题
* @param message 消息内容
*/
public async showSimple(title: string, message: string): Promise<TResponse> {
return await this.showNotification({
title,
body: message,
urgency: 'normal',
});
}
/**
* 显示警告通知
* @param title 标题
* @param message 消息内容
*/
public async showWarning(title: string, message: string): Promise<TResponse> {
return await this.showNotification({
title,
body: message,
urgency: 'critical',
actions: [
{ text: '我知道了', type: 'confirm' }
]
});
}
}
export const notificationService = new NotificationService();
总结
- 渲染进程发送请求和参数
- 主进程收到请求后构造窗口、渲染路径
- 如果触发按钮,则返会通知渲染进程