自定义 Web 客户端¶
危险
本教程已过时。
本指南介绍如何为Odoo的Web客户端创建模块。
一个简单的模块¶
让我们从一个简单的Odoo模块开始,其中包含基本的Web组件配置,让我们测试Web框架。
示例模块可在线获取,使用以下命令进行下载:
$ git clone http://github.com/odoo/petstore
这将在您执行命令的位置创建一个 petstore
文件夹。然后,您需要将该文件夹添加到 Odoo 的 addons path
,创建一个新的数据库并安装 oepetstore
模块。
如果您浏览 petstore
文件夹,您应该会看到以下内容:
oepetstore
|-- images
| |-- alligator.jpg
| |-- ball.jpg
| |-- crazy_circle.jpg
| |-- fish.jpg
| `-- mice.jpg
|-- __init__.py
|-- oepetstore.message_of_the_day.csv
|-- __manifest__.py
|-- petstore_data.xml
|-- petstore.py
|-- petstore.xml
`-- static
`-- src
|-- css
| `-- petstore.css
|-- js
| `-- petstore.js
`-- xml
`-- petstore.xml
该模块已经包含了各种服务器自定义内容。稍后我们会回到这些内容,现在让我们专注于 static
文件夹中的与Web相关的内容。
在Odoo模块的”web”部分中使用的文件必须放置在 static
文件夹中,以便Web浏览器可以访问,文件夹外的文件无法被浏览器获取。 src/css
、 src/js
和 src/xml
子文件夹是传统的,但并非必需。
oepetstore/static/css/petstore.css
目前为空,将用于保存宠物商店内容的CSS
oepetstore/static/xml/petstore.xml
大部分为空,将保存 QWeb模板 模板
oepetstore/static/js/petstore.js
最重要(也是最有趣的)部分包含了应用程序的逻辑(或至少是其Web浏览器端)作为JavaScript。它目前应该看起来像:
odoo.oepetstore = function(instance, local) { var _t = instance.web._t, _lt = instance.web._lt; var QWeb = instance.web.qweb; local.HomePage = instance.Widget.extend({ start: function() { console.log("pet store home page loaded"); }, }); instance.web.client_actions.add( 'petstore.homepage', 'instance.oepetstore.HomePage'); }
这只会在浏览器控制台中打印一条小消息。
The files in the static
folder, need to be defined within the module in order for them to be
loaded correctly. Everything in src/xml
is defined in __manifest__.py
while the contents of
src/css
and src/js
are defined in petstore.xml
, or a similar file.
警告
所有的JavaScript文件都被连接并且 minified 以提高应用程序的加载时间。
其中一个缺点是,由于个别文件消失,代码变得难以调试,并且可读性显著降低。可以通过启用“开发者模式”来禁用此过程:登录到您的Odoo实例(默认用户 admin 密码 admin ),打开用户菜单(位于Odoo屏幕右上角),然后选择: 关于Odoo ,然后选择: 激活开发者模式 :
这将重新加载 Web 客户端,禁用优化,使开发和调试更加舒适。
Odoo JavaScript 模块¶
Javascript 没有内置模块。因此,在不同文件中定义的变量都会混在一起,可能会发生冲突。这导致出现了各种模块模式,用于构建清晰的命名空间并限制命名冲突的风险。
Odoo框架使用这样的一种模式来定义Web插件中的模块,以便对代码进行命名空间管理并正确地排序其加载顺序。
oepetstore/static/js/petstore.js
contains a module declaration:
odoo.oepetstore = function(instance, local) {
local.xxx = ...;
}
在Odoo web中,模块被声明为设置在全局 odoo
变量上的函数。函数的名称必须与插件的名称相同(在本例中为 oepetstore
),以便框架可以找到它并自动初始化它。
当 Web 客户端加载您的模块时,它将调用根函数并提供两个参数:
第一个参数是当前的Odoo Web客户端实例,它提供了访问Odoo定义的各种功能(翻译、网络服务)以及核心或其他模块定义的对象的能力。
第二个参数是由Web客户端自动创建的本地命名空间。应该在该命名空间中设置可以从模块外部访问的对象和变量(无论是因为Odoo Web客户端需要调用它们还是因为其他人可能想要自定义它们)。
类¶
与大多数面向对象语言不同,javascript 不像模块一样内置 classes1,尽管它提供了大致等效的(更低级和更冗长的)机制。
为了简单和开发者友好性,Odoo web 提供了一个基于 John Resig 的 Simple JavaScript Inheritance 的类系统。
通过调用 extend()
方法来定义新类,该方法属于 odoo.web.Class()
类:
var MyClass = instance.web.Class.extend({
say_hello: function() {
console.log("hello");
},
});
The extend()
method takes a dictionary describing
the new class’s content (methods and static attributes). In this case, it will
only have a say_hello
method which takes no parameters.
类使用 new
运算符进行实例化::
var my_object = new MyClass();
my_object.say_hello();
// print "hello" in the console
实例的属性可以通过 this
访问:
var MyClass = instance.web.Class.extend({
say_hello: function() {
console.log("hello", this.name);
},
});
var my_object = new MyClass();
my_object.name = "Bob";
my_object.say_hello();
// print "hello Bob" in the console
类可以提供一个初始化器来执行实例的初始设置,通过定义一个 init()
方法。初始化器接收使用 new
操作符传递的参数:
var MyClass = instance.web.Class.extend({
init: function(name) {
this.name = name;
},
say_hello: function() {
console.log("hello", this.name);
},
});
var my_object = new MyClass("Bob");
my_object.say_hello();
// print "hello Bob" in the console
也可以通过在父类上调用 extend()
来创建现有(用户定义的)类的子类,就像对 Class()
进行子类化一样:
var MySpanishClass = MyClass.extend({
say_hello: function() {
console.log("hola", this.name);
},
});
var my_object = new MySpanishClass("Bob");
my_object.say_hello();
// print "hola Bob" in the console
当使用继承覆盖方法时,您可以使用 this._super()
调用原始方法::
var MySpanishClass = MyClass.extend({
say_hello: function() {
this._super();
console.log("translation in Spanish: hola", this.name);
},
});
var my_object = new MySpanishClass("Bob");
my_object.say_hello();
// print "hello Bob \n translation in Spanish: hola Bob" in the console
警告
_super
不是一个标准方法,它是在当前继承链中动态设置为下一个方法。它仅在方法调用的 同步 部分中定义,在异步处理程序中使用(在网络调用或 setTimeout
回调之后)应 保留对其值的引用,不应通过 this
访问:
// broken, will generate an error
say_hello: function () {
setTimeout(function () {
this._super();
}.bind(this), 0);
}
// correct
say_hello: function () {
// don't forget .bind()
var _super = this._super.bind(this);
setTimeout(function () {
_super();
}.bind(this), 0);
}
小部件基础¶
Odoo 网页客户端捆绑了 jQuery,用于简化 DOM 操作。它非常有用,提供了比标准的 W3C DOM2 更好的 API,但对于构建复杂应用程序来说还是不够,导致维护困难。
与面向对象的桌面UI工具包(例如Qt,Cocoa_或GTK)类似,Odoo Web使特定组件负责页面的各个部分。在Odoo Web中,这些组件的基础是 Widget()
类,这是一个专门处理页面部分并显示信息给用户的组件。
你的第一个小部件¶
初始演示模块已经提供了一个基本的小部件:
local.HomePage = instance.Widget.extend({
start: function() {
console.log("pet store home page loaded");
},
});
它扩展了 Widget()
并重写了标准方法 start()
,这个方法 — 就像之前的 MyClass
— 目前还没有做太多事情。
文件末尾的这一行:
instance.web.client_actions.add(
'petstore.homepage', 'instance.oepetstore.HomePage');
将我们的基本小部件注册为客户端操作。客户端操作将在稍后解释,现在只是允许我们的小部件在我们选择
菜单时被调用和显示的内容。警告
因为小部件将从我们模块外部调用,所以 Web 客户端需要其“完全限定”名称,而不是本地版本。
显示内容¶
小部件有许多方法和功能,但基础知识很简单:
设置一个小部件
格式化小部件的数据
显示小部件
The HomePage
widget already has a start()
method. That method is part of the normal widget lifecycle and automatically
called once the widget is inserted in the page. We can use it to display some
content.
所有小部件都有一个 $el
属性,它代表它们负责的页面部分(作为一个 jQuery 对象)。小部件的内容应该插入其中。默认情况下, $el
是一个空的 <div>
元素。
如果 <div>
元素没有内容(或者没有指定大小的特定样式),则通常对用户不可见,这就是为什么在启动 HomePage
时页面上没有显示任何内容的原因。
让我们使用jQuery向小部件的根元素添加一些内容:
local.HomePage = instance.Widget.extend({
start: function() {
this.$el.append("<div>Hello dear Odoo user!</div>");
},
});
当您打开
时,该消息将会出现注解
要刷新在Odoo Web中加载的JavaScript代码,您需要重新加载页面。无需重新启动Odoo服务器。
The HomePage
widget is used by Odoo Web and managed automatically.
To learn how to use a widget “from scratch” let’s create a new one:
local.GreetingsWidget = instance.Widget.extend({
start: function() {
this.$el.append("<div>We are so happy to see you again in this menu!</div>");
},
});
We can now add our GreetingsWidget
to the HomePage
by using the
GreetingsWidget
’s appendTo()
method:
local.HomePage = instance.Widget.extend({
start: function() {
this.$el.append("<div>Hello dear Odoo user!</div>");
var greeting = new local.GreetingsWidget(this);
return greeting.appendTo(this.$el);
},
});
HomePage
first adds its own content to its DOM rootHomePage
然后实例化GreetingsWidget
最后,它告诉
GreetingsWidget
在哪里插入自己,将其$el
的一部分委托给GreetingsWidget
。
当调用 appendTo()
方法时,它会要求小部件将自己插入到指定位置并显示其内容。 start()
方法将在调用 appendTo()
时被调用。
为了查看显示界面下发生的情况,我们将使用浏览器的 DOM Explorer。但首先,让我们稍微修改一下我们的小部件,以便更容易找到它们的位置,通过 在它们的根元素上添加一个类名
:
local.HomePage = instance.Widget.extend({
className: 'oe_petstore_homepage',
...
});
local.GreetingsWidget = instance.Widget.extend({
className: 'oe_petstore_greetings',
...
});
如果你能找到DOM的相关部分(右键点击文本然后选择 检查元素),它应该看起来像这样:
<div class="oe_petstore_homepage">
<div>Hello dear Odoo user!</div>
<div class="oe_petstore_greetings">
<div>We are so happy to see you again in this menu!</div>
</div>
</div>
这清楚地显示了由 Widget()
自动创建的两个 <div>
元素,因为我们在它们上面添加了一些类。
我们还可以看到我们自己添加的两个消息容器div
最后,请注意 <div class="oe_petstore_greetings">
元素代表的 GreetingsWidget
实例是 在 <div class="oe_petstore_homepage">
元素内,代表 HomePage
实例,因为我们附加了
小部件的父子关系¶
在上一部分中,我们使用以下语法实例化了一个小部件:
new local.GreetingsWidget(this);
第一个参数是 this
,在这种情况下是一个 HomePage
实例。这告诉被创建的小部件它的其他小部件是它的 parent。
如我们所见,小部件通常由另一个小部件插入到DOM中,并且位于该其他小部件的根元素 内部 。这意味着大多数小部件是另一个小部件的”一部分”,并且代表它存在。我们称容器为 父级 ,包含的小部件为 子级 。
由于多种技术和概念上的原因,小部件需要知道它的父级和子级是谁。
getParent()
可以用来获取小部件的父级:
local.GreetingsWidget = instance.Widget.extend({ start: function() { console.log(this.getParent().$el ); // will print "div.oe_petstore_homepage" in the console }, });
getChildren()
可以用来获取其子项列表:
local.HomePage = instance.Widget.extend({ start: function() { var greeting = new local.GreetingsWidget(this); greeting.appendTo(this.$el); console.log(this.getChildren()[0].$el); // will print "div.oe_petstore_greetings" in the console }, });
当覆盖小部件的 init()
方法时, 非常重要 将父级传递给 this._super()
调用,否则关系将不会正确设置:
local.GreetingsWidget = instance.Widget.extend({
init: function(parent, name) {
this._super(parent);
this.name = name;
},
});
最后,如果一个小部件没有父级(例如,因为它是应用程序的根小部件),可以将 null
作为父级提供:
new local.GreetingsWidget(null);
销毁小部件¶
如果您可以向用户显示内容,那么您也应该能够擦除它。这可以通过 destroy()
方法来完成:
greeting.destroy();
当一个小部件被销毁时,它首先会调用 destroy()
来销毁它的所有子元素。然后它会从 DOM 中擦除自己。如果你在 init()
或者 start()
中设置了永久结构,这些结构必须被显式清理 (因为垃圾回收器不会处理它们),你可以重写 destroy()
。
危险
when overriding destroy()
, _super()
must always be called otherwise the widget and its children are not
correctly cleaned up leaving possible memory leaks and “phantom events”,
even if no error is displayed
QWeb模板引擎¶
在前一节中,我们通过直接操作(并添加到)它们的DOM来向我们的小部件添加内容:
this.$el.append("<div>Hello dear Odoo user!</div>");
这允许生成和显示任何类型的内容,但在生成大量DOM时会变得笨重(存在大量重复、引用问题等)
与许多其他环境一样,Odoo的解决方案是使用一个 模板引擎。Odoo的模板引擎被称为 QWeb模板。
QWeb 是一种基于 XML 的模板语言,类似于 Genshi <http://en.wikipedia.org/wiki/Genshi_(templating_language)>
_、 Thymeleaf <http://en.wikipedia.org/wiki/Thymeleaf>
_ 或 Facelets <http://en.wikipedia.org/wiki/Facelets>
_。它具有以下特点:
它完全使用JavaScript实现,并在浏览器中呈现
每个模板文件(XML文件)包含多个模板
它在Odoo Web的
Widget()
中有特殊支持,尽管它可以在Odoo的Web客户端之外使用(并且可以在不依赖于QWeb的情况下使用Widget()
)
注解
The rationale behind using QWeb instead of existing javascript template engines is the extensibility of pre-existing (third-party) templates, much like Odoo views.
大多数JavaScript模板引擎都是基于文本的,这使得使用基于XML的模板引擎进行结构性扩展变得困难,而基于XPath或CSS以及树形修改DSL(甚至只是XSLT)的XML模板引擎可以进行通用修改。这种灵活性和可扩展性是Odoo的核心特征,失去它被认为是不可接受的。
使用 QWeb¶
首先,在几乎空白的 oepetstore/static/src/xml/petstore.xml
文件中定义一个简单的 QWeb 模板:
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="HomePageTemplate">
<div style="background-color: red;">This is some simple HTML</div>
</t>
</templates>
现在我们可以在 HomePage
小部件中使用这个模板。使用页面顶部定义的 QWeb
加载器变量,我们可以调用在 XML 文件中定义的模板:
local.HomePage = instance.Widget.extend({
start: function() {
this.$el.append(QWeb.render("HomePageTemplate"));
},
});
QWeb.render()
寻找指定的模板,将其渲染为字符串并返回结果。
然而,由于 Widget()
对 QWeb 有特殊的集成,模板可以直接通过 它的 template
属性设置:
local.HomePage = instance.Widget.extend({
template: "HomePageTemplate",
start: function() {
...
},
});
虽然结果看起来相似,但这些用法之间有两个区别:
使用第二个版本,在调用
start()
之前,模板会被渲染在第一个版本中,模板的内容被添加到小部件的根元素中,而在第二个版本中,模板的根元素直接 设置为 小部件的根元素。这就是为什么”greetings”子小部件也会有红色背景
警告
模板应该有一个单一的非``t``根元素,特别是当它们被设置为小部件的 template
。如果有多个”根元素”,结果是未定义的(通常只使用第一个根元素,其他的将被忽略)
QWeb上下文¶
QWeb模板可以接收数据并包含基本的显示逻辑。
对于显式调用 QWeb.render()
的情况,模板数据作为第二个参数传递:
QWeb.render("HomePageTemplate", {name: "Klaus"});
模板已修改为:
<t t-name="HomePageTemplate">
<div>Hello <t t-esc="name"/></div>
</t>
将会导致:
<div>Hello Klaus</div>
当使用 Widget()
的集成时,无法向模板提供额外的数据。模板将被给予一个单一的 widget
上下文变量,引用在 start()
被调用之前正在渲染的小部件 (小部件的状态基本上是由 init()
设置的):
<t t-name="HomePageTemplate">
<div>Hello <t t-esc="widget.name"/></div>
</t>
local.HomePage = instance.Widget.extend({
template: "HomePageTemplate",
init: function(parent) {
this._super(parent);
this.name = "Mordecai";
},
start: function() {
},
});
结果:
<div>Hello Mordecai</div>
模板声明¶
我们已经学习了如何 渲染 QWeb模板,现在让我们来看看模板本身的语法。
一个 QWeb 模板由常规 XML 与 QWeb 指令 混合组成。QWeb 指令是以 t-
开头的 XML 属性声明的。
最基本的指令是 t-name
,用于在模板文件中声明新的模板:
<templates>
<t t-name="HomePageTemplate">
<div>This is some simple HTML</div>
</t>
</templates>
t-name
takes the name of the template being defined, and declares that
it can be called using QWeb.render()
. It can only be used at the
top-level of a template file.
转义¶
The t-esc
directive can be used to output text:
<div>Hello <t t-esc="name"/></div>
它接受一个JavaScript表达式进行评估,然后将表达式的结果进行HTML转义并插入文档中。由于它是一个表达式,因此可以像上面那样提供一个变量名,也可以提供更复杂的表达式,如计算:
<div><t t-esc="3+5"/></div>
或方法调用:
<div><t t-esc="name.toUpperCase()"/></div>
输出 HTML¶
要在正在渲染的页面中注入HTML,请使用 t-raw
。与 t-esc
一样,它接受一个任意的Javascript表达式作为参数,但它不执行HTML转义步骤。
<div><t t-raw="name.link(user_account)"/></div>
危险
t-raw
必须不 在任何可能包含非转义用户提供内容的数据上使用,因为这会导致 跨站脚本 漏洞
条件语句¶
QWeb can have conditional blocks using t-if
. The directive takes an
arbitrary expression, if the expression is falsy (false
, null
, 0
or an empty string) the whole block is suppressed, otherwise it is displayed.
<div>
<t t-if="true == true">
true is true
</t>
<t t-if="true == false">
true is not true
</t>
</div>
注解
QWeb没有”else”结构,可以使用第二个带有原始条件取反的 t-if
。如果条件是复杂或昂贵的表达式,您可能需要将其存储在本地变量中。
迭代¶
要对列表进行迭代,请使用 t-foreach
和 t-as
。 t-foreach
接受返回列表的表达式进行迭代, t-as
接受一个变量名绑定到每个迭代项。
<div>
<t t-foreach="names" t-as="name">
<div>
Hello <t t-esc="name"/>
</div>
</t>
</div>
注解
t-foreach
can also be used with numbers and objects
(dictionaries)
定义属性¶
QWeb 提供了两个相关的指令来定义计算属性: t-att-name
和 t-attf-name
。在任何情况下, name 是要创建的属性的名称(例如, t-att-id
在渲染后定义了属性 id
)。
t-att-
takes a javascript expression whose result is set as the
attribute’s value, it is most useful if all of the attribute’s value is
computed:
<div>
Input your name:
<input type="text" t-att-value="defaultName"/>
</div>
t-attf-
takes a format string. A format string is literal text with
interpolation blocks inside, an interpolation block is a javascript
expression between {{
and }}
, which will be replaced by the result
of the expression. It is most useful for attributes which are partially
literal and partially computed such as a class:
<div t-attf-class="container {{ left ? 'text-left' : '' }} {{ extra_class }}">
insert content here
</div>
调用其他模板¶
模板可以分成子模板(为了简单性、可维护性、可重用性或避免过多的标记嵌套)。
使用 t-call
指令完成,该指令需要传入要渲染的模板名称:
<t t-name="A">
<div class="i-am-a">
<t t-call="B"/>
</div>
</t>
<t t-name="B">
<div class="i-am-b"/>
</t>
渲染 A
模板将会得到:
<div class="i-am-a">
<div class="i-am-b"/>
</div>
子模板继承其调用者的渲染上下文。
了解更多关于QWeb的内容¶
查看 QWeb 参考,请参阅 QWeb模板。
练习¶
Exercise
在小部件中使用 QWeb
创建一个构造函数除了 parent
之外还接受两个参数: product_names
和 color
。
product_names
should an array of strings, each one the name of a productcolor
is a string containing a color in CSS color format (ie:#000000
for black).
小部件应该将给定的产品名称一个接一个地显示在下面,每个名称都在一个单独的框中,背景颜色为 color
的值,并带有边框。您应该使用 QWeb 来渲染 HTML。任何必要的 CSS 应该在 oepetstore/static/src/css/petstore.css
中。
在 HomePage
中使用小部件,显示半打产品。
小部件助手¶
Widget
的jQuery选择器¶
在小部件中选择DOM元素可以通过在小部件的DOM根上调用 find()
方法来执行:
this.$el.find("input.my_input")...
但是因为这是一个常见的操作, Widget()
通过 $()
方法提供了一个等效的快捷方式:
local.MyWidget = instance.Widget.extend({
start: function() {
this.$("input.my_input")...
},
});
警告
全局的 jQuery 函数 $()
应该 绝对不 被使用,除非绝对必要:对于小部件根元素的选择是局部的,仅限于该小部件,但是使用 $()
进行的选择是全局的,可能会匹配其他小部件和视图的部分内容,导致奇怪或危险的副作用。由于小部件通常只应对其拥有的 DOM 部分进行操作,因此没有全局选择的原因。
更简单的DOM事件绑定¶
我们之前使用普通的 jQuery 事件处理程序(例如 .click()
或 .change()
)来绑定 DOM 事件到小部件元素上:
local.MyWidget = instance.Widget.extend({
start: function() {
var self = this;
this.$(".my_button").click(function() {
self.button_clicked();
});
},
button_clicked: function() {
..
},
});
虽然这样可以工作,但还存在一些问题:
它相当冗长
它不支持在运行时替换小部件的根元素,因为绑定仅在运行
start()
时执行(在小部件初始化期间执行)需要处理
this
绑定问题
Widgets 因此通过 events
提供了一种快捷方式来绑定 DOM 事件:
local.MyWidget = instance.Widget.extend({
events: {
"click .my_button": "button_clicked",
},
button_clicked: function() {
..
}
});
events
是一个事件到触发时要调用的函数或方法的对象(映射):
键是事件名称,可能会用CSS选择器进行细化,只有当事件发生在选定的子元素上时,函数或方法才会运行:
click
将处理小部件内的所有点击,但click.my_button
仅处理带有my_button
类的元素中的点击。当事件被触发时,该值是要执行的操作
它可以是一个函数:
events: { 'click': function (e) { /* code here */ } }
或者对象上的方法名称(参见上面的示例)。
无论哪种情况,
this
都是小部件实例,处理程序都会被给予一个参数,即事件的jQuery 事件对象
_。
小部件事件和属性¶
活动¶
小部件提供了一个事件系统(与上述DOM/jQuery事件系统分开):小部件可以在自身上触发事件,其他小部件(或自身)可以绑定并监听这些事件::
local.ConfirmWidget = instance.Widget.extend({
events: {
'click button.ok_button': function () {
this.trigger('user_chose', true);
},
'click button.cancel_button': function () {
this.trigger('user_chose', false);
}
},
start: function() {
this.$el.append("<div>Are you sure you want to perform this action?</div>" +
"<button class='ok_button'>Ok</button>" +
"<button class='cancel_button'>Cancel</button>");
},
});
此小部件充当门面,将用户输入(通过DOM事件)转换为可记录的内部事件,父小部件可以将自己绑定到该事件上。
trigger()
接受触发事件的名称作为其第一个(必填)参数,任何其他参数都被视为事件数据并直接传递给监听器。
我们可以设置一个父事件来实例化我们的通用小部件,并使用 on()
监听 user_chose
事件:
local.HomePage = instance.Widget.extend({
start: function() {
var widget = new local.ConfirmWidget(this);
widget.on("user_chose", this, this.user_chose);
widget.appendTo(this.$el);
},
user_chose: function(confirm) {
if (confirm) {
console.log("The user agreed to continue");
} else {
console.log("The user refused to continue");
}
},
});
on()
binds a function to be called when the
event identified by event_name
is. The func
argument is the
function to call and object
is the object to which that function is
related if it is a method. The bound function will be called with the
additional arguments of trigger()
if it has
any. Example:
start: function() {
var widget = ...
widget.on("my_event", this, this.my_event_triggered);
widget.trigger("my_event", 1, 2, 3);
},
my_event_triggered: function(a, b, c) {
console.log(a, b, c);
// will print "1 2 3"
}
注解
在其他小部件上触发事件通常是一个不好的想法。唯一的例外是 odoo.web.bus
,它专门用于在整个Odoo Web应用程序中广播任何小部件可能感兴趣的事件。
属性¶
属性与普通对象属性非常相似,它们允许在小部件实例上存储数据,但它们还具有额外的功能,即在设置时触发事件:
start: function() {
this.widget = ...
this.widget.on("change:name", this, this.name_changed);
this.widget.set("name", "Nicolas");
},
name_changed: function() {
console.log("The new value of the property 'name' is", this.widget.get("name"));
}
set()
设置属性的值并触发change:propname`(其中 *propname* 是作为第一个参数传递给 :func:`~odoo.Widget.set
的属性名称)和change
get()
检索属性的值。
练习¶
Exercise
小部件属性和事件
创建一个小部件 ColorInputWidget
,它将显示3个 <input type="text">
。每个 <input>
都专用于输入一个十六进制数,范围从00到FF。当用户修改任何一个 <input>
时,小部件必须查询这三个 <input>
的内容,将它们的值连接起来,以获得完整的CSS颜色代码(例如: #00FF00
),并将结果放入名为 color
的属性中。请注意,您可以将jQuery的 change()
事件绑定到任何HTML的 <input>
元素上,并且 val()
方法可以查询该 <input>
的当前值,这对您的练习可能会有用。
然后,修改 HomePage
小部件以实例化 ColorInputWidget
并显示它。 HomePage
小部件还应该显示一个空矩形。该矩形必须始终在任何时刻与 ColorInputWidget
实例的 color
属性中的颜色相同。
使用 QWeb 生成所有 HTML。
修改现有的小部件和类¶
Odoo 网页框架的类系统允许使用 include()
方法直接修改现有类:
var TestClass = instance.web.Class.extend({
testMethod: function() {
return "hello";
},
});
TestClass.include({
testMethod: function() {
return this._super() + " world";
},
});
console.log(new TestClass().testMethod());
// will print "hello world"
这个系统类似于继承机制,但它会直接在目标类中进行修改,而不是创建一个新类。
在这种情况下, this._super()
将调用被替换/重新定义的方法的原始实现。如果类已经有子类,子类中的所有对 this._super()
的调用将调用在对 include()
的调用中定义的新实现。如果在对 include()
的调用之前创建了类的某些实例(或其任何子类的实例),这也将起作用。
翻译¶
在Python和JavaScript代码中翻译文本的过程非常相似。您可能已经注意到了 petstore.js
文件开头的这些行:
var _t = instance.web._t,
_lt = instance.web._lt;
这些行仅用于在当前JavaScript模块中导入翻译函数。它们的使用方式如下::
this.$el.text(_t("Hello user!"));
在Odoo中,翻译文件是通过扫描源代码自动生成的。检测到调用特定函数的代码片段,并将其内容添加到一个翻译文件中,然后发送给翻译人员。在Python中,该函数是 _()
。在JavaScript中,该函数是 _t()
(还有 _lt()
)。
_t()
will return the translation defined for the text it is given. If no
translation is defined for that text, it will return the original text as-is.
注解
To inject user-provided values in translatable strings, it is recommended to use _.str.sprintf with named arguments after the translation:
this.$el.text(_.str.sprintf(
_t("Hello, %(user)s!"), {
user: "Ed"
}));
这使得可翻译的字符串对翻译人员更易读,并使他们能够更灵活地重新排序或忽略参数。
:func:`~odoo.web._lt`(“延迟翻译”)与之类似,但稍微复杂一些:它不会立即翻译其参数,而是返回一个对象,当转换为字符串时,将执行翻译。
它用于在翻译系统初始化之前定义可翻译术语,例如类属性(因为模块在用户语言配置和下载翻译之前加载)。
与Odoo服务器的通信¶
联系模型¶
大多数Odoo操作涉及与实现业务关注点的 模型 进行通信,然后这些模型将(可能)与某些存储引擎(通常是PostgreSQL)交互。
虽然 jQuery 提供了 $.ajax 函数用于网络交互,但与 Odoo 的通信需要额外的元数据,在每次调用之前设置这些元数据会很冗长且容易出错。因此,Odoo web 提供了更高级别的通信原语。
为了演示这一点,文件 petstore.py
已经包含了一个小模型和一个示例方法:
class message_of_the_day(models.Model):
_name = "oepetstore.message_of_the_day"
@api.model
def my_method(self):
return {"hello": "world"}
message = fields.Text(),
color = fields.Char(size=20),
这声明了一个具有两个字段的模型,并且一个名为 my_method()
的方法返回一个字典。
这里是一个调用 my_method()
并显示结果的示例小部件:
local.HomePage = instance.Widget.extend({
start: function() {
var self = this;
var model = new instance.web.Model("oepetstore.message_of_the_day");
model.call("my_method", {context: new instance.web.CompoundContext()}).then(function(result) {
self.$el.append("<div>Hello " + result["hello"] + "</div>");
// will show "Hello world" to the user
});
},
});
用于调用Odoo模型的类是 odoo.Model()
。它通过Odoo模型的名称作为第一个参数进行实例化 (oepetstore.message_of_the_day
这里)。
call()
可以用于调用 Odoo 模型的任何(公共)方法。它接受以下位置参数:
name
要调用的方法名称,在这里是
my_method
args
一个数组的 位置参数 用于提供给方法。因为示例没有位置参数提供,所以
args
参数没有提供。这是另一个使用位置参数的示例:
@api.model def my_method2(self, a, b, c): ...
model.call("my_method", [1, 2, 3], ... // with this a=1, b=2 and c=3
kwargs
a mapping of keyword arguments to pass. The example provides a single named argument
context
.@api.model def my_method2(self, a, b, c): ...
model.call("my_method", [], {a: 1, b: 2, c: 3, ... // with this a=1, b=2 and c=3
call()
返回一个延迟对象,该对象的值由模型方法的返回值作为第一个参数。
复合上下文¶
前一节使用了一个未在方法调用中解释的 context
参数:
model.call("my_method", {context: new instance.web.CompoundContext()})
上下文就像是一个“魔法”参数,当Web客户端调用方法时,它总是会传递给服务器。上下文是一个包含多个键的字典。其中一个最重要的键是用户的语言,由服务器用于翻译应用程序的所有消息。另一个键是用户的时区,用于在Odoo被不同国家的人使用时正确计算日期和时间。
The argument
is necessary in all methods, otherwise bad things could
happen (such as the application not being translated correctly). That’s why,
when you call a model’s method, you should always provide that argument. The
solution to achieve that is to use odoo.web.CompoundContext()
.
CompoundContext()
是一个用于将用户的上下文(包括语言、时区等)传递给服务器,并向上下文中添加新键(某些模型的方法使用添加到上下文的任意键)的类。它通过将任意数量的字典或其他 CompoundContext()
实例传递给其构造函数来创建。在将它们发送到服务器之前,它将合并所有这些上下文。
model.call("my_method", {context: new instance.web.CompoundContext({'new_key': 'key_value'})})
@api.model
def my_method(self):
print(self.env.context)
// will print: {'lang': 'en_US', 'new_key': 'key_value', 'tz': 'Europe/Brussels', 'uid': 1}
You can see the dictionary in the argument context
contains some keys that
are related to the configuration of the current user in Odoo plus the
new_key
key that was added when instantiating
CompoundContext()
.
查询¶
While call()
is sufficient for any interaction with Odoo
models, Odoo Web provides a helper for simpler and clearer querying of models
(fetching of records based on various conditions):
query()
which acts as a shortcut for the common
combination of search()
and
:read()
. It provides a clearer syntax to search
and read models:
model.query(['name', 'login', 'user_email', 'signature'])
.filter([['active', '=', true], ['company_id', '=', main_company]])
.limit(15)
.all().then(function (users) {
// do work with users records
});
对比:
model.call('search', [['active', '=', true], ['company_id', '=', main_company]], {limit: 15})
.then(function (ids) {
return model.call('read', [ids, ['name', 'login', 'user_email', 'signature']]);
})
.then(function (users) {
// do work with users records
});
query()
接受一个可选的字段列表作为参数 (如果没有提供字段,则获取模型的所有字段)。它返回一个odoo.web.Query()
对象,在执行之前可以进一步自定义Query()
表示正在构建的查询。它是不可变的,自定义查询的方法实际上返回一个修改后的副本,因此可以同时使用原始版本和新版本。有关其自定义选项,请参见Query()
。
当查询设置完成后,只需调用 all()
执行它并返回一个延迟对象以获取结果。结果与 read()
相同,是一个字典数组,其中每个字典是一个请求的记录,每个请求的字段都是一个字典键。
练习¶
Exercise
每日信息
创建一个 MessageOfTheDay
小部件,显示 oepetstore.message_of_the_day
模型的最后一条记录。小部件应该在显示时立即获取其记录。
在宠物商店首页显示小部件。
Exercise
宠物玩具清单
创建一个 PetToysList
小部件,显示 5 个玩具(使用它们的名称和图像)。
宠物玩具不是存储在一个新模型中,而是存储在 product.product
中,使用特殊类别 宠物玩具。您可以通过转到 来查看预生成的玩具并添加新的玩具。您可能需要探索 product.product
来创建正确的域以仅选择宠物玩具。
在Odoo中,图像通常存储在以base64_编码的常规字段中,HTML支持直接从base64显示图像,使用 <img src="data:mime_type;base64,base64_image_data"/>
The PetToysList
widget should be displayed on the home page on the
right of the MessageOfTheDay
widget. You will need to make some layout
with CSS to achieve this.
现有的Web组件¶
动作管理器¶
In Odoo, many operations start from an action: opening a menu item (to a view), printing a report, …
Actions是描述客户端应如何响应内容激活的数据片段。Actions可以被存储(并通过模型读取),也可以通过JavaScript代码本地生成(由客户端)或通过模型方法远程生成。
在Odoo Web中,负责处理和响应这些操作的组件是 Action Manager。
使用操作管理器¶
The action manager can be invoked explicitly from javascript code by creating a dictionary describing an action of the right type, and calling an action manager instance with it.
do_action()
是 Widget()
的快捷方式,查找“当前”动作管理器并执行动作:
instance.web.TestWidget = instance.Widget.extend({
dispatch_to_new_action: function() {
this.do_action({
type: 'ir.actions.act_window',
res_model: "product.product",
res_id: 1,
views: [[false, 'form']],
target: 'current',
context: {},
});
},
});
最常见的操作 type
是 ir.actions.act_window
,它提供了对模型的视图(以各种方式显示模型),其最常见的属性包括:
res_model
在视图中显示的模型
res_id
(optional)对于表单视图,
res_model
中预选的记录views
列出通过操作可用的视图。一个
[view_id, view_type]
的列表,view_id
可以是正确类型的视图的数据库标识符,或者false
以默认使用指定类型的视图。视图类型不能多次出现。操作将默认打开列表中的第一个视图。target
可以选择
current
(默认)将操作替换网页客户端的“content”部分,或选择new
在对话框中打开操作。context
在操作中使用的附加上下文数据。
Exercise
跳转到产品
修改 PetToysList
组件,使得点击玩具可以用该玩具的表单视图替换主页。
客户端操作¶
在整个指南中,我们使用了一个简单的 HomePage
小部件,当我们选择正确的菜单项时,Web 客户端会自动启动它。但是,Odoo Web 是如何知道要启动这个小部件的呢?因为这个小部件被注册为一个 客户端动作。
客户端操作(Client Action)是一种在Odoo Web中几乎完全由客户端(JavaScript)定义的操作类型。服务器只是发送一个操作标签(任意名称),并可选地添加一些参数,但除此之外, 所有 操作都由自定义客户端代码处理。
我们的小部件通过以下方式注册为客户端操作的处理程序:
instance.web.client_actions.add('petstore.homepage', 'instance.oepetstore.HomePage');
instance.web.client_actions
is a Registry()
in which
the action manager looks up client action handlers when it needs to execute
one. The first parameter of add()
is the name
(tag) of the client action, and the second parameter is the path to the widget
from the Odoo web client root.
当需要执行客户端操作时,操作管理器会在注册表中查找其标签,遍历指定的路径并显示其找到的小部件。
注解
客户端操作处理程序也可以是一个常规函数,在这种情况下,它将被调用,其结果(如果有)将被解释为要执行的下一个操作。
在服务器端,我们只是定义了一个 ir.actions.client
动作:
<record id="action_home_page" model="ir.actions.client">
<field name="tag">petstore.homepage</field>
</record>
以及一个打开操作菜单:
<menuitem id="home_page_petstore_menu" parent="petstore_menu"
name="Home Page" action="action_home_page"/>
视图的架构¶
Odoo Web 的大部分实用性(和复杂性)都存在于视图中。每种视图类型都是在客户端中显示模型的一种方式。
视图管理器¶
当 ActionManager
实例接收到一个类型为 ir.actions.act_window
的动作时,它将同步和处理视图本身的工作委托给一个 view manager,然后根据原始动作的要求设置一个或多个视图:
视图¶
Most Odoo views are implemented through a subclass
of odoo.web.View()
which provides a bit of generic basic structure
for handling events and displaying model information.
The search view is considered a view type by the main Odoo framework, but handled separately by the web client (as it’s a more permanent fixture and can interact with other views, which regular views don’t do).
视图负责加载自己的描述XML(使用 fields_view_get
)和任何其他数据源。为此,视图提供了一个可选的视图标识符,设置为 view_id
属性。
视图还提供了一个 DataSet()
实例,它保存了大部分必要的模型信息(模型名称和可能的各种记录 ID)。
视图也可以通过重写 do_search()
来处理搜索查询,并根据需要更新它们的 DataSet()
。
表单视图字段¶
常见的需求是扩展Web表单视图以添加新的字段显示方式。
All built-in fields have a default display implementation, a new
form widget may be necessary to correctly interact with a new field type
(e.g. a GIS field) or to provide new representations and ways to
interact with existing field types (e.g. validate
Char
fields which should contain email addresses
and display them as email links).
要明确指定要使用哪个表单小部件来显示字段,只需在视图的 XML 描述中使用 widget
属性:
<field name="contact_mail" widget="email"/>
注解
如果在表单视图的“查看”(只读)和“编辑”模式中使用相同的小部件,则不可能在一个模式中使用一个小部件,在另一个模式中使用另一个小部件。
在同一表单中不能多次使用同一字段(名称)
一个小部件可能会忽略表单视图的当前模式,在查看模式和编辑模式下保持不变
字段是在表单视图读取其XML描述并构建相应的HTML表示后由表单视图实例化的。之后,表单视图将使用一些方法与字段对象进行通信。这些方法由 FieldInterface
接口定义。几乎所有字段都继承了 AbstractField
抽象类。该类定义了大多数字段需要实现的一些默认机制。
以下是字段类的一些职责:
字段类必须显示并允许用户编辑字段的值。
它必须正确实现Odoo所有字段中可用的3个字段属性。
AbstractField
类已经实现了一个算法,动态计算这些属性的值(它们可以随时更改,因为它们的值根据其他字段的值而变化)。它们的值存储在 Widget Properties 中(在本指南中已经解释了widget属性)。每个字段类的责任是检查这些widget属性,并根据它们的值动态适应。以下是每个属性的描述:required
:在保存之前,该字段必须有一个值。如果required
是true
并且该字段没有值,则该字段的方法is_valid()
必须返回false
。invisible
: When this istrue
, the field must be invisible. TheAbstractField
class already has a basic implementation of this behavior that fits most fields.readonly
: Whentrue
, the field must not be editable by the user. Most fields in Odoo have a completely different behavior depending on the value ofreadonly
. As example, theFieldChar
displays an HTML<input>
when it is editable and simply displays the text when it is read-only. This also means it has much more code it would need to implement only one behavior, but this is necessary to ensure a good user experience.
Fields have two methods,
set_value()
andget_value()
, which are called by the form view to give it the value to display and get back the new value entered by the user. These methods must be able to handle the value as given by the Odoo server when aread()
is performed on a model and give back a valid value for awrite()
. Remember that the JavaScript/Python data types used to represent the values given byread()
and given towrite()
is not necessarily the same in Odoo. As example, when you read a many2one, it is always a tuple whose first value is the id of the pointed record and the second one is the name get (ie:(15, "Agrolait")
). But when you write a many2one it must be a single integer, not a tuple anymore.AbstractField
has a default implementation of these methods that works well for simple data type and set a widget property namedvalue
.
请注意,为了更好地理解如何实现字段,强烈建议您直接查看Odoo Web客户端代码中的 FieldInterface
接口和 AbstractField
类的定义。
创建新的字段类型¶
在本部分中,我们将解释如何创建新类型的字段。这里的示例将重新实现 FieldChar
类,并逐步解释每个部分。
简单只读字段¶
这是第一个实现,它只会显示文本。用户将无法修改字段的内容。
local.FieldChar2 = instance.web.form.AbstractField.extend({
init: function() {
this._super.apply(this, arguments);
this.set("value", "");
},
render_value: function() {
this.$el.text(this.get("value"));
},
});
instance.web.form.widgets.add('char2', 'instance.oepetstore.FieldChar2');
在这个例子中,我们声明了一个名为 FieldChar2
的类,它继承自 AbstractField
。我们还在注册表 instance.web.form.widgets
中将这个类注册为 char2
键。这将允许我们在任何表单视图中使用这个新字段,只需在视图的 XML 声明的 <field/>
标签中指定 widget="char2"
。
在这个例子中,我们定义了一个方法: render_value()
。它的作用是显示小部件属性 value
。这些都是由 AbstractField
类定义的两个工具。如前所述,表单视图将调用字段的 set_value()
方法来设置要显示的值。这个方法在 AbstractField
中已经有了一个默认实现,它只是简单地设置小部件属性 value
。 AbstractField
还会监听自身的 change:value
事件,并在事件发生时调用 render_value()
。因此, render_value()
是一个方便的方法,可以在子类中实现,每当字段的值发生变化时执行一些操作。
在 init()
方法中,如果表单视图没有指定字段的默认值,我们也可以定义该字段的默认值(这里我们假设 char
字段的默认值应该是空字符串)。
读写字段¶
只读字段仅显示内容,不允许用户修改,这可能很有用,但是Odoo中的大多数字段也允许编辑。这使得字段类更加复杂,主要是因为字段应该处理可编辑和不可编辑模式,这些模式通常完全不同(出于设计和可用性目的),并且字段必须能够随时在这些模式之间切换。
要知道当前字段应该处于哪种模式, AbstractField
类设置了一个名为 effective_readonly
的小部件属性。字段应该监视该小部件属性的变化,并相应地显示正确的模式。示例:
local.FieldChar2 = instance.web.form.AbstractField.extend({
init: function() {
this._super.apply(this, arguments);
this.set("value", "");
},
start: function() {
this.on("change:effective_readonly", this, function() {
this.display_field();
this.render_value();
});
this.display_field();
return this._super();
},
display_field: function() {
var self = this;
this.$el.html(QWeb.render("FieldChar2", {widget: this}));
if (! this.get("effective_readonly")) {
this.$("input").change(function() {
self.internal_set_value(self.$("input").val());
});
}
},
render_value: function() {
if (this.get("effective_readonly")) {
this.$el.text(this.get("value"));
} else {
this.$("input").val(this.get("value"));
}
},
});
instance.web.form.widgets.add('char2', 'instance.oepetstore.FieldChar2');
<t t-name="FieldChar2">
<div class="oe_field_char2">
<t t-if="! widget.get('effective_readonly')">
<input type="text"></input>
</t>
</div>
</t>
在 start()
方法中(在小部件被附加到 DOM 后立即调用),我们绑定了事件 change:effective_readonly
。这允许我们在小部件属性 effective_readonly
改变时重新显示字段。这个事件处理程序将调用 display_field()
,它也直接在 start()
中调用。 这个 display_field()
是专门为这个字段创建的,它不是在 AbstractField
或任何其他类中定义的方法。我们可以使用这个方法根据当前模式显示字段的内容。
从现在开始,这个字段的概念是典型的,除了有很多验证来了解 effective_readonly
属性的状态之外:
在用于显示小部件内容的 QWeb 模板中,如果我们处于读写模式,则显示
<input type="text" />
,如果处于只读模式,则不显示任何特定内容。在
display_field()
方法中,我们需要绑定<input type="text" />
的change
事件,以了解用户何时更改了值。当发生更改时,我们使用字段的新值调用internal_set_value()
方法。这是由AbstractField
类提供的便捷方法。该方法将在value
属性中设置新值,但不会触发对render_value()
的调用(因为<input type="text" />
已经包含了正确的值,所以这不是必要的)。在
render_value()
中,我们使用完全不同的代码来显示字段的值,具体取决于我们是处于只读模式还是读写模式。
Exercise
创建颜色字段
创建一个 FieldColor
类。该字段的值应该是一个字符串,包含一个颜色代码,就像在 CSS 中使用的那样(例如: #FF0000
表示红色)。在只读模式下,这个颜色字段应该显示一个小方块,其颜色与字段的值相对应。在读写模式下,你应该显示一个 <input type="color" />
。这种类型的 <input />
是一个 HTML5 组件,在所有浏览器中都不起作用,但在 Google Chrome 中效果很好。所以在练习中使用它是可以的。
您可以在 message_of_the_day
模型的表单视图中使用该小部件,用于其名为 color
的字段。作为额外的奖励,您可以更改在本指南的前一部分中创建的 MessageOfTheDay
小部件,以使用 color
字段中指定的背景颜色显示当天的消息。
表单视图自定义小部件¶
表单字段用于编辑单个字段,并与字段密切相关。但这可能有限制,因此也可以创建 表单小部件 ,这些小部件不那么受限制,与特定生命周期的联系较少。
可以通过 widget
标签将自定义表单小部件添加到表单视图中:
<widget type="xxx" />
这种类型的小部件将在HTML的创建过程中由表单视图根据XML定义简单地创建。它们与字段有一些共同的属性(例如 effective_readonly
属性),但它们没有被分配一个具体的字段。因此,它们没有像 get_value()
和 set_value()
这样的方法。它们必须继承自 FormWidget
抽象类。
表单小部件可以通过监听表单字段的变化来与其交互,并获取或修改其值。它们可以通过其 field_manager
属性访问表单字段:
local.WidgetMultiplication = instance.web.form.FormWidget.extend({
start: function() {
this._super();
this.field_manager.on("field_changed:integer_a", this, this.display_result);
this.field_manager.on("field_changed:integer_b", this, this.display_result);
this.display_result();
},
display_result: function() {
var result = this.field_manager.get_field_value("integer_a") *
this.field_manager.get_field_value("integer_b");
this.$el.text("a*b = " + result);
}
});
instance.web.form.custom_widgets.add('multiplication', 'instance.oepetstore.WidgetMultiplication');
FormWidget
通常是 FormView()
本身,但是从中使用的功能应该被限制在 FieldManagerMixin()
定义的功能中,其中最有用的是:
get_field_value(field_name)()
which returns the value of a field.set_values(values)()
设置多个字段值,接受一个映射{field_name: value_to_set}
每当字段名为
field_name
的字段的值发生变化时,会触发一个事件field_changed:field_name
Exercise
在谷歌地图上显示坐标
向 product.product
添加两个字段,分别存储纬度和经度,然后创建一个新的表单小部件,在地图上显示产品原产地的纬度和经度
要显示地图,请使用Google地图的嵌入功能:
<iframe width="400" height="300" src="https://maps.google.com/?ie=UTF8&ll=XXX,YYY&output=embed">
</iframe>
应将 XXX
替换为纬度,将 YYY
替换为经度。
在产品表单视图的新笔记本页中显示两个位置字段和一个地图小部件。
Exercise
获取当前坐标
添加一个按钮,将产品的坐标重置为用户的位置,您可以使用 javascript geolocation API 获取这些坐标。
现在我们想要显示一个额外的按钮,以自动将坐标设置为当前用户的位置。
要获取用户的坐标,一种简单的方法是使用地理位置 JavaScript API。 查看在线文档以了解如何使用。
请注意,当表单视图处于只读模式时,用户不应该能够点击该按钮。因此,这个自定义小部件应该像任何字段一样正确处理 effective_readonly
属性。一种方法是在 effective_readonly
为true时使按钮消失。