☰
Odoo17自定义仪表盘开发:基于Owl组件与read_group构建销售驾驶舱
2026/10/15 7:27:21 网站建设 项目流程

简介:Odoo17 自定义仪表盘开发示例源码,面向具备一定 Odoo 基础的模块开发者,解决标准看板无法覆盖的个性化数据聚合、图表交互与页面布局需求。压缩包内共 25 个文件,其中 9 个 JS 负责前端交互与组件逻辑,6 个 XML 定义菜单、视图与资源声明,4 个 Python 文件处理控制器和模型,另有 SCSS 样式、Readme 说明与演示图片,整体仅 16KB,体量轻、依赖少。源码按 Odoo 规范组织,包含 manifest 清单、controllers 扩展点、static 静态资源目录及 views 布局文件,可清晰看到自定义仪表盘从数据模型到前端渲染的完整链路。已有 254 人学习下载,适合需要二次开发仪表盘、构建数据看板或参考模块脚手架的技术人员,直接移植其中 JS 加载和视图继承写法,能显著降低踩坑成本。

1. Odoo17 自定义仪表盘开发:从默认看板到自己掌控的销售驾驶舱

很多团队在 Odoo 17 里待久了都会遇到同一个瓶颈:系统自带的仪表盘只能堆预设的看板块和图表,想按销售员、产品类目、订单状态交叉筛选时,社区版基本没得玩,企业版又要额外采购。我去年做某公司内部订单分析项目时,就被这个限制卡了两个星期,后来干脆脱离内置仪表盘,直接在 Odoo 17 里自建了一整套数据服务层加前端 Owl 组件,效果比预想的好很多。这份源码包的核心就是这套「后端模型提供聚合数据、前端组件接管渲染」的完整套路,很适合做管理驾驶舱、运营看板,又不想为 BI 工具单独付费的团队参考。新手能照着搭,熟手可以直接拿来做脚手架。

2. 数据侧先行:AbstractModel 聚合接口与 read_group 在 Odoo17 的变化

2.1 为什么不直接查数据库而要建一个独立的数据服务层

做仪表盘时最常见的坏习惯是绕过 ORM,直接在 Python 里写 SQL 连库查询。这种方案在验证阶段很爽,但一旦上了生产环境就会暴露出三件事:第一,Odoo 的权限规则和公司隔离规则全被绕过了,结果就是同一个仪表盘,不同用户看到的数字可能还带着别的公司的订单,领导层看到这种数据基本会当众宣判项目死刑;第二,字段变更校验、翻译值回退、多语言展示这些机制全部失效;第三,前端拿到的数据结构参差不齐,得单独写一堆序列化逻辑,维护成本翻倍。我一般会建一个AbstractModel作为数据服务层,对外只暴露方法,内部统一处理 domain、聚合和格式,前端拿到的永远是干净规整的 JSON。这样做的另外一个好处是:后端逻辑可以被定时任务、测试脚本、甚至另一个模块复用,不会出现仪表盘页面关了业务逻辑就没法调用的窘境。

以销售订单为例,我们常常需要三类数据:顶部 KPI、趋势图、按产品分组的排行。这三类数据有一个共通特点——都是对sale.order做聚合,而不是取某一条记录。

# -*- coding: utf-8 -*- from odoo import models, api class SalesDashboardData(models.AbstractModel): _name = 'sales.dashboard.data' _description = '销售仪表盘数据服务层' @api.model def get_kpi(self, start_date=None, end_date=None): """返回订单数、成交金额、平均客单价""" domain = [('state', 'in', ['sale', 'done'])] if start_date: domain.append(('date_order', '>=', start_date)) if end_date: domain.append(('date_order', '<', end_date)) orders = self.env['sale.order'].search(domain) total = sum(orders.mapped('amount_total')) return { 'order_count': len(orders), 'total_amount': round(float(total), 2), 'avg_amount': round(float(total) / len(orders), 2) if orders else 0.0, } @api.model def get_trend(self, start_date=None, end_date=None, step='day'): """按日/月聚合订单金额,前端画趋势图用""" group_by = 'date_order:day' if step == 'month': group_by = 'date_order:month' domain = [('state', 'in', ['sale', 'done'])] if start_date: domain.append(('date_order', '>=', start_date)) if end_date: domain.append(('date_order', '<', end_date)) data = self.env['sale.order'].read_group( domain, fields=['amount_total'], groupby=[group_by], lazy=False, ) result = [] for item in data: result.append({ 'date': item.get('date_order'), 'amount': round(float(item.get('amount_total') or 0), 2), }) return result

