Odoo自定义视图教程

我们在Odoo开发时基本都会对模型定义相关视图,其中常常用到的有form,tree,kanban,另外还有calendar,pivot,graph等视图,可以说视图是Odoo很重要的一个组成部分。此外有时视图自带的功能无法满足需求时,我们还需要尝试去对视图做自定义扩展,所以适当的了解视图的背后的运行机制可以让我们更从容、高效的面对视图开发。

这篇教程中我会介绍如何定义视图,视图的基本运行流程,一些主要属性以及实战部分。为了避免篇幅过长,一些在 Odoo 中添加自定义dashboard页面已经讲解过相同功能点,这篇教程中我不再作讲解,如果读者学习本篇教程感觉困难,那么可以先阅读自定义dashboard的教程。

Prerequisite

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

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

本教程中的示例代码可以从https://github.com/findsomeoneyys/odoo-custom-view-tutorial中获取,仓库中的每个tag对应一个章节结束后的完整代码,读者可以通过类似以下方式来自由切换到不同章节代码。

1
git checkout v0.1

定义基本模型

可以通过git checkout v0.1查看本章节的完整代码

为了方便展示新视图,我们需要建立基本的模型,视图,和默认数据,这里我建了个Game模型,包含名称,下载量和平台字段。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# -*- coding: utf-8 -*-
from odoo import models, fields, api


class Game(models.Model):
    _name = 'echart_views.game'
    _description = 'Games'

    name = fields.Char('游戏名', required=True)
    downloads = fields.Integer(string='下载量', default=0)
    platform = fields.Char(string='平台')

安装上模块后,便可看到基本的视图。 alt

定义新视图

可以通过git checkout v0.2查看本章节的完整代码

定义一个新视图的操作量比较大,我们需要给odoo的python代码中增加新视图类型与视图模式,其次我们还需要定义js相关文件和模板代码。

让Odoo识别新视图类型

首先我们在model下建立两个文件ir_action_act_window.pyir_ui_view.py,然后加入相关代码,这是为了odoo可以识别我们新定义的视图tag,如果没有这部分代码,在加载相关的xml文件会报错并提示你odoo没有这种类型视图。 这里我把我的新视图命名为eview

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ir_action_act_window.py

# -*- coding: utf-8 -*-
from odoo import fields, models


class ActWindowView(models.Model):
    _inherit = 'ir.actions.act_window.view'

    view_mode = fields.Selection(selection_add=[('eview', 'echart views')])
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ir_ui_view.py

# -*- coding: utf-8 -*-
from odoo import fields, models


class View(models.Model):
    _inherit = 'ir.ui.view'

    type = fields.Selection(selection_add=[('eview', 'echart views')])

同时也别忘了在models/__init__.py中加入新增的class

1
2
3
4
5
# -*- coding: utf-8 -*-

from . import models
from . import ir_ui_view
from . import ir_action_act_window

增加视图所需js文件

Odoo的视图中的底层实现已经将相关功能抽象成几个部分,所以我们只需要继承并实现Odoo为我们预留好的逻辑即可, 一个完整的视图是由view, controller, model, renderer这几个组件组成的。Odoo的视图的实现使用了MVC设计模式,它们之间的关系如下图所示:

alt

其中需要注意的是MVC设计模式在视图中实际对应controller, model, renderer(MRC),这是因为View在Odoo中有特殊的历史含义(也就是我们提到的展示数据的一种视图类型)。在这几部分中,View更多充当一个入口的角色,类似后端的路由。

现在我们增加相关js文件与实现逻辑,同时我会讲解各个组件的相关生命周期函数。这里需要注意的是,相关代码注释中如果上面包含@returns {Deferred},则需要返回一个Deferred对象,这是因为odoo是通过这种方式来增加相关函数的回调执行,如果不返回Deferred对象,有时会产生程序错误,大部分的时候我们只需加上return this._super.apply(this, arguments)或者$.when()即可。

实现Controller

Odoo对于Controller部分抽象出web.AbstractController,所以我们只需继承这个类并填写相关逻辑。 在static/src/js新增eview_controller.js文件,并键入以下代码:

 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
