# -*- coding: utf-8 -*- """ 自定义确认对话框集合 按"职责单一 + 不污染主 GUI 文件"原则拆分。 与 water_quality_gui.py 保持 1:1 风格(中文注释 / 顶部 encoding 声明)。 """ import os from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QFont from PyQt5.QtWidgets import ( QDialog, QLabel, QSpinBox, QPushButton, QVBoxLayout, QHBoxLayout, QDialogButtonBox, QWidget, QComboBox, QLineEdit, ) class BandConfirmDialog(QDialog): """波段越界智能确认对话框(60 秒倒计时) 场景 ---- 用户在 step3 面板里设置了 nir_band=66,但实际影像只有 50 波段。 Pipeline 一旦按 66 去取波段就会报 IndexError。 行为约定 -------- - 启动时 QTimer 开始 60s 倒计时,按钮文字同步显示 "确定 (Ns)"。 - 用户手动调整 QSpinBox + 点"确定":立即 accept(),返回当前 spinbox 值。 - 用户 60s 未操作:定时器归零时自动 accept(),返回当前 spinbox 值 (**默认值为影像最大波段数 = 用户拿不到想要波段时的兜底**)。 - 用户点"取消运行":reject(),调用方应中止 run_full_pipeline。 """ DEFAULT_TIMEOUT = 60 # 秒 def __init__( self, parent: QWidget = None, requested_band: int = 66, max_band: int = 50, recommended_band: int = 66, method_label: str = "NIR", timeout_seconds: int = DEFAULT_TIMEOUT, ): super().__init__(parent) self._requested_band = requested_band self._max_band = max_band self._recommended_band = recommended_band self._method_label = method_label self._timeout_seconds = timeout_seconds self._remaining = timeout_seconds self._selected_band = max_band # 默认 = 最大波段(兜底) self.setWindowTitle("波段索引越界") self.setModal(True) self.setMinimumWidth(420) self._init_ui() self._start_timer() def _init_ui(self): """搭建 UI:警告文字 + 灰色推荐 + SpinBox + 倒计时按钮""" layout = QVBoxLayout(self) # 1) 主提示(带 HTML 强调) self._msg_label = QLabel( f"影像仅有 {self._max_band} 个波段," f"无法读取第 {self._requested_band} 波段({self._method_label})。" ) self._msg_label.setWordWrap(True) self._msg_label.setFont(QFont("Microsoft YaHei", 10)) layout.addWidget(self._msg_label) # 2) 灰色小字推荐 hint_label = QLabel( f"(推荐近红外波段序号:{self._recommended_band})" ) hint_label.setStyleSheet("color: #888; font-size: 10px;") layout.addWidget(hint_label) # 3) 波段选择 SpinBox(默认值 = 最大波段 = 超时兜底) spin_row = QHBoxLayout() spin_row.addWidget(QLabel(f"请选择要使用的{self._method_label}索引:")) self._spin = QSpinBox() self._spin.setRange(0, self._max_band) self._spin.setValue(self._max_band) self._spin.setSuffix(f" / 0~{self._max_band}") spin_row.addWidget(self._spin) spin_row.addStretch(1) layout.addLayout(spin_row) # 4) 倒计时说明 countdown_tip = QLabel( f"⏱ {self._timeout_seconds} 秒内不操作,将自动使用最大波段 " f"({self._max_band})继续运行。" ) countdown_tip.setStyleSheet("color: #555; font-size: 9px;") countdown_tip.setWordWrap(True) layout.addWidget(countdown_tip) # 5) 按钮组(手动"确定 (Ns)" + "取消运行") btn_box = QDialogButtonBox() self._ok_btn = QPushButton(f"确定 ({self._remaining}s)") self._ok_btn.setDefault(True) self._ok_btn.clicked.connect(self.accept) self._cancel_btn = QPushButton("取消运行") self._cancel_btn.clicked.connect(self.reject) btn_box.addButton(self._ok_btn, QDialogButtonBox.AcceptRole) btn_box.addButton(self._cancel_btn, QDialogButtonBox.RejectRole) layout.addWidget(btn_box) def _start_timer(self): """启动 1Hz 倒计时;归零时自动 accept()""" self._timer = QTimer(self) self._timer.setInterval(1000) self._timer.timeout.connect(self._tick) self._timer.start() def _tick(self): """每秒刷新按钮文字;归零时停表 + accept()""" self._remaining -= 1 if self._remaining <= 0: self._timer.stop() self.accept() # 超时:返回当前 spinbox 值(= max_band) else: self._ok_btn.setText(f"确定 ({self._remaining}s)") # ── 暴露给调用方的结果接口 ────────────────────────────── def selected_band(self) -> int: """弹窗关闭后由调用方取回用户选定的波段索引""" return self._selected_band def accept(self): """点"确定"或倒计时归零触发:记录当前 spinbox 值后真正关闭""" self._selected_band = self._spin.value() self._timer.stop() super().accept() def reject(self): """点"取消运行"触发:停表 + 关闭,调用方需中止流程""" self._timer.stop() super().reject() # ───────────────────────────────────────────────────────────────────────────── # AI 引擎设置对话框 # ───────────────────────────────────────────────────────────────────────────── from PyQt5.QtCore import QSettings AI_SETTINGS_ORG = "IrisWaterQuality" AI_SETTINGS_APP = "WQ_GUI" AI_DEFAULTS = { "ollama": { "api_base_url": "http://localhost:11434", "vision_model": "qwen3-vl:8b", "text_model": "qwen3-vl:8b", }, "minimax": { "api_base_url": "https://api.minimaxi.com/v1/text/chatcompletion_v2", "vision_model": "abab6.5s-chat", "text_model": "abab6.5s-chat", }, } class AISettingsDialog(QDialog): """AI 引擎可视化配置弹窗,配置持久化到 QSettings。""" def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("AI 引擎配置") self.setModal(True) self.setMinimumWidth(520) self._load_settings() self._init_ui() def _load_settings(self): """从 QSettings 读取已有配置;无记录则回退到环境变量或默认值。""" s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) self._provider = s.value("ai_provider", "minimax", type=str) # API Key 不设默认值(敏感信息,首次必须由用户输入) self._api_key = s.value("minimax_api_key", "", type=str) # 已保存的 URL 和模型;若 QSettings 无记录则读环境变量 if self._provider == "ollama": self._api_base_url = ( s.value("api_base_url", "") or os.environ.get("OLLAMA_URL", AI_DEFAULTS["ollama"]["api_base_url"]) ) self._vision_model = ( s.value("vision_model", "") or os.environ.get("OLLAMA_VISION_MODEL", AI_DEFAULTS["ollama"]["vision_model"]) ) self._text_model = ( s.value("text_model", "") or os.environ.get("OLLAMA_TEXT_MODEL", AI_DEFAULTS["ollama"]["text_model"]) ) else: self._api_base_url = ( s.value("api_base_url", "") or os.environ.get("MINIMAX_BASE_URL", AI_DEFAULTS["minimax"]["api_base_url"]) ) self._vision_model = ( s.value("vision_model", "") or os.environ.get("MINIMAX_VISION_MODEL", AI_DEFAULTS["minimax"]["vision_model"]) ) self._text_model = ( s.value("text_model", "") or os.environ.get("MINIMAX_TEXT_MODEL", AI_DEFAULTS["minimax"]["text_model"]) ) self._timeout = s.value("timeout_s", 120, type=int) def _init_ui(self): layout = QVBoxLayout(self) layout.setSpacing(12) # ── Provider ────────────────────────────────────────────────────────── provider_row = QHBoxLayout() provider_row.addWidget(QLabel("AI 引擎提供商:")) self._provider_combo = QComboBox() self._provider_combo.addItems(["Ollama", "Minimax"]) self._provider_combo.setCurrentText("Ollama" if self._provider == "ollama" else "Minimax") self._provider_combo.currentIndexChanged.connect(self._on_provider_changed) provider_row.addWidget(self._provider_combo, 1) provider_row.addStretch(1) layout.addLayout(provider_row) # ── API Base URL ─────────────────────────────────────────────────────── url_row = QHBoxLayout() url_row.addWidget(QLabel("API Base URL:")) self._url_edit = QLineEdit(self._api_base_url) self._url_edit.setPlaceholderText("例如: http://localhost:11434") url_row.addWidget(self._url_edit, 1) layout.addLayout(url_row) # ── API Key ─────────────────────────────────────────────────────────── key_row = QHBoxLayout() key_row.addWidget(QLabel("API Key:")) self._key_edit = QLineEdit(self._api_key) self._key_edit.setPlaceholderText("输入 API Key(敏感信息,已加密存储)") self._key_edit.setEchoMode(QLineEdit.Password) key_row.addWidget(self._key_edit, 1) layout.addLayout(key_row) # ── 模型名称 ─────────────────────────────────────────────────────────── model_row = QHBoxLayout() model_row.addWidget(QLabel("视觉模型:")) self._vision_edit = QLineEdit(self._vision_model) model_row.addWidget(self._vision_edit, 1) model_row.addSpacing(12) model_row.addWidget(QLabel("文本模型:")) self._text_edit = QLineEdit(self._text_model) model_row.addWidget(self._text_edit, 1) layout.addLayout(model_row) # ── 超时 ────────────────────────────────────────────────────────────── timeout_row = QHBoxLayout() timeout_row.addWidget(QLabel("请求超时(秒):")) self._timeout_spin = QSpinBox() self._timeout_spin.setRange(30, 3600) self._timeout_spin.setSingleStep(30) self._timeout_spin.setValue(self._timeout) timeout_row.addWidget(self._timeout_spin) timeout_row.addStretch(1) layout.addLayout(timeout_row) # ── 说明 ────────────────────────────────────────────────────────────── hint = QLabel( "提示:切换引擎后将自动填充推荐默认值(可手动修改)。" "API Key 仅本地加密存储,不会明文暴露。" ) hint.setStyleSheet("color: #888; font-size: 10px;") hint.setWordWrap(True) layout.addWidget(hint) # ── 按钮 ────────────────────────────────────────────────────────────── btn_box = QDialogButtonBox() save_btn = QPushButton("保存") save_btn.setDefault(True) save_btn.clicked.connect(self._save_and_close) cancel_btn = QPushButton("取消") cancel_btn.clicked.connect(self.reject) btn_box.addButton(save_btn, QDialogButtonBox.AcceptRole) btn_box.addButton(cancel_btn, QDialogButtonBox.RejectRole) layout.addWidget(btn_box) def _on_provider_changed(self): """切换 Provider 时自动填充推荐默认值。""" provider = self._provider_combo.currentText().lower() defaults = AI_DEFAULTS.get(provider, AI_DEFAULTS["minimax"]) self._url_edit.setText(defaults["api_base_url"]) self._vision_edit.setText(defaults["vision_model"]) self._text_edit.setText(defaults["text_model"]) def _save_and_close(self): """持久化到 QSettings 并关闭。""" s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) provider = self._provider_combo.currentText().lower() s.setValue("ai_provider", provider) s.setValue("api_base_url", self._url_edit.text().strip()) s.setValue("api_key", self._key_edit.text().strip()) s.setValue("vision_model", self._vision_edit.text().strip()) s.setValue("text_model", self._text_edit.text().strip()) s.setValue("timeout_s", self._timeout_spin.value()) s.sync() self.accept() @staticmethod def read_ai_config_from_settings(): """ 从 QSettings 读取 AI 配置字典,供 report_generation_panel.py 等处使用。 返回键:ai_provider / api_base_url / api_key / vision_model / text_model / timeout_s """ s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) provider = s.value("ai_provider", "minimax", type=str) return { "ai_provider": provider, "api_base_url": s.value("api_base_url", "", type=str), "api_key": s.value("api_key", "", type=str), "vision_model": s.value("vision_model", "", type=str), "text_model": s.value("text_model", "", type=str), "timeout_s": s.value("timeout_s", 120, type=int), }