在 Odoo 中添加自定义dashboard页面

在使用Odoo开发时,有时会有这样的业务需求: 希望可以设计一个dashboard,以图表可视化的方式来展现相关数据。
其实Odoo内置的模块中很多页面都有实现了类似的功能,然而可惜的是官方对于这部分的教程Customizing the web client还是基于Odoo 8.0写的,已经过时很久了。
虽然网上也有像Ruter大神写的相关基础教程,但是为了照顾读者,一些比较深入的功能并没有提及到,本教程会在基于Ruter教程上,示范一些更深入的功能点。

一旦完成本教程,你的页面看起来会是这样子的

alt

Prerequisite

本教程基于以下环境开发:

  • 系统: windows wsl - Ubuntu 18.04
  • Odoo: Nightly Odoo 构建的post-20200101 12.0 版本
  • 数据库: PostgreSQL 10.11

在阅读本教程时,我会假定你已经具备了以下相关基础知识:

教程目标

通过本教程你将学会以下知识:

  • 了解定制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"

命令行的输出可以检测你是否正确配置了路径

alt

接着访问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,会看到新增的页面与控制台的相关输出:

alt

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实例, 否则在异步代码段里面的获取到的thisdocument, 就无法获取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'];
});
...

然而这次页面却报错了

alt

仔细观察控制台的输出,我们可以发现先是输出报错,后面才输出我们在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'
    });
},

此时刷新页面,点击新增的按钮,我们可以看到打开的用户列表视图

alt