certflow.widgets.filter_row 源代码

"""单条筛选行控件(通用可复用 UI 构件,归 widgets 包)。

从 ``query_view._add_filter_row`` 系列方法抽取为独立控件:字段下拉 + 操作符下拉 +
值输入栈(单值 / 区间 / 日期 / 枚举)+ 删除按钮。产出标准化条件
``{field: {value, operator}}``。

设计要点(对齐 docs/BLUEPRINT.md §五 wf5):

- **数据源无关**:exact 字段的下拉选项加载、日期选择器、主题 / 样式均通过
  构造回调注入,组件本身不依赖任何具体视图或 Controller。
- **统一条件契约**:``get_condition()`` 返回 ``{field: {value, operator}}``,
  与 ``query_view.get_filters`` 既有格式一致,便于等价替换。
- **row_data() 桥接**:返回与原 ``query_view`` 行字典同键的字典,使既有快筛
  / 保存加载逻辑可最小改动地迁移到本组件(见 §1.7 第 4 步)。
"""

from __future__ import annotations

from datetime import datetime
from re import match as re_match

from PySide6.QtCore import QTimer, Signal
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
    QCalendarWidget,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QHBoxLayout,
    QLineEdit,
    QPushButton,
    QSizePolicy,
    QStackedWidget,
    QVBoxLayout,
    QWidget,
)


