Odoo 19列表视图齿轮菜单自定义:扩展CogMenu菜单项完整指南
发布时间:2026/9/26 17:59:52 作者:尧图编辑部 阅读量:1,286

很多人第一次注意到 Odoo 列表页右上角那个齿轮图标CogMenu通常是客户提了一个需求想在齿轮菜单里加一个自定义操作入口。默认的齿轮菜单只有导出、导入、删除、收藏这些标准项真要往里面塞一个“专属按钮”做法就跟表单视图里放 button 完全不一样了。这篇文章从 Odoo 19 的前端机制讲起围绕“给 CogMenu 添加自定义菜单项”这条主线给出可以直接落地的代码方案并补充我实际项目中踩过的几个坑。如果你正在做 Odoo 二次开发想把列表页交互做深或者刚接触 Odoo 前端扩展这篇文章应该能帮你省下不少翻源码的时间。在动手写代码之前先把“齿轮菜单”这个东西拆开看。它是 Odoo 列表视图中列表控件右上角齿轮图标点击后弹出的下拉菜单。在前端术语里这个菜单由 ListController 统一管理所以所有齿轮菜单的定制归根结底都是在扩展 ListController。1. CogMenu 的底层机制先把原理看明白1.1 菜单项的数据从哪里来Odoo 从 16.0 开始基本完成了 OWL 组件框架的切换到了 19.0列表视图的核心依然是 ListController源码位置在web/views/list/list_controller.js。齿轮菜单里的每一项并不是后端服务端动作或 ir.actions 直接渲染出来的而是 ListController 这个 OWL 组件在渲染下拉菜单时通过一个 getter 属性cogMenuItems动态生成的。每次展开菜单前端都会读取cogMenuItems的值把数组里的每一项映射成一个菜单选项。所以给 CogMenu 添加自定义项核心就是两步扩展ListController.prototype的cogMenuItemsgetter往返回的数组里塞新项。在菜单项的callback里写点击后的处理逻辑。这两个动作基本就是整个功能的全部。cogMenuItems 返回的是一个数组数组元素有两种常见类型类型用途关键属性item普通菜单项key、description、icon、callbackseparator分隔线无特殊属性item 对象里比较常用的属性就这几个key是菜单项唯一标识description是显示在菜单里的文字icon是 FontAwesome 图标类名callback是点击后执行的函数。此外还有isEnabled、isVisible之类的可选属性用来做交互控制不同小版本之间可能有差异。举个例子Odoo 官方源码里导出菜单项的大致写法是{ type: item, key: export, description: this.env._t(Export), icon: fa-download, callback: () this.onExport(), }看到这个结构你其实就明白了自定义一个菜单项本质上跟写一个数组元素没啥区别。1.2 点击菜单项之后发生了什么每个菜单项渲染出来之后点击行为是由 ListController 内部统一下发的。它会读取当前菜单项的callback并调用同时传入一些上下文参数。callback内部可以通过this访问当前 ListController 实例这就意味着你在回调里几乎可以干任何前端能做的事情打开窗口动作、跳转视图、调用后端方法、弹对话框、发起 RPC 请求等。有一点值得注意齿轮菜单的所有状态都来自当前列表视图的搜索条件与选中记录。比如官方默认的“删除”菜单项通常只在选中了记录时才显示或可用。我们自定义菜单项时同样可以利用这套机制让菜单项在特定条件下才出现。2. 动手前的准备搭建一个最小前端模块2.1 模块目录与 manifest 配置给 CogMenu 加自定义项本质上是一个前端 JS 的扩展所以模块本身非常轻。项目结构可以是这样custom_cog_menu/ ├── __manifest__.py ├── __init__.py └── static/ └── src/ └── list/ └── cog_menu_extension.js__manifest__.py的配置如下{ name: Custom CogMenu, version: 19.0.1.0.0, category: Technical, license: LGPL-3, depends: [web], data: [], assets: { web.assets_backend: [ custom_cog_menu/static/src/list/cog_menu_extension.js, ], }, }如果你的目标模型在某个业务模块里比如 sale.order记得在depends里加上对应模块比如sale。否则模型在后端不一定存在虽然你只是加前端菜单不一定直接引用模型但保险起见建议依赖设置正确。注意assets是 Odoo 16 之后的资源声明方式。如果你以前写过 Odoo 13、14 的qweb模板资源请忘掉那套写法19.0 只认 manifest 里的assets关键词。2.2 理解 patch 方法Odoo 前端扩展里最常用的工具不是 ES6 继承而是patch。它定义在web/core/utils/patch模块中作用是直接修改目标类的原型对象。为什么不用继承因为 ListController 是由列表视图内部实例化的我们不能轻易在 XML 视图定义里指定“我要用我的 CustomListController”。如果使用 patch就能绕过实例化问题直接扩充现有类行为省去大量视图装配的麻烦。所以本文后续代码全部基于 patch 方式。一个最小可用骨架长这样/** odoo-module **/ import { patch } from web/core/utils/patch; import { ListController } from web/views/list/list_controller; patch(ListController.prototype, { get cogMenuItems() { const items super.cogMenuItems; // 在这里添加新菜单项 return items; }, });这里面的/** odoo-module **/注释是 Odoo 模块解析器要求的标记千万不能漏。漏了之后JS 文件不会被当作 ES6 模块解析import语句会直接报错。3. 核心实现给 CogMenu 添加自定义菜单项3.1 先来一个无条件添加的简单例子最简单的情况就是给所有列表视图的齿轮菜单都加一项。比如加一个菜单项点击后弹一个通知/** odoo-module **/ import { patch } from web/core/utils/patch; import { ListController } from web/views/list/list_controller; patch(ListController.prototype, { get cogMenuItems() { const items super.cogMenuItems; items.push({ type: item, key: hello_odoo, description: 你好Odoo, icon: fa-smile-o, callback: () { this.env.services.notification.add({ type: info, title: 来自 CogMenu 的问候, message: 自定义菜单项生效了, }); }, }); return items; }, });这里有几处细节值得说。第一super.cogMenuItems返回的是官方已经构建好的菜单项数组我们在此基础上 push 新项。技术上可行但我个人建议改成不可变的方式防止意外改动官方数组get cogMenuItems() { const items super.cogMenuItems; return [ ...items, { type: item, key: hello_odoo, description: 你好Odoo, icon: fa-smile-o, callback: () { this.env.services.notification.add({ type: info, title: 来自 CogMenu 的问候, message: 自定义菜单项生效了, }); }, }, ]; }第二this.env.services.notification是 Odoo 环境服务里现成的通知组件直接可以用比写死 UI 方便很多。第三super在 getter 里怎么用patch 工具在底层把当前方法伪装成原型链上的一环所以super.cogMenuItems调用的是被 patch 之前那个 getter 的逻辑。这是框架提供的兼容机制正常写就行。3.2 只给指定模型的列表视图添加菜单项如果所有模型都出现这个菜单项就太吵了。通常我们希望它只出现在某个业务模型上比如 sale.order。判断当前模型可以通过this.model.resModel拿到视图绑定的模型名patch(ListController.prototype, { get cogMenuItems() { const items super.cogMenuItems; if (this.model.resModel sale.order) { items.push({ type: item, key: sale_custom_action, description: 自定义销售操作, icon: fa-cogs, callback: () this._onCustomSaleAction(), }); } return items; }, _onCustomSaleAction() { // 具体处理逻辑写这里 }, });当然你可以用includes判断多个模型或者根据视图的上下文参数判断是否显示。根据上下文判断比较灵活因为有些项目会在 XML 视图的 context 里带自定义参数你可以用this.props.context或this.model.context读取这取决于具体版本。我自己更倾向于锁定模型名简单直接源码可读性也好。3.3 菜单项点击后打开一个窗口动作很多场景下我们并不需要在 JS 里写复杂的业务逻辑只是想点菜单项打开另一个视图或向导。此时可以直接调用doAction。Odoo 19 的环境服务this.env.services.action里有doAction方法用法跟后端act_window动作一脉相承只不过现在是在前端触发callback: () { this.env.services.action.doAction({ type: ir.actions.act_window, name: 待处理订单, res_model: sale.order, view_mode: list,form, domain: [[state, , draft]], }); },这样点击菜单项后就会跳转到销售订单列表并把 domain 条件带过去只显示草稿状态的订单。整个体验就跟用户从导航菜单点击进入视图一样自然。如果需要打开的是向导视图可以定义一个后端ir.actions.act_window把res_model指向向导模型view_mode为form再配合target: new参数弹出一个模态窗口callback: () { this.env.services.action.doAction({ type: ir.actions.act_window, res_model: sale.order.wizard, view_mode: form, target: new, }); },这是很经典的组合拳Catalog 菜单、上下文中带参数、向导联动都能这么实现。4. 进阶让菜单项根据状态与选中记录做动态控制4.1 获取当前选中记录的 ID齿轮菜单最常见的需求是“对选中的记录做点什么”。ListController 内部维护了一个model.root.selection它保存了当前选中的记录 ID 集合。拿到选中记录的方式const ids [...this.model.root.selection];需要注意selection 里的元素可能是字符串 id也可能有{id: xxx}的封装形式不同小版本的处理略有差异。最稳妥的方式是const ids [...this.model.root.selection].map((item) typeof item object ? item.id : item );这样无论是字符串还是对象都能得到干净的 ID 数组。4.2 无选中记录时菜单项置灰或隐藏Odoo 官方源码里很多菜单项在选择集为空时是不会出现的。我们可以用isEnabled或者自己控制菜单项是否 push 进去。这里推荐一种更简单的方式无条件 push但在回调开头判断选中记录数没有选中就弹提示callback: () { const ids [...this.model.root.selection]; if (!ids.length) { this.env.services.notification.add({ type: danger, title: 请先选择记录, message: 需要至少选择一条记录才能执行此操作。, }); return; } // 处理业务逻辑 }如果你希望菜单项本身变成不可点击状态可以在 item 对象里设置isEnabled: ids.length 0。这个属性的支持情况要看你当前版本的源码建议翻一下 ListController 里对cogMenuItems的消费逻辑确认后再用。4.3 在回调里调用后端 Python 方法菜单项只是个前端入口真正干活的一般还是后端 Python 方法。比如我们想在选中订单后调用模型上的一个方法做批量处理。前端代码可以这样写async _onBatchProcess() { const ids [...this.model.root.selection].map((item) typeof item object ? item.id : item ); if (!ids.length) { this.env.services.notification.add({ type: warning, title: 未选中记录, message: 请先勾选需要处理的记录。, }); return; } const { orm } this.env.services; const res await orm.call(sale.order, action_batch_process, [ids]); if (res) { this.env.services.action.doAction(res); } }this.env.services.orm是 Odoo 前端封装好的 ORM 调用工具。orm.call(sale.order, action_batch_process, [ids])等价于后端self.browse(ids).action_batch_process()省去了手拼call_kw的麻烦。对应后端方法大概这样def action_batch_process(self): self.ensure_one() # 或者对多条记录做业务处理 return { type: ir.actions.act_window, res_model: sale.order, view_mode: list,form, domain: [(id, in, self.ids)], }后端方法返回的是一个 action 时前端拿到结果直接扔给doAction即可如果后端方法只是执行操作不返回动作那前端调用完刷新列表就行await orm.call(sale.order, action_batch_process, [ids]); this.model.reload();4.4 控制菜单项的插入位置有的业务场景希望自定义菜单项出现在齿轮菜单最顶部紧挨着齿轮图标展开的第一行。cogMenuItems的数组顺序就是菜单展示顺序因此想放最上面就用unshift或者干脆手动构造新数组const items super.cogMenuItems; return [ { type: item, key: top_custom_item, description: 置顶的自定义项, icon: fa-star, callback: () {}, }, ...items, ];如果只想在某些官方项之间插入就需要分析官方数组的 key 和顺序。官方源码里导出项、导入项、删除项等在数组中的位置相对固定但不同版本顺序可能不同所以不建议硬编码 index。稳妥的做法是按 key 过滤出官方项再按自己期望的顺序重组数组。5. 完整案例在销售订单列表添加“生成报价单 PDF”菜单项5.1 需求描述一个很常见的业务场景销售人员在订单列表页勾选几个订单点齿轮菜单里的“生成报价单 PDF”系统自动把多个订单的报价单合并输出成 PDF 下载。5.2 后端模型扩展首先在 sale.order 模型上加一个方法。不直接写在 sale 模块里的话你得新建一个后端模型继承from odoo import models class SaleOrder(models.Model): _inherit sale.order def action_generate_quote_pdf(self): # 可以同时处理多条订单 report_action self.env.ref(sale.action_report_saleorder) return report_action.report_action(self)如果你希望在点击菜单后先弹出选择界面再生成 PDF那后端方法响应一个向导表单即可。这里先按最简单的直接出报表来写。5.3 前端 JS 实现完整代码如下/** odoo-module **/ import { patch } from web/core/utils/patch; import { ListController } from web/views/list/list_controller; patch(ListController.prototype, { get cogMenuItems() { const items super.cogMenuItems; if (this.model.resModel ! sale.order) { return items; } const selectedIds [...this.model.root.selection].map((item) typeof item object ? item.id : item ); return [ ...items, { type: item, key: generate_quote_pdf, description: 生成报价单 PDF, icon: fa-file-pdf-o, callback: () this._onGenerateQuotePdf(), }, ]; }, async _onGenerateQuotePdf() { const { orm, action } this.env.services; const ids [...this.model.root.selection].map((item) typeof item object ? item.id : item ); if (!ids.length) { this.env.services.notification.add({ type: warning, title: 未选中订单, message: 请先勾选需要生成报价单的订单。, }); return; } const result await orm.call(sale.order, action_generate_quote_pdf, [ids]); if (result) { action.doAction(result); } }, });整个过程是这样的用户打开销售订单列表视图组件初始化时cogMenuItems被调用。判断模型名是 sale.order于是往菜单数组里追加“生成报价单 PDF”。用户勾选记录点击菜单项_onGenerateQuotePdf被触发。前端取出选中记录 ID通过 orm 调用后端方法。后端返回报表 action前端doAction直接触发下载。实际跑下来这个流程非常顺畅。我在项目里就是这样给采购订单模块加的“打印采购单”菜单项客户反馈比在工具栏上放按钮更符合使用习惯毕竟齿轮菜单已经是个默认入口大家都会点。5.4 验证效果安装模块后进入销售订单列表视图点击右上角齿轮图标展开的菜单里就能看到“生成报价单 PDF”了。勾选订单后点击它浏览器会自动弹出 PDF 下载。如果没有勾选记录点这个菜单项会给出提示不会默默无响应。有一点要特别注意Odoo 前端资源是有缓存的。第一次写完 JS 代码如果发现菜单项没出现先不要怀疑代码逻辑先强制刷新浏览器缓存或者重启 Odoo 服务并清除 assets 缓存。开发阶段最稳妥的做法是开启开发者模式在“设置-技术-资源”里直接清缓存能省不少时间。6. 常见问题与排查技巧实录现象常见原因解决思路菜单项完全不出现JS 文件没被加载、模块未升级、缓存检查 manifest 里 assets 路径确认模块已升级强制刷新页面查看浏览器 console 是否有报错菜单项出现但点无反应callback 异常、this 指向不对、RPC 报错打开浏览器开发者工具看 console在 callback 里先console.log定位确认this.env.services是否存在报错super.cogMenuItems is not a function当前 Odoo 版本里 cogMenuItems 不是 getter 或名称变化打开当前版本list_controller.js源码搜索 cogMenuItems以源码为准调整代码菜单项在多余的视图里出现判断条件不够精确在cogMenuItems里判断this.model.resModel或根据视图 context 做更细粒度控制多个自定义模块间互相覆盖每个模块都 patch 了同一个 getter使用展开运算符而不是直接修改官方数组如果发现菜单项重复或缺失检查是否有其他模块也 patch 了同一个方法调用后端方法时提示Access Denied后端方法没有权限在模型的安全权限里给相应安全组开放调用权限或者直接用sudo()仅限可控内部场景这里再提醒一句Odoo 19 的 API 已经相对稳定但不同小版本之间仍然可能存在差异。如果你照着文章抄代码后发现不生效第一时间应该打开本地源码路径就是web/static/src/views/list/list_controller.js搜索cogMenuItems或get cogMenuItems看当前版本的 getter 到底接不接受你写的这些属性。这种做法比在网上查教程更可靠因为源码不会说谎。7. 我在项目中积累的几条实战经验说几个前面不方便穿插的个人体会。第一能用 patch 解决的就不要自定义子类。Odoo 前端框架的组件装配链路比较深如果为了加个菜单项就重写整个 ListController不仅要处理 XML 视图装配还要应对未来 Odoo 版本升级时的破坏性变化。patch 方式天然支持叠加未来升级时冲突概率小很多。第二菜单项的 key 要想清楚。key 是数组元素在 diff 阶段的重要标识多个模块如果用了重复的 key可能会互相覆盖或产生奇怪的行为。我习惯用模块名前缀比如my_module_custom_action尽量避免用custom_item这种太通用的名字。第三动态控制菜单项时要注意 getter 的重算时机。cogMenuItems是 getter不是响应式属性。假如你希望“勾选记录后菜单项从置灰变成可点击”但 getter 没有重新触发那 UI 就不会更新。这时可以选择把菜单项常驻在 callback 里做选中记录判断UI 层面反而更省心。这就是我前面推荐回调里判断ids.length的原因别在菜单项可见性上过度设计。第四后端方法一定要做权限控制。前端菜单项只是一个入口任何人都可以通过 RPC 直接调用后端方法。所以后端方法必须在模型的安全权限里配置正确或者加分组装饰器避免绕过 UI 直接操作数据。这一点项目上线前务必检查。第五多语言团队注意描述文案。菜单项里的description可以套用this.env._t(...)这样系统在切换语言时会自动翻译。别直接把中文文案写死后续维护会想哭。齿轮菜单自定义说白了就是前端数组扩展加回调处理看透之后并不神秘。你要是手头有一个列表视图的定制需求不妨先想想“这个操作是不是放在齿轮菜单里更顺”然后照着上面的思路去实现。试过几次之后你会发现 Odoo 的前端定制其实也是套路活。