首页
/ LobeHub Desktop 菜单体系实战指南:App 菜单、右键菜单与托盘菜单的配置原理

LobeHub Desktop 菜单体系实战指南:App 菜单、右键菜单与托盘菜单的配置原理

2026-09-06 12:00:09作者:霍妲思

本篇基于 LobeHub 仓库中的 Desktop 菜单配置指南 menu-config.md,系统讲解 Electron 桌面应用中三类菜单(App 菜单、右键上下文菜单、系统托盘菜单)的设计模式与配置方法,并结合 apps/desktop/src/main/menusapps/desktop/src/main/controllers 下的真实实现源码,说明每个菜单从模板定义、平台分发到 IPC 调用的完整链路。读完之后,你将能够理解 LobeHub Desktop 跨平台菜单的分发机制、托盘菜单的动态导航快照设计,以及如何为 Electron 应用配置可维护、可国际化(i18n)的菜单体系。

三类菜单的定位与选型

Desktop 端菜单体系分为三种类型,各自承担不同职责:

  1. App Menu(应用菜单):位于 macOS 的窗口顶部菜单栏,或 Windows/Linux 的标题栏,承载文件、编辑、视图等全局操作;
  2. Context Menu(右键上下文菜单):在用户右键点击内容区时弹出,根据点击对象类型(文本、链接、媒体等)动态决定可用操作;
  3. 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:osplatform() 返回值实例化对应平台的菜单类:

// 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.buildFromTemplateMenu.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 菜单等...
];

从中可以归纳出原文档四条最佳实践的实际用法:

  1. 优先使用标准 rolerole: 'services'role: 'hide'role: 'hideOthers'role: 'unhide'role: 'quit' 等由 Electron 内建处理,行为与原生应用一致,且自动适配平台惯例;
  2. 跨平台快捷键:文档建议用 CmdOrCtrl 组合保证 macOS 与 Windows/Linux 均可用;macOS 实现中对系统级偏好设置使用了 Command+, 这一更精确的写法(Electron 支持 Command 作为 Cmd 的别名);
  3. 分隔线分组{ type: 'separator' } 将「关于/更新」「偏好设置」「服务」「窗口显隐」「退出」分成视觉独立的组;
  4. 平台差异处理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);
}

即:聊天区域右键、编辑器(可编辑)右键、其他位置右键各有一套模板,模板内部再依据 isEditablemediaTypeselectionText 等字段决定具体项的启用与否——这是「基于 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(包含 pinnedagentsrecent 三个列表),把它转换成带分区标题的动态菜单:

// 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 } 作为分区头,空分区整体返回空数组,避免空标题残留;
  • 导航即广播openRouteshow() 主窗口,再 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 分组下,职责分两类:

  1. 快照与显隐的持久化updateNavigationSnapshot 把渲染进程上报的导航快照同步给 trayManager 以触发菜单重建;getAppTrayVisible / setAppTrayVisible 通过 storeManager 持久化 appTrayVisible 偏好并即时应用;
  2. Windows 平台专属能力:托盘图标更新(updateTrayIcon)、tooltip 文案更新(updateTrayTooltip)、气泡通知(showNotificationdisplayBalloon)三者在源码中均显式判断 process.platform === 'win32',非 Windows 平台直接返回 { success: false, error: 'Tray functionality is only supported on Windows platform' }。这是原文档「Handle platform differences with process.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.pinnedtray.recentAgentstray.quickChatmacOS.about 等),菜单文案与渲染进程共享同一份语言资源,切换语言后调用 refresh 重建菜单即可生效。

最佳实践清单

综合原文档建议与仓库源码验证,Electron 菜单开发的工程实践可归纳为:

实践 说明 仓库印证
使用标准 role role: 'quit''copy''hide' 等由 Electron 内建处理,行为原生且自动本地化 macOS.ts 中多处 role 项
跨平台快捷键 CmdOrCtrl 覆盖双平台;macOS 专属项(如 Command+,)仅出现在 darwin 模板 macOS.ts
分隔线分组 { type: 'separator' } 划分功能组;托盘菜单用 enabled: false 项作分区标题 trayMenu.tscreateSection
平台差异收敛 平台分支收敛到 createMenuImpl 工厂与 IPC 服务内的 process.platform 判断,不散落业务层 menus/index.tsTrayMenuCtr.ts
动态菜单带上限与折叠 动态列表裁剪(3/3/5)并以「更多」项兜底,防止托盘菜单无限增长 trayMenu.ts
菜单与窗口解耦 菜单点击只 broadcast 事件到渲染进程,不直接操作页面状态 MenuCtr.tsmacOS.ts

小结

LobeHub Desktop 的菜单体系完整实践了配置指南提出的三类菜单与四项最佳实践:以 IMenuPlatform 接口约束三平台实现、以 createMenuImpl 工厂做平台分发;以「菜单类型 + ContextMenuData」驱动上下文菜单模板;以 TrayNavigationSnapshot 快照驱动托盘菜单的动态重建,并把 Windows 专属的托盘图标/tooltip/气泡能力收敛在 TrayMenuCtr 内;全程菜单文案走 i18n.ns('menu') 命名空间翻译。若要继续深入,建议按以下路径阅读源码:menus/types.ts(接口契约)→ menus/impls/macOS.ts(最完整的平台实现)→ controllers/MenuCtr.tscontrollers/TrayMenuCtr.ts(IPC 入口),再配合 trayMenu.test.tsMenuCtr.test.ts 等测试验证各分支行为。

登录后查看全文
热门项目推荐
相关项目推荐