本章中,我们将讲解如下小节:
- 创建自定义组件
- 使用客户端QWeb模板
- 向服务端做RPC调用
- 新建一个视图
- 调试客户端代码
- 通过导览提升客户引导
- 移动应用JavaScript
引言
Odoo的网页客户端或后台,是公司成员花费最多时间的地方。在第十章 后端视图中,我们学习了如何使用后台中所存在的可能性。这里我们将学习如何继承和自定义这些可能性。web模块包含有关Odoo用户界面的所有内容。
本章中的所有代码都依赖于web模块。你已经知道Odoo有两个版本(企业版和社区版)。社区版的用户界面使用web模块,而企业版使用一个继承社区版web模块的版本,即web_enterprise模块。
企业版在社区版web模块的基础上提供了一些其它功能,如移动端兼容、可搜索菜单、材料设计等等。这里我们使用社区版。但不必担心,社区版中所开发的模块与企业版可以完美兼容,因为,在内部web_enterprise依赖于社区版的web模块,只是对其添加了一些功能。
本章中,我们将学习如何创建新字段组件来从用户获取输入。我们还将从零开始新建视图。在阅读完本章后,你将能够在Odoo后台中创建自己的UI元素。
ℹ️**注意:**Odoo 的用户界面重度依赖于JavaScript。在本章中我们会假定你已具备JavaScript, jQuery, Underscore.js和SCSS的基础知识。
技术准备
本章的技术要求有在线Odoo平台。
本章中的所有代码可通过GitHub仓库进行下载:https://github.com/PacktPublishing/Odoo-12-Development-Cookbook-Third-Edition/tree/master/Chapter16。
观看如下视频来查看实时代码操作:
创建自定义组件
正如在第十章 后端视图中所看到的,我们可以使用组件来以不同格式展示某些数据。例如,我们使用了widget=‘image’将二进制字段展示为图片。要演示如何创建你自己的组件,我们将编写一个允许用选择整型字段的组件,但还会进行不同的展示。使用的不是输入框,而是展示为颜色拾取器,这样我们我们可以选择颜色数值。
准备工作
本节中我们将使用第四章 创建Odoo插件模块中所创建的my_library模块。本节中我们会新建一个依赖于web模块的字段组件。确保在声明文件中添加了对web的依赖,如下所示:
...
'depends': ['base', 'web'],
...
如何实现…
我们将添加包含组件逻辑的JavaScript文件,以及负责样式的SCSS文件。然后,我们将使用新的组件在图书表单中添加一个整型字段。按照如下步骤来新增一个字段组件:
-
添加一个static/src/js/field_widget.js让你说的。此处所使用的语法可参见
第十五章 CMS网站开发
中
为网站扩展CSS和JavaScript
一节:
odoo.define('my_field_widget', function (require) { "use strict"; var AbstractField = require('web.AbstractField'); var fieldRegistry = require('web.field_registry'); -
通过继承AbstractField来创建组件:
var colorField = AbstractField.extend({ -
设置CSS类、根元素标签以及组件所支持的字段类型:
className: 'o_int_colorpicker', tagName: 'span', supportedFieldTypes: ['integer'], -
捕获一些JavaScript事件:
events: { 'click .o_color_pill': 'clickPill', }, -
重载init来进行初始化:
init: function () { this.totalColors = 10; this._super.apply(this, arguments); }, -
重载_renderEdit和_renderReadonly来设置DOM元素:
_renderEdit: function () { this.$el.empty(); for (var i = 0; i < this.totalColors; i++ ) { var className = "o_color_pill o_color_" + i; if (this.value === i ) { className += ' active'; } this.$el.append($('<span>', { 'class': className, 'data-val': i, })); } }, _renderReadonly: function () { var className = "o_color_pill active readonly o_color_" + this.value; this.$el.append($('<span>', { 'class': className, })); }, -
定义我们在前面所引用的handler:
clickPill: function (ev) { var $target = $(ev.currentTarget); var data = $target.data(); this._setValue(data.val.toString()); } }); // closing AbstractField -
别忘了注册你的组件:
fieldRegistry.add('int_color', colorField); -
让其在其它插件中可用:
return { colorField: colorField, }; }); // closing 'my_field_widget' namespace -
在static/src/scss/field_widget.scss中添加一些SCSS:
.o_int_colorpicker { .o_color_pill { display: inline-block; height: 25px; width: 25px; margin: 4px; border-radius: 25px; position: relative; @for $size from 1 through length($o-colors) { &.o_color_#{$size - 1} { background-color: nth($o-colors, $size); &:not(.readonly):hover { transform: scale(1.2); transition: 0.3s; cursor: pointer; } &.active:after{ content: "\f00c"; display: inline-block; font: normal normal normal 14px/1 FontAwesome; font-size: inherit; color: #fff; position: absolute; padding: 4px; font-size: 16px; } } } } } -
在views/templates.xml后台资源中注册这两个文件:
<?xml version="1.0" encoding="UTF-8"?> <odoo> <template id="assets_end" inherit_id="web.assets_backend"> <xpath expr="." position="inside"> <script src="/my_field_widget/static/src/js/field_widget.js" type="text/javascript" /> <link href="/my_field_widget/static/src/scss/field_widget.scss" rel="stylesheet" type="text/scss" /> </xpath> </template> </odoo> -
最后在library.book模型中添加颜色整型字段:
color = fields.Integer() -
在图书表单中添加颜色字段,同时添加widget=“int_color”:
... <group> <field name="date_release"/> <field name="color" widget="int_color"/> </group> ...
更新模块来应用修改。在更新完成后,打开图书表单视图,就可以看到下图中所示的颜色拾取器:

实现原理…
为便于读者掌握示例,我们来通过查看其组件来看一下组件的生命周期:
- init():这是组件构造函数。用于进行初始化。在初始化组件时,会先调用该方法。
- willStart():这个方法组件初始化以及在DOM中添加的过程中调用。它用于异步将数据初始化到组件中。它还会返回一个延迟对象,只需要通过super()调用即可获取。我们在后面的小节中将会使用到它。
- start():该方法在完成组件渲染且未添加到DOM中时调用。这非常有助于后渲染任务,将返回一个延迟对象。可以在this.$el中访问已渲染的对象。
- destroy():该方法在消灭组件时调用。它多用于基本的清理操作,如取消事件绑定。
**小贴士:**组件的基本 base 类是Widget(在web.Widget中定义)。如果想要做更深入的探讨,可以通过/addons/web/static/src/js/core/widget.js进行研究。
在第1步中,我们导入了AbstractField和fieldRegistry。
在第2步中,我们通过继承AbstractField创建了colorField。这样我们的colorField就会获取到AbstractField所有属性和方法。
第3步中,我们添加了3个属性 - className用于定义组件根元素的类,tagName用于根元素的类型,supportedFieldTypes用于决定该组件所支持的字段类型。在本例中,我们希望创建一个针对整型字段的组件。
第4步中,我们对组件的事件进行了映射。通常键为事件名和可选CSS选择器的组合。事件和CSS选择器由空格分割,值为组件方法的名称。因此,在执行事件时,指定的方法会自动调用。在本节中,当用户点击颜色块时,我们需要在字段中设置一个整型值。为管理点击事件,我们在事件的键中添加了一个CSS选择器和方法。
第5步中,我们重载了init方法并设置了totalColors属性的值。我们将使用该变量来决定颜色块的值。这里需要显示10个颜色块,因此将值设为了10.
第6步中,我们添加了两个方法 - _renderEdit和_renderReadonly。根据名称可以知道,_renderEdit在组件处于编辑模式时调用,而_renderReadonly在组件处于只读模式时调用。在编辑方法中,我们添加了一些标签,每个都在组件中表示一种颜色,并会在表单视图中添加。在点击标签时,会在字段中设置值。我们将它们添加到了 this.el是组件的根元素,它会在表单视图中进行添加。在只读模式下,我们仅需展示激活的颜色,因此通过_renderReadonly()方法添加了单个颜色块。就目前而言,我们以硬编码的方式添加了色块,但在下一节中,我们将使用JavaScript Qweb模板来渲染这些色块。注意在编辑模式下,我们使用了totalColors属性,它通过init()进行的设置。
第7步中,我们添加了clickPill处理方法来管理色块的点击。使用了_setValue方法来设置字段值。该方法通过AbstractField类添加。在设置字段值时,Odoo框架会重新渲染组件并再次调用_renderEdit方法,这样就可以通过更新后的值来渲染组件了。
第8步中,在定义了新组件之后,通过web.field_registry中的表单注册表来注册它非常关键。注意所有的视图类型都查找这个注册表,因此如果希望在列表视图中创建另一种展现字段的形式,也需要在这里添加组件并在视图定义中对该字段设置组件属性。
最后,我们导出了组件类,这样其它的插件可以对其进行扩展和继承。然后,我们在library.book模型中添加了一个新的名为color的整型字段。我们还通过widget=“int_color”属性在表单视图中添加了相同的字段。这会在表单视图中展示我们的组件,用于代替默认的整型组件。
扩展知识…
web.mixins命名空间定义了几个mixin帮助类,在开发表单组件时就记得使用。AbstractField通过继承Widget类来创建,而Widget继承了两个mixin。第一个为EventDispatcherMixin,它提供添加并触发事件handler的简单接口。第二个是ServicesMixin,提供RPC调用和动作的函数。
ℹ️在想要重载一个方法,应保持先研究其基类来了解它所返回的函数。很常见的一个错误是忘记返回超级用户的延迟对象,会导致异常操作时的问题。
组件需要负责验证。使用isValid函数来自定义自己的验证。
使用客户端QWeb模板
就像在JavaScript中创建HTML代码是一种不好的编程习惯一样,我们应在客户端JavaScript代码尽量少的创建DOM元素。幸好在客户端也有一个模板引擎,更幸运的是,客户端的模板引擎和服务端模板语法相同。
准备工作
本节中,我们将使用前一小节中的my_library模块。我们将通过把DOM元素创建移到QWeb来让其更加的模块化。
如何实现…
我们需要在声明文件中添加QWeb定义并修改JavaScript代码来进行使用。按照如下步骤来进行操作:
-
导入web.core并将对qweb的引用提取为一个变量,如以下代码所示:
odoo.define('my_field_widget', function (require) { "use strict"; var AbstractField = require('web.AbstractField'); var fieldRegistry = require('web.field_registry'); var core = require('web.core'); var qweb = core.qweb; ... -
修改_renderEdit函数来仅渲(继承自组件的)该元素:
_renderEdit: function () { this.$el.empty(); var pills = qweb.render('FieldColorPills', {widget: this}); this.$el.append(pills); }, -
向static/src/xml/qweb_template.xml中添加模板文件:
<?xml version="1.0" encoding="UTF-8"?> <templates> <t t-name="FieldColorPills"> <t t-foreach="widget.totalColors" t-as='pill_no'> <span t-attf-class="o_color_pill o_color_#{pill_no} #{widget.value === pill_no and 'active' or ''}" t-att-data-val="pill_no"/> </t> </t> </templates> -
在声明文件中注册QWeb文件:
"qweb": [ 'static/src/xml/qweb_template.xml', ],
现在,通过其它插件,我们修改组件所使用的HTML代码更为容易了,因为它们可以通过QWeb模式来进行重写。
实现原理…
因为在第十五章 CMS网站开发中创建或更改模板 - QWeb一节已有对QWeb基础知识非常全面的讨论,这里会集中讲解不同的地方。首先,需要注意到我们处理的是JavaScript QWeb的实现,而不是服务端中的Python实现。就是说我们没有读取记录或环境的权限,仅能访问qweb.render函数所传递的参数。
在本例中,我们通过组件的键传递了当前对象。这表示应将所有的逻辑放在组件的JavaScript代码中,只让模板访问属性或一种可能用到的函数。既然我们能访问所有组件中可用的属性,可以通过检查totalColors属性来在对模板的值进行检测。
客户端QWeb和QWeb视图间并没有关系,所以存在不同的机制来让网页客户端识别模板 - 通过qweb键以相对插件根路径的文件名列表将它们添加到插件的声明中。
扩展知识…
这里使用QWeb的原因是其可扩展性,它是客户端与服务端QWeb的另一个很大的区别。在客户端无法使用XPath表达式,需要使用jQuery选择器和操作。例如我们想要在另一个模块的组件中添加用户图标,需要在每个色块中添加如下代码来获取图标:
<t t-extend=“FieldColorPills”>
<t t-jquery=“span” t-operation=“prepend”>
<i class=“fa fa-user” />
</t>
</t>
如果在这里同时给定一个t-name 属性的话,会产生原模板的一个副本并不会动到原模板。 t-operation属性的其它可用值有append, before, after, inner和replace,会让t元素的内容要么通过append将其添加到匹配内容之后,要么通过before或after将其放到匹配元素之前或之后,要么通过inner将其替换所配置的元素内容,又或者通过replace替换掉整个元素。还有t-operation=‘attributes’,它让我们可以在匹配的元素上设置属性,规则 与服务端QWeb相同。‘
另一个不同是客户端QWeb中的名称不由模块名建立命名空间,因此需要选择模板的名称来让其在所安装的所有插件模块中保持唯一,这也是开发者常常使用很长名称的原因。
其它内容
如果想要学习Qweb模板的更多内容,可参照如下各点:
- 客户端QWeb引擎的氏误消息和处理较Odoo其它部分来得更为不方便。小错误常常并不代表有任何问题,让初学者很难往下使用。
- 幸而,本章稍后的调试客户端代码一节中会讲解客户端QWeb模板的一些调试语句。
向服务端做RPC调用
迟早你的组件会需要从服务端查找一些数据。本节中,我们将为颜色块添加一条提示信息。在用户将光标悬停在颜色块元素上时,提示框中会显示与该颜色相关的图书的数量。我们将会对服务端做一个RPC调用来获取指定颜色关联数据的图书数量。
准备工作
本节中,我们将使用上一节中的my_library模块。
如何实现…
执行如下步骤来对服务端做RPC调用并在提示框中显示结果:
-
添加willStart方法并在RPC调用中设置colorGroupData:
willStart: function () { var self = this; this.colorGroupData = {}; var colorDataDef = this._rpc({ model: this.model, method: 'read_group', domain: [], fields: ['color'], groupBy: ['color'], }).then(function (result) { _.each(result, function (r) { self.colorGroupData[r.color] = r.color_count; }); }); return $.when(this._super.apply(this, arguments), colorDataDef); }, -
更新_renderEdit并对颜色块设置初始提示框:
_renderEdit: function () { this.$el.empty(); var pills = qweb.render('FieldColorPills', {widget: this}); this.$el.append(pills); this.$el.find('[data-toggle="tooltip"]').tooltip(); }, -
更新FieldColorPills模板并添加提示数据:
<t t-name="FieldColorPills"> <t t-foreach="widget.totalColors" t-as='pill_no'> <span t-attf-class="o_color_pill o_color_#{pill_no} #{widget.value === pill_no and 'active' or ''}" t-att-data-val="pill_no" data-toggle="tooltip" data-placement="top" t-attf-title="This color is used in #{widget.colorGroupData[pill_no] or 0 } books." /> </t> </t>
更新模块来应用修改。在更新后,就可以在色块上看到如下图所示的提示信息:

实现原理…
willStart函数在渲染之前调用,更重要的是,它返回一个必须在渲染开始之前解析的延迟对象。因此,在像我们这样的用例中,因需要在渲染发生之前运行异步动作,这是应使用的正确函数。
在处理数据访问时,我们依赖于前面讲解的ServicesMixin类所提供的_rpc函数。该函数允许我们调用模型上的任意公有函数,如search, read, write或本例中的read_group。
第1步中,我们做了一个RPC调用并在当前模型本例中即library.book上调用了read_group方法。我们根据color字段进行了分组,因此RPC调用会返回由color分组的图书数据并在color_count键中添加了累加。我们还在colorGroupData中建立了color_count与color索引的映射,这样就可以在QWeb模板中使用了。在该函数的最后一行中,我们通过super来解析了willStart以使用$.when来进行了RPC调用。因此,渲染仅在获取值之后及super所执行的任意异步动作完成之后发生。
第2步并没有什么特别的内容。我们只是初始化了起始提示框。
第3步中我们使用了colorGroupData设置所需的属性来显示提示框。在willStart方法中,我们通过this.colorGroupData指定了color映射 ,因此通过widget.colorGroupData.在QWeb模板中访问它们。这是因此我们传递了组件引用,这就是qweb.render方法。
**小贴士:**可以在组件中的任意地方调用_rpc。注意这是一个异步调用,我们需要正确地管理延时对象来获取所要的结果。
扩展知识…
AbstractField类带有两个有趣的属性,其中一个我们刚刚使用过。在我们的示例中,我们使用了this.model属性,它存储了当前模型的名称(如library.book)。另一个属性是this.field,大概包含组件正在显示字段的模型的fields_get()函数的输出。它会给出所有与当前字段相关联的信息。例如,对于x2x字段,fields_get()函数会给出co-model或域的信息。也可以使用它来查询字段的string、size或其可在模型定义时为字段所设置的属性。
另一个很有帮助的属性是nodeOptions,它包含通过