# src/certflow/views/output_view.py
"""统一输出视图(OutputView)—— 打印/扫描合一(§11.5 阶段3)。
设计要点(方案A · 配置驱动 + 组合复用):
- 顶部一个「输出目标」下拉,选项由 ``output.targets`` 配置驱动(打印/扫描),
默认值取 ``output.default_target``;切换仅显隐对应子视图,不重建。
- 打印子页复用既有 :class:`PrintView`(ESC/P 打印 + 完整表单 + 预览),
扫描子页复用既有 :class:`ScanView`(PIL 渲染),二者逻辑/测试零改动 → 行为零回归。
- 护栏(阶段1 ``run_guard``)、``PrintLog`` 写点(阶段2 ``HistoryBackfillService``)、
历史 diff 闸均为两子视图共享服务,本视图只负责目标切换与信号收敛。
- 信号统一为 ``output_completed(target, payload)``;``PrintView.navigate_to_scan``
(内部「生成扫描件」按钮)在本视图内直接切换目标并加载,不再外抛。
"""
from __future__ import annotations
from typing import Any
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from certflow.config.settings import cfg, get_layout_groups
from certflow.controllers.output_controller import OutputController
from certflow.utils.logger import logger
# 目标 key -> 默认显示标签(配置未提供 label 时回退)
_TARGET_LABELS: dict[str, str] = {
"print": "打印合格证",
"scan": "扫描件生成",
}
[文档]
class OutputView(QWidget):
"""打印/扫描合一的统一输出视图(单 tab)"""
# 统一完成信号:(target, payload) —— target ∈ {"print","scan"}
output_completed = Signal(str, list)
def __init__(
self,
session: Any,
config: dict = None,
db_manager: Any = None,
parent: Any = None,
) -> None:
"""初始化统一输出视图。
Args:
session: SQLAlchemy 会话对象
config: 配置字典
db_manager: 数据库管理器实例(可选)
parent: 父控件(可选)
"""
super().__init__(parent)
self.session = session
self.config = config or {}
self.db_manager = db_manager
self._targets: list[tuple[str, str]] = [] # [(key, label), ...]
self._build_targets()
# §11.8 阶段4:统一输出控制器(聚合 PrinterController/ScanController + 显式 dispatch)
self.output_controller = OutputController(self.session)
# 复刻 PrintView 懒加载的打印机选择逻辑(集中到统一控制器,避免重复)
real = self.output_controller.current_printer
if real:
self.output_controller.printer_controller.set_printer(real)
logger.info(f"[OutputView] 系统默认打印机: {real}")
self._init_ui()
self._connect_signals()
# ============================================================
# 配置驱动:读取 output.targets 决定下拉选项
# ============================================================
def _build_targets(self) -> None:
"""从 ``output.targets`` 构造目标列表;``default_target`` 置顶。"""
targets = cfg("output.targets", {}) or {}
default = cfg("output.default_target", "print")
keys = list(targets.keys())
if default in keys:
keys.remove(default)
keys.insert(0, default)
for key in keys:
label = targets.get(key, {}).get("run", "") # run 非展示用,仅占位
# 优先用内置默认标签,其次取 targets 内可选 label,再回退 key
label = _TARGET_LABELS.get(key, label) or key
self._targets.append((key, label))
# ============================================================
# UI 构建
# ============================================================
def _init_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# 顶部输出目标选择栏
bar = QHBoxLayout()
bar.addWidget(QLabel("输出目标:"))
self.target_combo = QComboBox()
for key, label in self._targets:
self.target_combo.addItem(label, key)
self.target_combo.currentIndexChanged.connect(self._on_target_changed)
bar.addWidget(self.target_combo)
# 常用布局(D):一键套用 target+template+layout 命名组合,
# 选项来自 output.layout_groups(enabled 组),加载即写回下方子视图下拉。
bar.addSpacing(16)
bar.addWidget(QLabel("常用布局:"))
self.layout_group_combo = QComboBox()
self._layout_groups = self._load_layout_groups()
self.layout_group_combo.addItem("— 选择布局 —")
for _g in self._layout_groups:
self.layout_group_combo.addItem(_g.get("name", ""))
self.layout_group_combo.currentIndexChanged.connect(self._on_layout_group_changed)
bar.addWidget(self.layout_group_combo)
bar.addStretch()
layout.addLayout(bar)
# 子视图堆叠:打印 / 扫描
self.stack = QStackedWidget()
from certflow.views.print_view import PrintView
from certflow.views.scan_view import ScanView
self.print_view = PrintView(
self.session,
self.config,
db_manager=self.db_manager,
parent=self,
printer_controller=self.output_controller.printer_controller,
)
self.stack.addWidget(self.print_view)
self.scan_view = ScanView(
self.session,
db_manager=self.db_manager,
parent=self,
scan_controller=self.output_controller.scan_controller,
)
self.stack.addWidget(self.scan_view)
layout.addWidget(self.stack)
# 默认目标
default = cfg("output.default_target", "print")
self.set_target(default)
def _on_target_changed(self, index: int) -> None:
key = self.target_combo.itemData(index)
self._show_target(key)
# ============================================================
# 常用布局(D):一键套用 target+template+layout 组合
# ============================================================
def _load_layout_groups(self) -> list[dict]:
"""读取启用的常用布局组(output.layout_groups,enabled=true)。"""
return get_layout_groups(enabled_only=True)
def _on_layout_group_changed(self, index: int) -> None:
"""常用布局下拉变化 → 套用对应组到子视图下拉。
组 = {target, template(模板族), layout(拼版 label, 仅扫描)};套用后把下拉
回置到占位项,便于重复选择同一布局。
"""
if index <= 0:
return
group = self._layout_groups[index - 1]
target = group.get("target")
if target:
self.set_target(target)
family = group.get("template")
if family:
if target == "scan":
self.scan_view.set_template_by_family(family)
else:
self.print_view._restore_template_by_family(family)
layout = group.get("layout")
if target == "scan" and layout:
self.scan_view.apply_layout_preset(layout)
# 套用完成,回置占位项(不触发本次逻辑,index<=0 直接 return)
self.layout_group_combo.setCurrentIndex(0)
def _show_target(self, key: str) -> None:
if key == "scan":
self.stack.setCurrentWidget(self.scan_view)
else:
self.stack.setCurrentWidget(self.print_view)
# ============================================================
# 对外接口(供 main_window 调用;委托给对应子视图)
# ============================================================
[文档]
def set_target(self, key: str) -> None:
"""切换输出目标(打印/扫描)并更新下拉。
Args:
key: 目标 key("print" / "scan"),未知 key 回退打印。
"""
if key not in {k for k, _ in self._targets}:
key = self._targets[0][0] if self._targets else "print"
idx = self.target_combo.findData(key)
if idx >= 0:
self.target_combo.setCurrentIndex(idx)
else:
self._show_target(key)
[文档]
def current_target(self) -> str:
"""当前输出目标 key。"""
idx = self.target_combo.currentIndex()
return self.target_combo.itemData(idx) or "print"
[文档]
def run_output(self, target: str, action: str = "print", **kwargs: Any) -> Any:
"""统一调度入口:委托 OutputController.run(§11.8 阶段4 显式 dispatch 表)。
Args:
target: "print" / "scan"
action: 目标内动作键(print→print_certificate;scan→generate/load)
**kwargs: 透传给具体控制器方法
"""
return self.output_controller.run(target, action, **kwargs)
[文档]
def load_selected_sale_plan_ids(self, sale_plan_ids: list[int]) -> None:
"""加载销售计划(委托打印子视图)。"""
self.set_target("print")
self.print_view.load_selected_sale_plan_ids(sale_plan_ids)
[文档]
def load_certificates(self, certificate_ids: list[int]) -> None:
"""加载合格证(委托扫描子视图)。"""
self.set_target("scan")
self.scan_view.load_certificates(certificate_ids)
# ============================================================
# 信号收敛
# ============================================================
def _connect_signals(self) -> None:
# 打印完成 → 统一 output_completed("print", ids)
self.print_view.print_completed.connect(
lambda ids: self.output_completed.emit("print", ids)
)
# 内部「生成扫描件」按钮 → 直接切换目标并加载,不外抛
self.print_view.navigate_to_scan.connect(self._on_navigate_to_scan)
# 扫描完成 → 统一 output_completed("scan", ids)
self.scan_view.scan_completed.connect(lambda ids: self.output_completed.emit("scan", ids))
def _on_navigate_to_scan(self, certificate_ids: list[int]) -> None:
"""PrintView 内部「生成扫描件」→ 切换到扫描目标并加载对应合格证。"""
self.set_target("scan")
if certificate_ids:
self.scan_view.load_certificates(certificate_ids)