Odoo QWeb 精通:全面开发者指南

发布于:2025-05-24 ⋅ 阅读:(14) ⋅ 点赞:(0)

Odoo QWeb 简介

QWeb 是 Odoo 的主要 XML 基础模板引擎 。它主要用于为 Odoo 的各个部分生成 HTML 片段和页面。作为 Odoo 生态系统的核心组成部分,QWeb 在动态生成用户界面和文档方面扮演着至关重要的角色。

QWeb 的目的与优势:

QWeb 的设计目标是提供一个强大且灵活的模板解决方案,与 Odoo 的模块化和可扩展性架构紧密集成。其主要优势包括:

  • 基于 XML 的结构: QWeb 模板采用 XML 格式,这使得模板的继承和修改变得非常健壮和方便。这对于 Odoo 的模块化特性至关重要,因为不同的模块可能需要对核心模板进行调整或扩展 。这种结构化的继承机制是 Odoo 灵活性的基石之一,允许开发者在不重写整个模板的情况下,精确地修改模板的特定部分。
  • 服务器端与客户端渲染: QWeb 最初更多地用于服务器端渲染,例如生成报表和由服务器处理的视图。然而,随着技术的发展,QWeb 也越来越多地应用于客户端渲染,尤其是在 Odoo Web Library (OWL) 组件中 。这种双重能力使得 QWeb 能够适应不同的应用场景。
  • 与 Odoo 数据的集成: QWeb 能够无缝地访问和展示来自 Odoo 模型的数据。这意味着开发者可以轻松地将数据库中的信息动态地呈现在用户界面或报表中。
  • 动态内容生成: QWeb 提供了丰富的指令,用于实现条件渲染、循环遍历和动态属性操作,从而能够根据具体数据和逻辑生成高度动态的内容。

QWeb 之所以选择 XML 而非基于文本的模板引擎,其深层原因是 XML 提供的结构化扩展能力,这对于 Odoo 这样一个高度可定制的 ERP 系统来说是不可或缺的 。

QWeb 的主要应用场景:

QWeb 的应用遍及 Odoo 系统的各个方面,使其成为 Odoo 开发者必须掌握的核心技术之一:

  • 报表 (PDF/HTML): QWeb 是生成动态 PDF(通过 wkhtmltopdf 工具)和 HTML 报表的标准技术 。常见的例子包括发票、销售订单、采购订单以及各种自定义的业务分析报表。
  • 视图 (看板视图、自定义视图等):
    • 看板视图 (Kanban Views): 看板视图使用 QWeb 来定义卡片的结构和内容,使得信息能够以直观的方式组织和呈现 。
    • 自定义 QWeb 视图: 开发者可以创建 ir.ui.view 记录,并将其 type 设置为 qweb,以实现独特的、高度定制化的数据显示方式 。
    • 列表视图字段渲染 (List view field rendering): 在列表视图中,QWeb 也可以用于渲染特定的字段或单元格,尤其是在使用自定义小部件 (widget) 时 。
  • 网站页面与代码片段 (Website Pages & Snippets): Odoo 网站构建器广泛使用 QWeb 来定义主题、页面布局以及动态内容的代码片段 。
  • 邮件模板 (Email Templates): 动态邮件内容,例如订单确认邮件、营销邮件等,也是通过 QWeb 模板生成的 。
  • OWL 组件 (Odoo Web Library Components): QWeb 模板是定义 OWL 组件结构的核心部分,OWL 是 Odoo 用于构建现代化、响应式用户界面的前端框架 。

QWeb 的普遍应用意味着深入理解它能够解锁 Odoo 几乎所有模块的定制能力,不仅仅是后端报表,还包括前端网站设计和客户端 UI 组件。这种广泛的应用使得 QWeb 技能成为 Odoo 开发者的一个高杠杆技能点。

此外,QWeb 的演进,例如其在客户端 OWL 组件中的应用,预示着 Odoo 内部正朝着更动态和交互式的用户界面发展,逐渐超越传统的服务器渲染页面。这反映了现代 Web 应用的发展趋势。

值得注意的是,PDF 报表的生成依赖于外部工具 wkhtmltopdf。因此,wkhtmltopdf 的正确安装、配置及其可能存在的特性,对于 QWeb PDF 报表的正常工作至关重要。开发者需要意识到,有时 PDF 生成问题可能源于 wkhtmltopdf 而非 QWeb 模板本身 。

I. QWeb 基础:语法与核心概念

理解 QWeb 的基础语法和核心概念是有效使用该模板引擎的前提。QWeb 模板本质上是 XML 文档,通过特定的指令和规则与 Odoo 数据进行交互,最终生成 HTML 输出。

A. 基本 XML 结构与命名空间

QWeb 模板遵循标准的 XML 文档结构。当在一个文件中定义多个模板时,根元素通常是 <templates>;对于单个报表模板,根元素可能是 <template>。在视图定义中,如看板视图或表单视图,QWeb 代码段会嵌入到相应的视图结构标签(如 <kanban><form>)内部 。

QWeb 指令是通过以 t- 为前缀的 XML 属性来声明的,例如 t-if 用于条件判断,t-esc 用于安全输出数据 。

<templates> 标签上经常使用 xml:space="preserve" 属性。这个属性指示 XML 解析器保留模板内容中的空白字符(如空格、换行符),这对于最终生成的 HTML 的格式化和布局可能非常重要 。

<t> 元素本身是一个特殊的 QWeb 元素。它充当一个占位符,其自身不会在最终输出中生成任何 HTML 标签,但它所携带的 QWeb 指令会被正常处理。这对于需要应用逻辑控制(如条件判断或循环)但又不希望引入额外 HTML 包裹元素的情况非常有用 。

深入理解 QWeb 的 XML 本质,对于掌握其解析规则以及模板继承机制(如 XPath 如何操作模板结构)至关重要。

B. 核心 QWeb 指令详解

QWeb 提供了多种指令来处理数据输出、变量定义、流程控制等。

1. 输出数据:

  • t-esc:此指令计算一个 JavaScript 表达式,并将其结果进行 HTML 转义后输出到文档中。这是显示数据最安全、最常用的方式,可以有效防止跨站脚本攻击 (XSS) 。
    • 示例:<p>你好,<t t-esc="user.name"/>!</p>
  • t-raw:此指令计算一个 JavaScript 表达式,并将其结果直接输出到文档中,不进行 HTML 转义必须极其谨慎地使用此指令,因为如果输出的数据来源于用户输入或未经验证的外部源,可能会导致 XSS 漏洞 。
    • 示例:<div t-raw="html_field_content"/> (假设 html_field_content 是一个可信的 HTML 字符串)。
    • 使用指导: 仅应用于预先处理过、确认安全的 HTML 内容,或者明确需要输出原始 HTML 的场景。t-esct-raw 之间的区别对于安全性至关重要,对此的误解很容易导致 XSS 漏洞,这是 Web 应用程序中常见的安全缺陷。
  • t-out:在许多上下文中,t-outt-esc 的别名,同样执行 HTML 转义。Odoo 的一些核心文档 使用 t-out 进行通用输出。对于报表开发,t-esct-field 更为常见和推荐。

2. 设置变量:t-set

  • t-set 指令用于在模板的作用域内定义变量 。
  • 它可以接受一个 t-value 属性,该属性的值是一个 JavaScript 表达式,表达式的计算结果将赋给变量。
    • 示例:<t t-set="item_count" t-value="items.length"/>
  • 如果 t-set 标签没有 t-value 属性,则其标签体 (body) 内容会被渲染,渲染后的字符串结果将赋给变量。
    • 示例:<t t-set="complex_string">这是一个<em>格式化</em>的字符串。</t> (变量 complex_string 将包含 HTML 字符串 This is a <em>formatted</em> string.)。

3. 理解变量作用域与访问

  • 使用 t-set 定义的变量通常在当前模板及其通过 t-call 调用的子模板中可用(除非 t-call 的主体创建了一个新的局部上下文)。
  • t-foreach 循环内部,会创建新的变量(循环变量本身,以及 *_index, *_value 等辅助变量)。这些变量的作用域仅限于当前循环。如果循环外部存在同名变量,并且该变量在循环内部被修改,那么在循环结束时,修改后的值会被复制回全局上下文 。
    • 注意: 这种值复制回传的行为,如果不仔细管理,有时可能会导致难以察觉的错误,尤其是在嵌套循环或循环内部有复杂 t-set 操作时。 提及了关于 t-call 中子模板变量作用域的复杂性,这暗示了它是一个常见的混淆点。
  • 访问变量时,直接在表达式中使用其名称即可:<t t-esc="my_variable"/>

4. QWeb 中的表达式处理

  • QWeb 指令的属性值(如 t-if, t-esc, t-value, t-foreach 中的表达式)通常是 JavaScript 表达式 。
  • 这些表达式可以访问:
    • 来自渲染上下文的变量(从 Python 端传递或由 t-set 定义)。
    • 标准的 JavaScript 对象和函数(例如 Math 对象、字符串方法)。
    • 对象的属性和方法(例如 user.name, items.length 中的 record.state.raw_value)。
  • 这些表达式的求值上下文至关重要。对于报表,Odoo 提供了一个特定的服务器端 Python QWeb 引擎。对于客户端 QWeb(例如,在 OWL 或看板视图中),则是一个 JavaScript QWeb 引擎。虽然语法上相似,但可用的全局对象或辅助函数可能会有所不同。

