先说结论
Odoo 的网站菜单不是“数据库里存一棵树,前台照着渲染”这么简单。
从 /home/ubuntu/odoo-temp/addons/website/models/website_menu.py 和 tests/test_menu.py 看,website.menu 至少承担了五层职责:
- 导航树结构本身;
- 多网站场景下的默认菜单复制;
- Mega Menu 与普通菜单的结构约束;
- 按页面可见性和用户分组控制展示;
- 基于当前请求 URL 判断谁是 active 菜单。
所以理解它最好的方式不是“导航配置表”,而是:
一个把网站入口、站点隔离、可见性和当前上下文一起编码进来的导航对象。
这也是为什么很多菜单问题明明看起来像前端样式问题,最后却要回到模型层去查。
一、菜单为什么会“自动复制”到每个网站,而不是只建一条记录
website.menu.create() 有一段非常关键的逻辑:
- 如果创建菜单时没有显式
website_id; - 又不是当前上下文强制指定某个网站;
- 系统会把这条菜单复制到每个网站。
官方注释直接点明了用途:
- 模块安装时新增像
/shop这样的默认菜单; - 每个网站都应该各自有一份。
这意味着 Odoo 默认把菜单分成两类:
1. 站点特定菜单
显式带 website_id,只属于某个网站。
2. 模板式默认菜单
作为“每个网站都应有”的公共导航项,在创建时自动扩散。
这套机制很适合多网站产品线,因为:
- 公共功能入口不需要手工到每个站点再配一遍;
- 但复制出来后,各站仍然可以继续局部改名或调整顺序。
所以如果你在多网站项目里发现“为什么我只建了一次菜单,却到处都有”,别急着当成 bug,它很可能正是设计使然。
二、为什么 Odoo 不允许菜单树无限往下长
_validate_parent_menu() 明确限制了层级:
- 菜单层级不能超过两层;
- 带子菜单的菜单,不能再作为更深一层的子菜单;
- Mega Menu 不能有父也不能有子。
这背后的产品判断很鲜明:
官网导航不是后台科目树,过深层级会直接破坏可用性。
很多企业官网需求方一开始都想把:
- 一级菜单
- 二级菜单
- 三级菜单
- 四级栏目
全部塞进头部导航。Odoo 在模型层就替你踩了刹车。
这其实是好事。
因为一旦层级过深,不只是前端 hover 难做,移动端折叠、焦点导航、SEO 内链权重和用户认知都会一起变差。
三、Mega Menu 为什么被当成一种“结构特权”,而不是普通菜单皮肤
源码里 is_mega_menu 不是纯前端样式开关。
当它为真时:
- 菜单会持有
mega_menu_content; - 没内容时系统可自动渲染默认模板;
- 它不能有 parent,也不能有 child;
_compute_url()还会直接把 URL 置成#。
这说明在 Odoo 眼里,Mega Menu 的角色不是“多一层下拉菜单”,而是:
一个导航容器。
它的主要任务不是自己跳转,而是承载一整块内容区域。
所以如果你的设计是“Mega Menu 既想点进去,又想继续挂三级结构”,那就已经偏离 Odoo 预设的交互模型了。
四、菜单可见性为什么不只看菜单自己,还要看关联页面活不活着
_compute_visible() 很值得仔细看。
对外部用户来说,菜单可见性不只取决于菜单记录本身,还会继续检查:
- 如果绑的是
page_id,页面是不是is_visible; - 页面对应 view 的 visibility 是否允许当前用户访问;
- 如果绑的是
controller_page_id,对应 controller page 是否已发布、是否可访问。
也就是说,Odoo 不希望出现这种情况:
- 导航里还挂着一个链接;
- 结果用户点进去才发现页面未发布或无权限。
它更倾向于在菜单层就先把不可访问入口隐藏掉。
这点对体验非常重要,因为:
- 菜单本身就是承诺;
- 能展示出来,就意味着“你大概率可以进去”。
五、为什么 group 可见性会自动补上设计器组
write() 里还有个细节:
- 如果设置了
group_ids,系统会顺手把website.group_website_designer加进去。
这听上去很小,但很合理。
因为如果一个菜单被某些业务组限制可见,而网站设计器自己却看不到,就很难维护、预览和调试。
所以 Odoo 在这里体现的是一种后台运营友好原则:
限制前台用户不等于限制网站维护者。
六、active 菜单为什么不是简单判断“当前路径等于菜单 URL”
菜单高亮判定 _is_active() 是这份源码里最容易被低估的一段。
它处理得比常见 CMS 复杂得多:
- 会比较当前 request URL;
- 忽略锚点 fragment;
- 对 query string 采用“菜单参数必须是当前请求参数子集”的策略;
- 会做 unslug 后比较,兼容
/shop/1与/shop/my-product-1; - 如果菜单本身有子菜单,就主要看子菜单是否命中;
- 如果菜单链接到某个页面,哪怕 unslug 后相同,也要求 path 精确一致。
这套规则背后的思路很成熟:
1. 锚点不算路径身份
因为浏览器请求本来就不会把 fragment 发给服务器。
2. 查询参数会影响页面语义
如果 query string 不匹配,不能轻易算作同一个 active 页面。
3. 父菜单更像容器
只要子菜单命中,父菜单就应该亮。
4. 页面菜单要更谨慎
绑定 page_id 时,不能仅靠 unslug 宽松判定,否则容易把不同页面错高亮。
tests/test_menu.py 甚至专门覆盖了:
- 同路径不同 query;
- 带 anchor;
- 不同域名;
- 父子菜单;
- slug URL 场景。
这说明官方非常清楚 active 高亮如果做糙了,前台导航会一直给人“不稳”的感觉。
七、为什么默认菜单删除时,还会顺带删掉各网站副本
unlink() 还有一个多网站细节:
- 如果删除的是默认主菜单下的子菜单;
- 系统会把其它网站上 URL 相同的副本一起删掉。
这与 create 时的复制逻辑正好成对。
也就是说,Odoo 不是只会“帮你复制”,它还试图在删除时帮你维持模板树的一致性。
否则多网站环境下,默认菜单模板和各站副本会越来越漂移,后续很难收拾。
八、菜单问题最常见的误判是什么
误判一:导航不高亮,一定是模板 CSS 问题
很多时候其实是 _is_active() 的 URL 规则没命中。
误判二:菜单显示异常,只是前端渲染错了
不一定。可能是关联页面本身未发布、权限受限或 controller page 不可见。
误判三:多网站菜单重复是脏数据
如果那条菜单本来就是默认模板式菜单,重复可能是预期行为。
误判四:Mega Menu 只是多点 HTML
不是。它在模型层就被定义成不能再当普通可跳转树节点使用。
九、实战里怎么用 Odoo 菜单机制更稳
1. 先区分“模板导航项”和“网站专属导航项”
多网站下这一步非常重要。
2. 不要硬拗三级、四级头部菜单
如果信息量太大,宁可做专题页或 Mega Menu 内容块,也别把树越拉越深。
3. 对需要精确高亮的入口,认真设计 URL 规则
尤其是带筛选参数、slug 和多域名场景。
4. 页面权限和菜单权限一起想
用户能不能点进去,最好在菜单层就给出正确预期。
5. 把 Mega Menu 当容器,不要当普通链接
这能少掉很多交互冲突。
结语
Odoo 的网站菜单之所以值得读,不是因为它能做树,而是因为它把“导航”真正当成了网站入口治理的一部分。
它要处理:
- 多网站复制;
- 层级边界;
- 内容容器型菜单;
- 页面可见性联动;
- 当前上下文高亮。
所以 website.menu 真正代表的不是一排链接,而是:
网站当前可进入哪些入口、谁看得见、在什么上下文下应该被视为当前所在位置。
DISCUSSION
评论区