路由壳升级:TaskWorker 三信号 + main_router→main_view 迁移(54/54 smoke 通过)

This commit is contained in:
DXC
2026-06-16 18:23:38 +08:00
parent bd4263d2ca
commit 61bd8582e5
8 changed files with 86 additions and 55 deletions

View File

@ -16,7 +16,7 @@ src/new/
│ ├── __init__.py
│ ├── step1_view.py # Step 1 真实视图(继承 BaseView
│ └── placeholder_view.py # step2-step13 占位视图
└── main_router.py # 路由与调度壳QMainWindow + QThread
└── main_view.py # 路由与调度壳QMainWindow + QThread
```
## 端到端调用链
@ -28,13 +28,13 @@ Step1View._on_run_clicked (绿色按钮)
BaseView.dispatch_execute (沿父链上溯)
│ ancestor.run_single_step(step_id, config)
MainRouter.run_single_step (查 ROUTES 表 → 注入 work_dir)
MainView.run_single_step (查 ROUTES 表 → 注入 work_dir)
│ TaskWorker(service_func, config).start()
services.step1_service.execute_step1(config)
│ 调 WaterMaskStep.run(...) → 包装成 dict 返回
MainRouter._on_step_done (按 status 写日志)
MainView._on_step_done (按 status 写日志)
```
## 运行验证
@ -52,13 +52,13 @@ python _smoke_new_arch.py
```cmd
cd D:\111\office\ZHLduijie\1.WQ\WQ_GUI
python -m src.new.main_router
python -m src.new.main_view
```
或:
```cmd
python src\new\main_router.py
python src\new\main_view.py
```
启动后:

View File

