LobeHub Desktop 菜单体系实战指南:App 菜单、右键菜单与托盘菜单的配置原理
【免费下载链接】lobehub🤯 LobeHub is your Chief Agent Operator, organizing your agents into 7×24 operations by hiring, scheduling, and reporting on your entire AI team.项目地址: https://gitcode.com/GitHub_Trending/lo/lobehub
本篇基于 LobeHub 仓库中的 Desktop 菜单配置指南 menu-config.md,系统讲解 Electron 桌面应用中三类菜单(App 菜单、右键上下文菜单、系统托盘菜单)的设计模式与配置方法,并结合 apps/desktop/src/main/menus 与 apps/desktop/src/main/controllers 下的真实实现源码,说明每个菜单从模板定义、平台分发到 IPC 调用的完整链路。读完之后,你将能够理解 LobeHub Desktop 跨平台菜单的分发机制、托盘菜单的动态导航快照设计,以及如何为 Electron 应用配置可维护、可国际化(i18n)的菜单体系。
三类菜单的定位与选型
Desktop 端菜单体系分为三种类型,各自承担不同职责:
- App Menu(应用菜单):位于 macOS 的窗口顶部菜单栏,或 Windows/Linux 的标题栏,承载文件、编辑、视图等全局操作;
- Context Menu(右键上下文菜单):在用户右键点击内容区时弹出,根据点击对象类型(文本、链接、媒体等)动态决定可用操作;
- Tray Menu(托盘菜单):挂载在系统托盘图标上,提供不激活主窗口即可快速跳转会话、打开设置、退出应用的能力。
原文档建议的文件组织如下:
apps/desktop/src/main/ ├── menus/ │ ├── appMenu.ts # App menu config │ ├── contextMenu.ts # Context menu config │ └── factory.ts # Menu factory functions ├── controllers/ │ ├── MenuCtr.ts # Menu controller │ └── TrayMenuCtr.ts # Tray menu controller对照仓库实际结构,当前实现采用的是「menus(模板与平台实现)+ controllers(IPC 入口)」的清晰分层:menus/目录下按平台拆分实现(impls/子目录),控制器负责把渲染进程的 IPC 请求路由到菜单管理器。下文的所有代码事实均以仓库当前源码为准。
平台分发机制:一份接口,三套实现
菜单配置的入口是 createMenuImpl 工厂函数,它根据node:os的platform()返回值实例化对应平台的菜单类:
// apps/desktop/src/main/menus/index.ts export const createMenuImpl = (app: App): IMenuPlatform => { const currentPlatform = platform(); switch (currentPlatform) { case 'darwin': { return new MacOSMenu(app); } case 'win32': { return new WindowsMenu(app); } case 'linux': { return new LinuxMenu(app); } default: { console.warn( `Unsupported platform for menu: ${currentPlatform}, using Windows implementation as fallback.`, ); return new WindowsMenu(app); } } };这正是原文档「Best Practices」中process.platform平台差异处理的工程化落地——通过工厂函数把平台分支收敛到一处,未知平台降级为 Windows 实现并输出告警日志,而不是在业务代码里散落if (process.platform === 'darwin')判断。
三套平台实现共同遵守 IMenuPlatform 接口,对外暴露四个核心能力:
// apps/desktop/src/main/menus/types.ts export interface IMenuPlatform { /** 构建并设置应用菜单 */ buildAndSetAppMenu: (options?: MenuOptions) => Menu; /** 构建上下文菜单 */ buildContextMenu: (type: string, data?: ContextMenuData) => Menu; /** 构建托盘菜单 */ buildTrayMenu: (snapshot?: TrayNavigationSnapshot) => Menu; /** 刷新菜单 */ refresh: (options?: MenuOptions) => void; }其中MenuOptions.showDevItems控制是否显示开发相关菜单项。以 macOS 实现 为例,getAppMenuTemplate中的showDev = isDev || options?.showDevItems表明:开发环境默认可见,生产环境可通过 IPC 显式开启(对应下文setDevMenuVisibility方法)。
App 菜单配置:从模板到 setApplicationMenu
原文档给出了 App 菜单的标准配置范式——用MenuItemConstructorOptions[]描述菜单树,再通过Menu.buildFromTemplate与Menu.setApplicationMenu完成挂载:
// apps/desktop/src/main/menus/appMenu.ts import { BrowserWindow, Menu, MenuItemConstructorOptions } from 'electron'; export const createAppMenu = (win: BrowserWindow) => { const template: MenuItemConstructorOptions[] = [ { label: 'File', submenu: [ { label: 'New', accelerator: 'CmdOrCtrl+N', click: () => { /* ... */ }, }, { type: 'separator' }, { role: 'quit' }, ], }, // ... ]; return Menu.buildFromTemplate(template); }; // Register in MenuCtr.ts Menu.setApplicationMenu(menu);在仓库的真实实现中,MacOSMenu.buildAndSetAppMenu完整走通了这一范式,并且额外构建了一个 Dock 菜单:
// apps/desktop/src/main/menus/impls/macOS.ts buildAndSetAppMenu(options?: MenuOptions): Menu { const template = this.getAppMenuTemplate(options); this.appMenu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(this.appMenu); this.buildAndSetDockMenu(); return this.appMenu; }macOS 应用菜单的模板体现了几个值得注意的配置细节(节选自 macOS.ts):
const template: MenuItemConstructorOptions[] = [ { label: appName, submenu: [ { click: async () => { const mainWindow = this.app.browserManager.getMainWindow(); mainWindow.show(); mainWindow.broadcast('navigate', { path: '/settings/about' }); }, label: t('macOS.about', { appName }), }, this.getUpdateMenuItem(t), { type: 'separator' }, { accelerator: 'Command+,', click: async () => { const mainWindow = this.app.browserManager.getMainWindow(); mainWindow.show(); mainWindow.broadcast('createNewTab', { path: '/settings' }); }, label: t('macOS.preferences'), }, { type: 'separator' }, { label: t('macOS.services'), role: 'services', submenu: [] }, { type: 'separator' }, { label: t('macOS.hide', { appName }), role: 'hide' }, { label: t('macOS.hideOthers'), role: 'hideOthers' }, { label: t('macOS.unhide'), role: 'unhide' }, { type: 'separator' }, { label: t('file.quit'), role: 'quit' }, ], }, // File 菜单等... ];从中可以归纳出原文档四条最佳实践的实际用法:
- 优先使用标准 role:
role: 'services'、role: 'hide'、role: 'hideOthers'、role: 'unhide'、role: 'quit'等由 Electron 内建处理,行为与原生应用一致,且自动适配平台惯例; - 跨平台快捷键:文档建议用
CmdOrCtrl组合保证 macOS 与 Windows/Linux 均可用;macOS 实现中对系统级偏好设置使用了Command+,这一更精确的写法(Electron 支持Command作为Cmd的别名); - 分隔线分组:
{ type: 'separator' }将「关于/更新」「偏好设置」「服务」「窗口显隐」「退出」分成视觉独立的组; - 平台差异处理:
role: 'appMenu'一类的 macOS 专属项仅在 darwin 平台加入模板,在真实实现中则被createMenuImpl的工厂分发所取代。
另外,BaseMenuPlatform 抽象基类把各平台共享的复杂行为抽成了受保护方法:buildZoomMenuItem/buildZoomMenuItems(缩放菜单项,通过 ZoomService 应用缩放动作)、buildDevToolsMenuItem(独立 DevTools 窗口管理,支持打开/聚焦/关闭三态切换)、closeFocusedTabOrWindow(关闭当前标签页或窗口的通用逻辑)。这是「menus 按平台拆实现、公共能力下沉基类」这一组织方式的直接证据。
上下文菜单:按类型构建模板 + IPC 触发
原文档的上下文菜单范式非常简洁:
export const createContextMenu = () => { const template = [ { label: 'Copy', role: 'copy' }, { label: 'Paste', role: 'paste' }, ]; return Menu.buildFromTemplate(template); }; // Show on right-click const menu = createContextMenu(); menu.popup();仓库实现在此基础上扩展了「菜单类型 + 上下文数据」的双参数设计。types.ts 定义了ContextMenuData,它对齐 Electron 的ContextMenuParams语义:
export interface ContextMenuData { /** 点击位置是否可编辑(input、textarea、contenteditable) */ isEditable?: boolean; /** 右键点击链接时,该链接的 URL */ linkURL?: string; /** 右键点击媒体元素时的媒体类型 */ mediaType?: 'none' | 'image' | 'audio' | 'video' | 'canvas' | 'file' | 'plugin'; /** 选中的文本 */ selectionText?: string; /** 媒体元素的源 URL */ srcURL?: string; /** 上下文菜单坐标 */ x?: number; y?: number; }MacOSMenu.buildContextMenu根据type参数路由到不同模板:
// apps/desktop/src/main/menus/impls/macOS.ts buildContextMenu(type: string, data?: ContextMenuData): Menu { let template: MenuItemConstructorOptions[]; switch (type) { case 'chat': { template = this.getChatContextMenuTemplate(data); break; } case 'editor': { template = this.getEditorContextMenuTemplate(data); break; } default: { template = this.getDefaultContextMenuTemplate(data); } } return Menu.buildFromTemplate(template); }即:聊天区域右键、编辑器(可编辑)右键、其他位置右键各有一套模板,模板内部再依据isEditable、mediaType、selectionText等字段决定具体项的启用与否——这是「基于 role 的通用项 + 基于 data 的动态项」组合的典型写法。
触发链路位于 MenuCtr.ts。该控制器注册在menu分组下,把渲染进程的四个 IPC 请求转发给app.menuManager:
// apps/desktop/src/main/controllers/MenuCtr.ts export default class MenuController extends ControllerModule { static override readonly groupName = 'menu'; @IpcMethod() refreshAppMenu() { return this.app.menuManager.refreshMenus(); } @IpcMethod() showContextMenu(params: { data?: any; type: string }) { return this.app.menuManager.showContextMenu(params.type, params.data); } @IpcMethod() setDevMenuVisibility(visible: boolean) { return this.app.menuManager.rebuildAppMenu({ showDevItems: visible }); } @IpcMethod() popupContextMenu(params: PopupContextMenuParams): Promise<PopupContextMenuResult> { const context = getIpcContext(); const window = context ? BrowserWindow.fromWebContents(context.sender) : null; return this.app.menuManager.popupContextMenu(params, window); } @IpcMethod() closePopupContextMenu() { return this.app.menuManager.closePopupContextMenu(); } }其中popupContextMenu通过getIpcContext()拿到发送方webContents,再经BrowserWindow.fromWebContents反查窗口后交给菜单管理器,保证弹层式上下文菜单与发起它的窗口严格关联。对应的控制器级测试见 MenuCtr.test.ts。
托盘菜单:动态导航快照 + 平台受限的图标控制
原文档给出的托盘菜单最小实现是:
// TrayMenuCtr.ts this.tray = new Tray(trayIconPath); const contextMenu = Menu.buildFromTemplate([ { label: 'Show Window', click: this.showMainWindow }, { type: 'separator' }, { label: 'Quit', click: () => app.quit() }, ]); this.tray.setContextMenu(contextMenu);LobeHub 的真实实现远比「显示窗口/退出」两项丰富。buildTrayMenuTemplate 接收渲染进程上报的TrayNavigationSnapshot(包含pinned、agents、recent三个列表),把它转换成带分区标题的动态菜单:
// apps/desktop/src/main/menus/trayMenu.ts const PINNED_LIMIT = 3; const RECENT_AGENT_LIMIT = 3; const RECENT_LIMIT = 5; const openRoute = (app: App, path: string) => { const mainWindow = app.browserManager.getMainWindow(); mainWindow.show(); mainWindow.broadcast('navigate', { escape: true, path }); }; const createSection = ( label: string, items: MenuItemConstructorOptions[], ): MenuItemConstructorOptions[] => items.length > 0 ? [{ enabled: false, label }, ...items, { type: 'separator' }] : [];几个设计细节值得拆解:
- 数量上限裁剪:置顶项最多 3 条(
PINNED_LIMIT)、最近 Agent 最多 3 条(RECENT_AGENT_LIMIT)、最近会话最多 5 条(RECENT_LIMIT),超出部分折叠为一个「更多」项(t('tray.moreAgents')/t('tray.more')),点击后通过broadcast('openAllAgents')/broadcast('openRecentlyViewed')让主窗口自行展开完整列表; - 分区标题用禁用项实现:
createSection用{ enabled: false, label }作为分区头,空分区整体返回空数组,避免空标题残留; - 导航即广播:
openRoute先show()主窗口,再broadcast('navigate', ...)把路由请求广播给渲染进程,菜单本身不直接操纵 DOM,职责边界清晰; - 快捷入口:菜单尾部固定提供屏幕截图迷你工具条(快捷键
Alt+Shift+Space)、快速聊天弹窗(openQuickChatPopup)、新建对话(createNewTopic)、打开应用、设置、退出(role: 'quit')等项。
模板组装完成后,由平台类负责构建,例如 macOS.ts:
buildTrayMenu(snapshot: TrayNavigationSnapshot = { agents: [], pinned: [], recent: [] }): Menu { const template = buildTrayMenuTemplate(this.app, snapshot); this.trayMenu = Menu.buildFromTemplate(template); return this.trayMenu; }默认参数{ agents: [], pinned: [], recent: [] }保证快照缺失时仍能构建出一份只含固定入口项的可用菜单,属于防御式默认值设计。模板逻辑的单测覆盖见 trayMenu.test.ts。
与托盘相关的控制器 TrayMenuCtr.ts 注册在tray分组下,职责分两类:
- 快照与显隐的持久化:
updateNavigationSnapshot把渲染进程上报的导航快照同步给trayManager以触发菜单重建;getAppTrayVisible/setAppTrayVisible通过storeManager持久化appTrayVisible偏好并即时应用; - Windows 平台专属能力:托盘图标更新(
updateTrayIcon)、tooltip 文案更新(updateTrayTooltip)、气泡通知(showNotification,displayBalloon)三者在源码中均显式判断process.platform === 'win32',非 Windows 平台直接返回{ success: false, error: 'Tray functionality is only supported on Windows platform' }。这是原文档「Handle platform differences withprocess.platform」这一最佳实践在 IPC 服务层的直接体现,测试见 TrayMenuCtr.test.ts。
i18n 支持:命名空间翻译函数注入模板
原文档的 i18n 范式是把翻译函数引入菜单模板:
import { i18n } from '../locales'; const template = [ { label: i18n.t('menu.file'), submenu: [{ label: i18n.t('menu.new'), click: createNew }], }, ];真实实现中使用的是带命名空间的翻译函数,在每个平台菜单类内部创建:
// apps/desktop/src/main/menus/impls/macOS.ts(getAppMenuTemplate 内) const t = this.app.i18n.ns('menu'); // 用法:t('file.quit')、t('macOS.preferences')、t('tray.open', { appName })app.i18n.ns('menu')从应用基础设施(I18nManager)取出绑定menu命名空间的翻译函数,支持变量插值(如t('tray.open', { appName })把应用名注入文案)。由于托盘菜单、macOS 应用菜单等所有模板共用同一套 key(tray.pinned、tray.recentAgents、tray.quickChat、macOS.about等),菜单文案与渲染进程共享同一份语言资源,切换语言后调用refresh重建菜单即可生效。
最佳实践清单
综合原文档建议与仓库源码验证,Electron 菜单开发的工程实践可归纳为:
| 实践 | 说明 | 仓库印证 |
|---|---|---|
| 使用标准 role | role: 'quit'、'copy'、'hide'等由 Electron 内建处理,行为原生且自动本地化 | macOS.ts 中多处 role 项 |
| 跨平台快捷键 | 用CmdOrCtrl覆盖双平台;macOS 专属项(如Command+,)仅出现在 darwin 模板 | macOS.ts |
| 分隔线分组 | { type: 'separator' }划分功能组;托盘菜单用enabled: false项作分区标题 | trayMenu.ts 的createSection |
| 平台差异收敛 | 平台分支收敛到createMenuImpl工厂与 IPC 服务内的process.platform判断,不散落业务层 | menus/index.ts、TrayMenuCtr.ts |
| 动态菜单带上限与折叠 | 动态列表裁剪(3/3/5)并以「更多」项兜底,防止托盘菜单无限增长 | trayMenu.ts |
| 菜单与窗口解耦 | 菜单点击只broadcast事件到渲染进程,不直接操作页面状态 | MenuCtr.ts、macOS.ts |
小结
LobeHub Desktop 的菜单体系完整实践了配置指南提出的三类菜单与四项最佳实践:以IMenuPlatform接口约束三平台实现、以createMenuImpl工厂做平台分发;以「菜单类型 +ContextMenuData」驱动上下文菜单模板;以TrayNavigationSnapshot快照驱动托盘菜单的动态重建,并把 Windows 专属的托盘图标/tooltip/气泡能力收敛在TrayMenuCtr内;全程菜单文案走i18n.ns('menu')命名空间翻译。若要继续深入,建议按以下路径阅读源码:menus/types.ts(接口契约)→ menus/impls/macOS.ts(最完整的平台实现)→ controllers/MenuCtr.ts 与 controllers/TrayMenuCtr.ts(IPC 入口),再配合 trayMenu.test.ts、MenuCtr.test.ts 等测试验证各分支行为。
【免费下载链接】lobehub🤯 LobeHub is your Chief Agent Operator, organizing your agents into 7×24 operations by hiring, scheduling, and reporting on your entire AI team.项目地址: https://gitcode.com/GitHub_Trending/lo/lobehub
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考