找回密码
 立即注册
首页 业界区 业界 在Odoo18中实现多选下拉框搜索功能

在Odoo18中实现多选下拉框搜索功能

哈妙思 2 小时前
背景需求

最近在开发一个Odoo项目时,客户提出了一个特定的搜索需求:希望在列表页面中展示多个多选下拉框作为过滤条件。用户选中任意下拉选项时,列表需要实时查询并显示对应的结果。
这种设计相较于Odoo原生搜索更为直观,特别是当用户需要同时基于多个维度筛选数据时,操作更加便捷。
1.png

Odoo原生搜索的局限性

Odoo作为一款国际化的开源ERP系统,其搜索功能设计理念与国内用户的使用习惯存在一定差异:

  • 搜索模式单一:默认采用"搜索框+预设过滤器"的模式
  • 多条件过滤不够直观:需要点击过滤器图标,在弹出窗口中配置多个条件
  • 用户体验差异:国外用户习惯文本搜索+条件组合,国内用户更习惯可视化的多选过滤
2.png

解决方案:自定义控件开发

面对这种需求差异,我们决定采用Odoo的自定义开发能力。Odoo提供了灵活的扩展机制,特别是基于QWeb模板引擎,我们可以通过以下方式实现自定义搜索控件:

  • 自定义多选下拉框组件
  • 集成到搜索面板
  • 重写列表视图控制器
  • 动态构建搜索条件
完整方案实现

1. 多选下拉框组件 (XML模板)

首先需要在XML文件中定义自定义下拉框控件视图(multi_select_widget.xml):
  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <templates xml:space="preserve">
  3.     <t t-name="multi_select" owl="1">
  4.         
  5.             
  6.                
  7.                     <t t-esc="props.placeholder || 'Select options'"/>
  8.                
  9.                
  10.                     
  11.                
  12.                  1"  >
  13.                     已选择<t t-esc="state.selected.size"></t>个<t t-esc="props.fieldName"/>
  14.                
  15.             
  16.             
  17.             
  18.                 <t t-foreach="props.options" t-as="option" t-key="option">
  19.                      this.selectOption(option, ev)">
  20.                         <t t-esc="option"/>
  21.                     
  22.                 </t>
  23.             
  24.             
  25.         
  26.     </t>
  27. </templates>
复制代码
2. 多选下拉框组件逻辑 (JavaScript)