这段代码有两个关键点。第一是_name = 'sales.dashboard.data'而不是继承models.Model,AbstractModel不落数据库表,也没有create/write/unlink这类方法,纯粹为了提供可调用的服务,非常适合这种只读聚合的场景。第二是每个方法都加了@api.model装饰器,避免在调用时被 Odoo 当作「在单个记录上执行」而产生上下文环境报错。get_kpi用search拿到订单记录集,再用mapped('amount_total')取出金额求总和,整个过程都在 ORM 层完成,权限规则天然生效。

2.2 read_group 在 Odoo17 的 group_by 参数与返回结构

get_trend中用到的read_group是 Odoo 里最接近 SQL 分组查询的 API,但 Odoo 17 里它的行为比旧版严格了不少,很多从 14、15 迁移过来的开发者第一次跑就翻车。在 Odoo 17 中groupby参数必须传一个字符串列表,不能再传字典;而且时间字段必须显式指定聚合粒度,写成date_order:day或date_order:month,如果你只写date_order,Odoo 会按「天」做默认分组,导致几十天的数据返回几十行,前端再聪明也没法给你画出一条合理的趋势线。

另外要注意返回值结构。read_group返回的是一个列表,每个元素是一个字典,里面除了你声明的fields之外,还会额外带出__domain和__context两个隐藏键。这两个键在调试时很有用,能看清这次聚合的过滤条件,但在前端序列化时经常闯祸——你如果直接把这个返回值丢给前端,然后写JSON.stringify,序列化后的体积会大幅膨胀,而且__context里还可能带上环境上下文对象,在某些浏览器的存储限制下会直接报错。所以我通常在服务端就把这两个键过滤掉,只保留外来真正需要的字段,这也是上面代码里用item.get('date_order')而不是直接返回item的原因。

顺带说一下lazy=False这个参数。默认行为下,read_group不会一次性返回所有层级的分组,而是把第一层分组结果留在外面,子组纪律再通过__domain取。设成False后,聚合是按你给的groupby列表一次性展开,返回的 list 里每行就是一组数据,前端拿到的结构更扁平,省去递归拼装的麻烦。这个参数在数据量不大、分组层级就一层的时候性价比非常高。

参数Odoo17 行为常见坑
groupby必须是字符串列表传字典会直接抛出 ValueError
fields需要聚合的字段只读字段没在 fields 里声明,聚合结果就是空的
lazy建议设为 False默认 True 会多一层分组嵌套
offset/limit分页用的不传时自动聚合全表,小心数据量
返回键自带 __domain、__context直接返回前端会导致序列化爆炸

3. 前端组件化:Owl 组件生命周期、registry 注册与 QWeb 模板

3.1 从 action service 到 client action:仪表盘入口到底设在哪

玩过 Odoo 前端的人知道,Owl 组件框架的核心原则是「组件只负责渲染,路由和菜单都交给 registry」。Odoo 17 里想要一个自定义菜单,点开之后直接渲染你的仪表盘,不走系统默认列表或看板,正确姿势是注册一个 client action,然后把菜单指向这个 action。

很多没用过的人会误以为自定义页面需要继承原生的仪表盘视图,或者去 patchweb.dashboard组件,其实那属于「给浏览器做外科手术」,既脆弱又难维护。常见做法是新建一个ir.actions.client,它的tag字段填你自定义组件的注册名,前端拿到这个 action 后,会去registry.category("actions")里找对应的 Owl 组件并挂载。这个机制独立、可插拔,而且天然支持菜单权限、分组显示,跟系统对话框、向导这些机制完全解耦。

