# --- START OF FILE config_editor_dialog_visual.py --- import json import os from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QFileDialog, QMessageBox, QLabel, QListWidget, QListWidgetItem, QStackedWidget, QWidget, QTableWidget, QTableWidgetItem, QHeaderView, QSizePolicy, QSplitter, QGroupBox, QGridLayout, QLineEdit, QInputDialog) # Added QInputDialog from PyQt6.QtCore import Qt class ConfigEditorDialogVisual(QDialog): """ 用于可视化编辑JSON配置文件的模态对话框 """ def __init__(self, config_file_path, parent=None): super().__init__(parent) self.setWindowTitle("编辑配置文件 (可视化)") self.resize(800, 600) self.config_file_path = config_file_path self._config_data = {} # Internal dictionary to hold the parsed JSON self.original_content_hash = None # To track changes self.init_ui() self.load_config_data() # Connect signals after UI is initialized and data is loaded self.parameter_list_widget.currentItemChanged.connect(self._load_parameter_details) self._load_parameter_details(self.parameter_list_widget.currentItem(), None) # Load initial item details if any def init_ui(self): """初始化界面""" main_layout = QVBoxLayout(self) # Splitter for parameter list on the left and details on the right splitter = QSplitter(Qt.Orientation.Horizontal) main_layout.addWidget(splitter) # Left Panel: Parameter List left_widget = QWidget() left_layout = QVBoxLayout(left_widget) left_layout.addWidget(QLabel("参数列表:")) self.parameter_list_widget = QListWidget() left_layout.addWidget(self.parameter_list_widget) # Buttons for adding/removing parameters param_btn_layout = QHBoxLayout() self.add_param_btn = QPushButton("添加参数") self.remove_param_btn = QPushButton("移除参数") param_btn_layout.addWidget(self.add_param_btn) param_btn_layout.addWidget(self.remove_param_btn) left_layout.addLayout(param_btn_layout) self.add_param_btn.clicked.connect(self._add_parameter) self.remove_param_btn.clicked.connect(self._remove_parameter) splitter.addWidget(left_widget) # Right Panel: Parameter Details (StackedWidget to potentially show empty state) self.details_stack = QStackedWidget() self.empty_details_widget = QLabel("请从左侧选择一个参数或添加新参数") self.empty_details_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) self.details_stack.addWidget(self.empty_details_widget) # Index 0 self.parameter_details_widget = QWidget() self._setup_parameter_details_ui(self.parameter_details_widget) self.details_stack.addWidget(self.parameter_details_widget) splitter.addWidget(self.details_stack) # Set initial sizes for splitter panes splitter.setSizes([200, 600]) # Bottom Buttons button_layout = QHBoxLayout() self.save_btn = QPushButton("保存") self.save_as_btn = QPushButton("另存为...") self.cancel_btn = QPushButton("取消") self.save_btn.clicked.connect(self.save_config) self.save_as_btn.clicked.connect(self.save_config_as) self.cancel_btn.clicked.connect(self.reject) button_layout.addWidget(self.save_btn) button_layout.addWidget(self.save_as_btn) button_layout.addStretch() button_layout.addWidget(self.cancel_btn) main_layout.addLayout(button_layout) # Initial state: Disable save buttons until something is loaded/changed self.save_btn.setEnabled(False) self.save_as_btn.setEnabled(False) self.remove_param_btn.setEnabled(False) def _setup_parameter_details_ui(self, parent_widget): """Setup the UI for the parameter details panel""" layout = QVBoxLayout(parent_widget) # Fixed fields fixed_fields_group = QGroupBox("基本信息") fixed_fields_layout = QGridLayout(fixed_fields_group) self.name_edit = QTextEdit() self.name_edit.setFixedHeight(40) # Allow multi-line but not too tall self.method_edit = QTextEdit() self.method_edit.setFixedHeight(40) self.param_level_edit = QTextEdit() self.param_level_edit.setFixedHeight(40) self.standard_edit = QTextEdit() self.standard_edit.setFixedHeight(40) fixed_fields_layout.addWidget(QLabel("项目名称:"), 0, 0) fixed_fields_layout.addWidget(self.name_edit, 0, 1) fixed_fields_layout.addWidget(QLabel("分析方法:"), 1, 0) fixed_fields_layout.addWidget(self.method_edit, 1, 1) fixed_fields_layout.addWidget(QLabel("项目分级:"), 2, 0) fixed_fields_layout.addWidget(self.param_level_edit, 2, 1) fixed_fields_layout.addWidget(QLabel("分级标准名称:"), 3, 0) # Renamed for clarity fixed_fields_layout.addWidget(self.standard_edit, 3, 1) layout.addWidget(fixed_fields_group) # Standard Levels Table levels_group = QGroupBox("标准等级") levels_layout = QVBoxLayout(levels_group) self.levels_table = QTableWidget(0, 2) self.levels_table.setHorizontalHeaderLabels(["等级名称 (Key)", "标准值 (Value)"]) self.levels_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.levels_table.verticalHeader().setVisible(False) levels_layout.addWidget(self.levels_table) # Buttons for adding/removing levels level_btn_layout = QHBoxLayout() self.add_level_btn = QPushButton("添加等级") self.remove_level_btn = QPushButton("移除等级") level_btn_layout.addWidget(self.add_level_btn) level_btn_layout.addWidget(self.remove_level_btn) levels_layout.addLayout(level_btn_layout) self.add_level_btn.clicked.connect(self._add_standard_level) self.remove_level_btn.clicked.connect(self._remove_standard_level) layout.addWidget(levels_group) layout.addStretch() # Push everything to the top # Connect signals for tracking changes (set dirty flag) self.name_edit.textChanged.connect(self._set_dirty) self.method_edit.textChanged.connect(self._set_dirty) self.param_level_edit.textChanged.connect(self._set_dirty) self.standard_edit.textChanged.connect(self._set_dirty) self.levels_table.cellChanged.connect(self._set_dirty) # Adding/removing items/levels also makes it dirty self.add_param_btn.clicked.connect(self._set_dirty) self.remove_param_btn.clicked.connect(self._set_dirty) self.add_level_btn.clicked.connect(self._set_dirty) self.remove_level_btn.clicked.connect(self._set_dirty) def load_config_data(self): """加载文件内容到内部字典并填充UI""" self._config_data = {} # Clear previous data self.parameter_list_widget.clear() if not self.config_file_path or not os.path.exists(self.config_file_path): QMessageBox.warning(self, "加载错误", f"配置文件不存在或路径无效:\n{self.config_file_path}") self.details_stack.setCurrentIndex(0) # Show empty message return try: with open(self.config_file_path, 'r', encoding='utf-8') as f: self._config_data = json.load(f) # Store a hash or string representation for change detection # Use json.dumps to handle ordering consistency self.original_content_hash = hash(json.dumps(self._config_data, sort_keys=True, ensure_ascii=False)) # Populate the list widget from the loaded data if "export_config" in self._config_data and isinstance(self._config_data["export_config"], dict): for key in sorted(self._config_data["export_config"].keys()): self.parameter_list_widget.addItem(key) if self.parameter_list_widget.count() > 0: self.parameter_list_widget.setCurrentRow(0) self.details_stack.setCurrentIndex(1) else: self.details_stack.setCurrentIndex(0) else: QMessageBox.warning(self, "加载错误", "配置文件缺少 'export_config' 键或格式不正确。") self.details_stack.setCurrentIndex(0) except json.JSONDecodeError as e: QMessageBox.critical(self, "加载错误", f"配置文件JSON格式无效:\n{e}") self._config_data = {} self.details_stack.setCurrentIndex(0) except Exception as e: QMessageBox.critical(self, "加载错误", f"无法读取或解析配置文件:\n{str(e)}") self._config_data = {} self.details_stack.setCurrentIndex(0) def _load_parameter_details(self, current_item, previous_item): """Load details of the newly selected parameter""" # Before loading new details, save changes from the previous item if previous_item: self._save_parameter_details(previous_item.text()) # Clear the details UI self.name_edit.clear() self.method_edit.clear() self.param_level_edit.clear() self.standard_edit.clear() self.levels_table.setRowCount(0) # Clear table if current_item: key = current_item.text() param_data = self._config_data.get("export_config", {}).get(key, {}) self.name_edit.setPlainText(param_data.get("项目名称", "")) self.method_edit.setPlainText(param_data.get("分析方法", "")) self.param_level_edit.setPlainText(param_data.get("项目分级", "")) self.standard_edit.setPlainText(param_data.get("分级标准", "")) # Load standard levels into the table levels = param_data.get("标准等级", {}) if isinstance(levels, dict): self.levels_table.setRowCount(len(levels)) # Sort levels by key (等级一, 等级二, etc.) for consistency # for i, (level_key, level_value) in enumerate(sort(levels.items())): for i, (level_key, level_value) in enumerate(levels.items()): self.levels_table.setItem(i, 0, QTableWidgetItem(str(level_key))) self.levels_table.setItem(i, 1, QTableWidgetItem(str(level_value).replace("\n", "\\n"))) else: print(f"警告: 参数 '{key}' 的 '标准等级' 不是一个有效的字典。") self.details_stack.setCurrentIndex(1) self.remove_param_btn.setEnabled(True) self._set_dirty(False) else: self.details_stack.setCurrentIndex(0) self.remove_param_btn.setEnabled(False) self._set_dirty(False) def _save_parameter_details(self, key): """Save details from the UI widgets back to the internal dictionary for the given key""" if not key or "export_config" not in self._config_data or key not in self._config_data["export_config"]: return # Nothing to save if key is invalid or not in data param_data = self._config_data["export_config"][key] param_data["项目名称"] = self.name_edit.toPlainText() param_data["分析方法"] = self.method_edit.toPlainText() param_data["项目分级"] = self.param_level_edit.toPlainText() param_data["分级标准"] = self.standard_edit.toPlainText() # Save standard levels from the table levels = {} for row in range(self.levels_table.rowCount()): key_item = self.levels_table.item(row, 0) value_item = self.levels_table.item(row, 1) if key_item and value_item: level_key = key_item.text().strip() level_value = value_item.text().strip().replace("\\n", "\n") if level_key: # Only save if level key is not empty levels[level_key] = level_value else: print(f"警告: 参数 '{key}' 中存在空的等级名称,该行将被忽略。") param_data["标准等级"] = levels # print(f"Saved details for parameter: {key}") # Debugging def _add_parameter(self): """Add a new parameter item""" key, ok = QInputDialog.getText(self, "添加新参数", "输入新的参数键 (例如: NEW):") if ok and key: key = key.strip() if not key: QMessageBox.warning(self, "输入错误", "参数键不能为空。") return if "export_config" not in self._config_data: self._config_data["export_config"] = {} if key in self._config_data["export_config"]: QMessageBox.warning(self, "输入错误", f"参数键 '{key}' 已存在。") return # Add a default structure for the new parameter self._config_data["export_config"][key] = { "项目名称": "新项目名称", "分析方法": "新分析方法", "项目分级": "新项目分级", "分级标准": "新分级标准", "标准等级": {} } # Add to list widget and select it self.parameter_list_widget.addItem(key) # Re-sort the list self.parameter_list_widget.sortItems() # Find and select the new item items = self.parameter_list_widget.findItems(key, Qt.MatchFlag.MatchExactly) if items: self.parameter_list_widget.setCurrentItem(items[0]) self._set_dirty() def _remove_parameter(self): """Remove the currently selected parameter item""" current_item = self.parameter_list_widget.currentItem() if not current_item: return # Nothing selected key_to_remove = current_item.text() reply = QMessageBox.question( self, "移除参数", f"确定要移除参数 '{key_to_remove}' 吗?\n此操作不可撤销。", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: # Save details of the item being removed *before* removing it from data # (This handles case where user edits an item then immediately removes it) self._save_parameter_details(key_to_remove) # Remove from internal data if "export_config" in self._config_data and key_to_remove in self._config_data["export_config"]: del self._config_data["export_config"][key_to_remove] # Remove from list widget row = self.parameter_list_widget.row(current_item) self.parameter_list_widget.takeItem(row) # Clear details panel and maybe select next item or show empty self._load_parameter_details(self.parameter_list_widget.currentItem(), current_item) self._set_dirty() def _add_standard_level(self): """Add a new row to the standard levels table""" current_item = self.parameter_list_widget.currentItem() if not current_item: QMessageBox.warning(self, "添加等级", "请先选择一个参数。") return row_count = self.levels_table.rowCount() self.levels_table.insertRow(row_count) # Add default items (editable) self.levels_table.setItem(row_count, 0, QTableWidgetItem("新等级")) self.levels_table.setItem(row_count, 1, QTableWidgetItem("")) self._set_dirty() def _remove_standard_level(self): """Remove selected rows from the standard levels table""" # selected_rows = sorted(list(set(index.row() for index in self.levels_table.selectedIndexes())), reverse=True) selected_rows = list(set(index.row() for index in self.levels_table.selectedIndexes())) if not selected_rows: QMessageBox.warning(self, "移除等级", "请选择要移除的等级行。") return for row in selected_rows: self.levels_table.removeRow(row) self._set_dirty() def _set_dirty(self, dirty=True): """Set or clear the dirty flag and update button states""" # This slot might receive a bool from cellChanged, or be called with True/False # Ensure it's treated as a boolean self._is_dirty = bool(dirty) # Enable save buttons if dirty self.save_btn.setEnabled(self._is_dirty) self.save_as_btn.setEnabled(True) # print(f"Dirty state: {self._is_dirty}") # Debugging def is_dirty(self): """Check if the configuration has been modified""" if not self._is_dirty: return False # Perform a more thorough check by saving current state to a temp dict # and comparing its hash with the original hash try: # Save details of the currently selected item first current_key = self.parameter_list_widget.currentItem() if current_key: self._save_parameter_details(current_key.text()) current_hash = hash(json.dumps(self._config_data, sort_keys=True, ensure_ascii=False)) return current_hash != self.original_content_hash except Exception as e: print(f"Error checking dirty state: {e}") # If hashing fails, assume it's dirty to be safe return True def validate_config(self): """Validate the structure of the internal config data before saving""" if "export_config" not in self._config_data or not isinstance(self._config_data["export_config"], dict): QMessageBox.warning(self, "验证失败", "配置缺少主键 'export_config' 或其格式不正确。") return False standards_dict = self._config_data["export_config"] error_keys = [] for key, value in standards_dict.items(): # Check for required fields and types if not isinstance(value, dict): error_keys.append(f"{key}: 结构不是字典") continue required_keys = ["项目名称", "分析方法", "项目分级", "分级标准", "标准等级"] if not all(k in value for k in required_keys): error_keys.append(f"{key}: 缺少必要字段") continue if not isinstance(value.get("标准等级"), dict): error_keys.append(f"{key}: '标准等级' 不是字典") continue if error_keys: QMessageBox.warning(self, "验证失败", "配置文件结构或内容存在问题:\n" + "\n".join(error_keys)) return False return True def save_config(self): """保存配置到当前文件路径""" # Save details of the currently selected item before saving the whole config current_item = self.parameter_list_widget.currentItem() if current_item: self._save_parameter_details(current_item.text()) if not self.validate_config(): return False if not self.config_file_path: # If no path is set (e.g., started with no file and added items), force Save As return self.save_config_as() return self._perform_save(self.config_file_path) def save_config_as(self): """另存配置""" file_path, _ = QFileDialog.getSaveFileName( self, "另存配置文件", self.config_file_path if self.config_file_path else "", "JSON 文件 (*.json);;所有文件 (*)" ) if file_path: # Save details of the currently selected item before saving the whole config current_item = self.parameter_list_widget.currentItem() if current_item: self._save_parameter_details(current_item.text()) if not self.validate_config(): return False if self._perform_save(file_path): # Update the current file path if Save As was successful self.config_file_path = file_path # Reload from the new path to reset original_content_hash and dirty state self.load_config_data() QMessageBox.information(self, "另存成功", f"配置文件已另存为:\n{self.config_file_path}") self.accept() return True return False return False def _perform_save(self, path): """Execute the saving of the config data to a file""" try: # Ensure directory exists dir_name = os.path.dirname(path) if dir_name and not os.path.exists(dir_name): os.makedirs(dir_name, exist_ok=True) with open(path, 'w', encoding='utf-8') as f: # Use indent=4 for readability and ensure_ascii=False for Chinese chars json.dump(self._config_data, f, indent=4, ensure_ascii=False) # Update the original content hash after saving self.original_content_hash = hash(json.dumps(self._config_data, sort_keys=True, ensure_ascii=False)) self._set_dirty(False) # Clear dirty flag # For standard save, don't close the dialog, just inform # QMessageBox.information(self, "保存成功", f"配置文件已保存:\n{path}") # Removed msgbox for standard save return True except Exception as e: QMessageBox.critical(self, "保存失败", f"保存文件时出错:\n{str(e)}") return False def closeEvent(self, event): """Handle close event, ask to save if dirty""" if self.is_dirty(): reply = QMessageBox.question( self, "保存更改", "配置文件已修改,是否保存?", QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Save ) if reply == QMessageBox.StandardButton.Save: if self.save_config(): event.accept() else: event.ignore() elif reply == QMessageBox.StandardButton.Discard: event.accept() else: event.ignore() else: event.accept() # Method to retrieve the potentially new file path after saving (e.g., Save As) def get_new_config_path(self): return self.config_file_path # Example running (for testing this dialog itself) if __name__ == '__main__': from PyQt6.QtWidgets import QApplication import sys app = QApplication(sys.argv) # Create a dummy config file for testing if it doesn't exist test_dir = "temp_config_editor_test" os.makedirs(test_dir, exist_ok=True) test_config_path = os.path.join(test_dir, "test_config.json") dummy_data = { "export_config": { "AB": { "项目名称": "测试硼", "分析方法": "测试方法", "项目分级": "硼级别", "分级标准": "硼标准", "标准等级": { "等级1": ">2.00", "等级2": "1.00~2.00" } }, "ZN": { "项目名称": "测试锌", "分析方法": "锌方法", "项目分级": "锌级别", "分级标准": "锌标准", "标准等级": { "A级": ">3.00", "B级": "2.00~3.00", "C级": "<2.00" } } } } if not os.path.exists(test_config_path): try: with open(test_config_path, 'w', encoding='utf-8') as f: json.dump(dummy_data, f, indent=4, ensure_ascii=False) print(f"Created dummy config file: {test_config_path}") except Exception as e: print(f"Error creating dummy file: {e}") test_config_path = "" # Clear path if creation fails dialog = ConfigEditorDialogVisual(test_config_path) result = dialog.exec() if result == QDialog.DialogCode.Accepted: final_path = dialog.get_new_config_path() print(f"Dialog accepted. Final config path: {final_path}") else: print("Dialog rejected or cancelled.") # Optional: Clean up test files/directory # import shutil # if os.path.exists(test_dir): # shutil.rmtree(test_dir) sys.exit(app.exec())