odoo.define('echart_views.Controller', function (require) {
    'use strict';

    var AbstractController = require('web.AbstractController');
    var core = require('web.core');
    var qweb = core.qweb;

    var EchartController = AbstractController.extend({
        init: function (parent, model, renderer, params) {
            console.log("eview controller >>> init");
            this._super.apply(this, arguments);
        },
        /**
         * @returns {Deferred}
         */
        start: function() {
            console.log("eview controller >>> start");
            return this._super();
        },
        // 该方法会生成导航栏中的按钮,并可增加绑定按钮事件
        renderButtons: function ($node) {
            console.log("eview controller >>> renderButtons");
            this._super.apply(this, arguments);
        },
        /**
         * 执行该方法重新加载视图,默认逻辑是对调用update的封装
         * @param {Object} [params] This object will simply be given to the update
         * @returns {Deferred}
         */
        reload: function (params) {
            console.log("eview controller >>> reload");
            return this._super.apply(this, arguments);
        },
        /**
         * update是Controller的关键方法,在Odoo默认逻辑中,当用户操作搜索视图,或者部分内部更改会主动调用该方法。
         * 当我们自行编写相关方法时需要主动调用该函数。
         * 这个方法会调用model重新加载数据并通知renderer执行渲染
         * @param {*} params
         * @param {*} options
         * @param {boolean} [options.reload=true] if true, the model will reload data
         *
         * @returns {Deferred}
         */
        update: function (params, options) {
            console.log("eview controller >>> update");
            return this._super.apply(this, arguments);
        },
        /**
         * _update是update的回调方法,区别在于update是重新渲染页面主体部分,
         * _update则是渲染除了主体部分外的组件,比如控制面板中的组件 (buttons, pager, sidebar...)
         * @param {*} state
         * @returns {Deferred}
         */
        _update: function (state) {
            console.log("eview controller >>> _update");
            return this._super.apply(this, arguments);
        },


    });

    return EchartController;

});

实现Model

同样的,Model部分对应的抽象类是web.AbstractModel, Model是挂在Controller的一个对象,所有数据相关的部分都需要通过它来处理,这部分的主要逻辑很简单,只需要实现getload方法,通过rpc等方式向后台请求数据,将数据结果保存在对象上比如this.data=result,然后在get方法中返回this.data即可。

static/src/js新增eview_model.js文件,并键入以下代码:

 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
odoo.define('echart_views.Model', function (require) {
    'use strict';

    var AbstractModel = require('web.AbstractModel');

    var EchartModel = AbstractModel.extend({
        /**
         * 该方法需要返回renderer所需的数据
         * 数据可以通过load/reload执行相关获取数据方法时,设置到该对象上
         */
        get: function () {
            console.log("eview model >>> get");
            this._super();
        },
        /**
         * 只会初次加载时执行一次,需要自定义相关数据获取方法获取数据并设置到该对象上
         *
         * @param {Object} params
         * @param {string} params.modelName the name of the model
         * @returns {Deferred} The deferred resolves to some kind of handle
         */
        load: function (params) {
            console.log("eview model >>> load");
            return this._super.apply(this, arguments);
        },
        /**
         * 当有相关数据变动时,重新获取数据。
         *
         * @param {Object} params
         * @returns {Deferred}
         */
        reload: function (handle, params) {
            console.log("eview model >>> reload");
            return this._super.apply(this, arguments);
        },
    });

    return EchartModel;

});

实现Renderer

Renderer部分对应的抽象类是web.AbstractModel,renderer只需关注拿到数据并渲染页面即可,其中this.state对应的是Modelget方法获取的数据。

 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
odoo.define('echart_views.Renderer', function (require) {
    'use strict';

    var AbstractRenderer = require('web.AbstractRenderer');
    var core = require('web.core');

    var qweb = core.qweb;

    var EchartRenderer = AbstractRenderer.extend({
        init: function (parent, state, params) {
            console.log("eview renderer >>> init");
            this._super.apply(this, arguments);

        },
        /**
         *  renderer的渲染逻辑部分,自行渲染相关数据并插入this.$el中
         *
         * @abstract
         * @private
         * @returns {Deferred}
         */
        _render: function () {
            console.log("eview renderer >>> _render");
            var content = $("<div><p> eview </p></div>");
            this.$el.append(content);
            return this._super.apply(this, arguments);
        },
    });

    return EchartRenderer;

});

实现View

View对应的是web.AbstractView抽象类,是View的函数入口,它包含视图的基本定义信息,同时会根据传入的视图结构信息,相关参数初始化controller, model, renderer,当初始化controller完毕后,页面之后的相关处理都与这个类无关了。

