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

发布时间:2026/10/25 1:23:52
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 只负责返回结构化数据不返回 HTMLJavaScript 只负责状态和渲染不写业务口径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 idaction_custom_dashboard modelir.actions.client field namename销售仪表盘/field field nametagcustom_dashboard.dashboard/field /record menuitem idmenu_custom_dashboard_root name经营看板 sequence10/ menuitem idmenu_custom_dashboard_sale name销售仪表盘 parentmenu_custom_dashboard_root actionaction_custom_dashboard sequence10/ /odootag是前后端约定的字符串前端必须用同一个字符串注册组件少一个字母就会报「找不到动作」。menuitem的parent指向父菜单action指向刚定义的动作。sequence控制排序数值越小越靠前。这里没有写groups所以所有能进后端的用户都能看到菜单真实项目里一般会加groupssales_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_fromNone, date_toNone): # 基础口径只统计已确认的销售订单 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], limit5, orderbyamount_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表示对金额求和第三个是分组字段。limit5限制返回五组orderbyamount_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-namecustom_dashboard.Dashboard div classo_custom_dashboard div classo_dashboard_header h3销售概览/h3 span t-ifstate.loading加载中.../span /div div classo_dashboard_grid div classo_kpi_card span classo_kpi_label订单数/span span classo_kpi_value t-escstate.kpi.order_count/ /div div classo_kpi_card span classo_kpi_label销售额/span span classo_kpi_value t-escstate.kpi.total_amount/ /div /div table classo_top_partners t-ifstate.kpi.top_partners.length thead trth客户/thth金额/th/tr /thead tbody tr t-foreachstate.kpi.top_partners t-asp td t-escp.name/ td t-escp.amount/ /tr /tbody /table /div /tt-name必须和Dashboard.template的值一致这里是custom_dashboard.Dashboard。t-esc会转义输出数字和字符串都能用如果字段可能为空模板里可以用t-if兜底。t-foreach遍历数组t-asp定义循环变量循环内部用p.name、p.amount取值。表格包在t-ifstate.kpi.top_partners.length里数据为空时不会渲染空表头。如果要把卡片拆成子组件可以新建DashboardCard在模板里用DashboardCard title订单数 valuestate.kpi.order_count/子组件通过props接收。拆组件的好处是样式和结构收口坏处是多一层文件跳转示例规模不大时先写在一个模板里等卡片超过四个再拆也不迟。4. 跑起来与验证从空数据库到看到图表的 5 个步骤源码能不能跑不取决于代码写得多漂亮而取决于模块路径、依赖、升级命令和缓存这四件事有没有对齐。下面按实际启动顺序走一遍每一步都给出验证点避免装完不知道对不对。4.1 安装前检查依赖与模块路径先把custom_dashboard整个目录放到 Odoo 的 addons 路径下确认__init__.py里导入了models# custom_dashboard/__init__.py from . import modelsmodels/__init__.py再导入具体文件# custom_dashboard/models/__init__.py from . import dashboard_data这两个文件少一个模块安装时不会报错但 ORM 方法不存在前端调用会返回「方法未找到」。检查完导入再确认依赖模块sale已经在数据库里安装。如果数据库是空的先装销售模块或者把 manifest 里的sale换成你已有的业务模块别让示例依赖一个不存在的模型。4.2 启动与升级命令开发阶段用-i安装、-u升级配合--devall让前端资源不走缓存。命令大致如下# 首次安装-i 指定模块-d 指定数据库 ./odoo-bin -c odoo.conf -d demo_db -i custom_dashboard --devall-c指定配置文件里面通常有数据库连接和 addons 路径-d是数据库名-i是安装模块--devall会开启 Python 和前端资源的开发模式。改完 JS、XML 或 SCSS 后不需要重装用-u升级模块再刷新页面./odoo-bin -c odoo.conf -d demo_db -u custom_dashboard --devall-u会重新加载data里的 XML并让 assets bundle 重新生成。生产环境不要长期开--devall它会影响性能开发机上加这个参数是为了少踩缓存坑。如果启动时报模块找不到先看 addons 路径有没有包含模块父目录再看模块目录名和 manifest 里的名字是否一致。4.3 用 SQL 和界面交叉验证数字页面能打开不代表数字对。最直接的验证是拿数据库里的原始数据对一遍。假设你的示例统计的是已确认销售订单可以跑一条 SQLselect 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 请求是不是 500500 的话去 Odoo 日志里找 Python traceback。开发模式下资源路径通常带时间戳生产模式会走缓存所以同一个问题在开发机和生产环境的表现可能不一样排查时先确认当前跑的是哪种模式。5. 避坑与排查仪表盘开发里最容易翻车的 5 个点仪表盘的坑大多不在算法而在加载顺序、权限和时区这些「看起来不是业务」的地方。下面五条按现象、原因、解决拆开写都是我在类似模块里真实遇到过的类型。5.1 资源加载类问题现象一菜单能点开页面空白控制台报模板找不到。原因通常是 XML 模板没有加入assets或者t-name与组件里的template值不一致。解决方法是先打开浏览器控制台看第一个报错再回__manifest__.py检查assets列表里有没有static/src/xml/dashboard.xml最后核对t-namecustom_dashboard.Dashboard和Dashboard.template是否逐字符一致。改完必须升级模块只重启服务不够因为data和 assets 的清单在升级时才会重新读取。现象二改了 JS 或 SCSS刷新页面没变化。原因是 assets bundle 有缓存尤其在生产模式下。解决方法是开发机启动时加--devall改完用-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_fromNone, date_toNone): 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(hour23, minute59, second59))) # 后续聚合逻辑同上 return {total_amount: 0, order_count: 0, top_partners: []}fields.Datetime.to_datetime会把字符串转成 datetimereplace用来补边界。注意这段只是示意真实口径要和业务确认是按订单日期还是确认日期是否包含取消订单是否按公司过滤。口径不先定代码写得再顺也会返工。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], limit5, orderbyamount desc, )字段映射可以用一张表对照改的时候不容易漏示例位置示例值换成业务字段时改哪里domain状态state sale改成你模型的状态字段和完成值聚合字段amount_total:sum改成金额、数量或工时字段分组字段partner_id改成客户、团队、项目或产品分类前端卡片order_count改模板里的t-esc字段名加卡片时复制一个o_kpi_card块后端返回结构里加一个键不要在前端写死数字。权限上给菜单加groups给统计方法加必要的读权限检查用不同角色的账号各登录一次确认数字和列表页一致。验证方法很简单拿一个只有三条演示数据的测试库先对 SQL再对页面最后换普通用户看一遍。从那以后我每次加仪表盘卡片都先跑这三步再给业务方看希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询