Odoo17自定义仪表盘开发实战:KPI模型到Owl组件全解析

发布时间:2026/10/20 22:15:56
Odoo17自定义仪表盘开发实战:KPI模型到Owl组件全解析 简介Odoo17自定义仪表盘开发示例源码面向需要实现轻量化业务看板的Odoo二次开发人员演示了基于Odoo 17框架构建自定义仪表盘模块的完整路径与实现要点。资源共25个文件以JavaScript为主实现前端交互与图表展示XML负责视图和动作定义Python编写模型与控制器逻辑同时包含SCSS样式、说明文档等压缩包整体仅16KB结构紧凑且依赖完整适合作为模块开发脚手架。目前已有254人学习下载可作为快速理解Odoo模块组织方式的实际参考。通过该源码读者能掌握manifest清单、controllers路由、views视图与static静态资源之间的协作关系尤其是不依赖内置仪表盘而自建板块的挂载思路配套的Readme与示例代码可以帮助梳理开发流程节省自行排查的时间是一份兼顾学习与复用的轻量级参考。1. Odoo17 自定义仪表盘这个示例模块能干什么某公司的 Odoo17 里塞了上万条销售订单老板每天都要看 KPI可原生视图只能点进列表再分组统计既不直观也慢。这份自定义仪表盘示例源码要解决的正是这种“想在业务后台放一块数据面板”的刚需。它用一个自定义 KPI 模型承接聚合数据用看板视图渲染成卡片再用一个 Owl 组件把异步刷新做成实时概览完整覆盖了从模型、视图、动作到前端组件的开发链路。模块本身是一个可安装可卸载的 Odoo 插件适合 Odoo 实施工程师、想在业务系统里加数据面板的开发者以及想从源码层面搞懂 Odoo17 二次开发的人。老手可以直接看 3、4 章的实现思路新手跟着 2、5 章的步骤一样能跑起来不需要额外改框架底层。2. 模块骨架拆解从 manifest 到菜单动作的目录结构一个 Odoo 插件模块的目录结构是有约定的先把这个示例源码的目录摆出来后面每一步都能对上号。custom_dashboard/ ├── __init__.py # Python 包入口 ├── __manifest__.py # 模块声明Odoo 安装时首先读它 ├── models/ │ ├── __init__.py │ └── dashboard_kpi.py # KPI 模型定义与聚合逻辑 ├── security/ │ ├── ir.model.access.csv # 模型访问权限 │ └── custom_dashboard_security.xml # 可选的记录规则 ├── views/ │ ├── menu_views.xml # 菜单定义 │ └── dashboard_views.xml # 动作与看板视图 └── static/ └── src/ ├── js/ │ └── dashboard_widget.js # Owl 组件 ├── xml/ │ └── dashboard_widget.xml # 组件模板 └── css/ └── dashboard_widget.css # 卡片样式2.1 目录结构每个文件在模块里承担什么角色文件职责__manifest__.py声明模块名、版本、依赖、数据加载顺序、前端资源models/dashboard_kpi.py定义dashboard.kpi模型提供_compute_daily_kpis聚合方法security/ir.model.access.csv给普通用户分配 KPI 模型的只读权限views/dashboard_views.xml定义看板动作、看板视图、客户端动作与 cronviews/menu_views.xml定义顶层菜单“仪表盘”和两个子菜单static/src/js/dashboard_widget.jsOwl 组件负责异步拉取 KPI 数据static/src/xml/dashboard_widget.xml组件模板渲染卡片墙static/src/css/dashboard_widget.css卡片布局和视觉样式这个拆分是 Odoo 官方推荐的常规做法Python 逻辑全放models/界面声明放views/权限放security/前端资源放static/src/。模块一旦装了Odoo 会按 manifest 里的 data 列表顺序加载 XML 和 CSV顺序错了会直接报错或导致视图找不到。2.2 manifest.py依赖、数据文件与资源加载顺序{ name: Custom Dashboard, version: 17.0.1.0.0, category: Tools, summary: Odoo17 自定义仪表盘开发示例, description: 一套可运行的仪表盘示例模块包含 KPI 模型、统计方法、看板视图和前端组件, depends: [base, sale], data: [ security/custom_dashboard_security.xml, security/ir.model.access.csv, views/dashboard_views.xml, views/menu_views.xml, ], assets: { web.assets_backend: [ custom_dashboard/static/src/js/dashboard_widget.js, custom_dashboard/static/src/xml/dashboard_widget.xml, custom_dashboard/static/src/css/dashboard_widget.css, ], }, installable: True, application: True, license: LGPL-3, }depends里加了sale因为 KPI 统计要读sale.order这张表不加依赖硬去访问模块启动就会抛KeyError。data列表的顺序有讲究安全文件必须最先加载否则视图和数据文件在检查权限时可能被拦视图其次菜单放最后因为菜单引用的 action 必须先存在。assets里挂的是web.assets_backend意思是这些 JS、XML、CSS 只在 Odoo 后端后台加载前端网站不会加载它们。修改 JS 或 CSS 后命令行升级要带-u否则 Odoo 的资源合并缓存不会更新这是新手最常见的“改了没反应”。2.3 权限与入口让 KPI 模型对用户可见安全文件security/ir.model.access.csv控制了谁能读、谁能写。这个示例里普通用户只需要只读权限id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_dashboard_kpi_user,dashboard.kpi.user,model_dashboard_kpi,base.group_user,1,0,0,0model_id:id是model_dashboard_kpi这是 Odoo 根据模型名自动生成的 external idgroup_id:id用base.group_user表示所有后台登录用户。这里只给了读权限防止业务人员随手改掉统计结果。菜单和动作在views/menu_views.xml里串起来?xml version1.0 encodingutf-8? odoo data menuitem idmenu_dashboard_root name仪表盘 sequence10/ menuitem idmenu_dashboard_kpi parentmenu_dashboard_root actionaction_dashboard_kpi sequence10/ menuitem idmenu_dashboard_live parentmenu_dashboard_root actionaction_dashboard_client sequence20/ /data /odoo先建一个顶层菜单再挂两个子菜单。第一个子菜单指向看板动作历史 KPI第二个指向客户端动作实时概览。安装时把这两类入口分开业务人员看板卡看历史数据管理层点“实时概览”看动态刷新互不干扰。跑起来只需要三步把custom_dashboard目录放进配置文件里的addons_path在开发者模式下“更新应用列表”然后找到 Custom Dashboard 安装。命令行更直接./odoo-bin -u custom_dashboard -d mydb --dev reload--dev reload会监听前端资源变化改 JS 后浏览器自动刷新省得每次手动升级。3. 核心逻辑实现KPI 模型、聚合查询与看板视图的联动仪表盘的数据不是凭空来的它需要一个“结果的容器”。这一章演示的是一个完整闭环模型接收聚合结果 → 方法从sale.order算数 → 看板视图展示 → cron 保证每天更新。3.1 KPI 模型字段怎么设计才方便视图检索from odoo import api, fields, models class DashboardKpi(models.Model): _name dashboard.kpi _description 仪表盘KPI记录 _order kpi_date desc, id desc kpi_date fields.Date(统计日期, requiredTrue, indexTrue) kpi_name fields.Char(指标名称, requiredTrue) kpi_value fields.Float(指标数值, digits(16, 2), requiredTrue) kpi_type fields.Selection([ (order_count, 订单数量), (revenue, 销售总额), (avg_amount, 客单价), ], string指标类型, requiredTrue) group_key fields.Char(分组维度, help例如销售员姓名、区域名称用于看板分组展示) def name_get(self): return [(kpi.id, [%s] %s % (kpi.kpi_date, kpi.kpi_name)) for kpi in self]_order设成kpi_date desc看板默认展示最近日期在最前。indexTrue给日期加索引后面 cron 按kpi_date做清理时能走索引数据量大时不至于全表扫。重写name_get是为了让日志和消息里的关联展示友好一点实际跑起来不是必需。3.2 聚合查询read_group 的正确打开方式api.model def _compute_daily_kpis(self, date_valueNone): 计算并写入指定日期的KPI默认当天。重复执行不会产生重复记录。 today date_value or fields.Date.context_today(self) next_day fields.Date.add(today, days1) self.search([(kpi_date, , today)]).unlink() domain [ (date_order, , today), (date_order, , next_day), (state, in, [sale, done]), ] grouped self.env[sale.order].read_group( domaindomain, fields[id, amount_total], groupby[user_id], lazyFalse, ) for item in grouped: user_name item[user_id][1] if item[user_id] else 未分配 self.create({ kpi_date: today, kpi_name: %s 订单数 % user_name, kpi_value: item[amount_total_count], kpi_type: order_count, group_key: user_name, }) self.create({ kpi_date: today, kpi_name: %s 销售额 % user_name, kpi_value: item[amount_total], kpi_type: revenue, group_key: user_name, }) return True这段代码的关键点在read_group的用法domain是过滤条件fields至少要传一个可聚合字段groupby不能传空列表lazyFalse表示不嵌套返回。返回结果里amount_total是组内销售总额amount_total_count是组内记录数。我传了[id, amount_total]所以能同时拿到计数和求和。写入之前先按日期unlink旧记录这是为了幂等。cron 和手动触发都可能重复执行如果不清理同一天的 KPI 会越攒越多看板卡片直接裂开。生产环境我一般还会把这段逻辑包进事务统计出错时旧数据不回滚。手动触发一次可以配一个服务端动作record idaction_server_recompute modelir.actions.server field namename重新计算今日KPI/field field namemodel_id refmodel_dashboard_kpi/ field namestatecode/field field namecodemodel._compute_daily_kpis()/field /record在动作里执行model._compute_daily_kpis()不给 code 加环境变量。实际调的时候无需穿参数默认当天想回补历史就在代码里循环调用并传入date_value。3.3 看板视图把聚合结果渲染成 KPI 卡片record idaction_dashboard_kpi modelir.actions.act_window field namename历史KPI/field field nameres_modeldashboard.kpi/field field nameview_modekanban/field field namecontext{group_by: kpi_date}/field /record record iddashboard_kpi_kanban modelir.ui.view field namenamedashboard.kpi.kanban/field field namemodeldashboard.kpi/field field namearch typexml kanban classo_dashboard_kpi default_group_bykpi_date field namekpi_name/ field namekpi_value/ field namekpi_type/ field namegroup_key/ templates t t-namekanban-box div classoe_kanban_card div classo_kpi_label field namekpi_name/ /div div classo_kpi_value field namekpi_value/ /div div classo_kpi_group field namegroup_key/ /div /div /t /templates /kanban /field /record看板视图的模板里用field标签直接把字段渲染进卡片Odoo 会自动取值不需要手动拼接字符串。default_group_bykpi_date让卡片按日期分组同一天的指标落到一个泳道里非常适合日报场景。如果想把kpi_value显示成货币给field加widgetmonetary但需要模型里配currency_id字段这个示例里没有加为了保持精简。3.4 cron 定时重算让数据保持新鲜record idcron_dashboard_daily modelir.cron field namename仪表盘KPI每日统计/field field namemodel_id refmodel_dashboard_kpi/ field namestatecode/field field namecodemodel._compute_daily_kpis()/field field nameinterval_number1/field field nameinterval_typedays/field field namenumbercall-1/field field nameactiveTrue/field /recordmodel_id必须指向dashboard.kpi这样 cron 触发时会带着这个模型的环境上下文去执行code里的 Python 表达式。interval_number1配合interval_typedays表示每 1 天跑一次。numbercall-1是无限次永不自动停。activeTrue激活定时任务装完模块它就会自动挂到计划任务列表里不用人工去开启。4. 前端交互扩展Owl 组件如何让仪表盘真正“动”起来看板视图适合看历史快照但要“实时概览”还得靠前端主动拉数据。Odoo17 的前端框架是 Owl 2.0组件化写法比老版本清晰很多这一章把组件、模板、动作注册串一遍。4.1 Owl 组件用卡片墙承载实时数据/** odoo-module **/ import { Component, useState, onWillStart, onMounted, onWillUnmount } from odoo/owl; import { useService } from web/core/service_hook; export class DashboardWidget extends Component { static template custom_dashboard.DashboardWidget; setup() { this.orm useService(orm); this.state useState({ loading: true, records: [], }); this._timer null; onWillStart(() this._loadData()); onMounted(() { this._timer setInterval(() this._loadData(), 60000); }); onWillUnmount(() { if (this._timer) { clearInterval(this._timer); } }); } async _loadData() { try { const records await this.orm.call(dashboard.kpi, search_read, [ [], [kpi_name, kpi_value, kpi_date, group_key], { order: kpi_date desc, id desc, limit: 50 }, ]); this.state.records records; this.state.loading false; } catch (error) { console.error(加载KPI数据失败, error); this.state.loading false; } } }useService(orm)拿到的是 Odoo 的 ORM 服务call的第一个参数是模型名第二个是方法名第三个是位置参数列表第四个是关键字参数。search_read返回的 records 直接就是可以渲染的对象数组。onWillStart做首次加载onMounted里挂一个 60 秒的轮询定时器onWillUnmount里清掉避免组件被关闭后还在后台发请求这是前端最容易被忽略的内存泄漏点。组件模板放在static/src/xml/dashboard_widget.xml?xml version1.0 encodingutf-8? templates t t-namecustom_dashboard.DashboardWidget div classo_dashboard_widget container-fluid div classrow t t-foreachstate.records t-asrecord div classcol-3 o_kpi_card div classo_kpi_card_valuet t-escrecord.kpi_value//div div classo_kpi_card_namet t-escrecord.kpi_name//div /div /t /div /div /t /templatest-foreach循环渲染记录t-esc做转义输出防止字段里带 HTML 被浏览器执行。模板名要和 JS 里的static template保持一致否则组件找不到模板直接白屏。4.2 注册客户端动作菜单直接打开组件import { registry } from web/core/registry; const dashboardAction { Component: DashboardWidget, }; registry.category(actions).add(custom_dashboard, dashboardAction);XML 里声明一个ir.actions.clienttag 指向上面注册的 keyrecord idaction_dashboard_client modelir.actions.client field namename实时概览/field field nametagcustom_dashboard/field /recordir.actions.client的tag必须和registry.category(actions).add的第一个参数完全一致。菜单点进去后Odoo 客户端会找到这个注册项实例化DashboardWidget并渲染到主视图区。因为组件直接调 ORM 读dashboard.kpi不需要单独写 controller省掉一层 HTTP 接口的维护成本。4.3 数据刷新与筛选组件怎么接受外部参数同一个仪表盘组件往往要复用在不同的筛选场景比如看“销售额”还是“订单数”或者只看某个时间区间。可以在动作的 context 里传参record idaction_dashboard_revenue modelir.actions.client field namename销售额实时概览/field field nametagcustom_dashboard/field field namecontext{default_kpi_type: revenue}/field /record前端在 setup 里读this.props.contextsetup() { this.orm useService(orm); const context this.props.context || {}; this.kpiType context.default_kpi_type || revenue; this.state useState({ loading: true, records: [] }); onWillStart(() this._loadData()); } async _loadData() { const domain [[kpi_type, , this.kpiType]]; if (this.state.dateStart) { domain.push([kpi_date, , this.state.dateStart]); } const records await this.orm.call(dashboard.kpi, search_read, [ domain, [kpi_name, kpi_value, kpi_date, group_key], { order: kpi_date desc, id desc, limit: 50 }, ]); this.state.records records; }通过context传参是 Odoo 后端常见的做法菜单、按钮、动作都能往 context 里塞数据前端组件只要按约定读 key 就能拿到。这样同一个组件可以注册成多个菜单入口每个入口只看一类指标不用复制三份代码。5. 避坑指南Odoo17 仪表盘开发的五个高频问题这一章记录的五个坑都是开发仪表盘时容易翻车的真实场景每条按“现象 → 原因 → 解决”给你排查路径。5.1 看板视图空白权限没配现象菜单点进去看板视图区域全白左下角没有任何记录页面也不报错。原因dashboard.kpi模型在ir.model.access.csv里没有给base.group_user分配读权限。Odoo 对无权限的模型在视图中直接返回空记录而不是抛权限异常看起来就像“没有数据”。解决检查security/ir.model.access.csv确认存在model_dashboard_kpi对应行且perm_read1。改完文件后要升级模块./odoo-bin -u custom_dashboard -d mydb升级完刷新浏览器。如果还是空白再去看 Technical 菜单里的模型权限是否真的生效有时环境里开了多个数据库缓存。5.2 read_group 参数写错fields 必传groupby 不能为空现象调用read_group时日志里报TypeError: read_group() missing 1 required positional argument: fields或ValueError: Invalid groupby。原因Odoo17 把fields参数改成了必传groupby传空列表也会在部分版本直接抛异常。很多人照老版本的帖子写两参数调用必然翻车。解决fields至少传一个字段比如[amount_total]按用户分组就写groupby[user_id]。如果是纯计数场景用fields[id]取值时读id_count不要自己数返回数组长度那会在多分组时出偏差。5.3 XML 改了不生效资源缓存与模块升级现象修改了看板视图 XML 里的卡片模板刷新浏览器一点变化都没有JS 改了也一样。原因Odoo 的视图 arch 有数据库缓存前端资源有合并缓存普通刷新不会清理。只重启服务不一定有用因为 XML 没被重新加载。解决改视图 XML 后必须升级模块./odoo-bin -u custom_dashboard -d mydb改 JS/CSS 后如果没开--dev reload也要升级或手工清浏览器缓存CtrlShiftR。还想省事就在研发环境一直开着--dev reload前端文件保存即生效。5.4 日期统计少一天UTC 时区混用现象统计“今日”KPI早上 8 点前的订单全被算到昨天数值和报表对不上。原因Odoo 数据库存的是 UTC 时间fields.Date.context_today(self)会转成用户所在时区的日期但如果你在 domain 里用datetime.now()去拼日期得到的是本地时间再存进 UTC 比较自然差 8 小时。解决所有日期基准统一用fields.Date.context_today(self)或fields.Datetime.context_timestamp转换。代码里写死的datetime.now()在不跨时区部署时看不出来一旦服务器时区变更立刻炸。5.5 重复生成 KPI不清旧数据现象cron 跑了两天看板卡片里同一个日期出现两套相同的指标卡片分组乱掉。原因_compute_daily_kpis每次直接 create没有先清理当天的旧记录。cron 补跑、模块升级、手动触发都会造成重复。解决聚合之前先self.search([(kpi_date, , today)]).unlink()先删后建保证幂等。这个习惯我建议所有写统计型 cron 的人都继承不只是仪表盘场景。6. 二次开发思路把示例模板改造成自己的业务面板拿到这份源码改造成业务面板只需要动三处数据源模型、过滤条件、展示字段。先替换数据源。把_compute_daily_kpis里的self.env[sale.order]换成你的业务模型比如采购单、工单或项目任务。domain 里的状态过滤也要跟着换比如项目场景改成(stage, not in, [cancel])。模型换了以后views/dashboard_views.xml里的res_model和views字段绑定全部换成新模型这是一个容易漏的地方。再扩展统计维度。现在的groupby[user_id]是按销售员分组想按区域或产品类别分组改成groupby[team_id]或groupby[product_categ_id]即可。group_key字段的值跟着分组字段走看板卡片上会显示对应的维度名。如果想加趋势线给dashboard.kpi增加一个previous_value字段在聚合时把昨天的值一起查出来卡片上就能显示环比涨跌。最后做验证。装好模块后先在开发者模式下手动触发服务端动作“重新计算今日KPI”确认dashboard.kpi表里生成了预期行数再看看板分组是否正确最后观察 cron 在后台是否自动执行。检查时打开日志ir.cron的每次执行都会留下记录如果没执行多半是interval_type配错或模块没升级。那次做完这个示例模块之后我就养成了一个习惯凡是给业务方做仪表盘先跟业务核对“时间口径”——是按创建时间还是确认时间、用本地时区还是 UTC这两个问题能省下后面至少两轮返工。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询