ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Odoo17自定义仪表盘开发:OWL组件与ORM聚合实战

Odoo17自定义仪表盘开发:OWL组件与ORM聚合实战

简介:Odoo17 自定义仪表盘开发示例源码面向已具备 Odoo 基础、希望掌握后台仪表盘扩展与前端组件集成的开发者,也可作为企业信息化项目中定制看板的参考样例。压缩包共 25 个文件,约 16KB,包含 9 个 JavaScript、6 个 XML、4 个 Python 及 3 个 pyc 文件,并辅以 SCSS、Markdown 与 PNG 资源,分别对应前端交互、视图与菜单定义、控制器与模块初始化、样式说明和示例图片,目录按 controllers、static、views 等模块组织,便于理解 Odoo 插件结构。已有 254 人学习,说明其在小体量示例中具有一定参考价值。读者可从中获得仪表盘模块的清单配置、后端控制器写法、前端资源加载与视图挂载思路,适合按文件角色拆读,并迁移到自定义看板、数据展示或后台功能扩展场景。

1. 从一张没人看的首页说起:Odoo17 自定义仪表盘开发示例源码到底解决什么问题

Odoo17 自定义仪表盘开发示例源码,最容易被当成一个首页换皮的小 Demo,直到业务方把三个 KPI 卡片拍在你面前,要求今天下班前能看到数。这个示例解决的正是这类需求:在 Odoo17 后端注册一个客户端动作,用 OWL 组件搭出可复用的卡片,再通过 ORM 聚合把销售、库存或财务数据拉到前端。它适合已经能装 Odoo 模块、想从改菜单进阶到写组件的开发者,也适合需要给客户做轻量看板的外包团队。技术栈不花哨:Python 模型负责取数,JavaScript/OWL 负责渲染,XML 定义动作与模板,SCSS 收口样式。往下看你会看到目录怎么摆、动作怎么注册、数据怎么聚合,以及最常见的几个翻车点。

2. 技术选型与目录结构:OWL 组件、assets bundle 和 manifest 怎么配合

仪表盘这类页面,最怕做成「一锅炖」:菜单 XML、Python 查询、前端模板全塞进一个文件,第一次能跑,第二次加卡片就开始互相踩。这份 Odoo 插件源码的目录不复杂,但每一层都对应一个 Odoo 的加载阶段。先把选型和目录讲清楚,后面改代码时才知道该动哪一层。

2.1 为什么用 OWL 而不是纯 QWeb 模板

Odoo17 的后端界面本身跑在 OWL 上,客户端动作最终要返回一个组件类,而不是一段静态 HTML。纯 QWeb 模板当然也能渲染表格,但它更适合报表、打印单据这类「一次渲染完就结束」的场景。仪表盘不一样:卡片要局部刷新、日期筛选要改状态、图表实例要随组件销毁,这些都需要组件生命周期。

常见做法是把根页面做成一个 OWL 组件,卡片再拆成子组件,数据通过useService("orm")调后端方法。这样前端不需要知道 SQL 长什么样,后端也不需要拼 HTML。选 OWL 的另一个好处是调试路径清楚:模板没找到、组件没注册、动作 tag 对不上,控制台报错一般能直接指到具体文件。纯 QWeb 拼字符串的写法在第一次交付时快,但加第二个数据源时就开始出现「这个数字为什么和那个卡片不一致」的血泪经验。

我一般会先定三条边界:Python 只负责返回结构化数据,不返回 HTML;JavaScript 只负责状态和渲染,不写业务口径;XML 只负责动作、菜单和模板声明。边界定完,后面加卡片就是复制一个组件、加一个 ORM 方法,不用回头改根页面。

2.2 源码目录长什么样

示例模块按 Odoo 插件惯例组织,目录层级不深,但每个目录都有明确职责。下面这棵目录树可以直接对照下载包里的文件看:

