"""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
[文档]
def qsp_on_quick_search(self, text: str) -> None:
"""确认(选中候选/回车):按 active preset 字段发起查询。
- 含分隔符(``|`` / ``,`` / 全角 ``,``):按字段数严格切分,逐段精确回填并 AND 组合;
- 无分隔符的单关键字:在 preset **全部**字段上做 ``contains`` OR 匹配,
任一字段命中即返回(避免「只匹配首字段 → 漏结果」),与查询页行为一致。
"""
text = (text or "").strip()
if not text or not self._active_preset_name:
return
preset = self._qsp_find_preset(self._active_preset_name)
if not preset:
return
fields = preset.get("fields", [])
if not fields:
return
# 切换到精确模式,取消模糊防抖(避免二次查询)
if self._qs_typing_timer is not None:
self._qs_typing_timer.stop()
self._qsp_set_mode(QUICK_SEARCH_MODE_EXACT)
has_sep = ("|" in text) or ("," in text) or ("," in text)
self.filter_bar.clear_rows()
if has_sep:
segments = split_quick_search_text(text, len(fields))
for f, seg in zip(fields, segments, strict=True):
if seg:
self.filter_bar.add_row(
field_name=f["field"], operator=f.get("operator", "contains"), value=seg
)
self.on_quick_search()
else:
# 单关键字:跨全部 preset 字段 OR 匹配,输入即所查
or_filters = [
{"field": f["field"], "operator": f.get("operator", "contains"), "value": text}
for f in fields
]
self.on_quick_search(or_filters=or_filters)