<record id="action_sales_dashboard" model="ir.actions.act_window"> <field name="name">销售驾驶舱</field> <field name="type">ir.actions.client</field> <field name="tag">sales_dashboard_action</field> <field name="binding_model_id" ref="sale.model_sale_order"/> </record> <menuitem id="menu_sales_dashboard" name="销售驾驶舱" parent="sale.menu_sale_root" action="action_sales_dashboard" sequence="50"/>

这里最关键的一行是<field name="tag">sales_dashboard_action</field>,它必须与 JS 侧registry.category("actions").add()的第一个参数完全一致。很多白屏问题都出在这个名字对不上,而且 Odoo 的报错经常只显示「Action not found」,系统不会告诉你「你多打了个下划线」。

3.2 Owl 组件渲染仪表盘的骨架与生命周期钩子

Odoo 17 的仪表盘前端,本质上就是一个标准 Owl 组件。你需要关心的生命周期主要有三个:setup、onWillStart、onWillUpdateProps。setup中初始化响应式状态,onWillStart做首次数据加载,后续如果父组件传了新 props 进来,才会触发onWillUpdateProps。

基于上一节的数据接口,前端组件骨架可以写成这样:

/** @odoo-module **/ import { Component, onWillStart, useState } from "@odoo/owl"; import { registry } from "@web/core/registry"; class SalesDashboard extends Component { static template = "sales_dashboard.SalesDashboard"; setup() { this.state = useState({ loading: true, error: false, kpi: { order_count: 0, total_amount: 0, avg_amount: 0 }, trend: [], startDate: null, endDate: null, }); this.orm = this.env.services.orm; onWillStart(async () => { await this._loadData(); }); } async _loadData() { this.state.loading = true; this.state.error = false; try { const kpi = await this.orm.call( "sales.dashboard.data", "get_kpi", [[], this.state.startDate, this.state.endDate] ); const trend = await this.orm.call( "sales.dashboard.data", "get_trend", [[], this.state.startDate, this.state.endDate, "day"] ); this.state.kpi = kpi; this.state.trend = trend; } catch (e) { this.state.error = e.message; } finally { this.state.loading = false; } } } registry.category("actions").add("sales_dashboard_action", SalesDashboard);

这个代码块把数据加载逻辑放在了_loadData里,首次加载由onWillStart触发,后续刷新可以直接复用,不会出现「换了个日期范围却忘了更新 KPI」这种前后端不同步的问题。orm.call的第一个参数是模型名,第二个是方法名,第三个是一个数组——数组里第一位是 ids,后面依次是位置参数。所以这里写成[[], this.state.startDate, this.state.endDate],对应 Python 侧的方法签名get_kpi(self, start_date=None, end_date=None)。

这里有个容易踩的细节:服务方法本身是@api.model,不需要具体的 ids,但orm.call的接口约定必须有 ids 这一位,你传一个空数组即可。很多初学者把空数组漏掉,结果方法签名错位,start_date收到了一个[],后端直接报类型错误。

3.3 把后端聚合结果揉进 QWeb 模板

数据拉了回来,接下来就得渲染。Owl 组件可以通过static template = "sales_dashboard.SalesDashboard"指定一个 QWeb 模板,模板和组件同名,职责单一,不需要在 JS 里写一大串 DOM 操作。

<?xml version="1.0" encoding="utf-8"?> <odoo> <templates xml:space="preserve"> <t t-name="sales_dashboard.SalesDashboard"> <div class="container py-3"> <div class="row g-3"> <div class="col-4"> <div class="card h-100"> <div class="card-body text-center"> <h5 class="text-muted">订单数</h5> <p class="h2" t-esc="state.kpi.order_count"/> </div> </div> </div> <div class="col-4"> <div class="card h-100"> <div class="card-body text-center"> <h5 class="text-muted">成交金额</h5> <p class="h2" t-esc="state.kpi.total_amount"/> </div> </div> </div> <div class="col-4"> <div class="card h-100"> <div class="card-body text-center"> <h5 class="text-muted">平均客单价</h5> <p class="h2" t-esc="state.kpi.avg_amount"/> </div> </div> </div> </div> <div class="card mt-3"> <div class="card-body"> <table class="table table-sm table-striped mb-0"> <thead> <tr><th>日期</th><th>成交金额</th></tr> </thead> <tbody> <t t-foreach="state.trend" t-as="row" t-key="row.date"> <tr> <td t-esc="row.date"/> <td t-esc="row.amount"/> </tr> </t> <tr t-if="!state.trend.length"> <td colspan="2" class="text-center text-muted py-3">暂无数据</td> </tr> </tbody> </table> </div> </div> <p t-if="state.error" class="text-danger mt-2" t-esc="state.error"/> <div t-if="state.loading" class="text-center mt-3">加载中...</div> </div> </t> </templates> </odoo>