@ -3,8 +3,8 @@
三层冒烟端到端模块化新架构src/new/
Layer 1 service 单元execute_step1 在各种错误路径返回正确 dict
Layer 2 view offscreenStep1View UI 行为符合契约(不依赖 main_router
Layer 3 end-to-endMainRouter 创建 → 切到 step1 → 点击 → 日志验证
Layer 2 view offscreenStep1View UI 行为符合契约(不依赖 main_view
Layer 3 end-to-endMainView 创建 → 切到 step1 → 点击 → 日志验证
运行方式(项目根)::
@ -247,17 +247,17 @@ def smoke_view():
# =========================================================================
def smoke_e2e():
print("\n" + "=" * 70)
print("Layer 3 —— end-to-endMainRouter 路由 + TaskWorker 调度)")
print("Layer 3 —— end-to-endMainView 路由 + TaskWorker 调度)")
print("=" * 70)
from PyQt5.QtCore import QEventLoop, QTimer
from PyQt5.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv)
from src.new.main_router import MainRouter, ROUTES
from src.new.main_view import MainView, ROUTES
win = MainRouter()
report("L3", "MainRouter 实例化成功", win is not None)
win = MainView()
report("L3", "MainView 实例化成功", win is not None)
report("L3", "ROUTES 共 13 条", len(ROUTES) == 13, f"实际={len(ROUTES)}")
report("L3", "nav_list 项数 == 13", win.nav_list.count() == 13,
f"count={win.nav_list.count()}")

View File

@ -8,12 +8,12 @@ WQ_GUI 端到端模块化新架构
* ``core/`` 基础通讯接口BaseView 等契约)
* ``views/`` 前端皮囊 —— 仅 PyQt UI零业务
* ``services/`` 后端大脑 —— 纯函数计算,零 PyQt
* ``main_router.py`` 路由与调度壳,连接前端按钮与后端服务
* ``main_view.py`` 路由与调度壳,连接前端按钮与后端服务
调用链:
Step1View._on_run_clicked
→ BaseView.dispatch_execute(step_id, config)
→ MainRouter.run_single_step(step_id, config)
→ MainView.run_single_step(step_id, config)
→ TaskWorker 线程调用 services.step1_service.execute_step1(config)
→ 返回 dict 结果 → 日志区
"""

View File

@ -1,35 +1,40 @@
# -*- coding: utf-8 -*-
"""
MainRouter 端到端模块化新架构的路由与调度壳
=================================================
MainView 端到端模块化新架构的路由与调度壳主窗口
=========================================================
职责
----
1. **路由**根据 ``ROUTES`` 表动态加载每个 step view前端皮囊
service后端大脑左侧 ``QListWidget`` 导航右侧
``QStackedWidget`` 承载 view
2. **解耦通讯**view 不直接持有 main_router 引用按钮点击通过
2. **解耦通讯**view 不直接持有 main_view 引用按钮点击通过
``BaseView.dispatch_execute`` 沿父链回到本类 ``run_single_step``
3. **后台执行**内置 ``TaskWorker(QThread)`` service 调用丢进
子线程避免阻塞 UI
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_router
python -m src.new.main_view
# 方式 B直接脚本运行
python src/new/main_router.py
python src/new/main_view.py
"""
from __future__ import annotations
import importlib
import os
import sys
import traceback
from pathlib import Path
@ -41,7 +46,7 @@ from PyQt5.QtWidgets import (
QWidget,
)
# 让 ``python src/new/main_router.py`` 直接运行时也能 import ``src.xxx``
# 让 ``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:
@ -80,15 +85,27 @@ ROUTES = [
# =========================================================================
# TaskWorkerQThread 后台执行器
# TaskWorkerQThread 后台执行器(三信号协议)
# =========================================================================
class TaskWorker(QThread):
"""后台执行 ``service_func(config)`` 并把结果 emit 回主线程
"""后台执行 ``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 而不是 Threadservice_func 可能涉及 rasterio /
matplotlib 等原生扩展主线程与子线程的 Qt 事件循环隔离更稳
"""
finished_with_result = pyqtSignal(dict)
log_msg = pyqtSignal(str)
finished = pyqtSignal(dict)
error = pyqtSignal(str)
def __init__(self, service_func, config: dict):
super().__init__()
@ -96,22 +113,25 @@ class TaskWorker(QThread):
self.config = config
def run(self):
func_name = getattr(self.service_func, "__name__", "<anonymous>")
self.log_msg.emit(f"[Worker] 启动 {func_name}")
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)
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)
# =========================================================================
# MainRouterQMainWindow 路由壳
# MainViewQMainWindow 路由壳
# =========================================================================
class MainRouter(QMainWindow):
class MainView(QMainWindow):
"""新架构的轻量路由主窗口"""
def __init__(self):
@ -128,7 +148,7 @@ class MainRouter(QMainWindow):
self._build_ui()
self._build_routes()
self._log("[Boot] MainRouter 初始化完成")
self._log("[Boot] MainView 初始化完成")
self._log(f"[Boot] 已注册 {len(ROUTES)} 条路由step1 真实 / step2-step13 占位)")
# ------------------------------------------------------------------
@ -249,14 +269,15 @@ class MainRouter(QMainWindow):
1. 查路由表
2. 注入 ``work_dir``避免 view 重复感知
3. 启动 ``TaskWorker`` 在子线程运行 service
4. 完成后 emit 回主线程并写日志
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_router 已配置时)
# 注入 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}
@ -272,7 +293,9 @@ class MainRouter(QMainWindow):
return
self.worker = TaskWorker(service_func, config_dict)
self.worker.finished_with_result.connect(self._on_step_done)
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):
@ -286,7 +309,11 @@ class MainRouter(QMainWindow):
return None
def _on_step_done(self, result: dict):
"""TaskWorker 完成回调——按 status 分支写日志"""
"""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")
@ -298,12 +325,16 @@ class MainRouter(QMainWindow):
self._log(f"[Service↻] {message}(已复用历史结果)")
elif status == "not_implemented":
self._log(f"[Service…] {message}")
else: # error
else: # errorservice 内部已转 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}")
# ------------------------------------------------------------------
# 日志:所有日志统一走这里
# ------------------------------------------------------------------
@ -319,7 +350,7 @@ class MainRouter(QMainWindow):
def main():
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
win = MainRouter()
win = MainView()
win.show()
sys.exit(app.exec_())

View File

@ -2,7 +2,7 @@
"""
PlaceholderService —— 通用占位后端(用于尚未迁移的步骤)
返回 ``{"status": "not_implemented", ...}``main_router 据此在日志区
返回 ``{"status": "not_implemented", ...}``main_view 据此在日志区
输出友好提示;不抛异常、不阻塞 UI 线程。
"""
@ -15,7 +15,7 @@ def execute_placeholder(config: Dict[str, Any]) -> Dict[str, Any]:
"""通用占位执行函数
Args:
config: 由 view.get_config() 序列化、再经 main_router 注入 work_dir 的字典
config: 由 view.get_config() 序列化、再经 main_view 注入 work_dir 的字典
Returns:
标准结果字典 ``{status, output_path, message}``,其中 status="not_implemented"