t-name 指令 仅限于在模板文件的顶层使用,这意味着在单个 XML 文件中,全局可调用模板的命名空间是扁平的。这影响了模板的组织方式和引用方式。

下表总结了核心的 QWeb 输出和变量指令:

表 1:核心 QWeb 输出和变量指令

指令

目的

语法示例

主要注意事项/备注

t-esc

输出数据(HTML 转义)

<span t-esc="user.name"/>

推荐用于显示任何动态数据,防止 XSS 攻击。

t-raw

输出原始数据(不进行 HTML 转义)

<div t-raw="trusted_html_content"/>

极度危险,仅用于完全可信的、预处理过的 HTML 内容,否则易导致 XSS 漏洞。

t-out

输出数据(通常行为同 t-esc,进行 HTML 转义)

<p><t t-out="message"/></p>

在现代 Odoo QWeb (尤其是前端/OWL) 中,通常等同于 t-esc。为保持一致性和明确性,优先使用 t-esct-field

t-set (带 t-value)

定义变量并赋予表达式计算结果

<t t-set="item_price" t-value="product.price * (1 - discount_rate)"/>

变量在当前作用域及其调用的子模板中可用。

t-set (带标签体)

定义变量并赋予渲染后的标签体内容(字符串)

<t t-set="greeting">Hello, <strong><t t-esc="name"/></strong>!</t>

变量值为渲染后的 HTML 字符串。如果要在 HTML 中使用此变量,通常需要配合 t-raw


II. QWeb 中的数据绑定与操作

QWeb 模板的核心功能之一是与 Odoo 模型数据进行交互,将其动态地展示在用户界面或报表中。本节将详细探讨如何在 QWeb 中访问模型数据、处理不同数据类型以及执行简单的数据操作。

A. 访问 Odoo 模型数据

在 QWeb 模板中访问 Odoo 模型数据的方式取决于模板的上下文:

  • 报表模板 (Report Templates): 在报表上下文中,当前正在处理的一个或多个记录通常通过名为 docs (如果处理多个记录) 或 doc (如果处理单个记录,或在 t-foreach="docs" t-as="doc" 这样的循环内部) 的变量提供。字段的访问使用点运算符表示法,例如 doc.name 用于访问记录的 name 字段,doc.partner_id.name 用于访问关联的 partner_id 记录的 name 字段 。
  • 看板视图 (Kanban Views): 在看板视图的 QWeb 模板中,当前卡片对应的数据记录通常通过名为 record 的对象访问。例如,record.name.value 用于获取 name 字段的格式化显示值,而 record.priority.raw_value 用于获取 priority 字段的原始值 。注意 .value.raw_value 的区别,前者通常用于显示,后者用于逻辑判断。
  • 通用 QWeb 渲染 (例如网站页面): 对于通用的 QWeb 渲染场景(如网站页面),数据由 Python 控制器传递到渲染上下文中。在模板中,可以直接通过在上下文字典中定义的名称来访问这些数据 。

理解这些上下文变量(如 doc, docs, o, object, record)的来源和它们在不同场景下的具体名称(这可能取决于 t-as 指令的别名设置)是成功访问数据的关键。

B. 使用 t-field 指令进行智能字段渲染

t-field 是一个专门用于渲染 Odoo 通过 ORM 的 browse 方法获取的记录)字段的指令 。它相比 t-esc 提供了更高级的功能:

  • 自动格式化:t-field 能根据字段的类型(如日期、货币、关系字段等)以及用户的区域/语言设置自动处理数据格式化 。
  • 富文本编辑集成: 它与 Odoo 的富文本编辑功能(例如网站内联编辑)集成良好 。
  • 语法:<span t-field="record.field_name"/>

通常情况下,推荐使用 t-field 而非 t-esc 来显示模型字段,因为它能利用 Odoo 内置的格式化和本地化功能。 t-field 主要用于直接显示字段值,而 t-esc 更适用于一般表达式的输出。t-field 不仅仅是 t-esc 的语法糖;它是连接 Odoo ORM 能力的关键环节,提供了类型感知的渲染、本地化和部件集成,极大地简化了标准字段显示的模板开发。

C. 处理多样化的数据类型

QWeb 配合 t-fieldt-esc 指令,能够优雅地处理 Odoo 模型中定义的各种数据类型。

1. 文本 (Text)、数字 (Number)、布尔值 (Boolean):

  • 文本 (Char, Text):t-field 直接显示文本内容。t-esc 同样适用。
    • 示例:<span t-field="doc.name"/><t t-esc="doc.description"/>
  • 数字 (Integer, Float):t-field 会根据用户区域设置处理数字格式(如小数点、千位分隔符)。
    • 示例:<span t-field="doc.quantity"/>
    • 对于浮点数字段,可以使用 t-options 指定精度,例如:t-options='{"widget": "float", "precision": 2}'
  • 布尔值 (Boolean):t-field 通常将其渲染为一个禁用的复选框,或者本地化的“是/否”文本。
    • 示例:<span t-field="doc.is_active"/>
    • t-options 可以与特定的部件(如 'checkbox')一起使用以实现特定的渲染效果,尽管 t-field 通常会默认提供一个合理的表示。OWL 组件的布尔字段部件示例 可能在 QWeb t-options 中有类似的应用。

2. 日期 (Date) 和日期时间 (Datetime)(包括格式化):

  • t-field 会根据用户的语言和时区设置自动格式化 DateDatetime 字段。
    • 示例:<span t-field="doc.create_date"/>
  • 可以使用 t-options 并配合 format 字符串进行自定义格式化。 提供了一个示例:t-field-options='{"format": "d-MMM-y"}'
  • 在某些渲染上下文(例如邮件模板 )中,会提供诸如 format_date(date_obj, format_string)format_datetime(datetime_obj, format_string) 之类的辅助函数。
  • 报表上下文提供了 context_timestamp(datetime_obj) 函数,用于将 UTC 日期时间转换为用户所在时区的日期时间 。

3. 选项字段 (Selection Fields)(显示标签):

  • Selection 字段使用 t-field 会自动显示与存储值对应的人类可读标签。
    • 示例:<span t-field="doc.state"/> (如果 state 是一个选项字段)。
  • 如果对选项字段的原始存储值使用 t-esc,则需要手动获取标签,例如通过传递选项字典或使用如 中所示的辅助方法。

4. 关系字段 (Relational Fields: Many2one, One2many, Many2many):

  • Many2one:Many2one 字段使用 t-field 通常会显示关联记录的 name_get() 方法(或 _rec_name 属性)返回的名称。
    • 示例:<span t-field="doc.partner_id"/> 将显示合作伙伴的名称。
    • 要访问 Many2one 关联记录的特定字段:<span t-field="doc.partner_id.zip"/><t t-esc="doc.partner_id.phone"/>。 讨论了 Many2one 字段。
  • One2many & Many2many:t-field 通常不直接用于显示整个列表。相反,应该使用 t-foreach 遍历这些字段中的记录,然后在循环内部对单个记录的字段使用 t-fieldt-esc

示例:

<ul>
    <t t-foreach="doc.order_line_ids" t-as="line">
        <li><span t-field="line.product_id"/>: <span t-field="line.quantity"/></li>
    </t>
</ul>

D. 使用 t-options 和小部件 (Widgets) 自定义字段显示

t-field-options (或在与 t-field 相同的标签上作为 t-options 属性) 允许向负责渲染该字段的小部件传递一个 JSON 格式的选项字典 。t-options 的可用性及其行为高度依赖于底层字段类型和所使用的特定小部件,并非所有字段都有一套通用的 t-options

常见的选项包括:

  • "widget":指定用于渲染的特定小部件。例如:
    • "monetary":用于货币字段,通常与 display_currency 一起使用 。示例:<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": "doc.currency_id"}'/>
    • "image":用于显示图像。
    • "priority":用于显示优先级(如星级)。
    • "many2many_tags":将 Many2many 字段显示为标签 。
    • "contact":用于显示联系人信息,可配置显示的字段(如地址、姓名、电话)。示例:<div t-field="o.partner_id" t-options='{"widget": "contact", "fields": ["address", "name", "phone"], "no_marker": true}'/>
  • "format":用于日期/日期时间字段,指定格式化字符串 。示例:{"format": "d-MMM-y"}
  • "precision":用于浮点数字段,指定小数位数 。示例:{"precision": 2}
  • "display_currency":用于货币字段,其值是一个表达式,计算结果为货币对象或其 ID 。
  • 其他特定于小部件的选项:例如 contact 小部件的 no_marker,或 many2many_tags 小部件的 color_field

开发者需要查阅特定小部件的文档或 Odoo 源代码(字段类型定义、视图渲染代码)来了解可用的自定义选项,例如 (Studio 字段小部件) 和 (视图架构字段选项)。

E. 在模板中执行简单的数据操作和计算

QWeb 模板允许在 JavaScript 表达式中执行基本的算术和字符串操作:

  • 示例(算术):<t t-esc="price * quantity * (1 - discount / 100)"/>
  • 示例(字符串连接):<t t-set="full_name" t-value="user.first_name + ' ' + user.last_name"/>

然而,对于更复杂的逻辑或数据聚合,通常更好的做法是在提供 QWeb 模板上下文的 Python 控制器或模型方法中预先准备好数据,而不是让模板本身变得混乱 。QWeb 主要用于展示,虽然它支持 JavaScript 表达式,但复杂的业务逻辑或数据处理更适合利用 Python 的能力和 ORM 的功能(如 search, read_group, 计算字段 )。这种关注点分离是通用的软件设计原则。