模板里的t-esc会自动做 HTML 转义,数值直接渲染成字符串。在金额展示上,我习惯在后端先转成 float,避免前端出现Decimal("1234.56")这种非标准 JSON 结构的对象。空数据不用怕,用t-if="!state.trend.length"渲染一行「暂无数据」,比前端 JS 里堆if (trend.length === 0)更直观。这套模板风格沿用 Bootstrap 类,Odoo 17 的 web 库自带 Bootstrap 5,不用额外引入样式框架。

4. 完整落地:从空目录到一个能跑的 Odoo17 自定义仪表盘

4.1 目录结构与准备工作

一个 Odoo 17 模块的标准结构,不只是放 Python 文件那么简单,它需要__manifest__.py声明依赖、数据文件和前端资源,还需要把 JS、XML 模板放到static/目录下面让 web 框架能找到。以这份源码包为例,推荐目录骨架如下:

sales_dashboard/ ├── __init__.py ├── __manifest__.py ├── models/ │ ├── __init__.py │ └── sales_dashboard_data.py ├── security/ │ └── ir.model.access.csv ├── views/ │ ├── dashboard_views.xml │ └── dashboard_templates.xml └── static/ └── src/ └── js/ └── dashboard.js

__init__.py里要做两件事:导入模型包,以及让 Python 识别models子包。__manifest__.py是整个模块的说明书,assets字段声明了前端资源要注入到 web 框架的哪个 bundle 中。

# __manifest__.py { 'name': 'Sales Dashboard', 'version': '17.0.1.0.0', 'category': 'Sales', 'summary': 'Custom dashboard for sales order analysis', 'depends': ['sale', 'web'], 'data': [ 'security/ir.model.access.csv', 'views/dashboard_views.xml', 'views/dashboard_templates.xml', ], 'assets': { 'web.assets_backend': [ 'sales_dashboard/static/src/js/dashboard.js', ], }, 'installable': True, 'application': True, }

depends里的sale是关键,因为我们的数据源是销售订单,如果模块安装环境里没有销售模块,模型导入会直接失败。在assets里把dashboard.js挂到web.assets_backend,这样登录后台后,浏览器会自动加载这个脚本。这个注入是 Odoo 17 推荐的资源管理方式,比旧版的qweb模板声明清爽很多。

4.2 从 Python 模型到数据库权限

模型层的AbstractModel不需要数据库表,但依然需要访问权限记录。很多人在这一步栽跟头:界面白屏、后端日志报AccessError,就是因为漏掉了ir.model.access.csv。AbstractModel虽然没有物理表,但 Odoo 的权限框架仍然会对模型的调用做校验,不配置权限的话,非管理员用户做 RPC 调用会直接被拒。

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink sales_dashboard_access,sales.dashboard.data,model_sales_dashboard_data,base.group_user,1,0,0,0

model_sales_dashboard_data是模型sales.dashboard.data的自动生成 external id,前面的model_前缀是 Odoo 约定俗成的规则;base.group_user表示所有内部用户可读,因为我们只开放只读权限,所以perm_write、perm_create、perm_unlink全部填 0,这样即使有人通过 RPC 构造改写请求,也会被权限层拦截住。加了这行 CSV 之后,普通用户打开仪表盘就不会再碰到AccessError: Sorry, you are not allowed to access this document了。

4.3 视图 XML 与前端资源的关联关系

views/dashboard_views.xml里放的是菜单和 action,views/dashboard_templates.xml里放的是 QWeb 模板。这里有个容易被忽视的细节:QWeb 模板文件必须像普通视图一样在__manifest__.py的 data 列表里注册,否则 Odoo 后端根本不知道这个模板的存在。注册顺序也很重要,通常让模板文件在视图文件之后加载,这样 action 引用 tag 时,对应的 JS 和模板都已就绪。

