#!/usr/bin/env python # -*- coding: utf-8 -*- """ ReportGenerationPanel - Word 分析报告生成面板 """ import os import traceback from pathlib import Path from typing import Optional from src.gui.panels._step_path_resolver import resolve_subdir from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLabel, QCheckBox, QPushButton, QLineEdit, QMessageBox, QFileDialog, QProgressBar, ) from src.gui.styles import ModernStylesheet from src.gui.dialogs import AISettingsDialog, AI_SETTINGS_ORG, AI_SETTINGS_APP class ReportWorkerThread(QThread): """后台生成 Word 报告(避免阻塞 UI)。 三信号协议: - progress(int, str):报告进度百分比 + 当前文案 - finished(str):生成的报告绝对路径 - error(str):异常信息(含 traceback) """ progress = pyqtSignal(int, str) finished = pyqtSignal(str) error = pyqtSignal(str) def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, enable_ai: bool): super().__init__() self.work_dir = work_dir self.output_dir = output_dir self.report_title = report_title self.enable_ai = enable_ai 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( ai_provider=provider, minimax_api_key=s.value("api_key", "", type=str) or "", 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, ) 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 "水质参数反演分析报告", on_progress=lambda pct, text: self.progress.emit(int(pct), str(text)), ) self.finished.emit(str(out_path)) except Exception as e: self.error.emit(f"{e}\n{traceback.format_exc()}") class Step13ReportPanel(QWidget): """步骤13:分析报告生成。AI 配置统一由 AISettingsDialog 管理,本面板不持有配置状态。""" def __init__(self, main_window=None, parent=None): super().__init__(parent) self.main_window = main_window self._report_thread = None self._ai_label = None self.init_ui() def init_ui(self): # 1. 确保全局主题类被导入 from src.gui.styles import ModernStylesheet layout = QVBoxLayout() layout.setContentsMargins(24, 24, 24, 24) layout.setSpacing(20) # 重新定义全局绝美样式 common_lineedit_css = "QLineEdit { padding: 4px 8px; border: 1px solid #C0C0C0; border-radius: 4px; min-height: 24px; background-color: #FFFFFF; }" common_btn_css = """ QPushButton { background-color: #F8F9FA; border: 1px solid #D1D5DB; border-radius: 4px; padding: 4px 12px; color: #374151; } QPushButton:hover { background-color: #F3F4F6; border-color: #9CA3AF; } """ # 【核心黑科技】:定义标准行闭包,强制统一 120px 标签和 10px 间距 def create_standard_row(label_text, widget, btn=None): row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) row.setSpacing(10) label = QLabel(label_text) label.setMinimumWidth(120) label.setMaximumWidth(120) row.addWidget(label) row.addWidget(widget, 1) if btn: row.addWidget(btn) return row intro = QLabel( "💡 提示:根据工作目录下的可视化结果(12_visualization 等)自动生成 Word 分析报告。\n" "需已存在可视化图表;AI 分析通过 Ollama 或 Minimax 调用云端/本地服务。" ) intro.setWordWrap(True) intro.setStyleSheet(f""" QLabel {{ color: {ModernStylesheet.COLORS.get('primary', '#0078D7')}; background-color: {ModernStylesheet.COLORS.get('selected', '#E5F3FF')}; border: 1px solid {ModernStylesheet.COLORS.get('border_light', '#D1D5DB')}; border-radius: 6px; padding: 10px 14px; margin-bottom: 8px; }} """) layout.addWidget(intro) # ========================================== # 卡片 1:报告路径配置 # ========================================== path_group = QGroupBox("📁 报告路径配置") path_layout = QVBoxLayout() path_layout.setSpacing(16) path_layout.setContentsMargins(20, 24, 20, 20) # 工作目录 (只读) self.work_dir_edit = QLineEdit() self.work_dir_edit.setPlaceholderText("流程工作目录(含 12_visualization)…") self.work_dir_edit.setReadOnly(True) self.work_dir_edit.setStyleSheet(common_lineedit_css) path_layout.addLayout(create_standard_row("工作目录:", self.work_dir_edit)) # 报告输出目录 self.output_dir_edit = QLineEdit() self.output_dir_edit.setPlaceholderText("留空则保存到 工作目录/12_visualization") self.output_dir_edit.setStyleSheet(common_lineedit_css) out_browse = QPushButton("浏览...") out_browse.setStyleSheet(common_btn_css) out_browse.setMaximumWidth(80) out_browse.clicked.connect(self.browse_output_dir) path_layout.addLayout(create_standard_row("报告输出目录:", self.output_dir_edit, out_browse)) # 报告标题 self.report_title_edit = QLineEdit() self.report_title_edit.setText("水质参数反演分析报告") self.report_title_edit.setStyleSheet(common_lineedit_css) path_layout.addLayout(create_standard_row("报告标题:", self.report_title_edit)) path_group.setLayout(path_layout) layout.addWidget(path_group) # ========================================== # 卡片 2:AI 分析配置 # ========================================== ai_group = QGroupBox("🤖 AI 分析配置") ai_layout = QVBoxLayout() ai_layout.setSpacing(16) ai_layout.setContentsMargins(20, 24, 20, 20) # 启用复选框,采用占位符顶齐 120px check_row = QHBoxLayout() check_row.setContentsMargins(0, 0, 0, 0) check_row.setSpacing(10) placeholder = QLabel("") placeholder.setMinimumWidth(120) placeholder.setMaximumWidth(120) check_row.addWidget(placeholder) self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结") self.enable_ai_cb.setChecked(os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"}) check_row.addWidget(self.enable_ai_cb) check_row.addStretch() ai_layout.addLayout(check_row) # AI 引擎状态行 ai_status_row = QHBoxLayout() ai_status_row.setContentsMargins(0, 0, 0, 0) ai_status_row.setSpacing(10) engine_label = QLabel("当前 AI 引擎:") engine_label.setMinimumWidth(120) engine_label.setMaximumWidth(120) ai_status_row.addWidget(engine_label) self._ai_label = QLabel() self._ai_label.setStyleSheet("color: #0078D7; font-weight: bold; font-size: 13px;") ai_status_row.addWidget(self._ai_label) ai_status_row.addStretch(1) open_settings_btn = QPushButton("高级配置...") open_settings_btn.setStyleSheet(common_btn_css) 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) layout.addWidget(ai_group) # ========================================== # 卡片 3:输出与执行 # ========================================== execute_group = QGroupBox("🚀 输出与执行") execute_layout = QVBoxLayout() execute_layout.setSpacing(12) execute_layout.setContentsMargins(20, 24, 20, 20) # 进度状态呈现(使用独一无二的 ID 选择器,彻底击碎并无视任何全局白块背景样式!) self.progress_label = QLabel("⏳ 准备就绪") self.progress_label.setObjectName("step13_progress_lbl") self.progress_label.setStyleSheet(""" QLabel#step13_progress_lbl { background-color: transparent; background: transparent; border: none; color: #4A5568; font-size: 13px; font-weight: 500; } """) execute_layout.addWidget(self.progress_label) # 现代极简细线条进度条 self.progress_bar = QProgressBar() self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) self.progress_bar.setTextVisible(False) # 【核心】关闭原生文字,彻底干掉 Windows 丑陋白框! self.progress_bar.setFixedHeight(8) # 压低高度,高级感拉满 self.progress_bar.setStyleSheet(""" QProgressBar { border: none; border-radius: 4px; background-color: #E2E8F0; } QProgressBar::chunk { background-color: #0078D7; border-radius: 4px; } """) execute_layout.addWidget(self.progress_bar) action_layout = QHBoxLayout() action_layout.addStretch() self.generate_btn = QPushButton("独立运行步骤") self.generate_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("primary")) self.generate_btn.setMinimumWidth(140) self.generate_btn.clicked.connect(self.on_generate_clicked) action_layout.addWidget(self.generate_btn) execute_layout.addLayout(action_layout) execute_group.setLayout(execute_layout) layout.addWidget(execute_group) layout.addStretch() self.setLayout(layout) self._refresh_ai_label() 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)) def _open_ai_settings(self): """弹出全局 AI 设置对话框,保存后刷新提示标签。""" dlg = AISettingsDialog(self) # 【强力注入】强制将配置弹窗内的标签与编组框背景抹为透明,全面恢复高级灰度质感! dlg.setStyleSheet(""" QLabel { background-color: transparent; background: transparent; border: none; } QGroupBox { background-color: transparent; } """) if dlg.exec_() == dlg.Accepted: self._refresh_ai_label() 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 = resolve_subdir(default, '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 _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() def get_config(self): """返回路径和标题配置(AI 配置不由本面板持有)。""" 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) / "12_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() # 重置进度条并禁用按钮 self.generate_btn.setEnabled(False) 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) 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_progress(self, pct: int, text: str): """接收后台进度的槽函数 —— 主线程。""" self.progress_bar.setValue(int(pct)) # 将百分比和文案完美融合进透明标签 self.progress_label.setText(f"⏳ {text} ({pct}%)" if text else f"⏳ 正在生成... ({pct}%)") def _on_finished(self, path: str): """报告生成成功 —— 主线程槽。""" self.progress_bar.setValue(100) self.progress_label.setText("✅ 分析报告生成成功!") self.generate_btn.setEnabled(True) QMessageBox.information(self, "完成", f"报告已生成:\n{path}") self._forward_log(f"Word 报告已保存: {path}", "info") def _on_error(self, err: str): """报告生成异常 —— 主线程槽。""" self.progress_label.setText("❌ 报告生成失败") self.generate_btn.setEnabled(True) QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}") self._forward_log(err, "error")