View File

@ -3,14 +3,14 @@
Step1 后端计算服务(水域掩膜生成)
=====================================
这是一个**纯计算函数**——绝对不引用 PyQt、绝对不引用 main_router
这是一个**纯计算函数**——绝对不引用 PyQt、绝对不引用 main_view
绝对不读写全局变量。它只:
1. 从 ``config`` 字典读取参数;
2. 调用旧版 ``WaterMaskStep.run`` 执行水域掩膜生成;
3. 返回结果字典 ``{status, output_path, message}``。
调用入口(由 main_router 在后台 QThread 中调用):
调用入口(由 main_view 在后台 QThread 中调用):
execute_step1({
"mask_path": "D:/xx.shp", # 现有掩膜路径NDWI 模式下可为 None
@ -18,7 +18,7 @@ Step1 后端计算服务(水域掩膜生成)
"ndwi_threshold": 0.4, # NDWI 阈值
"img_path": "D:/ref.dat", # 参考影像
"output_path": "D:/mask.dat", # NDWI 输出路径(可选)
"work_dir": "D:/workspace", # 工作目录main_router 注入)
"work_dir": "D:/workspace", # 工作目录main_view 注入)
})
返回字典字段:
@ -56,7 +56,7 @@ def execute_step1(config: Dict[str, Any]) -> Dict[str, Any]:
"""Step 1 后端计算入口——纯函数
Args:
config: 由前端 view.get_config() 序列化、再经 main_router 注入 work_dir 的字典
config: 由前端 view.get_config() 序列化、再经 main_view 注入 work_dir 的字典
Returns:
标准结果字典 ``{status, output_path, message, mode}``
@ -87,7 +87,7 @@ def execute_step1(config: Dict[str, Any]) -> Dict[str, Any]:
generate_png=True,
output_path=output_path,
water_mask_dir=water_mask_dir,
callback=None, # 日志由 main_router 统一接管
callback=None, # 日志由 main_view 统一接管
)
except FileNotFoundError as e:
return {

View File

@ -5,7 +5,7 @@ PlaceholderView —— 通用占位视图(用于尚未迁移的步骤)
仅显示 step 名称与"待实现"提示,仍遵循 BaseView 契约:
* ``get_config()`` 返回空 dictservice 端据此走占位分支)
* 点击"执行"按钮仍会触发 ``dispatch_execute``,便于在 main_router
* 点击"执行"按钮仍会触发 ``dispatch_execute``,便于在 main_view
中验证路由链路完整通畅
"""
@ -42,7 +42,7 @@ class PlaceholderView(BaseView):
hint = QLabel(
"此步骤的 View 与 Service 尚未迁移到端到端模块化新架构。\n"
"按钮仍可点击以验证 main_router 路由链路完整性。"
"按钮仍可点击以验证 main_view 路由链路完整性。"
)
hint.setAlignment(Qt.AlignCenter)
hint.setWordWrap(True)

View File

@ -17,9 +17,9 @@ Step1View —— Step 1水域掩膜生成的前端视图端到端模块
Step1View._on_run_clicked
→ BaseView.dispatch_execute("step1", config)
→ MainRouter.run_single_step(step_id, config)
→ MainView.run_single_step(step_id, config)
→ TaskWorker → services.step1_service.execute_step1(config)
→ 返回结果 dict → MainRouter 日志区
→ 返回结果 dict → MainView 日志区
"""
import os
@ -279,6 +279,6 @@ class Step1View(BaseView):
所有跨层通讯一律走 ``dispatch_execute``
Step1View → 沿父链上溯 → MainRouter.run_single_step(step_id, config)
Step1View → 沿父链上溯 → MainView.run_single_step(step_id, config)
"""
self.dispatch_execute("step1", self.get_config())