业务逻辑我们用js来实现(multi_select_widget.js)
  1. import { Component, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl";
  2. export class MultiSelectField extends Component {
  3.     static template = "multi_select";
  4.     static props = {
  5.         options: Array,
  6.         placeholder: { type: String, optional: true },
  7.         fieldName: String,
  8.         onChange: Function,
  9.     };
  10.     setup() {
  11.         this.dropdownRef = useRef("multi_select_dropdown");
  12.         this.state = useState({
  13.             isOpen: false,
  14.             selected: new Set(),
  15.         });
  16.         this.clickOutsideHandler = null;
  17.         this.keydownHandler = null;
  18.         onMounted(() => {
  19.             this.setupEventListeners();
  20.         });
  21.         onWillUnmount(() => {
  22.             this.cleanupEventListeners();
  23.         });
  24.     }
  25.     toggleDropdown() {
  26.         this.state.isOpen = !this.state.isOpen;
  27.     }
  28.     selectOption = (option, ev) => {
  29.         if (this.state.selected.has(option)) {
  30.             this.state.selected.delete(option);
  31.         } else {
  32.             this.state.selected.add(option);
  33.         }
  34.         this.props.onChange(this.props.fieldName, [...this.state.selected]);
  35.     }
  36.     setupEventListeners() {
  37.         this.clickOutsideHandler = (event) => {
  38.             if (!this.dropdownRef || !this.dropdownRef.el) return;
  39.             if (!this.dropdownRef.el.contains(event.target)) {
  40.                 this.state.isOpen = false;
  41.             }
  42.         }
  43.         this.keydownHandler = (event) => {
  44.             if (event.key === 'Escape' && this.state.isOpen) {
  45.                 event.preventDefault();
  46.                 event.stopPropagation();
  47.                 event.stopImmediatePropagation();
  48.                 this.state.isOpen = false;
  49.             }
  50.         }
  51.         document.addEventListener('mousedown', this.clickOutsideHandler, true);
  52.         document.addEventListener('touchstart', this.clickOutsideHandler, true);
  53.         document.addEventListener('keydown', this.keydownHandler, true);
  54.     }
  55.     cleanupEventListeners() {
  56.         if (this.clickOutsideHandler) {
  57.             document.removeEventListener('mousedown', this.clickOutsideHandler, true);
  58.             document.removeEventListener('touchstart', this.clickOutsideHandler, true);
  59.         }
  60.         if (this.keydownHandler) {
  61.             document.removeEventListener('keydown', this.keydownHandler, true);
  62.         }
  63.         this.clickOutsideHandler = null;
  64.         this.keydownHandler = null;
  65.     }
  66. }
复制代码
3.自定义搜索面板 (XML模板)

同样定义一个xml(search_widget.xml)
  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <templates xml:space="preserve">
  3.     <t t-name="custom_search_panel" owl="1">
  4.         
  5.             
  6.             <t t-if="state.loading">
  7.                
  8.                     <i ></i>
  9.                     正在加载数据...
  10.                
  11.             </t>
  12.             
  13.             <t t-if="state.error">
  14.                
  15.                     <i ></i>
  16.                     
  17.                
  18.             </t>
  19.             
  20.             <t t-if="!state.loading and !state.error">
  21.                
  22.                     
  23.                     <MultiSelectField
  24.                         fieldName="field_a"
  25.                         options="state.dropdownData.field_a"
  26.                         placeholder="'字段A筛选'"
  27.                         onChange="(field, values) => handleSelection(field, values)"
  28.                     />
  29.                     
  30.                     <MultiSelectField
  31.                         fieldName="field_b"
  32.                         options="state.dropdownData.field_b"
  33.                         placeholder="'字段B筛选'"
  34.                         onChange="(field, values) => handleSelection(field, values)"
  35.                     />
  36.                     
  37.                     <MultiSelectField
  38.                         fieldName="field_c"
  39.                         options="state.dropdownData.field_c"
  40.                         placeholder="'字段C筛选'"
  41.                         onChange="(field, values) => handleSelection(field, values)"
  42.                     />
  43.                
  44.             </t>
  45.             
  46.             
  47.         
  48.     </t>
  49. </templates>
复制代码
4.搜索面板业务逻辑 (JavaScript)

search_widget.js
  1. import { Component, useState, onWillStart } from "@odoo/owl";
  2. import { registry } from "@web/core/registry";
  3. import { useService } from "@web/core/utils/hooks";
  4. import { MultiSelectField } from "./multi_select_widget";
  5. export class CustomSearchPanel extends Component {
  6.     static template = "custom_search_panel";
  7.     static components = { MultiSelectField };
  8.     setup() {
  9.         // 获取服务
  10.         this.ormService = useService("orm");
  11.         
  12.         // 初始化响应式状态
  13.         this.state = useState({
  14.             dropdownData: {
  15.                 field_a: [],
  16.                 field_b: [],
  17.                 field_c: [],
  18.             },
  19.             selectedValues: {
  20.                 field_a: [],
  21.                 field_b: [],
  22.                 field_c: [],
  23.             },
  24.             loading: false,
  25.             error: null,
  26.         });
  27.         // 组件挂载前加载数据
  28.         onWillStart(async () => {
  29.             await this.loadDropdownData();
  30.         });
  31.     }
  32.     // 加载下拉框数据
  33.     loadDropdownData = async () => {
  34.         this.state.loading = true;
  35.         this.state.error = null;
  36.         
  37.         try {
  38.             // 调用后端方法获取下拉框数据
  39.             const dropdownData = await this.ormService.call(
  40.                 "your.model.name",  // 替换为实际模型名
  41.                 "get_filter_dropdown_data",  // 后端方法名
  42.                 [],
  43.                 {}
  44.             );
  45.             
  46.             this.state.dropdownData = dropdownData;
  47.         } catch (error) {
  48.             console.error("加载下拉框数据失败:", error);
  49.             this.state.error = "加载筛选数据失败,请稍后重试";
  50.         } finally {
  51.             this.state.loading = false;
  52.         }
  53.     }
  54.     // 处理选择变化
  55.     handleSelection = async (fieldName, selectedValues) => {
  56.         // 更新选中值
  57.         this.state.selectedValues[fieldName] = selectedValues;
  58.         
  59.         // 生成搜索条件
  60.         const domain = this.generateSearchDomain();
  61.         
  62.         // 触发搜索更新
  63.         this.triggerSearchUpdate(domain);
  64.     }
  65.     // 生成搜索条件
  66.     generateSearchDomain() {
  67.         const domain = [];
  68.         
  69.         Object.entries(this.state.selectedValues).forEach(([field, values]) => {
  70.             if (values && values.length > 0) {
  71.                 // 使用 'in' 操作符支持多选
  72.                 domain.push([field, 'in', values]);
  73.             }
  74.         });
  75.         
  76.         return domain;
  77.     }
  78.     // 触发搜索更新
  79.     triggerSearchUpdate(domain) {
  80.         // 更新搜索模型
  81.         this.env.searchModel.updateDomain(domain);
  82.         
  83.         // 发送自定义事件通知列表刷新
  84.         this.env.bus.trigger('custom_search:updated', {
  85.             domain,
  86.             timestamp: Date.now()
  87.         });
  88.     }
  89. }
  90. // 注册组件
  91. registry.category("view_components").add("custom_search_panel", CustomSearchPanel);
复制代码
5.自定义列表控制器 (JavaScript)
  1. import { registry } from "@web/core/registry";
  2. import { listView } from "@web/views/list/list_view";
  3. import { ListController } from "@web/views/list/list_controller";
  4. import { CustomSearchPanel } from "./search_widget";
  5. import { useBus } from "@web/core/utils/hooks";
  6. // 扩展原生列表控制器
  7. export class CustomListController extends ListController {
  8.     static components = {
  9.         ...ListController.components,
  10.         SearchPanel: CustomSearchPanel,  // 替换搜索组件
  11.     };
  12.    
  13.     static template = "web.ListView";
  14.     setup() {
  15.         super.setup();
  16.         
  17.         // 监听自定义搜索事件
  18.         useBus(this.env.bus, "custom_search:updated", (ev) => {
  19.             this.handleCustomSearch(ev.detail.domain);
  20.         });
  21.     }
  22.     // 处理自定义搜索
  23.     async handleCustomSearch(domain) {
  24.         try {
  25.             // 显示加载状态
  26.             this.model.isLoading = true;
  27.             this.render();
  28.             
  29.             // 加载数据
  30.             await this.model.load({ domain });
  31.             
  32.             // 更新分页信息
  33.             if (this.model.data) {
  34.                 this.model.pager.limit = this.model.data.length;
  35.             }
  36.         } catch (error) {
  37.             console.error("搜索数据失败:", error);
  38.         } finally {
  39.             this.model.isLoading = false;
  40.             this.render();
  41.         }
  42.     }
  43. }
  44. // 注册自定义列表视图
  45. registry.category("views").add("custom_multi_select_list", {
  46.     ...listView,
  47.     Controller: CustomListController,
  48.     display: {
  49.         controlPanel: {
  50.         'bottom-left': false,
  51.         'bottom-right': false,
  52.         },
  53.     },
  54. });
复制代码
6.后端数据接口 (Python)
  1. # models/your_model.py
  2. from odoo import models, fields, api
  3. class YourModel(models.Model):
  4.     _name = 'your.model.name'
  5.     _description = '示例模型'
  6.    
  7.     # 定义字段
  8.     field_a = fields.Selection([
  9.         ('option1', '选项1'),
  10.         ('option2', '选项2'),
  11.         ('option3', '选项3'),
  12.     ], string='字段A')
  13.    
  14.     field_b = fields.Char(string='字段B')
  15.     field_c = fields.Many2one('related.model', string='字段C')
  16.    
  17.     # 获取下拉框数据的方法
  18.     @api.model
  19.     def get_filter_dropdown_data(self):
  20.         """返回所有下拉框的选项数据"""
  21.         return {
  22.             'field_a': self._get_field_a_options(),
  23.             'field_b': self._get_field_b_options(),
  24.             'field_c': self._get_field_c_options(),
  25.         }
  26.    
  27.     def _get_field_a_options(self):
  28.         """获取字段A的选项"""
  29.         return [
  30.             display_value
  31.             for value, display_value in self._fields['field_a'].selection
  32.         ]
  33.    
  34.     def _get_field_b_options(self):
  35.         """获取字段B的去重值"""
  36.         records = self.search_read(
  37.             [('field_b', '!=', False)],
  38.             ['field_b'],
  39.             limit=100
  40.         )
  41.         return sorted(list(set([
  42.             record['field_b']
  43.             for record in records
  44.             if record['field_b']
  45.         ])))
  46.    
  47.     def _get_field_c_options(self):
  48.         """获取字段C的关联选项"""
  49.         related_records = self.env['related.model'].search_read(
  50.             [],
  51.             ['name'],
  52.             limit=50
  53.         )
  54.         return [record['name'] for record in related_records]
复制代码
7. 视图配置 (XML)
  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <odoo>
  3.   
  4.    
  5.     <record id="view_custom_list" model="ir.ui.view">
  6.         <field name="name">your.model.custom.list</field>
  7.         <field name="model">your.model.name</field>
  8.         <field name="arch" type="xml">
  9.             <list js_>
  10.                 <field name="name" string="名称"/>
  11.                 <field name="field_a" string="字段A"/>
  12.                 <field name="field_b" string="字段B"/>
  13.                 <field name="field_c" string="字段C"/>
  14.                
  15.             </list>
  16.         </field>
  17.     </record>
  18. </odoo>
复制代码
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册