static/src/js新增eview_view.js文件,并键入以下代码:

 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
odoo.define('echart_views.View', function (require) {
    'use strict';

    var AbstractView = require('web.AbstractView');
    var view_registry = require('web.view_registry');
    var Controller = require('echart_views.Controller');
    var eViewModel = require('echart_views.Model');
    var eViewRenderer = require('echart_views.Renderer');


    var EchartView = AbstractView.extend({
        display_name: 'EchartView',
        icon: 'fa-bar-chart',
        cssLibs: [
        ],
        jsLibs: [
        ],
        config: {
            Model: eViewModel,
            Controller: Controller,
            Renderer: eViewRenderer,
        },
        viewType: 'eview',
        groupable: false,
        /**
         * View的入口,会传入相关视图定义的参数(视图结构,字段信息等。。),
         * 函数会处理并生产3个主要字段:this.rendererParams, this.controllerParams,this.loadParams
         * 分别对应renderer,controller,model的初始化参数,我们可以根据需要自行对相关增加相关参数
         * @param {Object} viewInfo.arch
         * @param {Object} viewInfo
         * @param {Object} viewInfo.fields
         * @param {Object} viewInfo.fieldsInfo
         * @param {Object} params
         * @param {string} params.modelName The actual model name
         * @param {Object} params.context
         */
        init: function (viewInfo, params) {
            console.log("eview view >>> init");
            this._super.apply(this, arguments);
        },
        /**
         * View的主要的执行逻辑,这个方法会分别执行getModel,getRenderer初始化相关组件,
         * 然后对renderer, model设置controller就完成了作用,之后的View相关操作与这个类无关了
         * @param {}} parent 
         */
        getController: function (parent) {
            console.log("eview view >>> getController");
            return this._super.apply(this, arguments);
        },
        // 这里会初始化model,并执行model中load方法
        getModel: function (parent) {
            console.log("eview view >>> getModel");
            return this._super.apply(this, arguments);
        },
        getRenderer: function (parent, state) {
            console.log("eview view >>> getRenderer");
            return this._super.apply(this, arguments);
        },

    });

    view_registry.add('eview', EchartView);

    return EchartView;

});

加载资源与添加新视图

js部分实现后,我们需要把相关文件加载进odoo中,在views目录下新建文件templates.xml并添加相关代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8"?>
<odoo>

    <template id="assets_end" inherit_id="web.assets_backend">
        <xpath expr="." position="inside">

            <script type="text/javascript" src="/echart_views/static/src/js/eview_view.js" />
            <script type="text/javascript" src="/echart_views/static/src/js/eview_model.js" />
            <script type="text/javascript" src="/echart_views/static/src/js/eview_controller.js" />
            <script type="text/javascript" src="/echart_views/static/src/js/eview_renderer.js" />

        </xpath>
    </template>

</odoo>

然后在__manifest__.py中引入该文件,最后在views.xmlact_window添加我们的新视图模式,以及我们的新视图定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<record id='echart_views_game_action' model='ir.actions.act_window'>
    <field name="name">Games</field>
    <field name="res_model">echart_views.game</field>
    <field name="view_type">form</field>
    <field name="view_mode">tree,form,eview</field>
</record>

...

<!-- eview View -->
<record id="echart_views_game_view_eview" model="ir.ui.view">
    <field name="name">Game echart view</field>
    <field name="model">echart_views.game</field>
    <field name="arch" type="xml">
        <eview>
            <field name="name"/>
            <field name="downloads"/>
            <field name="platform"/>
        </eview>
    </field>
</record>

小结

完成以上步骤后,重启Odoo并更新模块,打开debug=assets模式并进入视图,我们即可看到新增的视图效果与组件和相关函数的加载顺序了。 alt

实战

在前面的教程中我们了解到了views的组件初始化与生命周期函数,这也意味着我们可以在相关周期函数中加入自己的一系列事件,来实现我们自己独特的视图。 在接下来的章节中我会逐步实现加入视图模板,解析视图字段,事件绑定与处理等功能来实现一个基于echart的自定义饼图,这个视图中我们可以左上角自由切换定义在xml中的字段,饼图中则会统计该字段在数据库的全部数据:如果字段是数值,根据Name自动分类叠加,如果字段是字符串,则对该字段分组统计数量。

