#!/usr/bin/env python # -*- coding: utf-8 -*- """ 电动扳手自动拧紧界面程序(前端) 支持从后端API获取工单、认领工单、提交数据 """ import tkinter as tk from tkinter import ttk, messagebox import json import threading import time import requests from datetime import datetime import sys import os # 添加父目录到路径,以便导入wrench_controller sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from wrench_controller import WrenchController from device_manager import DeviceManagerWindow from device_manager import DeviceManagerWindow class WrenchGUI: """电动扳手图形界面(前端)""" @staticmethod def translate_status(status): """将英文状态转换为中文""" status_map = { 'pending': '待处理', 'claimed': '已认领', 'completed': '已完成' } return status_map.get(status, status) def __init__(self, root): self.root = root self.root.title("电动扳手自动拧紧系统") # 默认全屏显示 self.root.state('zoomed') # Windows全屏 # 如果zoomed不支持,使用geometry设置全屏 try: self.root.state('zoomed') except: # 获取屏幕尺寸并设置窗口大小 screen_width = self.root.winfo_screenwidth() screen_height = self.root.winfo_screenheight() self.root.geometry(f"{screen_width}x{screen_height}") # API配置(从配置文件读取,如果没有则使用默认值) self.api_base_url = self.load_api_config() self.poll_interval = 3 # 轮询间隔(秒) # 数据 self.work_order = None self.current_bolt_index = 0 self.wrench = None self.selected_device = None # 选中的扳手设备 self.work_device_sn = None # 工作开始时保存的设备SN self.work_device_name = None # 工作开始时保存的设备名称 self.is_running = False self.is_polling = False self.thread = None self.poll_thread = None self.test_mode = False # 工单列表缓存,用于判断是否需要更新 self.cached_orders = [] self.selected_trace_id = None self.selected_process_id = None # 设备列表 self.wrench_devices = [] # 不再需要追溯号和工序号输入 # 创建界面 self._create_widgets() # 检查测试模式 self.check_test_mode() # 初始化螺栓列表显示(确保始终可见) self.update_bolt_list() # 加载设备列表 self.load_wrench_devices() # 开始轮询 self.start_polling() def _create_widgets(self): """创建界面组件""" # 使用左右布局:左侧工单列表,右侧其他内容 self.root.update_idletasks() screen_height = self.root.winfo_screenheight() # 配置列权重:左侧40%,右侧60%,支持1920和1600分辨率 # 使用相对权重,自适应不同分辨率 self.root.grid_columnconfigure(0, weight=2, minsize=350) # 左侧工单列表和日志,最小350像素 self.root.grid_columnconfigure(1, weight=3, minsize=450) # 右侧内容区域,最小450像素 # 配置行权重:从row=2开始的内容区域可扩展 self.root.grid_rowconfigure(2, weight=1) # 标题(横跨两列) title_frame = tk.Frame(self.root, bg="#2c3e50", height=60) title_frame.grid(row=0, column=0, columnspan=2, sticky="ew") title_frame.grid_propagate(False) title_label = tk.Label( title_frame, text="电动扳手自动拧紧系统", font=("微软雅黑", 20, "bold"), fg="white", bg="#2c3e50" ) title_label.pack(pady=15) # 测试模式指示器 self.test_mode_label = tk.Label( title_frame, text="", font=("微软雅黑", 10, "bold"), fg="#f39c12", bg="#2c3e50" ) self.test_mode_label.place(relx=1.0, rely=0.5, anchor=tk.E, x=-20) # 工单列表状态区域(横跨两列) status_frame = tk.Frame(self.root, padx=10, pady=5) status_frame.grid(row=1, column=0, columnspan=2, sticky="ew") self.poll_status_label = tk.Label( status_frame, text="🔄 正在加载工单列表...", font=("微软雅黑", 10), fg="#3498db" ) self.poll_status_label.pack(side=tk.LEFT) self.auto_refresh_var = tk.BooleanVar(value=True) self.auto_refresh_check = tk.Checkbutton( status_frame, text="自动刷新", variable=self.auto_refresh_var, font=("微软雅黑", 9), command=self.toggle_auto_refresh ) self.auto_refresh_check.pack(side=tk.LEFT, padx=10) self.refresh_button = tk.Button( status_frame, text="手动刷新", font=("微软雅黑", 9), bg="#3498db", fg="white", command=self.query_work_orders ) self.refresh_button.pack(side=tk.RIGHT, padx=5) # ========== 左侧:工单列表和操作控制 ========== # 创建左侧主容器(上下布局:工单列表 + 操作控制) left_container = tk.Frame(self.root) left_container.grid(row=2, column=0, sticky="nsew", padx=(10, 5), pady=5) # 配置行权重:工单列表可扩展,操作控制固定不压缩 left_container.grid_rowconfigure(0, weight=3) # 工单列表权重较大 left_container.grid_rowconfigure(1, weight=0, minsize=120) # 操作控制固定,最小120像素,不压缩 left_container.grid_columnconfigure(0, weight=1) # 工单列表区域 order_list_frame = tk.LabelFrame(left_container, text="可用工单列表", font=("微软雅黑", 12), padx=10, pady=10) order_list_frame.grid(row=0, column=0, sticky="nsew", padx=0, pady=(0, 5)) order_list_frame.grid_rowconfigure(0, weight=1) order_list_frame.grid_columnconfigure(0, weight=1) # 创建工单列表表格(使用相对高度,不固定) order_columns = ("追溯号", "工序号", "工序名称", "产品", "螺栓数", "状态") self.order_tree = ttk.Treeview(order_list_frame, columns=order_columns, show="headings") for col in order_columns: self.order_tree.heading(col, text=col) self.order_tree.column(col, width=120, anchor=tk.CENTER) order_scrollbar = ttk.Scrollbar(order_list_frame, orient=tk.VERTICAL, command=self.order_tree.yview) self.order_tree.configure(yscrollcommand=order_scrollbar.set) self.order_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) order_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # 操作控制区域(移到左侧,放在工单列表下方) button_frame = tk.LabelFrame(left_container, text="操作控制", font=("微软雅黑", 12), padx=10, pady=8) button_frame.grid(row=1, column=0, sticky="ew", padx=0, pady=0) # 使用grid布局,将所有按钮放在同一行确保可见 # 第一列:工单操作 col1_frame = tk.Frame(button_frame) col1_frame.grid(row=0, column=0, padx=(0, 20), sticky=tk.W) tk.Label(col1_frame, text="工单操作", font=("微软雅黑", 9, "bold"), fg="#2c3e50").pack(anchor=tk.W, pady=(0, 3)) self.claim_button = tk.Button( col1_frame, text="认领工单", font=("微软雅黑", 10), bg="#f39c12", fg="white", width=10, height=1, cursor="hand2", command=self.claim_work_order ) self.claim_button.pack() # 第二列:设备选择 col2_frame = tk.Frame(button_frame) col2_frame.grid(row=0, column=1, padx=(0, 20), sticky=tk.W) tk.Label(col2_frame, text="设备选择", font=("微软雅黑", 9, "bold"), fg="#2c3e50").pack(anchor=tk.W, pady=(0, 3)) device_select_inner = tk.Frame(col2_frame) device_select_inner.pack() tk.Label(device_select_inner, text="选择扳手:", font=("微软雅黑", 9)).pack(side=tk.LEFT, padx=(0, 5)) # 设备状态指示器(圆点) self.device_status_indicator = tk.Label( device_select_inner, text="●", font=("微软雅黑", 12), fg="#7f8c8d" # 默认灰色 ) self.device_status_indicator.pack(side=tk.LEFT, padx=(0, 3)) self.device_combo = ttk.Combobox(device_select_inner, width=18, state="readonly", font=("微软雅黑", 9)) self.device_combo.pack(side=tk.LEFT, padx=(0, 5)) self.device_combo.bind("<>", self.on_device_selected) self.device_manage_button = tk.Button( device_select_inner, text="设备管理", font=("微软雅黑", 8), bg="#3498db", fg="white", width=8, height=1, command=self.open_device_manager ) self.device_manage_button.pack(side=tk.LEFT) # 第三列:执行控制(放在同一行,确保可见) col3_frame = tk.Frame(button_frame) col3_frame.grid(row=0, column=2, padx=(0, 0), sticky=tk.W) tk.Label(col3_frame, text="执行控制", font=("微软雅黑", 9, "bold"), fg="#2c3e50").pack(anchor=tk.W, pady=(0, 3)) exec_control_inner = tk.Frame(col3_frame) exec_control_inner.pack() self.start_button = tk.Button( exec_control_inner, text="开始拧紧", font=("微软雅黑", 10), bg="#27ae60", fg="white", width=10, height=1, cursor="hand2", command=self.start_process, state=tk.DISABLED ) self.start_button.pack(side=tk.LEFT, padx=(0, 10)) self.stop_button = tk.Button( exec_control_inner, text="停止", font=("微软雅黑", 10), bg="#e74c3c", fg="white", width=10, height=1, cursor="hand2", state=tk.DISABLED, command=self.stop_process ) self.stop_button.pack(side=tk.LEFT) # 配置grid列权重 button_frame.grid_columnconfigure(0, weight=0) button_frame.grid_columnconfigure(1, weight=1) button_frame.grid_columnconfigure(2, weight=0) # ========== 右侧:内容区域 ========== # 创建右侧主容器(上下布局) right_container = tk.Frame(self.root) right_container.grid(row=2, column=1, sticky="nsew", padx=(5, 10), pady=5) # 配置行权重:日志区域可压缩,螺栓列表可压缩 right_container.grid_rowconfigure(0, weight=0) # 工单信息不扩展 right_container.grid_rowconfigure(1, weight=0) # 当前螺栓不扩展 right_container.grid_rowconfigure(2, weight=3) # 螺栓列表可扩展可压缩(主要区域) right_container.grid_rowconfigure(3, weight=1, minsize=100) # 日志区域可压缩,最小100像素 right_container.grid_columnconfigure(0, weight=1) # 工单信息区域(上下布局,第一行) info_frame = tk.LabelFrame(right_container, text="当前工单信息", font=("微软雅黑", 12), padx=10, pady=10) info_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) self.trace_id_label = tk.Label(info_frame, text="追溯号: --", font=("微软雅黑", 10)) self.trace_id_label.grid(row=0, column=0, sticky=tk.W, padx=5, pady=2) self.process_id_label = tk.Label(info_frame, text="工序号: --", font=("微软雅黑", 10)) self.process_id_label.grid(row=0, column=1, sticky=tk.W, padx=5, pady=2) self.process_name_label = tk.Label(info_frame, text="工序名称: --", font=("微软雅黑", 10)) self.process_name_label.grid(row=0, column=2, sticky=tk.W, padx=5, pady=2) self.product_label = tk.Label(info_frame, text="产品: --", font=("微软雅黑", 10)) self.product_label.grid(row=1, column=0, sticky=tk.W, padx=5, pady=2) self.operator_label = tk.Label(info_frame, text="操作员: --", font=("微软雅黑", 10)) self.operator_label.grid(row=1, column=1, sticky=tk.W, padx=5, pady=2) self.device_label = tk.Label(info_frame, text="扳手设备: --", font=("微软雅黑", 10), fg="#2c3e50") self.device_label.grid(row=1, column=2, sticky=tk.W, padx=5, pady=2) # 当前螺栓信息(上下布局,第二行) current_frame = tk.LabelFrame(right_container, text="当前螺栓", font=("微软雅黑", 12), padx=10, pady=10) current_frame.grid(row=1, column=0, sticky="ew", padx=5, pady=5) self.current_bolt_label = tk.Label( current_frame, text="等待开始...", font=("微软雅黑", 16, "bold"), fg="#2c3e50" ) self.current_bolt_label.pack(pady=5) self.current_torque_label = tk.Label( current_frame, text="目标扭矩: -- Nm", font=("微软雅黑", 12) ) self.current_torque_label.pack(pady=2) self.current_status_label = tk.Label( current_frame, text="状态: 待机", font=("微软雅黑", 12), fg="#7f8c8d" ) self.current_status_label.pack(pady=2) # 进度条(放在current_frame内部) progress_frame = tk.Frame(current_frame, padx=10, pady=5) progress_frame.pack(fill=tk.X, pady=5) self.progress_label = tk.Label(progress_frame, text="总进度: 0/0", font=("微软雅黑", 10)) self.progress_label.pack(anchor=tk.W) self.progress_bar = ttk.Progressbar(progress_frame, mode='determinate', length=400) self.progress_bar.pack(fill=tk.X, pady=5) # 螺栓列表(固定在"当前螺栓"下方、"操作控制"上方,可压缩但始终可见) # 创建螺栓列表框架 list_frame = tk.LabelFrame(right_container, text="螺栓列表", font=("微软雅黑", 12), padx=10, pady=10) list_frame.grid(row=2, column=0, sticky="nsew", padx=5, pady=5) # 螺栓列表通过right_container的row=2的weight=1来控制,可以压缩 # 设置最小高度,支持1920和1600分辨率 self.root.update_idletasks() screen_height = self.root.winfo_screenheight() # 根据分辨率动态调整最小高度:1920约180像素,1600约150像素 if screen_height >= 1920: min_list_height = max(180, int(screen_height * 0.15)) elif screen_height >= 1600: min_list_height = max(150, int(screen_height * 0.15)) else: min_list_height = max(120, int(screen_height * 0.15)) right_container.grid_rowconfigure(2, weight=1, minsize=min_list_height) # 保存list_frame引用,确保始终可见 self.list_frame = list_frame # 创建表格(使用相对布局,不固定高度) columns = ("序号", "名称", "扭矩(Nm)", "状态", "实际扭矩", "时间") self.tree = ttk.Treeview(list_frame, columns=columns, show="headings") # 设置列 for col in columns: self.tree.heading(col, text=col) self.tree.column("序号", width=60, anchor=tk.CENTER) self.tree.column("名称", width=150, anchor=tk.W) self.tree.column("扭矩(Nm)", width=100, anchor=tk.CENTER) self.tree.column("状态", width=100, anchor=tk.CENTER) self.tree.column("实际扭矩", width=120, anchor=tk.CENTER) self.tree.column("时间", width=150, anchor=tk.CENTER) # 滚动条 scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.tree.yview) self.tree.configure(yscrollcommand=scrollbar.set) self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # 配置标签颜色 self.tree.tag_configure("pending", foreground="#7f8c8d") self.tree.tag_configure("running", foreground="#3498db", font=("微软雅黑", 10, "bold")) self.tree.tag_configure("success", foreground="#27ae60", font=("微软雅黑", 10, "bold")) self.tree.tag_configure("failed", foreground="#e74c3c") # 操作日志区域(移到右侧,放在螺栓列表下方) log_frame = tk.LabelFrame(right_container, text="操作日志", font=("微软雅黑", 9), padx=5, pady=3) log_frame.grid(row=3, column=0, sticky="nsew", padx=5, pady=(0, 5)) log_frame.grid_rowconfigure(0, weight=1) log_frame.grid_columnconfigure(0, weight=1) self.log_text = tk.Text(log_frame, font=("Consolas", 9)) self.log_text.pack(fill=tk.BOTH, expand=True) log_scrollbar = ttk.Scrollbar(log_frame, orient=tk.VERTICAL, command=self.log_text.yview) self.log_text.configure(yscrollcommand=log_scrollbar.set) log_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) def get_config_path(self): """获取配置文件路径(支持开发环境和打包后的环境)""" # 如果是打包后的可执行文件,config.json 应该在可执行文件同一目录 if getattr(sys, 'frozen', False): # 打包后的环境 base_path = os.path.dirname(sys.executable) else: # 开发环境,在项目根目录 base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.join(base_path, "config.json") def load_api_config(self): """从配置文件读取API地址,如果没有则使用默认值""" default_url = "http://localhost:5000/api" try: config_path = self.get_config_path() if os.path.exists(config_path): with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) api_config = config.get("api", {}) api_url = api_config.get("base_url", default_url) # 确保URL以/api结尾 if not api_url.endswith("/api"): api_url = api_url.rstrip("/") + "/api" return api_url except Exception as e: print(f"Warning: Failed to load API config: {e}, using default: {default_url}") return default_url def check_test_mode(self): """检查测试模式""" try: config_path = self.get_config_path() if os.path.exists(config_path): with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) self.test_mode = config.get("test_mode", {}).get("enabled", False) if self.test_mode: self.test_mode_label.config(text="⚠️ 测试模式") self.log("⚠️ 测试模式已启用:失败也算成功", "WARN") else: self.test_mode_label.config(text="") except Exception as e: self.log(f"读取配置失败: {e}", "ERROR") def log(self, message, level="INFO"): """添加日志""" timestamp = datetime.now().strftime("%H:%M:%S") log_message = f"[{timestamp}] [{level}] {message}\n" self.log_text.insert(tk.END, log_message) self.log_text.see(tk.END) print(log_message.strip()) def query_work_orders(self): """查询所有可用工单列表""" try: url = f"{self.api_base_url}/work-orders" # 不传参数,获取所有可用工单 response = requests.get(url, timeout=5) if response.status_code == 200: data = response.json() if data.get("success"): orders = data.get("data", []) self.update_order_list(orders) self.log(f"查询成功,找到 {len(orders)} 个可用工单") if len(orders) > 0: self.poll_status_label.config(text=f"🔄 已找到 {len(orders)} 个可用工单", fg="#27ae60") else: self.poll_status_label.config(text="⏸ 暂无可用工单", fg="#7f8c8d") else: self.log(f"查询失败: {data.get('message')}", "ERROR") self.poll_status_label.config(text="❌ 查询失败", fg="#e74c3c") else: self.log(f"查询失败: HTTP {response.status_code}", "ERROR") self.poll_status_label.config(text="❌ 查询失败", fg="#e74c3c") except requests.exceptions.RequestException as e: self.log(f"查询失败: {e}", "ERROR") self.poll_status_label.config(text="❌ 连接失败", fg="#e74c3c") if not self.is_polling: # 只在非轮询模式下显示错误对话框 messagebox.showerror("错误", f"无法连接到后端服务器\n请确保后端服务已启动\n\n错误: {e}") def _orders_equal(self, orders1, orders2): """比较两个工单列表是否相同""" if len(orders1) != len(orders2): return False # 创建键集合用于比较 keys1 = set((o.get('trace_id'), o.get('process_id')) for o in orders1) keys2 = set((o.get('trace_id'), o.get('process_id')) for o in orders2) return keys1 == keys2 def update_order_list(self, orders): """更新工单列表(只在数据变化时更新,并保持选中状态)""" # 检查数据是否真的变化了 if self._orders_equal(orders, self.cached_orders): return # 数据没有变化,不更新 # 保存当前选中状态 selected_item = self.order_tree.selection() if selected_item: item_values = self.order_tree.item(selected_item[0])['values'] if len(item_values) >= 2: self.selected_trace_id = item_values[0] self.selected_process_id = item_values[1] # 更新缓存 self.cached_orders = orders.copy() # 清空并重新填充列表 self.order_tree.delete(*self.order_tree.get_children()) for order in orders: status = order.get('status', 'pending') status_cn = self.translate_status(status) self.order_tree.insert("", tk.END, values=( order.get('trace_id', '--'), order.get('process_id', '--'), order.get('process_name', '--'), order.get('product_name', '--'), order.get('bolt_count', 0), status_cn )) # 恢复选中状态 if self.selected_trace_id and self.selected_process_id: for item in self.order_tree.get_children(): values = self.order_tree.item(item)['values'] if len(values) >= 2 and values[0] == self.selected_trace_id and values[1] == self.selected_process_id: self.order_tree.selection_set(item) self.order_tree.see(item) # 滚动到选中项 break def toggle_auto_refresh(self): """切换自动刷新状态""" if self.auto_refresh_var.get(): if not self.is_polling: self.start_polling() else: if self.is_polling: self.stop_polling() def start_polling(self): """开始轮询工单列表""" if self.is_polling: return self.is_polling = True self.poll_thread = threading.Thread(target=self.poll_work_orders, daemon=True) self.poll_thread.start() self.poll_status_label.config(text="🔄 正在轮询工单列表...", fg="#3498db") self.log("开始轮询工单列表") def stop_polling(self): """停止轮询""" self.is_polling = False self.poll_status_label.config(text="⏸ 轮询已停止", fg="#7f8c8d") self.log("停止轮询工单列表") def poll_work_orders(self): """轮询工单列表(获取所有可用工单)""" while self.is_polling: try: url = f"{self.api_base_url}/work-orders" # 不传参数,获取所有可用工单 response = requests.get(url, timeout=5) if response.status_code == 200: data = response.json() if data.get("success"): orders = data.get("data", []) # 只在数据变化时更新 self.root.after(0, lambda o=orders: self.update_order_list(o)) # 更新状态标签 if len(orders) > 0: self.root.after(0, lambda: self.poll_status_label.config( text=f"🔄 已找到 {len(orders)} 个可用工单", fg="#27ae60" )) else: self.root.after(0, lambda: self.poll_status_label.config( text="⏸ 暂无可用工单", fg="#7f8c8d" )) except Exception as e: # 静默失败,继续轮询 pass time.sleep(self.poll_interval) def claim_work_order(self): """认领工单""" selected = self.order_tree.selection() if not selected: messagebox.showwarning("警告", "请先选择要认领的工单") return item = self.order_tree.item(selected[0]) values = item['values'] trace_id = values[0] process_id = values[1] try: url = f"{self.api_base_url}/work-orders/claim" data = { "trace_id": trace_id, "process_id": process_id, "operator": "操作员" # 可以从配置或输入获取 } response = requests.post(url, json=data, timeout=5) if response.status_code == 200: result = response.json() if result.get("success"): self.work_order = result.get("data") self.update_work_order_info() self.update_bolt_list() self.claim_button.config(state=tk.DISABLED) self.start_button.config(state=tk.NORMAL) self.log(f"✅ 成功认领工单: {trace_id} - {process_id}", "SUCCESS") else: messagebox.showerror("错误", result.get("message", "认领失败")) self.log(f"认领失败: {result.get('message')}", "ERROR") else: messagebox.showerror("错误", f"认领失败: HTTP {response.status_code}") self.log(f"认领失败: HTTP {response.status_code}", "ERROR") except requests.exceptions.RequestException as e: messagebox.showerror("错误", f"无法连接到后端服务器\n\n错误: {e}") self.log(f"认领失败: {e}", "ERROR") def update_work_order_info(self): """更新工单信息显示""" if self.work_order: self.trace_id_label.config(text=f"追溯号: {self.work_order.get('trace_id', '--')}") self.process_id_label.config(text=f"工序号: {self.work_order.get('process_id', '--')}") self.process_name_label.config(text=f"工序名称: {self.work_order.get('process_name', '--')}") self.product_label.config(text=f"产品: {self.work_order.get('product_name', '--')}") self.operator_label.config(text=f"操作员: {self.work_order.get('operator', '--')}") else: self.trace_id_label.config(text="追溯号: --") self.process_id_label.config(text="工序号: --") self.process_name_label.config(text="工序名称: --") self.product_label.config(text="产品: --") self.operator_label.config(text="操作员: --") # 更新设备显示(无论是否有工单都要更新) self.update_device_status_display() def update_device_status_display(self): """更新设备状态显示(带颜色)""" if self.selected_device: device_name = self.selected_device.get('device_name', '--') status = self.selected_device.get('status', 'offline') status_text = "在线" if status == 'online' else "离线" # 根据状态设置颜色 if status == 'online': status_color = "#27ae60" # 绿色 else: status_color = "#e74c3c" # 红色 # 强制更新颜色和文本 self.device_label.config(text="") # 先清空 self.device_label.config( text=f"扳手设备: {device_name} ({status_text})", foreground=status_color # 使用foreground而不是fg ) print(f"[DEBUG] 更新设备状态显示: {device_name}, 状态: {status}, 颜色: {status_color}") else: self.device_label.config(text="扳手设备: 未选择", foreground="#2c3e50") def update_bolt_list(self): """更新螺栓列表(始终显示,即使没有工单)""" # 确保螺栓列表框架始终显示,无论连接状态如何 if hasattr(self, 'list_frame'): self.list_frame.grid() # 强制显示,防止被隐藏 # 始终清空表格,确保表格框架始终可见 self.tree.delete(*self.tree.get_children()) if not self.work_order: # 没有工单时,显示空表格,但表格框架保持可见 return # 有工单时,显示螺栓数据 bolts = self.work_order.get('bolts', []) for bolt in bolts: self.tree.insert("", tk.END, values=( bolt.get('bolt_id'), bolt.get('name'), bolt.get('target_torque'), "待拧紧", "--", "--" ), tags=("pending",)) total = len(bolts) self.progress_label.config(text=f"总进度: 0/{total}") self.progress_bar['maximum'] = total self.progress_bar['value'] = 0 self.current_bolt_index = 0 def start_process(self): """开始拧紧流程""" if not self.work_order: messagebox.showwarning("警告", "请先认领工单") return if not self.selected_device: messagebox.showwarning("警告", "请先选择扳手设备") return if self.selected_device.get('status') != 'online': result = messagebox.askyesno("警告", f"选中的扳手设备 {self.selected_device.get('device_name')} 当前离线,是否继续?") if not result: return if self.is_running: return self.is_running = True self.start_button.config(state=tk.DISABLED) self.stop_button.config(state=tk.NORMAL) self.claim_button.config(state=tk.DISABLED) self.device_combo.config(state=tk.DISABLED) # 启动工作线程 self.thread = threading.Thread(target=self.work_thread, daemon=True) self.thread.start() self.log("开始自动拧紧流程") def stop_process(self): """停止拧紧流程""" self.is_running = False self.start_button.config(state=tk.NORMAL) self.stop_button.config(state=tk.DISABLED) self.claim_button.config(state=tk.NORMAL) self.device_combo.config(state="readonly") self.log("用户停止流程") def work_thread(self): """工作线程""" try: # 保存工作开始时使用的设备信息(防止后续被状态检测线程覆盖) if self.selected_device: self.work_device_sn = self.selected_device.get('device_sn') self.work_device_name = self.selected_device.get('device_name') # 连接扳手(使用选中的设备配置) device_config = { "ip_address": self.selected_device.get('ip_address'), "port": self.selected_device.get('port', 7888), "address_code": self.selected_device.get('address_code', 1) } self.log(f"正在连接扳手: {device_config['ip_address']}:{device_config['port']}...") self.wrench = WrenchController(device_config=device_config) if not self.wrench.connect(): self.log("❌ 连接扳手失败,请检查IP和端口配置", "ERROR") self.root.after(0, lambda: messagebox.showerror( "连接失败", f"无法连接到扳手\nIP: {self.wrench.host}\n端口: {self.wrench.port}\n\n请检查:\n1. 扳手是否开机\n2. 网络连接是否正常\n3. IP和端口配置是否正确" )) self.stop_process() return self.log(f"✅ 已连接到扳手: {self.wrench.host}:{self.wrench.port}", "SUCCESS") # 启用远程控制 self.log("正在启用远程控制...") if not self.wrench.enable_remote_control(True): self.log("❌ 启用远程控制失败", "ERROR") self.root.after(0, lambda: messagebox.showerror("错误", "启用远程控制失败")) self.stop_process() return self.log("✅ 已启用远程控制", "SUCCESS") # 遍历所有螺栓 bolts = self.work_order.get('bolts', []) bolt_results = [] for index, bolt in enumerate(bolts): if not self.is_running: break self.current_bolt_index = index result = self.process_bolt(bolt, index) if result: bolt_results.append(result) # 完成,提交数据 if self.is_running: self.log("所有螺栓拧紧完成!", "SUCCESS") self.submit_results(bolt_results) self.root.after(0, lambda: messagebox.showinfo("完成", "所有螺栓已成功拧紧!")) except Exception as e: self.log(f"流程异常: {e}", "ERROR") import traceback traceback.print_exc() finally: if self.wrench: self.wrench.disconnect() # 清空工作设备信息 self.work_device_sn = None self.work_device_name = None self.root.after(0, lambda: self.device_combo.config(state="readonly")) self.root.after(0, self.stop_process) def process_bolt(self, bolt, index): """处理单个螺栓""" bolt_id = bolt.get('bolt_id') bolt_name = bolt.get('name') target_torque = bolt.get('target_torque') self.log(f"开始拧紧: [{bolt_id}] {bolt_name}, 目标扭矩: {target_torque} Nm") # 更新界面 self.root.after(0, lambda: self.update_current_bolt(bolt, "拧紧中...")) self.root.after(0, lambda: self.update_tree_item(index, "拧紧中", "running")) # 设定参数 self.log(f"设定参数: 扭矩={target_torque}Nm, 模式=M{bolt.get('mode', 1)}") self.wrench.set_torque_parameters( target_torque=target_torque, mode=bolt.get('mode', 1), torque_tolerance=bolt.get('torque_tolerance', 0.10), angle_min=bolt.get('angle_min', 1), angle_max=bolt.get('angle_max', 360) ) # 循环尝试,直到成功 attempt = 0 result_data = None while self.is_running: attempt += 1 self.log(f"第 {attempt} 次尝试拧紧螺栓 {bolt_id}") # 启动扳手 self.log("发送启动命令...") if not self.wrench.start_wrench(direction=1): self.log("❌ 发送启动命令失败", "ERROR") time.sleep(1) continue # 等待结果 self.log("等待扳手响应...") result = self.wrench.wait_for_result() if result and result.get("success"): # 成功 actual_torque = result.get("actual_torque", 0) actual_angle = result.get("actual_angle", 0) timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.log(f"✅ 螺栓 {bolt_id} 拧紧成功! 实际扭矩: {actual_torque} Nm", "SUCCESS") # 更新界面 self.root.after(0, lambda at=actual_torque, ts=timestamp: self.update_tree_item( index, "成功", "success", at, ts )) self.root.after(0, lambda: self.update_progress(index + 1)) # 保存结果数据 result_data = { "bolt_id": bolt_id, "name": bolt_name, "target_torque": target_torque, "actual_torque": actual_torque, "actual_angle": actual_angle, "status": "success", "timestamp": timestamp, "result": result } break else: # 失败,继续尝试 if result: self.log(f"❌ 螺栓 {bolt_id} 拧紧失败: {result.get('status', '未知错误')}, 继续重试...", "WARN") else: self.log(f"❌ 螺栓 {bolt_id} 无响应, 继续重试...", "WARN") time.sleep(1) # 等待1秒后重试 # 完成后等待一下 time.sleep(0.5) return result_data def submit_results(self, bolt_results): """提交结果到后端""" if not self.work_order or not bolt_results: return try: # 优先使用工作开始时保存的设备信息,如果没有则使用当前选中的设备信息 device_sn = self.work_device_sn device_name = self.work_device_name if not device_sn or not device_name: # 如果工作开始时没有保存,尝试从当前选中的设备获取 if self.selected_device: device_sn = device_sn or self.selected_device.get('device_sn') device_name = device_name or self.selected_device.get('device_name') # 如果还是没有,尝试从设备列表中查找 if not device_sn or not device_name: # 通过IP地址匹配设备(因为工作线程中使用了IP地址) if self.wrench and hasattr(self.wrench, 'host'): for device in self.wrench_devices: if device.get('ip_address') == self.wrench.host: device_sn = device_sn or device.get('device_sn') device_name = device_name or device.get('device_name') break # 记录设备信息(用于调试) self.log(f"提交数据 - 设备SN: {device_sn or '(未设置)'}, 设备名称: {device_name or '(未设置)'}") url = f"{self.api_base_url}/work-orders/submit" data = { "trace_id": self.work_order.get('trace_id'), "process_id": self.work_order.get('process_id'), "bolts": bolt_results, "device_sn": device_sn, "device_name": device_name } # 调试:打印提交的数据 print(f"[DEBUG] 提交工单数据:") print(f" trace_id: {data['trace_id']}") print(f" process_id: {data['process_id']}") print(f" device_sn: {device_sn}") print(f" device_name: {device_name}") print(f" bolts数量: {len(bolt_results)}") response = requests.post(url, json=data, timeout=10) if response.status_code == 200: result = response.json() if result.get("success"): self.log(f"✅ 数据提交成功", "SUCCESS") # 清空当前工单 self.work_order = None self.root.after(0, self.update_work_order_info) self.root.after(0, lambda: self.tree.delete(*self.tree.get_children())) self.claim_button.config(state=tk.NORMAL) self.start_button.config(state=tk.DISABLED) else: self.log(f"❌ 数据提交失败: {result.get('message')}", "ERROR") else: self.log(f"❌ 数据提交失败: HTTP {response.status_code}", "ERROR") except requests.exceptions.RequestException as e: self.log(f"❌ 数据提交失败: {e}", "ERROR") def update_current_bolt(self, bolt, status): """更新当前螺栓显示""" self.current_bolt_label.config(text=f"[{bolt.get('bolt_id')}] {bolt.get('name')}") self.current_torque_label.config(text=f"目标扭矩: {bolt.get('target_torque')} Nm") self.current_status_label.config(text=f"状态: {status}") def update_tree_item(self, index, status, tag, actual_torque="--", timestamp="--"): """更新表格项""" items = self.tree.get_children() if index < len(items): item = items[index] values = list(self.tree.item(item)['values']) values[3] = status if actual_torque != "--": values[4] = actual_torque if timestamp != "--": values[5] = timestamp self.tree.item(item, values=values, tags=(tag,)) def update_progress(self, completed): """更新进度""" if not self.work_order: return total = len(self.work_order.get('bolts', [])) self.progress_label.config(text=f"总进度: {completed}/{total}") self.progress_bar['value'] = completed def load_wrench_devices(self): """加载扳手设备列表""" try: url = f"{self.api_base_url}/wrench-devices" response = requests.get(url, timeout=5) if response.status_code == 200: data = response.json() if data.get("success"): self.wrench_devices = data.get("data", []) self.update_device_combo() # 启动设备状态检测 self.start_device_status_check() else: self.log(f"加载设备列表失败: {data.get('message')}", "ERROR") except Exception as e: self.log(f"加载设备列表失败: {e}", "ERROR") def update_device_combo(self): """更新设备下拉框""" device_names = [] for device in self.wrench_devices: # 不在下拉框中显示emoji,而是使用状态指示器 device_names.append(f"{device.get('device_name')} ({device.get('ip_address')})") self.device_combo['values'] = device_names if device_names and not self.device_combo.get(): self.device_combo.current(0) self.on_device_selected(None) # 更新状态指示器颜色 self.update_device_status_indicator() def on_device_selected(self, event): """设备选择事件""" selection = self.device_combo.current() if selection >= 0 and selection < len(self.wrench_devices): self.selected_device = self.wrench_devices[selection] self.log(f"已选择扳手设备: {self.selected_device.get('device_name')}") self.update_work_order_info() # 确保设备状态颜色更新 self.update_device_status_display() # 更新状态指示器 self.update_device_status_indicator() def update_device_status_indicator(self): """更新设备状态指示器(圆点)颜色""" if self.selected_device: status = self.selected_device.get('status', 'offline') if status == 'online': self.device_status_indicator.config(fg="#27ae60") # 绿色 else: self.device_status_indicator.config(fg="#e74c3c") # 红色 else: self.device_status_indicator.config(fg="#7f8c8d") # 灰色 def start_device_status_check(self): """启动设备状态检测(后台线程)""" def check_status(): while True: try: # 首先获取设备列表 url = f"{self.api_base_url}/wrench-devices" response = requests.get(url, timeout=5) if response.status_code == 200: data = response.json() if data.get("success"): devices = data.get("data", []) # 对每个设备进行在线状态检测 for device in devices: device_id = device.get('id') if device_id: try: # 调用后端API检测设备状态 check_url = f"{self.api_base_url}/wrench-devices/{device_id}/check-status" check_response = requests.post(check_url, timeout=3) if check_response.status_code == 200: check_data = check_response.json() if check_data.get("success"): # 更新设备状态 device['status'] = check_data.get('data', {}).get('status', 'offline') except: # 检测失败,保持原状态 pass # 更新设备状态 for device in devices: for i, old_device in enumerate(self.wrench_devices): if old_device.get('id') == device.get('id'): self.wrench_devices[i] = device break # 更新下拉框 self.root.after(0, self.update_device_combo) # 如果当前选中的设备状态变化,更新显示 if self.selected_device: for device in devices: if device.get('id') == self.selected_device.get('id'): self.selected_device = device # 更新设备状态显示(带颜色) self.root.after(0, self.update_device_status_display) # 更新状态指示器 self.root.after(0, self.update_device_status_indicator) break except: pass time.sleep(10) # 每10秒检测一次(避免过于频繁) status_thread = threading.Thread(target=check_status, daemon=True) status_thread.start() def open_device_manager(self): """打开设备管理窗口""" DeviceManagerWindow(self.root, self.api_base_url, self.load_wrench_devices) def main(): root = tk.Tk() app = WrenchGUI(root) def on_closing(): app.stop_polling() root.destroy() root.protocol("WM_DELETE_WINDOW", on_closing) root.mainloop() if __name__ == "__main__": main()