首页 / 资讯中心 / 文章详情

Odoo17自定义仪表盘开发:OWL与Chart.js实战

Odoo17自定义仪表盘开发:OWL与Chart.js实战 ★ FEATURED ARTICLE
简介Odoo17 自定义仪表盘开发示例源码面向具备 Python 与 Odoo 基础、希望学习后台仪表盘扩展方式的开发者也可供二次开发人员参考模块组织与前后端协作思路。压缩包共二十五个文件以九个脚本、六个 XML 视图、四个 Python 后端文件及三个编译缓存为主另含样式表、说明文档和图标图片整包约十六 KB体量轻便便于查阅与改造。代码围绕仪表盘模块展开涵盖模块清单、控制器、静态资源与视图定义等关键环节可帮助读者理解 Odoo17 仪表盘从后端数据接口、路由控制到前端展示的完整链路。已有二百五十四人学习或下载适合对照搭建自己的仪表盘插件原型并借鉴目录划分、清单配置和 XML 视图写法减少从零摸索的成本也能为课程演示或内部工具开发提供可复用骨架目录清晰的特点也方便按功能定位代码。1. 从一张空白仪表盘说起Odoo17 看板真正要动的只有四个文件把 Odoo17 装好翻遍设置也找不到「自定义仪表盘」这个开关——原生只给到 graph 和 pivot 两种分析视图想在企业首页摆一张带 KPI 卡片加趋势图的看板只能自己写模块。这份 Odoo17 自定义仪表盘开发示例源码走的正是官方推荐的那条路一个独立模块、一个ir.actions.client动作、一个 OWL 组件再加一个基于_read_group的后端聚合方法全程不侵入后端核心代码。它适合两类人一类是 Odoo 实施顾问客户张口就要「首页一眼看到这半年销售额」另一类是刚接手二开任务的 Python 后端第一次被扔进 OWL 和 assets 打包这两个新坑里。下面按文件拆从骨架一路拆到图表实例。2. 模块骨架与资产打包把看板挂进 Odoo17 最小可跑结构2.1 先定结构一个独立模块五个必需文件自定义仪表盘最稳的做法是开新模块而不是往web里塞代码。理由很直接升级 Odoo 大版本时改过核心的模块基本等于给自己埋雷而独立模块最差也就是卸载重装。这套示例源码的目录结构不大但每个文件都承担固定职责少一个页面就转不起来。custom_dashboard/ ├── __init__.py ├── __manifest__.py ├── models/ │ ├── __init__.py │ └── sale_order.py ├── views/ │ ├── dashboard_action.xml │ └── dashboard_menus.xml └── static/ └── src/ ├── js/dashboard.js ├── xml/dashboard.xml └── scss/dashboard.scss这段结构里models/负责数据聚合views/负责动作与菜单static/src/是前端的全部家当三个子目录分别对应脚本、QWeb 模板和样式Odoo 的资产打包器按这三类文件分别处理分开写排错时一眼就能定位是哪一层挂了。manifest 是整份源码的入口也是新手最先翻车的地方# custom_dashboard/__manifest__.py { name: Custom Dashboard, version: 17.0.1.0.0, category: Productivity, summary: 自定义仪表盘示例, depends: [base, web, sale], # web 必带OWL 运行时、assets_backend 都在里面 data: [ views/dashboard_action.xml, views/dashboard_menus.xml, ], assets: { web.assets_backend: [ web.chartjs_lib, # 复用 Odoo 自带的 Chart.js别重复引一份 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 运行时和web.assets_backend这个 bundle 名sale是因为示例拿销售订单当数据源换成别的业务就把这里换掉。assets那段是整个模块能不能在浏览器里跑起来的关键四个路径缺一个就对应一类报错后面第 5 章会逐条对照。application: True让模块在应用列表里单独成项安装和演示都方便。2.2 客户端动作与菜单tag 必须和注册表 key 一模一样Odoo 里前端自定义页面不是挂一个 URL而是注册一个「客户端动作」client action再用tag把它和 JS 注册表里的 key 对上。这两个字符串一旦不一致点菜单就是一句Unknown action tag而且不会告诉你差在哪。?xml version1.0 encodingutf-8? odoo !-- tag 的值 JS 侧 registry.category(actions).add() 的第一个参数 -- record idaction_dashboard_sales modelir.actions.client field namename销售仪表盘/field field nametagcustom_dashboard.sales/field field nameparams{}/field /record menuitem idmenu_dashboard_root name经营看板 sequence5/ menuitem idmenu_dashboard_sales name销售仪表盘 parentmenu_dashboard_root actionaction_dashboard_sales sequence10/ /odoomodelir.actions.client说明这是一个纯前端动作服务端只负责把tag和params传下去不返回业务数据。params字段后面第 6 章会拿来做区间配置现在留空即可。菜单这边用parent挂了个根节点是为了以后加第二个、第三个看板时不用再重复建一级菜单sequence控制排序数值小的靠前。动作 XML 和菜单 XML 都写在data里意味着模块安装或升级时会自动加载不需要手动去「技术 → 动作」里建记录。2.3 资产打包web.assets_backend里少一行页面就白给你看Odoo 的 assets 不是把文件一个个script src引进去而是启动时把 bundle 里所有文件合并压缩成一个资源浏览器按 hash 缓存。这个机制平时很省心调试时就特别反直觉明明文件改了、重启了服务页面还是老样子。开发期一定要带--dev启动# 本地开发关闭 assets 合并缓存QWeb 模板改动即时生效 odoo-bin -c /etc/odoo/odoo.conf -d demo_db --devall--devall实际上是--devassets,qwerkzeug,xml,reload的合集其中对我们最有用的是assets和xml前者让每次请求都重新走打包后者让 QWeb 模板不用重启就生效。生产环境千万别开性能会掉一大截上线前用普通模式再跑一遍确认合并后的 bundle 也没问题。如果不想重启服务也可以开开发者模式后访问/web/assets/debug手动触发生成。还有一点容易忽略web.assets_backend决定了这些资产只在后台/web加载如果哪天把看板嵌到门户页得改用web.assets_frontend两套 bundle 是分开的。3. 后端数据源用 ORM 聚合把指标算成前端能吃的 JSON3.1 为什么不直接写 SQL权限、多公司与字段变更看板上的数字最省事的写法是self.env.cr.execute(SELECT ...)跑起来也没问题但一旦进了客户环境就会出三类事故。第一类是权限原生 SQL 绕过ir.rule记录规则销售员能看到全部公司的订单金额这在多公司场景下是硬伤。第二类是多公司手写 SQL 很容易忘了company_id in (...)这一条切公司数字不变。第三类是字段变更哪天财务把amount_total的存储字段改了名SQL 不会报错只会返回空值排查起来极其难受。_read_group是 Odoo 官方提供的高层聚合接口它在翻译成 SQL 之前已经套好了记录规则和公司过滤返回值也是 Python 对象字段改名时 IDE 和单测都能提前发现。代价是语法稍微绕一点需要专门记一下分组后缀。3.2_read_group的分组后缀从date_order到date_order:monthOdoo17 的_read_group签名是_read_group(domain, groupby, aggregates)返回一个元组列表每个元组的第一项是分组键后面依次是聚合结果。# custom_dashboard/models/sale_order.py from dateutil.relativedelta import relativedelta from odoo import api, fields, models class SaleOrder(models.Model): _inherit sale.order api.model def get_dashboard_data(self, months12): 看板取数入口一次调用返回标签、数值、总额、订单数。 months max(1, min(int(months or 12), 24)) # 参数兜底防前端乱传 today fields.Date.context_today(self) date_from (today - relativedelta(monthsmonths - 1)).replace(day1) domain [ (state, in, (sale, done)), (date_order, , date_from), (company_id, in, self.env.companies.ids), # 多公司必须显式带上 ] groups self._read_group( domaindomain, groupby[date_order:month], # 按月分组 aggregates[amount_total:sum, __count], # 求和 计数 ) # groups 形如 [(date(2024, 1, 1), 12000.0, 8), ...] groups.sort(keylambda g: g[0]) return { labels: [g[0].strftime(%Y-%m) for g in groups], values: [round(g[1], 2) for g in groups], count: sum(g[2] for g in groups), total: round(sum(g[1] for g in groups), 2), }逐项说清这几个参数。api.model表示这是模型级方法可以在前端通过orm.call(sale.order, get_dashboard_data, [], {...})直接调到不需要先有记录集。months做了双向夹逼下限 1 个月、上限 24 个月原因是前端参数一旦被篡改成999date_from会算到 1999 年数据库直接全表扫。date_from里.replace(day1)是为了对齐到自然月否则区间起点落在月中第一个月的柱状图会短一截客户第一眼就会问「这个月怎么掉这么多」。groupby[date_order:month]里的:month是 Odoo 的日期粒度后缀常见还有:day、:week、:quarter、:year。不写后缀默认按完整日期分组一年的数据会拆成三百多根柱子图表直接糊成一片。注意返回值里分组键是date对象不是字符串前端要自己格式化示例里用strftime(%Y-%m)转成2024-01这种形式。aggregates[amount_total:sum, __count]中__count是 Odoo 内置的行数统计不需要额外字段。如果改成id:count也能跑但__count语义更明确。另外state过滤只取sale和done草稿和已取消的订单不计入这是业务口径问题实施时一定要跟客户确认别想当然。3.3 参数兜底与自测用 odoo shell 对着数据库核一遍前端调试聚合结果很慢正确姿势是直接开 shell 对着数据库核数# 进入模块所在数据库的交互式 shell不启动 HTTP 服务 odoo-bin shell -c /etc/odoo/odoo.conf -d demo_db --no-http# 在 shell 里手跑和看板页面上的数字对一遍 env[sale.order].get_dashboard_data(months6) {labels: [2024-07, 2024-08, ...], values: [12000.0, 15800.5, ...], count: 12, total: 34567.8} # 再用 search_count 交叉验证订单数两个数对不上就说明 domain 写错了 env[sale.order].search_count([ ... (state, in, (sale, done)), ... (date_order, , 2024-07-01), ... (company_id, in, env.companies.ids), ... ]) 12--no-http很关键不带的话 shell 会尝试占端口本机已经有 Odoo 在跑就会报端口冲突。shell 里env是当前数据库的环境对象env.companies和前端登录用户手上的公司集合一致所以这里算出来的数就是那位用户在看板上该看到的数。如果两次数对不上八成是domain里漏了company_id或者state口径不一致逐个条件注释掉再跑一遍几分钟就能定位。提示_read_group里不要放limit之类的参数它不支持真要限制返回行数在 Python 侧对结果切片别指望数据库层截断。4. 前端渲染OWL 组件 Chart.js 把 JSON 画成看板4.1 setup 里声明状态onWillStart 取数、onMounted 画图、onWillUnmount 收尾Odoo17 后端界面统一用 OWL 2组件写法和 Vue/React 有几分像但生命周期钩子的语义要单独记。取数放在onWillStart里它会在首次渲染之前把异步任务跑完画图放在onMounted里这时 DOM 已经插好了canvas拿得到销毁放在onWillUnmount里否则切换菜单之后图表实例还挂在内存里。/** odoo-module **/ import { registry } from web/core/registry; import { useService } from web/core/utils/hooks; import { Component, onMounted, onWillStart, onWillUnmount, useRef, useState, } from odoo/owl; /* global Chart */ export class SalesDashboard extends Component { static template custom_dashboard.SalesDashboard; static props { *: true }; // 允许客户端动作透传任意 props setup() { this.orm useService(orm); this.chartRef useRef(chart); this.chart null; this.state useState({ loading: true, error: null, data: null }); onWillStart(async () { try { // 第 4 个参数是 kwargs会作为关键字参数传给后端方法 this.state.data await this.orm.call( sale.order, get_dashboard_data, [], { months: this.props.action?.params?.months || 12 }, ); } catch (e) { this.state.error e.message || String(e); } finally { this.state.loading false; } }); onMounted(() this.renderChart()); onWillUnmount(() this.chart this.chart.destroy()); } renderChart() { if (!this.state.data || !this.chartRef.el) { return; } this.chart this.chart.destroy(); // 关键先销毁旧实例再新建 this.chart new Chart(this.chartRef.el, { type: bar, data: { labels: this.state.data.labels, datasets: [{ label: 销售额, data: this.state.data.values, backgroundColor: #875a7b, }], }, options: { responsive: true, maintainAspectRatio: false }, }); } } // key 必须与 XML 里 ir.actions.client 的 tag 完全一致 registry.category(actions).add(custom_dashboard.sales, SalesDashboard);useService(orm)拿到的是 Odoo 封装好的 ORM 服务call的第三个参数是位置参数数组第四个是关键字参数字典后端方法签名里的months正好对上。useRef(chart)和模板里的t-refchart是一对ref.el就是那个canvas元素。static props { *: true }这行不能省客户端动作会把整个 action 对象作为 props 传进组件不声明的话 OWL 会直接抛Invalid props异常。this.props.action?.params?.months用了可选链因为params可能是空对象第 6 章会真正用上它。renderChart第一行先destroy是血泪经验后面第 5 章单独讲。maintainAspectRatio: false配合外层容器固定高度图表才能随窗口宽度自适应而高度不塌。4.2 QWeb 模板的三态loading / error / empty 一个都不能少OWL 组件的模板用 QWeb 语法文件放在static/src/xml/下顶层节点必须是templates。三态是底线加载中、出错、正常渲染少一个用户在弱网环境下就只能盯着白屏。?xml version1.0 encodingUTF-8? templates xml:spacepreserve t t-namecustom_dashboard.SalesDashboard div classo_sales_dashboard p-3 t t-ifstate.loading div classtext-muted py-5 text-center数据加载中…/div /t t t-elifstate.error div classalert alert-danger t-escstate.error/ /t t t-else div classd-flex gap-4 mb-3 div classkpi-card div classkpi-label区间销售额/div div classkpi-value t-escstate.data.total/ /div div classkpi-card div classkpi-label订单数/div div classkpi-value t-escstate.data.count/ /div /div div classchart-wrap styleheight: 320px; canvas t-refchart/ /div /t /div /t /templatest-name必须和 JS 里static template custom_dashboard.SalesDashboard逐字符一致这是第二个高频对不上的地方。t-esc会自动做 HTML 转义数字和用户可读字符串都该用它不要用t-out加| safe除非你确定内容干净。t-refchart挂在canvas上而不是外层div上因为 Chart.js 要的是canvas的 2D context。t-else这个写法有点怪但 OWL 的指令要求不能省括号写t-else不带等号在部分版本会报解析错误照抄即可。4.3 Chart.js 实例管理destroy 写在 renderChart 的第一行Chart.js 的实例和canvas是绑定的同一个canvas上反复new Chart会叠加绘制层表现是柱子颜色越来越深、鼠标悬浮提示出现多份、切几次菜单内存明显上涨。正确做法是在创建新实例之前先销毁旧实例示例里把this.chart.destroy()放在renderChart第一行就是防止任何一条重绘路径漏掉。onWillUnmount里再兜一次保证离开页面时彻底释放。如果后续要做「切换区间重新取数」就不能只调renderChart得先await新数据再画顺序反了会画到上一次的数据上。建议把「取数 画图」封成reload()一个方法所有触发点都走它避免两处逻辑分叉。5. 避坑与排查六个真实翻车现场和它们的止损点5.1 白屏 控制台 404assets 没进包现象模块装上菜单点得开但页面一片空白F12 里能看到dashboard.js请求返回 404或者压根没发起请求。原因__manifest__.py的assets段路径写错常见是漏了模块前缀custom_dashboard/或者把文件放进了static/description/这种不在 bundle 里的目录。另一种是改完 manifest 没重启服务bundle 还是老的。解决先确认路径和磁盘文件逐字符一致再用--devassets重启或者开发者模式访问/web/assets/debug强制重建。生产环境不想重启的话升级模块-u custom_dashboard也能触发重建。5.2 Unknown action tag注册表 key 和 tag 差了一个字符现象点菜单弹出错误对话框提示Unknown action tag: custom_dashboard.sales但 JS 文件明明加载成功了。原因XML 里ir.actions.client的tag字段和 JS 里registry.category(actions).add(...)的第一个参数不一致。大小写、下划线、点号都算custom_dashboard.sales和custom_dashboard.Sales是两个不同的 key。解决把两处字符串复制到同一个文本文件里对比别靠肉眼。更省事的做法是定义一个常量文件XML 侧没法共享但至少 JS 里只写一次注册和模板引用都用同一个变量。5.3 图表挤成一根柱分组后缀写漏了现象看板能出来但横轴只有一两个标签柱子细得像根针鼠标悬浮发现数值其实是全部订单的总和。原因groupby[date_order]没写:monthOdoo 按完整日期分组但前端strftime(%Y-%m)又把同月的多个日期格式化成同一个字符串Chart.js 把重复标签合并处理了。解决确认groupby里带粒度后缀同时在 shell 里打印groups的原始长度长度和页面标签数对不上就是这个问题。日期类字段的粒度后缀别靠猜:day、:week、:month、:quarter、:year挨个试一遍看哪个符合业务口径。5.4 切了公司数字不变domain 漏了 company_id现象多公司环境下用 A 公司登录和用 B 公司登录看板上的销售额一模一样但列表视图里明明不一样。原因_read_group虽然会应用记录规则但如果模型上没有配置跨公司的ir.rule它不会自动按env.companies过滤。示例里那行(company_id, in, self.env.companies.ids)就是补这个缺口。解决所有自定义聚合方法都显式带上这一条别指望默认行为。验证方式是 shell 里切env.companies再跑一次数字必须跟着变。5.5 时区错位月底订单跑到下个月现象客户反馈「12 月 31 号晚上的单子看板上算到 1 月去了」本地测试又复现不了。原因date_order在数据库里存的是 UTC_read_group的:month按 UTC 切月而用户在东八区本地 12 月 31 日 23:00 对应 UTC 还是 12 月 31 日 15:00理论上不该错但如果用户在西半球时区就会跨月。示例里用fields.Date.context_today(self)算起点是对的分组这一层仍按 UTC 走。解决对时区敏感的业务要么在前端把区间参数换成 UTC 边界传下去要么在_read_group外层用pytz做一次时区转换再聚合。简单粗暴但有效的办法是跟客户约定看板口径统一按公司时区并在页面上标注「数据截至 UTC8」比事后解释省事得多。5.6 切几次菜单图表变黑实例没销毁现象反复在菜单间切换看板柱子颜色越来越深最后糊成一块浏览器任务管理器里内存曲线一路上涨。原因每次挂载都new Chart旧实例还挂在同一个canvas上Chart.js 不会自动清理。onWillUnmount漏写或者写在renderChart之后都会导致这个问题。解决destroy放在renderChart第一行onWillUnmount再兜一次。排查时可以在renderChart里打日志看同一个页面生命周期内被调了几次正常应该只有一次。6. 进阶把 months 变成可配置三个动作验证这块看板没画错硬编码12个月只能算 demo真上线客户第一句就是「能不能自己选区间」。最小改动是把区间塞进客户端动作的paramsfield nameparams{months: 6}/field前端this.props.action?.params?.months已经预留了读取逻辑改 XML 不用动 JS。想做得再细一点可以在res.users上加一个Integer字段存默认区间登录时读一次写进params这样每个用户看到的是自己习惯的窗口。验证这块看板有没有画错我一般固定跑三个动作动作做法能抓到的问题单测对拍造两条已知金额的订单断言total等于两者之和聚合口径、state 过滤写错shell 交叉get_dashboard_data()与search_count结果对比domain 漏条件、多公司串数浏览器 Network看call_kw的响应体字段是否齐全前端字段名拼错、后端返回结构变更单测建议直接写在模块的tests/下用TransactionCase造数据时把date_order显式设成区间内和区间外各一条跑--test-enable就能验证。Network 那一步最容易被跳过但字段改名这类低级错误往往就是它第一个发现——后端把total改成amount_total前端不报错只是 KPI 卡片显示空白。从那以后我每次交付自定义看板都会先把「shell 对拍 Network 看响应」走一遍再给客户演示因为图表这东西太会骗人柱子画出来了不代表数对数对了不代表口径是客户要的。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站