下表总结了不同数据类型下 t-fieldt-options 的常见用法:

表 2:按数据类型划分的常见 t-fieldt-options 用法

数据类型 (Odoo 字段)

基本 t-field 用法

示例 t-options

备注/常用小部件

文本 (Char, Text)

<span t-field="doc.name"/>

整数 (Integer)

<span t-field="doc.quantity"/>

浮点数 (Float)

<span t-field="doc.amount"/>

{"widget": "float", "precision": 2}

precision 控制小数位数。

布尔值 (Boolean)

<span t-field="doc.is_active"/>

{"widget": "boolean_toggle"} (如果适用)

通常显示为复选框或 "是/否"。

日期 (Date)

<span t-field="doc.order_date"/>

{"format": "YYYY-MM-dd"}

format 自定义日期格式。widget: "date"remaining_days

日期时间 (Datetime)

<span t-field="doc.create_date"/>

{"format": "YYYY-MM-dd HH:mm:ss"}

format 自定义格式。考虑时区,可配合 context_timestamp

货币 (Monetary)

<span t-field="doc.price_total"/>

{"widget": "monetary", "display_currency": "doc.currency_id"}

display_currency 必须提供货币对象或 ID。

选项 (Selection)

<span t-field="doc.state"/>

自动显示可读标签。

多对一 (Many2one)

<span t-field="doc.partner_id"/>

{"widget": "contact", "fields": ["name", "phone"]} (用于伙伴)

默认显示 _rec_name。可访问关联字段:doc.partner_id.city

一对多/多对多 (One2many/Many2many)

(在 t-foreach 循环内对单个记录的字段使用 t-field)

{"widget": "many2many_tags", "color_field": "color"} (用于 Many2many 标签)

通常通过 t-foreach 迭代,然后对每个子记录的字段使用 t-field。例如 widget: "many2many_tags"


III. 控制模板逻辑:条件与循环

QWeb 提供了强大的指令来控制模板的渲染流程,包括基于条件的显示和对数据集的迭代。这些控制流工具使得模板能够根据动态数据生成灵活的输出。

A. 条件渲染:t-if, t-elif, t-else

这些指令用于根据 JavaScript 表达式的求值结果来决定 XML/HTML 块是否被渲染 。

  • t-if="condition":如果 condition 表达式的计算结果为真值 (truthy),则包含此指令的元素及其内容将被渲染。
    • 示例:<p t-if="user.is_admin">管理员权限</p>
  • t-elif="condition":如果前一个 t-ift-elif 的条件为假值 (falsy),则计算此 t-elifcondition。如果为真值,则渲染其内容。
  • t-else="":如果所有前面的 t-ift-elif 条件都为假值,则渲染包含 t-else 指令的元素及其内容。注意,t-else 本身不带条件表达式。

示例:

<div t-if="order.state == 'sale'">订单已确认</div>
<div t-elif="order.state == 'draft'">报价单</div>
<div t-else="">已取消</div>

条件渲染指令可以直接应用于任何 HTML 元素,也可以应用于 <t> 占位符元素,以便在不引入额外 HTML 标签的情况下进行逻辑控制 。

使用场景: 根据记录的状态、用户角色或字段值显示不同的信息。例如, 建议使用 t-if/t-else 在属性没有报价时显示一条消息,而不是一个空表格。

B. 使用 t-foreacht-as 进行迭代

t-foreach 指令用于遍历集合中的项目,如列表、字典(JavaScript 中的对象)、Odoo 记录集,甚至是数字(尽管对数字的迭代已被弃用)。

  • t-foreach="collection_expression":指定要迭代的集合,其值为一个计算结果为集合的 JavaScript 表达式。
  • t-as="variable_name":在每次迭代中,将当前项目赋值给名为 variable_name 的变量。

示例 (列表/记录集):

<ul>
    <t t-foreach="order.order_line_ids" t-as="line">
        <li><span t-field="line.product_id.name"/> - <t t-esc="line.price_unit"/></li>
    </t>
</ul>

示例 (字典/JavaScript 对象): 当迭代一个字典时,variable_name (由 t-as 定义) 会获取当前项的键 (key),而 variable_name_value (例如,如果 t-as="detail",则为 detail_value) 会获取当前项的值 (value) 。

<dl>
    <t t-foreach="{'名称': user.name, '邮箱': user.email}" t-as="detail">
        <dt><t t-esc="detail"/></dt>
        <dd><t t-esc="detail_value"/></dd>
    </t>
</dl>

利用循环辅助变量: QWeb 在 t-foreach 循环内部提供了一些特殊的辅助变量,用于获取关于迭代状态的信息 。这些变量对于许多常见的 UI 模式(如对首尾元素应用不同样式、条纹行或项目编号)至关重要,无需在模板中使用复杂的 JavaScript。

辅助变量 (基于 t-as="item")

描述

示例用法

item_index

当前迭代的索引(从 0 开始)。

<span t-esc="item_index + 1"/>. <t t-esc="item.name"/>

item_value

当前迭代项目的值。对于列表,与 item 相同;对于字典,是键对应的值。

<t t-esc="item_value"/> (在字典迭代中)

item_first

布尔值,如果当前是第一个迭代项目,则为 true。

<div t-if="item_first" class="first-item">...</div>

item_last

布尔值,如果当前是最后一个迭代项目,则为 true (要求集合大小已知)。

<hr t-if="not item_last"/>

item_size

集合中项目的总数 (如果已知)。

共 <t t-esc="item_size"/> 项

item_parity (已弃用)

字符串 "even" 或 "odd",表示当前迭代的奇偶性。

<li t-attf-class="{{item_parity}}">...</li>

实现嵌套循环: 通过将一个 t-foreach 结构放置在另一个 t-foreach 结构内部来实现。每个循环都维护其自己的一套辅助变量。 提供了嵌套循环的示例,特别是在主记录循环中处理 one2many 字段时。

<t t-foreach="departments" t-as="dept">
    <h3><t t-esc="dept.name"/></h3>
    <ul>
        <t t-foreach="dept.employee_ids" t-as="emp">
            <li><t t-esc="emp.name"/> (索引: <t t-esc="emp_index"/>)</li>
        </t>
    </ul>
</t>

对整数进行迭代的 t-foreach 功能已被弃用 ,这表明 QWeb 更倾向于迭代显式的集合(列表/数组)。如果需要固定次数的迭代而没有实际的集合,开发者应在上下文中准备一个列表/数组(例如 ``)或使用 t-set 生成。

虽然 t-if 可以处理条件逻辑,但复杂的条件链或业务规则评估最好在 Python 中执行,然后将简单的布尔值或预计算结果传递给 QWeb。这能保持模板的整洁,并将逻辑集中管理。


IV. QWeb 中的动态属性

QWeb 提供了强大的指令来动态地设置 HTML 元素的属性。这使得模板能够根据数据或逻辑条件改变元素的行为和外观。

A. 使用 t-att-$attribute_name 直接设置属性

t-att-$attribute_name 指令用于动态设置单个 HTML 属性。其中 $attribute_name 是要设置的属性的名称(例如 src, href, data-record-id)。该属性的值是 JavaScript 表达式的计算结果 。

示例:

<img t-att-src="product.image_url"/>

示例:

<div t-att-data-record-id="record.id"/>

B. 使用 t-attf-$attribute_name 设置格式化字符串属性

t-attf-$attribute_namet-att- 类似,但其值是一个格式化字符串,而不是一个纯粹的 JavaScript 表达式。在这个格式化字符串中,位于 {{ }} 双大括号内的 JavaScript 表达式会被求值,并将其结果插入到字符串中 。

此指令对于构建混合了字面量字符串和动态数据的属性值特别有用,尤其常用于动态设置 class 属性。

示例:

<div t-attf-class="base_class {{ record.state == 'done'? 'highlight_done' : '' }} {{ extra_user_class }}"/>

示例:

<a t-attf-href="/shop/product/{{ product.id }}">查看产品</a>

t-attf-$attribute_name 在管理 CSS 类时特别强大,因为它允许以可读的方式将静态基类与多个条件类组合起来,这是非常常见的 UI 需求。

C. 使用 t-att="mapping"t-att="pair" 设置多个属性

QWeb 还提供了通过单个 t-att 指令一次性设置多个属性的方法。

  • t-att="mapping":此形式的 t-att 指令接受一个 JavaScript 对象(字典)作为其值。该对象中的每个键值对都会在 HTML 元素上生成一个对应的属性及其值 。

示例:

<div t-att="{'data-id': product.id, 'data-name': product.name, 'class': 'product_item'}"/>

这种 t-att="mapping" 的形式可以简化同时设置多个 data-* 属性的过程,这对于在 HTML 元素中嵌入元数据以供 JavaScript 交互或样式化非常有用。

  • t-att="pair":此形式的 t-att 指令接受一个包含两个元素的 JavaScript 数组 [attribute_name, attribute_value] 作为其值。数组的第一个元素是属性名,第二个元素是属性值 。

示例:

<div t-att="['data-dynamic-attr', product.custom_attribute_name]"/>

这种形式相对 mapping 形式而言不太常用。

虽然 QWeb 提供了这些动态属性功能,但过度使用它们来进行复杂的样式操作(如果这些操作可以通过更抽象的 CSS 规则或从 Python 传递预先计算的状态指示符来处理)可能会降低模板的可维护性。如果每个样式变体都由复杂的 t-attf-class 表达式控制,模板将变得难以阅读。通常更好的做法是定义诸如 .state-draft, .state-confirmed 之类的 CSS 类,然后在模板中简单设置 t-attf-class="record-item state-{{record.state}}",让 CSS 来处理每种状态的具体样式。这符合关注点分离的原则。


