# -*- 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 os import sys import traceback from pathlib import Path from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtGui import QIcon, QPixmap from PyQt5.QtWidgets import ( QFileDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, 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 _PROJECT_ROOT = _SRC_ROOT.parent # .../WQ_GUI(项目根,用于解析 data/icons/) if str(_SRC_ROOT) not in sys.path: sys.path.insert(0, str(_SRC_ROOT)) def _res(rel: str) -> str: """解析项目根的相对路径,PyInstaller 打包后兼容 sys._MEIPASS。""" if hasattr(sys, "_MEIPASS"): return os.path.join(sys._MEIPASS, rel) return str(_PROJECT_ROOT / rel) from src.gui.styles import ModernStylesheet from src.new.views.placeholder_view import PlaceholderView # ========================================================================= # 路由注册表:每条路由对应一对 view + service # ========================================================================= ROUTES = [ { "id": "step1", "name": "1. 水域掩膜", "icon": "1.png", "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. 耀斑检测", "icon": "2.png", "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. 耀斑去除", "icon": "3.png", "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. 采样点布设", "icon": "4.png", "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. 数据清洗", "icon": "5.png", "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. 光谱特征", "icon": "6.png", "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. 水质光谱指数", "icon": "7.png", "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. 机器学习建模", "icon": "8.png", "view_module": "src.new.views.step8_view", "view_class": "Step8View", "service_module": "src.new.services.step8_service", "service_func": "execute_step8", }, { # 旧版共用 10.png 是 bug;data/icons/ 里有 11.png 一直未引用,正好给"预测"用 "id": "step9", "name": "9. 机器学习预测", "icon": "11.png", "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. 水色指数反演", "icon": "10.png", "view_module": "src.new.views.step10_view", "view_class": "Step10View", "service_module": "src.new.services.step10_service", "service_func": "execute_step10", }, { # data/icons/ 没有 12.png/13.png;Step11/12/13 暂时复用 9.png(11 个 png 对 13 个 step 必然有共用) "id": "step11", "name": "11. 专题图生成", "icon": "9.png", "view_module": "src.new.views.step11_view", "view_class": "Step11View", "service_module": "src.new.services.step11_service", "service_func": "execute_step11", }, { "id": "step12", "name": "12. 可视化", "icon": "9.png", "view_module": "src.new.views.step12_view", "view_class": "Step12View", "service_module": "src.new.services.step12_service", "service_func": "execute_step12", }, { "id": "step13", "name": "13. 报告生成", "icon": "9.png", "view_module": "src.new.views.step13_view", "view_class": "Step13View", "service_module": "src.new.services.step13_service", "service_func": "execute_step13", }, ] # ========================================================================= # 全局输入参数:用户在任意 step 导图后,广播给所有 view 自动填充 # ========================================================================= # 这些是各 view 关心的"上游产物 / 用户导入文件"key。任何一次 run_single_step # 都会把 config_dict 中出现的非空值更新进 self.global_inputs,并全量广播。 # view.set_config 已实现"非空才覆盖"(避免回填空串覆盖上游有效值), # 所以直接全量广播是安全的。 GLOBAL_INPUT_KEYS: tuple[str, ...] = ( # step1 入口 "img_path", # 原始 BSQ/DAT/TIF 影像 "mask_path", # 现有水域掩膜(shp / dat / tif) # step2/3 输入 "water_mask_path", # step1 产出 # step3/4/6/10 输入 "deglint_img_path", # step3 产出 "bsq_path", # step10 用,等价 deglint_img_path "hdr_path", # ENVI 头文件 # step4 输入 "glint_mask_path", # step2 耀斑掩膜 # step5/6 输入 "csv_path", # step5 产出 "boundary_path", # step6 水域掩膜别名 # step7/8 输入 "training_csv_path", # step6 产出 # step9 输入 "sampling_csv_path", # step4 产出 "models_dir", # step8 产出 # step10 输入 "formula_csv_path", # waterindex.csv # step11 输入 "prediction_csv_path", "prediction_csv_dir", "geotiff_path", "geotiff_dir", ) # ========================================================================= # 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 # 各 step 的产出路径缓存(声明式路径流转的中枢) self.step_outputs: dict = {} # 全局输入参数广播:用户在任何 step 导图后,其他 step 自动同步 self.global_inputs: dict = {} self.setWindowTitle("WQ_GUI 新架构 · 端到端模块化路由壳") self.resize(1280, 800) self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) # 设置窗口图标(旧版 water_quality_gui.py L1339-1340 用 uitubiao.ico) ico_path = _res("data/icons-1/uitubiao.ico") if Path(ico_path).is_file(): self.setWindowIcon(QIcon(ico_path)) self._build_ui() self._build_routes() self._log("[Boot] MainView 初始化完成") self._log(f"[Boot] 已注册 {len(ROUTES)} 条路由(13 个 view 和 service 已全部打通真实链路!)") # ------------------------------------------------------------------ # UI 布局:左侧导航 + 右侧 stacked + 底部日志 # ------------------------------------------------------------------ def _build_ui(self): central = QWidget() self.setCentralWidget(central) outer = QVBoxLayout(central) outer.setContentsMargins(8, 8, 8, 8) # ----- 顶部工具条:logo + 项目名 + 工作目录 + 设置按钮 ----- toolbar = QHBoxLayout() # Logo(旧版 water_quality_gui.py L1569 用 logo.png) logo_path = _res("data/icons/logo.png") if Path(logo_path).is_file(): logo_pixmap = QPixmap(logo_path) if not logo_pixmap.isNull(): logo_pixmap = logo_pixmap.scaledToHeight( 36, Qt.SmoothTransformation ) logo_label = QLabel() logo_label.setPixmap(logo_pixmap) toolbar.addWidget(logo_label) # 项目名(紧贴 logo 右侧) title_label = QLabel("WQ_GUI · 新架构") title_label.setStyleSheet( "font-size: 14px; font-weight: bold; color: #0055D4;" ) toolbar.addWidget(title_label) toolbar.addSpacing(20) 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) # ----- 顶部 MegaCube Banner(CSS border-image 平铺拉伸 + 居中文字) ----- banner_label = QLabel() banner_label.setMinimumHeight(100) banner_label.setMaximumHeight(100) banner_label.setAlignment(Qt.AlignCenter) # 无条件显示文字(border-image 不会覆盖前景文字) banner_label.setText("MegaCube-Water Quality V1.2") banner_path = _res("data/icons/Mega Water 1.0.jpg").replace('\\', '/') if Path(banner_path).is_file(): banner_label.setStyleSheet( f"border-image: url('{banner_path}') 0 0 0 0 stretch stretch;" "color: white; font-size: 38px; font-weight: bold; font-family: 'Microsoft YaHei';" ) else: banner_label.setStyleSheet( "background: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0, " "stop:0 #00a0e9, stop:1 #005a9e);" "color: white; font-size: 38px; font-weight: bold; font-family: 'Microsoft YaHei';" ) outer.addWidget(banner_label) # ----- 中部:左导航 + 右 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) # 加载图标(ROUTES[icon] 字段);找不到图标时退化为无图标 item icon_filename = route.get("icon") if icon_filename: icon_full_path = _res(f"data/icons/{icon_filename}") if Path(icon_full_path).is_file(): item = QListWidgetItem(QIcon(icon_full_path), route["name"]) else: self._log( f"[Router] 图标缺失 {icon_full_path},{route['id']} 用纯文字" ) item = QListWidgetItem(route["name"]) else: item = QListWidgetItem(route["name"]) # 统一图标尺寸(避免不同 png 拉伸不一致) item.setSizeHint(item.sizeHint()) self.nav_list.addItem(item) 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}") # 工作目录变更 → 清空全局输入缓存(旧路径可能已失效) if self.global_inputs: self.clear_global_inputs() # 推送给所有 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. **全局输入广播**——把本次 config_dict 中的公共输入 key 累计到 ``self.global_inputs``,再推送给所有 view(实现"一处导入,全局共享"); 4. 启动 ``TaskWorker`` 在子线程运行 service; 5. 三信号路由: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} # 全局输入广播:累计 + 推送给所有 view self._broadcast_global_inputs(config_dict) 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) # 用 lambda 注入 step_id:finished/error 都拿得到自己服务的是哪一步 self.worker.finished.connect( lambda result, sid=step_id: self._on_step_done(sid, result) ) self.worker.error.connect( lambda msg, sid=step_id: self._on_step_error(sid, msg) ) 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, step_id: str, result: dict): """TaskWorker finished 回调——按 status 分支写日志 1. 缓存 output_path 到 self.step_outputs; 2. 触发 _sync_dependencies 把产出路径推给所有下游 view; 3. status=completed → QMessageBox.information;status=error → warning。 注: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", "") # 1. 缓存路径 + 触发声明式下游推送(即使 error 也要缓存,让用户看到已失败) if output_path: self.step_outputs[step_id] = str(output_path) self._log(f"[Cache] {step_id} → step_outputs['{step_id}'] = {output_path}") self._sync_dependencies() else: self._log(f"[Cache] {step_id} 无 output_path,跳过 step_outputs 缓存") # 2. 日志分支 + 弹窗提示 if status == "completed": self._log(f"[Service✓] {message}(mode={mode}, output={output_path})") QMessageBox.information( self, "执行完成", f"{message or '当前步骤已完成!'}\n\n产出路径:\n{output_path or '(无)'}", ) 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) QMessageBox.warning( self, "执行失败", f"{message or '当前步骤执行失败'}\n\n请查看底部日志获取 traceback。", ) def _on_step_error(self, step_id: str, msg: str): """TaskWorker error 信号回调——worker 自身捕获的未预期异常 worker 自身崩溃(service_func 抛出未捕获异常)走这里; 与 finished 中 status='error' 语义不同:error 信号表示 worker 完全无法产出结果 dict。 """ self._log(f"[Worker✗] {step_id} 未预期异常:\n{msg}") QMessageBox.critical( self, "Worker 异常", f"步骤 {step_id} 后台执行崩溃:\n\n{msg[:500]}", ) # ------------------------------------------------------------------ # 声明式路径流转:用 set_config 把上游产出推给所有下游 view # ------------------------------------------------------------------ def _sync_dependencies(self): """遍历 self.step_outputs 字典,按依赖图把每步产出推给下游""" # ----- step1 → step2/3/4 (water_mask_path) 及 step6 (boundary_path) ----- s1 = self.step_outputs.get("step1") if s1: for sid in ("step2", "step3", "step4"): self._safe_set_config(sid, {"water_mask_path": s1}) # 补齐 step1 到 step6 的水体掩膜链路 self._safe_set_config("step6", {"boundary_path": s1}) # ----- step2 → step6 (glint_mask_path) ----- s2 = self.step_outputs.get("step2") if s2: # 补齐 step2 到 step6 的耀斑掩膜链路 self._safe_set_config("step6", {"glint_mask_path": s2}) # ----- step3 → step4/6 (deglint_img_path) / step10 (bsq_path) ----- s3 = self.step_outputs.get("step3") if s3: self._safe_set_config("step4", {"deglint_img_path": s3}) self._safe_set_config("step6", {"deglint_img_path": s3}) self._safe_set_config("step10", {"bsq_path": s3}) # ----- step4 → step9 (sampling_csv_path) ----- s4 = self.step_outputs.get("step4") if s4: self._safe_set_config("step9", {"sampling_csv_path": s4}) # ----- step5 → step6 (csv_path) ----- s5 = self.step_outputs.get("step5") if s5: self._safe_set_config("step6", {"csv_path": s5}) # ----- step6 → step7/step8 (training_csv_path) ----- s6 = self.step_outputs.get("step6") if s6: self._safe_set_config("step7", {"training_csv_path": s6}) self._safe_set_config("step8", {"training_csv_path": s6}) # ----- step8 → step9 (models_dir: 父目录) ----- s8 = self.step_outputs.get("step8") if s8: s8_dir = str(Path(s8).parent).replace("\\", "/") self._safe_set_config("step9", {"models_dir": s8_dir}) # ----- step9 → step11 (prediction_csv_dir / prediction_csv_path) ----- s9 = self.step_outputs.get("step9") if s9: s9_dir = str(Path(s9).parent).replace("\\", "/") self._safe_set_config("step11", {"prediction_csv_dir": s9_dir}) self._safe_set_config("step11", {"prediction_csv_path": s9}) # ----- step10 → step11 (geotiff_dir / geotiff_path) ----- s10 = self.step_outputs.get("step10") if s10: s10_dir = str(Path(s10).parent).replace("\\", "/") self._safe_set_config("step11", {"geotiff_dir": s10_dir}) self._safe_set_config("step11", {"geotiff_path": s10}) self._log(f"[Sync] 全局依赖同步完成(step_outputs keys: {list(self.step_outputs.keys())})") def _safe_set_config(self, step_id: str, config: dict): """安全调用 view.set_config;view 缺失 / 无该方法 / 抛异常时降级日志 返回 True 表示推送成功,False 表示降级跳过,便于上层做计数。 """ view = self._views.get(step_id) if view is None: return False if not hasattr(view, "set_config"): self._log(f"[Sync] {step_id} 没有 set_config,跳过推送 {list(config.keys())}") return False try: view.set_config(config) self._log(f"[Sync] → {step_id}.set_config({config})") return True except Exception as e: # noqa: BLE001 —— 单步推送失败不影响整体 sync self._log(f"[Sync] 推送 {step_id} 失败: {e}") traceback.print_exc() return False # ------------------------------------------------------------------ # 全局输入参数广播:用户在一处导图 → 所有 view 自动同步 # ------------------------------------------------------------------ def _broadcast_global_inputs(self, config_dict: dict): """把本次 config_dict 中的公共输入 key 累计到 self.global_inputs, 再把整个 global_inputs 推给所有 view(实现"一处导入,全局共享")。 设计要点: 1. **非空才覆盖**——空字符串/None 不会污染已存在的有效值(防止下游 view 用空串覆盖掉上游刚刚推送的有效路径)。 2. **全量广播是安全的**——各 view.set_config 已实现 "if key in config" 守卫,未识别的 key 直接忽略,互不干扰。 3. **顺序无关**——多次 run_single_step 都会持续累计 self.global_inputs, 直到用户主动切换工作目录或清理。 """ updated_keys: list[str] = [] for key in GLOBAL_INPUT_KEYS: val = config_dict.get(key) if val: # 非空才覆盖(None / "" 一律跳过) self.global_inputs[key] = val updated_keys.append(key) if updated_keys: self._log( f"[Global] 本次更新 {len(updated_keys)} 个全局输入: {updated_keys}" ) # 推送给所有 view if not self._views: return n_ok, n_fail = 0, 0 for step_id, view in self._views.items(): if not hasattr(view, "set_config"): continue try: view.set_config(self.global_inputs) n_ok += 1 except Exception as e: # noqa: BLE001 —— 单 view 失败不影响其它 n_fail += 1 self._log(f"[Global] {step_id} 推送失败: {e}") self._log( f"[Global] 广播完成: ok={n_ok} fail={n_fail} " f"keys={list(self.global_inputs.keys())}" ) def clear_global_inputs(self): """清空全局输入缓存(工作目录切换 / 用户重置时调用)""" self.global_inputs.clear() self._log("[Global] 已清空全局输入缓存") # ------------------------------------------------------------------ # 日志:所有日志统一走这里 # ------------------------------------------------------------------ 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()