自定义模板与按钮事件绑定

可以通过git checkout v0.3查看本章节的完整代码

在 Odoo 中添加自定义dashboard页面中的模板渲染流程一样,首先我们在eview_view.jsjsLibs中加上echart。

1
2
3
4
5
...

jsLibs: [
            'https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js',
        ],

接着在static/src/xml新增qweb_template.xml文件并增加模板代码,同时在__manifest__.py中引入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
qweb_template.xml

<?xml version="1.0" encoding="UTF-8"?>
<templates>

    <t t-name="echart_views.page">
        <div class="container-fluid mt-3">
            <div id="app" class="mt-2" style="width: 800px;height:500px;">
                <p>echart</p>
            </div>
        </div>
    </t>

</templates>
1
2
3
4
5
# __manifest__.py

'qweb': [
        'static/src/xml/qweb_template.xml',
    ]

然后在eview_renderer.js中做相关处理即可,这里我直接根据echart-饼图演示实现相关功能。

init中加入option参数,同时在_render中渲染模板并初始化echart

 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
48
49
50
51
52
53
54
55
56
57
58
59
init: function (parent, state, params) {
    console.log("eview renderer >>> init");
    this._super.apply(this, arguments);
    this.echart_option = {
        tooltip: {
            trigger: 'item',
            formatter: '{a} <br/>{b}: {c} ({d}%)'
        },
        legend: {
            orient: 'vertical',
            left: 10,
            data: ['直接访问', '邮件营销', '联盟广告', '视频广告', '搜索引擎']
        },
        series: [
            {
                name: '访问来源',
                type: 'pie',
                radius: ['50%', '70%'],
                avoidLabelOverlap: false,
                label: {
                    normal: {
                        show: false,
                        position: 'center'
                    },
                    emphasis: {
                        show: true,
                        textStyle: {
                            fontSize: '30',
                            fontWeight: 'bold'
                        }
                    }
                },
                labelLine: {
                    normal: {
                        show: false
                    }
                },
                data: [
                    {value: 335, name: '直接访问'},
                    {value: 310, name: '邮件营销'},
                    {value: 234, name: '联盟广告'},
                    {value: 135, name: '视频广告'},
                    {value: 1548, name: '搜索引擎'}
                ]
            }
        ]
    };
    

},
_render: function () {
    console.log("eview renderer >>> _render");
    this.$el.empty();
    this.$el.append(qweb.render('echart_views.page'));
    var el = this.$el.find('#app')[0];
    var myChart = echarts.init(el);
    myChart.setOption(this.echart_option);
    return this._super.apply(this, arguments);
},

更新模板并刷新页面,再次打开eview时,我们就会看到一个饼图: alt

接着我们为导航栏增加按钮,视图导航栏的按钮就是类似tree视图中创建、导入等按钮,通过重写Controller中的renderButtons方法便可轻松实现。 我们继续在qweb_template.xml中新增按钮组的模板代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<t t-name="echart_views.buttons">
    <div class="btn-group" role="toolbar" aria-label="Main actions">
        <button class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
            统计字段
        </button>
        <div class="dropdown-menu o_echart_measures_list" role="menu">
            <a class="dropdown-item" href="#" data-field="name">名字</a>
            <a class="dropdown-item" href="#" data-field="downloads">下载量</a>
        </div>
    </div>
</t>

eview_controller.js中修改renderButtons函数,渲染按钮组并为它们绑定事件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
renderButtons: function ($node) {
    console.log("eview controller >>> renderButtons");
    this._super.apply(this, arguments);
    this.$buttons = $(qweb.render('echart_views.buttons'));
    this.$measureList = this.$buttons.find('.o_echart_measures_list');
    this.$buttons.click(this._onButtonClick.bind(this));
    this.$buttons.appendTo($node);
},

....

_onButtonClick: function (event) {
    var $target = $(event.target);
    var field;
    if ($target.parents('.o_echart_measures_list').length) {
        event.preventDefault();
        event.stopPropagation();
        field = $target.data('field');
        _.each(this.$measureList.find('.dropdown-item'), function (item) {
            var $item = $(item);
            $item.toggleClass('selected', $item.data('field') === field);
        });
    }
},

