背景需求
最近在开发一个Odoo项目时,客户提出了一个特定的搜索需求:希望在列表页面中展示多个多选下拉框作为过滤条件。用户选中任意下拉选项时,列表需要实时查询并显示对应的结果。
这种设计相较于Odoo原生搜索更为直观,特别是当用户需要同时基于多个维度筛选数据时,操作更加便捷。
Odoo原生搜索的局限性
Odoo作为一款国际化的开源ERP系统,其搜索功能设计理念与国内用户的使用习惯存在一定差异:
- 搜索模式单一:默认采用"搜索框+预设过滤器"的模式
- 多条件过滤不够直观:需要点击过滤器图标,在弹出窗口中配置多个条件
- 用户体验差异:国外用户习惯文本搜索+条件组合,国内用户更习惯可视化的多选过滤
解决方案:自定义控件开发
面对这种需求差异,我们决定采用Odoo的自定义开发能力。Odoo提供了灵活的扩展机制,特别是基于QWeb模板引擎,我们可以通过以下方式实现自定义搜索控件:
- 自定义多选下拉框组件
- 集成到搜索面板
- 重写列表视图控制器
- 动态构建搜索条件
完整方案实现
1. 多选下拉框组件 (XML模板)
首先需要在XML文件中定义自定义下拉框控件视图(multi_select_widget.xml):
/* by yours.tools - online tools website : yours.tools/zh/requestmethod.html */ <?xml version="1.0" encoding="UTF-8"?> <templates xml:space="preserve"> <t t-name="multi_select" owl="1"> <div class="multiselect-container" t-ref="multi_select_dropdown"> <div class="form-control" t-on-click="toggleDropdown"> <span t-if="state.selected.size === 0"> <t t-esc="props.placeholder || 'Select options'"/> </span> <div t-if="state.selected.size === 1" class="selected-options" > <span class="badge bg-primary me-1" t-esc="[...state.selected][0]"/> </div> <div t-if="state.selected.size > 1" class="selected-options" > <span class="badge bg-primary me-1">已选择<t t-esc="state.selected.size"></t>个<t t-esc="props.fieldName"/></span> </div> </div> <div t-if="state.isOpen" class="dropdown-menu show"> <t t-foreach="props.options" t-as="option" t-key="option"> <a href="#" class="dropdown-item" t-att-class="{'active': state.selected.has(option)}" t-on-click="(ev) => this.selectOption(option, ev)"> <t t-esc="option"/> </a> </t> </div> <style> .multiselect-container{ margin: 3px; width: 200px; } </style> </div> </t> </templates>2. 多选下拉框组件逻辑 (JavaScript)
业务逻辑我们用js来实现(multi_select_widget.js)
/* by yours.tools - online tools website : yours.tools/zh/requestmethod.html */ import { Component, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl"; export class MultiSelectField extends Component { static template = "multi_select"; static props = { options: Array, placeholder: { type: String, optional: true }, fieldName: String, onChange: Function, }; setup() { this.dropdownRef = useRef("multi_select_dropdown"); this.state = useState({ isOpen: false, selected: new Set(), }); this.clickOutsideHandler = null; this.keydownHandler = null; onMounted(() => { this.setupEventListeners(); }); onWillUnmount(() => { this.cleanupEventListeners(); }); } toggleDropdown() { this.state.isOpen = !this.state.isOpen; } selectOption = (option, ev) => { if (this.state.selected.has(option)) { this.state.selected.delete(option); } else { this.state.selected.add(option); } this.props.onChange(this.props.fieldName, [...this.state.selected]); } setupEventListeners() { this.clickOutsideHandler = (event) => { if (!this.dropdownRef || !this.dropdownRef.el) return; if (!this.dropdownRef.el.contains(event.target)) { this.state.isOpen = false; } } this.keydownHandler = (event) => { if (event.key === 'Escape' && this.state.isOpen) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); this.state.isOpen = false; } } document.addEventListener('mousedown', this.clickOutsideHandler, true); document.addEventListener('touchstart', this.clickOutsideHandler, true); document.addEventListener('keydown', this.keydownHandler, true); } cleanupEventListeners() { if (this.clickOutsideHandler) { document.removeEventListener('mousedown', this.clickOutsideHandler, true); document.removeEventListener('touchstart', this.clickOutsideHandler, true); } if (this.keydownHandler) { document.removeEventListener('keydown', this.keydownHandler, true); } this.clickOutsideHandler = null; this.keydownHandler = null; } }3.自定义搜索面板 (XML模板)
同样定义一个xml(search_widget.xml)
<?xml version="1.0" encoding="UTF-8"?> <templates xml:space="preserve"> <t t-name="custom_search_panel" owl="1"> <div class="custom-search-panel" t-att-data-loading="state.loading"> <!-- 加载状态 --> <t t-if="state.loading"> <div class="loading-state text-center p-3"> <i class="fa fa-spinner fa-spin me-2"></i> <span>正在加载数据...</span> </div> </t> <!-- 错误状态 --> <t t-if="state.error"> <div class="error-state alert alert-warning m-3"> <i class="fa fa-exclamation-triangle me-2"></i> <span t-esc="state.error"></span> </div> </t> <!-- 正常状态 --> <t t-if="!state.loading and !state.error"> <div class="search-filters-container"> <!-- 多选下拉框组件 --> <MultiSelectField fieldName="field_a" options="state.dropdownData.field_a" placeholder="'字段A筛选'" onChange="(field, values) => handleSelection(field, values)" /> <MultiSelectField fieldName="field_b" options="state.dropdownData.field_b" placeholder="'字段B筛选'" onChange="(field, values) => handleSelection(field, values)" /> <MultiSelectField fieldName="field_c" options="state.dropdownData.field_c" placeholder="'字段C筛选'" onChange="(field, values) => handleSelection(field, values)" /> </div> </t> <style> .custom-search-panel { padding: 16px; background: #f8f9fa; border-bottom: 1px solid #dee2e6; } .search-filters-container { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } .loading-state { color: #6c757d; } .error-state { max-width: 600px; margin: 0 auto; } </style> </div> </t> </templates>4.搜索面板业务逻辑 (JavaScript)
search_widget.js
import { Component, useState, onWillStart } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; import { MultiSelectField } from "./multi_select_widget"; export class CustomSearchPanel extends Component { static template = "custom_search_panel"; static components = { MultiSelectField }; setup() { // 获取服务 this.ormService = useService("orm"); // 初始化响应式状态 this.state = useState({ dropdownData: { field_a: [], field_b: [], field_c: [], }, selectedValues: { field_a: [], field_b: [], field_c: [], }, loading: false, error: null, }); // 组件挂载前加载数据 onWillStart(async () => { await this.loadDropdownData(); }); } // 加载下拉框数据 loadDropdownData = async () => { this.state.loading = true; this.state.error = null; try { // 调用后端方法获取下拉框数据 const dropdownData = await this.ormService.call( "your.model.name", // 替换为实际模型名 "get_filter_dropdown_data", // 后端方法名 [], {} ); this.state.dropdownData = dropdownData; } catch (error) { console.error("加载下拉框数据失败:", error); this.state.error = "加载筛选数据失败,请稍后重试"; } finally { this.state.loading = false; } } // 处理选择变化 handleSelection = async (fieldName, selectedValues) => { // 更新选中值 this.state.selectedValues[fieldName] = selectedValues; // 生成搜索条件 const domain = this.generateSearchDomain(); // 触发搜索更新 this.triggerSearchUpdate(domain); } // 生成搜索条件 generateSearchDomain() { const domain = []; Object.entries(this.state.selectedValues).forEach(([field, values]) => { if (values && values.length > 0) { // 使用 'in' 操作符支持多选 domain.push([field, 'in', values]); } }); return domain; } // 触发搜索更新 triggerSearchUpdate(domain) { // 更新搜索模型 this.env.searchModel.updateDomain(domain); // 发送自定义事件通知列表刷新 this.env.bus.trigger('custom_search:updated', { domain, timestamp: Date.now() }); } } // 注册组件 registry.category("view_components").add("custom_search_panel", CustomSearchPanel);5.自定义列表控制器 (JavaScript)
import { registry } from "@web/core/registry"; import { listView } from "@web/views/list/list_view"; import { ListController } from "@web/views/list/list_controller"; import { CustomSearchPanel } from "./search_widget"; import { useBus } from "@web/core/utils/hooks"; // 扩展原生列表控制器 export class CustomListController extends ListController { static components = { ...ListController.components, SearchPanel: CustomSearchPanel, // 替换搜索组件 }; static template = "web.ListView"; setup() { super.setup(); // 监听自定义搜索事件 useBus(this.env.bus, "custom_search:updated", (ev) => { this.handleCustomSearch(ev.detail.domain); }); } // 处理自定义搜索 async handleCustomSearch(domain) { try { // 显示加载状态 this.model.isLoading = true; this.render(); // 加载数据 await this.model.load({ domain }); // 更新分页信息 if (this.model.data) { this.model.pager.limit = this.model.data.length; } } catch (error) { console.error("搜索数据失败:", error); } finally { this.model.isLoading = false; this.render(); } } } // 注册自定义列表视图 registry.category("views").add("custom_multi_select_list", { ...listView, Controller: CustomListController, display: { controlPanel: { 'bottom-left': false, 'bottom-right': false, }, }, });6.后端数据接口 (Python)
# models/your_model.py from odoo import models, fields, api class YourModel(models.Model): _name = 'your.model.name' _description = '示例模型' # 定义字段 field_a = fields.Selection([ ('option1', '选项1'), ('option2', '选项2'), ('option3', '选项3'), ], string='字段A') field_b = fields.Char(string='字段B') field_c = fields.Many2one('related.model', string='字段C') # 获取下拉框数据的方法 @api.model def get_filter_dropdown_data(self): """返回所有下拉框的选项数据""" return { 'field_a': self._get_field_a_options(), 'field_b': self._get_field_b_options(), 'field_c': self._get_field_c_options(), } def _get_field_a_options(self): """获取字段A的选项""" return [ display_value for value, display_value in self._fields['field_a'].selection ] def _get_field_b_options(self): """获取字段B的去重值""" records = self.search_read( [('field_b', '!=', False)], ['field_b'], limit=100 ) return sorted(list(set([ record['field_b'] for record in records if record['field_b'] ]))) def _get_field_c_options(self): """获取字段C的关联选项""" related_records = self.env['related.model'].search_read( [], ['name'], limit=50 ) return [record['name'] for record in related_records]7. 视图配置 (XML)
<?xml version="1.0" encoding="UTF-8"?> <odoo> <!-- 自定义列表视图 --> <record id="view_custom_list" model="ir.ui.view"> <field name="name">your.model.custom.list</field> <field name="model">your.model.name</field> <field name="arch" type="xml"> <list js_class="custom_multi_select_list"> <field name="name" string="名称"/> <field name="field_a" string="字段A"/> <field name="field_b" string="字段B"/> <field name="field_c" string="字段C"/> <!-- 其他字段 --> </list> </field> </record> </odoo>