CGQ_TEST_DEVICE/app/main.py

757 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""
传感器测试设备软件 - 主入口
基于 PyQt5 构建的桌面图形界面应用。
提供从登录、测试配置、自动化执行、实时监控到报告生成的全流程操作界面。
"""
import sys
import time
import threading
from datetime import datetime
from typing import Optional
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QTextEdit, QComboBox,
QTableWidget, QTableWidgetItem, QGroupBox, QFormLayout,
QMessageBox, QTabWidget, QSplitter, QStatusBar, QHeaderView,
QProgressBar, QFrame, QGridLayout, QStackedWidget, QDialog,
QListWidget, QListWidgetItem
)
from PyQt5.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject
from PyQt5.QtGui import QFont, QPalette, QColor, QPixmap, QPainter, QPen
from app.data_structures import (
UserInfo, UserRole, SensorModelConfig, TestContext,
JudgmentResult, TestResult, SystemStatus, SystemMode
)
from app.services import (
SecurityService, SensorConfigService, TestEngine,
CalculationService, ReportService
)
# =============================================================================
# 数据采集模拟线程
# =============================================================================
class AcquisitionWorker(QObject):
"""
数据采集工作线程
在后台线程中模拟硬件数据采集避免阻塞UI。
"""
data_acquired = pyqtSignal(object)
status_changed = pyqtSignal()
test_completed = pyqtSignal(object)
def __init__(self, engine: TestEngine):
"""
初始化采集线程
Args:
engine: 测试引擎实例
"""
super().__init__()
self._engine = engine
self._running = False
def start_acquisition(self) -> None:
"""开始采集循环"""
self._running = True
while self._running:
ctx = self._engine.context
if ctx.status_flags["is_running"] and not ctx.status_flags["is_paused"]:
point = self._engine.simulate_acquisition()
self.data_acquired.emit(point)
self.status_changed.emit()
# 检查测试是否完成
if not ctx.status_flags["is_running"]:
self.test_completed.emit(
self._engine.context.acquisition_buffer.get_all()
)
time.sleep(0.05) # ~20fps
else:
time.sleep(0.1)
def stop(self) -> None:
"""停止采集循环"""
self._running = False
# =============================================================================
# 登录对话框
# =============================================================================
class LoginDialog(QDialog):
"""
登录对话框
提供双因子认证(用户名/密码 + TOTP动态口令登录界面。
"""
login_success = pyqtSignal(object)
def __init__(self, security_service: SecurityService):
"""
初始化登录对话框
Args:
security_service: 安全认证服务实例
"""
super().__init__()
self._security = security_service
self._init_ui()
def _init_ui(self) -> None:
"""初始化用户界面"""
self.setWindowTitle("传感器测试设备 - 登录")
self.setFixedSize(400, 300)
layout = QVBoxLayout()
# 标题
title = QLabel("传感器测试设备软件")
title.setAlignment(Qt.AlignCenter)
title_font = QFont("Microsoft YaHei", 16, QFont.Bold)
title.setFont(title_font)
layout.addWidget(title)
layout.addSpacing(20)
# 表单
form = QFormLayout()
self.username_input = QLineEdit()
self.username_input.setPlaceholderText("请输入用户名")
self.password_input = QLineEdit()
self.password_input.setPlaceholderText("请输入密码")
self.password_input.setEchoMode(QLineEdit.Password)
self.totp_input = QLineEdit()
self.totp_input.setPlaceholderText("请输入6位动态口令")
self.totp_input.setMaxLength(6)
form.addRow("用户名:", self.username_input)
form.addRow("密 码:", self.password_input)
form.addRow("动态口令:", self.totp_input)
layout.addLayout(form)
layout.addSpacing(20)
# 登录按钮
self.login_btn = QPushButton("登 录")
self.login_btn.setMinimumHeight(36)
self.login_btn.clicked.connect(self._do_login)
layout.addWidget(self.login_btn)
# 状态显示
self.status_label = QLabel("")
self.status_label.setAlignment(Qt.AlignCenter)
self.status_label.setStyleSheet("color: red;")
layout.addWidget(self.status_label)
self.setLayout(layout)
# 默认填充(方便测试)
self.username_input.setText("op01")
self.password_input.setText("pass123")
self.totp_input.setText("123456")
def _do_login(self) -> None:
"""执行登录"""
username = self.username_input.text().strip()
password = self.password_input.text()
totp = self.totp_input.text().strip()
if not username or not password or not totp:
self.status_label.setText("请填写所有字段")
return
success, msg = self._security.login(username, password, totp)
if success:
self.login_success.emit(self._security.get_current_user())
self.accept()
else:
self.status_label.setText(msg)
# =============================================================================
# 主窗口
# =============================================================================
class MainWindow(QMainWindow):
"""
传感器测试设备主窗口
提供测试任务配置、自动化执行、实时监控、质量判定和报告生成的核心界面。
"""
def __init__(self, user: UserInfo):
"""
初始化主窗口
Args:
user: 当前登录用户信息
"""
super().__init__()
self._current_user = user
# 初始化服务
self._security = SecurityService()
self._security._current_session = user
self._sensor_config = SensorConfigService()
self._test_engine = TestEngine(self._sensor_config)
self._calc_service = CalculationService()
self._report_service = ReportService()
# 采集线程
self._acq_thread: Optional[QThread] = None
self._acq_worker: Optional[AcquisitionWorker] = None
# 最后判定结果
self._last_judgment: Optional[JudgmentResult] = None
self._init_ui()
self._setup_timers()
self._update_permissions()
def _init_ui(self) -> None:
"""初始化主界面"""
self.setWindowTitle("传感器测试设备软件 - 安全模式")
self.setGeometry(100, 100, 1200, 800)
# 中央部件
central = QWidget()
self.setCentralWidget(central)
main_layout = QVBoxLayout()
# 顶部工具栏
toolbar = QHBoxLayout()
self.user_label = QLabel(f"用户: {self._current_user.user_id} "
f"({self._current_user.role.value})")
self.user_label.setStyleSheet("font-weight: bold; padding: 5px;")
toolbar.addWidget(self.user_label)
toolbar.addStretch()
self.status_indicator = QLabel("● 系统正常")
self.status_indicator.setStyleSheet("color: green; font-weight: bold;")
toolbar.addWidget(self.status_indicator)
self.logout_btn = QPushButton("注销")
self.logout_btn.clicked.connect(self._do_logout)
toolbar.addWidget(self.logout_btn)
main_layout.addLayout(toolbar)
# 主分割器
splitter = QSplitter(Qt.Horizontal)
# 左侧面板
left_panel = QWidget()
left_layout = QVBoxLayout()
# 扫码/条码输入区
barcode_group = QGroupBox("条码识别")
barcode_layout = QVBoxLayout()
barcode_input_layout = QHBoxLayout()
self.barcode_input = QLineEdit()
self.barcode_input.setPlaceholderText("扫码或手动输入传感器条码")
self.barcode_input.returnPressed.connect(self._on_barcode_input)
barcode_input_layout.addWidget(self.barcode_input)
self.load_btn = QPushButton("加载测试计划")
self.load_btn.clicked.connect(self._on_barcode_input)
barcode_input_layout.addWidget(self.load_btn)
barcode_layout.addLayout(barcode_input_layout)
self.barcode_info = QLabel("等待扫码...")
barcode_layout.addWidget(self.barcode_info)
barcode_group.setLayout(barcode_layout)
left_layout.addWidget(barcode_group)
# 控制区
control_group = QGroupBox("测试控制")
control_layout = QGridLayout()
self.start_btn = QPushButton("▶ 开始")
self.start_btn.clicked.connect(self._start_test)
control_layout.addWidget(self.start_btn, 0, 0)
self.pause_btn = QPushButton("⏸ 暂停")
self.pause_btn.clicked.connect(self._pause_test)
self.pause_btn.setEnabled(False)
control_layout.addWidget(self.pause_btn, 0, 1)
self.stop_btn = QPushButton("⏹ 停止")
self.stop_btn.clicked.connect(self._stop_test)
self.stop_btn.setEnabled(False)
control_layout.addWidget(self.stop_btn, 1, 0)
self.skip_btn = QPushButton("⏭ 跳步")
self.skip_btn.clicked.connect(self._skip_point)
self.skip_btn.setEnabled(False)
control_layout.addWidget(self.skip_btn, 1, 1)
control_group.setLayout(control_layout)
left_layout.addWidget(control_group)
# 进度区
progress_group = QGroupBox("测试进度")
progress_layout = QVBoxLayout()
progress_layout.addWidget(QLabel("当前循环:"))
self.cycle_label = QLabel("0 / 0")
progress_layout.addWidget(self.cycle_label)
progress_layout.addWidget(QLabel("测试点进度:"))
self.point_progress = QProgressBar()
self.point_progress.setRange(0, 100)
self.point_progress.setValue(0)
progress_layout.addWidget(self.point_progress)
progress_layout.addWidget(QLabel("总体进度:"))
self.total_progress = QProgressBar()
self.total_progress.setRange(0, 100)
self.total_progress.setValue(0)
progress_layout.addWidget(self.total_progress)
progress_group.setLayout(progress_layout)
left_layout.addWidget(progress_group)
left_layout.addStretch()
left_panel.setLayout(left_layout)
splitter.addWidget(left_panel)
# 中间面板 - 实时数据与绘图
center_panel = QWidget()
center_layout = QVBoxLayout()
# 实时数值显示
value_group = QGroupBox("实时数据")
value_layout = QGridLayout()
value_layout.addWidget(QLabel("目标压力:"), 0, 0)
self.target_pressure_label = QLabel("-- MPa")
self.target_pressure_label.setStyleSheet("font-size: 18px; font-weight: bold;")
value_layout.addWidget(self.target_pressure_label, 0, 1)
value_layout.addWidget(QLabel("实际压力:"), 1, 0)
self.actual_pressure_label = QLabel("-- MPa")
self.actual_pressure_label.setStyleSheet("font-size: 18px; font-weight: bold;")
value_layout.addWidget(self.actual_pressure_label, 1, 1)
value_layout.addWidget(QLabel("传感器输出:"), 2, 0)
self.output_label = QLabel("--")
self.output_label.setStyleSheet("font-size: 18px; font-weight: bold;")
value_layout.addWidget(self.output_label, 2, 1)
value_layout.addWidget(QLabel("报警状态:"), 3, 0)
self.alarm_label = QLabel("● NORMAL")
self.alarm_label.setStyleSheet("color: green; font-size: 16px; font-weight: bold;")
value_layout.addWidget(self.alarm_label, 3, 1)
value_group.setLayout(value_layout)
center_layout.addWidget(value_group)
# 实时绘图区域(简化:使用文本模拟)
plot_group = QGroupBox("实时曲线图 (压力-输出)")
plot_layout = QVBoxLayout()
self.plot_widget = QTextEdit()
self.plot_widget.setReadOnly(True)
self.plot_widget.setMaximumHeight(200)
self.plot_widget.setPlaceholderText("实时曲线数据将在测试过程中显示...")
plot_layout.addWidget(self.plot_widget)
plot_group.setLayout(plot_layout)
center_layout.addWidget(plot_group)
# 数据表
data_group = QGroupBox("采集数据表")
data_layout = QVBoxLayout()
self.data_table = QTableWidget(0, 3)
self.data_table.setHorizontalHeaderLabels(["压力 (MPa)", "输出值", "时间戳"])
self.data_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
data_layout.addWidget(self.data_table)
data_group.setLayout(data_layout)
center_layout.addWidget(data_group)
center_panel.setLayout(center_layout)
splitter.addWidget(center_panel)
# 右侧面板
right_panel = QWidget()
right_layout = QVBoxLayout()
# 判定结果
result_group = QGroupBox("判定结果")
result_layout = QVBoxLayout()
self.result_label = QLabel("--")
self.result_label.setAlignment(Qt.AlignCenter)
self.result_label.setStyleSheet(
"font-size: 24px; font-weight: bold; padding: 10px;"
)
result_layout.addWidget(self.result_label)
result_layout.addWidget(QLabel("非线性误差:"))
self.nonlinearity_label = QLabel("-- %")
result_layout.addWidget(self.nonlinearity_label)
result_layout.addWidget(QLabel("迟滞误差:"))
self.hysteresis_label = QLabel("-- %")
result_layout.addWidget(self.hysteresis_label)
result_layout.addWidget(QLabel("重复性误差:"))
self.repeatability_label = QLabel("-- %")
result_layout.addWidget(self.repeatability_label)
result_layout.addWidget(QLabel("超标详情:"))
self.details_text = QTextEdit()
self.details_text.setReadOnly(True)
self.details_text.setMaximumHeight(60)
result_layout.addWidget(self.details_text)
result_group.setLayout(result_layout)
right_layout.addWidget(result_group)
# 报告生成
report_group = QGroupBox("报告导出")
report_layout = QVBoxLayout()
self.report_btn = QPushButton("📄 生成报告")
self.report_btn.clicked.connect(self._generate_report)
self.report_btn.setEnabled(False)
report_layout.addWidget(self.report_btn)
self.report_status = QLabel("就绪")
report_layout.addWidget(self.report_status)
report_group.setLayout(report_layout)
right_layout.addWidget(report_group)
right_layout.addStretch()
right_panel.setLayout(right_layout)
splitter.addWidget(right_panel)
main_layout.addWidget(splitter)
central.setLayout(main_layout)
# 状态栏
self.statusBar().showMessage("系统就绪 | 安全模式已启用")
def _setup_timers(self) -> None:
"""设置定时器"""
# 状态刷新定时器 (100ms)
self.status_timer = QTimer()
self.status_timer.timeout.connect(self._refresh_status)
self.status_timer.start(100)
def _update_permissions(self) -> None:
"""根据用户权限更新界面"""
role = self._current_user.role
# 工艺员和管理员可以管理配置(简化为界面不可见功能)
# 操作员只能执行测试
def _on_barcode_input(self) -> None:
"""处理条码输入"""
barcode = self.barcode_input.text().strip()
if not barcode:
return
# 查找配置
config = self._sensor_config.get_config(barcode)
if config:
self.barcode_info.setText(
f"已识别: {config.model_id}\n"
f"量程: {config.range_min}-{config.range_max} MPa\n"
f"测试点数: {config.test_points} | 循环: {config.cycles}"
)
# 加载测试计划
if self._test_engine.load_test_plan(barcode):
self.statusBar().showMessage(f"测试计划已加载: {barcode}")
self.start_btn.setEnabled(True)
else:
self.barcode_info.setText("加载测试计划失败")
else:
self.barcode_info.setText(f"未找到型号配置: {barcode}")
QMessageBox.warning(self, "未识别的条码",
f"传感器条码 '{barcode}' 未在配置表中找到。")
def _start_test(self) -> None:
"""启动测试"""
if not self._test_engine.start_test():
QMessageBox.warning(self, "启动失败", "无法启动测试,请先加载测试计划。")
return
# 启动采集线程
self._acq_thread = QThread()
self._acq_worker = AcquisitionWorker(self._test_engine)
self._acq_worker.moveToThread(self._acq_thread)
self._acq_worker.data_acquired.connect(self._on_data_acquired)
self._acq_worker.status_changed.connect(self._refresh_ui)
self._acq_worker.test_completed.connect(self._on_test_completed)
self._acq_thread.started.connect(self._acq_worker.start_acquisition)
self._acq_thread.start()
# 更新按钮状态
self.start_btn.setEnabled(False)
self.pause_btn.setEnabled(True)
self.stop_btn.setEnabled(True)
self.skip_btn.setEnabled(True)
self.load_btn.setEnabled(False)
self.statusBar().showMessage("测试进行中...")
def _pause_test(self) -> None:
"""暂停测试"""
if self._test_engine.pause_test():
self.pause_btn.setText("▶ 继续")
self.statusBar().showMessage("测试已暂停")
else:
# 如果当前是暂停状态,点击继续
if self._test_engine.resume_test():
self.pause_btn.setText("⏸ 暂停")
self.statusBar().showMessage("测试已继续")
def _stop_test(self) -> None:
"""停止测试"""
self._test_engine.stop_test()
if self._acq_worker:
self._acq_worker.stop()
if self._acq_thread:
self._acq_thread.quit()
self._acq_thread.wait()
self.start_btn.setEnabled(True)
self.pause_btn.setEnabled(False)
self.stop_btn.setEnabled(False)
self.skip_btn.setEnabled(False)
self.load_btn.setEnabled(True)
self.pause_btn.setText("⏸ 暂停")
self.statusBar().showMessage("测试已停止")
def _skip_point(self) -> None:
"""跳步到下一个测试点"""
ctx = self._test_engine.context
new_idx = ctx.current_point_index + 1
if self._test_engine.skip_to_point(new_idx):
self.statusBar().showMessage(f"跳转到第 {new_idx + 1} 个测试点")
else:
self.statusBar().showMessage("跳步失败")
def _on_data_acquired(self, point) -> None:
"""处理采集到的数据点"""
# 更新实时数值
self.target_pressure_label.setText(f"{point.pressure:.4f} MPa")
self.actual_pressure_label.setText(f"{point.pressure:.4f} MPa")
self.output_label.setText(f"{point.output:.6f}")
# 更新报警状态
ctx = self._test_engine.context
if ctx.status_flags["alarm_active"]:
self.alarm_label.setText("● ALARM")
self.alarm_label.setStyleSheet("color: red; font-size: 16px; font-weight: bold;")
else:
self.alarm_label.setText("● NORMAL")
self.alarm_label.setStyleSheet("color: green; font-size: 16px; font-weight: bold;")
def _refresh_ui(self) -> None:
"""刷新UI状态"""
ctx = self._test_engine.context
config = self._sensor_config.get_config(ctx.current_model)
# 更新进度
total_points = len(ctx.pressure_sequence)
if total_points > 0:
progress = int(ctx.current_point_index / total_points * 100)
self.point_progress.setValue(progress)
if config:
total_cycles = config.cycles
total = total_points * total_cycles
current = ctx.current_point_index + (ctx.current_cycle - 1) * total_points
if total > 0:
overall = int(current / total * 100)
self.total_progress.setValue(overall)
self.cycle_label.setText(f"{ctx.current_cycle} / {total_cycles}")
# 更新数据表仅显示最近10条
all_data = ctx.acquisition_buffer.get_all()
recent = all_data[-20:] if len(all_data) > 20 else all_data
self.data_table.setRowCount(len(recent))
for i, dp in enumerate(recent):
self.data_table.setItem(i, 0, QTableWidgetItem(f"{dp.pressure:.4f}"))
self.data_table.setItem(i, 1, QTableWidgetItem(f"{dp.output:.6f}"))
self.data_table.setItem(i, 2, QTableWidgetItem(f"{dp.timestamp:.3f}"))
# 更新曲线文本
if len(all_data) > 0:
display_data = all_data[-50:] if len(all_data) > 50 else all_data
text = "压力-输出曲线数据点:\n"
for dp in display_data:
bar_len = int(abs(dp.output) * 10) if dp.output else 1
bar = "" * min(bar_len, 40)
text += f"{dp.pressure:8.4f} MPa | {bar} {dp.output:.4f}\n"
self.plot_widget.setText(text)
def _refresh_status(self) -> None:
"""定时刷新系统状态"""
status = self._test_engine.system_status
if status.system_mode == SystemMode.NA:
self.status_indicator.setText("● 离线模式")
self.status_indicator.setStyleSheet("color: red; font-weight: bold;")
elif status.system_mode == SystemMode.NORMAL:
self.status_indicator.setText("● 系统正常")
self.status_indicator.setStyleSheet("color: green; font-weight: bold;")
else:
self.status_indicator.setText("● 维护模式")
self.status_indicator.setStyleSheet("color: orange; font-weight: bold;")
def _on_test_completed(self, data_points) -> None:
"""测试完成回调"""
self.statusBar().showMessage("测试完成,正在计算判定结果...")
# 停止采集线程
if self._acq_worker:
self._acq_worker.stop()
if self._acq_thread:
self._acq_thread.quit()
self._acq_thread.wait()
# 计算判定结果
config = self._sensor_config.get_config(
self._test_engine.context.current_model
)
if config:
self._last_judgment = self._calc_service.calculate(
data_points, config
)
self._display_judgment(self._last_judgment)
self.report_btn.setEnabled(True)
# 重置按钮
self.start_btn.setEnabled(True)
self.pause_btn.setEnabled(False)
self.stop_btn.setEnabled(False)
self.skip_btn.setEnabled(False)
self.load_btn.setEnabled(True)
self.pause_btn.setText("⏸ 暂停")
def _display_judgment(self, judgment: JudgmentResult) -> None:
"""
显示判定结果
Args:
judgment: 判定结果对象
"""
if judgment.result == TestResult.PASS:
self.result_label.setText("✓ PASS")
self.result_label.setStyleSheet(
"font-size: 24px; font-weight: bold; padding: 10px; "
"color: green; background-color: #e8f5e8;"
)
else:
self.result_label.setText("✗ FAIL")
self.result_label.setStyleSheet(
"font-size: 24px; font-weight: bold; padding: 10px; "
"color: red; background-color: #fde8e8;"
)
self.nonlinearity_label.setText(f"{judgment.non_linearity:.4f} %")
self.hysteresis_label.setText(f"{judgment.hysteresis:.4f} %")
self.repeatability_label.setText(f"{judgment.repeatability:.4f} %")
self.details_text.setText(judgment.details if judgment.details else "无异常")
def _generate_report(self) -> None:
"""生成测试报告"""
if not self._last_judgment:
QMessageBox.warning(self, "无测试数据", "请先完成一次测试。")
return
self.report_status.setText("正在生成报告...")
self.report_btn.setEnabled(False)
try:
# 生成报告
report = self._report_service.generate_report(
self._test_engine.context,
self._last_judgment,
self._current_user.user_id
)
# 生成PDF
pdf_data = self._report_service.generate_pdf_report(report)
# 生成CSV
csv_data = self._report_service.generate_csv_data(report)
self.report_status.setText(
f"报告已生成: {report.test_id}\n"
f"PDF: {len(pdf_data)} 字节 | CSV: {len(csv_data)} 字节\n"
f"SM3: {report.sm3_hash[:16]}..."
)
QMessageBox.information(
self, "报告生成成功",
f"测试报告已生成\n"
f"测试编号: {report.test_id}\n"
f"SM3哈希: {report.sm3_hash}\n"
f"数字水印已嵌入\n\n"
f"在实际系统中报告将通过认证U盘导出。"
)
except Exception as e:
self.report_status.setText(f"报告生成失败: {str(e)}")
QMessageBox.critical(self, "报告生成失败", str(e))
finally:
self.report_btn.setEnabled(True)
def _do_logout(self) -> None:
"""注销当前用户"""
self._test_engine.shutdown()
self._security.logout()
self.close()
# =============================================================================
# 应用入口
# =============================================================================
def main():
"""
应用主入口函数
启动传感器测试设备软件,显示登录界面。
"""
app = QApplication(sys.argv)
app.setApplicationName("传感器测试设备软件")
app.setApplicationVersion("1.0.0")
# 设置全局样式
app.setStyle("Fusion")
palette = QPalette()
palette.setColor(QPalette.Window, QColor(240, 240, 240))
app.setPalette(palette)
# 初始化安全服务
security = SecurityService()
# 显示登录对话框
login = LoginDialog(security)
if login.exec_() == QDialog.Accepted:
user = security.get_current_user()
if user:
window = MainWindow(user)
window.show()
sys.exit(app.exec_())
else:
sys.exit(0)
if __name__ == "__main__":
main()