自定义 Web 客户端¶
危险
本教程已过时。
本指南介绍如何为 Odoo 的 Web 客户端创建模块。
一个简单的模块¶
让我们从一个简单的 Odoo 模块开始,它包含基本的 Web 组件配置,并让我们可以测试 Web 框架。
示例模块可在线获取,可使用以下命令下载:
$ git clone http://github.com/odoo/petstore
这会在你执行命令的位置创建一个 petstore 文件夹。然后你需要将该文件夹添加到 Odoo 的 addons 路径 中,创建新数据库并安装 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 文件夹中,以便浏览器能够访问;该文件夹之外的文件无法被浏览器获取。src/css、src/js 和 src/xml 子文件夹只是约定,并非严格必需。
oepetstore/static/css/petstore.css目前为空,将用于存放宠物商店内容的 CSS
oepetstore/static/xml/petstore.xml基本为空,将用于存放 QWeb 模板 模板
oepetstore/static/js/petstore.js最重要的(也是最有趣的)部分,以 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'); }
它只会在浏览器控制台中打印一条简短消息。
static 文件夹中的文件需要在模块内进行定义,才能被正确加载。src/xml 中的所有内容都在 __manifest__.py 中定义,而 src/css 和 src/js 的内容则在 petstore.xml 或类似文件中定义。
警告
所有 JavaScript 文件都会被合并并 压缩 以提高应用的加载速度。
缺点之一是,随着各个文件消失且代码可读性大幅降低,调试变得更加困难。可以通过启用“开发者模式”来禁用此过程:登录你的 Odoo 实例(默认用户 admin、密码 admin),打开用户菜单(位于 Odoo 屏幕右上角),依次选择 关于 Odoo 和 激活开发者模式:
这会重新加载 Web 客户端并禁用优化,使开发和调试变得舒适得多。
Odoo JavaScript 模块¶
JavaScript 没有内置模块。因此,在不同文件中定义的变量会全部混在一起,可能发生冲突。这催生了各种模块模式,用于构建清晰的命名空间并降低命名冲突的风险。
Odoo 框架使用其中一种模式在 Web 插件模块中定义模块,既能给代码划分命名空间,又能正确安排其加载顺序。
oepetstore/static/js/petstore.js 包含一个模块声明:
odoo.oepetstore = function(instance, local) {
local.xxx = ...;
}
在 Odoo Web 中,模块被声明为设置在全局 odoo 变量上的函数。函数名必须与插件模块名相同(此处为 oepetstore),这样框架才能找到并自动初始化它。
当 Web 客户端加载你的模块时,它会调用根函数并提供两个参数:
第一个参数是 Odoo Web 客户端的当前实例,它提供对 Odoo 定义的各种功能(翻译、网络服务)以及核心或其他模块定义的对象的访问。
第二个参数是由 Web 客户端自动创建的、属于你自己的本地命名空间。应从模块外部访问的对象和变量(可能是因为 Odoo Web 客户端需要调用它们,也可能是因为其他人想要自定义它们)应设置在该命名空间内。
类¶
与模块类似,且与大多数面向对象语言相反,JavaScript 并没有内置 类 1,尽管它提供了大致等价(但更底层、更冗长)的机制。
为简单起见并方便开发者使用,Odoo Web 提供了基于 John Resig 的 Simple JavaScript Inheritance 的类系统。
新类通过调用 odoo.web.Class() 的 extend() 方法来定义:
var MyClass = instance.web.Class.extend({
say_hello: function() {
console.log("hello");
},
});
extend() 方法接受一个描述新类内容的字典(方法和静态属性)。在本例中,它只有一个不接受参数的 say_hello 方法。
类使用 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);
}
Widget 基础¶
Odoo Web 客户端捆绑了 jQuery 以便于操作 DOM。它很有用,提供了比标准 W3C DOM2 更好的 API,但不足以构建复杂的应用,会导致维护困难。
与面向对象的桌面 UI 工具包(例如 Qt、Cocoa 或 GTK)很像,Odoo Web 让特定的组件负责页面的各个区域。在 Odoo Web 中,这类组件的基础是 Widget() 类,它是一个专门处理页面区域并向用户显示信息的组件。
你的第一个 Widget¶
初始演示模块已经提供了一个基础 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');
将我们的基础 Widget 注册为一个客户端动作。客户端动作将在后面解释,目前它只是让我们在选择 菜单时能够调用并显示该 Widget。
警告
因为该 Widget 将从我们模块的外部被调用,Web 客户端需要它的“完全限定”名称,而不是本地版本。
显示内容¶
Widget 有许多方法和特性,但基础部分很简单:
设置一个 Widget
格式化 Widget 的数据
显示 Widget
HomePage Widget 已经有 start() 方法。该方法是正常 Widget 生命周期的一部分,会在 Widget 插入页面后自动调用。我们可以用它来显示一些内容。
所有 Widget 都有一个 $el,它表示它们负责的页面区域(作为一个 jQuery 对象)。Widget 的内容应插入其中。默认情况下,$el 是一个空的 <div> 元素。
如果没有内容(或没有赋予其尺寸的特定样式),<div> 元素通常对用户不可见,这就是启动 HomePage 时页面上什么都没显示的原因。
让我们使用 jQuery 向 Widget 的根元素添加一些内容:
local.HomePage = instance.Widget.extend({
start: function() {
this.$el.append("<div>Hello dear Odoo user!</div>");
},
});
当你打开 时,该消息现在就会显示
注解
要刷新 Odoo Web 中加载的 JavaScript 代码,你需要重新加载页面。无需重启 Odoo 服务器。
HomePage Widget 由 Odoo Web 使用并自动管理。要了解如何“从头开始”使用 Widget,让我们新建一个:
local.GreetingsWidget = instance.Widget.extend({
start: function() {
this.$el.append("<div>We are so happy to see you again in this menu!</div>");
},
});
现在,我们可以通过使用 GreetingsWidget 的 appendTo() 方法将 GreetingsWidget 添加到 HomePage 中:
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首先将自身内容添加到其 DOM 根节点HomePage然后实例化GreetingsWidget最后,它告诉
GreetingsWidget在哪里插入自身,并将自己的一部分$el委托给GreetingsWidget。
当调用 appendTo() 方法时,它会要求该 Widget 在指定位置插入自身并显示其内容。start() 方法将在调用 appendTo() 的过程中被调用。
为了弄清显示的界面背后发生了什么,我们将使用浏览器的 DOM 检查器。不过首先让我们稍微修改一下组件,以便更容易找到它们,方法是 为其根元素添加一个类:
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
最后请注意,代表 GreetingsWidget 实例的 <div class="oe_petstore_greetings"> 元素位于代表 HomePage 实例的 <div class="oe_petstore_homepage"> 内部,因为我们是把它追加进去的
组件的父与子¶
在上一部分中,我们使用下面的语法实例化了一个组件:
new local.GreetingsWidget(this);
第一个参数是 this,在这个例子中是一个 HomePage 实例。它告诉正在创建的组件,哪个组件是它的 父组件。
如我们所见,组件通常由另一个组件插入 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()。
危险
重写 destroy() 时,必须始终 调用 _super(),否则组件及其子组件不会被正确清理,从而可能造成内存泄漏和“幽灵事件”,即使没有显示任何错误
QWeb 模板引擎¶
在上一节中,我们通过直接操作(并向其中添加)组件的 DOM 来为其添加内容:
this.$el.append("<div>Hello dear Odoo user!</div>");
这样可以生成并显示任意类型的内容,但在生成大量 DOM 时会变得笨拙(大量重复、引号处理问题等)
与许多其他环境一样,Odoo 的解决方案是使用 template engine。Odoo 的模板引擎称为 QWeb 模板。
QWeb 是一种基于 XML 的模板语言,类似于 Genshi、Thymeleaf 或 Facelets。它具有以下特点:
它完全用 JavaScript 实现,并在浏览器中渲染
每个模板文件(XML 文件)都包含多个模板
它在 Odoo Web 的
Widget()中得到了特殊支持,不过也可以在 Odoo Web 客户端之外使用(并且可以在不依赖 QWeb 的情况下使用Widget())
注解
使用 QWeb 而不是现有 JavaScript 模板引擎的理由是,已有的(第三方)模板可以被扩展,这与 Odoo 的 视图 非常相似。
大多数 JavaScript 模板引擎都是基于文本的,这使其难以进行便捷的结构性扩展;而基于 XML 的模板引擎可以通过 XPath 或 CSS 以及树形修改 DSL(甚至仅用 XSLT)进行通用的改写。这种灵活性和可扩展性是 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-out="name"/></div>
</t>
将得到:
<div>Hello Klaus</div>
使用 Widget() 的集成时,无法向模板提供额外的数据。模板只会获得一个 widget 上下文变量,它引用在调用 start() 之前正在渲染的组件(组件的状态基本上就是由 init() 设置的状态):
<t t-name="HomePageTemplate">
<div>Hello <t t-out="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 接受正在定义的模板的名称,并声明可以使用 QWeb.render() 调用它。它只能用在模板文件的顶层。
转义¶
t-esc 指令可用于输出文本:
<div>Hello <t t-out="name"/></div>
它接受一个会被求值的 JavaScript 表达式,表达式的结果随后经过 HTML 转义并插入文档中。由于它是表达式,可以像上面那样只提供一个变量名,也可以提供更复杂的表达式,例如一个计算式:
<div><t t-out="3+5"/></div>
或方法调用:
<div><t t-out="name.toUpperCase()"/></div>
输出 HTML¶
要在正在渲染的页面中注入 HTML,请使用 t-raw。与 t-esc 一样,它接受任意 JavaScript 表达式作为参数,但不会执行 HTML 转义步骤。
<div><t t-raw="name.link(user_account)"/></div>
危险
t-raw 绝不能 用于可能包含未经转义的用户所提供内容的任何数据,因为这会导致 cross-site scripting 漏洞
条件判断¶
QWeb 可以使用 t-if 实现条件块。该指令接受任意表达式,如果表达式为假值(false、null、0 或空字符串),则整个块被抑制,否则就会显示出来。
<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-out="name"/>
</div>
</t>
</div>
注解
t-foreach 也可以用于数字和对象(字典)
定义属性¶
QWeb 提供两个相关的指令来定义计算属性,即 t-att-name 和 t-attf-name。无论哪种情况,其中的 name 都是要创建的属性名(例如 t-att-id 在渲染后定义属性 id)。
t-att- 接受一个 JavaScript 表达式,其结果被设为属性的值;当属性的整个值都是计算得出时,它最为有用:
<div>
Input your name:
<input type="text" t-att-value="defaultName"/>
</div>
t-attf- 接受一个 格式字符串。格式字符串是内部带有插值块的普通文本,插值块是位于 {{ 和 }} 之间的 JavaScript 表达式,它会被替换为表达式的结果。对于一部分是字面值、一部分是计算值的属性(例如 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 模板。
练习¶
练习
在组件中使用 QWeb
创建一个组件,其构造函数除 parent 外还接受两个参数:product_names 和 color。
product_names应为一个字符串数组,其中每个字符串是一个产品的名称color是一个以 CSS 颜色格式表示颜色的字符串(例如黑色为#000000)。
组件应将给定的产品名称一个接一个地显示,每个名称放在单独的方框中,方框带有取值为 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绑定方面的问题
因此,组件通过 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 event object。
组件事件与属性¶
事件¶
组件提供了一套事件系统(与上文所述的 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() 绑定一个函数,当由 event_name 标识的事件发生时调用它。func 参数是要调用的函数,object 是该函数关联的对象(如果它是方法)。如果 trigger() 有附加参数,被绑定的函数会以这些参数被调用。示例:
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 是作为第一个参数传给set()的属性名,同时还会触发changeget()获取属性的值。
练习¶
练习
组件属性与事件
创建一个组件 ColorInputWidget,它将显示 3 个 <input type="text">。每个 <input> 都用于输入一个从 00 到 FF 的十六进制数字。当用户修改其中任意一个 <input> 时,组件必须查询这三个 <input> 的内容,把它们的值连接成完整的 CSS 颜色代码(例如 #00FF00),并将结果放入名为 color 的属性中。请注意,你可以绑定到任何 HTML <input> 元素上的 jQuery change() 事件,以及可以查询该 <input> 当前值的 val() 方法,对本练习可能很有用。
然后,修改 HomePage 组件,让它实例化 ColorInputWidget 并显示它。HomePage 组件还应显示一个空矩形。该矩形在任何时刻都必须与 ColorInputWidget 实例的 color 属性中的颜色具有相同的背景色。
使用 QWeb 生成所有 HTML。
修改现有的组件和类¶
Odoo Web 框架的类系统允许使用 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() 将返回为其所给文本定义的翻译。如果没有为该文本定义翻译,则原样返回原始文本。
注解
要在可翻译字符串中注入用户提供的值,建议在翻译 之后 使用带命名参数的 _.str.sprintf:
this.$el.text(_.str.sprintf(
_t("Hello, %(user)s!"), {
user: "Ed"
}));
这使可翻译字符串对译者更易读,也让他们能更灵活地重新排序或忽略参数。
_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_methodargs要传递给该方法的 positional arguments 数组。由于该示例没有需要提供的位置参数,因此不提供
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要传入的 keyword arguments 映射。该示例提供了一个命名参数
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() 返回一个 deferred,其解析后的第一个参数即为该模型方法所返回的值。
CompoundContext¶
上一节使用了 context 参数,但没有解释方法调用中的这个参数:
model.call("my_method", {context: new instance.web.CompoundContext()})
上下文就像是一个“魔法”参数,Web 客户端在调用方法时总会把它传给服务器。上下文是一个包含多个键的字典。其中最重要的键之一是用户的语言,服务器用它来翻译应用的所有消息。另一个是用户的时区,当 Odoo 被不同国家的人使用时,用它来正确计算日期和时间。
argument 在所有方法中都是必需的,否则可能会出问题(例如应用无法被正确翻译)。因此,调用模型的方法时,你应始终提供该参数。实现这一点的解决方案是使用 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}
你可以看到 context 参数中的字典包含一些与 Odoo 中当前用户配置相关的键,以及实例化 CompoundContext() 时添加的 new_key 键。
查询¶
虽然 call() 足以完成与 Odoo 模型的任何交互,但 Odoo Web 提供了辅助工具,让模型的查询(按各种条件获取记录)更简单清晰:query(),它充当 :py search() 和 ::py read() 常见组合的快捷方式。它提供了更清晰的语法来搜索和读取模型:
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() 执行它,并返回一个指向其结果的 deferred。结果与 :py read() 相同,是一个字典数组,其中每个字典是一条请求的记录,每个请求的字段都是字典中的一个键。
练习¶
练习
当日消息
创建一个 MessageOfTheDay 组件,显示 oepetstore.message_of_the_day 模型的最后一条记录。该组件应在显示时立即获取其记录。
在宠物商店主页中显示该组件。
练习
宠物玩具列表
创建一个 PetToysList 组件,显示 5 个玩具(使用它们的名称和图片)。
宠物玩具并不存储在新的模型中,而是使用特殊分类 宠物玩具 存储在 product.product 中。你可以前往 查看预先生成的玩具并添加新的玩具。你可能需要研究 product.product 来创建正确的域,以便只筛选出宠物玩具。
在 Odoo 中,图片通常保存在编码为 base64 的普通字段中,HTML 支持直接通过 base64 显示图片,语法为 <img src="data:mime_type;base64,base64_image_data"/>
PetToysList 组件应显示在主页上 MessageOfTheDay 组件的右侧。为此,你需要用 CSS 做一些布局。
现有的 Web 组件¶
动作管理器¶
在 Odoo 中,许多操作都始于一个 动作:打开菜单项(进入某个视图)、打印报表……
动作是一些数据,描述客户端应如何响应某段内容的激活。动作可以被存储(并通过模型读取),也可以在运行时即时生成(由 JavaScript 代码在客户端本地生成,或由模型的方法在远端生成)。
在 Odoo Web 中,负责处理并响应这些动作的组件是 动作管理器。
使用动作管理器¶
可以从 JavaScript 代码中显式调用动作管理器:创建一个描述正确类型的 动作 的字典,并用它调用动作管理器实例。
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(可选)对于表单视图,
res_model中预选的记录views列出通过该动作可用的视图。它是一个
[view_id, view_type]列表,view_id可以是正确类型视图的数据库标识符,也可以是false,表示对指定类型使用默认视图。视图类型不能重复出现。该动作默认会打开列表中的第一个视图。targetcurrent(默认值),用该动作替换 Web 客户端的“内容”区域;或new,在对话框中打开该动作。context在该动作中使用的附加上下文数据。
练习
跳转到产品
修改 PetToysList 组件,使点击玩具时用该玩具的表单视图替换主页。
客户端动作¶
在本指南中,我们使用了一个简单的 HomePage 组件,当我们选择正确的菜单项时,Web 客户端会自动启动它。但 Odoo Web 是如何知道要启动这个组件的呢?因为该组件被注册为一个 客户端动作。
客户端动作(顾名思义)是一种几乎完全在客户端(对 Odoo Web 而言是 JavaScript)中定义的动作类型。服务器只发送一个动作标签(任意名称),并可选地附加几个参数,除此之外 一切 都由自定义客户端代码处理。
我们的组件通过以下方式注册为该客户端动作的处理程序:
instance.web.client_actions.add('petstore.homepage', 'instance.oepetstore.HomePage');
instance.web.client_actions 是一个 Registry(),动作管理器在需要执行某个客户端动作时会在其中查找对应的处理程序。add() 的第一个参数是客户端动作的名称(标签),第二个参数是从 Odoo Web 客户端根目录到该组件的路径。
当必须执行某个客户端动作时,动作管理器会在注册表中查找其标签,遍历指定的路径,并显示最终找到的组件。
注解
客户端动作处理程序也可以是一个普通函数,这种情况下会调用该函数,其结果(如果有)将被解释为下一个要执行的动作。
在服务器端,我们只是定义了一个 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 的动作时,它会把视图本身的同步和处理委托给一个 视图管理器,后者随后会根据原始动作的要求设置一个或多个视图:
视图¶
大多数 Odoo 视图 都是通过 odoo.web.View() 的子类实现的,后者提供了一些处理事件和显示模型信息的通用基础结构。
搜索视图 被主要的 Odoo 框架视为一种视图类型,但由 Web 客户端单独处理(因为它是一种更常驻的构件,并能与其他视图交互,而普通视图做不到这一点)。
视图负责加载自己的描述 XML(使用 :py fields_view_get())以及它需要的任何其他数据源。为此,视图会获得一个可选的视图标识符,设置为其 view_id 属性。
视图还会获得一个 DataSet() 实例,其中保存了大多数必要的模型信息(模型名称以及可能的多个记录 ID)。
视图也可能希望通过重写 do_search() 来处理搜索查询,并按需更新其 DataSet()。
表单视图字段¶
一种常见需求是扩展 Web 表单视图,以添加显示字段的新方式。
所有内置字段都有默认的显示实现,而为了正确处理新的字段类型(例如 GIS 字段),或为现有字段类型提供新的表示形式和交互方式(例如校验应包含电子邮件地址的 :py Char() 字段并以电子邮件链接的形式显示它们),可能需要新的表单组件。
要显式指定使用哪个表单组件来显示字段,只需在视图的 XML 描述中使用 widget 属性:
<field name="contact_mail" widget="email"/>
注解
同一个组件会同时用于表单视图的“视图”(只读)和“编辑”模式,因此不能在一个模式中使用某个组件、而在另一个模式中使用另一个组件
并且同一个表单中不能多次使用某个给定字段(名称)
组件可以忽略表单视图的当前模式,在视图模式和编辑模式下保持一致
表单视图在读取其 XML 描述并构建表示该描述的相应 HTML 之后,会实例化字段。此后,表单视图将使用一些方法与字段对象通信。这些方法由 FieldInterface 接口定义。几乎所有字段都继承 AbstractField 抽象类。该类定义了一些需要由大多数字段实现的默认机制。
以下是字段类的部分职责:
字段类必须显示字段的值,并允许用户编辑该值。
它必须正确实现 Odoo 所有字段都可用的 3 个字段属性。
AbstractField类已经实现了一种算法,能动态计算这些属性的值(它们的值会随其他字段的值变化,因此可能随时改变)。这些值存储在 组件属性 中(本指南前面已解释了组件属性)。每个字段类都有责任检查这些组件属性,并根据其值动态调整。以下是这些属性的说明:required:保存前该字段必须有值。如果required为true且该字段没有值,则字段的is_valid()方法必须返回false。invisible:当其为true时,字段必须是不可见的。AbstractField类已经实现了适用于大多数字段的基本行为实现。readonly:当为true时,字段不能被用户编辑。Odoo 中的大多数字段会根据readonly的值表现出完全不同的行为。例如,FieldChar在可编辑时会显示一个 HTML<input>,而在只读时只显示文本。这也意味着它的代码要多得多,而这些代码原本只需实现一种行为,但为了保证良好的用户体验这是必要的。
字段有两个方法,即
set_value()和get_value(),表单视图会调用它们来获取要显示的值,并取回用户输入的新值。这些方法必须能够处理 Odoo 服务器在模型上执行read()时给出的值,并为write()返回有效的值。请记住,在 Odoo 中,用于表示read()返回的值和传给write()的值的 JavaScript/Python 数据类型不一定相同。例如,读取 many2one 时,它始终是一个元组,第一个值是指向记录的 id,第二个值是 name get(即(15, "Agrolait"))。但写入 many2one 时,它必须是单个整数,而不再是元组。AbstractField为这些方法提供了默认实现,适用于简单的数据类型,并设置一个名为value的小部件属性。
请注意,为了更好地理解如何实现字段,强烈建议你直接查看 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。我们还把这个类以键 char2 注册到注册表 instance.web.form.widgets 中。这样,我们就可以在视图的 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()中,我们使用完全不同的代码,根据处于只读模式还是读写模式来显示字段的值。
练习
创建颜色字段
创建一个 FieldColor 类。该字段的值应是一个包含颜色代码的字符串,例如 CSS 中使用的颜色代码(示例:红色为 #FF0000)。在只读模式下,该颜色字段应显示一个小色块,其颜色与字段的值相对应。在读写模式下,应显示 <input type="color" />。这种 <input /> 是 HTML5 组件,并非在所有浏览器中都可用,但在 Google Chrome 中工作正常。因此作为练习使用是可以的。
你可以在 message_of_the_day 模型的表单视图中,为其名为 color 的字段使用这个小部件。作为附加练习,你可以修改本指南上一部分创建的 MessageOfTheDay 小部件,用 color 字段中指定的背景色来显示每日消息。
表单视图自定义小部件¶
表单字段用于编辑单个字段,并与某个字段本质绑定。由于这可能存在局限,也可以创建 表单小部件,它们的限制较少,与特定生命周期的关联也更弱。
可以通过 widget 标签将自定义表单小部件添加到表单视图:
<widget type="xxx" />
这类小部件会在表单视图根据 XML 定义创建 HTML 的过程中被简单地创建出来。它们与字段有一些共同的属性(例如 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)()返回字段的值。set_values(values)()设置多个字段值,接收{field_name: value_to_set}形式的映射每当名为
field_name的字段的值发生变化时,都会触发事件field_changed:field_name
练习
在 Google 地图上显示坐标
向 product.product 添加两个字段,分别存储纬度和经度,然后创建一个新的表单小部件,在地图上显示产品原产地的纬度和经度
要显示地图,请使用 Google 地图的嵌入功能:
<iframe width="400" height="300" src="https://maps.google.com/?ie=UTF8&ll=XXX,YYY&output=embed">
</iframe>
其中 XXX 应替换为纬度,YYY 替换为经度。
在产品表单视图的新笔记本页中,显示这两个位置字段以及使用它们的地图小部件。
练习
获取当前坐标
添加一个按钮,将产品的坐标重置为用户所在位置,你可以使用 javascript geolocation API 获取这些坐标。
现在我们想再显示一个按钮,用于自动将坐标设置为当前用户所在位置。
要获取用户的坐标,一个简便的方法是使用地理位置 JavaScript API。 See the online documentation to know how to use it。
另请注意,当表单视图处于只读模式时,用户不应能点击该按钮。因此,这个自定义小部件应像其他字段一样正确处理 effective_readonly 属性。一种做法是当 effective_readonly 为 true 时让按钮消失。