在 Odoo 中添加自定义dashboard页面
在使用Odoo开发时,有时会有这样的业务需求: 希望可以设计一个dashboard,以图表可视化的方式来展现相关数据。
其实Odoo内置的模块中很多页面都有实现了类似的功能,然而可惜的是官方对于这部分的教程Customizing the web client还是基于Odoo 8.0
写的,已经过时很久了。
虽然网上也有像Ruter大神写的相关基础教程,但是为了照顾读者,一些比较深入的功能并没有提及到,本教程会在基于Ruter教程上,示范一些更深入的功能点。
一旦完成本教程,你的页面看起来会是这样子的
Prerequisite
本教程基于以下环境开发:
- 系统: windows wsl -
Ubuntu 18.04
- Odoo: Nightly Odoo 构建的post-20200101
12.0
版本 - 数据库: PostgreSQL 10.11
在阅读本教程时,我会假定你已经具备了以下相关基础知识:
- 已经阅读过Ruter的在Odoo中添加自定义页面教程
- 了解odoo的基本开发流程
- 了解html,css,javascript,jQuery
- 了解基本git操作
教程目标
通过本教程你将学会以下知识:
- 了解定制Odoo
action_client
的Js中相关的生命周期以及常用方法。 - 事件绑定
- 如何与后台交互获取数据
- 一些Odoo实用小组件
本教程中的示例代码可以从https://github.com/findsomeoneyys/Odoo-Tutorial-Demo
中获取
安装模块
由于教程是于Ruter的,所以我们先在项目根目录下执行以下命令来获取项目模块。
git clone https://github.com/ruter/Odoo-Tutorial-Demo.git
然后需要把项目路径加入到odoo的addons_path中,可以在odoo.conf
配置
addons_path=...,/path/to/Odoo-Tutorial-Demo
亦或者可以在启动的时候加入参数方式
./odoo-bin -c odoo.conf --addons-path="./Odoo-Tutorial-Demo"
命令行的输出可以检测你是否正确配置了路径
接着访问odoo页面,打开开发者模式,更新app列表,再搜索custom_page
模块安装即可。
定义相关页面
这里不多与Ruter教程过程差不多,唯一区别是js会多出一些方法,稍后我们会用到。
创建页面
在custom_page/static/src/xml/
下新建 echart.xml
文件
1 2 3 4 5 6 7 8 9 10 11 12 | <?xml version="1.0" encoding="UTF-8"?> <templates id="template" xml:space="preserve"> <t t-name="custom_page.EchartPage"> <div class="container-fluid mt-3"> <div id="app" class="mt-2"> <p>echart</p> </div> </div> </t> </templates> |
定义动作
在/custom_page/static/src/js/
下创建demo_echart.js
这段js中新增了一些方法和属性,这些都是web.AbstractAction
从中继承,必定会执行的方法,相关行我都加上了简单注释,不太理解也没关系,稍后的例子中会说明。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | odoo.define('custom_page.echart', function (require) { "use strict"; var AbstractAction = require('web.AbstractAction'); var core = require('web.core'); var ajax = require('web.ajax'); var CustomPageEchart = AbstractAction.extend({ template: 'custom_page.EchartPage', // 需要额外引入的css文件 cssLibs: [ ], // 需要额外引入的js文件 jsLibs: [ ], // 事件绑定相关定义 events: { }, // action的构造器,可以自行根据需求填入需要初始化的数据,比如获取context里的参数,根据条件判断初始化一些变量。 init: function(parent, context) { this._super(parent, context); console.log("in action init!"); }, // willStart是执行介于init和start中间的一个异步方法,一般会执行向后台请求数据的请求,并储存返回来的数据。 // 其中ajax.loadLibs(this)会帮加载定义在cssLibs,jsLibs的js组件。 willStart: function() { var self = this; return $.when(ajax.loadLibs(this), this._super()).then(function() { console.log("in action willStart!"); }); }, // start方法会在渲染完template后执行,此时可以做任何需要处理的事情。 // 比如根据willStart返回来数据,初始化引入的第三方js库组件 start: function() { var self = this; return this._super().then(function() { console.log("in action start!"); }); }, }); core.action_registry.add('custom_page.echart', CustomPageEchart); return CustomPageEchart; }); |
定义菜单
打开/custom_page/views/views.xml
,删除里面的menuitem记录,然后加入以下内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <menuitem id="menu_root_custom_page" name="Custom Page" groups="base.group_user"/> <menuitem id="menu_custom_page_wired" name="Custom Page Wired" action="action_custom_page" parent="menu_root_custom_page" groups="base.group_user" sequence="1"/> <record id="action_custom_page_echart" model="ir.actions.client"> <field name="name">Custom Page echart</field> <field name="tag">custom_page.echart</field> </record> <menuitem id="menu_custom_page_echart" name="Custom Page echart" action="action_custom_page_echart" parent="menu_root_custom_page" groups="base.group_user" sequence="0"/> |
加载资源
打开custom_page/views/templates.xml
文件,在xpath
中新增我们刚加入的js
1 2 3 4 | <xpath expr="//script[last()]" position="after"> ... <script type="text/javascript" src="/custom_page/static/src/js/demo_echart.js"></script> </xpath> |
打开custom_page/__manifest__.py
, 在qweb中引入我们新增的模板
1 2 3 4 | 'qweb': [ .... "static/src/xml/echart.xml" ], |
至此我们已经完成了页面相关定义,重启odoo并升级模块,此时重新进入custom_page,会看到新增的页面与控制台的相关输出:
AbstractAction的基本知识
相信通过刚才demo_echart.js
,有聪明的同学已经猜到是怎么回事了,不过也可能有的同学似懂非懂,在正式开始写之前,我先简单的介绍一下里面的属性,方法作用。
定义Odoo JavaScript 模块
Odoo框架使用这样的模式来定义Web插件中的模块,这是为了避免命名空间冲突和按顺序正确加载模块。
1 2 3 4 | odoo.define('custom_page.echart', function (require) { "use strict"; .... }); |
其中custom_page.echart
是定义的模块名,并且可以利用require('js_module_name')
这样的方式,来引入别的js模块,这有点类似JavaScript ES6的export语法,比如我们的代码中就做了这样的引入。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | odoo.define('custom_page.echart', function (require) { "use strict"; var AbstractAction = require('web.AbstractAction'); var core = require('web.core'); var ajax = require('web.ajax'); var CustomPageEchart = AbstractAction.extend({ ... }); return CustomPageEchart; }); |
继承AbstractAction Class
一个Client action的动作需要一个对应的AbstractAction
子类来处理,Odoo继承一个类最快的方法就是使用它的extend
方法,这个也是比较类似JavaScript ES6的extends语法
我们的代码中刚才通过require
方法引入了AbstractAction
类, 现在用extend
方法继承它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | var CustomPageEchart = AbstractAction.extend({ template: 'custom_page.EchartPage', cssLibs: [], jsLibs: [], events: { }, init: function(parent, context) { this._super(parent, context); console.log("in action init!"); }, willStart: function() { var self = this; return $.when(ajax.loadLibs(this), this._super()).then(function() { console.log("in action willStart!"); }); }, start: function() { var self = this; return this._super().then(function() { console.log("in action start!"); }); }, }); |
这段代码中我显示的把AbstractAction
类中包含的常用属性,方法列了出来,这里我再具体的介绍一下各自的作用。
template: 'custom_page.EchartPage'
这段中指定了要使用的模板名,在这段代码中,odoo会在我们引入的qweb
列表中找到名为custom_page.EchartPage
的模板并渲染它。
这里加入custom_page
前缀是为了防止模板之间命名冲突。
cssLibs, jsLibs
指定了依赖的第三方js,css文件,里面的每一项类是'/addons/path/to/static/xx.js'
这样的命名,Odoo默认会在Willstart
中执行ajax.loadLibs(this)
方法按需加载第三方库,这样可以避免直接把第三方库像在assets文件那样加入全局引用中,减少默认后台打开的请求文件大小。
events
是注册绑定事件的地方,一般是类似'click .my-class': 'on_my_class_click_function'
, 这样写了之后,当点击my-class
时,会自动触发自己写的on_my_class_click_function
方法。
init,willStart,start
可以简单的理解成生命周期函数,根据我们刚才看到的控制台输出也可以很清楚他们之间的执行顺序,具体作用相信在上方的注释里写的比较清楚了,这里就不再多做讲解了。
这里需要注意的是在方法中如果包含类似$.when()或者.then()这样的异步代码段,那么在里面写代码时,需要在顶上加上var self = this;
来保存Odoo实例, 否则在异步代码段里面的获取到的this
是document
, 就无法获取odoo实例数据了
注册action
对于AbstractAction
,我们还需要额外注册进Odoo的注册表,这样才可以根据我们xml中定义的tag
,让Odoo知道要初始化我们写的这个模块来处理。在文件的末尾,return前加入如下代码注册。
1 2 3 | ... ... core.action_registry.add('custom_page.echart', CustomPageEchart); |
实战
相关知识点我们已经了解的差不多了,现在让我们开始实战把,我们来使用ECharts渲染一个图标,并且新增一些按钮,通过点击按钮来触发与后台请求数据交互事件等功能。
这里我们参考ECharts的官方起步教程
引入echarts
引入第三方依赖并不复杂,这里我使用了CDN的方式来引入,爱动手的同学建议尝试下本地引入,修改jsLibs
,此时它看起来是这样的
jsLibs: [ 'https://cdn.jsdelivr.net/npm/echarts@4.6.0/dist/echarts.min.js', ]
接着我们刷新页面,此时可以在控制台中输入echarts
,如果有类似以下输出,那么就是引入成功了。
1 2 | echarts > {version: "4.6.0", dependencies: {…}, PRIORITY: {…}, init: ƒ, connect: ƒ, …} |
绘制一个简单的图表
根据ECharts教程,这一步我们需要准备2样东西,配置项option和初始化echarts实例。
初始化数据一般放在init
或者willStart
中执行,其中willStart
主要放的是需要异步请求数据的部分,所以我们这里放在init中,此时init的方法看起来是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | init: function(parent, context) { this._super(parent, context); console.log("in action init!"); this.echart_option = { title: { text: 'ECharts 入门示例' }, tooltip: {}, legend: { data:['销量'] }, xAxis: { data: ["衬衫","羊毛衫","雪纺衫","裤子","高跟鞋","袜子"] }, yAxis: {}, series: [{ name: '销量', type: 'bar', data: [5, 20, 36, 10, 10, 20] }] }; } |
紧接着便是要初始化echarts实例并调用,这里我们在class定于的方法末尾中新增一个render_chart
方法
1 2 3 4 5 6 | ... render_chart: function() { var el = this.$el.find('#app')[0]; this.myChart = echarts.init(el); this.myChart.setOption(this.echart_option); }, |
这段代码中this.$el
就是指的是根据template
生产的DOM元素,但是要注意的是这个DOM元素并不是马上插入到页面中,所以我们需要用这样的方式来初始化echart实例,否则用document.getElementById
echart会提示找不到该DOM元素。
接着我们在start方法中调用该函数:
1 2 3 4 | ... console.log("in action start!"); self.render_chart(); ... |
最后在echart.xml
中给div增加style指定宽高,这是因为刚才说的生成的DOM元素并不是马上插入页面中,此时echart无法给我们识别它的宽高,所以我们要手动指定,否则会显示不出来。
1 2 3 | ... <div id="app" class="mt-2" style="width: 800px;height:500px;"> .... |
这时我们再刷新页面,就会看到渲染好的图表了。
给按钮绑定事件
给元素绑定事件是需要填写events
属性和增加自定义方法。
我们再次打开echart.xml
, 在div#app上方新增按钮:
1 2 3 4 5 6 | ... ... <div class="d-flex justify-content-center"> <button class="btn btn-primary ml-2" id="btn1">button one</button> </div> <div id="app">.... |
接着在js文件中给填写event
属性,接着也在方法列表末尾新增对应函数,可以看出这和我们通过使用jQuery绑定on click方式是一致的
1 2 3 4 5 6 7 8 9 | events: { 'click #btn1': 'on_btn1_click', }, ... ... on_btn1_click: function(event) { console.log('on_btn1_click!'); $(event.target).toggleClass('disabled'); }, |
这时刷新页面,多次点击按钮我们可以看到控制台输出日志,以及按钮在禁用/可用状态中切换
(如果点击按钮如果没反应,尝试重启Odoo并升级模块)
向后台请求数据
在前面的图表例子中,我们的数据是固定的,而实际上我们开发过程中渲染图表一般需要向后台请求数据,现在让我们来修改下on_btn1_click
方法,让它可以通过点击时可以向后台申请新数据并重新渲染图表。
首先我们需要写一个后台路由方法,这样才可以从指定路由中请求数据,打开custom_page/controllers/controllers.py
文件,然后取消部分注释代码,接着填写相关逻辑,完成后文件内容是这样
1 2 3 4 5 6 7 8 9 10 11 12 13 | import random from odoo import http class CustomPage(http.Controller): @http.route('/custom_page/data/', auth='public', type='json') def index(self, **kw): x_data = ["衬衫","羊毛衫","雪纺衫","裤子","高跟鞋","袜子"] y_data = list(range(10000)) random.shuffle(x_data) return { 'x_data': x_data, 'y_data': random.choices(y_data, k=len(x_data)), } |
这里我们指定了一个路径为/custom_page/data
的路由,并且返回的响应类型是json
,里面的数据逻辑很简单,我们把x_data中的数据随机打乱,然后y_data的数据从0~10000中随机抽取6次(与x_data长度一致)
接着我们回到demo_echart.js
文件中,修改on_btn1_click
方法,删除原有逻辑,新增请求后台数据和重新渲染图表逻辑,修改完后的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | on_btn1_click: function(event) { console.log('on_btn1_click!'); var self = this; return this._rpc({ route: '/custom_page/data/', params: {}, }).done(function(result) { console.log(result); var data = result; self.echart_option.xAxis.data = data['x_data']; self.echart_option.series[0].data = data['y_data']; self.myChart.setOption(self.echart_option, true); }); }, |
这段代码中使用了Odoo的_rpc
方法来请求数据,其中route
参数指的是地址, param
则可以加入附带的URL参数,下面的.done
则表示请求完毕后执行的回调方法,在回调方法中我们拿到了返回的数据,然后修改我们的echart_option
数据,接着再重新渲染图表。
重启Odoo并刷新页面,此时点击按钮,图表的渲染此时会随机变化。
Odoo其余的相关请求方法(选读)
_rpc
也可以支持直接调用Odoo ORM方法,类似于
1 2 3 4 5 6 7 8 | self._rpc({ model: model_name, method: 'name_search', kwargs: { name: 'yunshen', args: domain, }, }) |
此外,在我们通过var ajax = require('web.ajax');
引入的ajax
模块中也包含了几个请求方法,这里我直接取官方源码作为示例
1 | ajax.jsonRpc('/mailing/blacklist/check', 'call', {'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token}) |
1 2 3 | ajax.rpc("/web/session/get_session_info", {}).then(function() { Reload(parent, action); }); |
同时,我们也可以直接用jQuery的ajax
方法
1 2 3 4 5 6 7 | $.ajax({ url: '/server_connect', type: 'post', data: $('#server-config').serialize(), }).fail(function () { ... }); |
读者可以自行尝试使用不同的请求方法实现本章类似的功能。
模板渲染Qweb
Qweb
是Odoo写的一个XML模板渲染引擎,如果读者有学过django或者flask之类的话,可能会想起jinja2
, 它们之间虽然语法不同,但是用起来原理是十分相似的东西。
在刚刚的代码示例中,我们就用到了自己定义的custom_page.EchartPage
这个模板,但是并没有用到qweb里面的语法,所以看起来和普通html代码并没有什么不同,接下来我会通过增加一些代码段来展示部分qweb的功能,以及我们如何在action
中实现主动的渲染部分页面实现局部更新。
如果想更深入的学习Qweb,请查阅官方教程
固定渲染
让我们再次打开echart.xml
页面,在div#app下面新增一段代码,此时那部分代码看起来是这样:
1 2 3 4 5 6 7 8 9 10 11 12 | ..... <div id="app" class="mt-2" style="width: 800px;height:500px;"> <p>echart</p> </div> <div id="app2" class="mt-2"> <ul> <t t-foreach="widget.dashboard_data['x_data']" t-as="i"> <li><t t-esc="i"/></li> </t> </ul> </div> ... |
这里用到了Qweb的一部分语法,不过也不难理解,就是循环widget.dashboard_data['x_data']
的数据,然后标记为变量i
,然后下方输出i
的值。其中widget
是渲染模板时默认传进来上下文数据,widget
可以简单的理解成是js模块中的this
接着回到demo_echart.js
文件,在init
方法中加入如下代码
1 2 | this.dashboard_data = {}; this.dashboard_data['x_data'] = ["衬衫","羊毛衫","雪纺衫","裤子","高跟鞋","袜子"] |
这时再刷新页面,就可以看到图表下方列出了新渲染的列表
动态渲染
刚刚展示了默认进来加载的渲染方式,美中不足的是这部分数据是在init中写死的,不能动态的向后台请求新数据再加载,让我们来利用之前写的接口,改改这部分的实现。
有些反应快的读者可能马上就想到接下来该如何做了: 我们首先删除刚刚在init新增的代码,接着在willStart
方法中增加异步方法获取数据。
1 2 3 4 5 6 7 8 9 10 11 | ... console.log("in action willStart!"); self._rpc({ route: '/custom_page/data/', params: {}, }).done(function(result) { console.log('willStart load data finish!', result); self.dashboard_data = {}; self.dashboard_data['x_data'] = result['x_data']; }); ... |
然而这次页面却报错了
仔细观察控制台的输出,我们可以发现先是输出报错,后面才输出我们在willStart
增加的异步加载方法。所以Odoo在渲染模板的时候,我们的字段是没有数据的,所以会报错。
对于这种情况,我们只能等在异步加载方法结束后,手动渲染模板,并插入页面中去。
再次回到echart.xml
,把刚刚新增的部分代码移出来,放入一个新的模板custom_page.EchartPage2
1 2 3 4 5 6 7 8 9 10 11 | <t t-name="custom_page.EchartPage2"> <div class="container-fluid mt-3"> <div id="app2" class="mt-2"> <ul> <t t-foreach="widget.dashboard_data['x_data']" t-as="i"> <li><t t-esc="i"/></li> </t> </ul> </div> </div> </t> |
接着回到demo_echart.js
文件,新增一个render_ul
的方法:
1 2 3 4 5 | render_ul: function() { var self = this; var template = "custom_page.EchartPage2" $('.container-fluid').append(core.qweb.render(template, {widget: self})); }, |
这段代码渲染了custom_page.EchartPage2
模板,然后把渲染好的元素插入DOM中,然后把这方法加入到willStart
异步执行的方法里:
1 2 3 | .... self.dashboard_data['x_data'] = result['x_data']; self.render_ul(); |
这时我们再刷新页面又可以在下方看到列表了,并且每次进来的时候都不一样。
UI组件
至此为止,页面运行的很正常,但是好像少了那么点意思,让我们利用一些odoo的组件丰富下交互吧。
遮罩层
回到demo_echart.js
文件中,在上方引入web.framework
1 | var framework = require('web.framework'); |
然后在按钮事件的方法中on_btn1_click
,开头和异步方法末尾分别新增两行代码:
1 2 3 4 5 6 | framework.blockUI(); .... .done(function(result) { .... framework.unblockUI(); }); |
此时重启odoo并打开页面,再次点击按钮时,屏幕会出现一个短暂的遮罩层,并随着数据加载完成而消失。
提醒
在framework.unblockUI();
下方新增一行代码
1 | self.do_notify('请求成功!', '数据已更新!'); |
此时点击按钮,随着遮罩消失时,右上角会出现友好提示。
对话框
在上方引入Dialog
组件:
1 | var Dialog = require('web.Dialog'); |
然后在刚刚的do_notify
方法下方新增代码:
1 2 3 4 5 6 7 8 9 10 11 | var dialog = new Dialog(self, { size: 'medium', title: '对话框', $content: '<p>这是一个对话框</p>', buttons: [ { text: "Cancel", close: true, }, ], }).open(); |
再次重启odoo并打开页面,此时会出现一个对话框。
执行odoo action
页面一直是在odoo内部运行的,有的读者可能会好奇,那么在这页面里面,可不可以像正常使用odoo那样,执行一些actions.act_window
呢? 答案是可以的!
新增一个id为btn2
的按钮,并绑定点击事件on_btn2_click
1 2 3 4 5 6 7 8 9 | on_btn2_click: function(event) { var self = this; self.do_action({ type: 'ir.actions.act_window', res_model: 'res.users', views: [[false, 'list'], [false, 'form']], target: 'new' }); }, |
此时刷新页面,点击新增的按钮,我们可以看到打开的用户列表视图