V. 模板继承与扩展

QWeb 的模板继承机制是 Odoo 模块化和可扩展性的核心特性之一。它允许开发者在不直接修改原始模板代码的情况下,对现有的模板进行调整、添加内容或修改行为。这对于定制 Odoo 的标准视图、报表以及其他 QWeb 驱动的界面元素至关重要 。

A. 理解 QWeb 继承机制

QWeb 继承的主要目标有两个:

  1. 就地修改现有模板 (extension): 直接更改父模板的行为,使得任何调用该父模板的地方都会看到修改后的版本。
  2. 基于现有模板创建新模板 (primary): 创建一个继承自父模板内容的新模板,这个新模板可以独立调用,而父模板保持不变。

B. 使用 t-inheritt-inherit-mode

这两个指令是实现 QWeb 模板继承的关键:

  • t-inherit="parent_module.parent_template_id":此指令指定要继承的父模板。其值是父模板的完整技术名称,格式为 模块名.模板ID
  • t-inherit-mode:此指令定义继承的行为模式:
    • "extension":此模式用于就地修改父模板。任何模块在请求父模板时,都将获得经过此继承修改后的版本。这通常用于修改 Odoo 核心或其他应用提供的标准视图或报表 。
      • 示例:<t t-inherit="sale.report_saleorder_document" t-inherit-mode="extension">... </t>
    • "primary":此模式用于创建一个新的子模板,该子模板继承父模板的内容,但可以作为独立的模板被调用。如果提供了 t-name 属性,则新模板以此命名;否则,它将使用 t-inherit 指定的父模板名称(这通常不推荐,应为新模板指定明确的名称)。
      • 示例:<t t-name="my_module.my_custom_saleorder_doc" t-inherit="sale.report_saleorder_document" t-inherit-mode="primary">... </t>

在继承模板时,可以(对于 primary 模式通常是必须的)提供一个可选的 t-name 属性。对于 extension 模式,t-name 主要用作注释,帮助追溯继承关系。对于 primary 模式,t-name 定义了新创建的子模板的名称 。

extensionprimary 继承模式的选择对系统定制产生深远影响。extension 模式是一种全局性修改,影响所有使用该基础模板的地方,功能强大但也伴随着潜在的副作用风险,需要谨慎管理。而 primary 模式则更为安全,它创建了一个独立的变体,不影响原始模板。

C. 使用 XPath 表达式修改模板

在继承的模板内部,使用 XPath (XML Path Language) 表达式来选取父模板中需要修改的节点 。XPath 是一种强大的语言,用于在 XML 文档中导航和选择节点。

  • 选择器和常见模式:
    • //tag[@attribute='value']:选择具有特定属性值的任何 tag 元素。例如,//div[@id='total_amount']
    • //field[@name='my_field']:按名称选择字段 。
    • //t[@t-call='module.template_to_find']:选择特定的 t-call 指令 。
    • *[hasclass('my_class')]:选择具有特定 CSS 类的元素(这是 Odoo QWeb 对 XPath 的一个扩展函数)。
    • 相对路径:例如 div/p 选择直接位于 div 下的 p 元素。
  • position 属性:指定如何修改选中的节点。下表总结了常用的 position 值及其效果 。

表 3:XPath position 属性值及其效果

position

描述

示例用例

inside

(默认值) 将继承规范的内容追加到选中节点的内部,成为其最后一个子节点。

在一个 div 内部添加新的段落:<xpath expr="//div[@id='content']" position="inside"><p>New content</p></xpath>

after

将继承规范的内容添加到选中节点的父节点的子节点列表中,紧跟在选中节点之后(即成为选中节点的兄弟节点)。

在某个字段后添加另一个字段:<xpath expr="//field[@name='name']" position="after"><field name="description"/></xpath>

before

将继承规范的内容添加到选中节点的父节点的子节点列表中,位于选中节点之前(即成为选中节点的兄弟节点)。

在某个按钮前添加提示信息:<xpath expr="//button[@name='action_confirm']" position="before"><p>Please verify details.</p></xpath>

replace

用继承规范的内容完全替换选中的节点。如果在替换内容中使用了 $0,则 $0 会被原始选中节点的副本替换,从而实现对原始节点的包裹效果。

替换一个字段并用 div 包裹:<xpath expr="//field[@name='amount']" position="replace"><div class="highlight">$0</div></xpath>

attributes

修改选中节点的属性。其内容应为 <attribute> 标签。<attribute name="attr_name">new_value</attribute> 用于设置或更改属性;<attribute name="attr_name" add="class_to_add" remove="class_to_remove" separator=" "/> 用于修改类名等列表型属性。

使字段不可见:<xpath expr="//field[@name='confidential_data']" position="attributes"><attribute name="invisible">1</attribute></xpath>

示例:

<t t-inherit="base_module.original_template" t-inherit-mode="extension">
    <xpath expr="//div[@id='some_div']" position="after">
        <p>此段落添加在 ID 为 'some_div' 的 div 之后。</p>
    </xpath>

    <xpath expr="//field[@name='original_field']" position="attributes">
        <attribute name="invisible">1</attribute>
    </xpath>

    <xpath expr="//*[hasclass('important_section')]" position="attributes">
        <attribute name="class" add="extra_highlight" separator=" "/>
    </xpath>
</t>

XPath 的有效使用需要对目标模板的结构有很好的理解。开发者通常需要检查原始模板的 XML 结构才能编写出准确且健壮的 XPath 表达式。父模板的结构变化可能会导致继承模板中的 XPath 选择器失效,因此维护是一个需要考虑的因素。使用稳定的属性(如 id)或特定的字段名通常比依赖类名顺序或复杂的路径更为可靠。 提到 XPath 不是使用字符串作为选择器,这意味着它作用于 XML 节点结构。

attributes 定位方式,特别是配合 addremove 属性来修改类名,是一种非常常见且简洁的方式来调整现有元素的样式或行为,而无需完全替换它们 。


VI. 使用 t-call 构建可复用的 QWeb 组件

t-call 指令是 QWeb 中实现代码复用和模块化的核心机制。它允许开发者定义可复用的模板片段(或称为组件、子模板),并在其他模板中调用它们,从而避免代码重复,提高模板的可读性和可维护性 。

A. 定义和调用子模板

  • 定义子模板: 子模板的定义方式与普通 QWeb 模板相同,使用 <t t-name="my_module.my_sub_template">...</t> 结构,其中 t-name 属性赋予模板一个唯一的名称(通常包含模块名以确保唯一性)。
  • 调用子模板: 在另一个模板中,使用 t-call="my_module.my_sub_template" 指令来调用(或“包含”)已定义的子模板 。

B. 管理上下文和向被调用模板传递数据

当使用 t-call 调用子模板时,数据的传递和上下文管理有几种方式:

  • 继承上下文 (Inherited Context): 默认情况下,被调用的子模板会继承调用方模板的完整渲染上下文。这意味着在调用方模板作用域内定义的所有变量,在被调用的子模板中都是可访问的 。

示例:

<t t-set="user_name" t-value="'张三'"/>
<t t-call="my_module.greeting_template"/>

<t t-name="my_module.greeting_template">
    <p>你好, <t t-esc="user_name"/>!</p> </t>
  • 通过 t-call 主体内的 t-set 传递数据 (局部上下文 Local Context): 可以在 t-call 指令的主体(即 <t t-call>...</t t-call> 标签之间)使用 t-set 来定义变量。这些变量仅对被调用的子模板可见,形成一个局部上下文,不会污染调用方模板的作用域,也不会影响后续对其他子模板的调用 。这种方式使得子模板的依赖关系更清晰,减少了对全局上下文的依赖,从而创建出更健壮和可复用的组件。

示例:

<t t-call="my_module.user_card">
    <t t-set="card_user" t-value="specific_user_object"/>
    <t t-set="show_details" t-value="true"/>
</t>
<t t-name="my_module.user_card">
    <div>用户:<t t-esc="card_user.name"/></div>
    <div t-if="show_details">邮箱:<t t-esc="card_user.email"/></div>
</t>
  • 0 (零) 变量:t-call 指令主体渲染后的内容,会作为一个名为 0 (零) 的特殊变量传递给被调用的子模板。这允许将复杂的、已渲染的 HTML 块作为参数传递给子模板,非常适用于创建布局组件或“插槽”(slots) 。这种机制允许子模板定义一个通用结构(例如,一个带页眉和页脚的面板),而调用模板则可以动态填充其主体内容,从而提升了布局组件的可复用性。

示例:

<t t-call="my_module.wrapper_template">
    <p>这是要被包裹的<em>自定义内容</em>。</p>
</t>

<t t-name="my_module.wrapper_template">
    <div class="wrapper">
        <t t-raw="0"/> </div>
</t>

C. 创建模块化和可复用 QWeb 代码片段的策略