custom_dashboard/ ├── __init__.py ├── __manifest__.py ├── models/ │ ├── __init__.py │ └── dashboard_data.py ├── security/ │ └── ir.model.access.csv ├── views/ │ ├── dashboard_action.xml │ └── dashboard_menus.xml └── static/ └── src/ ├── js/ │ └── dashboard.js ├── xml/ │ └── dashboard.xml └── scss/ └── dashboard.scss

__manifest__.py是模块入口,决定依赖、数据和静态资源怎么加载。models/dashboard_data.py放聚合接口,通常用AbstractModel,因为它不需要建表,只提供方法。views/dashboard_action.xml定义客户端动作,views/dashboard_menus.xml定义菜单入口。static/src/js放 OWL 组件和动作注册,static/src/xml放 QWeb 模板,static/src/scss放卡片布局和颜色。

这里有个容易忽略的点:security/ir.model.access.csv在示例里可能只给自定义模型配权限,但如果你的聚合方法读的是sale.order,当前登录用户仍然需要销售订单的读权限。目录结构只是骨架,权限是另一条线,后面第 5 章会单独拆。

2.3 manifest 与 assets 的加载顺序

manifest 是整份源码的「总开关」,写错一个键,前端资源就不会进 bundle。下面是一个够用的版本:

{ "name": "自定义仪表盘示例", "version": "17.0.1.0.0", "category": "Productivity", "summary": "Odoo17 自定义仪表盘开发示例", "depends": ["web", "sale"], "data": [ "security/ir.model.access.csv", "views/dashboard_action.xml", "views/dashboard_menus.xml", ], "assets": { "web.assets_backend": [ "custom_dashboard/static/src/js/dashboard.js", "custom_dashboard/static/src/xml/dashboard.xml", "custom_dashboard/static/src/scss/dashboard.scss", ], }, "installable": True, "application": True, "license": "LGPL-3", }

depends里的web提供 OWL 运行时和动作管理器,sale是示例取数的来源。如果你换成自己的模型,把sale替换成对应模块,别在代码里直接假设模型一定存在。data里的 XML 在模块安装或升级时加载,负责把动作和菜单写进数据库;assets里的文件在页面渲染时注入,负责前端组件和样式。两者的加载时机不同,所以经常出现「菜单出来了,但点开空白」——菜单是data加载的,空白通常是assets没进去或模板名对不上。

assets的 key 用web.assets_backend,表示这些资源只进后端界面。JS 要放在 XML 模板前面吗?常见做法是 JS 在前、XML 在后、SCSS 最后,但 Odoo 的 bundle 会按依赖处理,真正要保证的是文件路径别写错。路径从模块根目录开始,写成custom_dashboard/static/src/...,少一层static就会在浏览器里报 404。

参数上,version用17.0.x.y.z是 Odoo 的惯例,方便升级时识别;application设为True会让模块出现在应用列表里,但不影响动作注册。license按你的分发方式填,示例源码里常见的是LGPL-3。这些字段不影响仪表盘能不能跑,但会影响模块能不能被正确安装和升级。

3. 核心模块拆解:一个仪表盘请求从菜单进入到卡片渲染的完整链路

把目录摆好只是第一步,真正要读懂的是请求链路:用户点菜单,动作管理器找到客户端动作,按 tag 去注册表里取组件,组件挂载前调 ORM 方法,后端聚合数据返回,模板再把数字画出来。这条链路里任何一环名字对不上,页面就是空白或者转圈。

3.1 客户端动作与菜单注册

Odoo 的菜单本身不直接渲染页面,它挂的是一个动作。仪表盘常用ir.actions.client,因为客户端动作可以把tag交给前端注册表处理。下面这段 XML 定义动作和两级菜单:

<odoo> <record id="action_custom_dashboard" model="ir.actions.client"> <field name="name">销售仪表盘</field> <field name="tag">custom_dashboard.dashboard</field> </record> <menuitem id="menu_custom_dashboard_root" name="经营看板" sequence="10"/> <menuitem id="menu_custom_dashboard_sale" name="销售仪表盘" parent="menu_custom_dashboard_root" action="action_custom_dashboard" sequence="10"/> </odoo>

