From e993a184bdeeb9e4bbe1c4bda603c6c9808ddf60 Mon Sep 17 00:00:00 2001 From: DXC Date: Tue, 16 Jun 2026 17:53:35 +0800 Subject: [PATCH] =?UTF-8?q?views/*=20+=20main=5Frouter.py=EF=BC=9A13=20ste?= =?UTF-8?q?p=20=E8=B7=AF=E7=94=B1=E5=A3=B3=EF=BC=88QListWidget+QStackedWid?= =?UTF-8?q?get+TaskWorker=20=E5=90=8E=E5=8F=B0=E6=89=A7=E8=A1=8C=E5=99=A8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/new/main_router.py | 328 ++++++++++++++++++++++++++++++ src/new/views/__init__.py | 5 + src/new/views/placeholder_view.py | 77 +++++++ src/new/views/step1_view.py | 284 ++++++++++++++++++++++++++ 4 files changed, 694 insertions(+) create mode 100644 src/new/main_router.py create mode 100644 src/new/views/__init__.py create mode 100644 src/new/views/placeholder_view.py create mode 100644 src/new/views/step1_view.py diff --git a/src/new/main_router.py b/src/new/main_router.py new file mode 100644 index 0000000..65c7744 --- /dev/null +++ b/src/new/main_router.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +""" +MainRouter —— 端到端模块化新架构的路由与调度壳 +================================================= + +职责 +---- +1. **路由**:根据 ``ROUTES`` 表动态加载每个 step 的 view(前端皮囊) + 和 service(后端大脑),左侧 ``QListWidget`` 导航,右侧 + ``QStackedWidget`` 承载 view。 +2. **解耦通讯**:view 不直接持有 main_router 引用;按钮点击通过 + ``BaseView.dispatch_execute`` 沿父链回到本类 ``run_single_step``。 +3. **后台执行**:内置 ``TaskWorker(QThread)``,把 service 调用丢进 + 子线程,避免阻塞 UI。 +4. **日志** :底部 ``QTextEdit`` 集中输出路由与 service 结果。 + +运行方式 +-------- +:: + + # 方式 A:作为模块运行(推荐) + cd D:/111/office/ZHLduijie/1.WQ/WQ_GUI + python -m src.new.main_router + + # 方式 B:直接脚本运行 + python src/new/main_router.py +""" + +from __future__ import annotations + +import importlib +import os +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_router.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", + }, + # -------- 占位路由(step2-step13 尚未迁移) -------- + {"id": "step2", "name": "2. 数据准备", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step3", "name": "3. 耀斑检测", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step4", "name": "4. 耀斑去除", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step5", "name": "5. 水色指数反演", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step6", "name": "6. 指数波段匹配", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step7", "name": "7. 水质参数计算", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step8", "name": "8. ML 建模训练", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step9", "name": "9. 模型校正", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step10", "name": "10. 浓度反演", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step11", "name": "11. 空间克里金", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step12", "name": "12. 制图渲染", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, + {"id": "step13", "name": "13. 结果导出", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"}, +] + + +# ========================================================================= +# TaskWorker:QThread 后台执行器 +# ========================================================================= +class TaskWorker(QThread): + """后台执行 ``service_func(config)`` 并把结果 emit 回主线程 + + 为什么用 QThread 而不是 Thread:service_func 可能涉及 rasterio / + matplotlib 等原生扩展,主线程与子线程的 Qt 事件循环隔离更稳。 + """ + finished_with_result = pyqtSignal(dict) + + def __init__(self, service_func, config: dict): + super().__init__() + self.service_func = service_func + self.config = config + + def run(self): + try: + result = self.service_func(self.config) + except Exception as e: # noqa: BLE001 + result = { + "status": "error", + "output_path": None, + "message": f"{type(e).__name__}: {e}", + "traceback": traceback.format_exc(), + } + self.finished_with_result.emit(result) + + +# ========================================================================= +# MainRouter:QMainWindow 路由壳 +# ========================================================================= +class MainRouter(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] MainRouter 初始化完成") + self._log(f"[Boot] 已注册 {len(ROUTES)} 条路由(step1 真实 / step2-step13 占位)") + + # ------------------------------------------------------------------ + # 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. 完成后 emit 回主线程并写日志。 + """ + route = self._route_map.get(step_id) + if route is None: + self._log(f"[Router] 未知 step_id: {step_id}") + return + + # 注入 work_dir(仅当 view 未带且 main_router 已配置时) + 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.finished_with_result.connect(self._on_step_done) + 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 完成回调——按 status 分支写日志""" + 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 + self._log(f"[Service✗] {message}(mode={mode or 'n/a'})") + tb = result.get("traceback") + if tb: + self._log(tb) + + # ------------------------------------------------------------------ + # 日志:所有日志统一走这里 + # ------------------------------------------------------------------ + def _log(self, msg: str): + # 控制台 + 底部日志框 + print(msg) + self.log_text.append(msg) + + +# ========================================================================= +# 入口 +# ========================================================================= +def main(): + from PyQt5.QtWidgets import QApplication + app = QApplication(sys.argv) + win = MainRouter() + win.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/new/views/__init__.py b/src/new/views/__init__.py new file mode 100644 index 0000000..7461334 --- /dev/null +++ b/src/new/views/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +"""views —— 独立前端视图包 + +每个 step 对应一个继承 ``BaseView`` 的 PyQt 组件。 +""" \ No newline at end of file diff --git a/src/new/views/placeholder_view.py b/src/new/views/placeholder_view.py new file mode 100644 index 0000000..73bfcc9 --- /dev/null +++ b/src/new/views/placeholder_view.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +""" +PlaceholderView —— 通用占位视图(用于尚未迁移的步骤) + +仅显示 step 名称与"待实现"提示,仍遵循 BaseView 契约: + +* ``get_config()`` 返回空 dict(service 端据此走占位分支) +* 点击"执行"按钮仍会触发 ``dispatch_execute``,便于在 main_router + 中验证路由链路完整通畅 +""" + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QLabel, QPushButton, QVBoxLayout + +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +class PlaceholderView(BaseView): + """占位视图:显示 step_id/标题,绿色按钮触发 dispatch_execute""" + + def __init__(self, step_id: str, title: str, parent=None): + # 先缓存元数据再 super,避免 init_ui 阶段访问不到 + self.step_id = step_id + self.title = title + super().__init__(parent) + + def init_ui(self): + layout = QVBoxLayout() + + header = QLabel(f"🚧 {self.title}({self.step_id})") + header.setAlignment(Qt.AlignCenter) + header.setStyleSheet(""" + QLabel { + font-size: 20px; + font-weight: bold; + color: #555; + padding: 20px; + } + """) + layout.addWidget(header) + + hint = QLabel( + "此步骤的 View 与 Service 尚未迁移到端到端模块化新架构。\n" + "按钮仍可点击以验证 main_router 路由链路完整性。" + ) + hint.setAlignment(Qt.AlignCenter) + hint.setWordWrap(True) + hint.setStyleSheet(""" + QLabel { + color: #0055D4; + font-size: 13px; + background-color: #FFF8DC; + border: 2px dashed #FFA500; + border-radius: 8px; + padding: 16px; + margin: 8px; + } + """) + layout.addWidget(hint) + + self.run_btn = QPushButton(f"执行 {self.title}") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success")) + self.run_btn.clicked.connect(self._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + def _on_run_clicked(self): + self.dispatch_execute(self.step_id, self.get_config()) + + def get_config(self) -> dict: + return {"_placeholder": True, "step_id": self.step_id} + + def set_config(self, config: dict): + return None \ No newline at end of file diff --git a/src/new/views/step1_view.py b/src/new/views/step1_view.py new file mode 100644 index 0000000..cc60e93 --- /dev/null +++ b/src/new/views/step1_view.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +""" +Step1View —— Step 1(水域掩膜生成)的前端视图(端到端模块化) + +设计原则 +======== + +1. **UI 完全移植**:控件、布局、单选样式、信号槽、文件过滤器、阈值参数 + 全部从 ``src/gui/panels/step1_panel.py`` 原样搬迁,业务行为保持一致。 +2. **零业务逻辑**:本文件 **绝不** import 任何 ``src/core/``、``src/services/`` + 算法模块;**绝不** 调用 Pipeline;唯一跨层出口是底部按钮的 + ``self.dispatch_execute("step1", self.get_config())``。 +3. **契约对齐**:继承 ``BaseView``;实现 ``init_ui / get_config / set_config``; + 通过 ``update_work_directory`` 在 NDWI 模式下自动填充输出路径。 + +调用链(自顶向下):: + + Step1View._on_run_clicked + → BaseView.dispatch_execute("step1", config) + → MainRouter.run_single_step(step_id, config) + → TaskWorker → services.step1_service.execute_step1(config) + → 返回结果 dict → MainRouter 日志区 +""" + +import os + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QCheckBox, QDoubleSpinBox, QGroupBox, QHBoxLayout, + QLabel, QPushButton, QRadioButton, QVBoxLayout, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +# QRadioButton 统一样式(实心选中点)——与旧 panel 完全一致 +_RADIO_STYLE = """ + QRadioButton::indicator { + width: 16px; + height: 16px; + border-radius: 8px; + border: 2px solid #999; + } + QRadioButton::indicator:checked { + background-color: #0078D7; + border: 2px solid #0078D7; + } + QRadioButton::indicator:unchecked { + background-color: white; + border: 2px solid #999; + } + QRadioButton::indicator:hover { + border: 2px solid #0078D7; + } +""" + + +class Step1View(BaseView): + """Step 1: 水域掩膜生成 —— 纯 View,所有跨层通讯走 dispatch_execute""" + + # ------------------------------------------------------------------ + # UI 构建(与旧 step1_panel.init_ui 字节级一致,仅按钮文案与回调不同) + # ------------------------------------------------------------------ + def init_ui(self): + layout = QVBoxLayout() + + # ---------- 掩膜生成方式 ---------- + method_group = QGroupBox("掩膜生成方式") + method_layout = QVBoxLayout() + + self.use_existing_radio = QRadioButton("使用现有掩膜文件") + self.use_existing_radio.setChecked(True) + method_layout.addWidget(self.use_existing_radio) + + self.use_ndwi_radio = QRadioButton("使用NDWI自动生成") + method_layout.addWidget(self.use_ndwi_radio) + + self.use_existing_radio.setStyleSheet(_RADIO_STYLE) + self.use_ndwi_radio.setStyleSheet(_RADIO_STYLE) + + method_group.setLayout(method_layout) + layout.addWidget(method_group) + + # ---------- 掩膜文件 ---------- + self.mask_file = FileSelectWidget( + "掩膜文件:", + "Shapefiles (*.shp);;Raster Files (*.dat *.tif);;All Files (*.*)" + ) + layout.addWidget(self.mask_file) + + # ---------- 参考影像 ---------- + self.img_file = FileSelectWidget( + "参考影像:", + "Image Files (*.bsq *.dat *.tif);;All Files (*.*)" + ) + layout.addWidget(self.img_file) + + # ---------- NDWI 参数 ---------- + self.ndwi_group = QGroupBox("NDWI参数设置") + ndwi_layout = QVBoxLayout() + + threshold_layout = QHBoxLayout() + threshold_layout.addWidget(QLabel("NDWI阈值:")) + self.ndwi_threshold = QDoubleSpinBox() + self.ndwi_threshold.setRange(0.0, 1.0) + self.ndwi_threshold.setSingleStep(0.05) + self.ndwi_threshold.setValue(0.4) + self.ndwi_threshold.setDecimals(2) + threshold_layout.addWidget(self.ndwi_threshold) + threshold_layout.addStretch() + ndwi_layout.addLayout(threshold_layout) + + self.ndwi_group.setLayout(ndwi_layout) + layout.addWidget(self.ndwi_group) + + # ---------- 输出掩膜路径(save 模式) ---------- + self.output_file = FileSelectWidget( + "输出掩膜:", + "Mask Files (*.dat *.tif);;All Files (*.*)", + mode="save", + ) + self.output_file.line_edit.setPlaceholderText("water_mask.dat") + layout.addWidget(self.output_file) + + # ---------- 提示信息(Info Alert 样式) ---------- + hint = QLabel( + "💡 提示: 如果掩膜文件是Shapefile(.shp),需要提供参考影像用于栅格化;" + "如果使用NDWI自动生成,只需要提供参考影像" + ) + hint.setWordWrap(True) + hint.setStyleSheet(""" + QLabel { + color: #0055D4; + font-size: 13px; + font-weight: bold; + background-color: #E8F4FF; + border: 2px solid #0055D4; + border-radius: 8px; + padding: 12px 16px; + margin: 8px 0px; + } + """) + layout.addWidget(hint) + + # ---------- 启用步骤 ---------- + self.enable_checkbox = QCheckBox("启用此步骤") + self.enable_checkbox.setChecked(True) + layout.addWidget(self.enable_checkbox) + + # ---------- 执行按钮(绿色 success 样式,仅作视图层入口) ---------- + self.run_btn = QPushButton("执行 Step 1: 水域掩膜") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success")) + self.run_btn.clicked.connect(self._on_run_clicked) + layout.addWidget(self.run_btn) + + # ---------- 信号联动 ---------- + self.use_existing_radio.toggled.connect(self.update_ui_state) + self.use_ndwi_radio.toggled.connect(self.update_ui_state) + + layout.addStretch() + self.setLayout(layout) + + # 初始 UI 状态 + self.update_ui_state() + + # ------------------------------------------------------------------ + # UI 状态联动(与旧 panel.update_ui_state 同语义) + # ------------------------------------------------------------------ + def update_ui_state(self): + """根据单选状态显示/隐藏 NDWI 参数与输出路径""" + use_ndwi = self.use_ndwi_radio.isChecked() + + if use_ndwi: + self.mask_file.setVisible(False) + self.ndwi_group.setVisible(True) + self.output_file.setVisible(True) + + # 主窗口已推送过 work_dir 才填,避免 init_ui 阶段空指针 + if self.work_dir: + self._auto_fill_output_path() + else: + self.mask_file.setVisible(True) + self.ndwi_group.setVisible(False) + self.output_file.setVisible(False) + + # 参考影像在两种模式下都显示 + self.img_file.setVisible(True) + + # ------------------------------------------------------------------ + # 工作目录推送——主窗口变更 work_dir 时调用 + # ------------------------------------------------------------------ + def update_work_directory(self, work_dir): + """主窗口推送工作目录变更;NDWI 模式下自动填充输出路径 + + 重写基类以叠加"自动填路径"行为,再 super 缓存保持一致。 + """ + super().update_work_directory(work_dir) + + if work_dir and self.use_ndwi_radio.isChecked(): + self._auto_fill_output_path() + + def _auto_fill_output_path(self): + """NDWI 模式下根据 work_dir 推导默认输出路径(统一正斜杠) + + view 层内联的轻量推导——不依赖旧 ``_step_path_resolver``: + output_dir = work_dir/1_water_mask/ + default_output = output_dir/water_mask_out.dat + """ + if not self.work_dir: + return + + output_dir = os.path.join(self.work_dir, "1_water_mask") + os.makedirs(output_dir, exist_ok=True) + + default_output_path = os.path.join(output_dir, "water_mask_out.dat").replace("\\", "/") + self.output_file.set_path(default_output_path) + + # ------------------------------------------------------------------ + # BaseView 契约:get_config / set_config + # ------------------------------------------------------------------ + def get_config(self) -> dict: + """读取当前 UI 状态——返回干净字典供 dispatch_execute 携带 + + 字段: + mask_path : str | None 现有掩膜文件路径;NDWI 模式下为 None + use_ndwi : bool 是否走 NDWI 自动生成 + ndwi_threshold : float NDWI 阈值 + img_path : str 参考影像路径(非空才包含) + output_path : str | None NDWI 模式下的输出路径;现有掩膜模式下为 None + """ + use_ndwi = self.use_ndwi_radio.isChecked() + + config = { + "mask_path": None if use_ndwi else self.mask_file.get_path(), + "use_ndwi": use_ndwi, + "ndwi_threshold": self.ndwi_threshold.value(), + } + + # 参考影像:非空才写入,避免下游合并时被空字符串覆盖 + img_path = self.img_file.get_path() + if img_path: + config["img_path"] = img_path + + if use_ndwi: + output_path = self.output_file.get_path() + if output_path: + config["output_path"] = output_path + else: + # 现有掩膜模式下不传递 output_path,避免底层错误尝试保存文件 + config["output_path"] = None + + return config + + def set_config(self, config: dict): + """根据 config 恢复 UI 状态——None / 空值字段一律跳过""" + if config.get("mask_path"): + self.mask_file.set_path(config["mask_path"]) + if config.get("img_path"): + self.img_file.set_path(config["img_path"]) + if config.get("output_path"): + self.output_file.set_path(config["output_path"]) + if "use_ndwi" in config: + if config["use_ndwi"]: + self.use_ndwi_radio.setChecked(True) + else: + self.use_existing_radio.setChecked(True) + if "ndwi_threshold" in config: + self.ndwi_threshold.setValue(config["ndwi_threshold"]) + + self.update_ui_state() + + # ------------------------------------------------------------------ + # 执行入口:仅触发 dispatch_execute——无任何业务校验/算法调用 + # ------------------------------------------------------------------ + def _on_run_clicked(self): + """绿色按钮:纯 View 层入口,把当前 UI 配置打包交给主窗口 + + 所有跨层通讯一律走 ``dispatch_execute``: + + Step1View → 沿父链上溯 → MainRouter.run_single_step(step_id, config) + """ + self.dispatch_execute("step1", self.get_config()) \ No newline at end of file