跳到主要内容

Odoo 16 X2ManyField 组件详细解析报告

备注

本文由AI生成+人工校正。

X2ManyField 是 Odoo 16 视图层中处理关系型字段(One2many 和 Many2many)的核心 Owl 组件。它充当了数据管理与渲染器(Renderer)之间的桥梁。

1. 组件概述​

  • 注册名称: one2many, many2many (注册于 fields 注册表)
  • 文件路径: addons/web/static/src/views/fields/x2many/x2many_field.js
  • 支持类型: one2many, many2many
  • 核心功能: 管理关联记录的 CRUD 操作、处理分页、控制视图模式(列表或看板)切换,并作为父组件为渲染器提供数据和操作接口。

2. 组件结构解析​

2.1 属性定义 (Props)​

X2ManyField 继承自 standardFieldProps,并扩展了以下属性:

  • addLabel: (String) 自定义“添加”按钮的文本。
  • editable: (String) 可选值为 'top' 或 'bottom',决定列表行内新增记录的位置。

2.2 核心 Hooks (Setup 阶段)​

组件在 setup() 中通过一系列核心 Hooks 初始化逻辑:

  • useX2ManyCrud: 封装了基础的增删改查逻辑(saveRecord, updateRecord, removeRecord)。
  • useActiveActions: 解析架构(Arch)中的权限,确定是否允许创建、编辑或删除记录。
  • useAddInlineRecord: 专门处理列表行内(Inline)新增记录的逻辑。
  • useOpenX2ManyRecord: 处理在对话框(Dialog)中打开记录详情的逻辑。
  • useSelectCreate: 针对 Many2many 字段,处理打开记录选择器(SelectCreateDialog)的逻辑。

2.3 关键状态与计算属性 (Getters)​

  • viewMode: 获取当前子视图的模式,通常是 'list' 或 'kanban'。
  • list: 返回 props.value,这是一个 X2ManyList 对象,包含了所有的记录数据和状态。
  • rendererProps: 这是一个非常核心的方法。它在传递给渲染器之前,会预处理 archInfo:
    • 动态列隐藏: 遍历列定义,评估 column_invisible 属性。
    • 按钮组处理: 过滤掉不可见的按钮。
    • 权限注入: 将 activeActions 和 onAdd 回调传递给渲染器。
  • displayAddButton: 逻辑判断是否在控制面板显示“添加”按钮(主要用于看板视图)。

3. 核心交互流程​

3.1 新增记录 (onAdd)​

onAdd 方法根据字段类型和配置执行不同策略:

  1. Many2many: 调用 selectCreate 弹出已有记录的选择列表。
  2. 列表行内编辑: 如果配置了 editable,则调用 addInLine 在列表中插入空行。
  3. 表单弹出: 如果未配置行内编辑,则调用 _openRecord 弹出空白表单对话框。

3.2 打开记录 (openRecord)​

当用户点击列表行 or 看板卡片时触发。它会根据 readonly 属性决定是以编辑模式还是只读模式打开记录对话框。

3.3 分页处理 (Pager)​

组件内置了对 Pager 的支持。当关联记录数超过 limit 时,pagerProps 会计算偏移量、限制数和总数,并处理翻页时的异步数据加载。

4. 与 ListRenderer 的关系​

X2ManyField 本身不负责具体的表格渲染,它将这一任务委托给 ListRenderer。

  • 父子通信: X2ManyField (父) 通过 t-props="rendererProps" 向 ListRenderer (子) 传递数据。
  • 回调机制: 当用户在 ListRenderer 中点击“添加一行”或“删除”时,会通过 props 调用 X2ManyField 定义好的方法,从而实现数据层的更新。
  • 动态调整: X2ManyField 在 rendererProps 中会根据当前记录的上下文(Context)实时计算列的可见性,确保渲染器只显示符合条件的列。

5. 模板结构 (XML)​