再次进入页面,我们可以发现导航栏部分多了 ‘统计字段’下拉按钮,点击相关选项,按钮组就会处于激活状态 alt

获取视图定义结构信息

可以通过git checkout v0.4查看本章节的完整代码

刚刚我们的例子中按钮组的数组是写死的,现在我们来做一些改动,把这部分修改成根据我们定义在xml的字段动态生成下拉菜单。

在我们使用Odoo原生xml定义的field中,是可以自定义添加属性,并且odoo对应会有不一样的行为,比如加上invisible=1时,该字段在视图中会自动隐藏,现在我们也为eview视图中做一些类似的自定义属性处理,我们增加一个type属性,type="name"代表这个字段是记录的显示名字,type="measure"代表这个字段是可加入我们按钮组的下拉菜单中。

现在我们打开views/views.xml,修改eview的视图,为field加上type属性, 此外 也为eview加上一个chart="bar"属性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<record id="echart_views_game_view_eview" model="ir.ui.view">
    <field name="name">Game echart view</field>
    <field name="model">echart_views.game</field>
    <field name="arch" type="xml">
        <eview chart="bar">
            <field name="name" type="name"/>
            <field name="downloads" type="measure"/>
            <field name="platform" type="measure"/>
        </eview>
    </field>
</record>

之前提的介绍中提到过视图的结构信息都是会传入View的init方法中,其中this.arch包含Odoo的已经为我们解析好的视图结构化数据,this.fields则包含对应模型中全部字段的信息(包括魔法字段),在debug=assets控制台打断点输出,我们可以轻松看到完整的结构。 alt

知道了数据结构后剩下的事就简单多了,我们自定义三个参数displayNameField, measure, measures,分别表示哪个字段对应记录的显示名称,视图当前所选择的统计字段,统计字段的所对应字段定义信息。其中measure, measures会在下章节中使用到。 现在我们回到eview_view.js,修改init方法为如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
init: function (viewInfo, params) {
    console.log("eview view >>> init");
    this._super.apply(this, arguments);

    var self = this;
    var displayNameField;
    var measure;
    var measures = {};

    this.arch.children.forEach(function (field) {
        var fieldName = field.attrs.name;
        if (field.attrs.type === 'measure') {
            if (!measure) {
                measure = fieldName;
            }
            measures[fieldName] = self.fields[fieldName];
        } else if(field.attrs.type === 'name') {
            displayNameField = fieldName;
        }
    });

    this.controllerParams.measures = measures;
},

这段代码中最后一句this.controllerParams.measures = measures;代表我们为Controller的初始参数中添加measures属性,这样我们可以在Controller获取到 measures数据,到时就可使用这部分数据来渲染模板。 接着我们打开eview_controller.jsinit中接收measures字段,并在renderButtons使用这部分数据渲染视图:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
init: function (parent, model, renderer, params) {
    console.log("eview controller >>> init");
    this._super.apply(this, arguments);
    this.measures = params.measures;
}

....

renderButtons: function ($node) {
    console.log("eview controller >>> renderButtons");
    this._super.apply(this, arguments);
    var context = {
            measures: _.sortBy(_.pairs(this.measures), function (x) {
                return x[1].string.toLowerCase();
            }),
        };
    this.$buttons = $(qweb.render('echart_views.buttons', context));

    .....
},

最后打开xml/qweb_template.xml,将下拉选项部分改成模板语法渲染

1
2
3
4
5
<div class="dropdown-menu o_echart_measures_list" role="menu">
    <t t-foreach="measures" t-as="measure">
        <a role="menuitem" href="#" class="dropdown-item" t-att-data-field="measure[0]"><t t-esc="measure[1].string"/></a>
    </t>
</div>

此时重启Odoo并更新模块,再次进入视图我们可以发现统计字段的选项改变了,我们也可以自行尝试在xml中去除相关field,统计字段的选项也会对应动态改变。

通过Model在页面中传递数据

可以通过git checkout v0.5查看本章节的完整代码

在视图操作用经常会改变数据,数据改变后我们需要及时处理相关数据并更新视图,在这章里我们将改进按钮组的相关处理,当点击选项时,数据会更新到model中并实时更新我们的视图页面。

回到eview_view.js,在init末尾加上model的初始参数

1
2
3
4
5
6
7
8
init: function (viewInfo, params) {

    ...

    this.loadParams.measure = measure;
    this.loadParams.measures = measures;
    this.loadParams.displayNameField = displayNameField || 'display_name';
},