构建可复用的 QWeb 组件或代码片段,可以遵循以下策略:

  1. 识别重复模式: 在现有的模板中找出重复出现的 UI 模式或自包含的 HTML 块。
  2. 封装为子模板: 将这些重复的部分提取出来,封装到独立的、命名良好的 <t t-name> 模板中。
  3. 使用 t-call 包含: 在需要的地方通过 t-call 指令引入这些子模板。
  4. 显式传递数据: 如果子模板需要特定的参数(而非依赖全局上下文),应通过在 t-call 主体内使用 t-set 来显式传递这些数据。
  5. 设计可配置的片段: 设计子模板时,使其可以通过传入的变量进行配置。例如,一个通用的面板片段可以接受一个 title 变量来设置其标题。
  6. 考虑 OWL 组件: 对于复杂的客户端交互和状态管理,Odoo 的 OWL 框架(它使用 QWeb 进行模板定义)提供了更强大的组件模型 。虽然本指南主要关注通用 QWeb,但提及 OWL 作为这一概念的延伸是相关的。t-call 非常适合服务器端渲染的复用和简单的客户端包含。
  7. 参考核心模块: Odoo 核心模块中包含了大量使用 t-call 实现组件化的示例,例如网站应用中的头部组件 website.placeholder_header_brand, website.submenu 等 。

通过这些策略,可以构建出结构清晰、易于维护且高度可复用的 QWeb 模板库,从而提高开发效率和代码质量。


VII. QWeb 实战:常见的 Odoo 应用场景

QWeb 作为 Odoo 的核心模板引擎,在各种应用场景中都发挥着关键作用。本节将探讨 QWeb 在生成报表、构建看板视图以及在网站、邮件和 OWL 组件中的应用。

A. 生成 PDF 和 HTML 报表

QWeb 是 Odoo 中生成动态 PDF 和 HTML 报表的标准技术。

1. 报表动作 (ir.actions.report) 与模板结构:

  • 报表通过在 XML 文件中定义 <record model="ir.actions.report"> 来声明 。
  • ir.actions.report 记录中的关键字段包括:
    • name:用户友好的报表名称,将显示在打印菜单中。
    • model:此报表关联的 Odoo 模型(例如 sale.order)。
    • report_type:报表类型,通常为 qweb-pdf(生成 PDF)或 qweb-html(生成 HTML)。
    • report_name:主 QWeb 模板的完整技术名称,格式为 模块名.模板ID (例如 my_module.report_my_document) 。
    • report_file:通常与 report_name 相同,指向包含 QWeb 模板定义的 XML 文件中的模板 ID 。
    • binding_model_id:将此报表链接到指定模型的“打印”菜单 。
    • paperformat_id:引用一个 report.paperformat 记录,用于定义 PDF 的页面格式(如纸张大小、边距、方向等)。
  • 报表的主 QWeb 模板的 id 通常需要与 report_actionreport_name 字段指定的值匹配 。

2. 标准布局 (web.html_container, web.external_layout):

Odoo 提供了一些标准的 QWeb 布局模板,以简化报表的创建:

  • web.html_container:提供基本的 HTML 文档结构(<html>, <head>, <body> 标签)。这通常是报表模板中最外层的 t-call
  • web.external_layout:在 web.html_container 内部调用。此布局会自动为报表添加默认的公司页眉和页脚。报表的具体内容应放置在调用此布局后的 <div class="page"> 元素内部 。要全局更改这些默认设置(例如,修改所有报表的页眉/页脚),需要继承并修改 web.external_layout 这个基础布局模板本身。

3. 访问报表特定上下文 (docs, user, res_company 等):

在 QWeb 报表模板中,可以访问一个预定义的上下文,其中包含以下常用变量:

  • docs:一个记录集,包含正在为其生成报表的一个或多个 Odoo 对象 。
  • doc_idsdocs 记录集中记录的 ID 列表 。
  • doc_modeldocs 记录集的模型名称 。
  • user:当前打印报表的用户的 res.users 记录 。
  • res_company:当前用户所属公司的 res.company 记录 。
  • time:Python 的 time 模块的引用 。
  • web_base_url:Web 服务器的基础 URL 。
  • context_timestamp(datetime_obj):一个函数,用于将 UTC 时区的 datetime 对象转换为用户所在时区的日期时间 。
  • 自定义数据: 可以通过在名为 report.module_name.report_name 的 Python 模型中定义 _get_report_values(self, docids, data=None) 方法,向报表上下文添加额外的自定义数据 。

4. 国际化:使用 t-lang 实现可翻译报表:

  • t-call 指令上使用 t-lang 属性,可以使被调用的模板以指定的语言进行渲染 。
  • 这常用于根据合作伙伴(如客户)的语言偏好打印报表,例如 t-lang="doc.partner_id.lang"
  • 示例:<t t-call="my_module.report_body_translatable" t-lang="doc.partner_id.lang"/>
  • 限制:t-lang 仅在调用外部模板进行翻译时有效。如果要翻译模板的一部分,需要将该部分提取到其自己的外部模板中,然后使用 t-lang 调用它 。

B. QWeb 在看板视图 (Kanban Views) 中的应用

看板视图使用 QWeb 模板来定义其卡片的可视化结构和内容。

1. 定义看板卡片结构 (<kanban><templates><t t-name="kanban-box">):

  • 看板视图的 QWeb 模板嵌入在 <kanban> 视图定义的 <templates> 标签内部 。
  • 每个看板卡片的主模板必须命名为 kanban-box

示例:

<kanban>
    <field name="name"/>
    <field name="priority"/>
    <templates>
        <t t-name="kanban-box">
            <div class="oe_kanban_global_click">
                <strong><field name="name"/></strong>
                <div>优先级:<field name="priority" widget="priority"/></div>
            </div>
        </t>
    </templates>
</kanban>

2. 在卡片内访问记录数据 (record.field_name.value):

  • 在看板 QWeb 模板内部,当前卡片对应的数据记录通过 record 对象访问 。
  • 字段的访问方式类似于 record.field_name。每个字段对象通常具有以下属性:
    • record.field_name.value:字段的格式化值,适合直接显示。
    • record.field_name.raw_value:字段在数据库中的原始未格式化值,常用于条件逻辑判断 。
  • 看板模板的渲染上下文中还可能包含其他变量,如 widget(包含 editable, deletable 等布尔值,指示用户权限)、read_only_mode、日期时间处理库 luxonJSON 对象等 。

看板 QWeb (kanban-box) 是在客户端渲染的。这意味着应尽量减少在卡片模板中进行复杂的数据操作或获取未在视图字段中明确包含的关联数据,以避免性能问题或从客户端发起过多的 RPC 调用。通常,应通过在看板视图定义中包含必要的字段(即使是隐藏字段)来预取数据,以便在 t-if 等条件判断中使用 。

C. 简要概述:QWeb 在网站代码片段、邮件模板和 OWL 组件中的应用

  • 网站代码片段/页面 (Website Snippets/Pages): QWeb 用于定义网站页面和可复用代码片段的结构及动态行为。数据通常从 Python 控制器传递到模板 。
  • 邮件模板 (Email Templates): 用于在邮件中生成动态内容。上下文中通常包含 object (与邮件相关的记录)、userformat_date 等变量 。
  • OWL 组件 (Odoo Web Library Components): OWL 组件使用 QWeb 来定义其模板结构。传递给组件的属性 (props) 可以在 QWeb 模板中访问 。

QWeb 的渲染上下文在服务器端报表、客户端看板视图(JavaScript QWeb)和 OWL 组件之间存在显著差异。开发者必须清楚在每种特定场景下哪些变量和辅助函数是可用的。例如,报表上下文有 docs, user, res_company,看板上下文有 record, widget,而邮件模板上下文有 object, format_date。这种差异意味着 QWeb 代码片段并非总能不经修改地在这些不同上下文之间直接移植。


VIII. QWeb 调试与故障排除

在 QWeb 模板开发过程中,遇到错误或需要验证模板行为是常有的事。Odoo 和 QWeb 提供了一些工具和技术来帮助开发者调试和排除故障。

A. 利用 t-debug, t-log, 和 t-js 进行检查

QWeb 提供了一些内置指令,用于在模板渲染时进行调试:

  • t-debug
    • Python (服务器端 QWeb,例如报表):t-debug 指令的值为空字符串时 (<t t-debug=""/>),它会调用 Python 的内置 breakpoint() 函数,这通常会启动一个 Python 调试器,如 pdb
    • JavaScript (客户端 QWeb,例如看板视图、OWL 组件): 在客户端 QWeb 中,<t t-debug=""/> 会在模板渲染到该点时触发浏览器的 JavaScript 调试器断点 。
  • t-log="expression" (仅限 JavaScript QWeb): 此指令会计算指定的 expression (JavaScript 表达式),并使用 console.log() 将其结果输出到浏览器的开发者控制台 。
    • 示例:<t t-log="my_variable"/> 将在控制台打印 my_variable 的值。
  • t-js="context_name" (仅限 JavaScript QWeb): 此指令允许在其主体内执行任意 JavaScript 代码。渲染上下文会以 context_name 指定的名称在此 JavaScript 代码块中可用 。

示例:

<t t-js="ctx">
    console.log('当前用户名:', ctx.user.name);
    if (ctx.items.length === 0) {
        debugger; // 以编程方式设置断点
    }
</t>

此外,Odoo 的开发者模式本身也提供了许多用于检查视图、字段和可用数据的工具,这对于理解传递给 QWeb 模板的上下文非常有帮助 。激活开发者模式通常会在界面上显示更详细的错误信息,并解锁技术菜单。

B. 识别和解决常见错误

