#!/usr/bin/env python # -*- coding: utf-8 -*- """ ReportGenerationPanel - Word 分析报告生成面板 """ import os import traceback from pathlib import Path from typing import Optional from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLabel, QCheckBox, QPushButton, QLineEdit, QSpinBox, QMessageBox, QFileDialog, ) from src.gui.styles import ModernStylesheet class ReportGenerateThread(QThread): """后台生成 Word 报告(避免阻塞 UI)。""" finished_ok = pyqtSignal(str) failed = pyqtSignal(str) log_message = pyqtSignal(str, str) def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, options: dict): super().__init__() self.work_dir = work_dir self.output_dir = output_dir self.report_title = report_title self.options = options def run(self): try: from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig url = (self.options.get("ollama_url") or "").strip() or None vision = (self.options.get("ollama_vision_model") or "").strip() or None text = (self.options.get("ollama_text_model") or "").strip() or None if self.options.get("text_same_as_vision"): text = vision timeout = self.options.get("ollama_timeout_s") enable_ai = self.options.get("enable_ai_analysis") ai_cfg = ReportGenerationConfig( ollama_base_url=url, ollama_vision_model=vision, ollama_text_model=text, ollama_timeout_s=int(timeout) if timeout is not None else None, enable_ai_analysis=bool(enable_ai), ) self.log_message.emit( f"报告生成:工作目录={self.work_dir},AI={'开' if enable_ai else '关'}," f"模型URL={url or '(环境变量 OLLAMA_URL)'}", "info", ) 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 "水质参数反演分析报告", ) self.finished_ok.emit(str(out_path)) except Exception as e: self.failed.emit(f"{e}\n{traceback.format_exc()}") class ReportGenerationPanel(QWidget): """Word 报告生成:工作目录、输出目录、Ollama URL/模型、是否启用 AI 等。""" def __init__(self, main_window=None, parent=None): super().__init__(parent) self.main_window = main_window self._report_thread = None 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 /api/chat 调用本地或远程服务。" ) intro.setWordWrap(True) intro.setStyleSheet( f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};" ) layout.addWidget(intro) path_group = QGroupBox("路径") path_form = QFormLayout() wd_row = QHBoxLayout() self.work_dir_edit = QLineEdit() self.work_dir_edit.setPlaceholderText("选择流程工作目录(含 14_visualization)…") wd_browse = QPushButton("浏览…") wd_browse.clicked.connect(self.browse_work_dir) sync_btn = QPushButton("同步主窗口工作目录") sync_btn.clicked.connect(self.sync_work_dir_from_main) 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_group = QGroupBox("AI 分析(Ollama)") ai_form = QFormLayout() self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结") self.enable_ai_cb.setChecked( os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"} ) ai_form.addRow(self.enable_ai_cb) self.ollama_url_edit = QLineEdit() self.ollama_url_edit.setText( os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/") ) ai_form.addRow("服务 URL:", self.ollama_url_edit) self.vision_model_edit = QLineEdit() self.vision_model_edit.setText( os.environ.get("OLLAMA_VISION_MODEL", "qwen3-vl:8b") ) ai_form.addRow("视觉模型:", self.vision_model_edit) self.same_text_model_cb = QCheckBox("文本总结与视觉使用同一模型") self.same_text_model_cb.setChecked(True) ai_form.addRow(self.same_text_model_cb) self.text_model_edit = QLineEdit() self.text_model_edit.setText( os.environ.get( "OLLAMA_TEXT_MODEL", self.vision_model_edit.text() or "qwen3-vl:8b" ) ) self.text_model_edit.setEnabled(False) self.same_text_model_cb.toggled.connect(self._on_same_text_toggled) self.vision_model_edit.textChanged.connect(self._sync_text_model_if_linked) ai_form.addRow("文本模型:", self.text_model_edit) self.timeout_spin = QSpinBox() self.timeout_spin.setRange(30, 3600) self.timeout_spin.setSingleStep(30) self.timeout_spin.setValue(int(os.environ.get("OLLAMA_TIMEOUT_S", "120"))) ai_form.addRow("请求超时(秒):", self.timeout_spin) ai_group.setLayout(ai_form) layout.addWidget(ai_group) 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) layout.addStretch() self.setLayout(layout) def _on_same_text_toggled(self, checked: bool): self.text_model_edit.setEnabled(not checked) if checked: self.text_model_edit.setText(self.vision_model_edit.text()) def _sync_text_model_if_linked(self, _t=None): if self.same_text_model_cb.isChecked(): self.text_model_edit.blockSignals(True) self.text_model_edit.setText(self.vision_model_edit.text()) self.text_model_edit.blockSignals(False) 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 "" def browse_work_dir(self): default = self._get_default_work_dir() d = QFileDialog.getExistingDirectory(self, "选择工作目录", default) if d: self.work_dir_edit.setText(d) def browse_output_dir(self): default = self._get_default_work_dir() if default: default = os.path.join(default, "14_visualization") d = QFileDialog.getExistingDirectory(self, "选择报告输出目录", default) 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)) def get_config(self): 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 "水质参数反演分析报告", "ollama_url": self.ollama_url_edit.text().strip(), "ollama_vision_model": self.vision_model_edit.text().strip(), "ollama_text_model": self.text_model_edit.text().strip(), "text_same_as_vision": self.same_text_model_cb.isChecked(), "ollama_timeout_s": self.timeout_spin.value(), "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 config.get("ollama_url"): self.ollama_url_edit.setText(str(config["ollama_url"])) if config.get("ollama_vision_model"): self.vision_model_edit.setText(str(config["ollama_vision_model"])) if "text_same_as_vision" in config: self.same_text_model_cb.setChecked(bool(config["text_same_as_vision"])) if config.get("ollama_text_model"): self.text_model_edit.setText(str(config["ollama_text_model"])) if config.get("ollama_timeout_s") is not None: self.timeout_spin.setValue(int(config["ollama_timeout_s"])) 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 "水质参数反演分析报告" opts = { "ollama_url": self.ollama_url_edit.text().strip(), "ollama_vision_model": self.vision_model_edit.text().strip(), "ollama_text_model": self.text_model_edit.text().strip(), "text_same_as_vision": self.same_text_model_cb.isChecked(), "ollama_timeout_s": self.timeout_spin.value(), "enable_ai_analysis": self.enable_ai_cb.isChecked(), } self.generate_btn.setEnabled(False) self._report_thread = ReportGenerateThread(wd, out, title, opts) self._report_thread.log_message.connect(self._forward_log, Qt.QueuedConnection) self._report_thread.finished_ok.connect(self._on_report_ok, Qt.QueuedConnection) self._report_thread.failed.connect(self._on_report_fail, Qt.QueuedConnection) self._report_thread.finished.connect( lambda: self.generate_btn.setEnabled(True), Qt.QueuedConnection ) 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}") def _on_report_ok(self, path: str): QMessageBox.information(self, "完成", f"报告已生成:\n{path}") self._forward_log(f"Word 报告已保存: {path}", "info") def _on_report_fail(self, err: str): QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}") self._forward_log(err, "error")