ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Electron+Vue3构建跨平台桌面通知中心:从原理到实战

基于Electron+Vue3构建跨平台桌面通知中心:从原理到实战 在日常开发或运维工作中你是否也遇到过这样的困扰服务器监控告警、CI/CD构建结果、脚本执行状态等关键信息只能通过频繁刷新网页、查看邮件或登录服务器才能获取这种被动的信息获取方式不仅效率低下还容易错过重要通知。受经典游戏《环世界》中优雅、沉浸的信件系统启发我开发了一款开源的桌面告警小工具旨在将关键信息主动、美观地推送到你的桌面角落实现“信息找人”。本文将详细介绍这款工具的完整实现过程从设计思路、技术选型到代码实战手把手教你构建一个属于自己的桌面通知中心。无论你是想学习桌面应用开发、了解通知系统原理还是希望直接使用一个轻量级的告警工具本文都将为你提供一条清晰的路径。文章末尾附有完整的项目开源地址你可以直接使用、修改或参与贡献。1. 项目背景与核心设计思路1.1 灵感来源《环世界》的信件系统在模拟经营游戏《环世界》中游戏事件如袭击、贸易商到达、 colonists 心情变化会以“信件”的形式出现在屏幕右上角。这些通知非模态、不强制打断当前操作会短暂停留后自动收起并留有历史记录供玩家随时查阅。这种设计既保证了信息的及时送达又最大限度地减少了对主流程的干扰用户体验极佳。1.2 现实需求开发运维中的信息孤岛在软件开发和运维领域我们面临着类似的信息过载与被动获取问题监控告警Zabbix、Prometheus Alertmanager 产生的告警需要登录控制台查看。持续集成Jenkins、GitLab CI 的构建成功或失败结果。日志关键字服务器日志中出现的特定错误信息。定时任务结果Cron 或计划任务执行完毕后的状态报告。自定义脚本输出任何你想主动推送的结果。传统解决方案邮件、即时通讯机器人要么打扰性过强要么容易被淹没在其他信息流中。一个独立的、位于桌面层的轻量级通知中心成为了一个理想的选择。1.3 工具核心设计目标基于以上分析我们确定了桌面告警小工具的四大核心目标非侵入式以桌面角落弹窗形式展示不抢占焦点不影响当前工作。信息结构化支持标题、内容、级别成功、警告、错误等、来源等元数据。历史可追溯提供历史通知面板方便回溯。高度可定制支持自定义样式、停留时间、触发方式支持从命令行、HTTP API、配置文件等多种方式触发。2. 技术选型与环境准备2.1 技术栈决策为了实现一个跨平台、轻量级且易于扩展的桌面应用我们选择了以下技术组合前端/界面层ElectronVue 3TypeScript。Electron 允许我们使用 Web 技术构建跨平台桌面应用Vue 3 提供了响应式且高效的 UI 开发体验TypeScript 则能保障代码质量和可维护性。样式与组件Tailwind CSSHeadless UI。Tailwind CSS 的实用类优先策略能极大加速 UI 构建Headless UI 提供了完全无样式的可访问性组件方便自定义。后端/通信层Electron 的主进程Main Process负责创建系统托盘、通知窗口、注册全局快捷键、处理系统级事件。渲染进程Renderer Process负责展示UI。进程间通信IPC用于前后端数据同步。通知触发接口本地触发通过命令行参数或读取本地文件如 JSON 配置文件。网络触发在 Electron 主进程中启动一个轻量级 HTTP 服务器如使用express或koa提供 RESTful API 接收外部告警。2.2 开发环境搭建请确保你的系统已安装以下基础环境Node.js 与 npm这是运行 Electron 和 Vue 的基础。建议使用 LTS 版本。# 检查 Node.js 和 npm 版本 node --version # 推荐 v18.x 或 v20.x npm --version # 推荐 9.x 或 10.xVue CLI 或 Vite我们将使用更现代的 Vite 作为构建工具它比 Vue CLI 启动更快。# 全局安装 pnpm (推荐速度更快) 或使用 npm npm install -g pnpm # 使用 Vite 官方模板创建项目选择 vue-ts pnpm create vite my-desktop-alert --template vue-ts cd my-desktop-alert安装 Electron在项目根目录下安装 Electron 相关依赖。pnpm add -D electron electron-builder pnpm add electron-log # 用于日志记录electron-builder用于后续的项目打包。安装 UI 相关依赖pnpm add vue-router pinia # 状态管理 pnpm add -D tailwindcss postcss autoprefixer pnpm add headlessui/vue heroicons/vue # 图标与无头组件 npx tailwindcss init -p # 初始化 Tailwind CSS 配置2.3 项目结构预览创建完成后你的项目结构应大致如下my-desktop-alert/ ├── src/ │ ├── main/ # Electron 主进程代码 │ │ ├── index.ts # 主进程入口创建窗口、处理系统事件 │ │ ├── preload.ts # 预加载脚本定义安全的 IPC 暴露 API │ │ └── http-server.ts # 可选的 HTTP 通知接收服务器 │ ├── renderer/ # Vue 渲染进程代码 │ │ ├── assets/ │ │ ├── components/ # Vue 组件 │ │ │ ├── AlertNotification.vue # 单个通知组件 │ │ │ ├── AlertHistory.vue # 历史通知面板 │ │ │ └── Settings.vue # 设置面板 │ │ ├── stores/ # Pinia 状态管理 │ │ │ └── alert.ts # 管理通知状态 │ │ ├── views/ # 页面视图 │ │ ├── App.vue │ │ └── main.ts # Vue 应用入口 │ └── types/ # TypeScript 类型定义 ├── index.html # 主 HTML 文件 ├── package.json ├── vite.config.ts # Vite 配置 ├── tailwind.config.js # Tailwind 配置 └── electron-builder.yml # Electron 打包配置3. 核心模块实现详解3.1 Electron 主进程应用骨架主进程是应用的核心负责创建窗口、管理生命周期和提供系统集成。src/main/index.ts- 应用入口与窗口管理import { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain } from electron; import path from path; import { createHttpServer } from ./http-server; // 可选HTTP服务器 let mainWindow: BrowserWindow | null null; let tray: Tray | null null; // 创建浏览器窗口 function createWindow() { mainWindow new BrowserWindow({ width: 400, height: 600, x: 0, // 初始位置可后续根据屏幕尺寸调整 y: 0, frame: false, // 无边框窗口实现自定义标题栏 transparent: true, // 可实现圆角等效果 alwaysOnTop: true, // 保持在最前但可通过设置调整 skipTaskbar: true, // 不在任务栏显示 show: false, // 初始不显示由托盘图标控制 webPreferences: { preload: path.join(__dirname, preload.js), // 预加载脚本 nodeIntegration: false, // 安全考虑关闭 Node 集成 contextIsolation: true, // 开启上下文隔离 }, }); // 加载 Vue 开发服务器地址或打包后的文件 if (process.env.NODE_ENV development) { mainWindow.loadURL(http://localhost:5173); mainWindow.webContents.openDevTools(); // 打开开发者工具 } else { mainWindow.loadFile(path.join(__dirname, ../renderer/index.html)); } // 窗口显示与隐藏逻辑 mainWindow.on(blur, () { // 失去焦点时自动隐藏模仿《环世界》通知的短暂停留 // mainWindow.hide(); }); } // 创建系统托盘图标和菜单 function createTray() { const iconPath path.join(__dirname, ../../assets/icon.png); const trayIcon nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }); tray new Tray(trayIcon); const contextMenu Menu.buildFromTemplate([ { label: 显示/隐藏, click: () toggleWindow() }, { label: 历史通知, click: () showHistory() }, { type: separator }, { label: 设置, click: () showSettings() }, { type: separator }, { label: 退出, click: () app.quit() }, ]); tray.setToolTip(桌面告警小工具); tray.setContextMenu(contextMenu); tray.on(click, toggleWindow); // 点击托盘图标切换窗口显示 } function toggleWindow() { if (mainWindow?.isVisible()) { mainWindow.hide(); } else { // 显示在屏幕右上角 const { width, height } mainWindow!.getBounds(); const { width: screenWidth } require(electron).screen.getPrimaryDisplay().workAreaSize; mainWindow?.setPosition(screenWidth - width - 10, 10); mainWindow?.show(); mainWindow?.focus(); } } // 处理渲染进程通过 IPC 发送的通知请求 ipcMain.handle(show-alert, (event, alertData) { // 这里可以调用系统通知或通过 WebSocket/其他方式通知渲染进程更新UI if (mainWindow) { mainWindow.webContents.send(new-alert, alertData); } // 也可以直接调用系统通知 // new Notification({ title: alertData.title, body: alertData.content }).show(); }); app.whenReady().then(() { createWindow(); createTray(); createHttpServer(3000); // 启动 HTTP 服务器监听 3000 端口 }); app.on(window-all-closed, () { if (process.platform ! darwin) app.quit(); });3.2 预加载脚本安全暴露 API预加载脚本在渲染进程加载网页之前运行并可以访问 Node.js API。我们在这里定义渲染进程可以安全调用的方法。src/main/preload.tsimport { contextBridge, ipcRenderer } from electron; // 向渲染进程暴露一个安全的 API contextBridge.exposeInMainWorld(electronAPI, { // 渲染进程调用此方法向主进程发送通知数据 sendAlert: (alertData: any) ipcRenderer.invoke(show-alert, alertData), // 接收来自主进程的新通知 onNewAlert: (callback: (event: any, data: any) void) ipcRenderer.on(new-alert, callback), // 窗口控制 minimizeWindow: () ipcRenderer.send(window-minimize), closeWindow: () ipcRenderer.send(window-close), // 获取系统信息等 getPlatform: () process.platform, });3.3 通知数据模型与状态管理我们需要一个清晰的数据结构来定义一条通知。src/types/alert.tsexport enum AlertLevel { INFO info, // 信息蓝色 SUCCESS success, // 成功绿色 WARNING warning, // 警告黄色 ERROR error, // 错误红色 } export interface Alert { id: string; // 唯一标识可以用 uuid 或时间戳生成 title: string; content: string; level: AlertLevel; source?: string; // 来源如 Jenkins, Prometheus, Custom Script timestamp: number; // 时间戳 read: boolean; // 是否已读 duration?: number; // 自动隐藏的时长(ms)默认为 5000 }src/renderer/stores/alert.ts- Pinia 状态管理import { defineStore } from pinia; import { ref, computed } from vue; import type { Alert, AlertLevel } from ../../types/alert; import { v4 as uuidv4 } from uuid; // 需要安装 uuid 库 export const useAlertStore defineStore(alert, () { // 状态 const alerts refAlert[]([]); const maxHistory 100; // 最大历史记录条数 // Getter const unreadCount computed(() alerts.value.filter(a !a.read).length); const recentAlerts computed(() [...alerts.value].sort((a, b) b.timestamp - a.timestamp).slice(0, 20)); // Actions function addAlert(alert: OmitAlert, id | timestamp | read) { const newAlert: Alert { id: uuidv4(), timestamp: Date.now(), read: false, ...alert, }; alerts.value.unshift(newAlert); // 最新通知放在最前面 // 限制历史记录数量 if (alerts.value.length maxHistory) { alerts.value alerts.value.slice(0, maxHistory); } // 触发桌面通知可选 triggerDesktopNotification(newAlert); } function markAsRead(id: string) { const alert alerts.value.find(a a.id id); if (alert) alert.read true; } function markAllAsRead() { alerts.value.forEach(a a.read true); } function clearAll() { alerts.value []; } function triggerDesktopNotification(alert: Alert) { if (Notification in window Notification.permission granted) { new Notification(alert.title, { body: alert.content, icon: getIconByLevel(alert.level) }); } } function getIconByLevel(level: AlertLevel): string { const icons { [AlertLevel.INFO]: /assets/info.svg, [AlertLevel.SUCCESS]: /assets/success.svg, [AlertLevel.WARNING]: /assets/warning.svg, [AlertLevel.ERROR]: /assets/error.svg, }; return icons[level]; } return { alerts, unreadCount, recentAlerts, addAlert, markAsRead, markAllAsRead, clearAll }; });3.4 核心组件桌面通知弹窗这是模仿《环世界》风格的核心UI组件。src/renderer/components/AlertNotification.vuetemplate Transition enter-active-classtransform transition-all duration-300 ease-out enter-from-classtranslate-x-full opacity-0 enter-to-classtranslate-x-0 opacity-100 leave-active-classtransform transition-all duration-300 ease-in leave-from-classtranslate-x-0 opacity-100 leave-to-classtranslate-x-full opacity-0 div v-ifvisible classfixed top-4 right-4 w-80 max-w-sm rounded-lg shadow-2xl border-l-4 p-4 backdrop-blur-sm bg-white/90 dark:bg-gray-800/90 z-50 :classborderColorClass mouseenterpauseTimeout mouseleaveresumeTimeout div classflex items-start !-- 图标 -- div classflex-shrink-0 :classtextColorClass component :islevelIcon classh-5 w-5 / /div !-- 内容 -- div classml-3 w-0 flex-1 p classtext-sm font-medium text-gray-900 dark:text-gray-100 {{ alert.title }} /p p classmt-1 text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap {{ alert.content }} /p !-- 来源和时间 -- div classmt-2 flex items-center text-xs text-gray-500 dark:text-gray-400 span v-ifalert.source classinline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 {{ alert.source }} /span span classml-2{{ formattedTime }}/span /div /div !-- 关闭按钮 -- div classml-4 flex-shrink-0 flex button clickclose classinline-flex text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none XMarkIcon classh-4 w-4 / /button /div /div /div /Transition /template script setup langts import { computed, onMounted, onUnmounted, ref } from vue; import { XMarkIcon, InformationCircleIcon, CheckCircleIcon, ExclamationTriangleIcon, ExclamationCircleIcon } from heroicons/vue/24/outline; import type { Alert, AlertLevel } from ../../types/alert; const props defineProps{ alert: Alert; duration?: number; // 默认显示时长 }(); const emit defineEmits{ close: [id: string]; }(); const visible ref(true); let timeoutId: number | null null; const defaultDuration props.duration || 5000; // 根据级别确定样式 const borderColorClass computed(() { const map: RecordAlertLevel, string { [AlertLevel.INFO]: border-blue-500, [AlertLevel.SUCCESS]: border-green-500, [AlertLevel.WARNING]: border-yellow-500, [AlertLevel.ERROR]: border-red-500, }; return map[props.alert.level]; }); const textColorClass computed(() { const map: RecordAlertLevel, string { [AlertLevel.INFO]: text-blue-400, [AlertLevel.SUCCESS]: text-green-400, [AlertLevel.WARNING]: text-yellow-400, [AlertLevel.ERROR]: text-red-400, }; return map[props.alert.level]; }); const levelIcon computed(() { const map: RecordAlertLevel, any { [AlertLevel.INFO]: InformationCircleIcon, [AlertLevel.SUCCESS]: CheckCircleIcon, [AlertLevel.WARNING]: ExclamationTriangleIcon, [AlertLevel.ERROR]: ExclamationCircleIcon, }; return map[props.alert.level]; }); const formattedTime computed(() { return new Date(props.alert.timestamp).toLocaleTimeString([], { hour: 2-digit, minute: 2-digit }); }); function startTimer() { if (timeoutId) clearTimeout(timeoutId); timeoutId window.setTimeout(() { close(); }, defaultDuration); } function pauseTimeout() { if (timeoutId) { clearTimeout(timeoutId); timeoutId null; } } function resumeTimeout() { if (!timeoutId) { startTimer(); } } function close() { visible.value false; // 等待过渡动画结束后再触发关闭事件 setTimeout(() { emit(close, props.alert.id); }, 300); } onMounted(() { startTimer(); }); onUnmounted(() { if (timeoutId) clearTimeout(timeoutId); }); /script4. 通知触发方式实战工具的核心价值在于如何方便地触发通知。我们实现三种主流方式。4.1 方式一HTTP API 触发最通用在主进程中启动一个简单的 HTTP 服务器接收 POST 请求来创建通知。src/main/http-server.tsimport express from express; import { ipcMain } from electron; import type { AlertLevel } from ../types/alert; export function createHttpServer(port: number) { const app express(); app.use(express.json()); // 解析 JSON 请求体 app.post(/api/alert, (req, res) { const { title, content, level info, source, duration } req.body; // 基本验证 if (!title || !content) { return res.status(400).json({ error: Missing title or content }); } // 构造通知数据 const alertData { title, content, level: level as AlertLevel, source: source || HTTP API, duration, }; // 通过 IPC 通知渲染进程主窗口 ipcMain.emit(show-alert, { alertData }); // 或者直接发送给所有窗口 // mainWindow?.webContents.send(new-alert, alertData); console.log([HTTP Server] Alert received: ${title}); res.json({ success: true, message: Alert sent to desktop }); }); app.listen(port, () { console.log(Desktop Alert HTTP server listening on port ${port}); }); }使用示例cURLcurl -X POST http://localhost:3000/api/alert \ -H Content-Type: application/json \ -d { title: 构建成功, content: 项目 my-app 的 #123 构建已成功完成。, level: success, source: Jenkins }4.2 方式二命令行工具触发我们可以打包一个轻量级的 CLI 工具或者直接通过 Node.js 脚本调用。创建 CLI 脚本cli/send-alert.js#!/usr/bin/env node const axios require(axios); // 需要安装 axios const args process.argv.slice(2); const [title, content, level info, source CLI] args; if (!title || !content) { console.error(Usage: send-alert title content [level] [source]); console.error(Example: send-alert Test Hello World info Custom Script); process.exit(1); } axios.post(http://localhost:3000/api/alert, { title, content, level, source, }).then(response { console.log(Alert sent successfully:, response.data); }).catch(error { console.error(Failed to send alert:, error.message); });在package.json中添加bin字段即可全局安装使用。4.3 方式三读取配置文件守护进程模式对于监控日志文件或定期检查的场景可以编写一个守护进程脚本。示例脚本monitor-log.jsconst fs require(fs); const tail require(tail); // 需要安装 tail 库 const axios require(axios); // 监控一个日志文件当出现 ERROR 关键字时发送告警 const logFile /var/log/myapp/error.log; const tail new tail.Tail(logFile); tail.on(line, (line) { if (line.includes(ERROR)) { axios.post(http://localhost:3000/api/alert, { title: 应用错误日志, content: line.substring(0, 200), // 截取前200字符 level: error, source: App Log Monitor, }).catch(console.error); } }); tail.on(error, (error) { console.error(Tail error:, error); });5. 应用打包与分发开发完成后我们需要将应用打包成可执行文件。5.1 配置 Electron Builder在项目根目录创建electron-builder.yml配置文件appId: com.yourcompany.desktopalert productName: 桌面告警小工具 directories: output: dist_electron buildResources: build files: - !**/.vscode/* - !src/* - !electron-builder.yml - !{.eslintignore,.eslintrc.js,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md} - !{.env,.env.*,.npmrc,pnpm-lock.yaml} - !{tsconfig.json,tsconfig.node.json,tsconfig.web.json} - !{vue.config.js,vite.config.*} asar: true win: target: nsis icon: build/icon.ico nsis: oneClick: false allowToChangeInstallationDirectory: true createDesktopShortcut: true mac: target: dmg icon: build/icon.icns linux: target: AppImage icon: build/icon.png5.2 添加打包脚本在package.json中添加脚本{ scripts: { dev: vite, build:renderer: vue-tsc vite build, build:electron: tsc -p tsconfig.electron.json, build: npm run build:renderer npm run build:electron, electron:dev: concurrently -k \npm run dev\ \wait-on http://localhost:5173 electron .\, electron:build: npm run build electron-builder, pack: npm run build electron-builder --dir, dist: npm run build electron-builder } }5.3 执行打包命令# 打包成安装程序Windows 生成 .exe macOS 生成 .dmg Linux 生成 .AppImage npm run dist打包后的文件将输出到dist_electron目录你可以直接分发这些文件。6. 常见问题与排查思路在开发和使用过程中你可能会遇到以下问题问题现象可能原因解决思路应用启动后托盘图标不显示图标路径错误或图标文件不存在检查createTray函数中的图标路径确保图片文件在指定位置。开发时可以使用绝对路径或path.join(__dirname, ...)。通知弹窗不出现Vue 组件未正确挂载或状态未更新主进程 IPC 通信失败1. 检查渲染进程控制台是否有错误。2. 检查AlertNotification.vue组件的visible状态。3. 在主进程和预加载脚本中添加日志确认 IPC 消息是否成功传递。HTTP API 调用后无反应HTTP 服务器未启动端口被占用防火墙阻止1. 检查主进程日志确认createHttpServer已调用并监听成功。2. 使用curl或 Postman 测试http://localhost:3000/api/alert是否可达。3. 检查系统防火墙设置。打包后应用白屏渲染进程资源加载路径错误1. 确保mainWindow.loadFile路径正确指向打包后的index.html。2. 检查vite.config.ts中的base配置。3. 使用electron-builder的asar: true时确保文件包含规则正确。通知不自动隐藏setTimeout被阻塞或组件生命周期问题1. 检查AlertNotification.vue中的startTimer、pauseTimeout逻辑。2. 确认mouseenter和mouseleave事件绑定正确。应用无法开机自启未配置开机启动项使用electron的app.setLoginItemSettingsAPI 在设置中提供选项并处理不同操作系统的差异。7. 最佳实践与扩展建议7.1 安全性建议HTTP API 认证如果 HTTP 服务器暴露在局域网甚至公网务必添加简单的认证如 API Key。可以在请求头中携带X-API-Key并在服务器端验证。输入验证与清理对 HTTP API 和 CLI 传入的title、content进行基本的清理防止 XSS 攻击虽然渲染进程使用 Vue 的文本插值默认是安全的但良好的习惯很重要。最小权限原则打包时在electron-builder.yml中声明应用所需的最小系统权限。7.2 性能与体验优化通知防抖短时间内同一来源的大量通知可以合并或限制频率避免刷屏。历史记录持久化使用electron-store或lowdb将历史通知存储到本地文件应用重启后不丢失。自定义音效为不同级别的通知添加细微的音效提示增强感知。多屏幕适配在createWindow时计算窗口位置确保通知显示在当前激活的屏幕上。7.3 扩展功能方向规则引擎允许用户配置规则例如来自“Prometheus”且内容包含“CPU”的告警自动提升为“error”级别并延长显示时间。通知分组类似《环世界》将同一来源的连续通知折叠显示点击展开。丰富内容支持支持在通知内容中渲染 Markdown、或可点击的链接。更多触发方式集成 WebSocket 服务实现实时推送支持从剪贴板、指定文件夹读取文件内容作为通知。云同步通过可选账户将通知历史同步到云端实现多设备间同步。7.4 项目开源与协作本项目已完全开源你可以在 GitHub 上找到完整的源代码https://github.com/your-username/desktop-alert-tool此为示例地址实际地址请替换。欢迎通过以下方式参与直接使用克隆仓库按照 README 安装依赖并运行。问题反馈在 GitHub Issues 中提交 Bug 或功能建议。贡献代码Fork 项目修改后提交 Pull Request。分享用例如果你有有趣的集成用例如结合 Jenkins、Prometheus、自定义脚本欢迎提交到 Wiki 或 Discussions。通过这个项目我们不仅实现了一个实用的桌面工具更完整走了一遍 Electron Vue 3 的桌面应用开发流程。从灵感迸发到产品落地从技术选型到细节打磨希望这个过程能为你自己的项目带来启发。
返回列表