tag是前后端约定的字符串,前端必须用同一个字符串注册组件,少一个字母就会报「找不到动作」。menuitem的parent指向父菜单,action指向刚定义的动作。sequence控制排序,数值越小越靠前。这里没有写groups,所以所有能进后端的用户都能看到菜单;真实项目里一般会加groups="sales_team.group_sale_salesman"之类的限制,避免财务或仓库用户看到销售看板。

前端注册动作的代码通常单独放在dashboard.js里:

/** @odoo-module **/ import { registry } from "@web/core/registry"; import { Dashboard } from "./dashboard"; // tag 必须与 ir.actions.client 里的 tag 完全一致 registry.category("actions").add("custom_dashboard.dashboard", Dashboard);

registry.category("actions")是 Odoo 17 前端动作的注册表,add的第二个参数是组件类。组件类不需要在这里实例化,动作管理器会在路由命中时创建它。注意/** @odoo-module **/这行注释不能丢,Odoo 的模块加载器靠它识别文件类型。

3.2 Dashboard 根组件的状态与取数

根组件只做三件事:注入 ORM 服务、在挂载前拉一次数据、把状态传给模板。下面是一个最小但完整的写法:

/** @odoo-module **/ import { Component, onWillStart, useState } from "@odoo/owl"; import { useService } from "@web/core/utils/hooks"; export class Dashboard extends Component { setup() { // 注入 ORM 服务,后续调用后端方法不用手写 fetch this.orm = useService("orm"); // useState 让模板能响应数据变化 this.state = useState({ loading: true, kpi: { total_amount: 0, order_count: 0, top_partners: [], }, }); // onWillStart 在组件首次渲染前执行,适合拉初始数据 onWillStart(async () => { const result = await this.orm.call( "custom.dashboard.sale", "get_kpi", [] ); this.state.kpi = result; this.state.loading = false; }); } } Dashboard.template = "custom_dashboard.Dashboard";

useService("orm")返回的对象有call(model, method, args, kwargs)方法,参数依次是模型名、方法名、位置参数、关键字参数。这里调用的是custom.dashboard.sale的get_kpi,方法定义在 Python 的AbstractModel里。onWillStart的回调可以是异步函数,组件会等它完成后再首次渲染;如果请求失败,控制台会打印后端异常,页面可能停在加载状态。

useState包了一层响应式代理,直接给this.state.kpi赋新对象会触发模板更新。这里没有用this.state.kpi.total_amount = ...逐字段赋值,是因为后端返回的是完整结构,整体替换更不容易漏字段。loading放在状态里,模板可以据此显示「加载中」而不是先闪一个 0。

3.3 ORM 聚合接口:read_group 与日期参数

后端方法的目标是返回前端能直接用的结构,不要把 recordset 原样丢过去。下面这个例子按销售订单状态过滤,统计总额、订单数,并取金额最高的前五个客户:

from odoo import api, models class SaleDashboard(models.AbstractModel): _name = "custom.dashboard.sale" _description = "仪表盘销售数据接口" @api.model def get_kpi(self, date_from=None, date_to=None): # 基础口径:只统计已确认的销售订单 domain = [("state", "=", "sale")] if date_from: domain.append(("date_order", ">=", date_from)) if date_to: domain.append(("date_order", "<=", date_to)) orders = self.env["sale.order"].search(domain) total_amount = sum(orders.mapped("amount_total")) # read_group 按客户聚合,limit 控制返回条数 groups = self.env["sale.order"].read_group( domain, ["amount_total:sum"], ["partner_id"], limit=5, orderby="amount_total desc", ) top_partners = [] for group in groups: partner = group.get("partner_id") top_partners.append({ "name": partner[1] if partner else "未指定客户", "amount": group.get("amount_total", 0), }) return { "total_amount": total_amount, "order_count": len(orders), "top_partners": top_partners, }

_name用点号分隔,custom.dashboard.sale不会建表,因为继承的是AbstractModel。@api.model表示这是模型级方法,不需要具体记录。domain里的state = sale是示例口径,换成你自己的模型时,状态字段和值都要改。date_from和date_to是前端传进来的字符串,直接拼进 domain 时 Odoo 会按字段类型转换;如果日期带时区,边界值要特别小心,第 5 章会展开。

read_group的第一个参数是 domain,第二个是聚合字段列表,amount_total:sum表示对金额求和,第三个是分组字段。limit=5限制返回五组,orderby="amount_total desc"按聚合金额倒序。返回的每组是一个字典,partner_id是 many2one 的(id, display_name)元组,所以取partner[1]做显示名。total_amount用sum(orders.mapped("amount_total"))在 Python 侧算,数据量大时更常见的做法是也用read_group或 SQL 聚合,避免把整批 recordset 读进内存。

3.4 卡片子组件与 QWeb 模板

根组件负责取数,卡片负责显示。示例里可以把卡片拆成独立组件,通过props传标题和值,这样加第二个 KPI 时不用复制模板。先看模板:

<t t-name="custom_dashboard.Dashboard"> <div class="o_custom_dashboard"> <div class="o_dashboard_header"> <h3>销售概览</h3> <span t-if="state.loading">加载中...</span> </div> <div class="o_dashboard_grid"> <div class="o_kpi_card"> <span class="o_kpi_label">订单数</span> <span class="o_kpi_value" t-esc="state.kpi.order_count"/> </div> <div class="o_kpi_card"> <span class="o_kpi_label">销售额</span> <span class="o_kpi_value" t-esc="state.kpi.total_amount"/> </div> </div> <table class="o_top_partners" t-if="state.kpi.top_partners.length"> <thead> <tr><th>客户</th><th>金额</th></tr> </thead> <tbody> <tr t-foreach="state.kpi.top_partners" t-as="p"> <td t-esc="p.name"/> <td t-esc="p.amount"/> </tr> </tbody> </table> </div> </t>

t-name必须和Dashboard.template的值一致,这里是custom_dashboard.Dashboard。t-esc会转义输出,数字和字符串都能用;如果字段可能为空,模板里可以用t-if兜底。t-foreach遍历数组,t-as="p"定义循环变量,循环内部用p.name、p.amount取值。表格包在t-if="state.kpi.top_partners.length"里,数据为空时不会渲染空表头。

如果要把卡片拆成子组件,可以新建DashboardCard,在模板里用<DashboardCard title="'订单数'" value="state.kpi.order_count"/>,子组件通过props接收。拆组件的好处是样式和结构收口,坏处是多一层文件跳转;示例规模不大时,先写在一个模板里,等卡片超过四个再拆也不迟。

4. 跑起来与验证:从空数据库到看到图表的 5 个步骤

源码能不能跑,不取决于代码写得多漂亮,而取决于模块路径、依赖、升级命令和缓存这四件事有没有对齐。下面按实际启动顺序走一遍,每一步都给出验证点,避免装完不知道对不对。

4.1 安装前检查依赖与模块路径

先把custom_dashboard整个目录放到 Odoo 的 addons 路径下,确认__init__.py里导入了models:

# custom_dashboard/__init__.py from . import models

models/__init__.py再导入具体文件:

# custom_dashboard/models/__init__.py from . import dashboard_data

这两个文件少一个,模块安装时不会报错,但 ORM 方法不存在,前端调用会返回「方法未找到」。检查完导入,再确认依赖模块sale已经在数据库里安装。如果数据库是空的,先装销售模块,或者把 manifest 里的sale换成你已有的业务模块,别让示例依赖一个不存在的模型。

4.2 启动与升级命令

开发阶段用-i安装、-u升级,配合--dev=all让前端资源不走缓存。命令大致如下:

# 首次安装:-i 指定模块,-d 指定数据库 ./odoo-bin -c odoo.conf -d demo_db -i custom_dashboard --dev=all

-c指定配置文件,里面通常有数据库连接和 addons 路径;-d是数据库名;-i是安装模块;--dev=all会开启 Python 和前端资源的开发模式。改完 JS、XML 或 SCSS 后,不需要重装,用-u升级模块再刷新页面:

./odoo-bin -c odoo.conf -d demo_db -u custom_dashboard --dev=all

-u会重新加载data里的 XML,并让 assets bundle 重新生成。生产环境不要长期开--dev=all,它会影响性能;开发机上加这个参数是为了少踩缓存坑。如果启动时报模块找不到,先看 addons 路径有没有包含模块父目录,再看模块目录名和 manifest 里的名字是否一致。

4.3 用 SQL 和界面交叉验证数字

页面能打开不代表数字对。最直接的验证是拿数据库里的原始数据对一遍。假设你的示例统计的是已确认销售订单,可以跑一条 SQL:

select count(*) as order_count, coalesce(sum(amount_total), 0) as total_amount from sale_order where state = 'sale';

把order_count和total_amount与仪表盘卡片上的数字对比。如果 SQL 有值、页面是 0,先看前端请求有没有发出去;如果前端请求发了但返回 0,看 domain 是不是多加了条件,或者当前用户有没有记录规则限制。coalesce是为了让空结果返回 0 而不是 null,方便对照。

再验证 Top 客户表:SQL 里按partner_id分组求和,取前五,和页面表格逐行对。数字对不上时,先确认币种和多公司字段。Odoo 的多公司环境里,sale.order默认会按当前公司过滤,SQL 不会,所以两边口径可能差一个公司。验证时最好在单公司测试库跑,或者把公司条件显式写进 SQL。

4.4 前端资源不生效时的排查顺序

改完样式没变化、模板报找不到,先按这个顺序排:第一,确认文件路径写进了assets的web.assets_backend;第二,确认t-name和Dashboard.template完全一致;第三,用-u custom_dashboard升级模块;第四,浏览器硬刷新或清缓存;第五,看控制台第一个报错,不要从最后一个报错开始猜。

如果控制台报「Cannot find template」,多半是 XML 没进 bundle 或t-name拼错。如果报「Action not found」,检查ir.actions.client的tag和registry.category("actions").add的字符串。如果页面一直显示加载中,看网络面板里 ORM 请求是不是 500,500 的话去 Odoo 日志里找 Python traceback。开发模式下资源路径通常带时间戳,生产模式会走缓存,所以同一个问题在开发机和生产环境的表现可能不一样,排查时先确认当前跑的是哪种模式。

5. 避坑与排查:仪表盘开发里最容易翻车的 5 个点

仪表盘的坑大多不在算法,而在加载顺序、权限和时区这些「看起来不是业务」的地方。下面五条按现象、原因、解决拆开写,都是我在类似模块里真实遇到过的类型。

5.1 资源加载类问题

现象一:菜单能点开,页面空白,控制台报模板找不到。原因通常是 XML 模板没有加入assets,或者t-name与组件里的template值不一致。解决方法是先打开浏览器控制台看第一个报错,再回__manifest__.py检查assets列表里有没有static/src/xml/dashboard.xml,最后核对t-name="custom_dashboard.Dashboard"和Dashboard.template是否逐字符一致。改完必须升级模块,只重启服务不够,因为data和 assets 的清单在升级时才会重新读取。

现象二:改了 JS 或 SCSS,刷新页面没变化。原因是 assets bundle 有缓存,尤其在生产模式下。解决方法是开发机启动时加--dev=all,改完用-u custom_dashboard升级,再硬刷新浏览器。如果仍然不生效,检查文件是不是放在了static/src下、路径有没有多一层或少一层。还有一种情况是浏览器插件缓存了旧资源,换无痕窗口能快速排除。

5.2 数据与权限类问题

现象三:卡片数字是 0,但数据库里明明有订单。原因可能是当前用户没有sale.order的读权限,或者多公司规则把数据过滤掉了。解决方法是先用管理员账号看同一页面,如果管理员有数、普通用户没数,就去检查用户所属分组和记录规则。聚合方法里不要随手加sudo()绕过权限,仪表盘的数字应该和用户能看到的列表一致,否则业务方会质疑口径。确实需要跨权限统计时,单独建一个只读的统计模型,把权限收窄到看板分组。

现象四:按日期筛选时,边界当天的数据漏了。原因是前端传的是日期字符串,后端字段是 datetime,直接比较会把当天 00:00:00 之后的数据排除掉一部分。解决方法是明确口径:如果按天筛选,date_to要转成当天 23:59:59,或者用fields.Date.end_of这类方法处理边界。更稳妥的做法是前端传 ISO 格式的日期时间,后端统一转 UTC 再比较,同时把用户时区考虑进去。下面是一个边界处理的写法:

from odoo import api, fields, models class SaleDashboard(models.AbstractModel): _name = "custom.dashboard.sale" _description = "仪表盘销售数据接口" @api.model def get_kpi(self, date_from=None, date_to=None): domain = [("state", "=", "sale")] if date_from: domain.append(("date_order", ">=", fields.Datetime.to_datetime(date_from))) if date_to: # 如果 date_to 只到日期,补到当天最后一秒 end = fields.Datetime.to_datetime(date_to) domain.append(("date_order", "<=", end.replace(hour=23, minute=59, second=59))) # 后续聚合逻辑同上 return {"total_amount": 0, "order_count": 0, "top_partners": []}

fields.Datetime.to_datetime会把字符串转成 datetime,replace用来补边界。注意这段只是示意,真实口径要和业务确认:是按订单日期还是确认日期,是否包含取消订单,是否按公司过滤。口径不先定,代码写得再顺也会返工。

5.3 前端交互与布局类问题

现象五:图表或卡片在切换菜单后报错,提示组件已销毁还在更新状态。原因是异步请求返回时组件已经卸载,回调里还在改state。解决方法是把异步请求的返回值判断放在组件生命周期内,必要时用onWillDestroy打标记,回调里先判断再赋值。如果集成了图表实例,还要在销毁时调destroy(),否则重复进入页面会叠加多个实例,内存和渲染都会出问题:

/** @odoo-module **/ import { onWillDestroy } from "@odoo/owl"; setup() { this.chart = null; onWillDestroy(() => { if (this.chart) { this.chart.destroy(); this.chart = null; } }); }

onWillDestroy在组件卸载前执行,适合清理定时器、图表实例和事件监听。布局上的常见坑是卡片用固定像素宽度,到了小屏幕直接溢出;用 CSS Grid 的minmax和gap更省心,比如grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));。样式改完同样要走升级和硬刷新,别在缓存上浪费时间。

6. 二次开发改造:把示例仪表盘换成业务自己的 KPI 面板

改造时先换数据源,再换卡片,最后补权限。示例里的sale.order只是占位,换成自己的模型时,改domain、聚合字段和分组字段三处即可。比如把销售订单换成项目记录,统计完成数量按团队分组:

domain = [("state", "=", "done")] groups = self.env["my.business.record"].read_group( domain, ["amount:sum"], ["team_id"], limit=5, orderby="amount desc", )

字段映射可以用一张表对照,改的时候不容易漏:

示例位置示例值换成业务字段时改哪里
domain状态state = sale改成你模型的状态字段和完成值
聚合字段amount_total:sum改成金额、数量或工时字段
分组字段partner_id改成客户、团队、项目或产品分类
前端卡片order_count改模板里的t-esc字段名

加卡片时复制一个o_kpi_card块,后端返回结构里加一个键,不要在前端写死数字。权限上,给菜单加groups,给统计方法加必要的读权限检查;用不同角色的账号各登录一次,确认数字和列表页一致。验证方法很简单:拿一个只有三条演示数据的测试库,先对 SQL,再对页面,最后换普通用户看一遍。从那以后我每次加仪表盘卡片,都先跑这三步再给业务方看,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进