以下是一些在 QWeb 开发中可能遇到的常见错误及其排查思路:

  • 模板未找到 (Template Not Found):
    • 原因:t-call 指令中引用的模板名称不正确、模板 ID 拼写错误、包含模板的模块未安装,或者模板文件未在模块的 __manifest__.py 文件的 dataassets 部分正确列出 。
    • 排查:仔细核对模板名称(应为 模块名.模板ID 格式),检查 __manifest__.py 文件,确保模块已正确安装或更新。
  • wkhtmltopdf 相关错误 (针对 PDF 报表):
    • 错误信息可能类似于 "Wkhtmltopdf failed (error code : -10)" 。
    • 原因:wkhtmltopdf 工具未正确安装或配置、系统路径问题、版本不兼容、QWeb 模板中包含 wkhtmltopdf 无法处理的无效 HTML/CSS,或者与特定布局(如 web.internal_layout)的兼容性问题。
    • 排查:确认 wkhtmltopdf 已正确安装且在系统路径中,检查 Odoo 服务器日志以获取来自 wkhtmltopdf 的更详细错误信息,尝试简化模板以隔离有问题的 HTML/CSS,测试使用 web.external_layout 是否能正常工作。
  • XPath 继承错误:
    • 错误信息可能类似于 "Element '<xpath>' cannot be located in parent view." 。
    • 原因:XPath 表达式未能匹配到父模板中的任何元素,或者父模板的结构已发生变化。
    • 排查:仔细检查 XPath 表达式的语法和逻辑,对照父模板的 XML 结构进行验证,尽量使用更稳定的选择器(例如基于 id 或特定字段名,而非依赖元素顺序或不稳定的 CSS 类)。
  • 变量作用域问题 / 未定义变量:
    • 原因:尝试访问当前上下文中不存在的变量(例如,在 t-call 主体内部设置的变量,却试图在外部访问;或者变量名拼写错误)。
    • 排查:使用 t-log (客户端) 或 t-debug (服务器端/客户端) 检查当前作用域内可用的上下文变量,回顾 t-sett-call 的作用域规则。
  • XML 解析错误:
    • 原因:模板中存在格式错误的 XML(例如,标签未闭合、特殊字符未正确转义)。
    • 排查:Odoo 服务器日志通常会指示错误发生的文件和行号。可以使用 XML 验证工具检查模板的语法。
  • 字段未找到 / 记录属性错误 (Field Not Found / Attribute Error on Record):
    • 原因:尝试访问模型上不存在的字段,或者该字段未被获取到当前的记录对象中。
    • 排查:确保字段名称拼写正确,字段确实存在于模型定义中。对于看板视图等客户端渲染的视图,确保该字段已在视图的 <field> 声明中列出,或者如果仅用于 t-if 等逻辑判断,也需要确保其被正确获取。

许多所谓的 "QWeb 错误" 实际上并非 QWeb 语法本身的错误,而是与数据上下文(例如,试图访问记录上不存在的字段)、外部依赖(如 wkhtmltopdf)或模板继承逻辑不正确相关。这意味着故障排除通常需要超越 QWeb 模板代码本身。

C. 利用 Odoo 服务器日志进行错误诊断

Odoo 服务器日志对于诊断 QWeb 渲染错误至关重要,尤其是对于服务器端 QWeb(如报表)。日志中可能包含:

  • XML 解析错误,通常会指明出错的文件和行号。
  • 在 QWeb 表达式求值或从 QWeb 调用模型方法时发生的 Python 异常。
  • 来自 wkhtmltopdf 的详细错误信息。

通过调整 Odoo 服务器的启动参数,例如设置 --log-level=debug,可以获取更详细的日志输出,有助于定位问题 。

调试 QWeb 通常需要多方面的方法:对于客户端 QWeb(看板视图、OWL),应使用浏览器开发者工具;对于服务器端 QWeb(报表),则依赖服务器日志和 Python 调试器 (PDB);同时,仔细检查和理解当前的数据上下文是所有场景下都必不可少的。


IX. 优化 QWeb 模板性能

虽然 QWeb 设计得相当高效,但在处理复杂模板或大量数据时,性能仍然是需要考虑的因素。本节将介绍一些优化 QWeb 模板性能的策略和工具。

A. 缓存策略:t-cachet-nocache (主要用于 Python QWeb)

QWeb 提供了一套缓存指令,主要用于服务器端渲染(例如报表),以减少不必要的计算和数据库查询,从而加快渲染速度 。

  • t-cache="cache_key_expression":此指令用于标记模板的一部分进行缓存。在缓存块内的所有子指令(包括那些可能导致数据库查询的指令)只会在给定 cache_key_expression 的结果首次出现时执行。后续对相同缓存键的请求将直接使用缓存的结果。
    • 目的: 通过缓存最终文档的某些部分来加速渲染,特别是那些依赖数据库查询且内容不经常变化的部分。
    • 缓存键 (Cache Key):cache_key_expression 是一个 Python 表达式。如果表达式返回一个记录集,QWeb 会使用该记录集的模型名、ID 和 write_date (最后修改时间) 来生成缓存键,这样当记录被修改时,缓存会自动失效。如果表达式返回一个元组或列表,它们也会被用于生成缓存键。如果键表达式的计算结果为假值 (Falsy),则该部分内容不会被缓存。
    • 注意事项:
      • t-cache 可能会使模板逻辑复杂化,尤其是在与 t-set 和模板继承一起使用时。
      • t-cache 块内部使用 t-set 定义的变量具有作用域,但它们可能会影响模板后续部分的渲染,即使这些部分不在 t-cache 块内部。
      • 为了有效地通过缓存减少数据库查询,模板可能需要使用延迟求值 (lazily evaluated) 的值进行渲染。如果这些延迟值在缓存部分中使用,并且该部分已存在于缓存中,则这些延迟值将不会被重新评估。
  • t-nocache="documentation_string":此指令用于标记模板的一部分(通常位于已缓存的区域内)每次渲染时都保持动态,即不使用缓存。documentation_string 参数仅用于文档目的,不起实际作用。
    • 目的: 允许在一个较大的缓存块内部嵌入小块需要动态更新的内容。
    • 限制:t-nocache 块内部的内容只能访问初始渲染上下文提供的根值。在 t-nocache 内部使用 t-set 定义的变量,其作用域仅限于该 t-nocache 块。
  • t-nocache-*="expression":此指令允许缓存模板内部生成的原始值(primitive values)。语法为 t-nocache-*="expr",其中 * 是所选值的名称,expr 是其结果将被缓存的 Python 表达式。这个缓存的值每次都会被添加到作用域的根值中。

t-cache 是一个强大的高级功能,但不正确的使用(例如,错误的缓存键、对作用域的误解)可能导致数据陈旧或性能提升不明显。使用前需要通过分析器进行仔细分析。

B. 高效数据处理和模板逻辑的最佳实践

  1. 最小化 QWeb 中的复杂逻辑: 将复杂的数据处理、大量计算和广泛的数据获取操作放在 Python 端(模型或控制器中)完成,然后将准备好的数据传递给 QWeb 模板。模板应主要关注表现层逻辑 。
  2. 预取数据 (Prefetch Data): 当在循环中访问关联字段时,应在 Python 中预取这些数据,以避免 N+1 查询问题。N+1 查询问题是渲染记录列表及关联数据时常见的性能瓶颈。虽然 QWeb 本身不直接导致此问题,但在循环中访问数据的方式(如 <t t-field="record.many2one_id.related_field"/>)若未在 Python 端预取数据,则可能触发此问题 。
    • Python 示例:records = self.env['my.model'].search([...]).with_prefetch(self._fields['one2many_field'].ids)
  3. 高效循环: 注意避免在 QWeb 中直接迭代非常大的数据集。如果可能,应在 Python 端考虑分页或数据汇总。
  4. 避免重复计算: 如果一个表达式的结果在模板中多处被使用,应使用 t-set 将其计算一次并存储在变量中,然后复用该变量。
  5. 平衡 t-call 的使用: 虽然 t-call 有利于代码复用,但对于非常小且不复杂的代码片段,过多的 t-call 调用可能会引入轻微的性能开销。应在可复用性和性能之间取得平衡。

C. Odoo 性能分析器简介 (Profiler for QWeb)

Odoo 内置了一个性能分析器,可以帮助识别性能瓶颈,包括 QWeb 模板渲染过程中的瓶颈 。

  • 启用分析器: 可以通过开发者工具中的“启用分析”按钮,或在“设置” -> “常规设置” -> “性能”中设置分析的持续时间来启用。
  • 记录内容: 分析器可以记录执行的 SQL 查询和 Python 的堆栈跟踪。
  • QWeb 收集器 (QWeb Collector): 对于 QWeb 性能分析,启用分析器选项中的“添加 qweb 指令上下文 (Add qweb directive context)”至关重要。这有助于分析模板渲染过程,识别使用延迟值的指令,并查看哪些部分触发了数据库查询。这对于优化 t-cache 的使用非常有价值 。
  • 分析结果: 分析结果可以展示代码/模板不同部分所花费的时间,帮助定位性能问题。

一些通用的性能提示 包括:确保服务器具有足够的资源(CPU、RAM、SSD),优化 PostgreSQL 配置(如 work_mem, shared_buffers),并审查自定义模块以查找低效代码。

客户端 QWeb(例如看板视图)的性能也至关重要。大量复杂的卡片,或者那些会触发额外 JavaScript 计算或数据获取的卡片,都可能导致 UI 响应迟缓。高效的数据加载和精简的卡片模板是关键。


X. QWeb 开发最佳实践

遵循一套成熟的最佳实践,可以显著提高 QWeb 模板的质量、可读性、可维护性和性能。

