Part3 - update一个双向有主的child

本文介绍如何在现有的父对象中添加新的子对象,通过JPA和JDO两种方式实现双向拥有的多对一关系更新。示例使用Book和Chapter两个实体类进行说明。
Hello again and welcome to Episode 3 of JDO/JPA Snippets That Work. Today's episode is called......

Updating A Bidrectional Owned One-To-Many With A New Child

All the way back in episode one we demonstrated how to create both a parent and a child of a bidirectional, owned, one-to-many relationship
at the same time. This week we're going to see how to add a child to an existing parent. We'll use the same model objects we used in episode one:

JPA:
@Entity
public class Book {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Key id;

private String title;

@OneToMany(mappedBy = "book", cascade = CascadeType.ALL)
private List<Chapter> chapters = new ArrayList<Chapter>();

// getters and setters
}

@Entity
public class Chapter {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Key id;

private String title;
private int numPages;

@ManyToOne(fetch = FetchType.LAZY)
private Book book;

// getters and setters
}


Now let's assume we've already created a book with a few chapters in the datastore and we want to add a brand new chapter to a Book with a given id (we'll assume someone else is creating and closing an EntityManager named 'em' for us):

public void addChapterToBook(EntityManager em, Key bookKey, Chapter chapter) {
em.getTransaction().begin();
try {
Book b = em.find(Book.class, bookKey);
if (b == null) {
throw new RuntimeException("Book " + bookKey + " not found!");
}
b.getChapters().add(chapter);
em.getTransaction().commit();
} finally {
if (em.getTransaction().isActive()) {
em.getTransaction().rollback();
}
}
}



JDO:

@PersistenceCapable(identityType = IdentityType.APPLICATION, detachable = "true")
public class Book {

@PrimaryKey
@Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
private Key id;

private String title;

@Persistent(mappedBy = "book")
@Element(dependent = "true")
@Order(extensions = @Extension(vendorName="datanucleus", key="list-ordering", value="id asc"))
private List<Chapter> chapters = new ArrayList<Chapter>();

// getters and setters
}

@PersistenceCapable(identityType = IdentityType.APPLICATION, detachable = "true")
public class Chapter {
@PrimaryKey
@Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
private Key id;

private String title;
private int numPages;

@Persistent
private Book book;

// getters and setters
}


Now let's assume we've already created a book with a few chapters in the datastore and we want to add a brand new chapter to a Book with a given id (we'll assume someone else is creating and closing a PersistenceManager named 'pm' for us):


public void addChapterToBook(PersistenceManager pm, Key bookKey, Chapter chapter) {
pm.currentTransaction().begin();
try {
// throws a runtime exception if book is not found
Book b = pm.getObjectById(Book.class, bookKey);
b.getChapters().add(chapter);
pm.currentTransaction().commit();
} finally {
if (pm.currentTransaction().isActive()) {
pm.currentTransaction().rollback();
}
}
}

--------------------------------

The interesting thing about both of these examples is that we're not making any explicit calls to save the new Chapter. We look up the Book identified by the Key that was passed into the function and then we manipulate the persistent state of the object by manipulating the POJO that was returned by em.fetch/pm.getObjectById. JPA and JDO both have mechanisms that allow them to monitor the objects that you've looked up for changes. Ever wonder what exactly the enhancer is doing to your classes? It's adding hooks so that the persistence framework gets notified when things change (among other things). This allows JPA and JDO to automatically flush your changes to the datastore when you commit your transaction. If you wanted to modify the title of the Book or the number of pages in an existing Chapter the approach would be exactly the same: Start a transaction, look up the Book, make your changes, commit your transaction. Whether you're using JPA or JDO your changes will be persisted for you without any explicit calls to change the persistent state. This is a prime example of how JPA and JDO facilitate "transparent persistence."

转载自:[url]http://groups.google.com/group/google-appengine-java/browse_thread/thread/28d9c3b99df25e43#[/url]
import os import subprocess import platform import tkinter as tk from tkinter import ttk, messagebox, simpledialog, filedialog, Menu from openpyxl import Workbook, load_workbook from openpyxl.styles import Alignment, Font, Border, Side import json import webbrowser import datetime import time EXCEL_FILE = &#39;零件登记.xlsx&#39; PAIR_FILE = &#39;零件配对.json&#39; TITLE_FONT = Font(name=&#39;微软雅黑&#39;, size=18, bold=True) BODY_FONT = Font(name=&#39;微软雅黑&#39;, size=11) TITLE_ALIGN = Alignment(horizontal=&#39;center&#39;, vertical=&#39;center&#39;) BODY_ALIGN = Alignment(horizontal=&#39;center&#39;, vertical=&#39;center&#39;) thin = Side(border_style=&#39;thin&#39;, color=&#39;000000&#39;) all_border = Border(top=thin, left=thin, right=thin, bottom=thin) class PairManager: """零件配对管理类""" def __init__(self, master): self.master = master self.master.title("零件配对管理") self.master.geometry("600x400") self.master.resizable(False, False) # 设置为模态窗口,禁用窗口 self.master.grab_set() self.master.transient(master.master) # 加载现有配对 self.pairs = self.load_pairs() # 创建界面 self.create_widgets() self.update_tree() # 设置窗口居中 self.center_window() def center_window(self): """使窗口居中显示""" self.master.update_idletasks() width = self.master.winfo_width() height = self.master.winfo_height() x = (self.master.winfo_screenwidth() // 2) - (width // 2) y = (self.master.winfo_screenheight() // 2) - (height // 2) self.master.geometry(f&#39;+{x}+{y}&#39;) def create_widgets(self): # 框架 main_frame = ttk.Frame(self.master) main_frame.pack(fill=&#39;both&#39;, expand=True, padx=10, pady=10) # 使用Treeview替代Listbox tree_frame = ttk.Frame(main_frame) tree_frame.pack(fill=&#39;both&#39;, expand=True) # 创建带滚动条的Treeview scrollbar = ttk.Scrollbar(tree_frame) scrollbar.pack(side=&#39;right&#39;, fill=&#39;y&#39;) self.tree = ttk.Treeview( tree_frame, columns=(&#39;零件编号&#39;, &#39;零件名称&#39;), show=&#39;headings&#39;, selectmode=&#39;extended&#39;, yscrollcommand=scrollbar.set ) self.tree.pack(fill=&#39;both&#39;, expand=True) scrollbar.config(command=self.tree.yview) # 设置列 self.tree.heading(&#39;零件编号&#39;, text=&#39;零件编号&#39;, anchor=&#39;center&#39;) self.tree.heading(&#39;零件名称&#39;, text=&#39;零件名称&#39;, anchor=&#39;center&#39;) self.tree.column(&#39;零件编号&#39;, width=150, anchor=&#39;center&#39;) self.tree.column(&#39;零件名称&#39;, width=400, anchor=&#39;center&#39;) # 按钮框架 btn_frame = ttk.Frame(main_frame) btn_frame.pack(fill=&#39;x&#39;, pady=10) ttk.Button(btn_frame, text="添加", command=self.add_pair).pack(side=&#39;left&#39;, padx=5) ttk.Button(btn_frame, text="编辑", command=self.edit_pair).pack(side=&#39;left&#39;, padx=5) ttk.Button(btn_frame, text="删除", command=self.delete_pair).pack(side=&#39;left&#39;, padx=5) ttk.Button(btn_frame, text="保存并关闭", command=self.save_and_close).pack(side=&#39;right&#39;, padx=5) def load_pairs(self): """加载零件配对数据""" try: if os.path.exists(PAIR_FILE): with open(PAIR_FILE, &#39;r&#39;, encoding=&#39;utf-8&#39;) as f: return json.load(f) except Exception as e: messagebox.showerror("错误", f"加载配对文件失败: {str(e)}") return {} def save_pairs(self): """保存零件配对数据""" try: with open(PAIR_FILE, &#39;w&#39;, encoding=&#39;utf-8&#39;) as f: json.dump(self.pairs, f, ensure_ascii=False, indent=2) return True except Exception as e: messagebox.showerror("错误", f"保存配对文件失败: {str(e)}") return False def update_tree(self): """更新Treeview显示""" # 清空现有数据 for item in self.tree.get_children(): self.tree.delete(item) # 添加新数据 for code, name in self.pairs.items(): self.tree.insert(&#39;&#39;, &#39;end&#39;, values=(code, name)) def add_pair(self): """添加新配对""" dialog = PairDialog(self.master, "添加零件配对") if dialog.result: code, name = dialog.result if code in self.pairs: messagebox.showwarning("警告", f"零件编号 {code} 已存在!") else: self.pairs[code] = name self.update_tree() def edit_pair(self): """编辑现有配对""" selection = self.tree.selection() if not selection: messagebox.showwarning("提示", "请先选择一个配对进行编辑") return # 如果只选了一个,执行单编辑 if len(selection) == 1: item = selection[0] code = self.tree.item(item, &#39;values&#39;)[0] name = self.pairs[code] dialog = PairDialog(self.master, "编辑零件配对", code, name) if dialog.result: new_code, new_name = dialog.result if new_code != code and new_code in self.pairs: messagebox.showwarning("警告", f"零件编号 {new_code} 已存在!") else: if new_code != code: del self.pairs[code] self.pairs[new_code] = new_name self.update_tree() else: # 多选编辑 - 批量编辑名称 selected_codes = [self.tree.item(item, &#39;values&#39;)[0] for item in selection] dialog = BatchEditDialog(self.master, "批量编辑零件名称", selected_codes, self.pairs) if dialog.result: for code in selected_codes: self.pairs[code] = dialog.result self.update_tree() def delete_pair(self): """删除配对""" selection = self.tree.selection() if not selection: messagebox.showwarning("提示", "请先选择一个配对") return selected_codes = [self.tree.item(item, &#39;values&#39;)[0] for item in selection] if messagebox.askyesno("确认", f"确定要删除选中的 {len(selected_codes)} 个配对吗?"): for code in selected_codes: del self.pairs[code] self.update_tree() def save_and_close(self): """保存并关闭窗口""" if self.save_pairs(): self.master.destroy() class PairDialog(simpledialog.Dialog): """零件配对对话框""" def __init__(self, parent, title, code="", name=""): self.initial_code = code self.initial_name = name super().__init__(parent, title) def body(self, frame): ttk.Label(frame, text="零件编号:").grid(row=0, column=0, padx=5, pady=5, sticky=&#39;e&#39;) self.code_var = tk.StringVar(value=self.initial_code) code_entry = ttk.Entry(frame, textvariable=self.code_var, width=25) code_entry.grid(row=0, column=1, padx=5, pady=5, sticky=&#39;we&#39;) ttk.Label(frame, text="零件名称:").grid(row=1, column=0, padx=5, pady=5, sticky=&#39;e&#39;) self.name_var = tk.StringVar(value=self.initial_name) name_entry = ttk.Entry(frame, textvariable=self.name_var, width=25) name_entry.grid(row=1, column=1, padx=5, pady=5, sticky=&#39;we&#39;) return code_entry # 初始焦点 def validate(self): code = self.code_var.get().strip() name = self.name_var.get().strip() if not code or not name: messagebox.showwarning("输入错误", "零件编号和名称都不能为空") return False return True def apply(self): self.result = (self.code_var.get().strip(), self.name_var.get().strip()) class BatchEditDialog(simpledialog.Dialog): """批量编辑对话框""" def __init__(self, parent, title, codes, pair_dict): self.codes = codes self.pair_dict = pair_dict super().__init__(parent, title) def body(self, frame): ttk.Label(frame, text=f"批量编辑 {len(self.codes)} 个零件").grid(row=0, column=0, columnspan=2, pady=10) # 显示前几个零件名称 preview_text = "\n".join([f"{code}: {self.pair_dict[code]}" for code in self.codes[:3]]) if len(self.codes) > 3: preview_text += f"\n...等 {len(self.codes)} 个零件" preview = tk.Text(frame, height=6, width=40, font=(&#39;微软雅黑&#39;, 9)) preview.grid(row=1, column=0, columnspan=2, padx=10, pady=5) preview.insert(tk.END, preview_text) preview.config(state=tk.DISABLED) ttk.Label(frame, text="新零件名称:").grid(row=2, column=0, padx=5, pady=10, sticky=&#39;e&#39;) self.name_var = tk.StringVar() name_entry = ttk.Entry(frame, textvariable=self.name_var, width=25) name_entry.grid(row=2, column=1, padx=5, pady=10, sticky=&#39;we&#39;) return name_entry def validate(self): name = self.name_var.get().strip() if not name: messagebox.showwarning("输入错误", "零件名称不能为空") return False return True def apply(self): self.result = self.name_var.get().strip() class HelpDialog: """帮助对话框""" def __init__(self, parent): self.parent = parent self.window = tk.Toplevel(parent) self.window.title("使用帮助") self.window.geometry("600x450") self.window.resizable(False, False) # 设置为模态窗口,禁用窗口 self.window.grab_set() self.window.transient(parent) # 创建标签页 self.notebook = ttk.Notebook(self.window) self.notebook.pack(fill=&#39;both&#39;, expand=True, padx=10, pady=10) # 基本操作标签 basic_frame = ttk.Frame(self.notebook) self.notebook.add(basic_frame, text="基本操作") self.create_basic_tab(basic_frame) # 快捷键标签 shortcut_frame = ttk.Frame(self.notebook) self.notebook.add(shortcut_frame, text="快捷键") self.create_shortcut_tab(shortcut_frame) # 零件管理标签 pair_frame = ttk.Frame(self.notebook) self.notebook.add(pair_frame, text="零件管理") self.create_pair_tab(pair_frame) # 底部按钮 btn_frame = ttk.Frame(self.window) btn_frame.pack(fill=&#39;x&#39;, padx=10, pady=10) ttk.Button(btn_frame, text="关闭", command=self.window.destroy).pack(side=&#39;right&#39;) self.center_window() def center_window(self): """使窗口居中显示""" self.window.update_idletasks() width = self.window.winfo_width() height = self.window.winfo_height() x = (self.window.winfo_screenwidth() // 2) - (width // 2) y = (self.window.winfo_screenheight() // 2) - (height // 2) self.window.geometry(f&#39;+{x}+{y}&#39;) def create_basic_tab(self, frame): content = """ 1. 添加零件 - 在左侧输入零件编号、名称和数量 - 点击【添加并写入 Excel】按钮或按 Ctrl+Enter - 零件信息将添加到右侧列表并保存到Excel 2. 编辑零件 - 在右侧列表右键点击零件 - 选择【编辑】修改单个零件 - 选择【批量编辑数量】修改多个零件数量 3. 删除零件 - 在右侧列表右键点击零件 - 选择【删除】删除选中零件 - 或按 Delete 键删除 4. 加载Excel - 点击【加载Excel】按钮导入已有数据 - 支持覆盖当前数据或追加数据 5. 打开Excel - 点击【打开 Excel】查看生成的Excel文件 """ text = tk.Text(frame, wrap=tk.WORD, font=(&#39;微软雅黑&#39;, 10)) text.pack(fill=&#39;both&#39;, expand=True, padx=10, pady=10) text.insert(tk.END, content.strip()) text.config(state=tk.DISABLED) def create_shortcut_tab(self, frame): content = """ 常用快捷键列表: Ctrl + Enter : 添加当前零件并写入Excel Delete : 删除选中的零件 Ctrl + E : 打开零件配对管理 Ctrl + O : 打开Excel文件 Ctrl + L : 加载Excel数据 Ctrl + H : 打开帮助文档 """ text = tk.Text(frame, wrap=tk.WORD, font=(&#39;微软雅黑&#39;, 10)) text.pack(fill=&#39;both&#39;, expand=True, padx=10, pady=10) text.insert(tk.END, content.strip()) text.config(state=tk.DISABLED) def create_pair_tab(self, frame): content = """ 零件配对管理: 1. 添加配对 - 点击【添加】按钮创建新零件配对 - 输入零件编号和名称 2. 编辑配对 - 选择单个配对点击【编辑】修改 - 选择多个配对点击【编辑】批量修改名称 3. 删除配对 - 选择单个或多个配对 - 点击【删除】按钮删除 4. 自动填充 -界面输入零件编号时自动填充名称 - 输入零件名称时自动填充编号 """ text = tk.Text(frame, wrap=tk.WORD, font=(&#39;微软雅黑&#39;, 10)) text.pack(fill=&#39;both&#39;, expand=True, padx=10, pady=10) text.insert(tk.END, content.strip()) text.config(state=tk.DISABLED) class LogDialog: """日志对话框(只包含操作日志)""" def __init__(self, parent, logs): self.parent = parent self.logs = logs self.window = tk.Toplevel(parent) self.window.title("操作日志") self.window.geometry("800x500") self.window.resizable(True, True) # 设置为模态窗口,禁用窗口 self.window.grab_set() self.window.transient(parent) # 创建框架 frame = ttk.Frame(self.window) frame.pack(fill=&#39;both&#39;, expand=True, padx=10, pady=10) # 创建带滚动条的文本框 scrollbar = ttk.Scrollbar(frame) scrollbar.pack(side=&#39;right&#39;, fill=&#39;y&#39;) self.log_text = tk.Text(frame, wrap=tk.WORD, font=(&#39;微软雅黑&#39;, 10), yscrollcommand=scrollbar.set) self.log_text.pack(fill=&#39;both&#39;, expand=True, padx=10, pady=10) scrollbar.config(command=self.log_text.yview) # 添加日志内容 for log in self.logs: self.log_text.insert(tk.END, log + "\n") self.log_text.config(state=tk.DISABLED) # 底部按钮 btn_frame = ttk.Frame(self.window) btn_frame.pack(fill=&#39;x&#39;, padx=10, pady=10) ttk.Button(btn_frame, text="关闭", command=self.window.destroy).pack(side=&#39;right&#39;) self.center_window() def center_window(self): """使窗口居中显示""" self.window.update_idletasks() width = self.window.winfo_width() height = self.window.winfo_height() x = (self.window.winfo_screenwidth() // 2) - (width // 2) y = (self.window.winfo_screenheight() // 2) - (height // 2) self.window.geometry(f&#39;+{x}+{y}&#39;) class PartRegApp: def __init__(self, root): self.root = root root.title("零件登记工具") root.geometry("950x580") root.resizable(False, False) # 创建菜单栏 self.create_menu() # 加载零件配对数据 self.pair_dict = self.load_pair_data() # 初始化日志 self.logs = [] self.log("程序启动") # ---------- 顶部:设备型号 ---------- top_frame = ttk.Frame(root) top_frame.pack(fill=&#39;x&#39;, padx=5, pady=5) ttk.Label(top_frame, text="设备型号:").pack(side=&#39;left&#39;) self.device_model = tk.StringVar(value=&#39;DS-9C&#39;) ttk.Entry(top_frame, textvariable=self.device_model, width=15).pack(side=&#39;left&#39;, padx=5) # ---------- ---------- paned = ttk.PanedWindow(root, orient=&#39;horizontal&#39;) paned.pack(fill=&#39;both&#39;, expand=True, padx=5, pady=5) # 左侧 left = ttk.Frame(paned, width=300) paned.add(left) left.pack_propagate(False) # 零件信息输入框 frm = ttk.LabelFrame(left, text="零件信息", padding=10) frm.pack(fill=&#39;x&#39;) ttk.Label(frm, text="零件编号:").pack(anchor=&#39;w&#39;) self.code_var = tk.StringVar() # 使用Combobox替代Entry self.code_combo = ttk.Combobox(frm, textvariable=self.code_var, width=20) self.code_combo.pack(fill=&#39;x&#39;, pady=2) self.code_combo.bind(&#39;<KeyRelease>&#39;, self.on_code_keyrelease) self.code_combo.bind(&#39;<<ComboboxSelected>>&#39;, self.on_code_selected) # 绑定变量变化事件 self.code_var.trace_add(&#39;write&#39;, self.on_code_var_changed) self.code_timer = None # 用于延迟检索的计时器 ttk.Label(frm, text="零件名称:").pack(anchor=&#39;w&#39;, pady=(8, 0)) self.name_var = tk.StringVar() # 使用Combobox替代Entry self.name_combo = ttk.Combobox(frm, textvariable=self.name_var, width=20) self.name_combo.pack(fill=&#39;x&#39;, pady=2) self.name_combo.bind(&#39;<KeyRelease>&#39;, self.on_name_keyrelease) self.name_combo.bind(&#39;<<ComboboxSelected>>&#39;, self.on_name_selected) # 绑定变量变化事件 self.name_var.trace_add(&#39;write&#39;, self.on_name_var_changed) self.name_timer = None # 用于延迟检索的计时器 ttk.Label(frm, text="数量:").pack(anchor=&#39;w&#39;, pady=(8, 0)) self.qty_var = tk.StringVar() qty_entry = ttk.Entry(frm, textvariable=self.qty_var) qty_entry.pack(fill=&#39;x&#39;, pady=2) # 添加快捷键绑定 qty_entry.bind(&#39;<Return>&#39;, lambda event: self.add_and_write()) qty_entry.bind(&#39;<KP_Enter>&#39;, lambda event: self.add_and_write()) # 小键盘回车 btn = ttk.Button(frm, text="添加并写入 Excel (Ctrl+Enter)", command=self.add_and_write) btn.pack(pady=(15, 0)) # 操作按钮 - 垂直排列 btn_frame = ttk.Frame(left) btn_frame.pack(fill=&#39;x&#39;, pady=(10, 5)) # 按钮垂直排列 ttk.Button(btn_frame, text="加载Excel (Ctrl+L)", command=self.load_excel).pack(side=&#39;top&#39;, fill=&#39;x&#39;, padx=5, pady=2) ttk.Button(btn_frame, text="打开 Excel (Ctrl+O)", command=self.open_excel).pack(side=&#39;top&#39;, fill=&#39;x&#39;, padx=5, pady=2) ttk.Button(btn_frame, text="零件配对管理 (Ctrl+E)", command=self.open_pair_manager).pack(side=&#39;top&#39;, fill=&#39;x&#39;, padx=5, pady=2) # 右侧预览 right = ttk.Frame(paned) paned.add(right) self.tree = ttk.Treeview(right, columns=(&#39;零件编号&#39;, &#39;零件名称&#39;, &#39;数量&#39;), show=&#39;headings&#39;, height=22) self.tree.pack(fill=&#39;both&#39;, expand=True) for col in (&#39;零件编号&#39;, &#39;零件名称&#39;, &#39;数量&#39;): self.tree.heading(col, text=col) self.tree.column(col, anchor=&#39;center&#39;, stretch=True) # 添加滚动条 scrollbar = ttk.Scrollbar(right, orient="vertical", command=self.tree.yview) scrollbar.pack(side=&#39;right&#39;, fill=&#39;y&#39;) self.tree.configure(yscrollcommand=scrollbar.set) self.tree.bind(&#39;<Button-3>&#39;, self.show_menu) self.menu = tk.Menu(root, tearoff=0) self.menu.add_command(label="编辑", command=self.edit_selected) self.menu.add_command(label="批量编辑数量", command=self.batch_edit_qty) self.menu.add_command(label="删除", command=self.delete_selected) # 状态栏 status_frame = ttk.Frame(root) status_frame.pack(side=&#39;bottom&#39;, fill=&#39;x&#39;) # 左侧状态信息 self.status = tk.StringVar(value="就绪") status_bar = ttk.Label(status_frame, textvariable=self.status, relief=&#39;sunken&#39;, anchor=&#39;w&#39;, padding=(5, 2)) status_bar.pack(side=&#39;left&#39;, fill=&#39;x&#39;, expand=True) # 右侧时间和公司信息 self.time_var = tk.StringVar() time_label = ttk.Label(status_frame, textvariable=self.time_var, relief=&#39;sunken&#39;, anchor=&#39;e&#39;, padding=(5, 2)) time_label.pack(side=&#39;right&#39;, fill=&#39;x&#39;) # 更新时间显示 self.update_time() self.data = [] self.updating = False # 防止自动填充时触发循环事件 self.cached_codes = list(self.pair_dict.keys()) self.cached_names = list(self.pair_dict.values()) # 绑定全局快捷键 self.root.bind(&#39;<Control-Return>&#39;, lambda event: self.add_and_write()) self.root.bind(&#39;<Control-o>&#39;, lambda event: self.open_excel()) self.root.bind(&#39;<Control-O>&#39;, lambda event: self.open_excel()) self.root.bind(&#39;<Control-l>&#39;, lambda event: self.load_excel()) self.root.bind(&#39;<Control-L>&#39;, lambda event: self.load_excel()) self.root.bind(&#39;<Control-e>&#39;, lambda event: self.open_pair_manager()) self.root.bind(&#39;<Control-E>&#39;, lambda event: self.open_pair_manager()) self.root.bind(&#39;<Control-h>&#39;, lambda event: self.show_help()) self.root.bind(&#39;<Control-H>&#39;, lambda event: self.show_help()) self.root.bind(&#39;<Delete>&#39;, lambda event: self.delete_selected()) self.root.bind(&#39;<KP_Delete>&#39;, lambda event: self.delete_selected()) # 小键盘Delete # 初始焦点 self.code_combo.focus_set() def create_menu(self): """创建菜单栏""" menubar = Menu(self.root) self.root.config(menu=menubar) # 文件菜单 file_menu = Menu(menubar, tearoff=0) file_menu.add_command(label="打开Excel (Ctrl+O)", command=self.open_excel) file_menu.add_command(label="加载Excel (Ctrl+L)", command=self.load_excel) file_menu.add_separator() file_menu.add_command(label="退出", command=self.root.quit) menubar.add_cascade(label="文件", menu=file_menu) # 日志菜单 log_menu = Menu(menubar, tearoff=0) log_menu.add_command(label="操作日志", command=lambda: self.show_logs()) menubar.add_cascade(label="日志", menu=log_menu) # 帮助菜单 help_menu = Menu(menubar, tearoff=0) help_menu.add_command(label="使用帮助 (Ctrl+H)", command=self.show_help) help_menu.add_command(label="关于", command=self.show_about) menubar.add_cascade(label="帮助", menu=help_menu) def log(self, message): """记录日志""" timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") log_entry = f"[{timestamp}] {message}" self.logs.append(log_entry) # 保持日志数量不超过100条 if len(self.logs) > 100: self.logs.pop(0) def show_logs(self): """显示日志对话框""" LogDialog(self.root, self.logs) def load_pair_data(self): """加载零件配对数据""" if os.path.exists(PAIR_FILE): try: with open(PAIR_FILE, &#39;r&#39;, encoding=&#39;utf-8&#39;) as f: return json.load(f) except: return {} return {} # ---------- 自动补全功能(优化版)---------- def filter_items(self, items, pattern): """根据输入模式过滤项目""" if not pattern: return items return [item for item in items if pattern.lower() in item.lower()] def on_code_keyrelease(self, event): """零件编号输入框按键释放事件(优化版)""" # 忽略方向键和回车键 if event.keysym in (&#39;Up&#39;, &#39;Down&#39;, &#39;Return&#39;, &#39;Escape&#39;): return # 取消之前的定时器(如果存在) if self.code_timer: self.root.after_cancel(self.code_timer) # 设置新的定时器(900毫秒后执行) self.code_timer = self.root.after(900, self.process_code_input) def process_code_input(self): """处理零件编号输入(延迟执行)""" code = self.code_var.get().strip() # 过滤并更新下拉列表 filtered_codes = self.filter_items(self.cached_codes, code) self.code_combo[&#39;values&#39;] = filtered_codes # 如果输入框不为空,显示下拉列表 if code: self.code_combo.event_generate(&#39;<Down>&#39;) # 自动填充零件名称 if not self.updating and code in self.pair_dict: self.updating = True self.name_var.set(self.pair_dict[code]) self.updating = False def on_name_keyrelease(self, event): """零件名称输入框按键释放事件(优化版)""" # 忽略方向键和回车键 if event.keysym in (&#39;Up&#39;, &#39;Down&#39;, &#39;Return&#39;, &#39;Escape&#39;): return # 取消之前的定时器(如果存在) if self.name_timer: self.root.after_cancel(self.name_timer) # 设置新的定时器(500毫秒后执行) self.name_timer = self.root.after(500, self.process_name_input) def process_name_input(self): """处理零件名称输入(延迟执行)""" name = self.name_var.get().strip() # 过滤并更新下拉列表 filtered_names = self.filter_items(self.cached_names, name) self.name_combo[&#39;values&#39;] = filtered_names # 如果输入框不为空,显示下拉列表 if name: self.name_combo.event_generate(&#39;<Down>&#39;) # 自动填充零件编号 if not self.updating: for code, n in self.pair_dict.items(): if n == name: self.updating = True self.code_var.set(code) self.updating = False break def on_code_selected(self, event): """零件编号下拉选项选择事件""" code = self.code_var.get().strip() if code in self.pair_dict: self.name_var.set(self.pair_dict[code]) def on_name_selected(self, event): """零件名称下拉选项选择事件""" name = self.name_var.get().strip() for code, n in self.pair_dict.items(): if n == name: self.code_var.set(code) break # ---------- 输入框变化事件 ---------- def on_code_var_changed(self, *args): """零件编号变化时的事件处理""" if self.updating: return code = self.code_var.get().strip() # 如果零件编号被清空,则同时清空零件名称 if not code: self.name_var.set(&#39;&#39;) def on_name_var_changed(self, *args): """零件名称变化时的事件处理""" if self.updating: return name = self.name_var.get().strip() # 如果零件名称被清空,则同时清空零件编号 if not name: self.code_var.set(&#39;&#39;) # ---------- 配对管理 ---------- def open_pair_manager(self): """打开零件配对管理窗口""" manager_window = tk.Toplevel(self.root) PairManager(manager_window) # 当管理窗口关闭后重新加载配对数据 manager_window.wait_window() self.pair_dict = self.load_pair_data() # 更新缓存 self.cached_codes = list(self.pair_dict.keys()) self.cached_names = list(self.pair_dict.values()) # 更新下拉列表 self.code_combo[&#39;values&#39;] = self.cached_codes self.name_combo[&#39;values&#39;] = self.cached_names self.log("零件配对数据已更新") # ---------- 工具 ---------- def set_status(self, msg): self.status.set(msg) self.root.update_idletasks() def update_time(self): """更新时间显示""" current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.time_var.set(f"广州图森机械设备有限公司 | {current_time}") self.root.after(1000, self.update_time) # 每秒更新一次 def add_and_write(self): code = self.code_var.get().strip() name = self.name_var.get().strip() qty_str = self.qty_var.get().strip() if not all([code, name, qty_str]): messagebox.showwarning("提示", "请完整填写零件信息!") return try: qty = int(qty_str) except ValueError: messagebox.showwarning("提示", "数量必须是整数!") return # 检查是否已存在相同零件 existing_index = None for i, item in enumerate(self.data): if item[0] == code and item[1] == name: existing_index = i break if existing_index is not None: # 更新现有零件的数量 old_qty = self.data[existing_index][2] new_qty = old_qty + qty self.data[existing_index] = (code, name, new_qty) self.set_status(f"已更新:{code} 数量从 {old_qty} 增加到 {new_qty}") self.log(f"更新零件: {code} ({name}) 数量 {old_qty} → {new_qty}") else: # 添加新零件 self.data.append((code, name, qty)) self.set_status(f"已添加:{code} × {qty}") self.log(f"添加零件: {code} ({name}) × {qty}") self.refresh_tree() self.write_excel() self.clear_inputs() def clear_inputs(self): self.code_var.set(&#39;&#39;) self.name_var.set(&#39;&#39;) self.qty_var.set(&#39;&#39;) self.code_combo.focus_set() # 焦点回到零件编号输入框 def refresh_tree(self): for item in self.tree.get_children(): self.tree.delete(item) for row in self.data: self.tree.insert(&#39;&#39;, &#39;end&#39;, values=row) def write_excel(self): wb = Workbook() ws = wb.active ws.title = "零件登记" # 标题 title = self.device_model.get().strip() or "零件登记" ws.merge_cells(&#39;A1:C1&#39;) ws[&#39;A1&#39;] = title ws[&#39;A1&#39;].font = TITLE_FONT ws[&#39;A1&#39;].alignment = TITLE_ALIGN ws.row_dimensions[1].height = 45 * 0.75 # 表头 headers = [&#39;零件编号&#39;, &#39;零件名称&#39;, &#39;数量&#39;] for idx, h in enumerate(headers, 1): cell = ws.cell(row=2, column=idx, value=h) cell.font = BODY_FONT cell.alignment = BODY_ALIGN cell.border = all_border # 数据(数量列写入为数字) for r, (code, name, qty) in enumerate(self.data, 3): ws.cell(row=r, column=1, value=code).font = BODY_FONT ws.cell(row=r, column=2, value=name).font = BODY_FONT ws.cell(row=r, column=3, value=qty).font = BODY_FONT for c in range(1, 4): ws.cell(row=r, column=c).alignment = BODY_ALIGN ws.cell(row=r, column=c).border = all_border # 固定列宽 ws.column_dimensions[&#39;A&#39;].width = 15 ws.column_dimensions[&#39;B&#39;].width = 20 ws.column_dimensions[&#39;C&#39;].width = 10 try: wb.save(EXCEL_FILE) self.log(f"Excel文件已保存: {EXCEL_FILE}") except PermissionError: messagebox.showerror("错误", "文件被占用,请关闭后重试!") self.log("保存Excel失败: 文件被占用") def open_excel(self): if not os.path.isfile(EXCEL_FILE): messagebox.showwarning("提示", f"未找到 {EXCEL_FILE}\n请先添加数据生成文件。") return system = platform.system() try: if system == &#39;Windows&#39;: os.startfile(EXCEL_FILE) elif system == &#39;Darwin&#39;: subprocess.run([&#39;open&#39;, EXCEL_FILE]) else: subprocess.run([&#39;xdg-open&#39;, EXCEL_FILE]) self.set_status(f"已打开 Excel:{EXCEL_FILE}") self.log(f"打开Excel文件: {EXCEL_FILE}") except Exception as e: messagebox.showerror("错误", f"无法打开文件:\n{e}") self.log(f"打开Excel失败: {str(e)}") # ---------- 加载Excel文件 ---------- def load_excel(self): """加载已有的Excel文件到预览区""" # 询问用户加载方式 if self.data: choice = messagebox.askquestion("加载选项", "请选择加载方式:\n\n&#39;是&#39; - 覆盖当前数据\n&#39;否&#39; - 追加到当前数据\n&#39;取消&#39; - 中止操作", icon=&#39;question&#39;, type=&#39;yesnocancel&#39;) if choice == &#39;cancel&#39;: return else: choice = &#39;yes&#39; # 没有数据时默认覆盖 # 弹出文件选择对话框 file_path = filedialog.askopenfilename( title="选择零件登记表", filetypes=[("Excel文件", "*.xlsx"), ("所有文件", "*.*")] ) if not file_path: return # 用户取消选择 try: # 加载工作簿 wb = load_workbook(file_path) ws = wb.active # 如果是追加模式,保留当前数据 if choice == &#39;no&#39;: new_data = self.data.copy() self.log(f"开始追加Excel数据: {file_path}") else: # 覆盖模式 - 清空当前数据 new_data = [] # 读取标题(设备型号) if ws[&#39;A1&#39;].value: self.device_model.set(ws[&#39;A1&#39;].value) self.log(f"开始覆盖加载Excel数据: {file_path}") # 读取数据行(从第三行开始) for row in ws.iter_rows(min_row=3, values_only=True): # 跳过空行 if not any(row[:3]): continue # 确保数据格式正确 code = str(row[0]) if row[0] else "" name = str(row[1]) if row[1] else "" qty = row[2] if row[2] is not None else 0 # 如果是数字类型,直接使用 if isinstance(qty, (int, float)): qty = int(qty) else: try: qty = int(qty) except: qty = 0 # 添加到数据列表 new_data.append((code, name, qty)) # 更新数据 self.data = new_data # 刷新Treeview self.refresh_tree() # 更新状态 self.set_status(f"已加载文件: {os.path.basename(file_path)},共 {len(self.data)} 条记录") # 保存到默认位置 self.write_excel() self.log(f"成功加载Excel: {file_path},共 {len(self.data)} 条记录") except Exception as e: messagebox.showerror("加载错误", f"无法加载Excel文件:\n{str(e)}") self.log(f"加载Excel失败: {str(e)}") # ---------- 右键菜单功能 ---------- def show_menu(self, event): if self.tree.identify_row(event.y): self.menu.post(event.x_root, event.y_root) def edit_selected(self): """编辑单个选中的零件""" selected = self.tree.selection() if not selected or len(selected) > 1: return item = selected[0] values = self.tree.item(item, &#39;values&#39;) idx = self.tree.index(item) # 创建编辑对话框 dialog = tk.Toplevel(self.root) dialog.title("编辑零件") dialog.geometry("300x200") dialog.resizable(False, False) # 设置为模态窗口,禁用窗口 dialog.grab_set() dialog.transient(self.root) # 零件编号 ttk.Label(dialog, text="零件编号:").place(x=20, y=20) code_var = tk.StringVar(value=values[0]) code_entry = ttk.Entry(dialog, textvariable=code_var, width=20) code_entry.place(x=100, y=20) # 零件名称 ttk.Label(dialog, text="零件名称:").place(x=20, y=60) name_var = tk.StringVar(value=values[1]) name_entry = ttk.Entry(dialog, textvariable=name_var, width=20) name_entry.place(x=100, y=60) # 数量 ttk.Label(dialog, text="数量:").place(x=20, y=100) qty_var = tk.StringVar(value=values[2]) qty_entry = ttk.Entry(dialog, textvariable=qty_var, width=10) qty_entry.place(x=100, y=100) def save_changes(): code = code_var.get().strip() name = name_var.get().strip() qty_str = qty_var.get().strip() if not all([code, name, qty_str]): messagebox.showwarning("输入错误", "所有字段都必须填写!") return try: qty = int(qty_str) except ValueError: messagebox.showwarning("输入错误", "数量必须是整数!") return # 记录原始值 original_code, original_name, original_qty = self.data[idx] # 更新数据 self.data[idx] = (code, name, qty) self.refresh_tree() self.write_excel() self.set_status(f"已更新: {code}") self.log(f"编辑零件: {original_code}({original_name})×{original_qty} → {code}({name})×{qty}") dialog.destroy() ttk.Button(dialog, text="保存", command=save_changes).place(x=120, y=140) ttk.Button(dialog, text="取消", command=dialog.destroy).place(x=200, y=140) # 居中对话框 dialog.update_idletasks() width = dialog.winfo_width() height = dialog.winfo_height() x = (dialog.winfo_screenwidth() // 2) - (width // 2) y = (dialog.winfo_screenheight() // 2) - (height // 2) dialog.geometry(f&#39;+{x}+{y}&#39;) code_entry.focus_set() def batch_edit_qty(self): """批量编辑选中零件的数量""" selected = self.tree.selection() if not selected: return # 创建批量编辑对话框 dialog = tk.Toplevel(self.root) dialog.title("批量编辑数量") dialog.geometry("300x150") dialog.resizable(False, False) # 设置为模态窗口,禁用窗口 dialog.grab_set() dialog.transient(self.root) # 显示选中零件数量 ttk.Label(dialog, text=f"选中 {len(selected)} 个零件").pack(pady=10) # 新数量输入框 ttk.Label(dialog, text="新数量:").pack() qty_var = tk.StringVar() qty_entry = ttk.Entry(dialog, textvariable=qty_var, width=10) qty_entry.pack() def apply_changes(): qty_str = qty_var.get().strip() if not qty_str: messagebox.showwarning("输入错误", "数量不能为空!") return try: new_qty = int(qty_str) except ValueError: messagebox.showwarning("输入错误", "数量必须是整数!") return # 更新所有选中零件的数量 for item in selected: idx = self.tree.index(item) code, name, old_qty = self.data[idx] self.data[idx] = (code, name, new_qty) self.log(f"批量更新: {code}({name}) 数量 {old_qty} → {new_qty}") self.refresh_tree() self.write_excel() self.set_status(f"已批量更新 {len(selected)} 个零件的数量为 {new_qty}") dialog.destroy() btn_frame = ttk.Frame(dialog) btn_frame.pack(pady=10) ttk.Button(btn_frame, text="应用", command=apply_changes).pack(side=&#39;left&#39;, padx=10) ttk.Button(btn_frame, text="取消", command=dialog.destroy).pack(side=&#39;left&#39;, padx=10) # 居中对话框 dialog.update_idletasks() width = dialog.winfo_width() height = dialog.winfo_height() x = (dialog.winfo_screenwidth() // 2) - (width // 2) y = (dialog.winfo_screenheight() // 2) - (height // 2) dialog.geometry(f&#39;+{x}+{y}&#39;) qty_entry.focus_set() def delete_selected(self): """删除选中的零件""" selected = self.tree.selection() if not selected: return # 获取所有要删除的零件信息 to_delete = [] for item in selected: idx = self.tree.index(item) to_delete.append(self.data[idx]) if not messagebox.askyesno("确认", f"确定要删除选中的 {len(selected)} 个零件吗?"): return # 从后往前删除,避免索引变化问题 for item in sorted(selected, reverse=True): idx = self.tree.index(item) deleted_part = self.data.pop(idx) self.tree.delete(item) self.log(f"删除零件: {deleted_part[0]}({deleted_part[1]}) × {deleted_part[2]}") # 更新状态 if to_delete: deleted_codes = [item[0] for item in to_delete] if len(deleted_codes) > 3: codes_str = f"{&#39;, &#39;.join(deleted_codes[:3])} 等共 {len(deleted_codes)} 个零件" else: codes_str = ", ".join(deleted_codes) self.set_status(f"已删除: {codes_str}") self.write_excel() # ---------- 帮助功能 ---------- def show_help(self): """显示帮助对话框""" HelpDialog(self.root) self.log("打开帮助文档") def show_about(self): """显示关于对话框""" about = tk.Toplevel(self.root) about.title("关于") about.geometry("360x250") about.resizable(False, False) # 设置为模态窗口,禁用窗口 about.grab_set() about.transient(self.root) # 框架 main_frame = ttk.Frame(about) main_frame.pack(fill=&#39;both&#39;, expand=True, padx=20, pady=20) # 公司名称 ttk.Label(main_frame, text="广州图森机械设备有限公司", font=(&#39;微软雅黑&#39;, 14, &#39;bold&#39;)).pack(pady=(10, 5)) # 软件名称和版本 ttk.Label(main_frame, text="零件登记工具", font=(&#39;微软雅黑&#39;, 12)).pack(pady=5) ttk.Label(main_frame, text="版本: 2.0", font=(&#39;微软雅黑&#39;, 10)).pack() # 分隔线 ttk.Separator(main_frame, orient=&#39;horizontal&#39;).pack(fill=&#39;x&#39;, pady=10) # 网站链接 website_frame = ttk.Frame(main_frame) website_frame.pack(pady=5) ttk.Label(website_frame, text="公司网站:", font=(&#39;微软雅黑&#39;, 9)).pack(side=&#39;left&#39;) # 创建可点击的链接 link = ttk.Label(website_frame, text="www.tusen.cn", font=(&#39;微软雅黑&#39;, 9, &#39;underline&#39;), foreground="blue", cursor="hand2") link.pack(side=&#39;left&#39;, padx=5) link.bind("<Button-1>", lambda e: webbrowser.open("http://www.tusen.cn/")) # 版权信息 ttk.Label(main_frame, text="© 2025 广州图森机械设备有限公司", font=(&#39;微软雅黑&#39;, 9)).pack(side=&#39;bottom&#39;, pady=10) # 居中窗口 about.update_idletasks() width = about.winfo_width() height = about.winfo_height() x = (about.winfo_screenwidth() // 2) - (width // 2) y = (about.winfo_screenheight() // 2) - (height // 2) about.geometry(f&#39;+{x}+{y}&#39;) # ---------- 其他功能 ---------- def clear_data(self): """清空当前数据""" if not self.data: return if messagebox.askyesno("确认", "确定要清空所有零件数据吗?"): self.log(f"清空所有零件数据,共 {len(self.data)} 条记录") self.data = [] self.refresh_tree() self.write_excel() self.set_status("已清空所有数据") # ---------------- 运行 ---------------- if __name__ == &#39;__main__&#39;: root = tk.Tk() app = PartRegApp(root) root.mainloop() 这是我写的一个工具,现在的功能基本已经完善好了,你再帮我分析看下,我这个工具还需要哪些功能呢?或者哪些地方还需要优化呢?给我个建议看看
08-15
帮忙分析,如下代码中,什么时候会提示“服务器IP地址无效”,如何解决,我在运行过程中有在窗口输入服务器IP。 import sys import os import ctypes import json import subprocess import threading import time import psutil import pythoncom from datetime import datetime, timedelta from PyQt5.QtWidgets import ( QApplication, QMainWindow, QTabWidget, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QCheckBox, QComboBox, QSpinBox, QGroupBox, QScrollArea, QTextEdit, QFileDialog, QMessageBox, QDoubleSpinBox, QSizePolicy, QListWidget, QAbstractItemView, QListWidgetItem ) from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer from PyQt5.QtGui import QColor, QIcon import wmi # Matplotlib 导入 import matplotlib matplotlib.use(&#39;Qt5Agg&#39;) import matplotlib.pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure import matplotlib.dates as mdates from matplotlib import ticker """# 检查管理员权限并请求提升 def is_admin(): try: return ctypes.windll.shell32.IsUserAnAdmin() except: return False if not is_admin(): # 请求管理员权限 ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, " ".join(sys.argv), None, 1) sys.exit(0)""" # 设置中文字体支持 plt.rcParams[&#39;font.sans-serif&#39;] = [&#39;SimHei&#39;, &#39;Microsoft YaHei&#39;, &#39;SimSun&#39;, &#39;KaiTi&#39;] # 设置中文字体 plt.rcParams[&#39;axes.unicode_minus&#39;] = False # 解决负号显示问题 # 网卡事件监听线程 class NicMonitorThread(QThread): nic_event = pyqtSignal(dict) # 发送事件:{&#39;name&#39;: 网卡名, &#39;event&#39;: &#39;up&#39; or &#39;down&#39;, &#39;time&#39;: 时间字符串} error_signal = pyqtSignal(str) # 错误信号 def __init__(self): super().__init__() self.running = True self.c = None self.watcher = None self.last_status = {} # 记录每个网卡的最后状态 def init_wmi(self): """初始化WMI连接""" try: pythoncom.CoInitialize() # 添加COM初始化 self.c = wmi.WMI() return True except Exception as e: self.error_signal.emit(f"无法初始化WMI连接: {str(e)}") return False def run(self): # 创建事件监听 if not self.init_wmi(): self.error_signal.emit("网卡事件监听器初始化失败") return try: # 获取当前网卡状态作为初始状态 for adapter in self.c.Win32_NetworkAdapter(NetConnectionStatus=2): self.last_status[adapter.Name] = &#39;up&#39; for adapter in self.c.Win32_NetworkAdapter(NetConnectionStatus=7): self.last_status[adapter.Name] = &#39;down&#39; # 创建事件监听器 self.watcher = self.c.watch_for( notification_type="__InstanceModificationEvent", wmi_class="Win32_NetworkAdapter", delay_secs=0.1, # 更短的延迟以提高响应速度 fields=["NetConnectionStatus", "Name"] ) while self.running: try: adapter = self.watcher() if adapter: name = adapter.Name status = adapter.NetConnectionStatus event_time = datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S.%f&#39;)[:-3] # 只处理状态变化 if status == 2 and self.last_status.get(name) != &#39;up&#39;: self.nic_event.emit({&#39;name&#39;: name, &#39;event&#39;: &#39;up&#39;, &#39;time&#39;: event_time}) self.last_status[name] = &#39;up&#39; elif status == 7 and self.last_status.get(name) != &#39;down&#39;: self.nic_event.emit({&#39;name&#39;: name, &#39;event&#39;: &#39;down&#39;, &#39;time&#39;: event_time}) self.last_status[name] = &#39;down&#39; except wmi.x_wmi_timed_out: # 超时是正常的,继续循环 pass except Exception as e: self.error_signal.emit(f"监听网卡事件出错: {str(e)}") # 尝试重新初始化WMI连接 time.sleep(1) if not self.init_wmi(): break except Exception as e: self.error_signal.emit(f"创建网卡监听器出错: {str(e)}") finally: pythoncom.CoUninitialize() # 清理COM资源 def stop(self): self.running = False if self.watcher: self.watcher.cancel() # iperf3 进程管理器 class IperfManager: def __init__(self, iperf_path=&#39;&#39;): self.active_servers = {} self.active_clients = {} self.iperf_path = iperf_path or &#39;iperf3&#39; def start_server(self, group_id, port, server_ip, nic_name=""): """启动iperf3服务器""" # 使用服务器IP地址作为绑定的IP地址 if not server_ip or not self.validate_ip(server_ip): raise ValueError(f"测试组{group_id}服务器IP地址无效: {server_ip}") command = f&#39;"{self.iperf_path}" -s -p {port} -B {server_ip} -J --logfile server_{group_id}_{port}.log&#39; proc = subprocess.Popen(command, shell=True) self.active_servers[(group_id, port)] = (proc, nic_name) # 保存网卡名称用于日志 return command def start_client(self, group_id, server_ip, port, duration, streams, client_ip, reverse=False, omit=5, nic_name=""): """启动iperf3客户端""" # 使用客户端IP地址作为绑定的IP地址 if not client_ip or not self.validate_ip(client_ip): raise ValueError(f"测试组{group_id}客户端IP地址无效: {client_ip}") if not server_ip or not self.validate_ip(server_ip): raise ValueError(f"测试组{group_id}服务器IP地址无效: {server_ip}") direction = "-R" if reverse else "" # 添加-B参数绑定客户端IP,-c参数连接服务器IP command = f&#39;"{self.iperf_path}" -c {server_ip} -p {port} -t {duration} -P {streams} {direction} -O {omit} -B {client_ip} -J --logfile client_{group_id}_{port}.json&#39; proc = subprocess.Popen(command, shell=True) self.active_clients[(group_id, port)] = (proc, nic_name) # 保存网卡名称用于日志 return command def validate_ip(self, ip_str): """验证IP地址格式""" if not ip_str: return False parts = ip_str.split(&#39;.&#39;) if len(parts) != 4: return False for part in parts: if not part.isdigit(): return False num = int(part) if num < 0 or num > 255: return False return True def stop_all(self): """停止所有iperf进程""" for key, (proc, _) in list(self.active_servers.items()) + list(self.active_clients.items()): try: parent = psutil.Process(proc.pid) for child in parent.children(recursive=True): child.kill() parent.kill() except Exception as e: print(f"停止进程时出错: {e}") self.active_servers = {} self.active_clients = {} # 实时数据收集线程 class DataCollectorThread(QThread): data_updated = pyqtSignal(dict) # {group_id: {timestamp: (up, down)}} test_completed = pyqtSignal(int, dict) # group_id, full_data test_timeout = pyqtSignal() # 测试超时信号 def __init__(self, manager, groups, parent=None): super().__init__(parent) self.manager = manager self.groups = groups self.running = True self.data = {} self.full_data = {} self.start_time = time.time() self.max_duration = 0 # 记录最长测试时间(秒) self.timeout_timer = None # 计算最长测试时间(用于超时检测) for config in groups.values(): if config[&#39;duration&#39;] > self.max_duration: self.max_duration = config[&#39;duration&#39;] def run(self): # 初始化数据结构 for group_id in self.groups: self.data[group_id] = {"up": {}, "down": {}} self.full_data[group_id] = {"up": {}, "down": {}} # 设置超时计时器(最长测试时间+5秒) self.timeout_timer = threading.Timer(self.max_duration + 5, self.handle_timeout) self.timeout_timer.start() # 采集循环 while self.running: for group_id, config in self.groups.items(): # 处理上传日志 upload_log = f"client_{group_id}_{config[&#39;upload_port&#39;]}.json" if os.path.exists(upload_log): try: with open(upload_log, &#39;r&#39;) as f: data = json.load(f) # 解析上传数据 if &#39;intervals&#39; in data: for interval in data[&#39;intervals&#39;]: ts = time.time() up = interval[&#39;sum&#39;][&#39;bits_per_second&#39;] / 1e6 # 转换为Mbps # 存储实时数据 self.data[group_id]["up"][ts] = up self.full_data[group_id]["up"][ts] = up except Exception as e: print(f"读取上传日志文件错误: {e}") # 处理下载日志(如果是双向测试) if config[&#39;direction&#39;] in ["下载", "双向"]: download_log = f"client_{group_id}_{config[&#39;download_port&#39;]}.json" if os.path.exists(download_log): try: with open(download_log, &#39;r&#39;) as f: data = json.load(f) # 解析下载数据 if &#39;intervals&#39; in data: for interval in data[&#39;intervals&#39;]: ts = time.time() down = interval[&#39;sum&#39;][&#39;bits_per_second&#39;] / 1e6 # 存储实时数据 self.data[group_id]["down"][ts] = down self.full_data[group_id]["down"][ts] = down except Exception as e: print(f"读取下载日志文件错误: {e}") # 发送更新信号 self.data_updated.emit(self.data) time.sleep(1) # 每秒更新一次 # 测试完成后发送完整数据 for group_id in self.groups: self.test_completed.emit(group_id, self.full_data[group_id]) def handle_timeout(self): """处理测试超时""" self.running = False self.test_timeout.emit() def stop(self): self.running = False if self.timeout_timer: self.timeout_timer.cancel() # Matplotlib 图表组件(支持中文) class MplCanvas(FigureCanvas): def __init__(self, parent=None, width=5, height=4, dpi=100): self.fig = Figure(figsize=(width, height), dpi=dpi, facecolor=(0.94, 0.97, 1.0)) # 淡蓝色背景 self.ax = self.fig.add_subplot(111) super().__init__(self.fig) self.setParent(parent) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.updateGeometry() # 初始化图表(中文) self.ax.set_xlabel(&#39;时间 (秒)&#39;) self.ax.set_ylabel(&#39;吞吐量 (Mbps)&#39;) self.ax.set_title(&#39;iPerf3 流量监控&#39;) self.ax.grid(True) self.ax.set_facecolor((0.98, 0.99, 1.0)) # 更淡的蓝色背景 self.relative_time = True # 默认使用相对时间 self.start_time = None self.end_time = None self.start_timestamp = None def update_plot(self, data, selected_groups=None): """更新图表数据""" self.ax.clear() # 颜色列表 colors = [ &#39;#4169E1&#39;, # RoyalBlue &#39;#4682B4&#39;, # SteelBlue &#39;#6495ED&#39;, # CornflowerBlue &#39;#1E90FF&#39;, # DodgerBlue &#39;#00BFFF&#39; # DeepSkyBlue ] max_value = 0 min_timestamp = float(&#39;inf&#39;) max_timestamp = float(&#39;-inf&#39;) for group_id, group_data in data.items(): if selected_groups is not None and group_id not in selected_groups: continue color = colors[(group_id-1) % len(colors)] # 上传数据(实线) if group_data["up"]: timestamps = sorted(group_data["up"].keys()) values = [group_data["up"][ts] for ts in timestamps] # 更新时间范围 if timestamps: min_timestamp = min(min_timestamp, min(timestamps)) max_timestamp = max(max_timestamp, max(timestamps)) if not self.relative_time and self.start_time: # 转换为绝对时间 abs_timestamps = [self.start_time + timedelta(seconds=ts - self.start_timestamp) for ts in timestamps] self.ax.plot(abs_timestamps, values, &#39;-&#39;, color=color, label=f"组{group_id} 上传", linewidth=2) else: # 相对时间(从0开始) rel_timestamps = [ts - min_timestamp for ts in timestamps] self.ax.plot(rel_timestamps, values, &#39;-&#39;, color=color, label=f"组{group_id} 上传", linewidth=2) if values: max_value = max(max_value, max(values)) # 下载数据(虚线) if group_data["down"]: timestamps = sorted(group_data["down"].keys()) values = [group_data["down"][ts] for ts in timestamps] # 更新时间范围 if timestamps: min_timestamp = min(min_timestamp, min(timestamps)) max_timestamp = max(max_timestamp, max(timestamps)) if not self.relative_time and self.start_time: abs_timestamps = [self.start_time + timedelta(seconds=ts - self.start_timestamp) for ts in timestamps] self.ax.plot(abs_timestamps, values, &#39;--&#39;, color=color, label=f"组{group_id} 下载", linewidth=2) else: rel_timestamps = [ts - min_timestamp for ts in timestamps] self.ax.plot(rel_timestamps, values, &#39;--&#39;, color=color, label=f"组{group_id} 下载", linewidth=2) if values: max_value = max(max_value, max(values)) # 设置坐标轴范围 if max_value > 0: self.ax.set_ylim(0, max_value * 1.2) # 添加图例(中文) self.ax.legend(loc=&#39;best&#39;, fontsize=9) # 设置标签和标题(中文) if self.relative_time: self.ax.set_xlabel(&#39;时间 (秒)&#39;) else: self.ax.set_xlabel(&#39;时间&#39;) # 设置时间格式 self.ax.xaxis.set_major_formatter(mdates.DateFormatter(&#39;%H:%M:%S&#39;)) self.ax.xaxis.set_major_locator(ticker.AutoLocator()) self.ax.set_ylabel(&#39;吞吐量 (Mbps)&#39;) self.ax.set_title(&#39;iPerf3 流量监控&#39;) self.ax.grid(True) self.draw() def switch_to_absolute_time(self, start_time, end_time): """切换到绝对时间模式""" self.relative_time = False self.start_time = start_time self.end_time = end_time # 记录测试开始时的第一个时间戳(作为相对时间的零点) if self.start_time: self.start_timestamp = time.time() - (datetime.now() - start_time).total_seconds() # 应用窗口 class IperfVisualizer(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("iPerf3 流量可视化工具") self.setWindowIcon(QIcon(&#39;iperf_icon.ico&#39;)) # 设置软件图标 self.resize(1400, 900) # 应用配置 self.config = { &#39;iperf_path&#39;: &#39;&#39;, &#39;auto_save_log&#39;: True, &#39;log_path&#39;: os.getcwd(), &#39;test_groups&#39;: {}, &#39;current_group_id&#39;: 1 } # 初始化管理器 self.manager = None self.data_collector = None self.nic_monitor = None self.nic_events = [] # 存储网卡事件 self.nic_summary = {} # 网卡事件汇总: {网卡名: {&#39;down&#39;: 次数, &#39;up&#39;: 次数, &#39;last_status&#39;: 最后状态}} self.nic_map = {} # 网卡到测试组映射: {网卡名: [(group_id, port, role)]} # 设置UI self.init_ui() self.load_config() # 启动网卡事件监听 self.start_nic_monitor() def init_ui(self): # 布局 main_widget = QWidget() main_layout = QVBoxLayout() main_widget.setLayout(main_layout) self.setCentralWidget(main_widget) # 标签页 self.tabs = QTabWidget() main_layout.addWidget(self.tabs) # 配置页 self.config_tab = QWidget() self.tabs.addTab(self.config_tab, "测试配置") self.setup_config_tab() # 图表页 self.chart_tab = QWidget() self.tabs.addTab(self.chart_tab, "流量图表") self.setup_chart_tab() # 信息页 self.info_tab = QWidget() self.tabs.addTab(self.info_tab, "测试信息") self.setup_info_tab() # 状态栏 self.statusBar().showMessage("就绪") def setup_config_tab(self): layout = QVBoxLayout() self.config_tab.setLayout(layout) # iperf路径配置 iperf_group = QGroupBox("iperf3 配置") iperf_layout = QVBoxLayout() path_layout = QHBoxLayout() path_layout.addWidget(QLabel("iperf3路径:")) self.iperf_path = QLineEdit(self.config[&#39;iperf_path&#39;]) path_layout.addWidget(self.iperf_path) self.iperf_browse_btn = QPushButton("浏览...") self.iperf_browse_btn.clicked.connect(self.browse_iperf_path) path_layout.addWidget(self.iperf_browse_btn) iperf_layout.addLayout(path_layout) # 测试按钮 test_btn_layout = QHBoxLayout() self.test_iperf_btn = QPushButton("测试路径") self.test_iperf_btn.clicked.connect(self.test_iperf_path) test_btn_layout.addWidget(self.test_iperf_btn) iperf_layout.addLayout(test_btn_layout) iperf_group.setLayout(iperf_layout) layout.addWidget(iperf_group) # 测试描述 desc_group = QGroupBox("测试描述") desc_layout = QVBoxLayout() self.test_desc = QLineEdit("iPerf3流量测试") desc_layout.addWidget(QLabel("测试描述:")) desc_layout.addWidget(self.test_desc) desc_group.setLayout(desc_layout) layout.addWidget(desc_group) # 日志设置 log_group = QGroupBox("日志设置") log_layout = QVBoxLayout() self.auto_save = QCheckBox("自动保存测试日志") self.auto_save.setChecked(True) log_layout.addWidget(self.auto_save) path_layout = QHBoxLayout() path_layout.addWidget(QLabel("保存路径:")) self.log_path = QLineEdit(os.getcwd()) path_layout.addWidget(self.log_path) self.browse_btn = QPushButton("浏览...") self.browse_btn.clicked.connect(self.browse_log_path) path_layout.addWidget(self.browse_btn) log_layout.addLayout(path_layout) log_group.setLayout(log_layout) layout.addWidget(log_group) # 测试组配置 self.test_groups_area = QScrollArea() self.test_groups_area.setWidgetResizable(True) self.test_groups_widget = QWidget() self.test_groups_layout = QVBoxLayout() self.test_groups_widget.setLayout(self.test_groups_layout) self.test_groups_area.setWidget(self.test_groups_widget) layout.addWidget(QLabel("测试组配置:")) layout.addWidget(self.test_groups_area) # 添加测试组按钮 self.add_group_btn = QPushButton("添加测试") self.add_group_btn.clicked.connect(self.add_test_group) layout.addWidget(self.add_group_btn) # 操作按钮 btn_layout = QHBoxLayout() self.start_btn = QPushButton("开始测试") self.start_btn.clicked.connect(self.start_test) btn_layout.addWidget(self.start_btn) self.stop_btn = QPushButton("停止测试") self.stop_btn.clicked.connect(self.stop_test) self.stop_btn.setEnabled(False) btn_layout.addWidget(self.stop_btn) layout.addLayout(btn_layout) # 添加初始测试组 self.add_test_group() def setup_chart_tab(self): layout = QVBoxLayout() self.chart_tab.setLayout(layout) # 使用 Matplotlib 图表(支持中文) self.canvas = MplCanvas(self, width=10, height=6, dpi=100) layout.addWidget(self.canvas, 3) # 图表占3/4空间 # 测试组选择面板 group_select_layout = QHBoxLayout() # 测试组列表 group_list_layout = QVBoxLayout() group_list_layout.addWidget(QLabel("选择显示的测试组:")) self.group_list = QListWidget() self.group_list.setSelectionMode(QAbstractItemView.MultiSelection) # 多选 group_list_layout.addWidget(self.group_list) # 选择按钮 btn_layout = QHBoxLayout() self.select_all_btn = QPushButton("全选") self.select_all_btn.clicked.connect(self.select_all_groups) btn_layout.addWidget(self.select_all_btn) self.deselect_all_btn = QPushButton("全不选") self.deselect_all_btn.clicked.connect(self.deselect_all_groups) btn_layout.addWidget(self.deselect_all_btn) group_list_layout.addLayout(btn_layout) group_select_layout.addLayout(group_list_layout, 1) # 占1/4宽度 # 图表操作按钮 chart_btn_layout = QVBoxLayout() chart_btn_layout.addWidget(QLabel("图表操作:")) self.export_btn = QPushButton("导出图表") self.export_btn.clicked.connect(self.export_chart) chart_btn_layout.addWidget(self.export_btn) self.clear_btn = QPushButton("清除图表") self.clear_btn.clicked.connect(self.clear_chart) chart_btn_layout.addWidget(self.clear_btn) self.zoom_reset_btn = QPushButton("重置缩放") self.zoom_reset_btn.clicked.connect(self.reset_zoom) chart_btn_layout.addWidget(self.zoom_reset_btn) group_select_layout.addLayout(chart_btn_layout, 1) # 占1/4宽度 layout.addLayout(group_select_layout, 1) # 控制面板占1/4高度 # 初始化后更新组列表 self.update_group_list() def setup_info_tab(self): layout = QVBoxLayout() self.info_tab.setLayout(layout) # 执行日志 exec_group = QGroupBox("执行日志") exec_layout = QVBoxLayout() self.exec_log = QTextEdit() self.exec_log.setReadOnly(True) exec_layout.addWidget(self.exec_log) btn_layout = QHBoxLayout() self.clear_log_btn = QPushButton("清除日志") self.clear_log_btn.clicked.connect(lambda: self.exec_log.clear()) btn_layout.addWidget(self.clear_log_btn) self.export_log_btn = QPushButton("导出日志") self.export_log_btn.clicked.connect(self.export_log) btn_layout.addWidget(self.export_log_btn) exec_layout.addLayout(btn_layout) exec_group.setLayout(exec_layout) layout.addWidget(exec_group) # 网卡状态 nic_group = QGroupBox("网卡状态监控") nic_layout = QVBoxLayout() self.nic_status = QTextEdit() self.nic_status.setReadOnly(True) nic_layout.addWidget(self.nic_status) # 网卡事件摘要 self.nic_summary_text = QTextEdit() self.nic_summary_text.setReadOnly(True) nic_layout.addWidget(QLabel("网卡事件摘要:")) nic_layout.addWidget(self.nic_summary_text) # 网卡映射 self.nic_mapping_text = QTextEdit() self.nic_mapping_text.setReadOnly(True) nic_layout.addWidget(QLabel("网卡测试组映射:")) nic_layout.addWidget(self.nic_mapping_text) nic_btn_layout = QHBoxLayout() self.refresh_nic_btn = QPushButton("刷新状态") self.refresh_nic_btn.clicked.connect(self.update_nic_status) nic_btn_layout.addWidget(self.refresh_nic_btn) self.export_nic_btn = QPushButton("导出状态") self.export_nic_btn.clicked.connect(self.export_nic_status) nic_btn_layout.addWidget(self.export_nic_btn) self.clear_nic_btn = QPushButton("清除事件") self.clear_nic_btn.clicked.connect(self.clear_nic_events) nic_btn_layout.addWidget(self.clear_nic_btn) nic_layout.addLayout(nic_btn_layout) nic_group.setLayout(nic_layout) layout.addWidget(nic_group) # 初始更新网卡状态 self.update_nic_status(initial=True) def browse_iperf_path(self): """浏览并选择iperf3可执行文件""" file_path, _ = QFileDialog.getOpenFileName( self, "选择iperf3可执行文件", "", "可执行文件 (*.exe);;所有文件 (*)" ) if file_path: self.iperf_path.setText(file_path) self.config[&#39;iperf_path&#39;] = file_path self.save_config() def test_iperf_path(self): """测试iperf3路径是否有效""" path = self.iperf_path.text() if not path: QMessageBox.warning(self, "警告", "请先选择iperf3路径") return try: result = subprocess.run( [path, &#39;-v&#39;], capture_output=True, text=True, creationflags=subprocess.CREATE_NO_WINDOW ) if &#39;iperf 3.&#39; in result.stdout: QMessageBox.information(self, "测试成功", f"iperf3版本信息:\n{result.stdout.splitlines()[0]}") self.config[&#39;iperf_path&#39;] = path self.save_config() else: QMessageBox.critical(self, "错误", "选择的文件不是有效的iperf3可执行文件") except Exception as e: QMessageBox.critical(self, "错误", f"测试失败: {str(e)}") def start_nic_monitor(self): """启动网卡事件监听线程""" self.nic_monitor = NicMonitorThread() self.nic_monitor.nic_event.connect(self.handle_nic_event) self.nic_monitor.error_signal.connect(self.handle_nic_error) # 连接错误信号 self.nic_monitor.start() def handle_nic_error(self, error_msg): """处理网卡监听错误""" self.exec_log.append(f"[{datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] {error_msg}") # 如果无法使用事件监听,回退到轮询方式 if "无法初始化WMI连接" in error_msg or "创建网卡监听器出错" in error_msg: self.exec_log.append("将使用轮询方式监控网卡状态") self.start_polling_nic_status() def start_polling_nic_status(self): """启动轮询方式监控网卡状态""" self.last_nic_status = {} # 初始化状态记录 self.nic_poll_timer = QTimer() self.nic_poll_timer.timeout.connect(self.poll_nic_status) self.nic_poll_timer.start(1000) # 每秒轮询一次 def poll_nic_status(self): """轮询网卡状态""" try: c = wmi.WMI() # 获取所有网卡状态 for adapter in c.Win32_NetworkAdapter(): status = &#39;unknown&#39; if adapter.NetConnectionStatus == 2: status = &#39;up&#39; elif adapter.NetConnectionStatus == 7: status = &#39;down&#39; # 只处理状态变化的网卡 if adapter.Name in self.last_nic_status and self.last_nic_status[adapter.Name] != status: event_time = datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S.%f&#39;)[:-3] self.handle_nic_event({&#39;name&#39;: adapter.Name, &#39;event&#39;: status, &#39;time&#39;: event_time}) # 记录当前状态 self.last_nic_status[adapter.Name] = status except Exception as e: self.exec_log.append(f"轮询网卡状态出错: {str(e)}") def handle_nic_event(self, event): """处理网卡事件""" name = event[&#39;name&#39;] event_type = event[&#39;event&#39;] event_time = event[&#39;time&#39;] # 检查网卡映射 affected_groups = [] if name in self.nic_map: for group_id, port, role in self.nic_map[name]: affected_groups.append(f"测试组{group_id}的{role}端口{port}") # 记录事件 event_msg = f"[{event_time}] 网卡 &#39;{name}&#39; 状态变化: {event_type}" if affected_groups: event_msg += f" (影响: {&#39;, &#39;.join(affected_groups)})" self.nic_events.append(event_msg) # 更新汇总 if name not in self.nic_summary: self.nic_summary[name] = {&#39;down&#39;: 0, &#39;up&#39;: 0, &#39;last_status&#39;: &#39;unknown&#39;} if event_type == &#39;down&#39;: self.nic_summary[name][&#39;down&#39;] += 1 elif event_type == &#39;up&#39;: self.nic_summary[name][&#39;up&#39;] += 1 self.nic_summary[name][&#39;last_status&#39;] = event_type # 更新网卡状态显示 self.update_nic_status() def add_test_group(self): # 计算当前可用最小ID existing_ids = set(self.config[&#39;test_groups&#39;].keys()) group_id = 1 while group_id in existing_ids: group_id += 1 if group_id > 30: QMessageBox.warning(self, "警告", "最多只能添加30个测试组") return group = QGroupBox(f"测试组 {group_id}") group.setProperty(&#39;group_id&#39;, group_id) # 设置属性用于标识 group_layout = QVBoxLayout() # 组名 name_layout = QHBoxLayout() name_layout.addWidget(QLabel("组名:")) group_name = QLineEdit(f"测试组{group_id}") name_layout.addWidget(group_name) group_layout.addLayout(name_layout) # Server配置 server_group = QGroupBox("Server配置") server_layout = QVBoxLayout() ip_layout = QHBoxLayout() ip_layout.addWidget(QLabel("IP地址*:")) server_ip = QLineEdit() server_ip.setPlaceholderText("必须填写服务器IP") ip_layout.addWidget(server_ip) server_layout.addLayout(ip_layout) port_layout = QHBoxLayout() port_layout.addWidget(QLabel("端口号:")) server_port = QSpinBox() server_port.setRange(1024, 65535) server_port.setValue(5201 + group_id - 1) port_layout.addWidget(server_port) server_layout.addLayout(port_layout) # 添加网卡选择 nic_layout = QHBoxLayout() nic_layout.addWidget(QLabel("绑定网卡:")) server_nic = QComboBox() server_nic.addItem("") # 空选项 # 获取系统网卡列表 try: c = wmi.WMI() for adapter in c.Win32_NetworkAdapter(NetConnectionStatus=2): # 2表示已连接 server_nic.addItem(adapter.Name) except Exception as e: print(f"获取网卡列表失败: {e}") nic_layout.addWidget(server_nic) server_layout.addLayout(nic_layout) server_group.setLayout(server_layout) group_layout.addWidget(server_group) # Client配置 client_group = QGroupBox("Client配置") client_layout = QVBoxLayout() c_ip_layout = QHBoxLayout() c_ip_layout.addWidget(QLabel("IP地址*:")) client_ip = QLineEdit() client_ip.setPlaceholderText("必须填写客户端IP") c_ip_layout.addWidget(client_ip) client_layout.addLayout(c_ip_layout) c_name_layout = QHBoxLayout() c_name_layout.addWidget(QLabel("客户端名称:")) client_name = QLineEdit(f"客户端{group_id}") c_name_layout.addWidget(client_name) client_layout.addLayout(c_name_layout) # 添加网卡选择 c_nic_layout = QHBoxLayout() c_nic_layout.addWidget(QLabel("绑定网卡:")) client_nic = QComboBox() client_nic.addItem("") # 空选项 # 获取系统网卡列表 try: c = wmi.WMI() for adapter in c.Win32_NetworkAdapter(NetConnectionStatus=2): # 2表示已连接 client_nic.addItem(adapter.Name) except Exception as e: print(f"获取网卡列表失败: {e}") c_nic_layout.addWidget(client_nic) client_layout.addLayout(c_nic_layout) client_group.setLayout(client_layout) group_layout.addWidget(client_group) # 测试参数 param_group = QGroupBox("测试参数") param_layout = QVBoxLayout() direction_layout = QHBoxLayout() direction_layout.addWidget(QLabel("测试方向:")) test_direction = QComboBox() test_direction.addItems(["上传", "下载", "双向"]) test_direction.currentIndexChanged.connect(lambda idx, gid=group_id: self.update_port_visibility(gid, idx)) direction_layout.addWidget(test_direction) param_layout.addLayout(direction_layout) # 下载端口配置(默认隐藏) download_port_layout = QHBoxLayout() download_port_layout.addWidget(QLabel("下载端口号:")) download_port = QSpinBox() download_port.setRange(1024, 65535) download_port.setValue(server_port.value() + 1) download_port_layout.addWidget(download_port) # 初始时隐藏下载端口配置(除非是双向测试) if test_direction.currentIndex() != 2: download_port_layout.itemAt(0).widget().hide() download_port_layout.itemAt(1).widget().hide() param_layout.addLayout(download_port_layout) time_layout = QHBoxLayout() time_layout.addWidget(QLabel("测试时长:")) test_time = QDoubleSpinBox() test_time.setRange(1, 10000) test_time.setValue(10) time_layout.addWidget(test_time) time_unit = QComboBox() time_unit.addItems(["秒", "分钟", "小时"]) time_layout.addWidget(time_unit) param_layout.addLayout(time_layout) omit_layout = QHBoxLayout() omit_layout.addWidget(QLabel("跳过前N秒:")) omit_seconds = QSpinBox() omit_seconds.setRange(0, 60) omit_seconds.setValue(5) omit_layout.addWidget(omit_seconds) param_layout.addLayout(omit_layout) streams_layout = QHBoxLayout() streams_layout.addWidget(QLabel("并行流数量:")) streams = QSpinBox() streams.setRange(1, 100) streams.setValue(1) streams_layout.addWidget(streams) param_layout.addLayout(streams_layout) auto_restart = QCheckBox("每6小时自动重跑") auto_restart.setChecked(True) param_layout.addWidget(auto_restart) error_restart = QCheckBox("异常自动重跑") error_restart.setChecked(True) param_layout.addWidget(error_restart) restart_count_layout = QHBoxLayout() restart_count_layout.addWidget(QLabel("重跑次数:")) restart_count = QSpinBox() restart_count.setRange(1, 10) restart_count.setValue(3) restart_count_layout.addWidget(restart_count) param_layout.addLayout(restart_count_layout) param_group.setLayout(param_layout) group_layout.addWidget(param_group) # 删除按钮 delete_btn = QPushButton("删除此组") delete_btn.clicked.connect(lambda: self.remove_test_group(group)) group_layout.addWidget(delete_btn) group.setLayout(group_layout) self.test_groups_layout.addWidget(group) # 保存配置(只保存控件值) self.config[&#39;test_groups&#39;][group_id] = { &#39;name&#39;: group_name.text(), &#39;server_ip&#39;: server_ip.text(), &#39;server_port&#39;: server_port.value(), &#39;server_nic&#39;: server_nic.currentText(), # 保存网卡名称 &#39;download_port&#39;: download_port.value(), &#39;client_ip&#39;: client_ip.text(), &#39;client_name&#39;: client_name.text(), &#39;client_nic&#39;: client_nic.currentText(), # 保存网卡名称 &#39;direction&#39;: test_direction.currentIndex(), &#39;test_time&#39;: test_time.value(), &#39;time_unit&#39;: time_unit.currentIndex(), &#39;omit&#39;: omit_seconds.value(), &#39;streams&#39;: streams.value(), &#39;auto_restart&#39;: auto_restart.isChecked(), &#39;error_restart&#39;: error_restart.isChecked(), &#39;restart_count&#39;: restart_count.value() } # 更新组选择列表 if hasattr(self, &#39;group_list&#39;): self.update_group_list() def update_port_visibility(self, group_id, direction_idx): """根据测试方向显示/隐藏下载端口配置""" # 找到对应的group_widget for child in self.test_groups_widget.children(): if isinstance(child, QGroupBox) and child.property(&#39;group_id&#39;) == group_id: # 找到下载端口控件 download_port_label = None download_port_widget = None for layout in child.findChildren(QHBoxLayout): if layout.itemAt(0) and layout.itemAt(0).widget() and layout.itemAt(0).widget().text() == "下载端口号:": download_port_label = layout.itemAt(0).widget() download_port_widget = layout.itemAt(1).widget() break if download_port_label and download_port_widget: # 双向测试时显示下载端口配置 if direction_idx == 2: # 双向 download_port_label.show() download_port_widget.show() else: download_port_label.hide() download_port_widget.hide() break def remove_test_group(self, group_widget): group_id = group_widget.property(&#39;group_id&#39;) if group_id: # 从布局中移除 self.test_groups_layout.removeWidget(group_widget) group_widget.deleteLater() # 从配置中移除 if group_id in self.config[&#39;test_groups&#39;]: del self.config[&#39;test_groups&#39;][group_id] self.save_config() # 更新组选择列表 if hasattr(self, &#39;group_list&#39;): self.update_group_list() def update_group_list(self): """更新测试组选择列表""" if not hasattr(self, &#39;group_list&#39;): return self.group_list.clear() for group_id in self.config[&#39;test_groups&#39;].keys(): item = QListWidgetItem(f"测试组 {group_id}") item.setData(Qt.UserRole, group_id) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(Qt.Checked) self.group_list.addItem(item) def select_all_groups(self): """选择所有测试组""" if not hasattr(self, &#39;group_list&#39;): return for i in range(self.group_list.count()): item = self.group_list.item(i) item.setCheckState(Qt.Checked) self.update_chart() def deselect_all_groups(self): """取消选择所有测试组""" if not hasattr(self, &#39;group_list&#39;): return for i in range(self.group_list.count()): item = self.group_list.item(i) item.setCheckState(Qt.Unchecked) self.update_chart() def browse_log_path(self): path = QFileDialog.getExistingDirectory(self, "选择日志保存路径") if path: self.log_path.setText(path) self.config[&#39;log_path&#39;] = path self.save_config() def start_test(self): # 验证配置 if not self.config[&#39;iperf_path&#39;]: QMessageBox.critical(self, "错误", "请先配置iperf3路径") return # 创建管理器(使用配置的iperf路径) self.manager = IperfManager(self.config[&#39;iperf_path&#39;]) # 禁用UI控件 self.start_btn.setEnabled(False) self.add_group_btn.setEnabled(False) self.stop_btn.setEnabled(True) # 清除图表 self.clear_chart() # 记录开始时间 self.start_time = datetime.now() self.canvas.start_time = self.start_time self.canvas.start_timestamp = time.time() self.exec_log.append(f"[{self.start_time.strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] 测试开始") # 收集测试组配置 test_groups = {} self.nic_map = {} # 重置网卡映射 for group_id, group in self.config[&#39;test_groups&#39;].items(): # 计算测试时长(秒)- 修复:转换为整数秒 time_val = group[&#39;test_time&#39;] unit_idx = group[&#39;time_unit&#39;] if unit_idx == 1: # 分钟 time_val *= 60 elif unit_idx == 2: # 小时 time_val *= 3600 # 转换为整数秒(至少1秒) duration = max(1, int(round(time_val))) test_direction = ["上传", "下载", "双向"][group[&#39;direction&#39;]] test_groups[group_id] = { &#39;server_ip&#39;: group[&#39;server_ip&#39;], &#39;server_port&#39;: group[&#39;server_port&#39;], &#39;client_ip&#39;: group[&#39;client_ip&#39;], &#39;direction&#39;: test_direction, &#39;duration&#39;: duration, # 使用整数秒 &#39;streams&#39;: group[&#39;streams&#39;], &#39;omit&#39;: group[&#39;omit&#39;], &#39;upload_port&#39;: group[&#39;server_port&#39;], # 上传端口 &#39;download_port&#39;: group[&#39;download_port&#39;] # 下载端口 } try: # 记录网卡映射 server_nic = group[&#39;server_nic&#39;] client_nic = group[&#39;client_nic&#39;] if server_nic: if server_nic not in self.nic_map: self.nic_map[server_nic] = [] self.nic_map[server_nic].append((group_id, group[&#39;server_port&#39;], &#39;服务器&#39;)) # 启动服务器(上传) cmd = self.manager.start_server(group_id, test_groups[group_id][&#39;upload_port&#39;], test_groups[group_id][&#39;server_ip&#39;], server_nic) self.exec_log.append(f"[{datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] 测试组 {group_id} 启动上传服务器: {cmd}") # 如果是双向测试,启动下载服务器 if test_groups[group_id][&#39;direction&#39;] in ["下载", "双向"]: cmd = self.manager.start_server(group_id, test_groups[group_id][&#39;download_port&#39;], test_groups[group_id][&#39;server_ip&#39;], server_nic) self.exec_log.append(f"[{datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] 测试组 {group_id} 启动下载服务器: {cmd}") # 启动客户端 if test_groups[group_id][&#39;direction&#39;] in ["上传", "双向"]: if client_nic: if client_nic not in self.nic_map: self.nic_map[client_nic] = [] self.nic_map[client_nic].append((group_id, test_groups[group_id][&#39;upload_port&#39;], &#39;客户端(上传)&#39;)) cmd = self.manager.start_client( group_id, test_groups[group_id][&#39;server_ip&#39;], # 连接服务器IP test_groups[group_id][&#39;upload_port&#39;], # 连接上传端口 test_groups[group_id][&#39;duration&#39;], # 整数秒 test_groups[group_id][&#39;streams&#39;], test_groups[group_id][&#39;client_ip&#39;], # 绑定客户端IP False, test_groups[group_id][&#39;omit&#39;], client_nic ) self.exec_log.append(f"[{datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] 测试组 {group_id} 启动上传客户端: {cmd}") if test_groups[group_id][&#39;direction&#39;] in ["下载", "双向"]: if client_nic: if client_nic not in self.nic_map: self.nic_map[client_nic] = [] self.nic_map[client_nic].append((group_id, test_groups[group_id][&#39;download_port&#39;], &#39;客户端(下载)&#39;)) cmd = self.manager.start_client( group_id, test_groups[group_id][&#39;server_ip&#39;], # 连接服务器IP test_groups[group_id][&#39;download_port&#39;], # 连接下载端口 test_groups[group_id][&#39;duration&#39;], # 整数秒 test_groups[group_id][&#39;streams&#39;], test_groups[group_id][&#39;client_ip&#39;], # 绑定客户端IP True, # 下载方向 test_groups[group_id][&#39;omit&#39;], client_nic ) self.exec_log.append(f"[{datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] 测试组 {group_id} 启动下载客户端: {cmd}") except ValueError as e: self.exec_log.append(f"[{datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] 错误: {str(e)}") QMessageBox.critical(self, "错误", str(e)) self.stop_test() return # 启动数据收集线程 self.data_collector = DataCollectorThread(self.manager, test_groups) self.data_collector.data_updated.connect(self.update_chart) self.data_collector.test_completed.connect(self.handle_test_completed) self.data_collector.test_timeout.connect(self.handle_test_timeout) self.data_collector.start() # 更新网卡映射显示 self.update_nic_status() def stop_test(self): if self.manager: self.manager.stop_all() if self.data_collector: self.data_collector.stop() self.data_collector.quit() self.data_collector.wait() # 启用UI控件 self.start_btn.setEnabled(True) self.add_group_btn.setEnabled(True) self.stop_btn.setEnabled(False) # 记录结束时间 end_time = datetime.now() self.exec_log.append(f"[{end_time.strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] 测试停止") # 清空网卡映射 self.nic_map = {} self.update_nic_status() def handle_test_completed(self, group_id, data): self.exec_log.append(f"[{datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] 测试组 {group_id} 完成") def handle_test_timeout(self): self.exec_log.append(f"[{datetime.now().strftime(&#39;%Y-%m-%d %H:%M:%S&#39;)}] 测试超时") self.stop_test() def update_chart(self): if not self.data_collector or not hasattr(self.data_collector, &#39;data&#39;): return # 获取选中的测试组 selected_groups = set() for i in range(self.group_list.count()): item = self.group_list.item(i) if item.checkState() == Qt.Checked: group_id = item.data(Qt.UserRole) selected_groups.add(group_id) # 更新图表 self.canvas.update_plot(self.data_collector.data, selected_groups) def clear_chart(self): self.canvas.ax.clear() self.canvas.draw() def reset_zoom(self): self.canvas.ax.relim() self.canvas.ax.autoscale_view() self.canvas.draw() def export_chart(self): file_path, _ = QFileDialog.getSaveFileName( self, "导出图表", "", "PNG图像 (*.png);;JPEG图像 (*.jpg);;PDF文件 (*.pdf)" ) if file_path: self.canvas.fig.savefig(file_path) def export_log(self): file_path, _ = QFileDialog.getSaveFileName( self, "导出日志", "", "文本文件 (*.txt);;所有文件 (*)" ) if file_path: with open(file_path, &#39;w&#39;) as f: f.write(self.exec_log.toPlainText()) def export_nic_status(self): file_path, _ = QFileDialog.getSaveFileName( self, "导出网卡状态", "", "文本文件 (*.txt);;所有文件 (*)" ) if file_path: with open(file_path, &#39;w&#39;) as f: f.write(self.nic_status.toPlainText()) f.write("\n\n网卡事件摘要:\n") f.write(self.nic_summary_text.toPlainText()) f.write("\n\n网卡测试组映射:\n") f.write(self.nic_mapping_text.toPlainText()) def clear_nic_events(self): self.nic_events = [] self.nic_summary = {} self.update_nic_status() def update_nic_status(self, initial=False): """更新网卡状态显示""" # 获取当前网卡状态 status_text = "" try: c = wmi.WMI() for adapter in c.Win32_NetworkAdapter(): status = &#39;未知&#39; if adapter.NetConnectionStatus == 2: status = &#39;已连接&#39; elif adapter.NetConnectionStatus == 7: status = &#39;已断开&#39; status_text += f"网卡: {adapter.Name}\n" status_text += f"状态: {status}\n" status_text += f"MAC地址: {adapter.MACAddress}\n" status_text += f"描述: {adapter.Description}\n" status_text += "-"*30 + "\n" except Exception as e: status_text = f"获取网卡状态失败: {str(e)}" self.nic_status.setText(status_text) # 更新网卡事件摘要 summary_text = "" for name, data in self.nic_summary.items(): summary_text += f"网卡: {name}\n" summary_text += f"最后状态: {data[&#39;last_status&#39;]}\n" summary_text += f"断开次数: {data[&#39;down&#39;]}\n" summary_text += f"连接次数: {data[&#39;up&#39;]}\n" summary_text += "-"*30 + "\n" self.nic_summary_text.setText(summary_text) # 更新网卡映射 mapping_text = "" for nic, mappings in self.nic_map.items(): mapping_text += f"网卡: {nic}\n" for group_id, port, role in mappings: mapping_text += f" - 测试组{group_id} {role} 端口{port}\n" mapping_text += "\n" self.nic_mapping_text.setText(mapping_text) # 如果是初始更新,则添加事件日志 if initial and self.nic_events: self.exec_log.append("\n".join(self.nic_events)) def load_config(self): # 这里应该从文件加载配置 pass def save_config(self): # 这里应该保存配置到文件 pass if __name__ == "__main__": app = QApplication(sys.argv) window = IperfVisualizer() window.show() sys.exit(app.exec_())
08-14
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值