模板 web.X2ManyField 包含两个主要部分:

  1. o_x2m_control_panel: 包含可选的“添加”按钮和分页器 Pager。
  2. 渲染器占位符: 使用 t-if/t-elif 根据 viewMode 动态挂载 ListRenderer 或 KanbanRenderer。

6. 开发建议​

  • 自定义按钮: 如果需要在 X2M 列表上方添加自定义按钮,建议通过继承 X2ManyField 并扩展其模板来实现。
  • 动态列控制: 利用 column_invisible 可以在不修改 JS 的情况下实现复杂的业务逻辑列隐藏。
  • Context 传递: 通过 onAdd 和 openRecord 传递的 context 是处理父子模型数据联动(如默认值填充)的关键。

7. X2ManyField 与 ListRenderer 的详细上下层传递关系​

在 Odoo 16 的设计中,X2ManyField 是 控制器(Controller) 角色,而 ListRenderer 是 展示器(Presenter) 角色。两者之间存在严密的双向交互。

7.1 下层传递 (Top-Down: Data & Config)​

父组件 X2ManyField 通过 rendererProps 将状态和配置“单向”流向 ListRenderer:

传递项来源作用
listthis.props.value核心数据源。包含记录集、字段元数据、排序状态、选中状态等。渲染器通过 list.records 迭代生成表格行。
archInfothis.activeField.views.list视图结构。包含列定义(Columns)、分组信息、装饰器(Decorations)等。在传递前,父组件会过滤掉 column_invisible 的列。
activeActionsuseActiveActions操作权限。告知渲染器是否显示删除按钮、是否允许拖拽排序等。
editablearchInfo.editable编辑模式。决定渲染器是支持行内编辑('top'/'bottom')还是点击弹窗。
openRecordthis.openRecord打开动作。渲染器点击行时触发的逻辑回调。
onAddthis.onAdd新增动作。渲染器点击“添加一行”时触发的回调。

7.2 上层回调 (Bottom-Up: Actions & Events)​

当用户在 ListRenderer 进行交互时,通过调用父组件传入的 props 方法来通知父组件更新数据:

  1. 新增请求 (onAdd):
    • 触发:点击列表底部的 Add a line 链接。
    • 流程:ListRenderer 调用 this.props.onAdd() -> 执行 X2ManyField.onAdd() -> 触发 useAddInlineRecord (行内新增) 或 selectCreate (M2M选择)。
  2. 打开/编辑请求 (openRecord):
    • 触发:点击非编辑状态下的数据行。
    • 流程:ListRenderer 调用 this.props.openRecord(record) -> 执行 X2ManyField.openRecord() -> 弹出 FormViewDialog。
  3. 删除请求 (onDeleteRecord):
    • 触发:点击行尾的删除/取消关联图标。
    • 流程:ListRenderer 调用 this.activeActions.onDelete(record) -> 执行 useX2ManyCrud 中的删除逻辑。
  4. 状态同步 (setDirty):
    • 触发:行内编辑时,字段值发生变化。
    • 流程:Field 组件通知渲染器 -> 渲染器调用 setDirty -> 父组件追踪记录的变更状态,用于保存逻辑。

7.3 响应式核心:Reactive List​

值得注意的是,list 对象是一个由 Odoo 模型层管理的 Reactive 对象。

  • 当 X2ManyField 增加一条记录到 list 时,由于 Owl 的响应式机制,ListRenderer 会感知到 props.list.records 的变化并自动重新渲染 DOM。
  • 这种机制避免了显式的事件总线(Event Bus)传递,使得上下层在数据一致性上保持同步。

8. 扩展需求​

在明细列表上方增加按钮,点击后自动写入多行预设明细。新行只进入父表单的本地草稿,等用户手动保存后才落库;用户丢弃表单时,虚拟行一并丢掉。

本节对应第 6 节“自定义按钮”建议,说明应继承 X2ManyField、走哪条写入路径、以及为什么这条路径不会立刻入库。

8.1 需求边界​

