Files
WQ_GUI/src/gui/panels/step13_report_panel.py

353 lines
15 KiB
Python
Raw Normal View History

2026-05-07 14:23:58 +08:00
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
ReportGenerationPanel - Word 分析报告生成面板
"""
import os
import sys
2026-05-07 14:23:58 +08:00
import traceback
from pathlib import Path
from typing import Optional
# 路径归一化 helper(与 pipeline.get_step_output_dir 互为表里)
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import resolve_subdir
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings
2026-05-07 14:23:58 +08:00
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QLabel, QCheckBox, QPushButton, QLineEdit,
2026-06-23 14:39:54 +08:00
QMessageBox, QFileDialog, QProgressBar,
2026-05-07 14:23:58 +08:00
)
from src.gui.styles import ModernStylesheet
from src.gui.dialogs import AISettingsDialog, AI_SETTINGS_ORG, AI_SETTINGS_APP
2026-05-07 14:23:58 +08:00
2026-06-23 14:39:54 +08:00
class ReportWorkerThread(QThread):
"""后台生成 Word 报告(避免阻塞 UI)。
三信号协议:
- progress(int, str):报告进度百分比 + 当前文案
- finished(str):生成的报告绝对路径
- error(str):异常信息(含 traceback)
"""
progress = pyqtSignal(int, str)
finished = pyqtSignal(str)
error = pyqtSignal(str)
2026-05-07 14:23:58 +08:00
def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, enable_ai: bool):
2026-05-07 14:23:58 +08:00
super().__init__()
self.work_dir = work_dir
self.output_dir = output_dir
self.report_title = report_title
self.enable_ai = enable_ai
2026-05-07 14:23:58 +08:00
def run(self):
try:
from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = s.value("ai_provider", "minimax", type=str)
timeout = int(s.value("timeout_s", 120, type=int))
if provider == "ollama":
ai_cfg = ReportGenerationConfig(
ai_provider="ollama",
ollama_base_url=s.value("api_base_url", "", type=str) or None,
ollama_vision_model=s.value("vision_model", "", type=str) or None,
ollama_text_model=s.value("text_model", "", type=str) or None,
ollama_timeout_s=timeout,
enable_ai_analysis=self.enable_ai,
)
else:
ai_cfg = ReportGenerationConfig(
2026-06-25 18:11:50 +08:00
ai_provider=provider,
minimax_api_key=s.value("api_key", "", type=str) or "",
2026-06-25 18:11:50 +08:00
minimax_base_url=s.value("api_base_url", "", type=str) or None, # <--- 新增这行,把界面上的 URL 传过去
minimax_vision_model=s.value("vision_model", "", type=str) or None,
minimax_text_model=s.value("text_model", "", type=str) or None,
minimax_timeout_s=timeout,
enable_ai_analysis=self.enable_ai,
)
2026-05-07 14:23:58 +08:00
gen = WaterQualityReportGenerator(
work_dir=self.work_dir,
output_dir=self.output_dir,
ai_config=ai_cfg,
)
out_path = gen.generate_report(
work_dir=self.work_dir,
report_title=self.report_title or "水质参数反演分析报告",
2026-06-23 14:39:54 +08:00
on_progress=lambda pct, text: self.progress.emit(int(pct), str(text)),
2026-05-07 14:23:58 +08:00
)
2026-06-23 14:39:54 +08:00
self.finished.emit(str(out_path))
2026-05-07 14:23:58 +08:00
except Exception as e:
2026-06-23 14:39:54 +08:00
self.error.emit(f"{e}\n{traceback.format_exc()}")
2026-05-07 14:23:58 +08:00
class Step13ReportPanel(QWidget):
"""步骤13:分析报告生成。AI 配置统一由 AISettingsDialog 管理,本面板不持有配置状态。"""
2026-05-07 14:23:58 +08:00
def __init__(self, main_window=None, parent=None):
super().__init__(parent)
self.main_window = main_window
self._report_thread = None
self._ai_label = None
2026-05-07 14:23:58 +08:00
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
intro = QLabel(
"根据工作目录下的可视化结果(14_visualization 等)生成 Word 分析报告。"
"需已存在可视化图表;AI 分析通过 Ollama 或 Minimax 调用云端/本地服务。"
2026-05-07 14:23:58 +08:00
)
intro.setWordWrap(True)
intro.setStyleSheet(
f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};"
)
layout.addWidget(intro)
# ── 路径 ──────────────────────────────────────────────────────────────
2026-05-07 14:23:58 +08:00
path_group = QGroupBox("路径")
path_form = QFormLayout()
wd_row = QHBoxLayout()
self.work_dir_edit = QLineEdit()
2026-06-23 14:39:54 +08:00
self.work_dir_edit.setPlaceholderText("流程工作目录(含 14_visualization)…")
self.work_dir_edit.setReadOnly(True)
2026-05-07 14:23:58 +08:00
wd_browse = QPushButton("浏览…")
wd_browse.clicked.connect(self.browse_work_dir)
2026-06-23 14:39:54 +08:00
wd_browse.setVisible(False)
2026-05-07 14:23:58 +08:00
sync_btn = QPushButton("同步主窗口工作目录")
sync_btn.clicked.connect(self.sync_work_dir_from_main)
2026-06-23 14:39:54 +08:00
sync_btn.setVisible(False)
2026-05-07 14:23:58 +08:00
wd_row.addWidget(self.work_dir_edit, 1)
wd_row.addWidget(wd_browse)
wd_row.addWidget(sync_btn)
path_form.addRow("工作目录:", wd_row)
out_row = QHBoxLayout()
self.output_dir_edit = QLineEdit()
self.output_dir_edit.setPlaceholderText("留空则保存到 工作目录/14_visualization")
out_browse = QPushButton("浏览…")
out_browse.clicked.connect(self.browse_output_dir)
out_row.addWidget(self.output_dir_edit, 1)
out_row.addWidget(out_browse)
path_form.addRow("报告输出目录:", out_row)
self.report_title_edit = QLineEdit()
self.report_title_edit.setText("水质参数反演分析报告")
path_form.addRow("报告标题:", self.report_title_edit)
path_group.setLayout(path_form)
layout.addWidget(path_group)
# ── AI 分析 ───────────────────────────────────────────────────────────
ai_group = QGroupBox("AI 分析")
ai_layout = QVBoxLayout()
2026-05-07 14:23:58 +08:00
self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结")
self.enable_ai_cb.setChecked(
os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"}
)
ai_layout.addWidget(self.enable_ai_cb)
# 只读提示行:当前引擎 + 配置按钮
ai_status_row = QHBoxLayout()
ai_status_row.addWidget(QLabel("当前 AI 引擎:"))
self._ai_label = QLabel()
self._ai_label.setStyleSheet("color: #0078d4; font-weight: bold;")
ai_status_row.addWidget(self._ai_label)
ai_status_row.addStretch(1)
open_settings_btn = QPushButton("高级配置...")
open_settings_btn.clicked.connect(self._open_ai_settings)
ai_status_row.addWidget(open_settings_btn)
ai_layout.addLayout(ai_status_row)
ai_group.setLayout(ai_layout)
2026-05-07 14:23:58 +08:00
layout.addWidget(ai_group)
# ── 按钮 ──────────────────────────────────────────────────────────────
2026-05-07 14:23:58 +08:00
btn_row = QHBoxLayout()
self.generate_btn = QPushButton("生成 Word 报告")
self.generate_btn.setStyleSheet(
ModernStylesheet.get_button_stylesheet("success")
)
self.generate_btn.clicked.connect(self.on_generate_clicked)
btn_row.addWidget(self.generate_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
2026-06-23 14:39:54 +08:00
# ── 进度条 + 状态文案 ────────────────────────────────────────────────
progress_row = QHBoxLayout()
self.progress_label = QLabel("就绪")
self.progress_label.setMinimumWidth(220)
progress_row.addWidget(self.progress_label)
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
self.progress_bar.setTextVisible(True)
progress_row.addWidget(self.progress_bar, 1)
layout.addLayout(progress_row)
2026-05-07 14:23:58 +08:00
layout.addStretch()
self.setLayout(layout)
# 刷新引擎提示文字
self._refresh_ai_label()
2026-06-23 14:39:54 +08:00
# 初次构建时尝试同步主窗口工作目录
self._auto_pull_work_dir()
def _refresh_ai_label(self):
"""从 QSettings 读取当前 Provider 并更新只读标签。"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = s.value("ai_provider", "minimax", type=str)
label_map = {"ollama": "Ollama (本地)", "minimax": "Minimax (云端)"}
self._ai_label.setText(label_map.get(provider, provider))
2026-05-07 14:23:58 +08:00
def _open_ai_settings(self):
"""弹出全局 AI 设置对话框,保存后刷新提示标签。"""
dlg = AISettingsDialog(self)
if dlg.exec_() == dlg.Accepted:
self._refresh_ai_label()
2026-05-07 14:23:58 +08:00
2026-05-07 16:49:24 +08:00
def _get_default_work_dir(self):
"""获取 work_dir,优先用主窗口缓存的 work_dir"""
if self.main_window and hasattr(self.main_window, 'work_dir') and self.main_window.work_dir:
return str(self.main_window.work_dir)
return ""
2026-05-07 14:23:58 +08:00
def browse_work_dir(self):
2026-05-07 16:49:24 +08:00
default = self._get_default_work_dir()
d = QFileDialog.getExistingDirectory(self, "选择工作目录", default)
2026-05-07 14:23:58 +08:00
if d:
self.work_dir_edit.setText(d)
def browse_output_dir(self):
2026-05-07 16:49:24 +08:00
default = self._get_default_work_dir()
if default:
default = resolve_subdir(default, 'visualization')
2026-05-07 16:49:24 +08:00
d = QFileDialog.getExistingDirectory(self, "选择报告输出目录", default)
2026-05-07 14:23:58 +08:00
if d:
self.output_dir_edit.setText(d)
def sync_work_dir_from_main(self):
mw = self.main_window
if mw is not None and getattr(mw, "work_dir", None):
self.work_dir_edit.setText(str(mw.work_dir))
else:
QMessageBox.information(self, "提示", "主窗口尚未设置工作目录。")
def set_work_dir(self, work_dir):
if work_dir:
self.work_dir_edit.setText(str(work_dir))
2026-06-23 14:39:54 +08:00
def _auto_pull_work_dir(self):
"""从主窗口自动同步工作目录到 work_dir_edit(无需用户操作)。"""
mw = self.main_window
if mw is not None and getattr(mw, "work_dir", None):
wd = str(mw.work_dir)
cur = self.work_dir_edit.text().strip()
if wd and wd != cur:
self.work_dir_edit.setText(wd)
def update_from_config(self, work_dir=None, pipeline=None):
"""切入面板时由主窗口统一调用,把当前 work_dir 同步到本面板。
解耦手动 browse:work_dir_edit 已 ReadOnly,外部只能通过此入口更新。
"""
if work_dir:
self.work_dir_edit.setText(str(work_dir))
self._auto_pull_work_dir()
def showEvent(self, event):
"""Tab 切换到本面板时再次兜底同步一次(应对 init_ui 时尚未绑定 main_window 的场景)。"""
super().showEvent(event)
self._auto_pull_work_dir()
2026-05-07 14:23:58 +08:00
def get_config(self):
"""返回路径和标题配置(AI 配置不由本面板持有)。"""
2026-05-07 14:23:58 +08:00
return {
"work_dir": self.work_dir_edit.text().strip() or None,
"output_dir": self.output_dir_edit.text().strip() or None,
"report_title": self.report_title_edit.text().strip() or "水质参数反演分析报告",
"enable_ai_analysis": self.enable_ai_cb.isChecked(),
}
def set_config(self, config):
if not config:
return
if config.get("work_dir"):
self.work_dir_edit.setText(str(config["work_dir"]))
if "output_dir" in config:
self.output_dir_edit.setText(str(config["output_dir"] or ""))
if config.get("report_title"):
self.report_title_edit.setText(str(config["report_title"]))
if "enable_ai_analysis" in config:
self.enable_ai_cb.setChecked(bool(config["enable_ai_analysis"]))
def on_generate_clicked(self):
wd = self.work_dir_edit.text().strip()
if not wd or not os.path.isdir(wd):
QMessageBox.warning(self, "提示", "请选择有效的工作目录。")
return
viz = Path(wd) / "14_visualization"
if not viz.is_dir():
QMessageBox.warning(
self,
"提示",
f"未找到可视化目录:\n{viz}\n请先完成流程或生成可视化。",
)
return
if self._report_thread and self._report_thread.isRunning():
QMessageBox.information(self, "提示", "报告正在生成中,请稍候。")
return
out = self.output_dir_edit.text().strip() or None
title = self.report_title_edit.text().strip() or "水质参数反演分析报告"
enable_ai = self.enable_ai_cb.isChecked()
2026-06-23 14:39:54 +08:00
# 重置进度条并禁用按钮
2026-05-07 14:23:58 +08:00
self.generate_btn.setEnabled(False)
2026-06-23 14:39:54 +08:00
self.progress_bar.setValue(0)
self.progress_label.setText("正在准备生成…")
self._report_thread = ReportWorkerThread(wd, out, title, enable_ai)
self._report_thread.progress.connect(self._on_progress, Qt.QueuedConnection)
self._report_thread.finished.connect(self._on_finished, Qt.QueuedConnection)
self._report_thread.error.connect(self._on_error, Qt.QueuedConnection)
2026-05-07 14:23:58 +08:00
self._report_thread.start()
self._forward_log("已开始生成 Word 报告…", "info")
def _forward_log(self, msg: str, level: str):
mw = self.main_window
if mw is not None and hasattr(mw, "log_message"):
mw.log_message(msg, level)
else:
print(f"[{level}] {msg}")
2026-06-23 14:39:54 +08:00
def _on_progress(self, pct: int, text: str):
"""接收 ReportWorkerThread.progress(int, str) —— 主线程槽。"""
self.progress_bar.setValue(int(pct))
self.progress_label.setText(text or f"{pct}%")
def _on_finished(self, path: str):
"""报告生成成功 —— 主线程槽。"""
self.progress_bar.setValue(100)
self.progress_label.setText("完成")
self.generate_btn.setEnabled(True)
2026-05-07 14:23:58 +08:00
QMessageBox.information(self, "完成", f"报告已生成:\n{path}")
self._forward_log(f"Word 报告已保存: {path}", "info")
2026-06-23 14:39:54 +08:00
def _on_error(self, err: str):
"""报告生成异常 —— 主线程槽。"""
self.progress_label.setText("失败")
self.generate_btn.setEnabled(True)
2026-05-07 14:23:58 +08:00
QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}")
self._forward_log(err, "error")