certflow.views.settings_view 源代码

# src/certflow/views/settings_view.py
"""系统设置视图(配置中心)

按 docs/discussions/resource-paths-audit.md §11.4 重构成三个声明式 tab:

- 概览(只读): config.yaml 合并后的全量树(含 T13 聚合的「旁路配置」),可编辑项灰显标注。
- 可编辑: 由 ``config/settings_ui.yaml`` 声明式 spec 自动渲染分区/控件,经
  ``ConfigCenterBackend``(T10)按 ``layer`` 写 ``paths.local.yaml``(目录覆盖)或
  ``userconfig.yaml``(用户偏好覆盖)。新增配置项只改 spec,不动视图代码。
- 我的覆盖: 列出 ``paths.local.yaml``(桶1 每机目录)+ ``userconfig.yaml`` 稳定段
  (桶2 每用户偏好)当前覆盖,支持一键「恢复默认(清空覆盖)」。

通过 SettingsController 实现偏好保存即生效(主题/语言/自动保存定时器)。
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import yaml
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
    QCheckBox,
    QComboBox,
    QDoubleSpinBox,
    QFileDialog,
    QFrame,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QLineEdit,
    QMessageBox,
    QPushButton,
    QScrollArea,
    QSpinBox,
    QTabWidget,
    QTreeWidget,
    QTreeWidgetItem,
    QVBoxLayout,
    QWidget,
)

from certflow.config.config_center_backend import ConfigCenterBackend
from certflow.config.paths_override import clear_path_override, reset_path_overrides
from certflow.config.settings import _get_user_config, set_override_section
from certflow.config.settings_ui import load_settings_ui_spec
from certflow.utils.logger import logger

if TYPE_CHECKING:
    from certflow.controllers.settings_controller import SettingsController

# userconfig.yaml 中属于「桶2 稳定偏好」的顶层键(我的覆盖 tab 展示/恢复用)
_USERCONFIG_STABLE_KEYS = (
    "preferences",
    "print_layout",
    "printer_offsets",
    "print_raw_capture",
    "template_field_overrides",
)


[文档] class SettingsView(QWidget): """系统设置视图(配置中心) 标签页: - 概览: config.yaml 全量树(只读,含旁路配置),可编辑项灰显 - 可编辑: 由 settings_ui.yaml spec 自动渲染分区/控件 - 我的覆盖: 列出 paths.local.yaml + userconfig 稳定段覆盖,一键恢复默认 通过 SettingsController 保存设置并立即生效(主题切换、语言切换等)。 """ def __init__( self, config: dict = None, controller: SettingsController | None = None, parent: Any = None ) -> None: """初始化 SettingsView。 Args: config: 配置字典(兜底用) controller: 控制器 parent: 父控件(可选) """ super().__init__(parent) self.config = config or {} self._controller = controller self._backend = ConfigCenterBackend() self._editable_fields: list[dict] = [] self.setup_ui() # ============================================================ # UI 构建 # ============================================================
[文档] def setup_ui(self) -> None: """构建界面""" main_layout = QVBoxLayout(self) main_layout.setContentsMargins(12, 12, 12, 12) self.tabs = QTabWidget() # §11.4 三 tab:概览(只读) / 可编辑(spec 驱动) / 我的覆盖(覆盖层) self.tabs.addTab(self._create_overview_tab(), self._tr("📋 概览")) self.tabs.addTab(self._create_editable_tab(), self._tr("🛠 可编辑")) self.tabs.addTab(self._create_overrides_tab(), self._tr("📌 我的覆盖")) main_layout.addWidget(self.tabs) # 底部保存按钮 btn_layout = QHBoxLayout() btn_layout.addStretch() reset_btn = QPushButton(self._tr("恢复默认")) reset_btn.setStyleSheet(""" QPushButton { background-color: #6c757d; color: white; padding: 8px 20px; border-radius: 4px; font-size: 14px; } QPushButton:hover { background-color: #5a6268; } """) reset_btn.clicked.connect(self._reset_defaults) btn_layout.addWidget(reset_btn) save_btn = QPushButton(self._tr("保存设置")) save_btn.setStyleSheet(""" QPushButton { background-color: #28a745; color: white; padding: 8px 20px; border-radius: 4px; font-size: 14px; } QPushButton:hover { background-color: #218838; } """) save_btn.clicked.connect(self._save_settings) btn_layout.addWidget(save_btn) main_layout.addLayout(btn_layout)
# ============================================================ # 布局辅助: 多列网格 (压缩纵向行高) # ============================================================ def _build_form_grid(self, fields: list[tuple[str, QWidget]], columns: int = 2) -> QGridLayout: """把 [(label, widget)] 以每行 ``columns`` 个(标签+输入)对的网格排布,压缩纵向空间。 label 为空字符串时,widget 横跨该对的两列(用于独立复选框/浏览行)。 """ from PySide6.QtCore import Qt as _Qt grid = QGridLayout() grid.setVerticalSpacing(6) grid.setHorizontalSpacing(12) for idx, (label, widget) in enumerate(fields): row = idx // columns pair = idx % columns col = pair * 2 if label: lbl = QLabel(label) lbl.setAlignment(_Qt.AlignmentFlag.AlignRight | _Qt.AlignmentFlag.AlignVCenter) grid.addWidget(lbl, row, col) grid.addWidget(widget, row, col + 1) else: grid.addWidget(widget, row, col, 1, 2) for pair in range(columns): grid.setColumnStretch(pair * 2, 0) grid.setColumnStretch(pair * 2 + 1, 1) return grid # ============================================================ # Tab 1: 概览(只读树,含 T13 聚合的旁路配置) # ============================================================ def _create_overview_tab(self) -> QWidget: """创建配置总览标签页 — 树形展示 config.yaml 全部节点(只读)""" widget = QWidget() layout = QVBoxLayout(widget) layout.setSpacing(8) hint = QLabel( self._tr( "📋 以下为 config.yaml 全部配置节点(含按文件名直接读取的旁路配置)的只读展示," "可编辑项见「可编辑」tab;修改请直接编辑对应 yaml 文件后重启。" ) ) hint.setStyleSheet("color: #856404; background: #fff3cd; padding: 8px; border-radius: 4px;") hint.setWordWrap(True) layout.addWidget(hint) self.overview_tree = QTreeWidget() self.overview_tree.setHeaderLabels([self._tr("节点"), self._tr("值 / 说明")]) self.overview_tree.header().setStretchLastSection(True) self.overview_tree.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.overview_tree.setAlternatingRowColors(True) self.overview_tree.setStyleSheet(""" QTreeWidget { font-size: 13px; } QTreeWidget::item { padding: 2px 4px; } """) self._populate_overview_tree() layout.addWidget(self.overview_tree) return widget def _populate_overview_tree(self) -> None: """填充配置总览树 使用加载器合并后的完整配置(含所有 !include 子文件), 而非 main.py 传入的 5 键子集,避免总览遗漏业务域配置。 另聚合「旁路配置」——未并入 config.yaml、由各模块按文件名直接读取的 config/*.yaml(grade_aliases/imperial_rules/query_fields/columns), 原总览完全看不到这些配置,现统一归入「旁路配置」分组展示。 """ self.overview_tree.clear() try: from certflow.config.loader import get_config full_config = get_config().model_dump() except Exception: # 兜底:加载器不可用时退回传入的子集 full_config = dict(self.config) # 聚合旁路配置:统一归入一个父节点,避免与主配置顶层键混淆/遗漏。 # 父节点名以中文开头,按 _add_dict_items 的 sorted 规则会自然排到末尾(参考区)。 bypass = self._load_bypass_configs() if bypass: full_config = dict(full_config) full_config["旁路配置(未并入 config.yaml)"] = bypass self._add_dict_items(self.overview_tree.invisibleRootItem(), full_config) # 未并入 config.yaml、由各模块按文件名直接读取的旁路配置文件清单(T13 聚合) _BYPASS_CONFIG_FILES = ( "grade_aliases.yaml", "imperial_rules.yaml", "query_fields.yaml", "columns.yaml", ) def _load_bypass_configs(self) -> dict: """加载旁路配置(未并入 config.yaml、由各模块按文件名直接读取的 config/*.yaml)。 这些文件不进主配置 !include,原「配置总览」完全看不到;此处聚合供总览展示。 任一文件缺失/损坏只跳过该文件,不影响其余配置与总览。 """ from certflow.config.settings import CONFIG_DIR result: dict = {} for name in self._BYPASS_CONFIG_FILES: path = CONFIG_DIR / name if not path.exists(): continue try: data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} except Exception as e: # noqa: BLE001 - 配置错误不应中断总览 logger.warning(f"读取旁路配置 {name} 失败,已跳过: {e}") continue if data: result[name] = data return result def _add_dict_items(self, parent: QTreeWidgetItem, data: dict) -> None: """递归添加字典到树节点""" for key in sorted(data.keys()): value = data[key] if isinstance(value, dict): sub_count = len(value) label = self._tr(f"({sub_count} 个子节点)") item = QTreeWidgetItem(parent, [key, label]) item.setForeground(0, Qt.darkBlue) self._add_dict_items(item, value) elif isinstance(value, list): summary = self._format_list_summary(value) item = QTreeWidgetItem(parent, [key, summary]) item.setForeground(0, Qt.darkGreen) # 展开列表项 for i, elem in enumerate(value): if isinstance(elem, dict): field_count = len(elem) sub = QTreeWidgetItem(item, [f"[{i}]", self._tr(f"({field_count} 个字段)")]) self._add_dict_items(sub, elem) else: val_str = self._format_value(elem) QTreeWidgetItem(item, [f"[{i}]", val_str]) elif isinstance(value, bool): QTreeWidgetItem(parent, [key, self._tr("✓ 是") if value else self._tr("✗ 否")]) elif value is None: QTreeWidgetItem(parent, [key, self._tr("(空)")]) elif value == "": QTreeWidgetItem(parent, [key, self._tr("(空字符串)")]) else: QTreeWidgetItem(parent, [key, self._format_value(value)]) @staticmethod def _format_value(value: Any) -> str: """格式化标量值为展示字符串""" if isinstance(value, float): return f"{value:.1f}" if value == int(value) else f"{value:.2f}" if isinstance(value, str) and len(value) > 80: return value[:80] + "..." return str(value) @staticmethod def _format_list_summary(lst: list) -> str: """格式化列表摘要""" total = len(lst) if total == 0: return "(空列表)" dict_count = sum(1 for x in lst if isinstance(x, dict)) scalar_count = total - dict_count parts = [] if dict_count: parts.append(f"{dict_count} 个对象") if scalar_count: parts.append(f"{scalar_count} 个值") return f"列表 ({total} 项: {', '.join(parts)})" # ============================================================ # Tab 2: 可编辑(由 settings_ui.yaml spec 自动渲染) # ============================================================ def _create_editable_tab(self) -> QWidget: """创建「可编辑」tab — 按 settings_ui.yaml 声明式 spec 自动生成分区与控件。""" widget = QWidget() outer = QVBoxLayout(widget) outer.setSpacing(12) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.NoFrame) content = QWidget() vbox = QVBoxLayout(content) vbox.setSpacing(12) self._editable_fields = [] spec = load_settings_ui_spec() sections = spec.get("sections", []) if not sections: vbox.addWidget(QLabel(self._tr("(未找到配置中心 spec:config/settings_ui.yaml)"))) for section in sections: editable = bool(section.get("editable", False)) title = section.get("title") or section.get("id") or "" group = QGroupBox(self._tr(title)) if section.get("note"): group.setToolTip(self._tr(section["note"])) pairs: list[tuple[str, QWidget]] = [] for field in section.get("fields", []): key = field.get("key") if not key: continue label = field.get("label") or key cur = self._backend.read(key).get("effective") if editable and field.get("type") == "bool": w, get_fn, set_fn = self._make_bool_widget(field, cur) pairs.append(("", w)) elif editable: w, get_fn, set_fn = self._make_editable_widget(field, cur) pairs.append((self._tr(label), w)) else: w = self._make_readonly_widget(cur) get_fn = None set_fn = None pairs.append((self._tr(label), w)) self._editable_fields.append( { "key": key, "layer": section.get("layer"), "editable": editable, "orig": cur, "get": get_fn, "set": set_fn, } ) group.setLayout(self._build_form_grid(pairs, columns=2)) vbox.addWidget(group) vbox.addStretch() scroll.setWidget(content) outer.addWidget(scroll) return widget def _make_editable_widget(self, field: dict, cur: Any) -> tuple[QWidget, Any, Any]: """按 ``type`` 创建可编辑控件,返回 (widget, get_fn, set_fn)。""" ftype = field.get("type", "text") if ftype == "int": w = QSpinBox() w.setRange(-100000, 100000) w.setValue(int(cur or 0)) return w, (lambda: w.value()), (lambda v: w.setValue(int(v or 0))) if ftype == "float": w = QDoubleSpinBox() w.setRange(-100000.0, 100000.0) w.setValue(float(cur or 0)) return w, (lambda: w.value()), (lambda v: w.setValue(float(v or 0))) if ftype == "combo": w = QComboBox() displays, values = self._combo_options(field.get("options", [])) w.addItems(displays) try: idx = values.index(cur) except ValueError: idx = 0 w.setCurrentIndex(idx) return ( w, (lambda: values[w.currentIndex()]), (lambda v: w.setCurrentIndex(values.index(v) if v in values else 0)), ) if ftype in ("path", "file"): return self._make_path_widget(cur, file_mode=(ftype == "file")) # 默认 text w = QLineEdit("" if cur is None else str(cur)) return w, (lambda: w.text().strip()), (lambda v: w.setText("" if v is None else str(v))) def _make_bool_widget(self, field: dict, cur: Any) -> tuple[QWidget, Any, Any]: """复选框控件(标签即控件文本,网格中横跨两列)。""" w = QCheckBox(self._tr(field.get("label", ""))) w.setChecked(bool(cur)) return w, (lambda: w.isChecked()), (lambda v: w.setChecked(bool(v))) def _make_path_widget(self, cur: Any, file_mode: bool) -> tuple[QWidget, Any, Any]: """目录/文件浏览行:QLineEdit + 浏览按钮。""" container = QWidget() h = QHBoxLayout(container) h.setContentsMargins(0, 0, 0, 0) le = QLineEdit("" if cur is None else str(cur)) le.setMinimumWidth(220) btn = QPushButton(self._tr("浏览...")) btn.clicked.connect(lambda _checked=False, line=le: self._browse(line, file_mode)) h.addWidget(le) h.addWidget(btn) return ( container, (lambda: le.text().strip()), (lambda v: le.setText("" if v is None else str(v))), ) def _make_readonly_widget(self, cur: Any) -> QWidget: """只读展示控件(灰显)。""" if isinstance(cur, dict): text = f"(dict · {len(cur)} 键)" elif isinstance(cur, list): text = self._format_list_summary(cur) else: text = self._format_value(cur) lbl = QLabel(text) lbl.setEnabled(False) lbl.setStyleSheet("color: #6c757d;") lbl.setWordWrap(True) return lbl @staticmethod def _combo_options(options: Any) -> tuple[list[str], list[Any]]: """解析 combo options 为 (显示列表, 值列表)。""" displays: list[str] = [] values: list[Any] = [] for o in options or []: if isinstance(o, dict): v = o.get("value") displays.append(str(o.get("label") if o.get("label") is not None else v)) values.append(v) else: displays.append(str(o)) values.append(o) return displays, values def _browse(self, line_edit: QLineEdit, file_mode: bool) -> None: """浏览目录或文件并写回 line_edit。""" cur = line_edit.text().strip() if file_mode: path, _ = QFileDialog.getOpenFileName(self, self._tr("选择文件"), cur) else: path = QFileDialog.getExistingDirectory(self, self._tr("选择目录"), cur) if path: line_edit.setText(path) # ============================================================ # Tab 3: 我的覆盖(paths.local.yaml + userconfig 稳定段) # ============================================================ def _create_overrides_tab(self) -> QWidget: """创建「我的覆盖」tab — 列出当前生效的覆盖并支持一键恢复默认。""" widget = QWidget() layout = QVBoxLayout(widget) layout.setSpacing(12) hint = QLabel( self._tr( "📌 以下为当前覆盖基础配置的项。覆盖层不与仓库配置冲突,可随时「恢复默认」清回基础值。" ) ) hint.setStyleSheet("color: #0c5460; background: #d1ecf1; padding: 8px; border-radius: 4px;") hint.setWordWrap(True) layout.addWidget(hint) self._overrides_container = QWidget() self._overrides_layout = QVBoxLayout(self._overrides_container) layout.addWidget(self._overrides_container) self._refresh_overrides_tab() return widget def _refresh_overrides_tab(self) -> None: """重建「我的覆盖」tab 内容。""" while self._overrides_layout.count(): child = self._overrides_layout.takeAt(0) if child.widget(): child.widget().deleteLater() # 桶1:paths.local.yaml(每机目录覆盖) group1 = QGroupBox(self._tr("路径覆盖 (paths.local.yaml · 每机目录, gitignored)")) g1 = QVBoxLayout(group1) keys = self._backend.list_overrides() if keys: for key in sorted(keys): eff = self._backend.read(key).get("effective") row = QHBoxLayout() row.addWidget(QLabel(f"{key} = {eff}")) btn = QPushButton(self._tr("恢复默认")) btn.clicked.connect(lambda _checked=False, k=key: self._restore_path_override(k)) row.addWidget(btn) row.addStretch() g1.addLayout(row) reset_all = QPushButton(self._tr("全部恢复默认(清空 paths.local.yaml)")) reset_all.clicked.connect(self._restore_all_path_overrides) g1.addWidget(reset_all) else: g1.addWidget(QLabel(self._tr("(无路径覆盖)"))) self._overrides_layout.addWidget(group1) # 桶2:userconfig.yaml 稳定段(每用户偏好覆盖) group2 = QGroupBox(self._tr("用户覆盖 (userconfig.yaml · 每用户偏好)")) g2 = QVBoxLayout(group2) usercfg = _get_user_config() any_user = False for sec in _USERCONFIG_STABLE_KEYS: data = usercfg.get(sec) if not data: continue any_user = True row = QHBoxLayout() row.addWidget(QLabel(f"{sec}: {self._summarize_override(data)}")) btn = QPushButton(self._tr("恢复默认")) btn.clicked.connect(lambda _checked=False, s=sec: self._restore_user_override(s)) row.addWidget(btn) row.addStretch() g2.addLayout(row) if not any_user: g2.addWidget(QLabel(self._tr("(无用户覆盖)"))) self._overrides_layout.addWidget(group2) self._overrides_layout.addStretch() @staticmethod def _summarize_override(data: Any) -> str: """简短概述一个覆盖段的内容(避免泄露过长的嵌套结构)。""" if isinstance(data, dict): keys = list(data.keys()) if len(keys) <= 4: return "{" + ", ".join(keys) + "}" return "{" + ", ".join(keys[:4]) + ", ...}" return str(data) def _restore_path_override(self, key: str) -> None: """恢复单个路径覆盖为默认(清空该键覆盖)。""" clear_path_override(key) self._refresh_overrides_tab() logger.info(f"已恢复路径覆盖默认: {key}") def _restore_all_path_overrides(self) -> None: """清空整个 paths.local.yaml 覆盖文件。""" reset_path_overrides() self._refresh_overrides_tab() logger.info("已清空全部路径覆盖 (paths.local.yaml)") def _restore_user_override(self, section: str) -> None: """恢复某个 userconfig 稳定段覆盖为默认(清空该段)。""" set_override_section(section, {}) self._refresh_overrides_tab() logger.info(f"已恢复用户覆盖默认: {section}") # ============================================================ # 保存 & 重置 # ============================================================ def _save_settings(self) -> None: """保存可编辑项:仅写发生变化的字段,路径类走 paths.local.yaml,偏好类走 userconfig。""" try: prefs_patch: dict = {} for item in self._editable_fields: if not item["editable"]: continue new_val = item["get"]() # 未变更的字段不写回,避免把「读取时解析出的绝对路径」误固化为覆盖 if new_val == item["orig"]: continue self._backend.write(item["key"], new_val, item["layer"]) if item["key"].startswith("preferences."): prefs_patch[item["key"].split(".", 1)[1]] = new_val # 偏好变更经 Controller 即时生效(主题/语言/定时器) if self._controller and prefs_patch: self._controller.save_all(prefs_patch) self._refresh_overrides_tab() QMessageBox.information(self, self._tr("保存成功"), self._tr("设置已保存并即时生效")) logger.info("配置中心:可编辑项已保存") except Exception as e: # noqa: BLE001 QMessageBox.critical(self, self._tr("保存失败"), f"保存设置时发生错误:\n{e}") logger.error(f"保存设置失败: {e}") def _reset_defaults(self) -> None: """恢复默认:清空所有可编辑项的覆盖(路径覆盖清键,用户覆盖清段)。""" reply = QMessageBox.question( self, self._tr("确认恢复"), self._tr( "确定将可编辑项恢复为默认(清空覆盖)吗?\n此操作不会立即保存,需点击「保存设置」生效。" ), QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if reply != QMessageBox.Yes: return for item in self._editable_fields: if not item["editable"]: continue key = item["key"] layer = item["layer"] if layer in (ConfigCenterBackend.LAYER_OVERRIDE, ConfigCenterBackend.LAYER_BASE): clear_path_override(key) else: top = key.split(".", 1)[0] set_override_section(top, {}) # 重新填充控件为当前生效值(已回退到基础层/默认) for item in self._editable_fields: if not item["editable"]: continue item["set"](self._backend.read(item["key"]).get("effective")) self._refresh_overrides_tab() logger.info("可编辑项已恢复默认(覆盖已清空,待保存生效)") # ============================================================ # I18n 辅助 # ============================================================ def _tr(self, text: str) -> str: """翻译文本(如果已注入 Controller 则委托翻译)""" if self._controller and hasattr(self._controller, "_i18n_manager"): return self._controller._i18n_manager.tr(text) # type: ignore[union-attr] return text
[文档] def retranslate_ui(self) -> None: """语言切换后刷新所有 UI 文本""" # 刷新标签页标题(顺序与 setup_ui 中的 addTab 保持一致) self.tabs.setTabText(0, self._tr("📋 概览")) self.tabs.setTabText(1, self._tr("🛠 可编辑")) self.tabs.setTabText(2, self._tr("📌 我的覆盖")) # 重新填充配置总览树 self._populate_overview_tree()