项约定
按钮位置列表上方的 o_x2m_control_panel,不是列表底部的 Add a line
字段类型明细行用 one2many
落库时机父表单保存时,随父记录一起写成 (0, 0, vals)
显示条件!props.readonly 且 activeActions.create,与原生新增同一套权限
注册方式新 widget(如 preset_one2many),不要覆盖全局 one2many

原生 Add a line 由 ListRenderer 画在表格最后一行(o_field_x2many_list_row_add),够不着控制面板。看板视图的“添加”按钮才画在 o_x2m_control_panel 里(见 displayAddButton)。列表模式下要在表格上方放按钮,只能扩展 web.X2ManyField 模板。

8.2 为何不会立刻入库​

Odoo 16 的 one2many 列表是 StaticList。按钮应复用它自己的 list.addNew(),不要对子模型发 create。

写入链路:

  1. list.addNew({ context, position }) 发出 CREATE 命令。
  2. 底层 _addX2ManyDefaultRecord() 只调子模型的 default_get,得到一个虚拟 id。
  3. 列表把 {operation: 'ADD', isNew: true} 推进父记录的 _changes。
  4. addNew() 里那次 save({ savePoint: true }) 只把当前编辑内容记成可回滚的本地存档。BasicModel.save 在 savePoint 为真时直接返回,不发 create / write。

用户点父表单保存时,这些新行才被收成 (0, 0, vals),跟父记录同一次写入。

8.3 推荐实现路径​

单独做一个小模块,三块东西:

  1. JS:class PresetX2ManyField extends X2ManyField,注册成新 widget(如 preset_one2many)。只有声明了这个 widget 的字段才出现按钮。
  2. XML:t-inherit="web.X2ManyField",t-inherit-mode="primary"。按钮放进已有的 o_x2m_control_panel。
  3. 视图:字段上声明 widget,子 tree 使用行内编辑,例如 editable="bottom"。

预设值从 this.props.record.data 读父记录当前值。many2one 传整数 id。context 里的 default_product_id、default_qty 会进 default_get。

8.4 按钮点击后的处理顺序​

按这个顺序,不要改成直接 RPC:

  1. 若 this.list.editedRecord 还在编辑,先 switchMode("readonly", { checkValidity: true })。X2ManyField.onAdd 就是这样做的。当前行校验不过就停,否则新行可能被丢弃。
  2. 对每一条预设调用 this.list.addNew({ context, position: "bottom" })。参数必须是对象,不要包数组。不要传 mode: "edit",否则新行会进入行内编辑。参数、default_* 与常见踩坑见第 9 节。
  3. default_* 覆盖不到的字段,再用返回的那条 record.update(vals) 补上。update 只会再触发 onchange,仍然不写库。
  4. 若子模型 onchange 会改写 default_*,以第 3 步的 update 为准,它发生在默认值和 onchange 之后。

多行按顺序逐条 addNew。列表已经满一页时,position: "bottom" 会把 limit 临时加一,新行留在当前页。list 是 Reactive 对象,插入后 ListRenderer 会自动重绘,不必再手动 render()。

8.5 不要走的路径​

做法结果
子记录上的 orm.create、record.save()立刻插入数据库
表单上的 type="object" 按钮Odoo 会先保存父记录再调方法
many2many 的“新建并关联”脏记录会先被 save()
覆盖全局 one2many widget所有 one2many 列表都会出现该按钮

子模型 onchange 里如果自己 create,那是服务端副作用,前端拦不住。预设行要满足 tree 上的必填字段,否则父表单保存会被校验拦住。

9. this.list.addNew 实操​

按钮点击后真正写入草稿行的入口是 StaticList.addNew(Odoo 16 在 basic_relational_model.js)。它只收 一个对象,不是数组。

9.1 签名与参数​

