Files
WQ_GUI/src/gui/panels/step13_report_panel.py
2026-07-01 09:57:27 +08:00

430 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)
# ==========================================
# 卡片 2AI 分析配置
# ==========================================
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 同步到本面板。
解耦手动 browsework_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")