"""通用对照表管理界面基类
把「材质牌号 / 口径映射 / 型号→压力」等基础字典表的 CRUD 维护界面统一到
一套**列定义驱动 + 共享控件**的框架中,避免每个表重复手写工具栏、表格、
增删改与编辑表单。
共享控件(``certflow.widgets``):
- ``ActionToolbar``:按钮簇工具栏(按内容定宽)
- ``EnhancedTable``:列元数据 → 表头 + 选择/编辑行为 + 列头点击客户端排序
- ``FilterBar``:统一条件契约的字段级筛选条
- ``ConfirmDialog``:危险操作二次确认
- ``DialogButtonBar``:确认 / 取消按钮栏
字段类型(``field_type``):
- ``text`` 普通文本(QLineEdit)
- ``int`` 整数(空→None;必填时空值抛 ``ValueError``)
- ``float`` 浮点数(同上)
- ``bool`` 布尔(QCheckBox,可设 ``default``)
- ``choice`` 下拉单选(QComboBox,``choices`` 列表,可含空串表示允许为空)
使用方式:子类只需声明 ``_COLUMNS``(表格列)与 ``_EDIT_FIELDS``(编辑表单),
并提供一个实现标准 CRUD 接口的控制器(``list_all`` / ``get_by_id`` /
``create`` / ``update`` / ``delete`` / ``close``)即可。
"""
from __future__ import annotations
import contextlib
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, override
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from certflow.utils.logger import logger
from certflow.views.bases.query_data_source import client_filter_records, client_sort_records
from certflow.widgets import ActionToolbar, ConfirmDialog, DialogButtonBar
from certflow.widgets.enhanced_table import EnhancedTable
from certflow.widgets.filter_bar import FilterBar
# 基础数据表字段多为文本,复用统一条件契约的客户端过滤算子
_TEXT_OPERATORS = {
"text": {
"contains": "包含",
"eq": "等于",
"ne": "不等于",
"is_null": "为空",
"is_not_null": "非空",
}
}
[文档]
@dataclass
class ColumnSpec:
"""表格列定义。
Attributes:
header: 表头显示文本
field: 对应 ORM 模型字段名(用于 ``getattr`` 取值)
editable: 是否可编辑(当前仅作语义标记,表格本身只读)
field_type: 字段类型,决定表格中的展示方式(bool → ✅/❌)
"""
header: str
field: str
editable: bool = True
field_type: str = "text"
[文档]
@dataclass
class FieldSpec:
"""编辑表单字段定义。
Attributes:
field: 模型字段名
label: 表单标签(可含 ``*`` 提示必填)
field_type: 字段类型(text/int/float/bool/choice)
placeholder: 输入框占位提示
choices: choice 类型的下拉选项列表(可含空串表示允许为空)
required: 是否必填(空值抛 ``ValueError``)
empty_as_none: 文本为空时返回 None(而非空串)
default: bool 类型的默认勾选值;其他类型暂未使用
"""
field: str
label: str
field_type: str = "text"
placeholder: str = ""
choices: list[str] | None = None
required: bool = False
empty_as_none: bool = False
default: Any = None
[文档]
class LookupRecordEditDialog(QDialog):
"""通用编辑子对话框(新增/编辑共用),由 ``FieldSpec`` 列表驱动构建表单。"""
def __init__(
self,
edit_fields: list[FieldSpec],
record: Any = None,
title: str = "编辑记录",
parent: QWidget | None = None,
) -> None:
"""初始化编辑对话框
Args:
edit_fields: 字段定义列表,决定表单内容
record: 待编辑的 ORM 实例;为 None 表示新增
title: 对话框标题
parent: 父窗口
"""
super().__init__(parent)
self._edit_fields = edit_fields
self.setWindowTitle(title)
self.setMinimumWidth(440)
self._widgets: dict[str, object] = {}
self._build_form()
if record is not None:
self._fill(record)
def _build_form(self) -> None:
layout = QVBoxLayout(self)
form = QFormLayout()
form.setSpacing(8)
for spec in self._edit_fields:
if spec.field_type == "choice":
w = QComboBox()
w.addItems(spec.choices or [])
self._widgets[spec.field] = w
form.addRow(spec.label, w)
elif spec.field_type == "bool":
w = QCheckBox(spec.label)
w.setChecked(bool(spec.default) if spec.default is not None else True)
self._widgets[spec.field] = w
form.addRow("", w)
else:
w = QLineEdit()
w.setPlaceholderText(spec.placeholder)
self._widgets[spec.field] = w
form.addRow(spec.label, w)
# 按钮(共享件 DialogButtonBar:确认/取消,定宽右对齐)
bar = DialogButtonBar(self, confirm_text="保存", cancel_text="取消")
bar.accepted.connect(self.accept)
bar.rejected.connect(self.reject)
layout.addLayout(form)
layout.addWidget(bar)
def _fill(self, record: Any) -> None:
for spec in self._edit_fields:
value = getattr(record, spec.field, None)
w = self._widgets[spec.field]
if spec.field_type == "choice":
idx = w.findText("" if value is None else str(value))
if idx >= 0:
w.setCurrentIndex(idx)
elif spec.field_type == "bool":
w.setChecked(bool(value))
else:
w.setText("" if value is None else str(value))
[文档]
def get_data(self) -> dict[str, Any]:
"""收集表单数据,按字段类型转换;必填为空抛 ``ValueError``。
Returns:
dict[str, Any]: 字段名 → 值(空文本按类型转为 None 或空串)
"""
data: dict[str, Any] = {}
for spec in self._edit_fields:
w = self._widgets[spec.field]
if spec.field_type == "choice":
text = w.currentText()
if spec.required and not text:
raise ValueError(f"「{spec.label}」为必填项")
data[spec.field] = text or None
elif spec.field_type == "bool":
data[spec.field] = w.isChecked()
elif spec.field_type in ("int", "float"):
text = w.text().strip()
if not text:
if spec.required:
raise ValueError(f"「{spec.label}」为必填项")
data[spec.field] = None
else:
try:
data[spec.field] = int(text) if spec.field_type == "int" else float(text)
except ValueError as err:
raise ValueError(f"「{spec.label}」需为{spec.field_type}数值") from err
else: # text
text = w.text().strip()
if spec.required and not text:
raise ValueError(f"「{spec.label}」为必填项")
data[spec.field] = (text or None) if spec.empty_as_none else text
return data
[文档]
class LookupTableManagerView(QDialog):
"""通用对照表管理对话框(列定义驱动 + 共享控件)。
子类通过传入 ``controller`` / ``columns`` / ``edit_fields`` / ``title``
即可获得完整的「工具栏 + 表格 + 增删改 + 编辑表单」能力,无需重复造轮子。
"""
def __init__(
self,
*,
controller: Any,
columns: list[ColumnSpec],
edit_fields: list[FieldSpec],
title: str,
window_title: str | None = None,
extra_actions: list[tuple[str, Callable[[], None], bool]] | None = None,
dict_key: str | None = None,
quick_filters: list[dict] | None = None,
table_key: str | None = None,
parent: QWidget | None = None,
) -> None:
"""初始化通用管理对话框
Args:
controller: 实现标准 CRUD 接口的控制器实例
columns: 表格列定义列表
edit_fields: 编辑表单字段定义列表
title: 表名(用于提示文案)
window_title: 窗口标题;缺省同 ``title``
extra_actions: 额外工具栏动作列表,元素为
``(按钮文本, 回调, 是否危险操作)``
dict_key: 字典键名(``dictionary.<key>.csv``);传入则在工具栏追加
「导出CSV / 导入CSV」按钮,实现 DB ↔ CSV 双向同步
quick_filters: 常用筛选快速按钮(显式覆盖);元素为
``{"label", "field", "operator", "value"}``。为 ``None`` 时改从
YAML 配置 ``lookup_tables.quick_filters.<table_key>`` 读取(数据驱动,
便于增减,无需改代码)。
table_key: 在 ``lookup_tables`` 配置中的标识(与 ``_TAB_REGISTRY`` 的 key
一致),用于从 YAML 拉取该表的快速筛选按钮。
parent: 父窗口
"""
super().__init__(parent)
self._controller = controller
self._columns = columns
self._edit_fields = edit_fields
self._title = title
self._dict_key = dict_key
# 显式 quick_filters 优先;否则按 table_key 从 YAML 配置读取
self._quick_filters = (
list(quick_filters)
if quick_filters is not None
else self._load_quick_filters_from_config(table_key)
)
# choice / bool 列的静态枚举,用于筛选条渲染下拉(避免文本框误输入)
self._field_choices_map: dict[str, list[str]] = {}
for f in self._edit_fields:
if f.field_type == "choice" and f.choices:
self._field_choices_map[f.field] = list(f.choices)
elif f.field_type == "bool":
self._field_choices_map[f.field] = ["", "True", "False"]
# 客户端筛选/排序状态
self._all_rows: list[Any] = []
self._order_by: str | None = None
self._order_desc: bool = False
self.setWindowTitle(window_title or title)
self.setMinimumSize(1000, 600)
self.resize(1100, 680)
self._init_ui(extra_actions or [])
self.refresh()
# ============================================================
# UI 构建
# ============================================================
@staticmethod
def _load_quick_filters_from_config(table_key: str | None) -> list[dict]:
"""从 YAML 配置 ``lookup_tables.quick_filters.<table_key>`` 读取快速按钮。
配置不可用时(如测试环境未加载)安全回退为 ``[]``,不影响主流程。
"""
if not table_key:
return []
try:
from certflow.config.settings import cfg
quick_filters_cfg = cfg("lookup_tables.quick_filters", {}) or {}
except Exception: # pragma: no cover - 防御:配置缺失不阻断界面
return []
rows = quick_filters_cfg.get(table_key)
return list(rows) if rows else []
def _init_ui(self, extra_actions: list[tuple[str, Callable[[], None], bool]]) -> None:
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(12, 12, 12, 12)
main_layout.setSpacing(10)
# 工具栏(共享件 ActionToolbar:按内容定宽,刷新按钮右对齐)
toolbar = ActionToolbar()
toolbar.add_button("➕ 新增", self.on_add)
toolbar.add_button("✏️ 编辑", self.on_edit)
toolbar.add_button("🗑️ 删除", self.on_delete, danger=True)
for label, cb, danger in extra_actions:
toolbar.add_button(label, cb, danger=danger)
toolbar.add_stretch()
if self._dict_key:
toolbar.add_button("📤 导出CSV", self.on_export_csv)
toolbar.add_button("📥 导入CSV", self.on_import_csv)
toolbar.add_button("🧹 清除筛选", self._on_clear_filter)
toolbar.add_button("🔄 刷新", self.refresh)
main_layout.addWidget(toolbar)
# 字段筛选条(复用统一 FilterBar;客户端 client_filter_records 过滤)
self._filter_bar = FilterBar(
columns=[{"label": c.header, "field": c.field} for c in self._columns],
field_op_map={},
field_choices_map=self._field_choices_map,
operators=_TEXT_OPERATORS,
grid_cols=3,
)
self._filter_bar.conditions_changed.connect(self._on_filter_changed_debounced)
main_layout.addWidget(self._filter_bar)
# 配置驱动的常用筛选快速按钮(数据驱动,可整体迁移到 YAML)
if self._quick_filters:
quick_layout = QHBoxLayout()
quick_layout.setContentsMargins(0, 0, 0, 0)
quick_label = QLabel("快速筛选:")
quick_label.setStyleSheet("font-size: 11px; color: #666;")
quick_layout.addWidget(quick_label)
for spec in self._quick_filters:
btn = QPushButton(spec.get("label", spec.get("field", "筛选")))
btn.setFixedHeight(24)
btn.clicked.connect(lambda _checked, s=spec: self._apply_quick_filter(s))
quick_layout.addWidget(btn)
quick_layout.addStretch()
main_layout.addLayout(quick_layout)
# 表格(EnhancedTable:列头点击触发客户端排序,行 UserRole 携带 obj.id)
self._table = EnhancedTable()
self._table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self._table.setColumnCount(len(self._columns))
self._table.setHorizontalHeaderLabels([c.header for c in self._columns])
for idx, col in enumerate(self._columns):
self._table.set_header_field(idx, col.field)
self._table.doubleClicked.connect(self._on_double_click)
self._table.sort_requested.connect(self._on_sort_requested)
main_layout.addWidget(self._table, stretch=1)
# 底部提示(显示筛选后数量)
self._hint_label = QLabel("")
self._hint_label.setStyleSheet("font-size: 11px; color: #666;")
main_layout.addWidget(self._hint_label)
# 筛选输入防抖定时器
self._filter_timer = QTimer(self)
self._filter_timer.setSingleShot(True)
self._filter_timer.timeout.connect(self._apply_view)
# ============================================================
# 数据加载
# ============================================================
[文档]
def refresh(self) -> None:
"""从数据库重新加载全量数据并应用当前筛选/排序。"""
try:
self._all_rows = self._controller.list_all()
except Exception as e:
logger.error(f"加载{self._title}失败: {e}")
QMessageBox.critical(self, "错误", f"加载失败: {e}")
return
self._apply_view()
# ============================================================
# 筛选 / 排序 / 填充
# ============================================================
def _apply_view(self) -> None:
"""按当前筛选条件过滤 + 列头排序,重填表格并更新计数提示。"""
conditions = self._filter_bar.get_conditions()
rows = client_filter_records(self._all_rows, conditions)
rows = client_sort_records(rows, self._order_by, self._order_desc)
self._populate(rows)
total = len(self._all_rows)
shown = len(rows)
if conditions:
self._hint_label.setText(
f"筛选后 {shown} 条/总计 {total} 双击行可编辑;删除前会二次确认。"
f"当前维护:{self._title} 对照表。"
)
else:
self._hint_label.setText(
f"共 {total} 条 双击行可编辑;删除前会二次确认。当前维护:{self._title} 对照表。"
)
def _populate(self, rows: list[Any]) -> None:
"""把记录列表填入表格(每行用 UserRole 携带 obj.id 供增删改定位)。"""
self._table.setRowCount(len(rows))
for r, obj in enumerate(rows):
for c, col in enumerate(self._columns):
value = getattr(obj, col.field, None)
if col.field_type == "bool":
value = "✅" if value else "❌"
item = QTableWidgetItem("" if value is None else str(value))
item.setData(Qt.ItemDataRole.UserRole, obj.id)
self._table.setItem(r, c, item)
self._table.resizeColumnsToContents()
def _on_filter_changed_debounced(self) -> None:
"""筛选条件变化:防抖触发重算(避免逐字符重排卡顿)。"""
self._filter_timer.start(200)
def _on_sort_requested(self, field: str, desc: bool) -> None:
"""列头点击:本地对全量数据排序后重填(EnhancedTable 已更新箭头)。"""
self._order_by = field
self._order_desc = desc
self._apply_view()
def _on_clear_filter(self) -> None:
"""清空所有筛选条件与排序,回到全量。"""
self._filter_bar.clear_rows()
self._filter_bar.ensure_at_least_one_row()
self._order_by = None
self._order_desc = False
self._table.horizontalHeader().setSortIndicator(-1, Qt.SortOrder.AscendingOrder)
self._apply_view()
def _apply_quick_filter(self, spec: dict) -> None:
"""点击快速筛选按钮:在筛选条追加一条预置条件并立即应用。"""
self._filter_bar.add_row(spec.get("field"), spec.get("operator", "eq"), spec.get("value"))
# conditions_changed 已触发防抖重算,这里再立即触发一次保证即时生效
self._filter_timer.start(0)
# ============================================================
# 增删改
# ============================================================
[文档]
def on_add(self) -> None:
"""新增记录"""
dlg = LookupRecordEditDialog(self._edit_fields, None, f"新增{self._title}", self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
try:
self._controller.create(dlg.get_data())
self.refresh()
except ValueError as e:
QMessageBox.warning(self, "校验失败", str(e))
[文档]
def on_edit(self) -> None:
"""编辑选中记录"""
rid = self._selected_id()
if rid is None:
QMessageBox.information(self, "提示", "请先选择一行")
return
obj = self._controller.get_by_id(rid)
if obj is None:
return
dlg = LookupRecordEditDialog(self._edit_fields, obj, f"编辑{self._title}", self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
try:
self._controller.update(rid, dlg.get_data())
self.refresh()
except ValueError as e:
QMessageBox.warning(self, "校验失败", str(e))
[文档]
def on_delete(self) -> None:
"""删除选中记录(共享件 ConfirmDialog 统一二次确认)"""
rid = self._selected_id()
if rid is None:
QMessageBox.information(self, "提示", "请先选择一行")
return
if not ConfirmDialog.danger(self, "确认删除", "确定删除该记录?此操作不可撤销。"):
return
if self._controller.delete(rid):
self.refresh()
def _on_double_click(self, index: int) -> None:
self.on_edit()
# ============================================================
# DB ↔ CSV 双向同步
# ============================================================
[文档]
def on_export_csv(self) -> None:
"""把当前 DB 表整表导出为 CSV 快照(与界面编辑保持同步)。"""
if not self._dict_key:
return
path = self._controller.resolve_dict_csv_path(self._dict_key)
if path is None:
QMessageBox.warning(self, "提示", f"未配置 {self._dict_key} 的 CSV 路径")
return
try:
n = self._controller.export_csv(path)
except Exception as e:
logger.error(f"导出CSV失败: {e}")
QMessageBox.critical(self, "错误", f"导出失败: {e}")
return
QMessageBox.information(self, "成功", f"已导出 {n} 行到:\n{path}")
self.refresh()
[文档]
def on_import_csv(self) -> None:
"""从 CSV 快照 upsert 回 DB(外部编辑的 CSV 同步进库)。"""
if not self._dict_key:
return
default_dir = ""
resolved = self._controller.resolve_dict_csv_path(self._dict_key)
if resolved is not None:
default_dir = str(resolved.parent)
file_path, _ = QFileDialog.getOpenFileName(
self, "选择 CSV 文件", default_dir, "CSV 文件 (*.csv)"
)
if not file_path:
return
try:
stats = self._controller.import_csv(file_path)
except Exception as e:
logger.error(f"导入CSV失败: {e}")
QMessageBox.critical(self, "错误", f"导入失败: {e}")
return
if stats.get("errors", 0) < 0:
QMessageBox.warning(self, "提示", f"文件不存在:\n{file_path}")
return
QMessageBox.information(
self,
"成功",
f"已从 {file_path} 同步:\n"
f"新增 {stats['inserted']} 行,更新 {stats['updated']} 行,"
f"跳过 {stats['skipped']} 行,错误 {stats['errors']} 行。",
)
self.refresh()
def _selected_id(self) -> int | None:
row = self._table.currentRow()
if row < 0:
return None
id_item = self._table.item(row, 0)
if id_item is None:
return None
return id_item.data(Qt.ItemDataRole.UserRole)
# ============================================================
# 资源清理
# ============================================================
[文档]
@override
def closeEvent(self, event: Any) -> None:
"""关闭时释放控制器会话"""
with contextlib.suppress(Exception): # pragma: no cover - 防御性
self._controller.close()
super().closeEvent(event)