然后打开eview_model.js,在loadreload的方法中增加获取相关字段值逻辑,同时修改get方法为返回相关字段数据,这里返回数据的部分设置measureString字段来返回对应字段的定义名称。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
get: function () {
    console.log("eview model >>> get");
    var measureString = this.measures[this.measure]['string'];
    return {measure: this.measure, measureString: measureString};
},
load: function (params) {
    console.log("eview model >>> load");
    this.measure = params.measure;
    this.measures = params.measures;
    this.displayNameField = params.displayNameField;
    return this._super.apply(this, arguments);
},
reload: function (handle, params) {
    console.log("eview model >>> reload");
    if ('measure' in params) {
        this.measure = params.measure;
    }
    return this._super.apply(this, arguments);
},

同时我们要修改Controller的逻辑,当有数据变动时,我们需要通过调用update方法来更新数据,update会自动代入参数调用model中的reload方法, 同时,触发视图的_render方法重新渲染数据。现在我们稍微修改下_onButtonClick的逻辑

 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
_onButtonClick: function (event) {
    var $target = $(event.target);
    var field;
    if ($target.parents('.o_echart_measures_list').length) {
        event.preventDefault();
        event.stopPropagation();
        field = $target.data('field');
        this._setMeasure(field);
    }
},
_setMeasure: function (measure) {
    var self = this;
    this.update({measure: measure}).then(function () {
        self._updateButtons();
    });
},
_updateButtons: function () {
    if (!this.$buttons) {
        return;
    }
    var state = this.model.get();
    _.each(this.$measureList.find('.dropdown-item'), function (item) {
        var $item = $(item);
        $item.toggleClass('selected', $item.data('field') === state.measure);
    });
},

这里把逻辑拆成了两个方法,一个是_updateButtons,他会通过model.get()来获取当前的数据,然后激活下拉菜单的选项状态,另外一个是_setMeasure, 这个方法的逻辑也很简单,就是对update的一个封装。此外我们再把_updateButtons方法也放在renderButtons中调用下,这样初次加载视图时也会有默认的选项激活状态

1
2
3
4
5
renderButtons: function ($node) {
    ...
    this._updateButtons();
    this.$buttons.appendTo($node);
},

最后我们要修改下renderer里面的_render方法,根据model里面的数据来渲染页面。我们为option增加个title属性,并在_render方法中设置这个属性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
this.echart_option = {
    ...
    title: {
        text: '',
        left: 'center',
        top: 20,
        textStyle: {
            color: '#ccc'
        }
    },
};

....

_render: function () {
    console.log("eview renderer >>> _render");
    this.$el.empty();

    this.echart_option.title.text = this.state.measureString;
    ....
},

Odoo会自动把从model.get()的数据放到this.state中,直接获取即可。

刷新页面,此时我们我们可以看到点击下拉选项时,页面会刷新,同时上方标题属性会显示对应字段的定义名。 alt

在Model向后台请求数据

可以通过git checkout v0.6查看本章节的完整代码

到目前为止,我们基本完成了视图的基本功能了,接下来我们要增加model的逻辑,向后台获取再渲染显示。在eview_model.js中新增一个_fetchData方法获取数据,同时在其他需要获取数据的方法中调用这个函数。

 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
get: function () {
    console.log("eview model >>> get");
    return this.data;
},
load: function (params) {
    console.log("eview model >>> load");
    this.modelName = params.modelName;
    this.domain = params.domain || [];
    this.measure = params.measure;
    this.measures = params.measures;
    this.displayNameField = params.displayNameField;
    return this._fetchData();
},
reload: function (handle, params) {
    console.log("eview model >>> reload");
    if ('measure' in params) {
        this.measure = params.measure;
    }
    if ('domain' in params) {
        this.domain = params.domain;
    }
    return this._fetchData();
},
_fetchData: function () {
    var self = this;
    var measureFieldInfo = this.measures[this.measure];
    var measureString = measureFieldInfo['string'];
    var seriesLegend = [];
    if (measureFieldInfo.type === 'integer') {
        return this._rpc({
            model: this.modelName,
            method: 'search_read',
            domain: this.domain,
            fields: [this.measure, this.displayNameField],
        }).then(function (result) {
            var seriesData = _.map(result, function (data) {
                return {value: data[self.measure], name: data[self.displayNameField]}
            });
            _.each(seriesData, function (d) {
                seriesLegend.push(d['name']);
            });
            self.data = {seriesData: seriesData, measureString: measureString, measure: self.measure, seriesLegend: seriesLegend};
        });
    } else {
        return this._rpc({
            model: this.modelName,
            method: 'search_read',
            domain: this.domain,
            fields: [this.measure],
        }).then(function (result) {
            var resGroupAndCount = _.pairs(_.countBy(result, function(o){return o[self.measure]}));
            var seriesData = _.map(resGroupAndCount, function (data) {
                return {value: data[1], name: data[0]}
            });
            _.each(seriesData, function (d) {
                seriesLegend.push(d['name']);
            });
            self.data = {seriesData: seriesData, measureString: measureString, measure: self.measure, seriesLegend:seriesLegend};
        });
    }
},

