新增统计栏/过滤/预设/深色主题/托盘/DPI适配/发送文件/RTS-DTR,修复配置保存与弹窗置顶

This commit is contained in:
2026-08-11 10:42:16 +08:00
parent b43249fdf8
commit 5a0341699f
3 changed files with 496 additions and 67 deletions
+4
View File
@@ -16,3 +16,7 @@ network_debugger_repaired.py
clean_consts.txt clean_consts.txt
mod_doc.txt mod_doc.txt
docs.json docs.json
# 运行时生成的应用配置
app_config.json
*_profiles.json
+43 -24
View File
@@ -1,31 +1,50 @@
# 修改事项 # 修改事项
以下为本次相对于 e9b3100(初始提交)的改动记录。 以下为相对于上一提交(b43249f)的改动记录。
## 1. 接收区字体大小:Ctrl + 鼠标滚轮调整 ## 1. 收发统计栏
- 新增 `RecvEdit` 类(继承 `QPlainTextEdit`),重写 `wheelEvent` - 底部新增统计标签,实时显示 **TX/RX 的字节数与帧数**
按住 **Ctrl + 滚轮** 即可放大/缩小接收区字体(范围 8~40pt)。
- 接收区改用 `RecvEdit` 并通过 `QFont` 设置字体,替换原来的 stylesheet 方式。
## 2. 导出/导入配置扩展 ## 2. 接收区右键菜单
- `export_config` 现在经 `collect_config` 导出以下内容: - 新增 `RecvEdit.contextMenuEvent`:复制、全选、**另存为**、清空。
- 接收设置:显示格式(ASCII/HEX)、时间戳、INFO 勾选; - "另存为"将接收区全部内容写入文本文件。
- 发送设置:内容格式(文本/HEX)、回车发送、回车换行、定时发送及间隔;
- 应答设置:自动应答、解析格式、回应格式勾选;
- 字体大小、应答规则、批量发送。
- **保存日志(chk_save_log)不参与导出/导入**,避免日志目录误恢复。
- `import_config` 通过 `apply_config` 统一恢复上述设置,兼容旧的 `reply_rules` / `batch_rows` 配置文件格式。
## 3. 关闭自动保存、重启自动恢复配置 ## 3. 发送区 Ctrl+滚轮调字号
- `MainWindow` 持有串口/TCP/UDP 三个面板引用 - `SendEdit` 支持 Ctrl+滚轮调整字号(8~40pt),字号随配置持久化
- `closeEvent` 时把各面板配置写入同目录 `app_config.json`
- 启动时自动读取并恢复,无需每次手动导入配置。
## 4. TCP 服务端发送改为广播所有客户端 ## 4. 关键字过滤
- `NetPanel.actual_send` 服务端分支不再限定于下拉框选中的客户端,改为发送给**所有已连接客户端** - 接收设置组新增"过滤"勾选 + 关键字输入框:启用后只显示包含关键字的行
- 过滤状态与关键字随配置保存/恢复。
## 5. 窗口置顶功能 ## 5. 发送历史持久化
- 新增顶栏"窗口置顶"勾选框 - 发送历史(最近 200 条)随 `app_config.json` 保存,重启后可用 ↑↓ 键继续浏览
- 使用 Windows `SetWindowPos` API(Win32)调整 z 序实现置顶,无闪烁;
为 API 声明了 `argtypes` 防止 64 位句柄被截断,API 失败时回退到 `setWindowFlag` ## 6. 多套配置预设(Profile
- 置顶状态随 `app_config.json` 自动保存/恢复。 - 顶栏"预设"下拉 + "保存预设/删除预设"按钮,可一键切换整套配置。
- 预设保存于 `network_debugger_profiles.json`
## 7. 深色主题切换
- 顶栏"深色模式"勾选框,基于 Fusion 风格切换亮/暗色板,状态持久化。
## 8. DPI 适配
- 启动时设置高分屏缩放舍入策略 `PassThrough`,避免高分屏模糊。
## 9. 串口 RTS/DTR 控制 + 发送文件
- 串口面板新增 RTS/DTR 勾选,打开串口时生效,随配置保存。
- 新增"发送文件"按钮:读取文件按 4KB 分块发送,避免阻塞界面。
## 10. 接收区性能优化
- 接收区改为**批量插入 + 定时(120ms)刷新**,高频率收发不再逐行刷新导致卡顿。
## 11. 窗口置顶修复:弹窗继承置顶
- 新增 `inherit_topmost()`:主窗口置顶时,批量发送/应答规则/自动应答弹窗自动带上置顶标志,避免被主窗口遮住。
- 修复 `BatchSendDialog` 未传 `parent` 导致无法继承置顶的问题。
## 12. 最小化到托盘
- 顶栏"最小化到托盘"勾选:勾选后显示托盘图标,**最小化或关闭**均隐藏到托盘,托盘菜单可"显示主界面/退出"。
- 修复托盘图标未调用 `show()` 导致功能无效的问题。
## 13. 配置保存修复与自动保存
- 修复残留隐藏 `app_config.json` 被锁定导致保存 `PermissionError`(静默失败)的问题。
- 新增每 30 秒自动保存兜底,异常退出也不易丢配置。
- 删除单实例锁,**默认支持多开**;`app_config.json` 加入 `.gitignore`
+448 -42
View File
@@ -18,15 +18,15 @@ from datetime import datetime
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports
from PySide6.QtCore import Qt, QTimer, Signal, QSize, QRect, QPoint from PySide6.QtCore import Qt, QTimer, Signal, QSize, QRect, QPoint, QEvent
from PySide6.QtGui import QFont, QColor, QTextCharFormat, QTextCursor, QIcon from PySide6.QtGui import QFont, QColor, QTextCharFormat, QTextCursor, QIcon, QPalette
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout,
QGridLayout, QLabel, QComboBox, QLineEdit, QPushButton, QDialog, QGridLayout, QLabel, QComboBox, QLineEdit, QPushButton, QDialog,
QPlainTextEdit, QCheckBox, QSpinBox, QSplitter, QMessageBox, QPlainTextEdit, QCheckBox, QSpinBox, QSplitter, QMessageBox,
QTableWidget, QTableWidgetItem, QListWidget, QListWidgetItem, QTableWidget, QTableWidgetItem, QListWidget, QListWidgetItem,
QGroupBox, QRadioButton, QHeaderView, QFileDialog, QWidgetItem, QLayout, QGroupBox, QRadioButton, QHeaderView, QFileDialog, QWidgetItem, QLayout,
QToolBar, QToolBar, QMenu, QInputDialog, QSystemTrayIcon,
) )
STAMP_OPTS = ['', 'ASCII(YYYY-MM-DD HH:MM:SS)', 'ASCII(HH:MM:SS)', STAMP_OPTS = ['', 'ASCII(YYYY-MM-DD HH:MM:SS)', 'ASCII(HH:MM:SS)',
@@ -41,6 +41,16 @@ WIDTH_OPTS = ['1', '2', '4']
ENDIAN_OPTS = ['小端', '大端'] ENDIAN_OPTS = ['小端', '大端']
def inherit_topmost(dialog):
"""若主窗口处于置顶状态,则让弹出的对话框也置顶,避免被主窗口遮住"""
p = dialog.parentWidget()
while p is not None:
if getattr(p, '_topmost_active', False):
dialog.setWindowFlag(Qt.WindowStaysOnTopHint, True)
return
p = p.parentWidget()
class FlowLayout(QLayout): class FlowLayout(QLayout):
"""可自动换行的流式布局:窗口过窄时控件自动换到下一行,不会显示不全""" """可自动换行的流式布局:窗口过窄时控件自动换到下一行,不会显示不全"""
@@ -117,11 +127,30 @@ class FlowLayout(QLayout):
class SendEdit(QPlainTextEdit): class SendEdit(QPlainTextEdit):
"""回车即发送的发送框,支持↑↓键浏览发送历史""" """回车即发送的发送框,支持↑↓键浏览发送历史Ctrl+滚轮调整字号"""
enterPressed = Signal() enterPressed = Signal()
histNav = Signal(int) histNav = Signal(int)
def __init__(self, parent=None):
super().__init__(parent)
self._font_size = 11
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
delta = event.angleDelta().y()
if delta:
self._font_size = max(8, min(40, self._font_size + (1 if delta > 0 else -1)))
font = self.font()
font.setPointSize(self._font_size)
self.setFont(font)
event.accept()
return
super().wheelEvent(event)
def font_size(self):
return self._font_size
def keyPressEvent(self, e): def keyPressEvent(self, e):
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier): if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
self.enterPressed.emit() self.enterPressed.emit()
@@ -134,25 +163,18 @@ class SendEdit(QPlainTextEdit):
class RecvEdit(QPlainTextEdit): class RecvEdit(QPlainTextEdit):
"""接收区编辑框:Ctrl+滚轮 调整字体大小""" """接收区编辑框:Ctrl+滚轮 调整字体大小,右键菜单"""
MIN_FONT = 8 MIN_FONT = 8
MAX_FONT = 40 MAX_FONT = 40
DEFAULT_FONT = 11 DEFAULT_FONT = 11
saveRequested = Signal()
clearRequested = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._font_size = self.DEFAULT_FONT self._font_size = self.DEFAULT_FONT
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
delta = event.angleDelta().y()
if delta:
self.set_font_size(self._font_size + (1 if delta > 0 else -1))
event.accept()
return
super().wheelEvent(event)
def set_font_size(self, size): def set_font_size(self, size):
self._font_size = max(self.MIN_FONT, min(self.MAX_FONT, int(size))) self._font_size = max(self.MIN_FONT, min(self.MAX_FONT, int(size)))
font = self.font() font = self.font()
@@ -162,6 +184,26 @@ class RecvEdit(QPlainTextEdit):
def font_size(self): def font_size(self):
return self._font_size return self._font_size
def contextMenuEvent(self, event):
menu = self.createStandardContextMenu()
menu.addSeparator()
act_save = menu.addAction('另存为…')
act_clear = menu.addAction('清空')
ch = menu.exec(event.globalPos())
if ch == act_save:
self.saveRequested.emit()
elif ch == act_clear:
self.clearRequested.emit()
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
delta = event.angleDelta().y()
if delta:
self.set_font_size(self.font_size() + (1 if delta > 0 else -1))
event.accept()
return
super().wheelEvent(event)
class NoWheelSpinBox(QSpinBox): class NoWheelSpinBox(QSpinBox):
def wheelEvent(self, event): def wheelEvent(self, event):
@@ -519,6 +561,7 @@ def write_field(ftype, widget, field):
class RuleEditDialog(QDialog): class RuleEditDialog(QDialog):
def __init__(self, parent=None, rule=None): def __init__(self, parent=None, rule=None):
super().__init__(parent) super().__init__(parent)
inherit_topmost(self)
self.setWindowTitle('应答规则') self.setWindowTitle('应答规则')
self.setMinimumWidth(800) self.setMinimumWidth(800)
v = QVBoxLayout(self) v = QVBoxLayout(self)
@@ -673,6 +716,7 @@ class RuleEditDialog(QDialog):
class AutoReplyManagerDialog(QDialog): class AutoReplyManagerDialog(QDialog):
def __init__(self, rules, parent=None): def __init__(self, rules, parent=None):
super().__init__(parent) super().__init__(parent)
inherit_topmost(self)
self.setWindowTitle('自动应答规则') self.setWindowTitle('自动应答规则')
self.setMinimumSize(520, 380) self.setMinimumSize(520, 380)
self.rules = [dict(r) for r in rules] self.rules = [dict(r) for r in rules]
@@ -784,6 +828,7 @@ class BatchSendDialog(QDialog):
def __init__(self, panel, parent=None): def __init__(self, panel, parent=None):
super().__init__(parent) super().__init__(parent)
inherit_topmost(self)
self.panel = panel self.panel = panel
self.setWindowTitle('批量发送') self.setWindowTitle('批量发送')
self.setMinimumSize(560, 360) self.setMinimumSize(560, 360)
@@ -1003,6 +1048,16 @@ class BasePanel(QWidget):
self._log_hour = None self._log_hour = None
self.log_rotate_timer = QTimer(self) self.log_rotate_timer = QTimer(self)
self.log_rotate_timer.timeout.connect(self._rotate_log) self.log_rotate_timer.timeout.connect(self._rotate_log)
self._stat_tx = 0
self._stat_rx = 0
self._stat_tx_frames = 0
self._stat_rx_frames = 0
self._filter_enabled = False
self._filter_text = ''
self._append_buffer = []
self._flush_timer = QTimer(self)
self._flush_timer.setSingleShot(True)
self._flush_timer.timeout.connect(self._flush_pending)
self.root = QVBoxLayout(self) self.root = QVBoxLayout(self)
@@ -1021,6 +1076,8 @@ class BasePanel(QWidget):
self.recv.setReadOnly(True) self.recv.setReadOnly(True)
self.recv.setMaximumBlockCount(2000) self.recv.setMaximumBlockCount(2000)
self.recv.setFont(QFont('Consolas', RecvEdit.DEFAULT_FONT)) self.recv.setFont(QFont('Consolas', RecvEdit.DEFAULT_FONT))
self.recv.saveRequested.connect(self.save_received_log)
self.recv.clearRequested.connect(self.recv.clear)
rv.addWidget(self.recv, 1) rv.addWidget(self.recv, 1)
splitter.addWidget(recv_widget) splitter.addWidget(recv_widget)
@@ -1030,7 +1087,7 @@ class BasePanel(QWidget):
sv.addWidget(QLabel('发送区')) sv.addWidget(QLabel('发送区'))
self.send = SendEdit() self.send = SendEdit()
self.send.setMaximumBlockCount(2000) self.send.setMaximumBlockCount(2000)
self.send.setStyleSheet('QPlainTextEdit{font-family:Consolas;font-size:11pt}') self.send.setFont(QFont('Consolas', 11))
self.send.enterPressed.connect(self.on_enter_pressed) self.send.enterPressed.connect(self.on_enter_pressed)
self.send.histNav.connect(self.history_nav) self.send.histNav.connect(self.history_nav)
sv.addWidget(self.send) sv.addWidget(self.send)
@@ -1069,6 +1126,15 @@ class BasePanel(QWidget):
self.chk_info = QCheckBox('INFO') self.chk_info = QCheckBox('INFO')
self.chk_info.setChecked(True) self.chk_info.setChecked(True)
hr.addWidget(self.chk_info) hr.addWidget(self.chk_info)
self.chk_filter = QCheckBox('过滤')
self.chk_filter.toggled.connect(self._on_filter_toggled)
self.edit_filter = QLineEdit()
self.edit_filter.setPlaceholderText('关键字')
self.edit_filter.setEnabled(False)
self.edit_filter.setMaximumWidth(130)
self.edit_filter.textChanged.connect(self._on_filter_text)
hr.addWidget(self.chk_filter)
hr.addWidget(self.edit_filter)
self.chk_save_log = QCheckBox('保存日志') self.chk_save_log = QCheckBox('保存日志')
self.chk_save_log.toggled.connect(self.toggle_save_log) self.chk_save_log.toggled.connect(self.toggle_save_log)
hr.addWidget(self.chk_save_log) hr.addWidget(self.chk_save_log)
@@ -1134,19 +1200,24 @@ class BasePanel(QWidget):
self.btn_import.clicked.connect(self.import_config) self.btn_import.clicked.connect(self.import_config)
self.btn_send = QPushButton('发送') self.btn_send = QPushButton('发送')
self.btn_batch = QPushButton('批量发送') self.btn_batch = QPushButton('批量发送')
self.btn_send_file = QPushButton('发送文件')
self.btn_clear_recv = QPushButton('清空接收') self.btn_clear_recv = QPushButton('清空接收')
self.btn_clear_send = QPushButton('清空发送') self.btn_clear_send = QPushButton('清空发送')
self.btn_send.clicked.connect(self.doSend) self.btn_send.clicked.connect(self.doSend)
self.btn_batch.clicked.connect(self.open_batch) self.btn_batch.clicked.connect(self.open_batch)
self.btn_send_file.clicked.connect(self.send_file)
self.btn_clear_recv.clicked.connect(self.recv.clear) self.btn_clear_recv.clicked.connect(self.recv.clear)
self.btn_clear_send.clicked.connect(self.send.clear) self.btn_clear_send.clicked.connect(self.send.clear)
btn_row.addWidget(self.btn_export) btn_row.addWidget(self.btn_export)
btn_row.addWidget(self.btn_import) btn_row.addWidget(self.btn_import)
btn_row.addWidget(self.btn_send) btn_row.addWidget(self.btn_send)
btn_row.addWidget(self.btn_batch) btn_row.addWidget(self.btn_batch)
btn_row.addWidget(self.btn_send_file)
btn_row.addWidget(self.btn_clear_recv) btn_row.addWidget(self.btn_clear_recv)
btn_row.addWidget(self.btn_clear_send) btn_row.addWidget(self.btn_clear_send)
btn_row.addStretch() btn_row.addStretch()
self.stat_label = QLabel('TX: 0 B / 0 帧 RX: 0 B / 0 帧')
btn_row.addWidget(self.stat_label)
self.root.addLayout(btn_row) self.root.addLayout(btn_row)
self.setWindowTitle(title) self.setWindowTitle(title)
@@ -1159,7 +1230,7 @@ class BasePanel(QWidget):
self.doSend() self.doSend()
def open_batch(self): def open_batch(self):
dlg = BatchSendDialog(self) dlg = BatchSendDialog(self, parent=self)
if self.batch_rows: if self.batch_rows:
dlg.import_config(self.batch_rows) dlg.import_config(self.batch_rows)
dlg.exec() dlg.exec()
@@ -1188,22 +1259,67 @@ class BasePanel(QWidget):
payload = bytes_to_hex(data) payload = bytes_to_hex(data)
else: else:
payload = data.decode('utf-8', 'replace') payload = data.decode('utf-8', 'replace')
if direction == 'TX':
self._stat_tx += len(data)
self._stat_tx_frames += 1
else:
self._stat_rx += len(data)
self._stat_rx_frames += 1
self._update_stats()
if self._filter_enabled and self._filter_text \
and self._filter_text.lower() not in payload.lower():
return
text = f'{ts()}' if self.chk_ts.isChecked() else '' text = f'{ts()}' if self.chk_ts.isChecked() else ''
color = 'blue' if direction == 'TX' else 'green' color = 'blue' if direction == 'TX' else 'green'
self._append_line(f'[{text}] {direction}: {payload}' if text else f'{direction}: {payload}', color) self._append_line(f'[{text}] {direction}: {payload}' if text else f'{direction}: {payload}', color)
def _append_colored(self, text: str, color: str): def _update_stats(self):
self.stat_label.setText(
f'TX: {self._stat_tx} B / {self._stat_tx_frames}'
f'RX: {self._stat_rx} B / {self._stat_rx_frames}')
def _flush_pending(self):
if not self._append_buffer:
return
cur = self.recv.textCursor() cur = self.recv.textCursor()
cur.movePosition(QTextCursor.End) cur.movePosition(QTextCursor.End)
if color: fmt_cache = {}
fmt = QTextCharFormat() for text, color in self._append_buffer:
fmt.setForeground(QColor(color)) if color not in fmt_cache:
cur.setCharFormat(fmt) f = QTextCharFormat()
cur.insertText(text + '\n') f.setForeground(QColor(color))
fmt_cache[color] = f
cur.setCharFormat(fmt_cache[color])
cur.insertText(text + '\n')
self._append_buffer.clear()
self.recv.setTextCursor(cur) self.recv.setTextCursor(cur)
bar = self.recv.verticalScrollBar() bar = self.recv.verticalScrollBar()
bar.setValue(bar.maximum()) bar.setValue(bar.maximum())
def _append_colored(self, text: str, color: str):
self._append_buffer.append((text, color))
if not self._flush_timer.isActive():
self._flush_timer.start(120)
def _on_filter_toggled(self, checked: bool):
self._filter_enabled = checked
self.edit_filter.setEnabled(checked)
def _on_filter_text(self, text: str):
self._filter_text = text
def save_received_log(self):
path, _ = QFileDialog.getSaveFileName(self, '保存接收内容', 'recv.txt',
'文本文件 (*.txt);;所有文件 (*)')
if not path:
return
try:
with open(path, 'w', encoding='utf-8') as f:
f.write(self.recv.toPlainText())
self.info(f'接收内容已保存: {path}')
except OSError as e:
self.info(f'保存失败: {e}')
def _append_line(self, text: str, color: str = None): def _append_line(self, text: str, color: str = None):
self._append_colored(text, color) self._append_colored(text, color)
if self._log_fh or self._log_dir: if self._log_fh or self._log_dir:
@@ -1263,6 +1379,36 @@ class BasePanel(QWidget):
def actual_send(self, data: bytes) -> bool: def actual_send(self, data: bytes) -> bool:
raise NotImplementedError raise NotImplementedError
def send_file(self):
path, _ = QFileDialog.getOpenFileName(self, '选择要发送的文件', '')
if not path:
return
try:
with open(path, 'rb') as f:
data = f.read()
except OSError as e:
QMessageBox.warning(self, '错误', f'读取文件失败:\n{e}')
return
if not data:
self.info('文件内容为空,未发送')
return
self._file_total = len(data)
self.info(f'开始发送文件({len(data)} B): {path}')
threading.Thread(target=self._send_file_worker, args=(data,),
daemon=True).start()
def _send_file_worker(self, data: bytes):
sent = 0
for i in range(0, len(data), 4096):
part = data[i:i + 4096]
if not self.actual_send(part):
self.info('发送文件已中止')
return
sent += len(part)
self.log_line('TX', part)
time.sleep(0.01)
self.info(f'文件发送完成,共 {sent} B')
def send_reply(self, reply: bytes, sock=None, addr=None): def send_reply(self, reply: bytes, sock=None, addr=None):
raise NotImplementedError raise NotImplementedError
@@ -1389,6 +1535,8 @@ class BasePanel(QWidget):
'display': 'HEX' if self.rb_recv_hex.isChecked() else 'ASCII', 'display': 'HEX' if self.rb_recv_hex.isChecked() else 'ASCII',
'ts': self.chk_ts.isChecked(), 'ts': self.chk_ts.isChecked(),
'info': self.chk_info.isChecked(), 'info': self.chk_info.isChecked(),
'filter': self._filter_enabled,
'filter_text': self._filter_text,
}, },
'send': { 'send': {
'format': 'HEX' if self.rb_send_hex.isChecked() else '文本', 'format': 'HEX' if self.rb_send_hex.isChecked() else '文本',
@@ -1396,6 +1544,7 @@ class BasePanel(QWidget):
'crlf': self.chk_crlf.isChecked(), 'crlf': self.chk_crlf.isChecked(),
'auto': self.chk_auto.isChecked(), 'auto': self.chk_auto.isChecked(),
'interval': self.spin_interval.value(), 'interval': self.spin_interval.value(),
'font_size': self.send.font_size(),
}, },
'reply': { 'reply': {
'enabled': self.chk_reply.isChecked(), 'enabled': self.chk_reply.isChecked(),
@@ -1403,6 +1552,7 @@ class BasePanel(QWidget):
'respond': 'HEX' if self.rb_reply_hex.isChecked() else 'ASCII', 'respond': 'HEX' if self.rb_reply_hex.isChecked() else 'ASCII',
}, },
'font_size': self.recv.font_size(), 'font_size': self.recv.font_size(),
'send_history': self.send_history[-200:],
'reply_rules': copy.deepcopy(self.reply_rules), 'reply_rules': copy.deepcopy(self.reply_rules),
'batch_rows': copy.deepcopy(self.batch_rows), 'batch_rows': copy.deepcopy(self.batch_rows),
} }
@@ -1420,6 +1570,11 @@ class BasePanel(QWidget):
self.chk_ts.setChecked(bool(recv['ts'])) self.chk_ts.setChecked(bool(recv['ts']))
if 'info' in recv: if 'info' in recv:
self.chk_info.setChecked(bool(recv['info'])) self.chk_info.setChecked(bool(recv['info']))
if 'filter' in recv:
self.chk_filter.setChecked(bool(recv['filter']))
self.edit_filter.setEnabled(bool(recv['filter']))
if 'filter_text' in recv:
self.edit_filter.setText(recv.get('filter_text', ''))
msg.append('接收设置') msg.append('接收设置')
send = cfg.get('send') send = cfg.get('send')
if isinstance(send, dict): if isinstance(send, dict):
@@ -1434,6 +1589,16 @@ class BasePanel(QWidget):
self.chk_auto.setChecked(bool(send['auto'])) self.chk_auto.setChecked(bool(send['auto']))
if 'interval' in send: if 'interval' in send:
self.spin_interval.setValue(int(send['interval'])) self.spin_interval.setValue(int(send['interval']))
if 'font_size' in send:
try:
size = max(8, min(40, int(send['font_size'])))
f = self.send.font()
f.setPointSize(size)
self.send.setFont(f)
if hasattr(self.send, '_font_size'):
self.send._font_size = size
except (TypeError, ValueError):
pass
msg.append('发送设置') msg.append('发送设置')
reply = cfg.get('reply') reply = cfg.get('reply')
if isinstance(reply, dict): if isinstance(reply, dict):
@@ -1452,6 +1617,10 @@ class BasePanel(QWidget):
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
msg.append('字体大小') msg.append('字体大小')
if 'send_history' in cfg and isinstance(cfg['send_history'], list):
self.send_history = [str(x) for x in cfg['send_history']][-200:]
self._hist_idx = len(self.send_history)
msg.append(f'发送历史 {len(self.send_history)}')
rr = cfg.get('reply_rules') rr = cfg.get('reply_rules')
if isinstance(rr, list) and all(isinstance(x, dict) for x in rr): if isinstance(rr, list) and all(isinstance(x, dict) for x in rr):
for r in rr: for r in rr:
@@ -1599,6 +1768,10 @@ class SerialPanel(BasePanel):
self.btn_refresh.clicked.connect(self.refresh_ports) self.btn_refresh.clicked.connect(self.refresh_ports)
self.btn_open = QPushButton('打开串口') self.btn_open = QPushButton('打开串口')
self.btn_open.clicked.connect(self.toggle_serial) self.btn_open.clicked.connect(self.toggle_serial)
self.chk_rts = QCheckBox('RTS')
self.chk_dtr = QCheckBox('DTR')
self.chk_rts.setToolTip('打开串口后控制 RTS 引脚电平')
self.chk_dtr.setToolTip('打开串口后控制 DTR 引脚电平')
g.addWidget(QLabel('串口'), 0, 0) g.addWidget(QLabel('串口'), 0, 0)
g.addWidget(self.combox_port, 0, 1) g.addWidget(self.combox_port, 0, 1)
@@ -1611,7 +1784,9 @@ class SerialPanel(BasePanel):
g.addWidget(self.combox_parity, 1, 4) g.addWidget(self.combox_parity, 1, 4)
g.addWidget(QLabel('停止位'), 2, 0) g.addWidget(QLabel('停止位'), 2, 0)
g.addWidget(self.combox_stop, 2, 1) g.addWidget(self.combox_stop, 2, 1)
g.addWidget(self.btn_open, 2, 3, 1, 2) g.addWidget(self.chk_rts, 2, 3)
g.addWidget(self.chk_dtr, 2, 4)
g.addWidget(self.btn_open, 3, 3, 1, 2)
g.setColumnStretch(1, 1) g.setColumnStretch(1, 1)
g.setColumnStretch(4, 1) g.setColumnStretch(4, 1)
w.setLayout(g) w.setLayout(g)
@@ -1646,6 +1821,11 @@ class SerialPanel(BasePanel):
stopbits=STOPBITS[self.combox_stop.currentText()], stopbits=STOPBITS[self.combox_stop.currentText()],
timeout=0.1, timeout=0.1,
) )
try:
self.ser.rts = self.chk_rts.isChecked()
self.ser.dtr = self.chk_dtr.isChecked()
except Exception:
pass
self.btn_open.setText('关闭串口') self.btn_open.setText('关闭串口')
self.combox_port.setEnabled(False) self.combox_port.setEnabled(False)
self.info(f'串口 {port} 已打开') self.info(f'串口 {port} 已打开')
@@ -1696,6 +1876,21 @@ class SerialPanel(BasePanel):
QMessageBox.warning(self, '错误', f'发送失败\n{e}') QMessageBox.warning(self, '错误', f'发送失败\n{e}')
return False return False
def collect_config(self):
cfg = super().collect_config()
cfg['rts'] = self.chk_rts.isChecked()
cfg['dtr'] = self.chk_dtr.isChecked()
return cfg
def apply_config(self, cfg):
msg = super().apply_config(cfg)
if isinstance(cfg, dict):
if 'rts' in cfg:
self.chk_rts.setChecked(bool(cfg['rts']))
if 'dtr' in cfg:
self.chk_dtr.setChecked(bool(cfg['dtr']))
return msg
class NetPanel(BasePanel): class NetPanel(BasePanel):
"""TCP/UDP 通用面板,支持客户端与服务器模式""" """TCP/UDP 通用面板,支持客户端与服务器模式"""
@@ -2000,13 +2195,47 @@ class MainWindow(QMainWindow):
self.top_btn = QCheckBox('窗口置顶') self.top_btn = QCheckBox('窗口置顶')
self.top_btn.setToolTip('选中后窗口保持在最前端') self.top_btn.setToolTip('选中后窗口保持在最前端')
self.top_btn.toggled.connect(self._toggle_topmost) self.top_btn.toggled.connect(self._toggle_topmost)
self.chk_dark = QCheckBox('深色模式')
self.chk_dark.setToolTip('切换亮色/暗色主题')
self.chk_dark.toggled.connect(self._toggle_dark)
self.chk_tray = QCheckBox('最小化到托盘')
self.chk_tray.setToolTip('最小化或关闭窗口时隐藏到系统托盘')
self.chk_tray.toggled.connect(self._on_tray_toggled)
tb = QToolBar('选项') tb = QToolBar('选项')
tb.setMovable(False) tb.setMovable(False)
tb.addWidget(self.top_btn) tb.addWidget(self.top_btn)
tb.addWidget(self.chk_dark)
tb.addWidget(self.chk_tray)
tb.addSeparator()
self.combo_profile = NoWheelComboBox()
self.combo_profile.setMinimumWidth(150)
self.combo_profile.currentIndexChanged.connect(self._on_profile_selected)
tb.addWidget(QLabel(' 预设 '))
tb.addWidget(self.combo_profile)
btn_psave = QPushButton('保存预设')
btn_pdel = QPushButton('删除预设')
btn_psave.clicked.connect(self._save_profile)
btn_pdel.clicked.connect(self._del_profile)
tb.addWidget(btn_psave)
tb.addWidget(btn_pdel)
self.addToolBar(tb) self.addToolBar(tb)
self._profiles = {}
self._tray_enabled = False
self._topmost_active = False
self._light_palette = QApplication.instance().palette()
self.tray_icon = None
self._autosave = QTimer(self)
self._autosave.setInterval(30000)
self._autosave.timeout.connect(self._save_app_config)
self._autosave.start()
self._build_tray()
self._load_profiles()
self._load_app_config() self._load_app_config()
def _toggle_topmost(self, on: bool): def _toggle_topmost(self, on: bool):
self._topmost_active = on
try: try:
import ctypes import ctypes
user32 = ctypes.windll.user32 user32 = ctypes.windll.user32
@@ -2035,28 +2264,94 @@ class MainWindow(QMainWindow):
QTimer.singleShot(0, lambda: (self.setWindowFlag(Qt.WindowStaysOnTopHint, on), QTimer.singleShot(0, lambda: (self.setWindowFlag(Qt.WindowStaysOnTopHint, on),
self.show())) self.show()))
def _save_app_config(self): def _toggle_dark(self, on: bool):
data = {'topmost': self.top_btn.isChecked(), app = QApplication.instance()
'tabs': { if on:
'serial': self.serial_panel.collect_config(), p = QPalette()
'tcp': self.tcp_panel.collect_config(), p.setColor(QPalette.Window, QColor('#2b2b2b'))
'udp': self.udp_panel.collect_config(), p.setColor(QPalette.WindowText, QColor('#f0f0f0'))
}} p.setColor(QPalette.Base, QColor('#232323'))
try: p.setColor(QPalette.AlternateBase, QColor('#3c3f41'))
with open(app_config_path(), 'w', encoding='utf-8') as f: p.setColor(QPalette.Text, QColor('#f0f0f0'))
json.dump(data, f, ensure_ascii=False, indent=2) p.setColor(QPalette.Button, QColor('#3c3f41'))
except OSError: p.setColor(QPalette.ButtonText, QColor('#f0f0f0'))
pass p.setColor(QPalette.BrightText, QColor('#ff5555'))
p.setColor(QPalette.Highlight, QColor('#2a82da'))
p.setColor(QPalette.HighlightedText, QColor('#ffffff'))
p.setColor(QPalette.Link, QColor('#2a82da'))
p.setColor(QPalette.ToolTipBase, QColor('#f0f0f0'))
p.setColor(QPalette.ToolTipText, QColor('#2b2b2b'))
p.setColor(QPalette.Disabled, QPalette.Text, QColor('#787878'))
p.setColor(QPalette.Disabled, QPalette.ButtonText, QColor('#787878'))
p.setColor(QPalette.Disabled, QPalette.Base, QColor('#2b2b2b'))
app.setStyle('Fusion')
app.setPalette(p)
else:
app.setStyle('Fusion')
app.setPalette(self._light_palette)
def _load_app_config(self): def _build_tray(self):
try: icon = QIcon(self._icon_path())
with open(app_config_path(), 'r', encoding='utf-8') as f: if icon.isNull():
data = json.load(f) self.tray_icon = None
except Exception:
return return
self.tray_icon = QSystemTrayIcon(icon, self)
menu = QMenu(self)
act_show = menu.addAction('显示主界面')
act_show.triggered.connect(self._show_from_tray)
menu.addSeparator()
act_quit = menu.addAction('退出')
act_quit.triggered.connect(self._quit_app)
self.tray_icon.setContextMenu(menu)
self.tray_icon.activated.connect(self._on_tray_activated)
def _on_tray_toggled(self, checked: bool):
self._tray_enabled = checked
if self.tray_icon:
if checked:
self.tray_icon.show()
else:
self.tray_icon.hide()
def _on_tray_activated(self, reason):
if reason == QSystemTrayIcon.Trigger:
self._show_from_tray()
def _show_from_tray(self):
self.showNormal()
self.show()
self.raise_()
self.activateWindow()
def _quit_app(self):
self._tray_enabled = False
if self.tray_icon:
self.tray_icon.hide()
self.close()
def changeEvent(self, event):
if event.type() == QEvent.WindowStateChange and self.isMinimized() \
and self._tray_enabled and self.tray_icon and self.tray_icon.isVisible():
QTimer.singleShot(0, self.hide)
super().changeEvent(event)
def info(self, text: str):
self.statusBar().showMessage(text, 3000)
def _collect_all_config(self):
return {
'topmost': self.top_btn.isChecked(),
'dark': self.chk_dark.isChecked(),
'tray': self.chk_tray.isChecked(),
'tabs': {
'serial': self.serial_panel.collect_config(),
'tcp': self.tcp_panel.collect_config(),
'udp': self.udp_panel.collect_config(),
},
}
def _apply_panels(self, data):
tabs_cfg = data.get('tabs') if isinstance(data, dict) else None tabs_cfg = data.get('tabs') if isinstance(data, dict) else None
if isinstance(data, dict) and data.get('topmost'):
self.top_btn.setChecked(True)
if not isinstance(tabs_cfg, dict): if not isinstance(tabs_cfg, dict):
return return
for panel, key in ((self.serial_panel, 'serial'), for panel, key in ((self.serial_panel, 'serial'),
@@ -2066,12 +2361,123 @@ class MainWindow(QMainWindow):
if isinstance(cfg, dict): if isinstance(cfg, dict):
panel.apply_config(cfg) panel.apply_config(cfg)
def _save_app_config(self):
try:
with open(app_config_path(), 'w', encoding='utf-8') as f:
json.dump(self._collect_all_config(), f, ensure_ascii=False, indent=2)
except Exception:
pass
def _load_app_config(self):
try:
with open(app_config_path(), 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception:
return
if not isinstance(data, dict):
return
if data.get('topmost'):
self.top_btn.setChecked(True)
if data.get('dark'):
self.chk_dark.setChecked(True)
if data.get('tray'):
self.chk_tray.setChecked(True)
self._apply_panels(data)
def _profile_path(self):
return os.path.splitext(app_config_path())[0] + '_profiles.json'
def _load_profiles(self):
try:
with open(self._profile_path(), 'r', encoding='utf-8') as f:
data = json.load(f)
self._profiles = data if isinstance(data, dict) else {}
except Exception:
self._profiles = {}
self._reload_profiles_combo()
def _write_profiles(self):
try:
with open(self._profile_path(), 'w', encoding='utf-8') as f:
json.dump(self._profiles, f, ensure_ascii=False, indent=2)
except OSError:
pass
def _reload_profiles_combo(self, select=None):
self.combo_profile.blockSignals(True)
self.combo_profile.clear()
self.combo_profile.addItem('← 选择预设 →')
for name in self._profiles:
self.combo_profile.addItem(name)
select = self.combo_profile.currentText() if select is None else select
idx = self.combo_profile.findText(select)
if idx >= 0 and idx != 0:
self.combo_profile.setCurrentIndex(idx)
self.combo_profile.blockSignals(False)
def _on_profile_selected(self, index):
if index <= 0:
return
name = self.combo_profile.currentText()
cfg = self._profiles.get(name)
if not isinstance(cfg, dict):
return
if cfg.get('topmost'):
self.top_btn.setChecked(True)
else:
self.top_btn.setChecked(False)
if cfg.get('dark'):
self.chk_dark.setChecked(True)
else:
self.chk_dark.setChecked(False)
if cfg.get('tray'):
self.chk_tray.setChecked(True)
else:
self.chk_tray.setChecked(False)
self._apply_panels(cfg)
self._save_app_config()
self.info('预设已加载: ' + name)
def _save_profile(self):
text = self.combo_profile.currentText()
if text == '← 选择预设 →':
text = ''
name, ok = QInputDialog.getText(self, '保存预设', '预设名称:', QLineEdit.Normal, text)
if not ok or not name.strip():
return
self._profiles[name.strip()] = self._collect_all_config()
self._write_profiles()
self._reload_profiles_combo(name.strip())
self.info('预设已保存: ' + name.strip())
def _del_profile(self):
name = self.combo_profile.currentText()
if name == '← 选择预设 →' or name not in self._profiles:
return
if QMessageBox.question(self, '删除预设', f'确定删除预设「{name}」吗?') \
!= QMessageBox.Yes:
return
del self._profiles[name]
self._write_profiles()
self._reload_profiles_combo()
self.info('预设已删除: ' + name)
def closeEvent(self, event): def closeEvent(self, event):
self._save_app_config() self._save_app_config()
if self._tray_enabled and self.tray_icon and self.tray_icon.isVisible():
event.ignore()
self.hide()
self.tray_icon.showMessage(APP_NAME, '程序已最小化到托盘',
QSystemTrayIcon.Information, 2000)
return
if self.tray_icon:
self.tray_icon.hide()
super().closeEvent(event) super().closeEvent(event)
if __name__ == '__main__': if __name__ == '__main__':
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName(APP_NAME) app.setApplicationName(APP_NAME)
app.setFont(QFont('Microsoft YaHei', 9)) app.setFont(QFont('Microsoft YaHei', 9))