hardware-test/test_jig.py

2007 lines
83 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""硬件测试治具 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("<<TreeviewSelect>>", 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("<FocusOut>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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 SNUI 不显示(已在端口栏下方显示)
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("<Configure>",
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("<Configure>",
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("<Enter>",
lambda _e: self.canvas.bind_all("<MouseWheel>", self._on_tests_mousewheel))
self.canvas.bind("<Leave>",
lambda _e: self.canvas.unbind_all("<MouseWheel>"))
# 日志
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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>",
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 不占 SNSN 列留空
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)
# 保存后重算下一个可用 SNPASS 占用 +1FAIL 不占用所以 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 2PASS 明细(已入库)
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 3FAIL 明细未入库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()