certflow.widgets.quick_search_preset 源代码

"""FilterBar 的「快查框 + preset 下拉」宿主侧公共逻辑(可复用控件)。

``query_view`` 与 ``report_view`` 都需要「切换 preset → 刷新快查候选 → 选中/输入按
字段逐段回填筛选行」这一套逻辑,原先各自内联实现,容易在「空字段段数对齐」上踩坑
(如 project_name 为空时,后续 plan_no 被错位填入 project_name 位,导致查不到结果)。

这里抽成 :class:`QuickSearchPresetMixin`,约定宿主提供:

- ``self.filter_bar``:已开启 ``show_quick_search`` / ``show_preset_selector`` 的 FilterBar
- ``self._qs_service``:含 ``get_distinct_field_combos(fields)`` 的查询服务(候选默认来源)
- ``on_quick_search()``:取 ``self.filter_bar.get_conditions()`` 并触发查询的宿主方法

可选重写的取数钩子(数据源无关):

- ``qsp_get_quick_search_items(fields)``:快查候选数据源,默认走 ``_qs_service``;
  宿主若用其它取数(如本视图默认范围、``controller.get_distinct_combos``)应重写。
- ``qsp_get_field_label(field)``:字段中文标签(占位提示),默认走 ``_qs_columns``。

修复点(对齐 BLUEPRINT §1.7):
- 严格按 preset 字段数解析,空段保留不丢弃,避免中间空字段被吞导致后续字段错位填入;
- **动态搜索模式切换(状态管理)**:``QuickSearch`` 区分「用户输入(typing / 模糊)」与
  「选中条目(activated / 精确)」两类交互——

  * 模糊模式:用户在输入框键入时,下拉由 completer 做模糊匹配,宿主再做【防抖】的初步数据
    筛选(在主字段上做 ``contains`` 匹配),给出较宽的结果集;
  * 精确模式:用户点击/回车选中某个具体候选条目时,自动切换为精确模式,按字段逐段回填
    (保留空段),对全部字段做精确匹配,确保最终结果准确。

  当前模式存于 ``self._qs_mode``(``QUICK_SEARCH_MODE_FUZZY`` / ``QUICK_SEARCH_MODE_EXACT``),
  可通过 ``get_quick_search_mode()`` 读取;每次切换会回调 ``qsp_on_mode_changed(mode)``
  (宿主可重写以反映 UI 状态,如状态标签),默认空实现。
"""

from __future__ import annotations

from loguru import logger
from PySide6.QtCore import QTimer

# 全角竖线(中文输入法常见),归一化后统一按半角处理
_FULLWIDTH_PIPE = "|"

# 快查动态搜索模式
QUICK_SEARCH_MODE_FUZZY = "fuzzy"  # 用户输入中:下拉模糊匹配 + 防抖初步筛选
QUICK_SEARCH_MODE_EXACT = "exact"  # 选中具体条目:按字段逐段精确回填查询

# 模糊模式防抖间隔(ms):停止输入后才跑初步筛选,避免逐字符打库
_FUZZY_DEBOUNCE_MS = 500


def _count_quick_search_seps(text: str) -> int:
    """统计快查文本中的分隔符个数(``|`` / ``,`` / 全角 ``,`` 统一归一为 ``|`` 计数)。"""
    t = text or ""
    t = t.replace(_FULLWIDTH_PIPE, "|").replace(" | ", "|")
    t = t.replace(",", "|").replace(",", "|")
    return t.count("|")