async addNew(params) {
const position = params.position;
const operation = { context: [params.context], operation: "CREATE", position };
await this.model.__bm__.save(this.__bm_handle__, { savePoint: true });
this.model.__bm__.freezeOrder(this.__bm_handle__);
await this.__syncParent(operation);
const newRecord = this.records[position === "bottom" ? this.records.length - 1 : 0];
if (params.mode === "edit") {
await newRecord.switchMode("edit");
}
return newRecord;
}
参数类型作用
contextObject追加到 default_get 的 context。default_xxx 会填到对应字段。
position"top" / "bottom"新行插入位置。不是 "bottom" 时取 records[0]。
mode"edit" / 其他只有 "edit" 会切到行内编辑。预设多行时应传 "readonly",或干脆不传。

返回值是刚插入的那条 Record。default_* 覆盖不到的字段,再对它 await record.update(vals)。

框架里的标准调用也是对象,不是数组:

// useAddInlineRecord
await addNew({ context, mode: "edit", position: editable });

// X2ManyField.onAdd:先合并字段 context,再走 addInLine
context = makeContext([record.getFieldContext(this.props.name), context]);
return this.addInLine({ context, editable });

setup() 里这句容易看错:

this.addInLine = useAddInlineRecord({
addNew: (...args) => this.list.addNew(...args),
});

...args 打印出来是数组,但展开后 addNew 收到的仍是那个对象。自定义代码应直接传对象,不要按打印结果再包一层 []。

9.2 正确调用​

const record = this.props.record;
const context = makeContext([
record.getFieldContext(this.props.name),
{ default_distance: 49.99, default_company_id: companyId },
]);

if (this.list.editedRecord) {
await this.list.editedRecord.switchMode("readonly", { checkValidity: true });
if (this.list.editedRecord) {
return; // 当前行校验失败,停
}
}

const newRecord = await this.list.addNew({
context,
mode: "readonly",
position: "top",
});
await newRecord.update({ co2_rmk: "preset" });

要点:

  1. 传对象 { context, mode, position }。
  2. 用 makeContext([字段 context, 额外 default_]) 合并。第二个参数是求值环境,不会并进返回的 context。
  3. 多行必须逐条 await。addNew 会改 list 的 commands,并发 CREATE 会互相覆盖。
  4. 当前行还在编辑时,先切回只读并校验,与 X2ManyField.onAdd 一致。

9.3 default_ 如何落到字段​

addNew 把 params.context 包成 { context: [params.context], operation: "CREATE" }。底层 _addX2ManyDefaultRecord 把每一项当作 additionalContext,再调子模型 default_get。

sanitize_default_values 只会剥掉 列表自身 context 里已有的 default_*,避免旧默认值污染新行。你这次传入的 additionalContext 不会被剥,default_distance 会进 default_get。

写法结果
makeContext([fieldCtx, { default_distance: 49.99 }])distance 有默认值
makeContext([fieldCtx], { default_distance: 49.99 })第二参只作求值环境,distance 没有默认值
this.list.addNew([{ context, mode, position }])params.context 为 undefined,default_* 全部丢失

9.4 错误示范:把 rest 参数当成 API​

// 错:包了一层数组
this.list.addNew([{
context: { default_distance: 49.99 },
mode: "readonly",
position: "top",
}]);

此时 params 是数组:

取值实际结果
params.contextundefined
params.positionundefined
params.modeundefined

CREATE 仍会执行,所以列表里能看到新行,但 default_get 拿不到 default_distance,字段全是空。行能建、默认值没有,就是这个原因。

9.5 完整示例:自定义按钮连插多行​

继承 X2ManyField 后,在点击方法里写:

async addMultiLines() {
const record = this.props.record;
const context = makeContext([
record.getFieldContext(this.props.name),
{ default_distance: 49.99 },
]);
for (let i = 0; i < 6; i++) {
await this.list.addNew({
context,
mode: "readonly",
position: "top",
});
}
}

新行只进父表单草稿。用户保存父记录时才写成 (0, 0, vals);丢弃表单则虚拟行一起丢掉。不要对子模型 orm.create,也不要在这之后 newRecord.save()。


日期:2026-09-22