构建接口扩展(Building Interface Extensions)
本指南是关于为Odoo的web客户创建模块。
要创建有Odoo的网站,请参见建立网站;要添加业务功能或扩展Odoo的现有业务系统,请参见构建模块。
警告:
该指南需要以下知识:
Javascript 、jQuery、Underscore.js
同时也需要安装 Odoo 和 Git。
一个简单的模型
让我们从一个简单的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
模块已经包含了各种服务器定制。稍后我们将回到这些内容,现在让我们关注与web相关的内容,在静态文件夹(static)中。
在Odoo模块的“web”端中使用的文件必须放置在静态文件夹中,这样它们就可以在web浏览器中使用,而浏览器之外的文件也不能被浏览器获取。src/css、src/js和src/xml子文件夹是常规的,并不是绝对必要的。
oepetstore/static/css/petstore.css
目前为空,将为宠物店(pet store)内容保留CSS。
oepetstore/static/xml/petstore.xml
大部分也是空的,将保存QWeb模板。
oepetstore/static/js/petstore.js
最重要(也是最有趣的)部分,包含javascript应用程序的逻辑(或者至少是它的web浏览器端)。它现在应该是:
openerp.oepetstore = function(instance, local) { //特别注意:红色部分在开发文档中10.0版本中用odoo关键字,但是测试时无法通过,必须是openerp,估计是尚未完全支持odoo关键字 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'); }
它只在浏览器的控制台打印一个小消息。
静态文件夹中的文件,需要在模块中定义,以便正确加载它们。src/xml中的所有内容都在__manifest . __中定义。在petstore.xml或类似的文件中定义或引用src/css和src/js的内容。
警告
所有的JavaScript文件都被连接和缩小以提高应用程序的加载时间。
其中一个缺点是,随着单个文件的消失,调试变得更加困难,而且代码的可读性也大大降低。可以通过启用“开发者模式”来禁用此过程:
登录到您的Odoo实例(默认用户admin密码admin)打开用户菜单(在Odoo屏幕的右上角)并选择Odoo,然后激活开发者模式:
Odoo JavaScript单元
Javascript没有内置模块。因此,在不同文件中定义的变量都会混合在一起,并可能发生冲突。这引发了各种模块模式,用于构建干净的名称空间并限制命名冲突的风险。 Odoo框架使用一种这样的模式来定义Web插件中的模块,以便命名空间代码和正确地命令其加载。
oepetstore/static/js/petstore.js
文件中包含一个模块声明,代码如下:
openerp.oepetstore = function(instance, local) { local.xxx = ...; }
在Odoo网站中,模块被声明为在全局odoo(请改成openerp)变量上设置的函数。该函数的名称必须与模块名称(在这里为oeststore)相同,以便框架可以找到它,并自动初始化它。
当Web客户端加载你的模块时,它会调用根函数并提供两个参数:
第一个参数(instance)是Odoo Web客户端的当前实例,它允许访问由Odoo(网络服务)定义的各种功能以及由内核或其他模块定义的对象。
第二个参数(local)是您自己的本地名称空间,由Web客户端自动创建。应该可以从模块外部访问的对象和变量(无论是因为Odoo Web客户端需要调用它们,还是因为其他人可能想要定制它们)应该在该名称空间内设置。
类
就像模块一样,并且与大多数面向对象的语言相反,JavaScript不会构建在classes中,尽管它提供了大致相同(如果是较低级别和更详细的)机制。
为了简单和开发人员友好,Odoo web提供了一个基于John Resig的简单JavaScript继承的类系统。
通过调用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()方法,类可以提供初始化程序来执行实例的初始设置。初始化程序接收使用新运算符时传递的参数:
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(); //调用父类中的原始方法,即“hello 。。。” 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回调之后)应该保留对其值的引用,因此不应通过以下方式访问它:
// 以下调用会产生错误 say_hello: function () { setTimeout(function () { this._super(); }.bind(this), 0); } // 以下方式正确 say_hello: function () { // 不能忘记 .bind() var _super = this._super.bind(this); setTimeout(function () { _super(); }.bind(this), 0); }
Widgets基础
Odoo web 客户端捆绑了jQuery以实现简单的DOM操作。它比标准的W3C DOM2更有用,并且提供了更好的API,但不足以构成复杂的应用程序,导致难以维护。 很像面向对象的桌面UI工具包(例如Qt,Cocoa或GTK),Odoo Web使特定组件负责页面的各个部分。在Odoo网站中,这些组件的基础是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注册为客户端操作。客户端操作将在稍后解释,现在这只是当我们选择
菜单时,可以调用和显示我们的窗口小部件。警告
由于该组件将从我们的模块外部调用,Web客户端需要其“完全限定(规范)”名称,而不是任意名称。
显示内容
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,我们来创建一个新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添加到主页:
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将自己的部分插入到GreetingsWidget中。
当调用appendTo()方法时,它会要求小部件(widget,以下将的小部件就是widget)将自身插入指定位置并显示其内容。在调用appendTo()期间,将调用start()方法。
要查看显示界面下发生了什么,我们将使用浏览器的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>元素,因为我们在它们上面添加了一些类。
我们也可以看到我们自己添加的两个消息控制器。
最后,注意GreetingsWidget实例的<div class =“oe_petstore_greetings”>元素位于代表HomePage实例的<div class =“oe_petstore_homepage”>中,这是因为我们追加了该元素。
Widget的父类和子类
在上一部分中,我们使用以下语法实例化了一个小部件:
new local.GreetingsWidget(this); //括号内对象是指greetingswidget实例化后归谁所有。
第一个参数是 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);
销毁Widget
如果您可以向用户显示内容,则应该也可以将其删除。这是通过destroy()方法完成的:
greeting.destroy();
当一个小部件被销毁时,它将首先对其所有子项调用destroy()。然后它从DOM中删除自己。如果你已经在init()或start()中设置了永久结构,必须明确清除它们(因为垃圾回收器不会处理它们),你可以重写destroy()。
危险
当覆盖destroy()时,必须始终调用_super(),否则即使没有显示错误,小部件及其子项也没有正确清理,从而可能会发生内存泄漏和“意想不到的事件”。
QWeb模板引擎
在上一节中,我们通过直接操作(并添加)DOM来将内容添加到我们的小部件:
this.$el.append("<div>Hello dear Odoo user!</div>");
这允许生成和显示任何类型的内容,但在生成大量DOM时会很难处理(大量重复,引用问题......)。
与许多其他环境一样,Odoo的解决方案是使用模板引擎。 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
首先让我们在几乎空白的地方定义一个简单的QWeb模板,在以下文件进行操作:
oepetstore/static/src/xml/petstore.xml
<?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>
local.HomePage = instance.Widget.extend({ start: function() { this.$el.append(QWeb.render("HomePageTemplate")); }, });
QWeb.render()查找指定的模板,将其呈现为一个字符串并返回结果。
但是,因为Widget()对QWeb有特殊的集成,所以模板可以通过它的模板属性直接设置在Widget上:
local.HomePage = instance.Widget.extend({ template: "HomePageTemplate", start: function() { ... }, });
尽管结果看起来相似,但这些用法之间有两点区别:
- 在第二个版本中,模板在调用start()之前就被渲染了;
- 在第一个版本中,模板的内容被添加到小部件的根元素,而在第二个版本中,模板的根元素被直接设置为小部件的根元素。这就是为什么“greetings”子窗口小部件也会出现红色背景。
警告
模板应该有一个非t根元素,特别是如果它们被设置为一个小部件的模板。如果有多个“根元素”,结果是未定义的(通常只有第一个根元素将被使用,其他元素将被忽略)。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()的集成时,不可能为模板提供额外的数据。该模板将被赋予一个单一的窗口小部件上下文变量,引用在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采用被定义模板的名称,并声明可以使用QWeb.render()来调用它。它只能在模板文件的顶层使用。
Escaping(文本输出)
t-esc指令可用于输出文本:
<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('http://www.baidu.com')"/></div> <!-- 产生一个超链接,指向百度-->
危险
t-raw不得用于用户提供的任何可能包含非转义内容的数据,因为这会导致跨站脚本漏洞。
条件语句
QWeb可以使用t-if的条件块。该指令采用任意表达式,如果表达式为falsy(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。如果它是复杂或昂贵的表达式,您可能需要将条件存储在局部变量中。
迭代
要在列表上迭代,请使用t-foreach和t-as。 t-foreach需要一个表达式返回一个列表来迭代t - 因为在迭代过程中需要一个变量名来绑定到每个项目。
<div> <t t-foreach="names" t-as="name"> <div> Hello <t t-esc="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表达式,它将被表达式的结果替换。对于部分文字和部分计算的属性(如类),这是最有用的:
<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的更多信息
练习:在Widgets中使用QWeb
在Widgets创建一个构件除了parent:product_names和color之外还有两个参数的构件。
- product_names应该是一个字符串数组,每个字符串都是一个产品的名称 颜色是包含CSS颜色格式的颜色的字符串(即:#000000表示黑色)。
- 小部件应该将给定的产品名称一个显示在另一个下面,每个显示在一个单独的框中,背景颜色为颜色值和边框。
- 你应该使用QWeb来呈现HTML。任何必要的CSS应该在oepetstore / static / src / css / petstore.css中。 在HomePage中使用小部件,并有六种产品。
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() { var products = new local.ProductsWidget( this, ["cpu", "mouse", "keyboard", "graphic card", "screen"], "#00FF00"); products.appendTo(this.$el); }, }); local.ProductsWidget = instance.Widget.extend({ template: "ProductsWidget", init: function(parent, products, color) { this._super(parent); this.products = products; this.color = color; }, }); instance.web.client_actions.add( 'petstore.homepage', 'instance.oepetstore.HomePage'); }
小工具助手
小部件的jQuery选择器
在窗口小部件中选择DOM元素可以通过调用窗口小部件的DOM根目录上的find()方法来执行:
this.$el.find("input.my_input")...
但是由于这是一种常见的操作,Widget()通过$()方法提供了一个等效的快捷方式:
local.MyWidget = instance.Widget.extend({ start: function() { this.$("input.my_input")... }, });
警告
全局jQuery函数$()应该永远不会被使用(不是this.$()),除非它是绝对必要的:对一个小部件的根进行选择的范围是小部件,对本地来说是本地的,但是使用$()的选择对于页面/应用程序是全局的,并且可以匹配部分其他小部件和视图,导致奇怪或危险的副作用。由于小部件通常只应用于其拥有的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()运行时执行(在小部件初始化期间)
- 它需要处理这个绑定问题
小部件因此提供了通过事件绑定DOM事件的捷径:
local.MyWidget = instance.Widget.extend({ events: { "click .my_button": "button_clicked", }, button_clicked: function() { .. } });
event 是事件触发时调用的函数或方法的对象(映射):
关键是一个事件名称,可能使用CSS选择器进行优化,在这种情况下,只有当事件发生在选定的子元素上时,函数或方法才会运行:点击将处理小部件内的所有点击,但单击.my_button将只处理点击含有my_button类的元素。
该值是触发事件时要执行的操作。
它也可以这样描述:
events: { 'click': function (e) { /* code here */ } }
或对象上方法的名称(请参见上面的示例)。
无论哪种情况,这都是小部件实例,并且处理程序被赋予一个参数,即事件的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>"); }, });
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应用程序感兴趣的平台。