Files
WQ_GUI/src/gui/dialogs.py

337 lines
14 KiB
Python
Raw Normal View History

# -*- 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"影像仅有 <b>{self._max_band}</b> 个波段,"
f"无法读取第 <b>{self._requested_band}</b> 波段({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),
}