在使用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
47
|
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
5
|
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
16
|
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
24
|
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
,此时它看起来是这样的
1
2
3
|
jsLibs: [
'https://cdn.jsdelivr.net/npm/[email protected]/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'
});
},
|
此时刷新页面,点击新增的按钮,我们可以看到打开的用户列表视图

接下来
现在已经学会了创建定制action的基本流程,当然实际上开发过程中会碰到更复杂的情况,这就需要读者们更深入的学习和不断的尝试。
这里我推荐一些个人平时学习研究的方式:
- 官方关于js部分的一些讲解
- 从odoo自带的源码中学习:我们可以在odoo源码中搜索相关方法,看官方是如何使用的,直接依样画葫芦
- 查看Odoo OCA的扩展模块,学习思路
另外也可以从一些厉害的大神们博客中学习,比如