Odoo的网页客户端或后台,是公司成员花费最多时间的地方。在第九章 后端视图中,我们学习了如何使用后台中所存在的功能。这里我们将学习如何继承和自定义这些功能。web模块包含有关Odoo用户界面的所有内容。
本章中的所有代码都依赖于web模块。读者已经知道Odoo有两个版本(企业版和社区版)。社区版的用户界面使用web模块,而企业版使用一个继承社区版web模块的版本,即web_enterprise模块。
企业版在社区版web模块的基础上提供了一些其它功能,如移动端兼容、可搜索菜单、material design等等。这里我们使用社区版。但不必担心,社区版中所开发的模块与企业版可以完美兼容,因为,在内部web_enterprise依赖于社区版的web模块,只是对其添加了一些功能。
📝重要信息:Odoo 14与此前的Odoo版本在网页客户端上有些特别之处。它包含了两套框架用于维护Odoo后台的图形界面。第一种是传统的基于微件(小部件)的框架,第二种是基于模块化的现代框架,称之后Odoo网页库(OWL)。OWL是在Odoo v14中新引入的UI框架。两者都使用QWeb模块作为结构,但在语法及框架运行方式上做出了很大的改变。
虽然Odoo 14使用了新框架OWL,但Odoo并没有在处处使用它。大多数网页客户端仍然使用老的基于微件的框架。本章中,我们将学习如何自定义基于微件框架的网页客户端。下一章中我们会学习OWL框架。
本章中,我们将学习如何创建新字段微件来获取用户输入。我们还将从零开始新建视图。在阅读完本章后,读者将能够在Odoo后台中创建自己的UI元素。
📝**注:**Odoo 的用户界面重度依赖于JavaScript。在本章中我们会假定你已具备JavaScript、jQuery、Underscore.js和SCSS的基础知识。
本章中,我们将讲解如下小节:
- 创建自定义微件
- 使用客户端QWeb模板
- 向服务端做RPC调用
- 新建一个视图
- 调试客户端代码
- 通过引导提升用户上手体验
- 移动应用JavaScript
技术准备
学习本章要求已经有一个Odoo在线平台。
本章中的所有代码可通过GitHub仓库进行下载。
创建自定义微件
在第九章 后端视图中已经学到,我们可以使用微件来以不同格式展示某些数据。例如,我们使用了widget=‘image’将二进制字段展示为图片。要演示如何创建自己的微件,我们将编写一个用户可以选择整型字段的微件,但我们将采用不同的展现方式。使用的不是输入框,而是展示为颜色拾取器,这样我们我们可以选择颜色数值。这里的数值与相关的颜色存在映射关系。
准备工作
本节中我们将使用带有基础字段和视图的my_library模块。在GitHub仓库的Chapter15/00_initial_module目录下可以找到这一基础my_library模块。
如何实现…
我们将添加包含微件逻辑的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> ...
更新模块应用修改。在更新完成后,打开图书表单视图,就可以看到下图中所示的颜色拾取器:

图15.1 – 如何显示自定义微件
实现原理…
为便于读者掌握示例,我们来通过查看组件来了解微件的生命周期:
- 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选择器由空格分割,值为微件中方法的名称。因此,在执行事件时,指定的方法会自动调用。在本节中,当用户点击颜色块时,我们要在字段中设置一个整型值。为管理点击事件,我们在events键中添加了一个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函数来渲染该元素(继承自
widget
):
_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函数中所传递的参数。
在本例中,我们通过widget键传递了当前对象。这表示应将所有的逻辑放在微件的JavaScript代码中,只让模板访问属性或可能用到的函数。既然我们能访问微件中所有可用的属性,可以通过查看totalColors属性来对模板中的值进行检测。
客户端QWeb和QWeb视图间并没有关系,所以存在不同的机制来让网页客户端识别模板 - 通过qweb键以相对插件根路径的文件名列表将它们添加到插件的声明中。
📝注:如果不希望在声明文件中列出QWeb模板,可以对小组件使用xmlDependencies键来懒加载模板。使用xmlDependencies,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>
更新模块来应用修改。更新后,就可以在色块上看到如下图所示的提示信息:

图15.2 – 使用RPC获取提示数据
实现原理…
willStart函数在渲染之前调用,更重要的是,它返回一个必须在渲染开始之前解析的Promise对象。因此,在像我们这样的用例中,需要在渲染之前运行异步动作,这才是该使用的函数。
在处理数据访问时,我们依赖于前面讲解的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,它包含通过