[文档] class FilterRowWidget(QWidget): """单条筛选条件行。 信号: value_changed:值输入框变化(应触发防抖搜索)。 structure_changed:字段 / 操作符变化(应触发快筛脱离,不触发搜索)。 remove_requested:请求删除本行。 """ value_changed = Signal() structure_changed = Signal() remove_requested = Signal() # 无值操作符(不读取值) _NO_VALUE_OPS = ("is_null", "is_not_null") def __init__( self, parent: QWidget | None = None, *, columns: list[dict], field_op_map: dict, operators: dict, field_choices_map: dict | None = None, is_dark_theme_fn=None, load_combo_values_fn=None, open_date_picker_fn=None, date_button_style_fn=None, field_name: str | None = None, operator: str | None = None, value=None, ) -> None: super().__init__(parent) self._columns = list(columns) self._field_op_map = field_op_map self._field_choices_map = dict(field_choices_map or {}) self._operators = operators self._is_dark_theme_fn = is_dark_theme_fn or (lambda: False) self._load_combo_values_fn = load_combo_values_fn self._open_date_picker_fn = open_date_picker_fn self._date_button_style_fn = date_button_style_fn or (lambda _d: "") self._completer_task: dict | None = None self._build_ui(field_name, operator, value) # 桥接 row_data 持久字典:迁移期 query_view 的快筛/保存逻辑会直接读写其中的 # _is_manual / _quick_filter_button 等标记,需持久化以便跨调用保留状态。 self._row_data = { "widget": self, "field_combo": self.field_combo, "op_combo": self.op_combo, "value_stack": self.value_stack, "value_edit": self.value_edit, "value_edit2": self.value_edit_max, "value_edit_min": self.value_edit_min, "value_edit_max": self.value_edit_max, "_is_manual": True, "_quick_filter_button": None, "_completer_task": self._completer_task, } # ============================================================ # UI 构建 # ============================================================ def _build_ui(self, field_name, operator, value) -> None: layout = QHBoxLayout(self) layout.setContentsMargins(0, 2, 0, 2) # 字段选择 self.field_combo = QComboBox() self.field_combo.setMinimumWidth(100) for col in self._columns: self.field_combo.addItem(col["label"], col["field"]) if field_name: idx = self.field_combo.findData(field_name) if idx >= 0: self.field_combo.setCurrentIndex(idx) else: # 字段不在当前视图 columns 中(如筛选组对话框传入的字段),追加并选中 self.field_combo.addItem(field_name, field_name) self.field_combo.setCurrentIndex(self.field_combo.count() - 1) self.field_combo.currentIndexChanged.connect(self._on_field_changed) layout.addWidget(self.field_combo) # 操作符选择 self.op_combo = QComboBox() self.op_combo.setMinimumWidth(80) layout.addWidget(self.op_combo) # 值输入栈(单值 / 双值) self.value_stack = QStackedWidget() self.value_stack.setMinimumWidth(200) is_date_field = bool(field_name and field_name.endswith("_date")) single_widget, self.value_edit = self._make_single_value_page(field_name, is_date_field) self.value_stack.addWidget(single_widget) self.value_edit_min, self.value_edit_max = self._create_range_inputs(is_date_field) layout.addWidget(self.value_stack) # 删除按钮 remove_btn = QPushButton("✕") remove_btn.setFixedWidth(30) remove_btn.setFixedHeight(24) remove_btn.setStyleSheet( """ QPushButton { background-color: transparent; border: none; color: #dc3545; font-size: 14px; font-weight: bold; padding: 0px; margin: 0px; } QPushButton:hover { background-color: #f8d7da; border-radius: 4px; } QPushButton:pressed { background-color: #f5c6cb; } """ ) remove_btn.setToolTip("删除此筛选条件") remove_btn.clicked.connect(self.remove_requested.emit) layout.addWidget(remove_btn) self._update_operators() # _set_initial_values 必须在 _update_operators 之后(后者会清空输入框) self._set_initial_values(operator, value) # exact 字段选项异步加载,延迟重试确保初始值被设置 if ( isinstance(self.value_edit, QComboBox) and value is not None and not isinstance(value, list) ): QTimer.singleShot( 150, lambda ve=self.value_edit, v=value: self._ensure_combo_value(ve, v) ) self.op_combo.currentIndexChanged.connect( lambda: self._update_value_stack(self.op_combo.currentData()) ) def _make_single_value_page(self, field_name, is_date_field): """创建单值输入页面(index 0),返回 ``(容器, 值编辑器)``。 优先按 ``field_choices_map`` 渲染静态枚举下拉(基础数据表 choice/bool 列); 其次按 ``field_op_map`` 的 exact 类型渲染可编辑精确下拉(异步加载选项); 否则渲染普通文本输入框。 """ op_type = self._field_op_map.get(field_name, "text") if field_name else "text" single_widget = QWidget() single_layout = QHBoxLayout(single_widget) single_layout.setContentsMargins(0, 0, 0, 0) static_choices = self._field_choices_map.get(field_name) if field_name else None if static_choices is not None: # 静态枚举下拉:首项为空串代表「不限制」,选中具体值即按该值筛选 value_edit = QComboBox() value_edit.setMinimumWidth(120) value_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) value_edit.addItem("") for ch in static_choices: if ch == "": # 空值用「为空」操作符表达,下拉里跳过 continue value_edit.addItem(str(ch)) value_edit.currentTextChanged.connect(lambda _t: self.value_changed.emit()) single_layout.addWidget(value_edit) elif op_type == "exact": value_edit = QComboBox() value_edit.setEditable(True) value_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) value_edit.setMinimumWidth(120) value_edit.lineEdit().setPlaceholderText("请选择...") value_edit.addItem("加载中...") value_edit.currentTextChanged.connect(lambda _t: self.value_changed.emit()) single_layout.addWidget(value_edit) if self._load_combo_values_fn is not None and field_name: QTimer.singleShot( 100, lambda cb=value_edit, fn=field_name: self._load_combo_values_fn(cb, fn) ) else: value_edit = QLineEdit() value_edit.setPlaceholderText("请输入值...") value_edit.setMinimumWidth(150) value_edit.textChanged.connect(lambda _t: self.value_changed.emit()) single_layout.addWidget(value_edit) if is_date_field and op_type != "exact" and static_choices is None: date_btn = QPushButton("📆") date_btn.setFixedWidth(30) date_btn.setFont(QFont("Segoe UI Emoji", 12)) date_btn.setStyleSheet(self._get_date_button_style(self._is_dark_theme())) date_btn.clicked.connect(lambda _checked, e=value_edit: self._open_date_picker(e)) single_layout.addWidget(date_btn) return single_widget, value_edit def _create_range_inputs(self, is_date_field): """创建双值区间输入页面(index 1),返回 (min, max) 编辑器。""" double_widget = QWidget() double_layout = QHBoxLayout(double_widget) double_layout.setContentsMargins(0, 0, 0, 0) min_ph = "YYYY-MM-DD" if is_date_field else "最小值..." max_ph = "YYYY-MM-DD" if is_date_field else "最大值..." value_edit_min = QLineEdit() value_edit_min.setPlaceholderText(min_ph) value_edit_min.setMinimumWidth(150) value_edit_min.textChanged.connect(lambda _t: self.value_changed.emit()) value_edit_max = QLineEdit() value_edit_max.setPlaceholderText(max_ph) value_edit_max.setMinimumWidth(150) value_edit_max.textChanged.connect(lambda _t: self.value_changed.emit()) double_layout.addWidget(value_edit_min) if is_date_field: style = self._get_date_button_style(self._is_dark_theme()) for target in (value_edit_min, value_edit_max): date_btn = QPushButton("📆") date_btn.setFixedWidth(30) date_btn.setFont(QFont("Segoe UI Emoji", 12)) date_btn.setStyleSheet(style) date_btn.clicked.connect(lambda _checked, e=target: self._open_date_picker(e)) double_layout.addWidget(date_btn) double_layout.addWidget(value_edit_max) self.value_stack.addWidget(double_widget) return value_edit_min, value_edit_max # ============================================================ # 操作符 / 值栈切换 # ============================================================ def _update_operators(self) -> None: """根据当前字段类型刷新操作符下拉。""" field_name = self.field_combo.currentData() op_type = self._field_op_map.get(field_name, "text") operators = self._operators.get(op_type, self._operators.get("text", {})) self.op_combo.blockSignals(True) self.op_combo.clear() for op_key, op_label in operators.items(): self.op_combo.addItem(op_label, op_key) # 静态枚举列默认用「等于」,更贴合下拉取值语义 if field_name in self._field_choices_map and "eq" in operators: self.op_combo.setCurrentIndex(list(operators.keys()).index("eq")) self.op_combo.blockSignals(False) self._update_value_stack(self.op_combo.currentData()) def _update_value_stack(self, operator) -> None: """根据操作符切换值输入页面(无值 / 单值 / 区间)。""" no_value = operator in self._NO_VALUE_OPS is_range = operator == "between" if no_value: self.value_stack.setVisible(False) else: self.value_stack.setVisible(True) self.value_stack.setCurrentIndex(1 if is_range else 0) # 切换时清空,避免残留旧数据 if is_range: self.value_edit_min.clear() self.value_edit_max.clear() else: ve = self.value_edit if isinstance(ve, QComboBox): ve.setCurrentIndex(0) else: ve.clear() def _on_field_changed(self) -> None: """字段变化:重建值输入控件(文本/枚举/精确),并刷新操作符 / 值栈。""" field_name = self.field_combo.currentData() op_type = self._field_op_map.get(field_name, "text") # 先按新字段重建单值输入控件(枚举列需从文本切换为下拉) self._rebuild_value_widget(field_name) if op_type == "exact" and isinstance(self.value_edit, QComboBox): # 取消上一异步加载任务 if self._completer_task is not None: self._completer_task["cancel"] = True self.value_edit.blockSignals(True) self.value_edit.clear() self.value_edit.addItem("加载中...") self.value_edit.blockSignals(False) if self._load_combo_values_fn is not None: task_marker = {"cancel": False} self._completer_task = task_marker QTimer.singleShot( 100, lambda t=task_marker, cb=self.value_edit, fn=field_name: ( self._load_combo_values_fn(cb, fn) if not t["cancel"] else None ), ) self._update_operators() self._update_value_stack(self.op_combo.currentData()) self.structure_changed.emit() def _rebuild_value_widget(self, field_name: str) -> None: """字段切换时按类型重建单值输入控件(文本 / 静态枚举 / 精确下拉)。""" old = self.value_stack.widget(0) if old is not None: self.value_stack.removeWidget(old) old.deleteLater() is_date = bool(field_name and field_name.endswith("_date")) single_widget, value_edit = self._make_single_value_page(field_name, is_date) self.value_stack.insertWidget(0, single_widget) self.value_edit = value_edit # ============================================================ # 日期 / 样式回调 # ============================================================ def _open_date_picker(self, target_edit: QLineEdit) -> None: """打开日期选择对话框,写入目标输入框。""" if self._open_date_picker_fn is not None: self._open_date_picker_fn(target_edit) return dlg = QDialog(self) dlg.setWindowTitle("选择日期") dlg.setMinimumSize(300, 250) layout = QVBoxLayout(dlg) calendar = QCalendarWidget() calendar.setGridVisible(True) current_text = target_edit.text().strip() if current_text: try: current_date = datetime.strptime(current_text, "%Y-%m-%d").date() calendar.setSelectedDate(current_date) except ValueError: pass layout.addWidget(calendar) btn_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) btn_box.accepted.connect(dlg.accept) btn_box.rejected.connect(dlg.reject) layout.addWidget(btn_box) if dlg.exec() == QDialog.DialogCode.Accepted: target_edit.setText(calendar.selectedDate().toString("yyyy-MM-dd")) def _get_date_button_style(self, is_dark: bool) -> str: return self._date_button_style_fn(is_dark) def _is_dark_theme(self) -> bool: return self._is_dark_theme_fn()
[文档] def set_columns(self, columns: list[dict]) -> None: """更新可用字段列表(视图切换时由 FilterBar 调用),重建字段下拉。""" self._columns = list(columns) self.field_combo.blockSignals(True) self.field_combo.clear() for col in self._columns: self.field_combo.addItem(col["label"], col["field"]) self.field_combo.blockSignals(False)
# ============================================================ # 初始值 / 异步值回填 # ============================================================ def _set_initial_values(self, operator, value) -> None: """设置初始操作符与值。""" if operator: op_idx = self.op_combo.findData(operator) if op_idx >= 0: self.op_combo.setCurrentIndex(op_idx) if value is None: return ve = self.value_edit if isinstance(value, list) and len(value) == 2: self.value_edit_min.setText(str(value[0])) self.value_edit_max.setText(str(value[1])) elif isinstance(ve, QComboBox): idx = ve.findText(str(value)) if idx >= 0: ve.setCurrentIndex(idx) else: ve.setCurrentText(str(value)) else: ve.setText(str(value)) def _ensure_combo_value(self, combo: QComboBox, value: str) -> None: """exact 字段异步加载选项后,确保初始值被正确设置。 注意:本方法由 ``QTimer.singleShot`` 延迟调用(见 __init__), 若 widget 在 150ms 内已析构,combo 的 C++ 对象已删除, 访问会抛 ``RuntimeError: ... already deleted``。此处兜底捕获, 避免延迟回调在事件循环中崩溃(表现为 pytest-qt CALL ERROR)。 """ try: current = combo.currentText() except RuntimeError: return # C++ 对象已销毁(延迟回调晚于 widget 析构),跳过 if current and current != value: return # 用户已修改,不覆盖 try: idx = combo.findText(str(value)) except RuntimeError: return if idx >= 0: combo.blockSignals(True) combo.setCurrentIndex(idx) combo.blockSignals(False) # ============================================================ # 条件读取 / 校验 # ============================================================
[文档] def get_condition(self) -> dict | None: """返回标准化条件 ``{field: {value, operator}}``,无效则返回 None。 注意:区间(between)操作符的值存于 ``value_edit_min/max``,单值框为空, 因此 ``not value`` 的跳过判定仅适用于非区间、非无值操作符。 """ field_name = self.field_combo.currentData() operator = self.op_combo.currentData() or "contains" value = ( self.value_edit.currentText().strip() if isinstance(self.value_edit, QComboBox) else self.value_edit.text().strip() ) if not field_name: return None if operator in self._NO_VALUE_OPS: return {field_name: {"value": None, "operator": operator}} if operator == "between": cond = self._build_between_condition(value) return {field_name: cond} if cond else None if not value: return None return {field_name: {"value": value, "operator": operator}}
[文档] def to_condition_dict(self) -> dict | None: """返回列表形式 ``{field, operator, value}``(供保存 / 加载使用)。""" field_name = self.field_combo.currentData() operator = self.op_combo.currentData() or "contains" value = ( self.value_edit.currentText().strip() if isinstance(self.value_edit, QComboBox) else self.value_edit.text().strip() ) if not field_name: return None condition: dict = {"field": field_name, "operator": operator} if operator in self._NO_VALUE_OPS: condition["value"] = None elif operator == "between": between_val = self._build_between_value(value) if between_val is None: return None condition["value"] = between_val else: if not value: return None condition["value"] = value return condition
def _build_between_condition(self, value: str) -> dict | None: """从值栈读取区间条件,校验日期后返回。""" between_val = self._build_between_value(value) return {"value": between_val, "operator": "between"} if between_val else None def _build_between_value(self, value: str) -> list | None: """从值栈读取区间值 ``[vmin, vmax]``,校验日期后返回;无效返回 None。""" vmin = self.value_edit_min.text().strip() vmax = self.value_edit_max.text().strip() or self.value_edit.text().strip() field_name = self.field_combo.currentData() if vmin and vmax: if not self._validate_between_dates(field_name, vmin, vmax): return None return [vmin, vmax] if value and vmax: if not self._validate_between_dates(field_name, value, vmax): return None return [value, vmax] if value and "," in value: parts = [p.strip() for p in value.split(",")] if len(parts) == 2 and parts[0] and parts[1]: return parts return None def _validate_between_dates(self, field_name: str, vmin: str, vmax: str) -> bool: return not ( field_name and field_name.endswith("_date") and (not self._is_valid_date(vmin) or not self._is_valid_date(vmax)) ) @staticmethod def _is_valid_date(date_str: str) -> bool: if not re_match(r"^\d{4}-\d{2}-\d{2}$", date_str): return False try: datetime.strptime(date_str, "%Y-%m-%d") return True except ValueError: return False # ============================================================ # 兼容桥接(迁移期使用) # ============================================================
[文档] def row_data(self) -> dict: """返回与原 ``query_view`` 行字典同键的**持久**字典,便于迁移期最小改动接入。 返回的字典在控件生命周期内保持稳定引用,迁移期 query_view 的快筛/保存逻辑 可直接对其中的 ``_is_manual`` / ``_quick_filter_button`` 等标记做增删改并持久化。 """ return self._row_data