[文档] def build_quick_search_plan(text: str, fields: list[dict]) -> dict: """根据快查输入文本与 preset 字段,返回查询方案(纯函数,便于单测)。 用于「模糊输入」场景(用户输入、未选中具体候选条目): - 若 ``text`` 含分隔符(半角 ``|`` / 半角 ``,`` / 全角 ``,``):按字段数严格切分为 多段,返回 AND 回填方案(``mode="and"``)。空段保留,段数与字段数一致, 避免中间空字段被吞导致后续字段错位。 - ``complete`` 标记:分隔符个数是否已达 ``字段数 - 1``。不足说明用户仍在逐段 填写中,宿主应**暂缓**发起 AND 查询(等用户填完或回车),避免半截输入 触发错误/空结果(即「防抖真正生效」)。 - 否则:返回 OR 方案(``mode="or"``, ``complete=True``),在 preset **全部**字段上 做 ``contains`` 匹配,任一字段命中即返回(避免「只匹配首字段 → 0 条」)。 Args: text: 用户输入的快查文本 fields: preset 字段列表(每个含 ``field`` / ``operator`` 等键) Returns: dict: ``{"mode": "and"|"or", "complete": bool, "rows": [(field, operator, value), ...], "or_filters": [{field, operator, value}, ...]}`` """ text = (text or "").strip() has_sep = ("|" in text) or ("," in text) or ("," in text) if has_sep: n_fields = len(fields) complete = _count_quick_search_seps(text) >= max(n_fields - 1, 0) segments = split_quick_search_text(text, n_fields) rows = [ (f["field"], f.get("operator", "contains"), seg) for f, seg in zip(fields, segments, strict=True) ] return {"mode": "and", "complete": complete, "rows": rows, "or_filters": []} or_filters = [ {"field": f["field"], "operator": f.get("operator", "contains"), "value": text} for f in fields ] return {"mode": "or", "complete": True, "rows": [], "or_filters": or_filters}
[文档] def split_quick_search_text(text: str, n: int) -> list[str]: """把快查文本拆成【恰好 n 段】,严格按字段数对齐、空段保留不丢弃。 分隔符兼容:半角 ``|`` / 空格环绕的 `` | `` / 全角 ``|`` 都归一化为半角无空格 ``|`` 再切分,避免中文输入法的全角竖线导致只切出部分段(如 ``天津荣程1100| `` 被整段当成 project_name),进而后续字段错位、查询结果为空。 手动模糊输入(无竖线)按逗号切分回退。最终按 n 对齐:段数不足补空、多余截断, 杜绝中间空字段被吞导致后续字段错位填入。 """ text = (text or "").replace(_FULLWIDTH_PIPE, "|") # 全角竖线 → 半角 text = text.replace(" | ", "|") # 统一成无空格半角竖线分隔 # 手动模糊输入(无竖线)按逗号切分回退;否则按半角竖线切分 parts = text.split("|") if "|" in text else text.replace(",", ",").split(",") segments = [p.strip() for p in parts] # 严格 n 段:不足补空、多余截断 if len(segments) > n: segments = segments[:n] elif len(segments) < n: segments = segments + [""] * (n - len(segments)) return segments
[文档] class QuickSearchPresetMixin: """封装 FilterBar 快查 + preset 的宿主胶水逻辑,供各视图复用。 通过 ``setup_quick_search_preset(presets, columns)`` 初始化;``FilterBar`` 构造时把 ``quick_search_callback`` / ``preset_changed_callback`` 指向本 Mixin 的 ``qsp_on_quick_search`` / ``qsp_on_preset_changed`` 即可。 """ # 由 setup_quick_search_preset 初始化 _presets: list[dict] = [] _qs_columns: list[dict] = [] _active_preset_name: str | None = None _qs_mode: str = QUICK_SEARCH_MODE_EXACT _qs_typing_timer: QTimer | None = None _qs_fuzzy_text: str = "" # ------------------------------------------------------------------ # # 初始化 # ------------------------------------------------------------------ #
[文档] def setup_quick_search_preset( self, presets: list[dict], columns: list[dict] | None = None ) -> None: """初始化 preset / 快查逻辑;默认选中首个 preset 并刷新候选。 Args: presets: 与 ui.yaml ``query_page.presets`` 同构的预设列表 columns: 字段中文标签表(``{label, field}``),用于占位提示 """ self._presets = list(presets or []) self._qs_columns = list(columns or []) self._active_preset_name = self._presets[0].get("name") if self._presets else None # 动态搜索模式状态管理:默认精确模式(无临时输入) self._qs_mode = QUICK_SEARCH_MODE_EXACT self._qs_fuzzy_text = "" self._qs_typing_timer = QTimer(self) self._qs_typing_timer.setSingleShot(True) self._qs_typing_timer.timeout.connect(self._qsp_run_fuzzy) if self._active_preset_name: self.qsp_on_preset_changed(self._active_preset_name)
# ------------------------------------------------------------------ # # 动态搜索模式状态管理 # ------------------------------------------------------------------ #
[文档] def get_quick_search_mode(self) -> str: """返回当前快查模式:``QUICK_SEARCH_MODE_FUZZY`` / ``QUICK_SEARCH_MODE_EXACT``。""" return self._qs_mode
def _qsp_set_mode(self, mode: str) -> None: """切换搜索模式(状态管理),并回调宿主钩子。""" if self._qs_mode == mode: return self._qs_mode = mode self.qsp_on_mode_changed(mode)
[文档] def qsp_on_mode_changed(self, mode: str) -> None: """模式切换钩子(默认空实现),宿主可重写以反映 UI 状态(如状态标签)。"""
# 示例:在状态栏显示「模糊匹配 / 精确匹配」 # ------------------------------------------------------------------ # # preset 切换 + 候选项加载 # ------------------------------------------------------------------ # def _qsp_find_preset(self, name: str) -> dict | None: """按名称查找预设 dict。""" for p in self._presets: if p.get("name") == name: return p return None def _qsp_field_label(self, field: str) -> str: """字段名 → 中文标签(用于快查框占位提示)。""" for c in self._qs_columns: if c.get("field") == field: return c.get("label", field) return field
[文档] def qsp_on_preset_changed(self, name: str) -> None: """preset 下拉切换:更新快查框提示并重算候选项。""" self._active_preset_name = name preset = self._qsp_find_preset(name) if preset and getattr(self, "filter_bar", None) is not None: first_field = preset["fields"][0]["field"] if preset.get("fields") else "" self.filter_bar.set_quick_search_placeholder( f"输入{self.qsp_get_field_label(first_field)}关键字快速筛选…" ) self._qsp_recompute_items()
def _qsp_recompute_items(self) -> None: """按 active preset 字段,从数据库全量抽出去重组合,填充快查候选项。""" if not self._presets: return preset = ( self._qsp_find_preset(self._active_preset_name) if self._active_preset_name else None ) if not preset: return fields = [f["field"] for f in preset.get("fields", [])] if not fields: return try: items = self.qsp_get_quick_search_items(fields) except Exception as e: # 兜底:候选失败不影响主查询 logger.warning(f"快查候选项加载失败: {e}") items = [] if getattr(self, "filter_bar", None) is not None: self.filter_bar.set_quick_search_items(items) # ------------------------------------------------------------------ # # 宿主可重写的取数钩子(数据源无关,见模块顶部说明) # ------------------------------------------------------------------ #
[文档] def qsp_get_quick_search_items(self, fields: list[str]) -> list[str]: """快查候选数据源钩子。 默认实现走 ``self._qs_service.get_distinct_field_combos(fields)``(report_view 使用)。 宿主若用其它取数方式(如 query_view 用 ``controller.get_distinct_combos`` 并基于 视图默认范围取稳定候选)应重写本方法,返回 ``list[str]`` 候选。 """ if getattr(self, "_qs_service", None) is None: return [] try: return self._qs_service.get_distinct_field_combos(fields) except Exception as e: # 兜底:候选失败不影响主查询 logger.warning(f"快查候选项加载失败: {e}") return []
[文档] def qsp_get_field_label(self, field: str) -> str: """字段中文标签钩子(用于快查框占位提示),默认走 ``_qs_columns``。""" return self._qsp_field_label(field)
# ------------------------------------------------------------------ # # 动态搜索模式:模糊(输入中)/ 精确(选中条目) # ------------------------------------------------------------------ #
[文档] def qsp_on_typing(self, text: str) -> None: """模糊模式:用户在输入框键入。 下拉已由 completer 做模糊匹配;此处切换到模糊模式,并【防抖】地对主字段做 ``contains`` 初步筛选,给出较宽的结果集。选中条目时会取消本防抖并切换精确模式。 """ text = (text or "").strip() if not text or not self._active_preset_name: # 清空输入:取消待执行的模糊查询,回到精确模式(无临时筛选) if self._qs_typing_timer is not None: self._qs_typing_timer.stop() self._qsp_set_mode(QUICK_SEARCH_MODE_EXACT) return self._qsp_set_mode(QUICK_SEARCH_MODE_FUZZY) self._qs_fuzzy_text = text
# 输入过程中不再自动查询(避免「边输入边查返回子集」);仅在回车/选中候选时由 # qsp_on_quick_search 发起查询。防抖定时器保留为占位(兼容 _qsp_run_fuzzy)。 def _qsp_run_fuzzy(self) -> None: """防抖(输入暂停)回调占位。 已改为「输入过程中不再自动查询」:边输入边查会返回不完整的子集、并清空用户已设的 筛选行。仅在用户显式确认时查询——选中下拉候选(``activated``)或按回车 (``returnPressed``,由 QuickSearch 控件统一发出 activated),二者都走 ``qsp_on_quick_search``。 """ return