在开发模式里,改完 JS 之后需要刷新浏览器缓存;改完 XML 模板之后,只刷新浏览器还不够,得重启 Odoo 服务并升级模块,因为模板是在服务端预编译之后随响应下发给前端的。我通常的做法是先python odoo-bin -d mydb -u sales_dashboard --stop-after-init升级模块,再去浏览器里硬刷新(Ctrl+Shift+R),两个动作配合才能看到最新的前端效果。

cd /path/to/odoo python odoo-bin -d mydb -u sales_dashboard --stop-after-init

如果是在已有数据库里调试,升级模块时会检测到新增的菜单、action 和权限记录,自动写入数据库。你如果发现菜单没出现,优先检查__manifest__.py里 data 文件路径是否写错,其次检查模块是否真的升级成功了,看日志输出里有没有Modules loaded.记录。

4.4 安装顺序与常见报错定位

安装时遇到最多的报错就两类。第一类是Module "sales_dashboard" is not installed,这是因为依赖没满足,检查环境里有没有sale模块,或者在depends里少写了某个依赖;第二类是ValueError: External ID not found: sales_dashboard.sale_dashboard_menu,这通常是在 XML 里 reference 了一个并不存在的 record,检查外键 ID 的前缀有没有写错。这两类报错在日志里都很好定位,难的是那种「报错不给你具体行号」的情况,我一般会分两步排查:先升级空模块看基础结构是否正常,再逐个注释掉 XML 文件里的 record 块,二分法定位到具体是哪段数据出了问题。

5. 避坑专题:Odoo17 仪表盘开发里最常见的五个翻车现场

5.1 白屏 + 控制台报 “Action not found”

现象:菜单点开之后,页面区域一片空白,开发者工具里报错信息是Action not found: sales_dashboard_action。原因:前端registry.category("actions")注册的 tag 名,与 XML 里<field name="tag">的值不一致。哪怕只差一个下划线,Odoo 也认不出来。解决:先把 XML 里的 tag 值复制到剪贴板,再回 JS 代码里对比字符串,确认一模一样后重启服务、升级模块,然后硬刷新浏览器。划重点:我遇到过 tag 值在 XML 里被写成sales_dashboard_action2,跟 JS 里注册名sales_dashboard_action就差一个字符,排查了整整一个下午。

5.2 金额展示变成科学计数法

现象:KPI 卡片上显示1.234568e+6,看着像 bug,但底账数字明明是对的。原因:后端amount_total是Decimal类型,JSON 序列化时 Python 侧将其转成了字符串或者科学计数法表示。解决:在后端返回前用float()包一层,或者在orm.call返回后前端用parseFloat()强转。我建议在后端统一处理,避免每个前端调用点都要做防御式转换。上面代码里get_kpi中round(float(total), 2)就是为了杜绝这种玄学问题。

5.3 read_group 返回的 dict 里出现 __domain 和 __context

现象:前端遍历trend列表时,明明没定义__domain字段,打印对象却发现多出了两个键,而且JSON.stringify出来的字符串长度翻倍。原因:这是read_group的默认行为,返回的每个分组元素都带有__domain、__context两个附加键,用于支持后续下钻操作。解决:在 Python 侧组装返回数据时,只用item.get('date_order')、item.get('amount_total')这类显式取值方式,不要直接把整份data返回给前端。从那天起,我要求团队里任何人调用read_group都必须在服务端做一次字段裁剪,谁都不许偷懒。

5.4 KPI 卡片迟迟不刷新,但控制台没报错

现象:调用_loadData()之后,页面上的数字仍然是旧的,network 面板明明能看到成功的 RPC 响应。原因:this.state.kpi = kpi这行代码执行了,但state对象里的字段在模板中没有被正确响应,多半是你在setup里把状态变量写成了普通对象,而不是用useState包装。解决:看一下setup()里是否真的写了this.state = useState({...}),如果没有,前面所有对this.state.xxx的赋值都不会触发渲染。注意:useState只对对象深层属性做响应式代理,你如果给整个this.state重新赋一个新对象,模板同样可能不刷新,要写Object.assign(this.state, {kpi, trend})。

