# -*- coding: utf-8 -*- """ MainView —— 端到端模块化新架构的路由与调度壳(主窗口) ========================================================= 职责 ---- 1. **路由**:根据 ``ROUTES`` 表动态加载每个 step 的 view(前端皮囊) 和 service(后端大脑),左侧 ``QListWidget`` 导航,右侧 ``QStackedWidget`` 承载 view。 2. **解耦通讯**:view 不直接持有 main_view 引用;按钮点击通过 ``BaseView.dispatch_execute`` 沿父链回到本类 ``run_single_step``。 3. **后台执行**:内置 ``TaskWorker(QThread)``(三信号协议: ``log_msg / finished / error``),把 service 调用丢进子线程, 避免阻塞 UI。 4. **日志** :底部 ``QTextEdit`` 集中输出路由与 service 结果。 本文件由原 ``src/new/main_router.py`` 迁移而来,类名 ``MainRouter`` 改名为 ``MainView`` 以贴合"视图壳"语义,同时按用户原意把 ``TaskWorker`` 从单信号 (``finished_with_result``)升级为三信号协议。 运行方式 -------- :: # 方式 A:作为模块运行(推荐) cd D:/111/office/ZHLduijie/1.WQ/WQ_GUI python -m src.new.main_view # 方式 B:直接脚本运行 python src/new/main_view.py """ from __future__ import annotations import importlib import sys import traceback from pathlib import Path from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtWidgets import ( QFileDialog, QHBoxLayout, QLabel, QListWidget, QMainWindow, QPushButton, QSplitter, QStackedWidget, QTextEdit, QVBoxLayout, QWidget, ) # 让 ``python src/new/main_view.py`` 直接运行时也能 import ``src.xxx`` _HERE = Path(__file__).resolve().parent # .../src/new _SRC_ROOT = _HERE.parent # .../src if str(_SRC_ROOT) not in sys.path: sys.path.insert(0, str(_SRC_ROOT)) from src.gui.styles import ModernStylesheet from src.new.views.placeholder_view import PlaceholderView # ========================================================================= # 路由注册表:每条路由对应一对 view + service # ========================================================================= ROUTES = [ { "id": "step1", "name": "1. 水域掩膜", "view_module": "src.new.views.step1_view", "view_class": "Step1View", "service_module": "src.new.services.step1_service", "service_func": "execute_step1", }, # -------- 业务路由(前 4 个预处理步骤已迁移到真实 service) -------- { "id": "step2", "name": "2. 耀斑检测", "view_module": "src.new.views.step2_view", "view_class": "Step2View", "service_module": "src.new.services.step2_service", "service_func": "execute_step2", }, { "id": "step3", "name": "3. 耀斑去除", "view_module": "src.new.views.step3_view", "view_class": "Step3View", "service_module": "src.new.services.step3_service", "service_func": "execute_step3", }, { "id": "step4", "name": "4. 采样点布设", "view_module": "src.new.views.step4_view", "view_class": "Step4View", "service_module": "src.new.services.step4_service", "service_func": "execute_step4", }, { "id": "step5", "name": "5. 数据清洗", "view_module": "src.new.views.step5_view", "view_class": "Step5View", "service_module": "src.new.services.step5_service", "service_func": "execute_step5", }, { "id": "step6", "name": "6. 光谱特征", "view_module": "src.new.views.step6_view", "view_class": "Step6View", "service_module": "src.new.services.step6_service", "service_func": "execute_step6", }, { "id": "step7", "name": "7. 水质光谱指数", "view_module": "src.new.views.step7_view", "view_class": "Step7View", "service_module": "src.new.services.step7_service", "service_func": "execute_step7", }, { "id": "step8", "name": "8. 机器学习建模", "view_module": "src.new.views.step8_view", "view_class": "Step8View", "service_module": "src.new.services.step8_service", "service_func": "execute_step8", }, { "id": "step9", "name": "9. 机器学习预测", "view_module": "src.new.views.step9_view", "view_class": "Step9View", "service_module": "src.new.services.step9_service", "service_func": "execute_step9", }, { "id": "step10", "name": "10. 水色指数反演", "view_module": "src.new.views.step10_view", "view_class": "Step10View", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder", }, { "id": "step11", "name": "11. 专题图生成", "view_module": "src.new.views.step11_view", "view_class": "Step11View", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder", }, { "id": "step12", "name": "12. 可视化", "view_module": "src.new.views.step12_view", "view_class": "Step12View", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder", }, { "id": "step13", "name": "13. 报告生成", "view_module": "src.new.views.step13_view", "view_class": "Step13View", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder", }, ] # ========================================================================= # TaskWorker:QThread 后台执行器(三信号协议) # ========================================================================= class TaskWorker(QThread): """后台执行 ``service_func(config)`` 并通过三信号协议回报主线程 信号协议 -------- * ``log_msg(str)`` —— 启动 / 完成 / 异常等关键节点的人类可读日志 * ``finished(dict)`` —— service 正常返回(含 service 内部 try/except 转成的 error 状态 dict) * ``error(str)`` —— worker 自身捕获的未预期异常(含 traceback), 与 finished 中的 status='error' 语义不同:error 表示 worker 完全崩溃,无法产出任何结果 dict。 为什么用 QThread 而不是 Thread:service_func 可能涉及 rasterio / matplotlib 等原生扩展,主线程与子线程的 Qt 事件循环隔离更稳。 """ log_msg = pyqtSignal(str) finished = pyqtSignal(dict) error = pyqtSignal(str) def __init__(self, service_func, config: dict): super().__init__() self.service_func = service_func self.config = config def run(self): func_name = getattr(self.service_func, "__name__", "") self.log_msg.emit(f"[Worker] 启动 {func_name}") try: result = self.service_func(self.config) except Exception as e: # noqa: BLE001 —— worker 兜底捕获所有未预期异常 tb = traceback.format_exc() self.log_msg.emit(f"[Worker] 异常 {type(e).__name__}: {e}") self.error.emit(f"{type(e).__name__}: {e}\n{tb}") return status = result.get("status", "unknown") if isinstance(result, dict) else "unknown" self.log_msg.emit(f"[Worker] 完成,status={status}") self.finished.emit(result) # ========================================================================= # MainView:QMainWindow 路由壳 # ========================================================================= class MainView(QMainWindow): """新架构的轻量路由主窗口""" def __init__(self): super().__init__() self.work_dir: str = "" self._views: dict = {} self._route_map: dict = {} self.worker: TaskWorker | None = None self.setWindowTitle("WQ_GUI 新架构 · 端到端模块化路由壳") self.resize(1280, 800) self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) self._build_ui() self._build_routes() self._log("[Boot] MainView 初始化完成") self._log(f"[Boot] 已注册 {len(ROUTES)} 条路由(13 个 view 全部真实,前 9 个步骤 service 已迁移)") # ------------------------------------------------------------------ # UI 布局:左侧导航 + 右侧 stacked + 底部日志 # ------------------------------------------------------------------ def _build_ui(self): central = QWidget() self.setCentralWidget(central) outer = QVBoxLayout(central) outer.setContentsMargins(8, 8, 8, 8) # ----- 顶部工具条:工作目录 + 设置按钮 ----- toolbar = QHBoxLayout() toolbar.addWidget(QLabel("工作目录:")) self.work_dir_label = QLabel("(未设置)") self.work_dir_label.setStyleSheet("color: #0055D4; font-weight: bold;") toolbar.addWidget(self.work_dir_label, 1) self.set_workdir_btn = QPushButton("设置工作目录...") self.set_workdir_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("normal")) self.set_workdir_btn.clicked.connect(self._on_set_workdir) toolbar.addWidget(self.set_workdir_btn) outer.addLayout(toolbar) # ----- 中部:左导航 + 右 stacked(用 QSplitter 可拖动) ----- splitter = QSplitter(Qt.Horizontal) self.nav_list = QListWidget() self.nav_list.setStyleSheet(ModernStylesheet.get_sidebar_stylesheet()) self.nav_list.setFixedWidth(200) self.nav_list.currentRowChanged.connect(self._on_nav_changed) splitter.addWidget(self.nav_list) self.stacked = QStackedWidget() splitter.addWidget(self.stacked) splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 1) outer.addWidget(splitter, 1) # ----- 底部日志 ----- self.log_text = QTextEdit() self.log_text.setReadOnly(True) self.log_text.setStyleSheet("background-color: #1E1E1E; color: #DCDCDC; font-family: Consolas, monospace;") self.log_text.setFixedHeight(180) outer.addWidget(self.log_text) # ------------------------------------------------------------------ # 路由注册:实例化 view、填充 nav_list 与 stacked # ------------------------------------------------------------------ def _build_routes(self): for route in ROUTES: self._route_map[route["id"]] = route view = self._instantiate_view(route) self._views[route["id"]] = view self.stacked.addWidget(view) self.nav_list.addItem(route["name"]) if ROUTES: self.nav_list.setCurrentRow(0) def _instantiate_view(self, route: dict) -> QWidget: """根据路由表实例化 view:step1 走真实类,其余走 PlaceholderView""" if route["view_module"] != "_placeholder": try: module = importlib.import_module(route["view_module"]) cls = getattr(module, route["view_class"]) return cls(self.stacked) except Exception as e: # noqa: BLE001 # 真实 view 加载失败时降级为占位,并在日志区报错 self._log(f"[Router] 加载真实 view 失败 ({route['id']}): {e}") traceback.print_exc() # 占位 view:直接构造 return PlaceholderView(route["id"], route["name"], self.stacked) # ------------------------------------------------------------------ # 导航切换 # ------------------------------------------------------------------ def _on_nav_changed(self, row: int): if row < 0 or row >= len(ROUTES): return route = ROUTES[row] view = self._views[route["id"]] self.stacked.setCurrentWidget(view) self._log(f"[Nav] 切换到 {route['id']} → {route['name']}") # ------------------------------------------------------------------ # 工作目录推送:设置后广播给所有 view # ------------------------------------------------------------------ def _on_set_workdir(self): d = QFileDialog.getExistingDirectory(self, "选择工作目录", self.work_dir or "") if not d: return self.set_work_dir(d) def set_work_dir(self, work_dir: str): """设置工作目录并广播给所有 view""" if not work_dir: return self.work_dir = work_dir.replace("\\", "/") self.work_dir_label.setText(self.work_dir) self._log(f"[Router] 工作目录已设置: {self.work_dir}") # 推送给所有 view(让它们按需自动填充路径) for view in self._views.values(): if hasattr(view, "update_from_config"): view.update_from_config(self.work_dir, pipeline=None) # ------------------------------------------------------------------ # 核心调度:run_single_step(被 view.dispatch_execute 调用) # ------------------------------------------------------------------ def run_single_step(self, step_id: str, config_dict: dict): """从 view 接收 ``(step_id, config)`` → 调度 service 后台执行 1. 查路由表; 2. 注入 ``work_dir``(避免 view 重复感知); 3. 启动 ``TaskWorker`` 在子线程运行 service; 4. 三信号路由:log_msg → 日志框;finished → 状态分支日志; error → 未预期异常写日志 + traceback。 """ route = self._route_map.get(step_id) if route is None: self._log(f"[Router] 未知 step_id: {step_id}") return # 注入 work_dir(仅当 view 未带且 main_view 已配置时) if self.work_dir and "work_dir" not in config_dict: config_dict = {**config_dict, "work_dir": self.work_dir} service_func = self._load_callable(route["service_module"], route["service_func"]) if service_func is None: return self._log(f"[Router] 收到 {step_id} 请求,配置: {config_dict}") # 启动后台线程(覆盖旧 worker 防泄漏) if self.worker and self.worker.isRunning(): self._log("[Router] 上一个任务尚未结束,丢弃新请求") return self.worker = TaskWorker(service_func, config_dict) self.worker.log_msg.connect(self._log) self.worker.finished.connect(self._on_step_done) self.worker.error.connect(self._on_step_error) self.worker.start() def _load_callable(self, module_name: str, attr_name: str): """动态加载 ``module_name.attr_name``,失败时写日志并返回 None""" try: module = importlib.import_module(module_name) return getattr(module, attr_name) except Exception as e: # noqa: BLE001 self._log(f"[Router] 加载 service 失败 ({module_name}.{attr_name}): {e}") traceback.print_exc() return None def _on_step_done(self, result: dict): """TaskWorker finished 回调——按 status 分支写日志 注:service 内部 try/except 转换的 status='error' 也走这里; 这与 error 信号(worker 自身崩溃)语义不同。 """ status = result.get("status", "unknown") message = result.get("message", "") output_path = result.get("output_path") mode = result.get("mode", "") if status == "completed": self._log(f"[Service✓] {message}(mode={mode}, output={output_path})") elif status == "skipped": self._log(f"[Service↻] {message}(已复用历史结果)") elif status == "not_implemented": self._log(f"[Service…] {message}") else: # error(service 内部已转 dict) self._log(f"[Service✗] {message}(mode={mode or 'n/a'})") tb = result.get("traceback") if tb: self._log(tb) def _on_step_error(self, msg: str): """TaskWorker error 信号回调——worker 自身捕获的未预期异常""" self._log(f"[Worker✗] 未预期异常:\n{msg}") # ------------------------------------------------------------------ # 日志:所有日志统一走这里 # ------------------------------------------------------------------ def _log(self, msg: str): # 控制台 + 底部日志框 print(msg) self.log_text.append(msg) # ========================================================================= # 入口 # ========================================================================= def main(): from PyQt5.QtWidgets import QApplication app = QApplication(sys.argv) win = MainView() win.show() sys.exit(app.exec_()) if __name__ == "__main__": main()