这段代码中在初始化数据中加入Odoo参数中的模型名modelName,和搜索框的内容domain,之后通过_rpc方法调用模型自带的search_read获取字段数据,接着在根据字段类型进行分类统计把数据归纳出来放入self.data中。 之后在eview_renderer.js里把optiondata部分删除,接着和上个章节一样,在_renderer设置相关字段。

1
2
3
4
5
6
...
this.echart_option.title.text = this.state.measureString;
this.echart_option.series[0].name = this.state.measureString;
this.echart_option.series[0].data = this.state.seriesData;
this.echart_option.legend.data = this.state.seriesLegend;
...

完成后刷新页面再次进入视图,点击选项Odoo会自动从后台获取对应字段的数据,同时右上角的搜索框我们也可以自由输入数据过滤结果。 alt

让Crontroller处理组件自定义事件

可以通过git checkout v0.7查看本章节的完整代码

在之前的绑定点击事件是指针对按钮组的,实际上当renderer为我们渲染好页面的时候,我们也会有要处理页面相关事件的要求,这个实际上也很简单: 在qweb_template.xml中div上方我们新增一个按钮:

1
2
3
4
5
6
<div class="container-fluid mt-3">
    <button class="btn btn-primary ml-2" id="reloadView">重新加载</button>
    <div id="app" class="mt-2" style="width: 800px;height:500px;">
        <p>echart</p>
    </div>
</div>

然后在eview_renderer.js中加入事件注册和相关处理函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
events: _.extend({}, AbstractRenderer.prototype.events, {
    'click #reloadView': '_onClickReloadView',
}),

...

_onClickReloadView: function (ev) {
    ev.preventDefault();
    console.log("eview renderer >>> _onClickReloadView");
    
}

这时刷新页面点击按钮,可以看到控制台的对应输出。但是这只能处理特定元素上的事件,有时候我们会希望点击后整个视图能响应到变化,做一些特别的处理,这时候就要主动触发一个OdooEvent,同时Controller里面加入对应事件处理,比如接下来的代码中就实现了点击按钮让视图重新加载的功能:

修改renderer中的_onClickReloadView函数,在里面主动通过trigger_up触发一个OdooEvent

1
2
3
4
5
6
_onClickReloadView: function (ev) {
    ev.preventDefault();
    console.log("eview renderer >>> _onClickReloadView");
    this.trigger_up('reload_view');
    
}

eview_controller.js中加入相关事件处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
    'reload_view': '_onClickReloadView',
}),

...

_onClickReloadView: function (ev) {
    console.log("eview controller >>> _onClickReloadView");
    this.reload();
},

再次刷新页面点击按钮,可以看到echart的饼图会重新载入。

小结

这篇教程中我详细介绍了View的各个组件的作用、主要函数和交互方式,同时包含了一个简短的教程。虽然Odoo自带的视图实现是会更复杂的,但是基本的主要逻辑还是与教程中一致。

当明白了View的整体运行逻辑后,我们再面对视图时对于它们的运行机制就不至于一头雾水了,比如官方tree视图是不支持在导航栏那增加自定义按钮的,而我们可以通过继承ListController并重写renderButtons方法的方式来实现我们自定义视图。

当然这篇教程中所使用到的相关参数只是Odoo视图其中的冰山一角,比如其中还有enableTimeRangeMenu来实现按时间过滤分组参数等,更丰富的组件可以在官方源码研究。

comments powered by Disqus