1990 lines
82 KiB
Python
1990 lines
82 KiB
Python
"""硬件测试治具 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("<<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("产品-硬件入库测试工具 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("<<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)
|
||
|
||
# 第二条:串口
|
||
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 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("<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 不占 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()
|