898 lines
41 KiB
Python
898 lines
41 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
试验监控视图,实时监控试验设备和工位状态(响应式架构)
|
||
"""
|
||
|
||
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||
QPushButton, QTabWidget, QGridLayout, QGroupBox,
|
||
QFrame, QScrollArea, QMessageBox, QSizePolicy, QDialog,
|
||
QTableWidget, QTableWidgetItem, QHeaderView, QStackedWidget)
|
||
from PyQt5.QtCore import Qt
|
||
from PyQt5.QtGui import QFont, QColor
|
||
from viewmodels.test_monitor_viewmodel import TestMonitorViewModel
|
||
from ui_utils.websocket_client import WebSocketClient
|
||
from views.dut_selection_dialog import DUTSelectionDialog
|
||
from views.dut_form_dialog import DUTFormDialog
|
||
from models.dut_model import DUTModel
|
||
from models.system_model import SystemModel
|
||
import requests
|
||
import json
|
||
|
||
|
||
class TestMonitorView(QWidget):
|
||
"""试验监控主视图 - 响应式架构"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
|
||
# 创建 ViewModel
|
||
self.viewModel = TestMonitorViewModel()
|
||
|
||
# 创建 Model(用于新建样品)
|
||
self.dut_model = DUTModel()
|
||
self.system_model = SystemModel()
|
||
|
||
# HTTP API 配置
|
||
try:
|
||
from config import USE_HTTP_API, HTTP_API_BASE_URL
|
||
self.use_http_api = USE_HTTP_API
|
||
self.api_base_url = HTTP_API_BASE_URL
|
||
except ImportError:
|
||
self.use_http_api = False
|
||
self.api_base_url = "http://127.0.0.1:5050"
|
||
|
||
# 创建 WebSocket 客户端
|
||
self.ws_client = WebSocketClient(url="ws://localhost:5080")
|
||
|
||
# 记录各测试类型下当前选中的机台标签文本,刷新时保持不变
|
||
self._selected_machine_per_type = {}
|
||
self._machine_channel_containers = []
|
||
|
||
# 存储站点控件引用,用于响应式更新
|
||
self._station_widgets = {} # {(machine_sn, station_sn): widget_components}
|
||
|
||
# 初始化界面
|
||
self.init_ui()
|
||
|
||
# 记录结构签名,避免不必要的重建
|
||
self._last_structure_signature = None
|
||
|
||
# 绑定响应式信号
|
||
self._bind_signals()
|
||
|
||
# 启动 WebSocket 连接
|
||
self.ws_client.start()
|
||
|
||
def init_ui(self):
|
||
"""初始化界面"""
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
# 标签页容器:按测试类型(Phone/Pad/Mac)分组 + 右侧状态
|
||
self.tab_widget = QTabWidget()
|
||
self.tab_widget.setObjectName("monitor-tabs")
|
||
self.tab_widget.setTabsClosable(False)
|
||
self.tab_widget.setMovable(True)
|
||
# 标签样式:选中为蓝底白字,未选中黑字
|
||
self.tab_widget.setStyleSheet(
|
||
"QTabBar::tab{min-width:100px; min-height:32px; padding:6px 12px;}"
|
||
"QTabBar::tab:selected{background:#3A84FF; color:white; border-radius:4px;}"
|
||
"QTabBar::tab:!selected{background:transparent; color:black; border:1px solid #dcdcdc; border-radius:4px;}"
|
||
"QTabWidget::pane{border:0;}"
|
||
)
|
||
# 右侧状态区域(机台数量紧挨连接状态)
|
||
corner = QWidget()
|
||
corner_layout = QHBoxLayout(corner)
|
||
corner_layout.setContentsMargins(0, 0, 10, 0)
|
||
corner_layout.setSpacing(10)
|
||
self.machine_count_label = QLabel("机台数量: 0")
|
||
self.connection_label = QLabel("● 未连接")
|
||
self.connection_label.setObjectName("connection-status")
|
||
self.connection_label.setStyleSheet("color: gray; font-weight: bold;")
|
||
corner_layout.addWidget(self.machine_count_label)
|
||
corner_layout.addWidget(self.connection_label)
|
||
# 放置到 Tab 右上角
|
||
self.tab_widget.setCornerWidget(corner, Qt.TopRightCorner|Qt.AlignVCenter)
|
||
|
||
layout.addWidget(self.tab_widget)
|
||
|
||
|
||
def _bind_signals(self):
|
||
"""绑定响应式信号"""
|
||
# ViewModel 数据变化 -> 刷新界面
|
||
self.viewModel.machinesChanged.connect(self._refresh_machines)
|
||
self.viewModel.stationsChanged.connect(self._refresh_stations)
|
||
self.viewModel.connectionStatusChanged.connect(self._refresh_connection_status)
|
||
|
||
# WebSocket 消息 -> 更新 ViewModel(按命令类型分发,避免覆盖完整站点数据)
|
||
self.ws_client.machine_data_changed.connect(self.viewModel.updateMachineData)
|
||
|
||
def _on_station_msg(message):
|
||
try:
|
||
cmd = message.get('command', '')
|
||
payload = message.get('data', message)
|
||
if cmd == 'station_drop_result_change':
|
||
self.viewModel.updateStationDropResults(payload)
|
||
else:
|
||
self.viewModel.updateStationData(payload)
|
||
except Exception:
|
||
pass
|
||
|
||
self.ws_client.station_data_changed.connect(_on_station_msg)
|
||
self.ws_client.connection_status_changed.connect(self.viewModel.setConnectionStatus)
|
||
self.ws_client.error_occurred.connect(self._on_ws_error)
|
||
|
||
# ===================== 响应式刷新方法 =====================
|
||
|
||
def _refresh_connection_status(self):
|
||
"""刷新 WebSocket 连接状态"""
|
||
connected = self.viewModel.connected
|
||
if connected:
|
||
self.connection_label.setText("● 已连接")
|
||
self.connection_label.setStyleSheet("color: green; font-weight: bold;")
|
||
else:
|
||
self.connection_label.setText("● 未连接")
|
||
self.connection_label.setStyleSheet("color: gray; font-weight: bold;")
|
||
|
||
def _refresh_machines(self):
|
||
"""刷新机台列表(响应式,避免不必要重建)"""
|
||
machines = self.viewModel.machines
|
||
machine_count = len(machines)
|
||
# 更新统计信息
|
||
self.machine_count_label.setText(f"机台数量: {machine_count}")
|
||
# 计算结构签名(测试类型 + 机台SN集合)
|
||
sig_parts = []
|
||
for t in self.viewModel.getAllTestTypes():
|
||
sns = [m.get('SN', '') for m in self.viewModel.getMachinesByTestType(t)]
|
||
sig_parts.append(f"{t}:{','.join(sorted(sns))}")
|
||
signature = '|'.join(sig_parts)
|
||
# 结构未变化时,仅增量刷新通道,不重建标签页
|
||
if hasattr(self, '_last_structure_signature') and self._last_structure_signature == signature:
|
||
self._refresh_stations()
|
||
return
|
||
# 结构变化,重建标签页并记录签名
|
||
self._last_structure_signature = signature
|
||
self._rebuild_tabs()
|
||
|
||
def _refresh_stations(self):
|
||
"""刷新工位数据(响应式更新)"""
|
||
# 遍历所有存储的站点控件并更新它们
|
||
for (machine_sn, station_sn), widget_components in self._station_widgets.items():
|
||
# 获取最新的站点数据
|
||
machine = self.viewModel.getMachineBySN(machine_sn)
|
||
if machine:
|
||
stations = machine.get('stations', [])
|
||
station_data = None
|
||
for station in stations:
|
||
if station.get('SN') == station_sn:
|
||
station_data = station
|
||
break
|
||
|
||
if station_data:
|
||
# 更新站点显示
|
||
self._update_station_widget(widget_components, machine, station_data)
|
||
|
||
def _rebuild_tabs(self):
|
||
"""重新构建标签页(按测试类型分组),保留当前选中分类"""
|
||
# 清空站点控件引用
|
||
self._station_widgets.clear()
|
||
|
||
# 记录当前选中的标签文本(分类),避免复位
|
||
current_index = self.tab_widget.currentIndex()
|
||
current_text = self.tab_widget.tabText(current_index) if current_index >= 0 else None
|
||
|
||
# 清空现有标签页
|
||
self.tab_widget.clear()
|
||
|
||
# 获取所有测试类型
|
||
test_types = self.viewModel.getAllTestTypes()
|
||
|
||
if not test_types:
|
||
# 无数据时显示空白页
|
||
empty_widget = QWidget()
|
||
empty_layout = QVBoxLayout(empty_widget)
|
||
empty_label = QLabel("等待机台数据...")
|
||
empty_label.setAlignment(Qt.AlignCenter)
|
||
empty_label.setStyleSheet("color: gray; font-size: 18px;")
|
||
empty_layout.addWidget(empty_label)
|
||
self.tab_widget.addTab(empty_widget, "监控")
|
||
return
|
||
|
||
# 为每个测试类型创建标签页
|
||
for test_type in test_types:
|
||
machines = self.viewModel.getMachinesByTestType(test_type)
|
||
tab = self._create_test_type_tab(test_type, machines)
|
||
self.tab_widget.addTab(tab, test_type)
|
||
|
||
# 恢复到原先选中的标签页(如果仍存在)
|
||
if current_text:
|
||
for i in range(self.tab_widget.count()):
|
||
if self.tab_widget.tabText(i) == current_text:
|
||
self.tab_widget.setCurrentIndex(i)
|
||
break
|
||
|
||
def _create_test_type_tab(self, test_type, machines):
|
||
"""创建测试类型标签页(同类机台使用二级 Tab 切换)"""
|
||
tab = QWidget()
|
||
tab_layout = QVBoxLayout(tab)
|
||
tab_layout.setContentsMargins(10, 10, 10, 10)
|
||
tab_layout.setSpacing(10)
|
||
|
||
# 二级机台 Tab
|
||
machine_tab = QTabWidget()
|
||
machine_tab.setObjectName(f"machine-tab-{test_type}")
|
||
machine_tab.setTabsClosable(False)
|
||
machine_tab.setMovable(True)
|
||
machine_tab.setStyleSheet(
|
||
"QTabBar::tab{min-width:160px; min-height:28px; padding:4px 10px;}"
|
||
"QTabBar::tab:selected{background:#3A84FF; color:white; border-radius:4px;}"
|
||
"QTabBar::tab:!selected{background:transparent; color:black; border:1px solid #dcdcdc; border-radius:4px;}"
|
||
"QTabWidget::pane{border:0;}"
|
||
)
|
||
|
||
# 为每个机台创建一个 Tab
|
||
for machine in machines:
|
||
machine_card = self._create_machine_card(machine)
|
||
machine_label_text = machine.get('label', '未知机台')
|
||
machine_tab.addTab(machine_card, machine_label_text)
|
||
|
||
tab_layout.addWidget(machine_tab)
|
||
|
||
# 恢复该测试类型下之前选中的机台 Tab
|
||
prev_text = self._selected_machine_per_type.get(test_type)
|
||
if prev_text:
|
||
for i in range(machine_tab.count()):
|
||
if machine_tab.tabText(i) == prev_text:
|
||
machine_tab.setCurrentIndex(i)
|
||
break
|
||
|
||
# 监听机台 Tab 切换,记录当前选择
|
||
def on_machine_tab_changed(idx):
|
||
text = machine_tab.tabText(idx) if idx >= 0 else None
|
||
if text:
|
||
self._selected_machine_per_type[test_type] = text
|
||
machine_tab.currentChanged.connect(on_machine_tab_changed)
|
||
|
||
return tab
|
||
|
||
def _create_machine_card(self, machine):
|
||
"""创建机台监控卡片"""
|
||
card = QGroupBox()
|
||
card.setObjectName("machine-card")
|
||
card_layout = QVBoxLayout(card)
|
||
card_layout.setSpacing(10)
|
||
|
||
# 机台标题
|
||
machine_label = machine.get('label', '未知机台')
|
||
machine_sn = machine.get('SN', '')
|
||
|
||
# 机台标题已在二级Tab上显示,这里不再占用垂直空间
|
||
|
||
# 工位(通道)监控区域(固定双通道显示)
|
||
stations = machine.get('stations', [])
|
||
# 准备两个通道数据
|
||
st1 = stations[0] if len(stations) >= 1 else None
|
||
st2 = stations[1] if len(stations) >= 2 else None
|
||
|
||
monitor_widget = QWidget()
|
||
grid = QGridLayout(monitor_widget)
|
||
grid.setSpacing(8)
|
||
|
||
# 最多4个通道,按2x2网格布局;缺省位置保留空白(传入None)
|
||
channels = []
|
||
for i in range(4):
|
||
st = stations[i] if len(stations) > i else None
|
||
ch = self._create_station_widget(machine, st, i + 1)
|
||
row, col = divmod(i, 2)
|
||
grid.addWidget(ch, row, col)
|
||
|
||
# 限制通道区域最大高度为窗口高度的 40%
|
||
monitor_widget.setMaximumHeight(int(max(800, self.height() * 0.4)))
|
||
self._machine_channel_containers.append(monitor_widget)
|
||
|
||
card_layout.addWidget(monitor_widget)
|
||
|
||
return card
|
||
|
||
def _create_station_widget(self, machine, station, channel_index):
|
||
"""创建工位监控组件(一次性构建,后续复用控件)"""
|
||
widget = QGroupBox()
|
||
widget.setObjectName("station-widget")
|
||
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||
layout = QVBoxLayout(widget)
|
||
layout.setSpacing(10)
|
||
|
||
machine_sn = machine.get('SN', '')
|
||
offline = not machine.get('connectState', 0) # 默认为 0(离线)
|
||
|
||
# 未配置通道:留空白占位,不显示任何文字
|
||
if station is None:
|
||
widget.setStyleSheet("QGroupBox{border:1px dashed #e0e0e0; background:#fafafa}")
|
||
layout.addStretch()
|
||
return widget
|
||
|
||
# 工位标题
|
||
station_label = station.get('label', '未知工位')
|
||
station_sn = station.get('SN', '')
|
||
|
||
# 通道标题行(标题在中间,状态在右边)
|
||
|
||
title_widget = QWidget()
|
||
title_widget.setStyleSheet("background-color: lightgray;")
|
||
title_layout = QHBoxLayout(title_widget)
|
||
title_layout.setContentsMargins(0, 5, 0, 5) # 增加上下边距
|
||
title_layout.setSpacing(5)
|
||
|
||
machine_label = QLabel(f"{machine_sn}")
|
||
machine_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||
machine_label.setStyleSheet("font-size: 14px; padding-left: 8px;")
|
||
title_layout.addWidget(machine_label)
|
||
|
||
# 左侧占位
|
||
title_layout.addStretch()
|
||
|
||
# 中间:通道标题
|
||
title = QLabel(f"{station_label}")
|
||
title.setAlignment(Qt.AlignCenter)
|
||
title_font = QFont(); title_font.setPointSize(12); title_font.setBold(True)
|
||
title.setFont(title_font)
|
||
title_layout.addWidget(title)
|
||
|
||
# 右侧占位
|
||
title_layout.addStretch()
|
||
|
||
# 最右边:状态标签(根据实际状态动态设置)
|
||
station_status_label = QLabel() # 局部变量,不是实例变量
|
||
station_status_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
station_status_label.setStyleSheet("font-size: 16px; padding-right: 8px;") # 增大字体大小
|
||
title_layout.addWidget(station_status_label)
|
||
|
||
layout.addWidget(title_widget)
|
||
|
||
# 存储控件引用以便后续更新
|
||
widget_components = {
|
||
'stationGroup': widget,
|
||
'station_status_label': station_status_label,
|
||
'title_widget': title_widget
|
||
}
|
||
|
||
# 保存控件引用
|
||
if machine_sn and station_sn:
|
||
self._station_widgets[(machine_sn, station_sn)] = widget_components
|
||
|
||
# 内容栈(offline/idle/dut)
|
||
content_stack = QStackedWidget()
|
||
layout.addWidget(content_stack)
|
||
widget_components['stack'] = content_stack
|
||
|
||
# 离线页
|
||
offline_page = QWidget(); offline_layout = QVBoxLayout(offline_page)
|
||
offline_label = QLabel("设备离线")
|
||
offline_label.setAlignment(Qt.AlignCenter)
|
||
offline_label.setStyleSheet("color: red; font-size: 18px; padding: 20px;")
|
||
offline_layout.addWidget(offline_label); offline_layout.addStretch()
|
||
# 离线页底部status bar
|
||
offline_status_bar = QLabel("")
|
||
offline_status_bar.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||
offline_status_bar.setStyleSheet("color: blue; font-size: 12px; padding: 5px; background: #f0f0f0; border-top: 1px solid #ccc;")
|
||
offline_status_bar.setWordWrap(True)
|
||
offline_status_bar.setMinimumHeight(30)
|
||
offline_layout.addWidget(offline_status_bar)
|
||
content_stack.addWidget(offline_page)
|
||
widget_components['offline_label'] = offline_label
|
||
widget_components['offline_status_bar'] = offline_status_bar
|
||
|
||
# 空闲页
|
||
idle_page = QWidget(); idle_layout = QVBoxLayout(idle_page)
|
||
idle_tip = QLabel("设备在线,请安排试验")
|
||
idle_tip.setAlignment(Qt.AlignCenter)
|
||
idle_tip.setStyleSheet("color: gray; font-size: 16px; padding: 20px;")
|
||
idle_layout.addWidget(idle_tip)
|
||
idle_layout.addStretch()
|
||
btn_layout = QHBoxLayout(); btn_layout.setSpacing(5)
|
||
select_dut_btn = QPushButton("选择样品")
|
||
select_dut_btn.setObjectName("station-action-btn")
|
||
select_dut_btn.setMinimumHeight(35)
|
||
select_dut_btn.clicked.connect(lambda: self._on_select_dut(machine_sn, station_sn))
|
||
btn_layout.addWidget(select_dut_btn)
|
||
new_test_btn = QPushButton("新建试验")
|
||
new_test_btn.setObjectName("station-action-btn")
|
||
new_test_btn.setMinimumHeight(35)
|
||
new_test_btn.clicked.connect(lambda: self._on_new_test(machine_sn, station_sn))
|
||
btn_layout.addWidget(new_test_btn)
|
||
idle_layout.addLayout(btn_layout)
|
||
# 空闲页底部status bar
|
||
idle_status_bar = QLabel("")
|
||
idle_status_bar.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||
idle_status_bar.setStyleSheet("color: blue; font-size: 12px; padding: 5px; background: #f0f0f0; border-top: 1px solid #ccc;")
|
||
idle_status_bar.setWordWrap(True)
|
||
idle_status_bar.setMinimumHeight(30)
|
||
idle_layout.addWidget(idle_status_bar)
|
||
content_stack.addWidget(idle_page)
|
||
widget_components['idle_tip'] = idle_tip
|
||
widget_components['idle_status_bar'] = idle_status_bar
|
||
|
||
# DUT页:信息 + 跌落方向表格 + status bar
|
||
dut_page = QWidget(); dut_page_layout = QVBoxLayout(dut_page); dut_page_layout.setSpacing(8)
|
||
info_widget = QWidget()
|
||
info_widget.setStyleSheet("QWidget { background-color: #FFF4E6; border: 1px solid #FFB84D; border-radius: 4px; padding: 8px; }")
|
||
info_layout = QGridLayout(info_widget); info_layout.setContentsMargins(8, 8, 8, 8); info_layout.setSpacing(5)
|
||
sample_title = QLabel("样品信息")
|
||
sample_title.setStyleSheet("background-color: #FF8C00; color: white; font-weight: bold; padding: 4px 12px; border-radius: 3px; font-size: 12px;")
|
||
sample_title.setAlignment(Qt.AlignCenter)
|
||
info_layout.addWidget(sample_title, 0, 0, 1, 5)
|
||
# 表头
|
||
sn_label_header = QLabel("产品序列号"); sn_label_header.setStyleSheet("border:none"); info_layout.addWidget(sn_label_header, 1, 0, Qt.AlignHCenter)
|
||
project_label_header = QLabel("项目代码"); project_label_header.setStyleSheet("border:none"); info_layout.addWidget(project_label_header, 1, 1, Qt.AlignHCenter)
|
||
phase_label_header = QLabel("项目阶段"); phase_label_header.setStyleSheet("border:none"); info_layout.addWidget(phase_label_header, 1, 2, Qt.AlignHCenter)
|
||
work_order_label_header = QLabel("工单名称"); work_order_label_header.setStyleSheet("border:none"); info_layout.addWidget(work_order_label_header, 1, 3, Qt.AlignHCenter)
|
||
weeks_label_header = QLabel("测试周次"); weeks_label_header.setStyleSheet("border:none"); info_layout.addWidget(weeks_label_header, 1, 4, Qt.AlignHCenter)
|
||
# 数据标签(支持最多4个DUT,初始创建4行)
|
||
dut_data_labels = []
|
||
for i in range(4): # 最多4个DUT
|
||
row_idx = 2 + i
|
||
sn_label = QLabel(""); sn_label.setStyleSheet("font-weight: bold; color: #FF8C00; font-size: 13px; border:none"); sn_label.setAlignment(Qt.AlignCenter); info_layout.addWidget(sn_label, row_idx, 0)
|
||
project_code_label = QLabel(""); project_code_label.setStyleSheet("color: #0066CC; font-weight: bold; border:none"); project_code_label.setAlignment(Qt.AlignCenter); info_layout.addWidget(project_code_label, row_idx, 1)
|
||
phase_label = QLabel(""); phase_label.setStyleSheet("color: #0066CC; border:none"); phase_label.setAlignment(Qt.AlignCenter); info_layout.addWidget(phase_label, row_idx, 2)
|
||
work_order_label = QLabel(""); work_order_label.setStyleSheet("border:none"); work_order_label.setAlignment(Qt.AlignCenter); info_layout.addWidget(work_order_label, row_idx, 3)
|
||
weeks_value_label = QLabel(""); weeks_value_label.setStyleSheet("border:none"); weeks_value_label.setAlignment(Qt.AlignCenter); info_layout.addWidget(weeks_value_label, row_idx, 4)
|
||
# 默认隐藏
|
||
sn_label.hide()
|
||
project_code_label.hide()
|
||
phase_label.hide()
|
||
work_order_label.hide()
|
||
weeks_value_label.hide()
|
||
dut_data_labels.append({
|
||
'sn': sn_label,
|
||
'project': project_code_label,
|
||
'phase': phase_label,
|
||
'work_order': work_order_label,
|
||
'weeks': weeks_value_label,
|
||
})
|
||
dut_page_layout.addWidget(info_widget)
|
||
# 跌落方向表格
|
||
drop_table = QTableWidget(); drop_table.setObjectName("drop-direction-table")
|
||
dut_page_layout.addWidget(drop_table)
|
||
dut_page_layout.addStretch()
|
||
# DUT页底部status bar
|
||
dut_status_bar = QLabel("")
|
||
dut_status_bar.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||
dut_status_bar.setStyleSheet("color: blue; font-size: 16px; padding: 5px; background: #f0f0f0; border-top: 1px solid #ccc;")
|
||
dut_status_bar.setWordWrap(True)
|
||
dut_status_bar.setMinimumHeight(30)
|
||
dut_page_layout.addWidget(dut_status_bar)
|
||
content_stack.addWidget(dut_page)
|
||
widget_components['dut_data_labels'] = dut_data_labels # 存储多行DUT标签
|
||
widget_components['drop_table'] = drop_table
|
||
widget_components['dut_status_bar'] = dut_status_bar
|
||
|
||
# 调用更新方法来初始化显示
|
||
self._update_station_widget(widget_components, machine, station)
|
||
|
||
return widget
|
||
|
||
def _update_station_widget(self, widget_components, machine, station):
|
||
"""更新工位监控组件(复用控件,不重建)"""
|
||
station_group = widget_components['stationGroup']
|
||
station_status_label = widget_components['station_status_label']
|
||
stack = widget_components.get('stack')
|
||
|
||
machine_sn = machine.get('SN', '')
|
||
offline = not machine.get('connectState', 0) # 默认为 0(离线)
|
||
|
||
# 获取status bar控件
|
||
offline_status_bar = widget_components.get('offline_status_bar')
|
||
idle_status_bar = widget_components.get('idle_status_bar')
|
||
dut_status_bar = widget_components.get('dut_status_bar')
|
||
|
||
# 处理离线状态
|
||
if offline:
|
||
station_status_label.setText("设备离线")
|
||
station_status_label.setStyleSheet(self._get_status_style('offline') + " padding-right: 8px;")
|
||
if offline_status_bar:
|
||
offline_status_bar.setText(station.get('lastDropResult', ''))
|
||
if stack: stack.setCurrentIndex(0)
|
||
return
|
||
|
||
# 获取通道状态(不依赖是否有DUT)
|
||
station_status = station.get('status', '')
|
||
|
||
# 根据 station_status 判断显示哪个页面
|
||
if station_status == 'finished':
|
||
# finished 状态:显示DUT页面,左上角显示"完成",底部显示"试验已经完成"
|
||
station_status_label.setText("完成")
|
||
station_status_label.setStyleSheet(self._get_status_style('finished') + " padding-right: 8px;")
|
||
if dut_status_bar:
|
||
dut_status_bar.setText("试验已经完成")
|
||
dut_status_bar.setAlignment(Qt.AlignCenter)
|
||
dut_status_bar.setStyleSheet("color: green; font-size: 20px; font-weight: bold; padding: 5px; background: #f0f0f0; border-top: 1px solid #ccc;")
|
||
# 更新DUT信息和跌落方向表
|
||
self._update_dut_info(widget_components, station)
|
||
if stack: stack.setCurrentIndex(2)
|
||
elif station_status in ['running', 'paused', 'error'] or station.get('dutSN'):
|
||
# 有DUT分配或试验运行中:显示DUT页面
|
||
station_status_label.setText(self._get_status_text(station_status))
|
||
station_status_label.setStyleSheet(self._get_status_style(station_status))
|
||
if dut_status_bar:
|
||
dut_status_bar.setText(station.get('lastDropResult', ''))
|
||
dut_status_bar.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||
dut_status_bar.setStyleSheet("color: blue; font-size: 16px; padding: 5px; background: #f0f0f0; border-top: 1px solid #ccc;")
|
||
# 更新DUT信息和跌落方向表
|
||
self._update_dut_info(widget_components, station)
|
||
if stack: stack.setCurrentIndex(2)
|
||
elif station_status in ['idle']:
|
||
# 空闲状态:显示空闲页面
|
||
station_status_label.setText("空闲")
|
||
station_status_label.setStyleSheet(self._get_status_style('idle') + " padding-right: 8px;")
|
||
idle_tip = widget_components.get('idle_tip')
|
||
if idle_tip:
|
||
idle_tip.setText("设备在线,请安排试验")
|
||
if idle_status_bar:
|
||
idle_status_bar.setText(station.get('lastDropResult', ''))
|
||
if stack: stack.setCurrentIndex(1)
|
||
else:
|
||
pass
|
||
|
||
def _update_dut_info(self, widget_components, station):
|
||
"""更新DUT信息和跌落方向表(提取公共逻辑)"""
|
||
# 获取DUT列表(从 station['dut']['duts'] 获取)
|
||
duts = []
|
||
dut_obj = station.get('dut', {})
|
||
if isinstance(dut_obj, dict):
|
||
# 新的数据结构:dut 对象包含 duts 数组
|
||
duts = dut_obj.get('duts', [])
|
||
if not duts:
|
||
# 如果 dut.duts 为空,但有 dutSN,则兼容旧的单DUT结构
|
||
dut_sn = station.get('dutSN', '')
|
||
if dut_sn:
|
||
duts = [{
|
||
'SN': dut_sn,
|
||
'name': station.get('dutName', ''),
|
||
'project': station.get('dutProject', ''),
|
||
'projectName': station.get('projectName', ''),
|
||
'phase': station.get('dutPhase', ''),
|
||
'projectPhase': station.get('projectPhase', ''),
|
||
'workOrder': dut_obj.get('workOrder', ''),
|
||
'weeks': dut_obj.get('weeks', 0)
|
||
}]
|
||
|
||
# 更新DUT数据标签
|
||
dut_data_labels = widget_components.get('dut_data_labels', [])
|
||
if dut_data_labels:
|
||
# 隐藏所有行
|
||
for labels in dut_data_labels:
|
||
labels['sn'].hide()
|
||
labels['project'].hide()
|
||
labels['phase'].hide()
|
||
labels['work_order'].hide()
|
||
labels['weeks'].hide()
|
||
|
||
# 填充有数据的行
|
||
for idx, dut_info in enumerate(duts[:4]): # 最多显示4个
|
||
if idx < len(dut_data_labels):
|
||
labels = dut_data_labels[idx]
|
||
labels['sn'].setText(dut_info.get('SN', ''))
|
||
labels['project'].setText(dut_info.get('project', ''))
|
||
labels['phase'].setText(dut_info.get('phase', ''))
|
||
labels['work_order'].setText(dut_info.get('workOrder', ''))
|
||
labels['weeks'].setText(str(dut_info.get('weeks', 0)))
|
||
# 显示该行
|
||
labels['sn'].show()
|
||
labels['project'].show()
|
||
labels['phase'].show()
|
||
labels['work_order'].show()
|
||
labels['weeks'].show()
|
||
|
||
# 更新跌落方向表
|
||
table = widget_components.get('drop_table')
|
||
if table:
|
||
drop_dirs = station.get('dropDirections', [])
|
||
self._populate_drop_table(table, drop_dirs)
|
||
|
||
def _populate_drop_table(self, table, drop_directions):
|
||
"""增量填充跌落方向表格(复用表实例)"""
|
||
if drop_directions is None:
|
||
drop_directions = []
|
||
# 如果没有数据,保持空表
|
||
table.setColumnCount(4)
|
||
table.setHorizontalHeaderLabels(["跌落方向", "跌落高度(mm)", "设定跌落次数", "当前跌落次数"])
|
||
table.setRowCount(len(drop_directions))
|
||
|
||
# 样式与属性(只需设置一次,但多次设置也安全)
|
||
table.setStyleSheet(
|
||
"QTableWidget { gridline-color: #d0d0d0; border: 1px solid #d0d0d0; background-color: white; font-size: 11px; } "
|
||
"QHeaderView::section { background-color: #E8F4FD; padding: 4px; border: 1px solid #d0d0d0; font-weight: bold; font-size: 11px; } "
|
||
"QTableWidget::item { padding: 3px; } "
|
||
)
|
||
table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||
table.setSelectionBehavior(QTableWidget.SelectRows)
|
||
table.verticalHeader().setVisible(True)
|
||
table.verticalHeader().setDefaultSectionSize(28)
|
||
|
||
# 填充数据
|
||
for row, direction in enumerate(drop_directions):
|
||
direction_name = direction.get('方向') or direction.get('name', '')
|
||
height = direction.get('高度', 0)
|
||
total_cycles = direction.get('设定次数', 0)
|
||
current_cycles = direction.get('当前次数', 0)
|
||
item0 = QTableWidgetItem(str(direction_name)); item0.setTextAlignment(Qt.AlignCenter)
|
||
item1 = QTableWidgetItem(str(height)); item1.setTextAlignment(Qt.AlignCenter)
|
||
item2 = QTableWidgetItem(str(total_cycles)); item2.setTextAlignment(Qt.AlignCenter)
|
||
item3 = QTableWidgetItem(str(current_cycles)); item3.setTextAlignment(Qt.AlignCenter)
|
||
|
||
# 将第1、2、3列字体大小设为其他列的1.1倍
|
||
font = item0.font()
|
||
font.setPointSize(int(9 * 1.1)) # 基础字号11px * 1.1
|
||
item0.setFont(font)
|
||
item1.setFont(font)
|
||
item2.setFont(font)
|
||
|
||
# 设置item3字体大小为其他列的1.4倍
|
||
font = item3.font()
|
||
font.setPointSize(int(11 * 1.4)) # 基础字号11px * 1.4
|
||
item3.setFont(font)
|
||
table.setItem(row, 0, item0)
|
||
table.setItem(row, 1, item1)
|
||
table.setItem(row, 2, item2)
|
||
table.setItem(row, 3, item3)
|
||
if current_cycles > 0:
|
||
for item in [item0, item1, item2, item3]:
|
||
item.setBackground(QColor("#ADD8E6"))
|
||
item3.setForeground(QColor("#FF0000"))
|
||
|
||
# 自动调整列宽
|
||
header = table.horizontalHeader()
|
||
header.setSectionResizeMode(0, QHeaderView.Fixed)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||
header.setSectionResizeMode(3, QHeaderView.Stretch)
|
||
|
||
table.setColumnWidth(0, 100) # 跌落方向
|
||
table.setColumnWidth(1, 100) # 设定高度
|
||
table.setColumnWidth(2, 110) # 设定次数
|
||
# 限制高度
|
||
table_height = table.verticalHeader().length() + table.horizontalHeader().height() + 10
|
||
table.setMaximumHeight(table_height); table.setMinimumHeight(table_height)
|
||
|
||
def _get_status_text(self, status):
|
||
"""获取状态文本"""
|
||
status_map = {
|
||
'idle': '空闲',
|
||
'running': '运行中',
|
||
'pause': '暂停',
|
||
'paused': '暂停',
|
||
'error': '故障',
|
||
'offline': '离线',
|
||
'finished': '完成'
|
||
}
|
||
return status_map.get(status, '未知')
|
||
|
||
def _get_status_style(self, status):
|
||
"""获取状态样式"""
|
||
style_map = {
|
||
'idle': 'color: blue; font-size: 14px; font-weight: bold;',
|
||
'running': 'color: red; font-size: 14px; font-weight: bold;',
|
||
'paused': 'color: orange; font-size: 14px; font-weight: bold;',
|
||
'pause': 'color: orange; font-size: 14px; font-weight: bold;',
|
||
'error': 'color: red; font-size: 14px; font-weight: bold;',
|
||
'offline': 'color: gray; font-size: 14px; font-weight: bold;',
|
||
'finished': 'color: green; font-size: 14px; font-weight: bold;'
|
||
}
|
||
return style_map.get(status, 'color: black; font-size: 14px;')
|
||
|
||
def _get_dut_status_style(self, dut_status):
|
||
"""获取 DUT 状态样式"""
|
||
style_map = {
|
||
'待启动': 'color: orange; font-size: 14px; font-weight: bold; padding: 5px;',
|
||
'运行中': 'color: blue; font-size: 14px; font-weight: bold; padding: 5px;',
|
||
'暂停': 'color: orange; font-size: 14px; font-weight: bold; padding: 5px;',
|
||
'已完成': 'color: green; font-size: 14px; font-weight: bold; padding: 5px;',
|
||
'异常': 'color: red; font-size: 14px; font-weight: bold; padding: 5px;'
|
||
}
|
||
return style_map.get(dut_status, 'color: black; font-size: 14px; padding: 5px;')
|
||
|
||
def _get_dut_status_style_inline(self, dut_status):
|
||
"""获取 DUT 状态内联样式(用于标题栏右侧小字体显示)"""
|
||
style_map = {
|
||
'待启动': 'color: orange; font-size: 16px; font-weight: bold; padding-right: 8px;',
|
||
'运行中': 'color: blue; font-size: 16px; font-weight: bold; padding-right: 8px;',
|
||
'暂停': 'color: orange; font-size: 16px; font-weight: bold; padding-right: 8px;',
|
||
'已完成': 'color: green; font-size: 16px; font-weight: bold; padding-right: 8px;',
|
||
'异常': 'color: red; font-size: 16px; font-weight: bold; padding-right: 8px;'
|
||
}
|
||
return style_map.get(dut_status, 'color: black; font-size: 16px; padding-right: 8px;')
|
||
|
||
# ===================== 事件处理 =====================
|
||
|
||
def _on_select_dut(self, machine_sn, station_sn):
|
||
"""选择 DUT(样品)"""
|
||
# 获取机台信息
|
||
machine = self.viewModel.getMachineBySN(machine_sn)
|
||
machine_label = machine.get('label', '') if machine else ''
|
||
# 获取connectState作为最大样品数量(1-4)
|
||
max_dut_count = machine.get('connectState', 1) if machine else 1
|
||
# 确保在有效范围内
|
||
if not isinstance(max_dut_count, int) or max_dut_count < 1:
|
||
max_dut_count = 1
|
||
if max_dut_count > 4:
|
||
max_dut_count = 4
|
||
|
||
# TODO: 获取当前登录用户ID(从系统中获取)
|
||
user_id = "" # 默认为空,后续集成用户管理后填充
|
||
|
||
# 打开 DUT 选择对话框
|
||
dialog = DUTSelectionDialog(
|
||
machine_sn=machine_sn,
|
||
station_num=station_sn,
|
||
machine_label=machine_label,
|
||
user_id=user_id,
|
||
max_dut_count=max_dut_count, # 传递最大样品数量
|
||
parent=self
|
||
)
|
||
|
||
# 对话框关闭后自动刷新界面
|
||
if dialog.exec_() == QDialog.Accepted:
|
||
# API 已在对话框中调用,这里可以选择刷新界面
|
||
print(f"样品分配完成 - 机台: {machine_sn}, 通道: {station_sn}")
|
||
# TODO: 刷新机台状态显示(如果需要)
|
||
|
||
def _on_new_test(self, machine_sn, station_sn):
|
||
"""新建试验 - 打开新建样品对话框并自动分配到当前工位"""
|
||
# 获取机台信息
|
||
machine = self.viewModel.getMachineBySN(machine_sn)
|
||
if not machine:
|
||
QMessageBox.warning(self, "错误", "无法获取机台信息")
|
||
return
|
||
|
||
# 预填充机台和通道信息
|
||
initial_form = {
|
||
"dtMachine": machine_sn,
|
||
"station": station_sn
|
||
}
|
||
|
||
# 打开新建样品对话框
|
||
dialog = DUTFormDialog(
|
||
mode="create",
|
||
dut_form=initial_form,
|
||
system_model=self.system_model,
|
||
parent=self
|
||
)
|
||
|
||
if dialog.exec_() == QDialog.Accepted:
|
||
# 获取表单数据
|
||
form = dialog.get_form_dict()
|
||
|
||
# 验证必填字段
|
||
if not form.get("SN"):
|
||
QMessageBox.warning(self, "错误", "产品序列号不能为空")
|
||
return
|
||
|
||
# 补充非表单字段的默认值
|
||
form.update({
|
||
'name': '',
|
||
'projectType': '',
|
||
'itemOnGoing': '',
|
||
'itemsFinished': 0,
|
||
'status': '待安排', # 新建默认为待安排状态
|
||
'deadLine': '',
|
||
'createdate': '',
|
||
'direction_codes': ''
|
||
})
|
||
|
||
try:
|
||
# 保存样品到数据库
|
||
self.dut_model.addDUT(form)
|
||
print(f"样品 {form.get('SN')} 已保存到数据库")
|
||
|
||
# 刷新 DUT Model 数据(确保能获取到刚创建的样品)
|
||
self.dut_model._load_duts()
|
||
|
||
# 自动选择并分配样品到当前机台和通道
|
||
# 使用与 DUTSelectionDialog 相同的分配逻辑
|
||
dut_sn = form.get("SN")
|
||
success = self._attach_dut_to_station(machine_sn, station_sn, [dut_sn])
|
||
|
||
if success:
|
||
QMessageBox.information(
|
||
self,
|
||
"成功",
|
||
f"样品 {dut_sn} 已创建并分配到\n机台: {machine_sn}\n通道: {station_sn}"
|
||
)
|
||
else:
|
||
QMessageBox.warning(
|
||
self,
|
||
"警告",
|
||
f"样品 {dut_sn} 已创建,但分配到工位失败\n请手动选择样品进行分配"
|
||
)
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
QMessageBox.critical(self, "错误", f"创建样品失败:{str(e)}")
|
||
|
||
def _attach_dut_to_station(self, machine_sn, station_sn, dut_sns):
|
||
"""将样品分配到指定机台和通道(复用 DUTSelectionDialog 的逻辑)
|
||
|
||
Args:
|
||
machine_sn: 机台SN
|
||
station_sn: 通道编号(如 "01")
|
||
dut_sns: 样品SN列表
|
||
|
||
Returns:
|
||
bool: 是否成功
|
||
"""
|
||
try:
|
||
# 根据 dut_direction_mode 决定 action(默认为2,表示从PLC读取跌落参数)
|
||
dut_direction_mode = 2
|
||
if dut_direction_mode == 2:
|
||
action = "select_and_transfer" # 选择并传送参数到 PLC
|
||
else:
|
||
action = "select" # 仅选择
|
||
|
||
# 构造请求参数(与 DUTSelectionDialog._attach_dut_to_station 完全一致)
|
||
control_reqs = {
|
||
"attachDut": {
|
||
"action": action,
|
||
"machine": machine_sn,
|
||
"station": station_sn,
|
||
"dut": dut_sns, # 使用数组传递DUT的SN
|
||
"updateTableFlag": True, # 更新数据表
|
||
"userid": "" # 用户ID(可选)
|
||
}
|
||
}
|
||
|
||
print(f"\n[调试] 分配 DUT 到机台:")
|
||
print(f" 机台: {machine_sn}")
|
||
print(f" 通道: {station_sn}")
|
||
print(f" 样品: {dut_sns}")
|
||
print(f" Action: {action}")
|
||
print(f" 请求参数: {json.dumps(control_reqs, ensure_ascii=False, indent=2)}")
|
||
|
||
# 调用 HTTP API
|
||
if self.use_http_api:
|
||
url = f"{self.api_base_url}/machine_control"
|
||
response = requests.post(
|
||
url,
|
||
json=control_reqs,
|
||
timeout=10
|
||
)
|
||
response.raise_for_status()
|
||
result = response.json()
|
||
|
||
print(f" API 响应: {json.dumps(result, ensure_ascii=False, indent=2)}")
|
||
|
||
if result.get("status") == "success":
|
||
return True
|
||
else:
|
||
print(f"样品分配失败: {result.get('message', '未知错误')}")
|
||
return False
|
||
else:
|
||
print("HTTP API未启用,无法分配样品")
|
||
return False
|
||
|
||
except Exception as e:
|
||
print(f"分配样品到工位失败: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
def _on_ws_error(self, error_message):
|
||
"""WebSocket 错误处理"""
|
||
try:
|
||
print(f"WebSocket 错误: {error_message}")
|
||
except Exception:
|
||
pass
|
||
|
||
def resizeEvent(self, event):
|
||
"""调整通道容器高度为窗口高度的约 40%"""
|
||
super().resizeEvent(event)
|
||
h = int(max(800, self.height() * 0.4))
|
||
for w in getattr(self, '_machine_channel_containers', []):
|
||
try:
|
||
w.setMaximumHeight(h)
|
||
except Exception:
|
||
pass
|