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