5.5 普通用户打开仪表盘报权限错误

现象:管理员账号一切正常,切换到普通内部用户后,打开仪表盘直接弹错误对话框,日志里出现AccessError。原因:忘记给sales.dashboard.data模型配置ir.model.access权限记录。原因:很多人误以为AbstractModel不需要权限,其实所有通过 RPC 调用的模型都要过权限校验。解决:在security/ir.model.access.csv里加上base.group_user的行,并给perm_read设为 1。权限粒度建议只开读,不开写,因为这类数据服务层理论上根本不需要create/write/unlink。

6. 进阶玩法:给仪表盘加热筛选联动与防抖缓存,让大订单量数据一秒出图

6.1 时间范围筛选联动

基础版的销售驾驶舱只有静态展示,但真正用得舒服的仪表盘需要交互。最常见的交互就是日期范围筛选:前端放两个日期输入框,用户选完起始日期和截止日期后,KPI 卡片和趋势表都要跟着变化。实现方式是给_loadData方法加两个参数,并在输入框的change事件里重新拉取数据。

import { useState } from "@odoo/owl"; class SalesDashboard extends Component { static template = "sales_dashboard.SalesDashboard"; setup() { this.state = useState({ startDate: null, endDate: null, kpi: null, trend: [], loading: false, }); this.orm = this.env.services.orm; } async _loadData() { if (this.state.loading) return; this.state.loading = true; const args = [[], this.state.startDate, this.state.endDate]; try { const kpi = await this.orm.call("sales.dashboard.data", "get_kpi", args); const trend = await this.orm.call("sales.dashboard.data", "get_trend", args.concat("day")); Object.assign(this.state, { kpi, trend }); } catch (e) { console.error(e); } finally { this.state.loading = false; } } async _onDateChange() { await this._loadData(); } }

筛选联动有两个容易忽略的细节。第一个是防抖:用户连续点击两个日期控件时,change事件被触发的频率很高,如果每次触发都立刻发 RPC,数据库可能会被打爆。常见做法是加一个 200ms 的定时器,或者用lodash的debounce,等用户停下来了再发请求。第二个是日期边界:后端 domain 用的>=和<语义不一致,如果用户选「2024-01-01 到 2024-01-31」,endDate在界面上显示的是当天 00:00:00,而你后端写的是< end_date,那么 1 月 31 日白天的订单都会被丢掉。我一般在前端把结束日期加一天,或者在后端把<改成<=并把时间部分做成当天结束的一瞬间。血泪经验,很多人在这块吃过亏。

6.2 加一层轻量缓存与防抖

对于订单量特别大的数据库,每次打开仪表盘都实时跑全表聚合,性能会很差。其实对于「近 7 天」「近 30 天」这种常用区间,数据完全可以在前端缓存一分钟,避免重复请求。我在真实项目里常用的策略是给_loadData加一个Map缓存,键是startDate + '_' + endDate,值是后端返回的 KPI 和 trend 数据,只有缓存到期或用户显式点击刷新时才重新请求。

这样做有两个额外好处:一是用户来回切换日期范围时,旧数据可以瞬间展示,体验更好;二是后端压力下降,仪表盘打开速度直接决定业务方愿不愿意用,这是所有 BI 类页面的底层逻辑。缓存失效策略我用的是「最大年龄 + 最大条目数」组合,超过 60 秒或 20 条记录后清空最旧的缓存,防止内存被无限制占用。

有一回给一家做订单管理的公司搭驾驶舱,因为没做防抖,用户在销售列表页快速点筛选时,后端同时收到十几个聚合请求,直接把生产库拖垮了。从那以后我每次给仪表盘加新交互,都强制走一遍「先确定数据源模型 → 后端方法单测 → 前端防抖 → 缓存策略 → 真实数据量压测」的流程,再也不敢把没测过并发的前端代码直接丢上去。这套流程和这份源码包里的模块结构一起用,能把 Odoo 17 的仪表盘开发周期从两周压缩到两三天。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询