commit 2deb201844ffd80aa06c008d0aa5a53a2572e6f6 Author: VALM-Labs <135186421+VALM-Labs@users.noreply.github.com> Date: Wed May 20 15:51:28 2026 +0800 v2.1.0: 产品-硬件入库测试工具 — 多产品 profile + MAC 双源 + SN 自动分配(PASS 占用)+ FAIL 不占 SN + 统计汇总 xlsx 三 sheet 导出 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47275e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +build/ +dist/ +__pycache__/ +*.pyc +*.pyo +*.spec +debug.log +.vscode/ +.idea/ diff --git a/profiles/ESP32-S3-Airhub.json b/profiles/ESP32-S3-Airhub.json new file mode 100644 index 0000000..311d4ce --- /dev/null +++ b/profiles/ESP32-S3-Airhub.json @@ -0,0 +1,76 @@ +{ + "product_name": "ESP32-S3-Airhub", + "mac_pattern": "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": "生产测试模式已启用" + }, + { + "id": "story_btn", + "name": "故事按键", + "type": "auto", + "pattern": "故事按键.*?被按下" + }, + { + "id": "boot_btn", + "name": "BOOT/打断按键", + "type": "auto", + "pattern": "BOOT按键.*?被按下" + }, + { + "id": "audio_done", + "name": "音频播放完成(自动)", + "type": "auto", + "pattern": "音频播放完成" + }, + { + "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": [ + "红-充电中", + "绿-已充满", + "无-异常" + ] + } + ] +} diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..5354f0e --- /dev/null +++ b/settings.json @@ -0,0 +1,8 @@ +{ + "current_profile": "ESP32-S3-Airhub", + "serial": { + "port": "", + "baudrate": 115200 + }, + "operator": "" +} \ No newline at end of file diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..3cc5b7e --- /dev/null +++ b/start.bat @@ -0,0 +1,5 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +"C:\Users\Administrator\anaconda3\python.exe" test_jig.py +pause diff --git a/test_jig.py b/test_jig.py new file mode 100644 index 0000000..a1e74f4 --- /dev/null +++ b/test_jig.py @@ -0,0 +1,1989 @@ +"""硬件测试治具 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 + +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("产品-硬件入库测试工具 v2.1.0") + 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) + + # 第二条:串口 + 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 _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()