# 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()