A. 在模块内组织和管理 QWeb 模板文件

  • 目录结构: 将相关的 QWeb 模板组织到模块内的专用 XML 文件中,通常放置在 views/report/ 目录下 。
    • 例如:views/my_model_views.xml (用于视图相关的 QWeb,如看板卡片定义),report/my_report_templates.xml (用于报表模板定义),report/my_report_actions.xml (用于报表动作定义)。
  • 文件分离: 对于报表,建议将 QWeb 模板定义与报表动作 (ir.actions.report) 的定义分离开来,放在不同的文件中,以保持清晰 。
  • 命名规范: 为模板文件使用清晰且一致的命名方式,能够反映其内容和用途。

B. 遵循模板和 XML ID 的命名约定

  • 模板名称 (t-name): 应使用 模块名.唯一模板名 的格式,例如 sale.sale_order_portal_contentmy_module.my_custom_card。 建议使用精确的模板名称,只包含小写字母数字和下划线。
  • XML ID (<template id="...">, <record id="...">): 在 Odoo 实例中必须是唯一的。通常的约定是 模块名.模板XML_ID视图模型名_类型_描述。例如,my_module.view_product_kanban_custom_card。 提供了通用的 XML ID 命名约定。
    • 对于继承的视图或模板,可以考虑使用与原始记录相同的 ID,这样有助于一目了然地找到所有相关的继承关系 。
  • 描述性名称: 无论是模板名称还是 XML ID,都应具有描述性,能够清晰地表明其用途。

一致的命名约定不仅仅是为了美观,它们对于确保 Odoo 的继承和覆盖机制正常工作至关重要,尤其是在包含许多自定义模块的大型系统中。不一致或非唯一的 ID 可能导致冲突,或使得追踪模板来源和修改变得极为困难。

C. 编写清晰、可读、可维护的 QWeb 代码

  1. 缩进和格式化: 正确地缩进 XML 代码,以提高可读性。
  2. 注释: 对于复杂的逻辑或不明显的模板部分,使用 XML 注释 `` 进行解释说明。
  3. 最小化模板中的逻辑: 将复杂的业务逻辑保留在 Python 模型或控制器中。QWeb 模板应主要负责数据的展示和简单的表现逻辑 。这是“关注点分离”原则的体现,也是稳健 Odoo 开发的标志。违反此原则通常会导致技术债务。
  4. 可复用性: 对于重复出现的 UI 元素,使用 t-call 将其封装为可复用的子模板 。
  5. 避免硬编码: 尽量使用变量和从上下文中获取的数据,而不是在模板中硬编码文本或可能发生变化的值。
  6. 安全性: 始终对用户生成或动态数据使用 t-esc(或 t-field)进行输出,以防止 XSS 攻击。只有在绝对必要且内容已经过安全处理的情况下,才谨慎使用 t-raw
  7. 简洁性: 在保持清晰的前提下,如果简单的表达式或 t-set 可以达到相同的效果,应避免过于冗长的模板结构。
  8. 遵循 Odoo 编码规范: 在适用的情况下,遵循 Odoo 官方的通用编码指南 。

虽然 Odoo 在 QWeb 中提供了很大的灵活性,但遵循社区的最佳实践(通常可以在 OCA 指南或结构良好的 Odoo 核心模块中找到)可以提高自定义模块的互操作性和长期健康状况。


XI. Odoo 18 中的 QWeb:新特性与变化

Odoo 的每个版本都会带来新的功能和改进,Odoo 18 也不例外。虽然核心 QWeb 引擎的根本性变化不常见,但特定应用领域的功能增强可能会间接影响 QWeb 模板的使用和开发。

A. 邮件模板中 Jinja 到 QWeb 的转换支持

  • Odoo 18 引入了一个增强的邮件编辑器,支持“Jinja 到 QWeb 的转换”。
  • 潜在影响: 这表明 Odoo 正在努力统一其模板技术栈,特别是在邮件模板方面。过去,Jinja 可能在一些较旧的或高度定制的邮件场景中使用。此功能可能旨在提供从 Jinja 到 QWeb 的迁移路径,或者至少使得在邮件模板中更多地使用 QWeb 成为标准。对于开发者而言,这意味着 QWeb 的重要性进一步提升,掌握 QWeb 对于处理邮件模板也变得更加关键。这种统一简化了开发者的学习曲线,并使得 QWeb 成为更加核心的技能。

B. PDF 报价单构建器 2.0 (PDF Quote Builder 2.0)

  • 新功能包括“添加自定义区域(例如,字幕或特定的订单详情)以及为每个报价单创建/编辑翻译”。
  • 对 QWeb 的潜在影响: 虽然这是一个面向用户的功能,但这些自定义区域及其在 PDF 报价单中的可翻译性的底层实现,很可能涉及到对报价单报表 QWeb 模板的增强或更灵活的使用方式。开发者在定制报价单报表时,可能会遇到新的 QWeb 结构或可用选项。

C. 通用 UI/UX 改进

  • Odoo 18 在多个模块(如条码、预约等)都进行了用户体验 (UX) 的改进或全新的设计。
  • 对 QWeb 的潜在影响: UI 的变化通常意味着相关的 QWeb 模板(用于看板视图、自定义视图或相关报表)也会发生变化。开发者如果需要继承或修改这些核心模板,就需要了解 Odoo 18 中新的模板结构。这些功能性增强几乎必然会转化为实现了这些增强的更新版 QWeb 模板。

D. 网站构建块的网格模式 (Grid Mode for Website Building Blocks)

  • 新功能描述为“完全控制网站构建块内的元素,允许精确的自定义和布局调整”。
  • 对 QWeb 的潜在影响: 网站构建块本质上是 QWeb 代码片段。这种新的“网格模式”很可能意味着引入了新的 CSS 类、QWeb 结构或选项,开发者在 Odoo 18 中处理网站主题和代码片段时可以利用这些新特性,或者会遇到这些新的实现方式。

E. 未明确提及核心 QWeb 指令或引擎的重大改动

  • 从已审查的发布说明 和通用文档片段来看,目前没有信息表明 QWeb 引擎本身发生了根本性的变化,或者引入了除上述功能相关的增强之外的主要新 t- 指令。前面章节中讨论的核心 QWeb 语法和指令预计在 Odoo 18 中仍然有效。
  • 核心 QWeb 引擎本身没有宣布重大变化,这表明其基本语法和指令保持了稳定性。这对于已有的 QWeb 知识来说是件好事,因为它们在很大程度上仍然适用。

总体而言,Odoo 18 中与 QWeb 相关的变化更多地体现在特定应用功能的增强上,这些增强会通过新的或修改过的 QWeb 模板来实现,而不是对 QWeb 引擎本身进行颠覆性的改动。开发者在升级或为 Odoo 18 开发时,应关注其定制所涉及的具体模块的 QWeb 实现,以适应这些新的模式和功能。


XII. 综合实践示例:构建自定义 QWeb 报表/视图

为了将前面讨论的 QWeb 概念融会贯通,本节将提供一个构建自定义 QWeb PDF 报表的完整示例。这个例子将涵盖从定义报表动作到编写 QWeb 模板的整个过程。

A. 场景:详细的项目任务摘要报表

  • 目标: 创建一个 PDF 报表,用于显示所选项目任务的摘要信息。报表应包含任务名称、所属项目、负责人、截止日期、任务描述,以及一个子任务列表(如果存在),子任务列表中应包含子任务的名称、状态等信息。
  • 适用模型:project.task

B. 关联的 Python 模型 (简化以供参考)

为简单起见,我们假设 project.task 模型已包含以下字段,并且无需编写额外的 Python 代码来准备报表数据(尽管在实际复杂场景中,通过 _get_report_values 方法预处理数据是很常见的):

  • name (Char): 任务名称
  • project_id (Many2one to project.project): 所属项目
  • user_ids (Many2many to res.users): 负责人
  • date_deadline (Date): 截止日期
  • description (Html): 任务描述
  • child_ids (One2many to project.task): 子任务列表
  • stage_id (Many2one to project.task.type): 任务阶段/状态
  • company_id (Many2one to res.company): 所属公司

C. 报表动作和纸张格式的 XML 定义

首先,我们需要定义报表的纸张格式和报表动作。这些通常放在模块的 report/ 目录下的一个 XML 文件中,例如 custom_reports/report/project_task_report.xml

<odoo>
    <record id="paperformat_project_task_summary" model="report.paperformat">
        <field name="name">Project Task Summary A4</field>
        <field name="default" eval="True"/>
        <field name="format">A4</field>
        <field name="orientation">Portrait</field>
        <field name="margin_top">40</field>
        <field name="margin_bottom">25</field>
        <field name="margin_left">7</field>
        <field name="margin_right">7</field>
        <field name="header_line" eval="False"/>
        <field name="header_spacing">35</field>
        <field name="dpi">90</field>
    </record>

    <record id="action_report_project_task_summary" model="ir.actions.report">
        <field name="name">任务摘要 (Task Summary)</field>
        <field name="model">project.task</field>
        <field name="report_type">qweb-pdf</field>
        <field name="report_name">custom_reports.report_project_task_summary_document</field>
        <field name="report_file">custom_reports.report_project_task_summary_document</field>
        <field name="print_report_name">(object.name or 'Task_Summary')</field>
        <field name="binding_model_id" ref="project.model_project_task"/>
        <field name="binding_type">report</field>
        <field name="paperformat_id" ref="custom_reports.paperformat_project_task_summary"/>
    </record>
</odoo>

