diff --git a/src/gui/panels/step10_watercolor_panel.py b/src/gui/panels/step10_watercolor_panel.py index 1242c20..1eb14b6 100644 --- a/src/gui/panels/step10_watercolor_panel.py +++ b/src/gui/panels/step10_watercolor_panel.py @@ -39,6 +39,23 @@ from PyQt5.QtCore import Qt, QThread, pyqtSignal from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet +# ========================================== +# 必须放在所有 import 的下方,class 的上方! +# ========================================== +CATEGORY_CHINESE_MAP = { + 'Total_Suspended_Matter': '总悬浮物 (TSM)', + 'Phycocyanin (BGA_PC)': '藻蓝蛋白 (PC)', + 'Turbidity': '浊度 (Turbidity)', + 'chlorophyll_a': '叶绿素a (Chl-a)', + 'Colored_Dissolved_Organic_Matter': '有色可溶性有机物 (CDOM)', + 'Secchi_Disk_Depth': '透明度 (SDD)', + 'Total_Nitrogen': '总氮 (TN)', + 'Total_Phosphorus': '总磷 (TP)', + 'Chemical_Oxygen_Demand': '化学需氧量 (COD)', + 'Ammonia_Nitrogen': '氨氮 (NH3-N)', + 'Dissolved_Oxygen': '溶解氧 (DO)' +} + class WaterIndexWorker(QThread): """后台线程:散点 CSV → 逐行公式计算 → 多 CSV 输出 @@ -99,6 +116,16 @@ class WaterIndexWorker(QThread): self.error.emit(f"{e}\n{traceback.format_exc()}") +class NoScrollPassListWidget(QListWidget): + """一个绝对不会把滚轮事件传给外层父组件的列表控件""" + def wheelEvent(self, event): + # 先执行原本正常的列表内部滚动逻辑 + super().wheelEvent(event) + # 核心黑科技:强制接收该事件。 + # 告诉 PyQt:"这个滚轮操作到我这里就结束了,无论如何不要传给外层的 ScrollArea" + event.accept() + + class Step10WatercolorPanel(QWidget): """步骤10:水色指数反演(散点 CSV 模式)""" @@ -109,89 +136,141 @@ class Step10WatercolorPanel(QWidget): self._categories: List[str] = [] self._all_formulas: List[Dict] = [] self._formula_list_widgets: Dict[str, QListWidgetItem] = {} + self._current_type_filter = "all" # 'all', 'ratio', 'concentration' self.init_ui() self._load_formulas() def init_ui(self): layout = QVBoxLayout() + # 注意:这里彻底删除了顶部的 title 和 hint 变量,保持全局页面清爽 - # ---- 标题 ---- - title = QLabel("步骤10:水色指数反演(散点 CSV 模式)") - title.setFont(QFont("Arial", 12, QFont.Bold)) - layout.addWidget(title) + # ========================================== + # 卡片 1:输入数据配置 + # ========================================== + input_group = QGroupBox("📁 输入数据") + # 【关键修正1】必须用 QVBoxLayout,如果用 QFormLayout 会导致标签文字重复! + input_layout = QVBoxLayout() + input_layout.setSpacing(16) + input_layout.setContentsMargins(20, 24, 20, 20) - # ---- 说明 ---- - hint = QLabel( - "读取 Step 4 生成的 sampling_spectra.csv 散点光谱," - "对每个采样点逐行套用 waterindex.csv 中勾选的公式," - "输出每公式一个 CSV(列:longitude, latitude, 公式值)。" - "结果可被 Step 11 直接以 ContentMapper 模式消费。" + self.formula_file = FileSelectWidget( + "内置公式库源:", + "CSV Files (*.csv);;All Files (*.*)" ) - hint.setWordWrap(True) - hint.setStyleSheet(f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};") - layout.addWidget(hint) - - # ---- 输入采样点数据 ---- - input_group = QGroupBox("输入采样点数据") - input_layout = QFormLayout() + self.formula_file.line_edit.setReadOnly(True) + self.formula_file.label.setMinimumWidth(100) + builtin_csv = self._find_waterindex_csv() + if builtin_csv: + self.formula_file.set_path(builtin_csv) + input_layout.addWidget(self.formula_file) self.sampling_csv_file = FileSelectWidget( "采样点 CSV:", "CSV Files (*.csv);;All Files (*.*)" ) - self.sampling_csv_file.line_edit.setPlaceholderText( - "选择 Step 4 输出的 sampling_spectra.csv" - ) - input_layout.addRow("采样点 CSV:", self.sampling_csv_file) + self.sampling_csv_file.label.setMinimumWidth(100) + input_layout.addWidget(self.sampling_csv_file) - # 数据规模提示(运行后回填,避免启动时强制 read_csv) - self.meta_label = QLabel("未加载采样点数据") - self.meta_label.setStyleSheet( - "background: #f0f0f0; padding: 4px 8px; border-radius: 4px; " - "font-size: 12px; color: #333;" - ) - input_layout.addRow("数据信息:", self.meta_label) + # 注意:彻底去掉了 self.meta_label 及其相关的布局代码 input_group.setLayout(input_layout) layout.addWidget(input_group) # ---- 公式选择 ---- formula_group = QGroupBox("公式选择") - formula_layout = QGridLayout() + formula_layout = QVBoxLayout() + formula_layout.setSpacing(16) + formula_layout.setContentsMargins(20, 24, 20, 20) - # 类别过滤 - formula_layout.addWidget(QLabel("按类别筛选:"), 0, 0) + # 顶部工具栏 (筛选 + 按钮) + toolbar_layout = QHBoxLayout() + toolbar_layout.setContentsMargins(0, 0, 0, 0) + toolbar_layout.setSpacing(10) + + toolbar_layout.addWidget(QLabel("按类别筛选:")) self.category_combo = QComboBox() - self.category_combo.currentTextChanged.connect(self._on_category_changed) - formula_layout.addWidget(self.category_combo, 0, 1, 1, 2) + self.category_combo.setStyleSheet(""" + QComboBox { padding: 4px 8px; border: 1px solid #C0C0C0; border-radius: 4px; min-height: 24px; } + """) + self.category_combo.currentIndexChanged.connect(self._on_category_changed) + toolbar_layout.addWidget(self.category_combo) + toolbar_layout.addSpacing(20) - # 全选/取消全选 - select_btn_layout = QHBoxLayout() self.select_all_btn = QPushButton("全选") - self.select_all_btn.setMaximumWidth(80) - self.select_all_btn.clicked.connect(self._select_all) - select_btn_layout.addWidget(self.select_all_btn) - self.deselect_all_btn = QPushButton("取消全选") - self.deselect_all_btn.setMaximumWidth(80) - self.deselect_all_btn.clicked.connect(self._deselect_all) - select_btn_layout.addWidget(self.deselect_all_btn) - select_btn_layout.addStretch() - formula_layout.addLayout(select_btn_layout, 0, 3) + self.select_ratio_btn = QPushButton("仅比值型") + self.select_conc_btn = QPushButton("仅浓度型") - # 公式列表 - self.formula_list = QListWidget() - self.formula_list.setSelectionMode(QAbstractItemView.MultiSelection) - self.formula_list.setMinimumHeight(200) - self.formula_list.itemChanged.connect(self._on_item_changed) - formula_layout.addWidget(self.formula_list, 1, 0, 1, 4) + # 强制指定标准的按钮样式,彻底解决没有边框的问题 + btn_style = """ + QPushButton { background-color: #FFFFFF; border: 1px solid #C0C0C0; border-radius: 4px; padding: 4px 16px; color: #333333; } + QPushButton:hover { background-color: #F0F0F0; border: 1px solid #0078D7; } + QPushButton:pressed { background-color: #E0E0E0; } + """ + # 把新按钮也加进循环里应用样式 + for btn in [self.select_all_btn, self.deselect_all_btn, self.select_ratio_btn, self.select_conc_btn]: + btn.setStyleSheet(btn_style) + btn.setFixedHeight(28) + btn.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) + + self.select_all_btn.clicked.connect(self._select_all) + self.deselect_all_btn.clicked.connect(self._deselect_all) + # 绑定新按钮的事件 + self.select_ratio_btn.clicked.connect(self._select_ratio) + self.select_conc_btn.clicked.connect(self._select_conc) + + toolbar_layout.addWidget(self.select_all_btn) + toolbar_layout.addWidget(self.deselect_all_btn) + toolbar_layout.addWidget(self.select_ratio_btn) + toolbar_layout.addWidget(self.select_conc_btn) + toolbar_layout.addStretch() + + # 新增统计标签,直观显示公式数量 + self.formula_count_label = QLabel("共 0 个公式") + self.formula_count_label.setStyleSheet("color: #666666; font-size: 13px; font-weight: bold; padding-right: 10px;") + toolbar_layout.addWidget(self.formula_count_label) + + formula_layout.addLayout(toolbar_layout) + + # 列表框 (使用刚刚定义的防穿透自定义类) + self.formula_list = NoScrollPassListWidget() + self.formula_list.setMinimumHeight(240) + self.formula_list.setWordWrap(True) # 开启自动换行,防止公式过长被截断 + + # 关键优化:取消原生选中的蓝色渐变框,放大复选框 + self.formula_list.setStyleSheet(""" + QListWidget { + border: 1px solid #D1D5DB; border-radius: 6px; outline: none; background-color: #FFFFFF; + } + QListWidget::item { + padding: 10px 8px; border-bottom: 1px solid #F3F4F6; + } + QListWidget::item:hover { + background-color: #F9FAFB; + } + /* 重点:彻底屏蔽原生的深蓝色选中渐变 */ + QListWidget::item:selected { + background-color: transparent; color: #333333; + } + QListWidget::indicator { + width: 18px; height: 18px; margin-right: 8px; + } + """) + # 绑定新方法:实现整行点击打勾 + self.formula_list.itemSelectionChanged.connect(self._on_selection_changed) + formula_layout.addWidget(self.formula_list) formula_group.setLayout(formula_layout) layout.addWidget(formula_group) - # ---- 输出设置 ---- + # ========================================== + # 卡片 3:输出设置 + # ========================================== output_group = QGroupBox("输出设置") - output_layout = QFormLayout() + # 【关键修正2】改成 QVBoxLayout,消除重复的"输出目录:" + output_layout = QVBoxLayout() + output_layout.setSpacing(16) + output_layout.setContentsMargins(20, 24, 20, 20) self.output_dir = FileSelectWidget( "输出目录:", @@ -200,7 +279,7 @@ class Step10WatercolorPanel(QWidget): self.output_dir.line_edit.setPlaceholderText( "留空 → 工作目录/10_WaterIndex_CSV" ) - output_layout.addRow("输出目录:", self.output_dir) + output_layout.addWidget(self.output_dir) output_group.setLayout(output_layout) layout.addWidget(output_group) @@ -217,15 +296,33 @@ class Step10WatercolorPanel(QWidget): self.progress_label.setStyleSheet("font-size: 11px; color: #666;") layout.addWidget(self.progress_label) - # ---- 启用 & 运行 ---- - self.enable_checkbox = QCheckBox("启用此步骤") - self.enable_checkbox.setChecked(True) - layout.addWidget(self.enable_checkbox) + # ========================================== + # 卡片 4:输出与执行 (与进度显示合并) + # ========================================== + execute_group = QGroupBox("🚀 输出与执行") + execute_layout = QVBoxLayout() + execute_layout.setSpacing(16) + execute_layout.setContentsMargins(20, 24, 20, 20) - self.run_btn = QPushButton("▶ 执行水色指数反演") - self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) - self.run_btn.clicked.connect(self.run_step) - layout.addWidget(self.run_btn) + # 把原本悬空的进度条放进这个卡片里,看起来更整洁 + execute_layout.addWidget(self.progress_bar) + execute_layout.addWidget(self.progress_label) + + # 完美对齐的底部按钮栏(彻底移除了 enable_checkbox) + action_layout = QHBoxLayout() + action_layout.addStretch() + + self.run_btn = QPushButton("独立运行步骤") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary')) + self.run_btn.setMinimumWidth(140) + # 统一改成连接到标准的 EventBus 方法 + self.run_btn.clicked.connect(self._on_run_single_clicked) + action_layout.addWidget(self.run_btn) + + execute_layout.addLayout(action_layout) + execute_group.setLayout(execute_layout) + + layout.addWidget(execute_group) layout.addStretch() self.setLayout(layout) @@ -244,7 +341,6 @@ class Step10WatercolorPanel(QWidget): def _load_formulas(self): """加载 waterindex.csv 中的公式""" if not self._waterindex_csv or not Path(self._waterindex_csv).exists(): - self.meta_label.setText("⚠️ waterindex.csv 未找到") return import csv @@ -252,37 +348,44 @@ class Step10WatercolorPanel(QWidget): try: with open(self._waterindex_csv, 'r', encoding='utf-8-sig') as f: reader = csv.DictReader(f) - self._all_formulas = list(reader) + for row in reader: + # 关键修复1:强制清理 CSV 键值中的所有前后空格,防止匹配失败导致丢失 + cleaned_row = {str(k).strip(): str(v).strip() for k, v in row.items() if k} + self._all_formulas.append(cleaned_row) except Exception as e: - self.meta_label.setText(f"⚠️ 加载公式失败: {e}") + QMessageBox.critical(self, "读取失败", f"加载公式失败: {e}") return # 提取所有类别 cats = set() for f in self._all_formulas: - c = f.get('Category', '').strip() + c = f.get('Category', '') if c: cats.add(c) self._categories = sorted(cats) + self.category_combo.blockSignals(True) self.category_combo.clear() - self.category_combo.addItem("全部") - self.category_combo.addItems(self._categories) + self.category_combo.addItem("全部", "全部") + for cat in self._categories: + display_name = CATEGORY_CHINESE_MAP.get(cat, cat) + self.category_combo.addItem(display_name, cat) + self.category_combo.blockSignals(False) - self._populate_list("全部") + # 关键修复2:不再动态渲染,而是一次性初始化所有公式 + self._init_list_items() - def _populate_list(self, category: str): - """根据类别填充公式列表""" + def _init_list_items(self): + """一次性加载所有公式项,按照类别进行排序""" + from PyQt5.QtGui import QColor self.formula_list.clear() self._formula_list_widgets.clear() - formulas_to_show = ( - [f for f in self._all_formulas if f.get('Category', '') == category] - if category != "全部" - else self._all_formulas - ) + # 核心优化:对 self._all_formulas 进行自定义排序 + # 排序规则:主要按 Category (类别/物质) 排,同类别下按 Formula_Name 排 + sorted_formulas = sorted(self._all_formulas, key=lambda x: (x.get('Category', ''), x.get('Formula_Name', ''))) - for f in formulas_to_show: + for f in sorted_formulas: name = f.get('Formula_Name', '') formula_str = f.get('Formula', '') cat = f.get('Category', '') @@ -291,52 +394,117 @@ class Step10WatercolorPanel(QWidget): item = QListWidgetItem() item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(Qt.Checked) + + # 存入隐式数据供后续逻辑使用 item.setData(Qt.UserRole, name) - item.setText( - f"☑ {name} [{cat}] ({ftype})\n {formula_str}" - ) - item.setToolTip(f"{name}\n{category}\n{formula_str}") + item.setData(Qt.UserRole + 1, cat) + item.setData(Qt.UserRole + 2, ftype) # 存入类型,供"仅比值型/仅浓度型"按钮区分 + + cat_cn = CATEGORY_CHINESE_MAP.get(cat, cat) + ftype_cn = "比值型" if ftype == 'ratio' else "浓度型" + + item.setText(f"【{cat_cn}】 {name} ({ftype_cn})\n公式: {formula_str}") + item.setToolTip(f"{name}\n{cat_cn}\n{formula_str}") + + bg_color = QColor("#FFFFFF") if ftype == 'ratio' else QColor("#E8F4F8") + item.setBackground(bg_color) + self.formula_list.addItem(item) self._formula_list_widgets[name] = item - def _on_category_changed(self, category: str): - self._populate_list(category) + self.formula_count_label.setText(f"共加载 {self.formula_list.count()} 个公式") + + def _refresh_visibility(self): + """统一管理公式列表的可见性(综合物质类别 + 公式类型)""" + # 获取当前选中的物质类别 + current_category = self.category_combo.currentData() + if not current_category: + current_category = "全部" + + visible_count = 0 + + for i in range(self.formula_list.count()): + item = self.formula_list.item(i) + item_cat = item.data(Qt.UserRole + 1) + item_type = item.data(Qt.UserRole + 2) + + # 条件1:物质类别匹配 + cat_match = (current_category == "全部" or item_cat == current_category) + # 条件2:公式类型匹配 + type_match = (self._current_type_filter == "all" or item_type == self._current_type_filter) + + if cat_match and type_match: + item.setHidden(False) + visible_count += 1 + else: + item.setHidden(True) + + # 更新统计标签(如果选了特定类型,标签上体现出来) + type_str = "" + if self._current_type_filter == 'ratio': + type_str = " (仅比值型)" + elif self._current_type_filter == 'concentration': + type_str = " (仅浓度型)" + + self.formula_count_label.setText(f"当前显示: {visible_count} 个{type_str}") + + def _on_category_changed(self, index: int): + # 切换物质类别时,重置类型过滤器(可选:如果不重置,就能叠加筛选。这里我们选择重置,体验更好) + self._current_type_filter = "all" + self._refresh_visibility() def _select_all(self): - for item in self.formula_list.selectedItems(): - item.setCheckState(Qt.Checked) - # 也全选当前显示的 + """恢复显示所有当前类别的公式,并全部勾选""" + self._current_type_filter = "all" + self._refresh_visibility() + for i in range(self.formula_list.count()): - it = self.formula_list.item(i) - it.setCheckState(Qt.Checked) + item = self.formula_list.item(i) + if not item.isHidden(): + item.setCheckState(Qt.Checked) def _deselect_all(self): + """保留当前显示状态,但全部取消勾选""" for i in range(self.formula_list.count()): - it = self.formula_list.item(i) - it.setCheckState(Qt.Unchecked) + item = self.formula_list.item(i) + if not item.isHidden(): + item.setCheckState(Qt.Unchecked) + + def _select_ratio(self): + """过滤出比值型并全部勾选""" + self._current_type_filter = "ratio" + self._refresh_visibility() + + for i in range(self.formula_list.count()): + item = self.formula_list.item(i) + if not item.isHidden(): + item.setCheckState(Qt.Checked) + + def _select_conc(self): + """过滤出浓度型并全部勾选""" + self._current_type_filter = "concentration" + self._refresh_visibility() + + for i in range(self.formula_list.count()): + item = self.formula_list.item(i) + if not item.isHidden(): + item.setCheckState(Qt.Checked) + + def _on_selection_changed(self): + """实现点击整行任意位置即可切换勾选状态""" + # 暂时阻塞信号,防止死循环 + self.formula_list.blockSignals(True) + for item in self.formula_list.selectedItems(): + # 翻转勾选状态 + new_state = Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked + item.setCheckState(new_state) + # 立即清除选中高亮状态,保持视觉干净 + item.setSelected(False) + self.formula_list.blockSignals(False) def _on_item_changed(self, item: QListWidgetItem): pass # 可扩展:实时统计选中数量 - def _refresh_sampling_meta(self): - """从 sampling_csv 路径快速 peek 数据规模(不触发公式计算)""" - path = self.sampling_csv_file.get_path().strip() - if not path: - self.meta_label.setText("未加载采样点数据") - return - if not Path(path).exists(): - self.meta_label.setText("⚠️ 采样点 CSV 不存在") - return - try: - import pandas as pd - df = pd.read_csv(path, encoding="utf-8-sig", nrows=0) - n_cols = len(df.columns) - self.meta_label.setText( - f"✅ 已选采样点 CSV({n_cols} 列,完整列数将在运行时打印)" - ) - except Exception as e: - self.meta_label.setText(f"⚠️ 读取失败: {e}") - def _get_selected_formula_names(self) -> List[str]: names = [] for i in range(self.formula_list.count()): @@ -369,7 +537,6 @@ class Step10WatercolorPanel(QWidget): def set_config(self, config: dict): if config.get('sampling_csv_path'): self.sampling_csv_file.set_path(config['sampling_csv_path']) - self._refresh_sampling_meta() if config.get('output_dir'): self.output_dir.set_path(config['output_dir']) if 'selected_formulas' in config: @@ -425,7 +592,6 @@ class Step10WatercolorPanel(QWidget): self.work_dir or '', sampling_path ).replace('\\', '/') self.sampling_csv_file.set_path(sampling_path) - self._refresh_sampling_meta() # 自动填入输出目录(默认 work_dir/10_WaterIndex_CSV/) if self.work_dir: