import tkinter as tk from tkinter import ttk, messagebox import json import uuid import re # 用于验证IP地址和端口 from utils import center_toplevel_window class SettingsApp(tk.Toplevel): def __init__(self, master, on_save_callback, devices_data): super().__init__(master) self.master = master self.on_save_callback = on_save_callback self.original_devices_data = json.loads(json.dumps(devices_data)) # 深拷贝一份原始数据 self.devices = devices_data # 引用主应用中的设备列表 self.title("设备设置") self.geometry("800x600") self.create_widgets() self.load_devices_to_treeview() # --- 修正点:在所有部件创建并加载数据后,再居中窗口 --- center_toplevel_window(self, master) # --- 修正点结束 --- self.protocol("WM_DELETE_WINDOW", self.on_closing) # 使 SettingsApp 成为模态窗口 self.transient(master) self.grab_set() self.wait_window(self) def create_widgets(self): # Top buttons frame top_buttons_frame = ttk.Frame(self) top_buttons_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=5) ttk.Button(top_buttons_frame, text="添加设备", command=self.add_device).pack(side=tk.LEFT, padx=5) ttk.Button(top_buttons_frame, text="编辑设备", command=self.edit_selected_device).pack(side=tk.LEFT, padx=5) ttk.Button(top_buttons_frame, text="删除设备", command=self.delete_selected_device).pack(side=tk.LEFT, padx=5) ttk.Button(top_buttons_frame, text="保存并关闭", command=self.on_closing).pack(side=tk.RIGHT, padx=5) # Device list Treeview self.device_tree = ttk.Treeview(self, columns=("Name", "Type", "Protocol", "Address", "Port", "Keep Alive"), show="headings") self.device_tree.heading("Name", text="设备名称") self.device_tree.heading("Type", text="设备类型") self.device_tree.heading("Protocol", text="协议") self.device_tree.heading("Address", text="地址") self.device_tree.heading("Port", text="端口") self.device_tree.heading("Keep Alive", text="保持连接") self.device_tree.column("Name", width=120) self.device_tree.column("Type", width=100) self.device_tree.column("Protocol", width=80) self.device_tree.column("Address", width=120) self.device_tree.column("Port", width=60) self.device_tree.column("Keep Alive", width=80, anchor=tk.CENTER) self.device_tree.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.device_tree.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.device_tree.config(yscrollcommand=scrollbar.set) self.device_tree.bind("", self.edit_selected_device) def load_devices_to_treeview(self): """将设备数据加载到 Treeview""" for i in self.device_tree.get_children(): self.device_tree.delete(i) for device in self.devices: self.device_tree.insert("", tk.END, iid=device["id"], values=(device["name"], device["type"], device["protocol"], device["address"], device["port"], "是" if device["keep_alive"] else "否")) def add_device(self): """打开一个新窗口以添加设备""" self.DeviceEditor(self, None) def edit_selected_device(self, event=None): """编辑选中的设备""" selected_item = self.device_tree.focus() if selected_item: device_id = selected_item device = next((d for d in self.devices if d["id"] == device_id), None) if device: self.DeviceEditor(self, device) else: messagebox.showerror("错误", "未找到选中的设备。") else: messagebox.showwarning("提示", "请选择一个设备进行编辑。") def delete_selected_device(self): """删除选中的设备""" selected_item = self.device_tree.focus() if selected_item: if messagebox.askyesno("确认删除", "确定要删除选中的设备吗?", parent=self): device_id = selected_item self.devices[:] = [d for d in self.devices if d["id"] != device_id] self.on_save_callback(self.devices) # 通知主应用保存 self.load_devices_to_treeview() else: messagebox.showwarning("提示", "请选择一个设备进行删除。", parent=self) def on_closing(self): """关闭窗口时保存配置""" # 检查是否有设备被删除,通知 network_manager 关闭连接 original_device_ids = {d["id"] for d in self.original_devices_data} current_device_ids = {d["id"] for d in self.devices} deleted_device_ids = original_device_ids - current_device_ids for device_id in deleted_device_ids: # 找到对应的原始设备信息,以便关闭连接 deleted_device = next((d for d in self.original_devices_data if d["id"] == device_id), None) if deleted_device and deleted_device.get("keep_alive"): self.master.network_manager.close_connection_for_device(deleted_device) self.on_save_callback(self.devices) self.destroy() class DeviceEditor(tk.Toplevel): def __init__(self, master_app, device_data=None): super().__init__(master_app) self.master_app = master_app # SettingsApp instance self.device_data = device_data # Existing device data or None for new device self.title("编辑设备" if device_data else "添加设备") self.geometry("470x650") # 增加高度以确保所有控件可见 self.create_widgets() if self.device_data: self._load_device_data() # --- 修正点:在所有部件创建并加载数据后,再居中窗口 --- center_toplevel_window(self, master_app) # --- 修正点结束 --- self.transient(master_app) self.grab_set() self.wait_window(self) def create_widgets(self): main_frame = ttk.Frame(self, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) # Device Name ttk.Label(main_frame, text="设备名称:").grid(row=0, column=0, sticky=tk.W, pady=2) self.name_entry = ttk.Entry(main_frame) self.name_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), pady=2) # Device Type ttk.Label(main_frame, text="设备类型:").grid(row=1, column=0, sticky=tk.W, pady=2) self.type_var = tk.StringVar(self) self.type_entry = ttk.Entry(main_frame, textvariable=self.type_var) self.type_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=2) # Protocol ttk.Label(main_frame, text="协议:").grid(row=2, column=0, sticky=tk.W, pady=2) self.protocol_var = tk.StringVar(self) self.protocol_optionmenu = ttk.OptionMenu(main_frame, self.protocol_var, "TCP", "TCP", "UDP") self.protocol_optionmenu.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=2) # Address ttk.Label(main_frame, text="地址 (IP/域名):").grid(row=3, column=0, sticky=tk.W, pady=2) self.address_entry = ttk.Entry(main_frame) self.address_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=2) # Port ttk.Label(main_frame, text="端口:").grid(row=4, column=0, sticky=tk.W, pady=2) self.port_entry = ttk.Entry(main_frame) self.port_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), pady=2) # Keep Alive self.keep_alive_var = tk.BooleanVar(self) self.keep_alive_checkbutton = ttk.Checkbutton(main_frame, text="保持连接", variable=self.keep_alive_var) self.keep_alive_checkbutton.grid(row=5, column=0, columnspan=2, sticky=tk.W, pady=5) # Commands Frame self.commands_frame = ttk.LabelFrame(main_frame, text="指令列表", padding="5") self.commands_frame.grid(row=6, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) self._create_commands_widgets(self.commands_frame) # Save/Cancel Buttons button_frame = ttk.Frame(main_frame) button_frame.grid(row=7, column=0, columnspan=2, sticky=tk.E, pady=10) ttk.Button(button_frame, text="保存", command=self._save_device).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="取消", command=self.destroy).pack(side=tk.LEFT, padx=5) main_frame.columnconfigure(1, weight=1) main_frame.rowconfigure(6, weight=1) # 允许 commands_frame 垂直扩展 main_frame.rowconfigure(7, weight=0) # 确保按钮行不会扩展 def _create_commands_widgets(self, parent_frame): # Clear existing command widgets for widget in parent_frame.winfo_children(): widget.destroy() self.command_entries = [] # List to hold (name_entry, data_entry, type_var, type_optionmenu) for each command # Add Command Button ttk.Button(parent_frame, text="添加指令", command=self._add_command_row).pack(pady=5) # Scrollable frame for commands self.command_canvas = tk.Canvas(parent_frame) self.command_scrollbar = ttk.Scrollbar(parent_frame, orient="vertical", command=self.command_canvas.yview) self.command_scrollable_frame = ttk.Frame(self.command_canvas) # Bind the canvas's configure event to resize the inner frame self.command_canvas.bind('', self._on_command_canvas_resize) self.command_canvas.create_window((0, 0), window=self.command_scrollable_frame, anchor="nw", tags="scrollable_frame_window") self.command_canvas.configure(yscrollcommand=self.command_scrollbar.set) self.command_canvas.pack(side="left", fill="both", expand=True) self.command_scrollbar.pack(side="right", fill="y") # Initial command rows if any if self.device_data and self.device_data.get("commands"): for command in self.device_data["commands"]: self._add_command_row(command) def _on_command_canvas_resize(self, event): # Update the width of the scrollable frame to match the canvas width self.command_canvas.itemconfig("scrollable_frame_window", width=event.width) # Update scrollregion self.command_canvas.configure(scrollregion=self.command_canvas.bbox("all")) def _add_command_row(self, command_data=None): row_frame = ttk.Frame(self.command_scrollable_frame, padding=5, relief=tk.RIDGE, borderwidth=1) row_frame.pack(fill=tk.X, expand=True, pady=2) # Make row_frame expand horizontally # Command Name ttk.Label(row_frame, text="名称:").pack(side=tk.LEFT) name_entry = ttk.Entry(row_frame, width=10) name_entry.pack(side=tk.LEFT, padx=2) # Command Data ttk.Label(row_frame, text="数据:").pack(side=tk.LEFT) data_entry = ttk.Entry(row_frame, width=20) # Keep initial width data_entry.pack(side=tk.LEFT, padx=2, fill=tk.X, expand=True) # Allow data_entry to expand # Command Type ttk.Label(row_frame, text="类型:").pack(side=tk.LEFT) type_var = tk.StringVar(self) type_optionmenu = ttk.OptionMenu(row_frame, type_var, "text", "text", "hex") type_optionmenu.pack(side=tk.LEFT, padx=2) # Delete button delete_btn = ttk.Button(row_frame, text="X", command=lambda: self._delete_command_row(row_frame)) delete_btn.pack(side=tk.RIGHT) self.command_entries.append((row_frame, name_entry, data_entry, type_var, type_optionmenu)) if command_data: name_entry.insert(0, command_data.get("name", "")) data_entry.insert(0, command_data.get("data", "")) type_var.set(command_data.get("type", "text")) self.command_scrollable_frame.update_idletasks() self.command_canvas.config(scrollregion=self.command_canvas.bbox("all")) def _delete_command_row(self, row_frame): for i, (frame, _, _, _, _) in enumerate(self.command_entries): if frame == row_frame: self.command_entries.pop(i) row_frame.destroy() break self.command_scrollable_frame.update_idletasks() self.command_canvas.config(scrollregion=self.command_canvas.bbox("all")) def _load_device_data(self): self.name_entry.insert(0, self.device_data["name"]) self.type_var.set(self.device_data["type"]) self.address_entry.insert(0, self.device_data["address"]) self.port_entry.insert(0, str(self.device_data["port"])) self.protocol_var.set(self.device_data["protocol"]) self.keep_alive_var.set(self.device_data["keep_alive"]) def _save_device(self): name = self.name_entry.get().strip() device_type = self.type_var.get().strip() protocol = self.protocol_var.get() address = self.address_entry.get().strip() port_str = self.port_entry.get().strip() keep_alive = self.keep_alive_var.get() # Validation if not name: messagebox.showerror("错误", "设备名称不能为空。") return if not device_type: messagebox.showerror("错误", "设备类型不能为空。") return if not address: messagebox.showerror("错误", "地址不能为空。") return if not port_str: messagebox.showerror("错误", "端口不能为空。") return try: port = int(port_str) if not (1 <= port <= 65535): raise ValueError except ValueError: messagebox.showerror("错误", "端口号必须是1到65535之间的整数。") return # Basic IP/Domain validation if not re.match(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$|^([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.[a-zA-Z]{2,6}$", address): messagebox.showerror("错误", "地址格式不正确 (IP地址或域名)。") return commands = [] for _, name_entry, data_entry, type_var, _ in self.command_entries: cmd_name = name_entry.get().strip() cmd_data = data_entry.get().strip() cmd_type = type_var.get() if not cmd_name: messagebox.showerror("错误", "指令名称不能为空。") return if not cmd_data: messagebox.showerror("错误", "指令数据不能为空。") return if cmd_type == "hex": try: bytes.fromhex(cmd_data) # Validate hex string except ValueError: messagebox.showerror("错误", f"指令 '{cmd_name}' 的十六进制数据格式不正确。") return commands.append({ "name": cmd_name, "data": cmd_data, "type": cmd_type }) if not commands: messagebox.showerror("错误", "请至少添加一个指令。") return # Update existing device or add new one if self.device_data: # Find the device by ID and update it for i, d in enumerate(self.master_app.devices): if d["id"] == self.device_data["id"]: self.master_app.devices[i].update({ "name": name, "type": device_type, "protocol": protocol, "address": address, "port": port, "keep_alive": keep_alive, "commands": commands }) self.master_app.master.log_message(f"设备 '{name}' (ID: {self.device_data['id'][:8]}...) 已更新。", "blue") break else: # Add new device new_device = { "id": str(uuid.uuid4()), "name": name, "type": device_type, "protocol": protocol, "address": address, "port": port, "keep_alive": keep_alive, "commands": commands } self.master_app.devices.append(new_device) self.master_app.master.log_message(f"新设备 '{name}' (ID: {new_device['id'][:8]}...) 已添加。", "blue") self.master_app.on_save_callback(self.master_app.devices) # 通知 SettingsApp 保存并刷新 self.master_app.load_devices_to_treeview() # 刷新设备列表显示 self.destroy() # Close the editor window