XML 定义解释:

  • paperformat_project_task_summary:定义了一个名为 "Project Task Summary A4" 的 A4 纵向纸张格式,并设置了边距等参数。
  • action_report_project_task_summary:定义了一个报表动作。
    • name:报表在用户界面中显示的名称。
    • model:指定此报表是针对 project.task 模型的。
    • report_type:设置为 qweb-pdf,表示生成 PDF 格式的报表。
    • report_namereport_file:指定了 QWeb 模板的完整技术名称,这里是 custom_reports.report_project_task_summary_documentcustom_reports 是模块名,report_project_task_summary_document 是模板的 ID。
    • print_report_name:定义了生成 PDF 文件时的默认文件名,这里会使用任务的名称。
    • binding_model_idbinding_type:将此报表绑定到 project.task 模型的“打印”菜单中。
    • paperformat_id:引用了上面定义的纸张格式。

D. 完整的 QWeb 模板代码

接下来,我们创建 QWeb 模板本身。这通常放在模块的 report/ 目录下的另一个 XML 文件中,例如 custom_reports/report/project_task_templates.xml。模板的 id 必须与报表动作中 report_name 指定的名称一致。

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <template id="report_project_task_summary_document">
        <t t-call="web.html_container">
            <t t-foreach="docs" t-as="task"> <t t-call="web.external_layout"> <div class="page">
                        <h1>任务摘要:<span t-field="task.name"/></h1>
                        
                        <t t-set="company" t-value="task.company_id or res_company"/>

                        <div class="row mt32 mb32">
                            <div class="col-6">
                                <strong>项目:</strong>
                                <span t-field="task.project_id.name"/><br/>
                                <strong>负责人:</strong>
                                <span t-if="task.user_ids">
                                    <t t-foreach="task.user_ids" t-as="user">
                                        <span t-esc="user.name"/>
                                        <t t-if="not user_last">, </t> </t>
                                </span>
                                <span t-else="">未分配</span><br/>
                                <strong>截止日期:</strong>
                                <span t-field="task.date_deadline" t-options='{"format": "MMMM dd, YYYY"}'/><br/>
                            </div>
                            <div class="col-6 text-end"> <strong>公司:</strong>
                                <span t-esc="company.name"/><br/>
                                <strong>报表日期:</strong>
                                <span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
                            </div>
                        </div>

                        <h2>描述</h2>
                        <div t-raw="task.description" style="border: 1px solid #ccc; padding: 10px; min-height: 100px;"/>

                        <h2 t-if="task.child_ids">子任务</h2>
                        <table class="table table-sm" t-if="task.child_ids"> <thead>
                                <tr>
                                    <th>子任务名称</th>
                                    <th>负责人</th>
                                    <th>状态</th>
                                    <th>截止日期</th>
                                </tr>
                            </thead>
                            <tbody>
                                <tr t-foreach="task.child_ids" t-as="sub_task">
                                    <td><span t-field="sub_task.name"/></td>
                                    <td>
                                        <span t-if="sub_task.user_ids">
                                            <t t-foreach="sub_task.user_ids" t-as="st_user">
                                                <span t-esc="st_user.name"/>
                                                <t t-if="not st_user_last">, </t>
                                            </t>
                                        </span>
                                        <span t-else="">未分配</span>
                                    </td>
                                    <td><span t-field="sub_task.stage_id.name"/></td>
                                    <td><span t-field="sub_task.date_deadline" t-options='{"widget": "date"}'/></td>
                                </tr>
                            </tbody>
                        </table>
                        <p t-if="not task.child_ids">此任务没有子任务。</p> </div>
                </t>
            </t>
        </t>
    </template>
</odoo>

E. 示例代码分步解释

  1. 外部结构与迭代:
    • <template id="report_project_task_summary_document">:定义 QWeb 模板。
    • <t t-call="web.html_container">:调用 Odoo 标准的 HTML 容器模板,为报表提供基础 HTML 结构。
    • <t t-foreach="docs" t-as="task">:遍历传递给报表的 project.task 记录集 (docs)。在每次迭代中,当前任务记录赋值给变量 task。如果用户从列表视图选择多个任务并打印此报表,则会为每个任务生成一页。
    • <t t-call="web.external_layout">:调用标准的外部布局模板,它会自动添加公司页眉、页脚和默认样式。
    • <div class="page">:报表每一页的内容都应放在此类中。
  2. 标题和基本信息:
    • <h1>任务摘要:<span t-field="task.name"/></h1>:显示报表主标题和当前任务的名称,使用 t-field 进行智能渲染。
    • <t t-set="company" t-value="task.company_id or res_company"/>:定义一个名为 company 的变量。它首先尝试获取任务记录上的 company_id,如果任务没有关联公司,则回退到使用报表上下文提供的 res_company (当前用户的公司)。
  3. 两列布局显示任务详情:
    • 使用 Bootstrap 的栅格系统 (rowcol-6) 将任务的关键信息分为两列显示。
    • 左列显示项目名称 (task.project_id.name)、负责人列表和截止日期。
    • 负责人列表通过 <t t-foreach="task.user_ids" t-as="user"> 迭代显示,并使用 <t t-if="not user_last">, </t> 在非最后一个用户名后添加逗号。
    • 截止日期使用 t-field="task.date_deadline" t-options='{"format": "MMMM dd, YYYY"}' 进行自定义格式化,例如显示为 "July 26, 2024"。
    • 右列使用 text-end 类使其内容右对齐,显示公司名称和报表生成日期。
    • 报表日期通过 context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M') 获取当前时间,转换为用户时区,并格式化为 "年-月-日 时:分"。这里需要 datetime 模块在上下文中可用(通常报表上下文会提供)。
  4. 任务描述:
    • <h2>描述</h2>:描述部分的标题。
    • <div t-raw="task.description".../>:由于任务描述字段 (description) 通常是 HTML 类型,这里使用 t-raw 来直接输出其内容,保留 HTML 格式。注意: 使用 t-raw 时应确保内容是可信的,以避免 XSS 风险。如果描述内容可能来自不可信用户输入且未经过滤,应考虑使用更安全的处理方式或仅显示纯文本。
  5. 子任务列表:
    • <h2 t-if="task.child_ids">子任务</h2><table... t-if="task.child_ids">:使用 t-if="task.child_ids" 条件指令,仅当当前任务存在子任务 (child_ids 字段不为空) 时,才显示“子任务”标题和子任务表格。
    • 表格使用 Bootstrap 类 table table-sm 进行基本样式化。
    • 表头定义了子任务列表的列:子任务名称、负责人、状态、截止日期。
    • <tr t-foreach="task.child_ids" t-as="sub_task">:迭代当前任务的 child_ids (子任务记录集),每个子任务赋值给 sub_task 变量。
    • 表格行内使用 t-fieldt-esc 显示每个子任务的相应信息。
      • 子任务负责人同样使用嵌套的 t-foreacht-if="not st_user_last" 来处理多用户显示。
      • 子任务状态通过 sub_task.stage_id.name 显示关联阶段的名称。
      • 子任务截止日期使用 t-field="sub_task.date_deadline" t-options='{"widget": "date"}',确保以日期格式正确显示。
    • <p t-if="not task.child_ids">此任务没有子任务。</p>:如果任务没有子任务,则显示一条提示信息。

这个综合示例展示了如何结合使用多种 QWeb 指令(如 t-call, t-foreach (包括嵌套), t-as, t-field, t-esc, t-raw, t-set, t-if, t-options)来构建一个结构清晰、信息丰富的自定义 PDF 报表。它还演示了如何访问关联模型的字段(如 task.project_id.name, sub_task.stage_id.name),进行日期格式化,并利用 Bootstrap 类进行基本的页面布局和样式化。这种多指令的组合是实际 QWeb 开发中构建复杂报表的核心。


结论

Odoo QWeb 模板引擎是 Odoo 框架中一个极其强大且用途广泛的工具。通过本指南的深入探讨,我们可以看到 QWeb 不仅仅局限于生成 PDF 报表,它贯穿于 Odoo 的各个层面,包括动态视图(如看板视图)、网站页面和代码片段、电子邮件模板乃至现代化的 OWL 客户端组件。

掌握 QWeb 的核心语法,如各种 t- 指令(t-esc, t-raw, t-set, t-if, t-foreach, t-call, t-field, t-att-* 等),对于任何 Odoo 开发者来说都至关重要。理解变量的作用域、表达式的计算方式以及数据绑定的机制,是构建功能强大且数据驱动的模板的基础。

模板的继承与扩展机制,无论是通过 XPath 还是 t-inherit 指令,都体现了 Odoo 模块化设计的精髓,允许开发者在不修改核心代码的前提下对现有模板进行灵活的定制和增强。

在实际应用中,遵循最佳实践,如合理组织模板文件、采用清晰的命名约定、保持模板逻辑的简洁性(将复杂业务逻辑置于 Python 模型中)、以及注意性能优化(如使用缓存指令、预取数据),将有助于开发出高质量、可维护的 QWeb 解决方案。

Odoo 18 中虽然未对 QWeb 引擎本身进行革命性的改动,但其在特定应用(如邮件模板、报价单构建器)中的增强和统一,进一步巩固了 QWeb 作为 Odoo 首选模板技术的地位。

通过本指南提供的详细解释、代码示例和综合实践案例,Odoo 开发者和学习者应能系统地理解和掌握 Odoo 18 QWeb 模板引擎,从而有能力独立创建和自定义 Odoo 中的报表与视图,满足各种复杂的业务需求。QWeb 的灵活性和与 Odoo 数据模型的紧密集成,使其成为实现高度定制化用户体验和业务流程的关键技术。


网站公告

今日签到

点亮在社区的每一天
去签到