"""硬件测试治具 v2 - 多产品 Profile 架构 - 顶部产品下拉切换不同被测产品 - 测试项可在 UI 内增删改、改类型、改正则 - 5 种测试项类型:自动关键词 / 自动抓值 / 人工勾选 / 人工多选 / 人工文本 - 报告按产品+日期分文件存到 reports/ """ import csv import json import os import queue import re import shutil import sys import threading import tkinter as tk from datetime import datetime from pathlib import Path from tkinter import filedialog, messagebox, scrolledtext, simpledialog, ttk import serial import serial.tools.list_ports try: from openpyxl import Workbook from openpyxl.styles import Alignment, Font HAS_OPENPYXL = True except ImportError: HAS_OPENPYXL = False APP_VERSION = "v2.1.1" if getattr(sys, "frozen", False): BASE = Path(sys.executable).parent else: BASE = Path(__file__).parent PROFILES_DIR = BASE / "profiles" REPORTS_DIR = BASE / "reports" SETTINGS_FILE = BASE / "settings.json" PROFILES_DIR.mkdir(exist_ok=True) REPORTS_DIR.mkdir(exist_ok=True) TYPE_LABELS = { "auto": "自动-关键词命中", "auto_value": "自动-抓取数值", "manual": "人工-勾选 ✓/✗", "manual_choice": "人工-多选下拉", "manual_text": "人工-填写文本", } TYPES_ORDER = ["auto", "auto_value", "manual", "manual_choice", "manual_text"] FONT_SIZES = {"小": 10, "中": 12, "大": 14, "特大": 18, "超大": 22} DEFAULT_PROFILE = { "product_name": "ESP32-S3-Airhub", "mac_pattern": r"Wi-Fi MAC Address:\s+([0-9a-fA-F:]+)", "sn": {"prefix": "SN", "next_number": 1, "digits": 6, "step": 1}, "tests": [ {"id": "test_mode", "name": "进入测试模式", "type": "auto", "pattern": r"生产测试模式已启用"}, {"id": "story_btn", "name": "故事按键", "type": "auto", "pattern": r"故事按键.*?被按下"}, {"id": "boot_btn", "name": "BOOT/打断按键", "type": "auto", "pattern": r"BOOT按键.*?被按下"}, {"id": "audio_done", "name": "音频播放完成(自动)", "type": "auto", "pattern": r"音频播放完成"}, {"id": "speaker", "name": "喇叭音质", "type": "manual", "hint": "听播报是否清晰、无杂音"}, {"id": "mic_wakeup", "name": "麦克风唤醒", "type": "manual", "hint": "喊唤醒词,板载 LED 应闪烁"}, {"id": "touch", "name": "触摸", "type": "manual", "hint": "摸触摸板,听喇叭播报「触摸被按下」"}, {"id": "battery", "name": "电量", "type": "manual_text", "hint": "听喇叭播报「电量:xx%」,填写听到的百分比"}, {"id": "gyro", "name": "陀螺仪", "type": "manual", "hint": "晃动设备,听喇叭播报「陀螺仪正常」"}, {"id": "charge", "name": "充电灯", "type": "manual_choice", "choices": ["红-充电中", "绿-已充满", "无-异常"]}, ] } DEFAULT_SETTINGS = { "current_profile": "ESP32-S3-Airhub", "serial": {"port": "", "baudrate": 115200}, "operator": "", "font_size_label": "中", "batch_no": "1", "batch_size": 600, "auto_clear_log_on_disconnect": False, } # ----- 配置 IO ----- def load_settings(): if SETTINGS_FILE.exists(): with open(SETTINGS_FILE, "r", encoding="utf-8") as f: s = json.load(f) for k, v in DEFAULT_SETTINGS.items(): s.setdefault(k, v) return s save_settings(DEFAULT_SETTINGS) return json.loads(json.dumps(DEFAULT_SETTINGS)) def save_settings(s): with open(SETTINGS_FILE, "w", encoding="utf-8") as f: json.dump(s, f, ensure_ascii=False, indent=2) def profile_path(name): safe = re.sub(r"[^\w\-.]+", "_", name) return PROFILES_DIR / f"{safe}.json" def list_profiles(): names = [] for p in PROFILES_DIR.glob("*.json"): try: with open(p, "r", encoding="utf-8") as f: d = json.load(f) names.append(d.get("product_name", p.stem)) except Exception: continue names.sort() return names def load_profile(name): p = profile_path(name) if p.exists(): with open(p, "r", encoding="utf-8") as f: d = json.load(f) d.setdefault("product_name", name) d.setdefault("mac_pattern", DEFAULT_PROFILE["mac_pattern"]) d.setdefault("sn", dict(DEFAULT_PROFILE["sn"])) d.setdefault("tests", []) return d return None def save_profile(prof): p = profile_path(prof["product_name"]) with open(p, "w", encoding="utf-8") as f: json.dump(prof, f, ensure_ascii=False, indent=2) def ensure_default_profile(): if not list_profiles(): save_profile(DEFAULT_PROFILE) # ----- 测试项编辑对话框 ----- class TestEditor(tk.Toplevel): def __init__(self, parent, profile, on_save): super().__init__(parent) self.title(f"编辑测试项 - {profile['product_name']}(所有修改实时同步到主界面)") self.minsize(1100, 600) self.transient(parent) self.profile = json.loads(json.dumps(profile)) self.on_save = on_save self.current_idx = None self._build() self._refresh_list() self.protocol("WM_DELETE_WINDOW", self._save_close) # 默认尺寸 + 屏幕居中(屏幕较小则占 90% 宽度) self.update_idletasks() sw = self.winfo_screenwidth() sh = self.winfo_screenheight() w = min(1500, int(sw * 0.92)) h = min(800, int(sh * 0.85)) x = max(0, (sw - w) // 2) y = max(0, (sh - h) // 2) self.geometry(f"{w}x{h}+{x}+{y}") def _build(self): left = ttk.Frame(self, padding=8) left.pack(side="left", fill="both", expand=True) cols = ("name", "type", "pattern_or_choices") self.tree = ttk.Treeview(left, columns=cols, show="headings", height=20) self.tree.heading("name", text="名称") self.tree.heading("type", text="类型") self.tree.heading("pattern_or_choices", text="正则 / 选项 / 提示") self.tree.column("name", width=180) self.tree.column("type", width=130) self.tree.column("pattern_or_choices", width=340) self.tree.pack(fill="both", expand=True) self.tree.bind("<>", self._on_select) btns = ttk.Frame(left) btns.pack(fill="x", pady=6) ttk.Button(btns, text="+ 新增", command=self._add).pack(side="left", padx=2) ttk.Button(btns, text="- 删除", command=self._del).pack(side="left", padx=2) ttk.Button(btns, text="↑ 上移", command=lambda: self._move(-1)).pack(side="left", padx=2) ttk.Button(btns, text="↓ 下移", command=lambda: self._move(1)).pack(side="left", padx=2) ttk.Label(btns, text=" MAC 提取正则:").pack(side="left", padx=(10, 2)) self.mac_var = tk.StringVar(value=self.profile.get("mac_pattern", "")) mac_entry = ttk.Entry(btns, textvariable=self.mac_var) mac_entry.pack(side="left", padx=2, fill="x", expand=True) mac_entry.bind("", lambda _e: self._save_mac()) right = ttk.LabelFrame(self, text=" 编辑当前项 ", padding=10) right.pack(side="right", fill="y", padx=8, pady=8) ttk.Label(right, text="ID(英文,唯一):").grid(row=0, column=0, sticky="w", pady=3) self.id_var = tk.StringVar() ttk.Entry(right, textvariable=self.id_var, width=26).grid(row=0, column=1, pady=3) ttk.Label(right, text="名称(显示在测试列表):").grid(row=1, column=0, sticky="w", pady=3) self.name_var = tk.StringVar() ttk.Entry(right, textvariable=self.name_var, width=26).grid(row=1, column=1, pady=3) ttk.Label(right, text="类型:").grid(row=2, column=0, sticky="w", pady=3) self.type_var = tk.StringVar() self.type_cb = ttk.Combobox(right, textvariable=self.type_var, values=[TYPE_LABELS[t] for t in TYPES_ORDER], state="readonly", width=24) self.type_cb.grid(row=2, column=1, pady=3) self.type_cb.bind("<>", self._on_type_change) ttk.Label(right, text="正则(自动类型用):").grid(row=3, column=0, sticky="nw", pady=3) self.pattern_var = tk.StringVar() self.pattern_entry = ttk.Entry(right, textvariable=self.pattern_var, width=26) self.pattern_entry.grid(row=3, column=1, pady=3) ttk.Label(right, text="测试匹配:").grid(row=4, column=0, sticky="nw", pady=3) tm = ttk.Frame(right) tm.grid(row=4, column=1, sticky="we", pady=3) self.test_input_var = tk.StringVar() ttk.Entry(tm, textvariable=self.test_input_var, width=18).pack(side="left") ttk.Button(tm, text="试", width=4, command=self._test_regex).pack(side="left", padx=4) self.test_result_var = tk.StringVar(value="(输入一行日志测试)") ttk.Label(right, textvariable=self.test_result_var, foreground="darkgreen", wraplength=220).grid(row=5, column=1, sticky="w") ttk.Label(right, text="选项(多选下拉用,|分隔):").grid(row=6, column=0, sticky="w", pady=3) self.choices_var = tk.StringVar() ttk.Entry(right, textvariable=self.choices_var, width=26).grid(row=6, column=1, pady=3) ttk.Label(right, text="提示语(可选):").grid(row=7, column=0, sticky="w", pady=3) self.hint_var = tk.StringVar() ttk.Entry(right, textvariable=self.hint_var, width=26).grid(row=7, column=1, pady=3) ttk.Button(right, text="💾 保存此项修改", command=self._apply).grid(row=8, column=1, pady=8, sticky="e") ttk.Label(right, text="(每次改完点这里立即同步主界面)", foreground="gray", font=("Segoe UI", 8)).grid(row=9, column=1, sticky="e") bottom = ttk.Frame(self) bottom.pack(side="bottom", fill="x", padx=8, pady=8) ttk.Button(bottom, text="完成 / 关闭", command=self._save_close).pack(side="right", padx=(8, 0)) def _type_value_to_key(self, label): for k, v in TYPE_LABELS.items(): if v == label: return k return "auto" def _on_type_change(self, _e=None): # 根据类型置灰对应输入 self._toggle_inputs() def _toggle_inputs(self): t = self._type_value_to_key(self.type_var.get()) self.pattern_entry.config(state="normal" if t in ("auto", "auto_value") else "disabled") def _refresh_list(self): for it in self.tree.get_children(): self.tree.delete(it) for i, t in enumerate(self.profile["tests"]): extra = "" if t["type"] in ("auto", "auto_value"): extra = t.get("pattern", "") elif t["type"] == "manual_choice": extra = " | ".join(t.get("choices", [])) elif t["type"] in ("manual", "manual_text"): extra = t.get("hint", "") self.tree.insert("", "end", iid=str(i), values=(t.get("name", ""), TYPE_LABELS.get(t["type"], t["type"]), extra)) def _on_select(self, _e=None): sel = self.tree.selection() if not sel: return idx = int(sel[0]) # 切到另一项前,先静默保存当前正在编辑的项 if self.current_idx is not None and self.current_idx != idx: try: self._apply_silent() except Exception: pass self.current_idx = idx t = self.profile["tests"][idx] self.id_var.set(t.get("id", "")) self.name_var.set(t.get("name", "")) self.type_var.set(TYPE_LABELS.get(t["type"], TYPE_LABELS["auto"])) self.pattern_var.set(t.get("pattern", "")) self.choices_var.set("|".join(t.get("choices", []))) self.hint_var.set(t.get("hint", "")) self.test_result_var.set("(输入一行日志测试)") self._toggle_inputs() def _push(self): """把当前编辑后的 profile 推送到主程序""" self.profile["mac_pattern"] = self.mac_var.get().strip() or DEFAULT_PROFILE["mac_pattern"] self.on_save(json.loads(json.dumps(self.profile))) def _save_mac(self): self._push() def _add(self): existing = {t["id"] for t in self.profile["tests"]} n = len(self.profile["tests"]) + 1 new_id = f"item_{n}" while new_id in existing: n += 1 new_id = f"item_{n}" self.profile["tests"].append({"id": new_id, "name": "新测试项", "type": "manual", "hint": ""}) self._refresh_list() self.tree.selection_set(str(len(self.profile["tests"]) - 1)) self._on_select() self._push() def _del(self): if self.current_idx is None: return if not messagebox.askyesno("确认", "删除当前测试项?", parent=self): return del self.profile["tests"][self.current_idx] self.current_idx = None self._refresh_list() self._push() def _move(self, delta): if self.current_idx is None: return new_idx = self.current_idx + delta tests = self.profile["tests"] if 0 <= new_idx < len(tests): tests[self.current_idx], tests[new_idx] = tests[new_idx], tests[self.current_idx] self.current_idx = new_idx self._refresh_list() self.tree.selection_set(str(new_idx)) self._push() def _apply_silent(self): """切换列表项前的静默保存,错误不弹窗""" if self.current_idx is None: return tid = self.id_var.get().strip() name = self.name_var.get().strip() if not tid or not name: return ttype = self._type_value_to_key(self.type_var.get()) for i, t in enumerate(self.profile["tests"]): if i != self.current_idx and t["id"] == tid: return item = {"id": tid, "name": name, "type": ttype} if ttype in ("auto", "auto_value"): pat = self.pattern_var.get().strip() if not pat: return try: re.compile(pat) except re.error: return item["pattern"] = pat if ttype == "manual_choice": choices = [c.strip() for c in self.choices_var.get().split("|") if c.strip()] if len(choices) < 2: return item["choices"] = choices hint = self.hint_var.get().strip() if hint: item["hint"] = hint self.profile["tests"][self.current_idx] = item self._refresh_list() self._push() def _apply(self): if self.current_idx is None: messagebox.showinfo("提示", "请先选中左边列表中的项", parent=self) return tid = self.id_var.get().strip() name = self.name_var.get().strip() if not tid or not name: messagebox.showwarning("提示", "ID 和名称不能为空", parent=self) return ttype = self._type_value_to_key(self.type_var.get()) # 检查 ID 唯一 for i, t in enumerate(self.profile["tests"]): if i != self.current_idx and t["id"] == tid: messagebox.showwarning("提示", f"ID '{tid}' 已被其他项使用", parent=self) return item = {"id": tid, "name": name, "type": ttype} if ttype in ("auto", "auto_value"): pat = self.pattern_var.get().strip() if not pat: messagebox.showwarning("提示", "自动类型必须填正则", parent=self) return try: re.compile(pat) except re.error as e: messagebox.showwarning("正则错误", str(e), parent=self) return item["pattern"] = pat if ttype == "manual_choice": choices = [c.strip() for c in self.choices_var.get().split("|") if c.strip()] if len(choices) < 2: messagebox.showwarning("提示", "至少需要 2 个选项,用 | 分隔", parent=self) return item["choices"] = choices hint = self.hint_var.get().strip() if hint: item["hint"] = hint self.profile["tests"][self.current_idx] = item self._refresh_list() self.tree.selection_set(str(self.current_idx)) self._push() def _test_regex(self): pat = self.pattern_var.get().strip() line = self.test_input_var.get() if not pat or not line: return try: m = re.search(pat, line) if m: self.test_result_var.set(f"✅ 命中: {m.groups() if m.groups() else m.group(0)}") else: self.test_result_var.set("❌ 未命中") except re.error as e: self.test_result_var.set(f"正则错误: {e}") def _save_close(self): # 关闭前如果右边面板有未点"保存此项修改"的改动,尝试自动保存 if self.current_idx is not None: try: self._apply() except Exception: pass self._push() self.destroy() class QuickItemEditor(tk.Toplevel): """单条测试项快速编辑器 - 从主界面 + / ✏ 按钮调起""" def __init__(self, parent, profile, item, on_save, index=None, on_delete=None): super().__init__(parent) self.title("编辑测试项" if item else "添加测试项") self.geometry("500x430") self.transient(parent) self.grab_set() self.profile = profile self.index = index self.on_save = on_save self.on_delete = on_delete if item: self.orig_id = item["id"] init = item else: self.orig_id = None existing = {t["id"] for t in profile["tests"]} n = len(profile["tests"]) + 1 new_id = f"item_{n}" while new_id in existing: n += 1 new_id = f"item_{n}" init = {"id": new_id, "name": "新测试项", "type": "manual", "hint": ""} self._build(init) def _build(self, init): f = ttk.Frame(self, padding=12) f.pack(fill="both", expand=True) ttk.Label(f, text="名称(显示在测试列表):").grid(row=0, column=0, sticky="w", pady=4) self.name_var = tk.StringVar(value=init.get("name", "")) ttk.Entry(f, textvariable=self.name_var, width=36).grid(row=0, column=1, pady=4, sticky="we") ttk.Label(f, text="ID(内部唯一,英文):").grid(row=1, column=0, sticky="w", pady=4) self.id_var = tk.StringVar(value=init.get("id", "")) ttk.Entry(f, textvariable=self.id_var, width=36).grid(row=1, column=1, pady=4, sticky="we") ttk.Label(f, text="类型:").grid(row=2, column=0, sticky="w", pady=4) self.type_var = tk.StringVar(value=TYPE_LABELS.get(init.get("type", "manual"))) type_cb = ttk.Combobox(f, textvariable=self.type_var, values=[TYPE_LABELS[t] for t in TYPES_ORDER], state="readonly", width=34) type_cb.grid(row=2, column=1, pady=4, sticky="we") type_cb.bind("<>", lambda _e: self._toggle_fields()) ttk.Label(f, text="正则(自动类型用):").grid(row=3, column=0, sticky="w", pady=4) self.pattern_var = tk.StringVar(value=init.get("pattern", "")) self.pattern_entry = ttk.Entry(f, textvariable=self.pattern_var, width=36) self.pattern_entry.grid(row=3, column=1, pady=4, sticky="we") ttk.Label(f, text="试匹配(贴一行日志):").grid(row=4, column=0, sticky="w", pady=4) tm = ttk.Frame(f) tm.grid(row=4, column=1, pady=4, sticky="we") self.test_input_var = tk.StringVar() ttk.Entry(tm, textvariable=self.test_input_var, width=24).pack(side="left") ttk.Button(tm, text="试", width=4, command=self._test_regex).pack(side="left", padx=4) self.test_result_var = tk.StringVar(value="") ttk.Label(f, textvariable=self.test_result_var, foreground="darkgreen", wraplength=300).grid(row=5, column=1, sticky="w") ttk.Label(f, text="多选项(| 分隔):").grid(row=6, column=0, sticky="w", pady=4) self.choices_var = tk.StringVar(value="|".join(init.get("choices", []))) self.choices_entry = ttk.Entry(f, textvariable=self.choices_var, width=36) self.choices_entry.grid(row=6, column=1, pady=4, sticky="we") ttk.Label(f, text="提示语(可选):").grid(row=7, column=0, sticky="w", pady=4) self.hint_var = tk.StringVar(value=init.get("hint", "")) ttk.Entry(f, textvariable=self.hint_var, width=36).grid(row=7, column=1, pady=4, sticky="we") # 按钮 btns = ttk.Frame(f) btns.grid(row=8, column=0, columnspan=2, sticky="we", pady=12) if self.on_delete is not None: ttk.Button(btns, text="🗑 删除此项", command=self._delete).pack(side="left") ttk.Button(btns, text="取消", command=self.destroy).pack(side="right", padx=4) ttk.Button(btns, text="✅ 保存", command=self._save).pack(side="right", padx=4) f.columnconfigure(1, weight=1) self._toggle_fields() def _type_key(self): for k, v in TYPE_LABELS.items(): if v == self.type_var.get(): return k return "manual" def _toggle_fields(self): t = self._type_key() self.pattern_entry.config(state="normal" if t in ("auto", "auto_value") else "disabled") self.choices_entry.config(state="normal" if t == "manual_choice" else "disabled") def _test_regex(self): pat = self.pattern_var.get().strip() line = self.test_input_var.get() if not pat or not line: self.test_result_var.set("(要先填正则和一行日志)") return try: m = re.search(pat, line) if m: groups = m.groups() if m.groups() else (m.group(0),) self.test_result_var.set(f"✅ 命中: {groups}") else: self.test_result_var.set("❌ 未命中") except re.error as e: self.test_result_var.set(f"正则错误: {e}") def _save(self): name = self.name_var.get().strip() tid = self.id_var.get().strip() if not name or not tid: messagebox.showwarning("提示", "名称和 ID 不能为空", parent=self) return # ID 唯一性 for t in self.profile["tests"]: if t["id"] == tid and t["id"] != self.orig_id: messagebox.showwarning("提示", f"ID '{tid}' 已被其他项使用", parent=self) return ttype = self._type_key() item = {"id": tid, "name": name, "type": ttype} if ttype in ("auto", "auto_value"): pat = self.pattern_var.get().strip() if not pat: messagebox.showwarning("提示", "自动类型必须填正则", parent=self) return try: re.compile(pat) except re.error as e: messagebox.showwarning("正则错误", str(e), parent=self) return item["pattern"] = pat if ttype == "manual_choice": choices = [c.strip() for c in self.choices_var.get().split("|") if c.strip()] if len(choices) < 2: messagebox.showwarning("提示", "至少要 2 个选项,用 | 分隔", parent=self) return item["choices"] = choices hint = self.hint_var.get().strip() if hint: item["hint"] = hint self.on_save(item, self.index) self.destroy() def _delete(self): if not messagebox.askyesno("确认", "删除此测试项?\n(不会影响已保存的报告 CSV)", parent=self): return if self.on_delete: self.on_delete() self.destroy() # ----- 主程序 ----- class TestJig: def __init__(self, root): self.root = root self.root.title("产品-硬件入库测试工具") self._set_window_icon() self.root.geometry("1240x820") ensure_default_profile() self.settings = load_settings() self.font_size_pt = FONT_SIZES.get(self.settings.get("font_size_label", "中"), 12) names = list_profiles() cur = self.settings.get("current_profile") if cur not in names: cur = names[0] self.settings["current_profile"] = cur save_settings(self.settings) self.profile = load_profile(cur) self.ser = None self.ser_running = False self.log_q = queue.Queue() self.current_mac = None self.test_widgets = {} self._replace_mac = None # 标记本台保存时要覆盖的旧 MAC(维修重测) self._replace_sn = None # 标记本台保存时要覆盖的旧 SN(复用失败 SN) self._mac_seen_in_session = set() # 本会话已询问过的 MAC,避免反复弹窗 self._build_ui() self.reset_state() self.root.after(80, self._drain_log_queue) self.root.protocol("WM_DELETE_WINDOW", self._on_close) # ---------- UI ---------- def _build_ui(self): # 第一条:产品切换 prod_bar = ttk.Frame(self.root, padding=(8, 6, 8, 3)) prod_bar.pack(fill="x") ttk.Label(prod_bar, text="产品:", font=("Segoe UI", 10, "bold")).pack(side="left") self.profile_var = tk.StringVar(value=self.profile["product_name"]) self.profile_cb = ttk.Combobox(prod_bar, textvariable=self.profile_var, values=list_profiles(), state="readonly", width=22) self.profile_cb.pack(side="left", padx=6) self.profile_cb.bind("<>", self._on_profile_change) ttk.Button(prod_bar, text="📝 编辑测试项", command=self._open_editor).pack(side="left", padx=4) ttk.Button(prod_bar, text="➕ 新建产品", command=self._new_profile).pack(side="left", padx=4) ttk.Button(prod_bar, text="📋 复制当前", command=self._copy_profile).pack(side="left", padx=4) ttk.Button(prod_bar, text="✏ 重命名", command=self._rename_profile).pack(side="left", padx=4) ttk.Button(prod_bar, text="🗑 删除当前", command=self._delete_profile).pack(side="left", padx=4) ttk.Button(prod_bar, text="📤 导出 JSON", command=self._export_profile).pack(side="left", padx=4) ttk.Button(prod_bar, text="📥 导入 JSON", command=self._import_profile).pack(side="left", padx=4) ttk.Label(prod_bar, text=APP_VERSION, foreground="#888", font=("Segoe UI", 10, "bold")).pack(side="right", padx=8) # 第二条:串口 ser_outer = ttk.Frame(self.root, padding=(8, 3, 8, 3)) ser_outer.pack(fill="x") ser_bar = ttk.Frame(ser_outer) ser_bar.pack(fill="x") ttk.Label(ser_bar, text="串口:").pack(side="left") ports = self._list_ports() saved_port = self.settings["serial"].get("port", "") init_port = saved_port if saved_port in ports else (ports[0] if ports else "") self.port_var = tk.StringVar(value=init_port) self.port_cb = ttk.Combobox(ser_bar, textvariable=self.port_var, width=10, values=ports, state="readonly") self.port_cb.pack(side="left", padx=4) self.port_cb.bind("<>", lambda _e: self._update_port_hint()) ttk.Button(ser_bar, text="刷新", width=6, command=self._refresh_ports).pack(side="left") ttk.Label(ser_bar, text=" 波特率:").pack(side="left") self.baud_var = tk.StringVar(value=str(self.settings["serial"].get("baudrate", 115200))) baud_cb = ttk.Combobox(ser_bar, textvariable=self.baud_var, width=10, values=["9600", "19200", "38400", "57600", "115200", "230400", "460800", "921600"], state="readonly") baud_cb.pack(side="left", padx=4) self.connect_btn = ttk.Button(ser_bar, text="连接", command=self._toggle_serial) self.connect_btn.pack(side="left", padx=10) ttk.Label(ser_bar, text=" 测试员:").pack(side="left") self.operator_var = tk.StringVar(value=self.settings.get("operator", "")) ttk.Entry(ser_bar, textvariable=self.operator_var, width=10).pack(side="left", padx=4) self.status_var = tk.StringVar(value="● 未连接") self.status_lbl = ttk.Label(ser_bar, textvariable=self.status_var, foreground="gray", font=("Segoe UI", 10, "bold")) self.status_lbl.pack(side="right") # 端口下小灰字:USB 描述 + 序列号 self.port_hint_var = tk.StringVar(value="") ttk.Label(ser_outer, textvariable=self.port_hint_var, foreground="gray", font=("Segoe UI", 8)).pack(anchor="w", padx=(40, 0)) # 第三条:SN sn_bar = ttk.LabelFrame(self.root, text=" SN 序列号 ", padding=8) sn_bar.pack(fill="x", padx=8, pady=3) ttk.Label(sn_bar, text="前缀:").grid(row=0, column=0, sticky="w") self.sn_prefix_var = tk.StringVar() ttk.Entry(sn_bar, textvariable=self.sn_prefix_var, width=12).grid(row=0, column=1, padx=4) ttk.Label(sn_bar, text="位数:").grid(row=0, column=2, sticky="w", padx=(10, 0)) self.sn_digits_var = tk.IntVar() ttk.Spinbox(sn_bar, from_=1, to=12, textvariable=self.sn_digits_var, width=5).grid(row=0, column=3, padx=4) ttk.Label(sn_bar, text="下一个编号:").grid(row=0, column=4, sticky="w", padx=(10, 0)) self.sn_number_var = tk.IntVar() ttk.Spinbox(sn_bar, from_=0, to=999999999, textvariable=self.sn_number_var, width=10).grid(row=0, column=5, padx=4) ttk.Label(sn_bar, text="步长:").grid(row=0, column=6, sticky="w", padx=(10, 0)) self.sn_step_var = tk.IntVar() ttk.Spinbox(sn_bar, from_=-100, to=100, textvariable=self.sn_step_var, width=5).grid(row=0, column=7, padx=4) self.sn_preview_var = tk.StringVar() ttk.Label(sn_bar, textvariable=self.sn_preview_var, foreground="darkgreen", font=("Consolas", 12, "bold")).grid(row=0, column=8, padx=15) for v in (self.sn_prefix_var, self.sn_digits_var, self.sn_number_var): v.trace_add("write", lambda *_: self._refresh_sn_preview()) # 第四条:当前设备 - 极简 dev = ttk.Frame(self.root, padding=(8, 6, 8, 6)) dev.pack(fill="x") ttk.Label(dev, text="MAC:", font=("Segoe UI", 10, "bold")).pack(side="left") self.mac_var = tk.StringVar(value="(等待捕获...)") ttk.Label(dev, textvariable=self.mac_var, font=("Consolas", 12), foreground="blue", width=22).pack(side="left", padx=6) self.mac_match_var = tk.StringVar(value="") ttk.Label(dev, textvariable=self.mac_match_var, font=("Segoe UI", 11, "bold"), width=4).pack(side="left") ttk.Button(dev, text="手动输入", command=self._manual_mac).pack(side="left", padx=2) ttk.Button(dev, text="清除", command=self._clear_mac).pack(side="left", padx=2) ttk.Button(dev, text="+ 添加测试项", command=self._add_test_item).pack(side="left", padx=(20, 2)) # 内部跟踪 USB SN,UI 不显示(已在端口栏下方显示) self.usb_sn_var = tk.StringVar(value="") # 主体(中间可拖动分隔条) body = ttk.PanedWindow(self.root, orient="horizontal") body.pack(fill="both", expand=True, padx=8, pady=3) self.tests_outer = ttk.LabelFrame(body, text=" 测试项 ", padding=4) body.add(self.tests_outer, weight=2) self.canvas = tk.Canvas(self.tests_outer, highlightthickness=0) self.canvas_sb = ttk.Scrollbar(self.tests_outer, orient="vertical", command=self.canvas.yview) self.tests_frame = ttk.Frame(self.canvas) self.tests_frame.bind("", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) self._canvas_window = self.canvas.create_window((0, 0), window=self.tests_frame, anchor="nw") self.canvas.bind("", lambda e: self.canvas.itemconfig(self._canvas_window, width=e.width)) self.canvas.configure(yscrollcommand=self.canvas_sb.set) # 统计汇总面板(测试项列表下方) stats = ttk.LabelFrame(self.tests_outer, text=" 统计-汇总 ", padding=8) stats.pack(side="bottom", fill="x", pady=(6, 0)) # 行 1:批次号 / 数量 / 累计 / 未测 row1 = ttk.Frame(stats) row1.pack(fill="x") ttk.Label(row1, text="批次号:", font=("Segoe UI", 11, "bold")).pack(side="left") self.batch_no_var = tk.StringVar(value=str(self.settings.get("batch_no", "1"))) ttk.Entry(row1, textvariable=self.batch_no_var, width=12, font=("Segoe UI", 11)).pack(side="left", padx=(4, 15)) self.batch_no_var.trace_add("write", lambda *_: self._on_batch_no_changed()) ttk.Label(row1, text="数量:", font=("Segoe UI", 11, "bold")).pack(side="left") self.batch_size_var = tk.IntVar(value=int(self.settings.get("batch_size", 600))) ttk.Spinbox(row1, from_=1, to=999999, textvariable=self.batch_size_var, width=6, command=self._on_batch_changed).pack(side="left", padx=(4, 2)) ttk.Label(row1, text="套", font=("Segoe UI", 11, "bold")).pack(side="left", padx=(2, 15)) self.batch_size_var.trace_add("write", lambda *_: self._on_batch_changed()) ttk.Label(row1, text="累计:", font=("Segoe UI", 11, "bold")).pack(side="left") self.stats_total_var = tk.StringVar(value="0") ttk.Label(row1, textvariable=self.stats_total_var, font=("Segoe UI", 16, "bold"), foreground="#222").pack(side="left", padx=(4, 0)) ttk.Label(row1, text="台", font=("Segoe UI", 11, "bold")).pack(side="left", padx=(2, 0)) ttk.Label(row1, text=" ⏱ 未测:", font=("Segoe UI", 11, "bold")).pack(side="left", padx=(15, 0)) self.stats_remain_var = tk.StringVar(value="0") ttk.Label(row1, textvariable=self.stats_remain_var, font=("Segoe UI", 16, "bold"), foreground="#0080c0").pack(side="left", padx=(4, 0)) ttk.Label(row1, text="台", font=("Segoe UI", 11, "bold")).pack(side="left", padx=(2, 0)) # 行 2:合格 + 合格率 / 不合格 + 不良率 row2 = ttk.Frame(stats) row2.pack(fill="x", pady=(4, 0)) ttk.Label(row2, text="✅ 合格:", font=("Segoe UI", 11, "bold")).pack(side="left") self.stats_pass_var = tk.StringVar(value="0") ttk.Label(row2, textvariable=self.stats_pass_var, font=("Segoe UI", 16, "bold"), foreground="darkgreen").pack(side="left", padx=(4, 0)) ttk.Label(row2, text="台", font=("Segoe UI", 11, "bold")).pack(side="left", padx=(2, 0)) ttk.Label(row2, text=" 合格率:", font=("Segoe UI", 11, "bold")).pack(side="left", padx=(8, 0)) self.stats_pass_rate_var = tk.StringVar(value="—") ttk.Label(row2, textvariable=self.stats_pass_rate_var, font=("Segoe UI", 16, "bold"), foreground="darkgreen").pack(side="left", padx=(4, 0)) ttk.Label(row2, text=" ❌ 不合格:", font=("Segoe UI", 11, "bold")).pack(side="left", padx=(10, 0)) self.stats_fail_var = tk.StringVar(value="0") ttk.Label(row2, textvariable=self.stats_fail_var, font=("Segoe UI", 16, "bold"), foreground="red").pack(side="left", padx=(4, 0)) ttk.Label(row2, text="台", font=("Segoe UI", 11, "bold")).pack(side="left", padx=(2, 0)) ttk.Label(row2, text=" 不良率:", font=("Segoe UI", 11, "bold")).pack(side="left", padx=(8, 0)) self.stats_rate_var = tk.StringVar(value="—") ttk.Label(row2, textvariable=self.stats_rate_var, font=("Segoe UI", 16, "bold"), foreground="#d97706").pack(side="left", padx=(4, 0)) ttk.Label(stats, text="⚠ 所有测试项必须全部通过才算合格;任一项 FAIL 即不合格,需返厂或维修", font=("Segoe UI", 10, "bold"), anchor="w", wraplength=520, justify="left").pack(fill="x", pady=(4, 0)) self.canvas.pack(side="left", fill="both", expand=True) self.canvas_sb.pack(side="right", fill="y") # 鼠标只在测试项区域内才响应滚轮,避免在其他区域滚动时误触 self.canvas.bind("", lambda _e: self.canvas.bind_all("", self._on_tests_mousewheel)) self.canvas.bind("", lambda _e: self.canvas.unbind_all("")) # 日志 right = ttk.LabelFrame(body, text=" 串口日志 ", padding=4) body.add(right, weight=3) self.log_widget = scrolledtext.ScrolledText(right, wrap="word", font=("Consolas", 9)) self.log_widget.pack(fill="both", expand=True) self.log_widget.tag_config("hit", background="#fffbcc") self.log_widget.tag_config("mac", foreground="blue", font=("Consolas", 9, "bold")) self.log_widget.tag_config("ts", foreground="#1a8a2e") lb = ttk.Frame(right) lb.pack(fill="x") self.autoscroll_var = tk.BooleanVar(value=True) ttk.Checkbutton(lb, text="自动滚动", variable=self.autoscroll_var).pack(side="left") self.auto_clear_log_var = tk.BooleanVar( value=bool(self.settings.get("auto_clear_log_on_disconnect", False))) ttk.Checkbutton(lb, text="断开时自动清空日志", variable=self.auto_clear_log_var, command=self._save_global_settings).pack(side="left", padx=(15, 0)) ttk.Button(lb, text="清空日志", command=lambda: self.log_widget.delete(1.0, "end")).pack(side="right") # 底部 bottom = ttk.Frame(self.root, padding=8) bottom.pack(fill="x") ttk.Button(bottom, text="🔄 重置本台", command=self.reset_state).pack(side="left", padx=4) ttk.Button(bottom, text="💾 保存本台数据", command=self._save_and_next).pack(side="left", padx=4) ttk.Button(bottom, text="📂 打开报告 CSV", command=self._open_report).pack(side="left", padx=4) ttk.Button(bottom, text="📊 导出统计汇总", command=self._export_summary).pack(side="left", padx=4) ttk.Button(bottom, text="📁 打开 reports 目录", command=lambda: os.startfile(REPORTS_DIR)).pack(side="left", padx=4) # 字号下拉 ttk.Label(bottom, text=" 字号:").pack(side="left", padx=(20, 2)) self.font_size_var = tk.StringVar(value=self.settings.get("font_size_label", "中")) font_cb = ttk.Combobox(bottom, textvariable=self.font_size_var, values=list(FONT_SIZES.keys()), state="readonly", width=6) font_cb.pack(side="left") font_cb.bind("<>", lambda _e: self._on_font_changed()) self.count_var = tk.StringVar() ttk.Label(bottom, textvariable=self.count_var, font=("Segoe UI", 10, "bold")).pack(side="right", padx=8) # 加载 profile 到 UI + 初始读取端口信息 self._apply_font_style() self._apply_profile_to_ui() self._update_port_hint() # ---------- Profile ---------- def _on_tests_mousewheel(self, event): """测试项 Canvas 滚轮:只在内容超出可视区时滚动,到顶/到底自动停""" try: first, last = self.canvas.yview() if first <= 0.0 and last >= 1.0: return # 内容没超出,无需滚 delta = int(-1 * (event.delta / 120)) if delta < 0 and first <= 0.0: return # 已到顶 if delta > 0 and last >= 1.0: return # 已到底 self.canvas.yview_scroll(delta, "units") except Exception: pass def _refresh_stats(self): """读取当前产品当日 CSV,统计 PASS/FAIL/累计/不良率(基于批次)""" if not hasattr(self, "stats_total_var"): return rf = self._report_file() total = pass_n = fail_n = 0 if rf.exists(): try: with open(rf, "r", encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) for row in reader: total += 1 r = row.get("整体结果", "").strip() if r == "PASS": pass_n += 1 elif r == "FAIL": fail_n += 1 except Exception: pass self.stats_total_var.set(str(total)) self.stats_pass_var.set(str(pass_n)) self.stats_fail_var.set(str(fail_n)) # 未测 = 批次 - 已测(不小于 0) try: batch = max(1, int(self.batch_size_var.get())) except (tk.TclError, ValueError): batch = 1 self.stats_remain_var.set(str(max(0, batch - total))) # 合格率 / 不良率 都基于已测台数;未测台数不计入分母 if total > 0: self.stats_pass_rate_var.set(f"{pass_n / total * 100:.1f}%") self.stats_rate_var.set(f"{fail_n / total * 100:.1f}%") else: self.stats_pass_rate_var.set("—") self.stats_rate_var.set("—") def _calc_defect_rate(self, n, f): """CSV 累计不良率列用:基于已测台数""" if n == 0: return "—" return f"{f / n * 100:.1f}%" def _on_batch_changed(self): try: self.settings["batch_size"] = int(self.batch_size_var.get()) save_settings(self.settings) except (tk.TclError, ValueError): pass self._refresh_stats() def _on_batch_no_changed(self): self.settings["batch_no"] = self.batch_no_var.get() save_settings(self.settings) # 切换批次号 = 切换 CSV 文件 → 刷新累计/合格/不合格等;MAC 询问表也清空 self._mac_seen_in_session.clear() self._refresh_stats() if hasattr(self, "count_var"): self.count_var.set(f"{self.profile['product_name']} 已记录: {self._count_records()} 台") def _apply_font_style(self): """让 ttk Button/Entry 用当前字号""" style = ttk.Style() btn_fs = max(self.font_size_pt - 1, 9) style.configure("Test.TButton", font=("Segoe UI", btn_fs)) def _on_font_changed(self): label = self.font_size_var.get() self.font_size_pt = FONT_SIZES.get(label, 12) self.settings["font_size_label"] = label save_settings(self.settings) self._apply_font_style() self._rebuild_tests() def _apply_profile_to_ui(self): self.sn_prefix_var.set(self.profile["sn"]["prefix"]) self.sn_digits_var.set(self.profile["sn"]["digits"]) self.sn_number_var.set(self.profile["sn"]["next_number"]) self.sn_step_var.set(self.profile["sn"]["step"]) self._refresh_sn_preview() self._rebuild_tests() self.count_var.set(f"{self.profile['product_name']} 已记录: {self._count_records()} 台") self._refresh_stats() def _rebuild_tests(self): for c in self.tests_frame.winfo_children(): c.destroy() self.test_widgets = {} for test in self.profile["tests"]: self._build_test_row(test) def _build_test_row(self, test): fs = self.font_size_pt hint_fs = max(fs - 3, 8) row = ttk.Frame(self.tests_frame) row.pack(fill="x", pady=2) # ✏ 编辑按钮放在最左(沙漏/状态标识之前) ttk.Button(row, text="✏", width=3, style="Test.TButton", command=lambda i=test["id"]: self._quick_edit_item(i)).pack(side="left", padx=2) state_var = tk.StringVar() ttk.Label(row, textvariable=state_var, width=28, anchor="w", font=("Segoe UI", fs)).pack(side="left") w = {"test": test, "state_var": state_var, "result": None, "value": None, "hits": 0, "base_name": test["name"]} ttype = test["type"] if ttype == "manual_choice": cv = tk.StringVar(value="") cb = ttk.Combobox(row, textvariable=cv, values=test.get("choices", []), width=14, state="readonly") cb.pack(side="left", padx=2) cb.bind("<>", lambda _e, i=test["id"], v=cv: self._set_choice(i, v.get())) # 选完状态后,再人工 ✓/✗ 判定(避免程序按关键词误判) ttk.Button(row, text="✓", width=3, style="Test.TButton", command=lambda i=test["id"]: self._set_manual(i, True)).pack(side="left", padx=2) ttk.Button(row, text="✗", width=3, style="Test.TButton", command=lambda i=test["id"]: self._set_manual(i, False)).pack(side="left", padx=2) w["choice_var"] = cv elif ttype == "manual_text": tv = tk.StringVar() ttk.Entry(row, textvariable=tv, width=16, font=("Segoe UI", fs)).pack(side="left", padx=2) ttk.Button(row, text="✓填入", width=6, style="Test.TButton", command=lambda i=test["id"], v=tv: self._set_text(i, v.get(), True)).pack(side="left", padx=2) ttk.Button(row, text="✗", width=3, style="Test.TButton", command=lambda i=test["id"]: self._set_manual(i, False)).pack(side="left", padx=2) w["text_var"] = tv else: ttk.Button(row, text="✓", width=3, style="Test.TButton", command=lambda i=test["id"]: self._set_manual(i, True)).pack(side="left", padx=2) ttk.Button(row, text="✗", width=3, style="Test.TButton", command=lambda i=test["id"]: self._set_manual(i, False)).pack(side="left", padx=2) hint = test.get("hint", "") if hint: ttk.Label(row, text=f"💡{hint}", foreground="gray", font=("Segoe UI", hint_fs)).pack(side="left", padx=6) self.test_widgets[test["id"]] = w self._refresh_label(test["id"]) def _on_profile_change(self, _e=None): # 保存当前 profile SN 进度 self._sync_sn_to_profile() save_profile(self.profile) # 切换 name = self.profile_var.get() new = load_profile(name) if not new: return self.profile = new self.settings["current_profile"] = name save_settings(self.settings) self._mac_seen_in_session.clear() self.reset_state() self._apply_profile_to_ui() def _open_editor(self): def on_save(new_prof): new_prof["sn"] = self.profile["sn"] # 保留 SN 状态 self.profile = new_prof save_profile(self.profile) self._apply_profile_to_ui() TestEditor(self.root, self.profile, on_save) def _new_profile(self): name = simpledialog.askstring("新建产品", "产品名称(英文字母数字+短横线):", parent=self.root) if not name: return name = name.strip() if not name or name in list_profiles(): messagebox.showwarning("提示", "名称为空或已存在") return prof = json.loads(json.dumps(DEFAULT_PROFILE)) prof["product_name"] = name prof["tests"] = [] # 新产品空白起步 save_profile(prof) self.profile_cb["values"] = list_profiles() self.profile_var.set(name) self._on_profile_change() messagebox.showinfo("已新建", f"产品 '{name}' 已新建。\n点 '编辑测试项' 添加测试项。") def _copy_profile(self): new_name = simpledialog.askstring( "复制当前", f"基于 '{self.profile['product_name']}' 复制,新名称:", initialvalue=self.profile["product_name"] + "_copy", parent=self.root) if not new_name: return new_name = new_name.strip() if new_name in list_profiles(): messagebox.showwarning("提示", "名称已存在") return prof = json.loads(json.dumps(self.profile)) prof["product_name"] = new_name prof["sn"]["next_number"] = 1 save_profile(prof) self.profile_cb["values"] = list_profiles() self.profile_var.set(new_name) self._on_profile_change() def _rename_profile(self): old = self.profile["product_name"] new_name = simpledialog.askstring("重命名", "新名称:", initialvalue=old, parent=self.root) if not new_name or new_name == old: return new_name = new_name.strip() if new_name in list_profiles(): messagebox.showwarning("提示", "名称已存在") return old_path = profile_path(old) self.profile["product_name"] = new_name save_profile(self.profile) try: old_path.unlink() except Exception: pass self.profile_cb["values"] = list_profiles() self.profile_var.set(new_name) self.settings["current_profile"] = new_name save_settings(self.settings) def _delete_profile(self): names = list_profiles() if len(names) <= 1: messagebox.showwarning("提示", "至少保留一个产品配置") return if not messagebox.askyesno("确认", f"删除产品 '{self.profile['product_name']}' 的配置?\n(已生成的报告 CSV 不会被删除)"): return try: profile_path(self.profile["product_name"]).unlink() except Exception: pass names = list_profiles() self.profile_cb["values"] = names self.profile_var.set(names[0]) self._on_profile_change() def _export_profile(self): f = filedialog.asksaveasfilename( defaultextension=".json", initialfile=f"{self.profile['product_name']}.json", filetypes=[("JSON", "*.json")]) if not f: return with open(f, "w", encoding="utf-8") as fp: json.dump(self.profile, fp, ensure_ascii=False, indent=2) messagebox.showinfo("已导出", f) def _import_profile(self): f = filedialog.askopenfilename(filetypes=[("JSON", "*.json")]) if not f: return try: with open(f, "r", encoding="utf-8") as fp: prof = json.load(fp) name = prof.get("product_name") or Path(f).stem prof["product_name"] = name if name in list_profiles(): if not messagebox.askyesno("覆盖", f"产品 '{name}' 已存在,覆盖?"): return save_profile(prof) except Exception as e: messagebox.showerror("导入失败", str(e)) return self.profile_cb["values"] = list_profiles() self.profile_var.set(name) self._on_profile_change() # ---------- 状态 ---------- def reset_state(self): self.current_mac = None self._replace_mac = None self._replace_sn = None self.mac_var.set("(等待捕获 MAC...)") for tid, w in self.test_widgets.items(): w["result"] = None w["value"] = None w["hits"] = 0 if "choice_var" in w: w["choice_var"].set("") if "text_var" in w: w["text_var"].set("") self._refresh_label(tid) def _refresh_label(self, tid): w = self.test_widgets[tid] name = w["base_name"] hits = f" [{w['hits']}次]" if w["hits"] > 0 else "" if w["result"] is None: if w["value"] not in (None, ""): text = f"⏳ {name} = {w['value']} (待判定)" else: text = f"⏳ {name}{hits}" elif w["result"] is True: extra = f" = {w['value']}" if w["value"] not in (None, "") else hits text = f"✅ {name}{extra}" else: extra = f" = {w['value']}" if w["value"] not in (None, "") else hits text = f"❌ {name}{extra}" w["state_var"].set(text) def _set_manual(self, tid, ok): self.test_widgets[tid]["result"] = ok self._refresh_label(tid) def _set_choice(self, tid, choice): """只记录选了什么状态;PASS/FAIL 由人工 ✓/✗ 决定,避免按关键词误判""" w = self.test_widgets[tid] w["value"] = choice self._refresh_label(tid) def _set_text(self, tid, text, mark_pass): w = self.test_widgets[tid] w["value"] = text.strip() w["result"] = mark_pass and bool(text.strip()) if not text.strip(): messagebox.showinfo("提示", "请先填写文本再点 ✓填入") return self._refresh_label(tid) def _refresh_sn_preview(self): try: prefix = self.sn_prefix_var.get() digits = max(1, int(self.sn_digits_var.get())) n = int(self.sn_number_var.get()) sn = f"{prefix}{n:0{digits}d}" self.sn_preview_var.set(f"下一台入库 SN ➜ {sn}(PASS 才占用,FAIL 不占)") except (ValueError, tk.TclError): self.sn_preview_var.set("(SN 格式无效)") def _current_sn(self): prefix = self.sn_prefix_var.get() digits = max(1, int(self.sn_digits_var.get())) return f"{prefix}{int(self.sn_number_var.get()):0{digits}d}" def _sync_sn_to_profile(self): try: self.profile["sn"] = { "prefix": self.sn_prefix_var.get(), "digits": int(self.sn_digits_var.get()), "next_number": int(self.sn_number_var.get()), "step": int(self.sn_step_var.get()), } except (ValueError, tk.TclError): pass # ---------- 串口 ---------- def _list_ports(self): return [p.device for p in serial.tools.list_ports.comports()] def _refresh_ports(self): ports = self._list_ports() self.port_cb["values"] = ports # 当前选中的端口已拔掉,自动切到第一个可用端口 if self.port_var.get() not in ports: self.port_var.set(ports[0] if ports else "") self._update_port_hint() def _update_port_hint(self): """端口栏下方的小灰字:描述 + 序列号(适配任意串口类型)""" if not hasattr(self, "port_hint_var"): return ports = self._list_ports() if not ports: self.port_hint_var.set(" ⚠ 未检测到任何串口(检查 USB 是否插好 / 驱动是否安装)") if hasattr(self, "usb_sn_var"): self.usb_sn_var.set("") self._update_mac_match() return port = self.port_var.get().strip() if not port: self.port_hint_var.set("") if hasattr(self, "usb_sn_var"): self.usb_sn_var.set("") self._update_mac_match() return for p in serial.tools.list_ports.comports(): if p.device == port: parts = [] if p.description and p.description.lower() != "n/a": parts.append(p.description) if p.manufacturer and p.manufacturer.lower() != "n/a": parts.append(p.manufacturer) sn = (p.serial_number or "").strip() if sn: norm = self._normalize_mac(sn) if len(norm) == 12: sn = ":".join(norm[i:i + 2].upper() for i in range(0, 12, 2)) parts.append(sn) self.port_hint_var.set(" " + " · ".join(parts) if parts else f" {port} (无额外信息)") if hasattr(self, "usb_sn_var"): self.usb_sn_var.set(p.serial_number or "") self._update_mac_match() return self.port_hint_var.set("") def _toggle_serial(self): if self.ser_running: self._disconnect() else: self._connect() def _connect(self): port = self.port_var.get().strip() if not port: messagebox.showwarning("提示", "请先选择串口") return try: baud = int(self.baud_var.get()) self.ser = serial.Serial(port, baud, timeout=0.5) self.ser_running = True threading.Thread(target=self._serial_loop, daemon=True).start() self.connect_btn.config(text="断开") self.status_var.set(f"● 已连接 {port}@{baud}") self.status_lbl.config(foreground="#1a8a2e") self._read_usb_sn() self._save_global_settings() except Exception as e: messagebox.showerror("连接失败", str(e)) def _disconnect(self): self.ser_running = False if self.ser: try: self.ser.close() except Exception: pass self.ser = None self.connect_btn.config(text="连接") self.status_var.set("● 未连接") self.status_lbl.config(foreground="gray") self._mac_seen_in_session.clear() if getattr(self, "auto_clear_log_var", None) and self.auto_clear_log_var.get(): try: self.log_widget.delete(1.0, "end") except Exception: pass def _read_usb_sn(self): self._update_port_hint() @staticmethod def _normalize_mac(s): if not s: return "" return re.sub(r"[^0-9a-fA-F]", "", s).lower() def _update_mac_match(self): usb = self._normalize_mac(self.usb_sn_var.get()) ser = self._normalize_mac(self.current_mac or "") if len(usb) != 12 or len(ser) != 12: self.mac_match_var.set("") return if usb == ser: self.mac_match_var.set("✅ 一致") else: self.mac_match_var.set("❌ 不一致!") def _check_and_warn_duplicate(self, mac): """新 MAC 进入流程: - 同会话同 MAC 反复来 → 忽略(避免设备没拔反复触发) - CSV 中已有 PASS 行匹配此 MAC → 弹窗确认是否重测覆盖 - 其他(无记录 / 只有 FAIL 行) → 当新设备,重算下一个可用 SN """ if not mac: return norm = self._normalize_mac(mac) or mac.strip().lower() if not norm: return if norm in self._mac_seen_in_session: return self._mac_seen_in_session.add(norm) rf = self._report_file() pass_match = None if rf.exists(): try: with open(rf, "r", encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) for row in reader: csv_mac = row.get("MAC", "") nm = self._normalize_mac(csv_mac) or csv_mac.strip().lower() if nm != norm: continue if row.get("整体结果", "").strip() == "PASS": pass_match = row break except Exception: pass if pass_match: sn_v = pass_match.get("SN", "") t_v = pass_match.get("时间", "") ans = messagebox.askyesno( "⚠ 该 MAC 已入库", f"MAC: {mac}\n" f"已作为合格设备入库:\n" f" SN: {sn_v}\n" f" 时间: {t_v}\n\n" f"是否确认是同一台设备?\n" f" 是 = 重测并覆盖原 PASS 记录\n" f" 否 = 清除 MAC,跳过这台" ) if ans: self._replace_mac = mac else: self.current_mac = None self.mac_var.set("(等待捕获...)") self._update_mac_match() self._replace_mac = None return # 全新设备 或 之前只 FAIL 过 → 当新设备,把 SN 改为最小可用 try: self.sn_number_var.set(self._next_available_sn()) self._refresh_sn_preview() except (tk.TclError, ValueError): pass def _next_available_sn(self): """扫主 CSV 的 PASS 行 SN 集合,返回最小未被占用的正整数(FAIL 行不占用)""" rf = self._report_file() used = set() if rf.exists(): try: with open(rf, "r", encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) for row in reader: if row.get("整体结果", "").strip() != "PASS": continue sn = row.get("SN", "").strip() m = re.search(r"\d+", sn) if m: used.add(int(m.group(0))) except Exception: pass # 从 1 开始找最小不在 used 中的(用户改了起始也按这逻辑,避免再被改) n = 1 while n in used: n += 1 return n def _remove_csv_row_by_mac(self, mac): """从当前批次 CSV 删除匹配 MAC 的旧行(维修重测覆盖用)""" rf = self._report_file() if not rf.exists() or not mac: return norm = self._normalize_mac(mac) or mac.strip().lower() try: with open(rf, "r", encoding="utf-8-sig", newline="") as f: reader = csv.reader(f) headers = next(reader, None) if headers is None: return try: mac_idx = headers.index("MAC") except ValueError: return kept = [] for r in reader: if mac_idx < len(r): nm = self._normalize_mac(r[mac_idx]) or r[mac_idx].strip().lower() if nm == norm: continue kept.append(r) with open(rf, "w", encoding="utf-8-sig", newline="") as f: w = csv.writer(f) w.writerow(headers) for r in kept: w.writerow(r) except Exception as e: messagebox.showwarning("覆盖旧记录失败", str(e)) def _use_usb_as_mac(self): sn = self.usb_sn_var.get() norm = self._normalize_mac(sn) if len(norm) != 12: messagebox.showwarning("提示", f"USB SN '{sn}' 不像 MAC(需 12 位 hex)。\n" f"如果板子用了 CH340/CP2102 等桥接芯片,USB SN 就不是 MAC。") return mac = ":".join(norm[i:i + 2] for i in range(0, 12, 2)) self.current_mac = mac self.mac_var.set(mac) self._update_mac_match() def _serial_loop(self): buf = bytearray() while self.ser_running and self.ser: try: data = self.ser.read(512) if not data: continue buf.extend(data) while b"\n" in buf: line, _, buf = buf.partition(b"\n") text = line.decode("utf-8", errors="replace").rstrip("\r") if text: self.log_q.put(text) except Exception as e: self.log_q.put(f"[串口异常] {e}") break def _drain_log_queue(self): try: while True: self._handle_line(self.log_q.get_nowait()) except queue.Empty: pass self.root.after(80, self._drain_log_queue) def _handle_line(self, line): ts = datetime.now().strftime("%H:%M:%S") hit_any = False mac_hit = False try: m = re.search(self.profile["mac_pattern"], line) if m: mac = m.group(1).lower() if mac != self.current_mac: self.current_mac = mac self.mac_var.set(mac) self._update_mac_match() self._check_and_warn_duplicate(mac) mac_hit = True except re.error: pass for test in self.profile["tests"]: ttype = test["type"] pat = test.get("pattern") if not pat: continue try: if ttype == "auto": if re.search(pat, line): w = self.test_widgets.get(test["id"]) if w: w["hits"] += 1 if w["result"] is None: w["result"] = True self._refresh_label(test["id"]) hit_any = True elif ttype == "auto_value": mm = re.search(pat, line) if mm: w = self.test_widgets.get(test["id"]) if w: val = ", ".join(mm.groups()) if len(mm.groups()) > 1 else mm.group(1) w["result"] = True w["value"] = val w["hits"] += 1 self._refresh_label(test["id"]) hit_any = True except re.error: continue # 时间戳单独用绿色 tag,内容保留原有 tag self.log_widget.insert("end", f"{ts} ", "ts") if mac_hit: self.log_widget.insert("end", f"{line}\n", "mac") elif hit_any: self.log_widget.insert("end", f"{line}\n", "hit") else: self.log_widget.insert("end", f"{line}\n") if self.autoscroll_var.get(): self.log_widget.see("end") # ---------- MAC / 临时项 ---------- def _manual_mac(self): v = simpledialog.askstring("手动输入 MAC", "MAC 地址:", initialvalue=self.current_mac or "", parent=self.root) if v: self.current_mac = v.strip().lower() self.mac_var.set(self.current_mac) self._update_mac_match() self._check_and_warn_duplicate(self.current_mac) def _clear_mac(self): # 主动清除 → 允许该 MAC 下次再被询问 if self.current_mac: norm = self._normalize_mac(self.current_mac) or self.current_mac.strip().lower() self._mac_seen_in_session.discard(norm) self.current_mac = None self._replace_mac = None self._replace_sn = None self.mac_var.set("(等待捕获...)") self._update_mac_match() self._refresh_sn_preview() def _add_test_item(self): """+ 按钮 - 加一条测试项到当前 profile(立即保存)""" QuickItemEditor(self.root, profile=self.profile, item=None, on_save=self._on_item_saved) def _quick_edit_item(self, tid): """✏ 按钮 - 编辑现有测试项""" for i, t in enumerate(self.profile["tests"]): if t["id"] == tid: QuickItemEditor(self.root, profile=self.profile, item=t, index=i, on_save=self._on_item_saved, on_delete=lambda idx=i: self._delete_item(idx)) return def _on_item_saved(self, item, index): """快速编辑器保存回调""" if index is None: self.profile["tests"].append(item) else: self.profile["tests"][index] = item save_profile(self.profile) self._rebuild_tests() def _delete_item(self, idx): del self.profile["tests"][idx] save_profile(self.profile) self._rebuild_tests() # ---------- 保存 ---------- def _report_file(self): date = datetime.now().strftime("%Y-%m-%d") safe_name = re.sub(r"[^\w\-.]+", "_", self.profile["product_name"]) batch_no = getattr(self, "batch_no_var", None) if batch_no is not None: bn = batch_no.get().strip() or "1" safe_bn = re.sub(r"[^\w\-.]+", "_", bn) return REPORTS_DIR / f"{safe_name}_{date}_批次{safe_bn}.csv" return REPORTS_DIR / f"{safe_name}_{date}.csv" def _save_and_next(self): sn = self._current_sn() if not self.current_mac: if not messagebox.askyesno("提示", f"SN={sn} 尚未捕获到 MAC,仍要保存吗?"): return all_results = [w["result"] for w in self.test_widgets.values()] if any(r is False for r in all_results): overall = "FAIL" elif any(r is None for r in all_results): overall = "未完成" else: overall = "PASS" rf = self._report_file() # 当前表头 headers = ["时间", "SN", "MAC", "测试员", "产品"] for test in self.profile["tests"]: headers.append(test["name"]) headers += ["整体结果", "备注"] # 表头一致性检查:若已有 CSV 列结构不同,归档旧文件 if rf.exists(): with open(rf, "r", encoding="utf-8-sig", newline="") as fr: old_headers = next(csv.reader(fr), []) if old_headers != headers: ts = datetime.now().strftime("%H%M%S") archived = rf.with_name(rf.stem + f"_archived_{ts}.csv") rf.rename(archived) messagebox.showinfo("CSV 表头已变", f"测试项有变动,旧 CSV 已归档为:\n{archived.name}\n\n新数据将写入新 CSV。") # 已入库重测:先删除原同 MAC 行(用户确认是同一台设备) replaced = bool(self._replace_mac) if replaced: self._remove_csv_row_by_mac(self._replace_mac) new_file = not rf.exists() with open(rf, "a", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) if new_file: writer.writerow(headers) # FAIL 不占 SN,SN 列留空 sn_field = sn if overall == "PASS" else "" row = [ datetime.now().strftime("%Y-%m-%d %H:%M:%S"), sn_field, self.current_mac or "", self.operator_var.get(), self.profile["product_name"], ] for test in self.profile["tests"]: w = self.test_widgets[test["id"]] if w["result"] is None: cell = "—" elif w["result"] is True: if w["value"] not in (None, ""): cell = f"PASS ({w['value']})" elif w["hits"] > 0: cell = f"PASS ({w['hits']}次)" else: cell = "PASS" else: cell = f"FAIL ({w['value']})" if w["value"] else "FAIL" row.append(cell) row.append(overall) row.append("") writer.writerow(row) # 保存后重算下一个可用 SN(PASS 占用 +1;FAIL 不占用所以 spinbox 不变) try: self.sn_number_var.set(self._next_available_sn()) except (tk.TclError, ValueError): pass self._sync_sn_to_profile() save_profile(self.profile) self._save_global_settings() self.count_var.set(f"{self.profile['product_name']} 已记录: {self._count_records()} 台") self._refresh_stats() replace_tag = "(已覆盖原记录)" if replaced else "" messagebox.showinfo("已保存", f"产品: {self.profile['product_name']}\nSN: {sn}\n" f"MAC: {self.current_mac or '(无)'}\n结果: {overall}{replace_tag}\n\n" f"已写入: {rf.name}\n准备下一台") # 批次测满自动提示生成汇总 try: batch = max(1, int(self.batch_size_var.get())) except (tk.TclError, ValueError): batch = 0 if batch and self._count_records() >= batch: if messagebox.askyesno("批次已完成", f"本批次 {batch} 套已全部测完!\n是否立即生成汇总报告?"): self._export_summary() # 保存完自动断开串口:本台测试结束,等测试员拔设备装下一台再连 if self.ser_running: self._disconnect() self.reset_state() def _count_records(self): rf = self._report_file() if not rf.exists(): return 0 with open(rf, "r", encoding="utf-8-sig") as f: return max(sum(1 for _ in f) - 1, 0) def _open_report(self): rf = self._report_file() if rf.exists(): os.startfile(rf) else: messagebox.showinfo("提示", f"今日 {self.profile['product_name']} 还没有报告\n({rf.name} 不存在)") def _export_summary(self, silent=False): """生成统计汇总文件:弹对话框让用户选 .xlsx(居中)或 .csv(纯文本)""" rf = self._report_file() if not rf.exists(): if not silent: messagebox.showinfo("提示", "今日还没有任何测试数据,先保存几台再导出汇总") return None total = pass_n = fail_n = 0 detail_rows = [] detail_headers = None op_counts = {} try: with open(rf, "r", encoding="utf-8-sig", newline="") as f: reader = csv.reader(f) detail_headers = next(reader, None) if detail_headers is None: return None try: res_idx = detail_headers.index("整体结果") except ValueError: res_idx = None try: op_idx = detail_headers.index("测试员") except ValueError: op_idx = None for r in reader: detail_rows.append(r) total += 1 if res_idx is not None and res_idx < len(r): v = r[res_idx].strip() if v == "PASS": pass_n += 1 elif v == "FAIL": fail_n += 1 if op_idx is not None and op_idx < len(r): op = r[op_idx].strip() if op: op_counts[op] = op_counts.get(op, 0) + 1 except Exception as e: messagebox.showerror("读取明细失败", str(e)) return None # 测试员汇总:从明细去重统计每人测了多少台(追责依据,跟明细一致) if op_counts: ops_str = ", ".join(f"{k}({v}台)" for k, v in op_counts.items()) else: ops_str = self.operator_var.get() or "—" try: batch = max(1, int(self.batch_size_var.get())) except (tk.TclError, ValueError): batch = 1 remain = max(0, batch - total) pass_rate = f"{pass_n / total * 100:.2f}%" if total else "—" fail_rate = f"{fail_n / total * 100:.2f}%" if total else "—" ts = datetime.now().strftime("%H%M%S") safe = re.sub(r"[^\w\-.]+", "_", self.profile["product_name"]) date = datetime.now().strftime("%Y-%m-%d") default_name = f"{safe}_{date}_汇总_{ts}" # 弹保存对话框,让用户选格式 types = [] if HAS_OPENPYXL: types.append(("Excel (.xlsx) — 居中、可保留格式", "*.xlsx")) types.append(("CSV (.csv) — 纯文本", "*.csv")) fpath = filedialog.asksaveasfilename( title="导出统计汇总 — 选择格式", initialdir=str(REPORTS_DIR), initialfile=default_name, defaultextension=".xlsx" if HAS_OPENPYXL else ".csv", filetypes=types, ) if not fpath: return None sumf = Path(fpath) batch_no = (self.batch_no_var.get().strip() if hasattr(self, "batch_no_var") else "") or "—" summary_pairs = [ ("产品", self.profile["product_name"]), ("日期", date), ("测试员", ops_str), ("批次号", batch_no), ("数量", batch), ("累计已测", total), ("合格", pass_n), ("不合格", fail_n), ("未测", remain), ("合格率", pass_rate), ("不良率", fail_rate), ("生成时间", datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ] try: if sumf.suffix.lower() == ".xlsx" and HAS_OPENPYXL: self._write_summary_xlsx(sumf, summary_pairs, detail_headers, detail_rows) else: self._write_summary_csv(sumf, summary_pairs, detail_headers, detail_rows) except Exception as e: messagebox.showerror("写入失败", str(e)) return None if not silent: if messagebox.askyesno("汇总已生成", f"已生成: {sumf.name}\n\n" f"批次 {batch} 累计 {total} 合格 {pass_n} 不合格 {fail_n} 未测 {remain}\n" f"合格率 {pass_rate} 不良率 {fail_rate}\n\n是否立即打开?"): os.startfile(sumf) return sumf def _write_summary_csv(self, sumf, pairs, detail_headers, detail_rows): with open(sumf, "w", encoding="utf-8-sig", newline="") as f: w = csv.writer(f) w.writerow(["=== 测试汇总 ==="]) for k, v in pairs: w.writerow([k, v]) w.writerow([]) w.writerow(["=== 明细数据 ==="]) if detail_headers: w.writerow(detail_headers) for r in detail_rows: w.writerow(r) def _write_summary_xlsx(self, sumf, pairs, detail_headers, detail_rows): wb = Workbook() center = Alignment(horizontal="center", vertical="center") bold = Font(bold=True) fail_font = Font(bold=True, color="FFD32F2F") # 找列索引以分类 + 高亮 sn_col = mac_col = res_col = None if detail_headers: for name, attr in (("SN", "sn_col"), ("MAC", "mac_col"), ("整体结果", "res_col")): try: if attr == "sn_col": sn_col = detail_headers.index(name) + 1 elif attr == "mac_col": mac_col = detail_headers.index(name) + 1 elif attr == "res_col": res_col = detail_headers.index(name) + 1 except ValueError: pass pass_rows, fail_rows = [], [] if res_col is not None: idx = res_col - 1 for r in detail_rows: v = r[idx].strip() if idx < len(r) else "" if v == "PASS": pass_rows.append(r) elif v == "FAIL": fail_rows.append(r) else: pass_rows = list(detail_rows) # Sheet 1:测试汇总 ws1 = wb.active ws1.title = "测试汇总" for k, v in pairs: ws1.append([k, v]) ws1.cell(row=ws1.max_row, column=1).font = bold # Sheet 2:PASS 明细(已入库) ws2 = wb.create_sheet("PASS 明细") if detail_headers: ws2.append(detail_headers) for c in range(1, len(detail_headers) + 1): ws2.cell(row=1, column=c).font = bold for r in pass_rows: ws2.append(r) # Sheet 3:FAIL 明细(未入库,SN/MAC 红字加粗) ws3 = wb.create_sheet("FAIL 明细") if detail_headers: ws3.append(detail_headers) for c in range(1, len(detail_headers) + 1): ws3.cell(row=1, column=c).font = bold for r in fail_rows: ws3.append(r) for i in range(2, ws3.max_row + 1): if sn_col is not None: ws3.cell(row=i, column=sn_col).font = fail_font if mac_col is not None: ws3.cell(row=i, column=mac_col).font = fail_font # 通用样式:所有单元格居中 + 自适应列宽 for ws in (ws1, ws2, ws3): for row in ws.iter_rows(): for cell in row: cell.alignment = center for col_cells in ws.columns: col_letter = col_cells[0].column_letter max_len = 0 for cell in col_cells: val = str(cell.value) if cell.value is not None else "" length = sum(2 if ord(c) > 127 else 1 for c in val) if length > max_len: max_len = length ws.column_dimensions[col_letter].width = min(50, max(10, max_len + 2)) wb.save(sumf) def _save_global_settings(self): self.settings["serial"]["port"] = self.port_var.get() try: self.settings["serial"]["baudrate"] = int(self.baud_var.get()) except ValueError: pass self.settings["operator"] = self.operator_var.get() if hasattr(self, "auto_clear_log_var"): self.settings["auto_clear_log_on_disconnect"] = self.auto_clear_log_var.get() save_settings(self.settings) def _set_window_icon(self): """设置窗口标题栏图标(兼容 PyInstaller --onefile 打包后从临时目录读取)""" try: if getattr(sys, "frozen", False): ico = Path(sys._MEIPASS) / "icon.ico" else: ico = Path(__file__).parent / "icon.ico" if ico.exists(): self.root.iconbitmap(str(ico)) except Exception: pass def _on_close(self): self._sync_sn_to_profile() save_profile(self.profile) self._save_global_settings() self._disconnect() self.root.destroy() def main(): root = tk.Tk() try: style = ttk.Style() if "vista" in style.theme_names(): style.theme_use("vista") except Exception: pass TestJig(root) root.mainloop() if __name__ == "__main__": main()