新增窗口置顶、接收区Ctrl+滚轮调字号、配置自动保存与恢复,TCP服务端发送改为广播所有客户端

This commit is contained in:
2026-08-07 13:07:54 +08:00
parent e9b3100322
commit b43249fdf8
2 changed files with 239 additions and 28 deletions
+31
View File
@@ -0,0 +1,31 @@
# 修改事项
以下为本次相对于 e9b3100(初始提交)的改动记录。
## 1. 接收区字体大小:Ctrl + 鼠标滚轮调整
- 新增 `RecvEdit` 类(继承 `QPlainTextEdit`),重写 `wheelEvent`
按住 **Ctrl + 滚轮** 即可放大/缩小接收区字体(范围 8~40pt)。
- 接收区改用 `RecvEdit` 并通过 `QFont` 设置字体,替换原来的 stylesheet 方式。
## 2. 导出/导入配置扩展
- `export_config` 现在经 `collect_config` 导出以下内容:
- 接收设置:显示格式(ASCII/HEX)、时间戳、INFO 勾选;
- 发送设置:内容格式(文本/HEX)、回车发送、回车换行、定时发送及间隔;
- 应答设置:自动应答、解析格式、回应格式勾选;
- 字体大小、应答规则、批量发送。
- **保存日志(chk_save_log)不参与导出/导入**,避免日志目录误恢复。
- `import_config` 通过 `apply_config` 统一恢复上述设置,兼容旧的 `reply_rules` / `batch_rows` 配置文件格式。
## 3. 关闭自动保存、重启自动恢复配置
- `MainWindow` 持有串口/TCP/UDP 三个面板引用。
- `closeEvent` 时把各面板配置写入同目录 `app_config.json`
- 启动时自动读取并恢复,无需每次手动导入配置。
## 4. TCP 服务端发送改为广播所有客户端
- `NetPanel.actual_send` 服务端分支不再限定于下拉框选中的客户端,改为发送给**所有已连接客户端**。
## 5. 窗口置顶功能
- 新增顶栏"窗口置顶"勾选框。
- 使用 Windows `SetWindowPos` API(Win32)调整 z 序实现置顶,无闪烁;
为 API 声明了 `argtypes` 防止 64 位句柄被截断,API 失败时回退到 `setWindowFlag`
- 置顶状态随 `app_config.json` 自动保存/恢复。
+208 -28
View File
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
QPlainTextEdit, QCheckBox, QSpinBox, QSplitter, QMessageBox,
QTableWidget, QTableWidgetItem, QListWidget, QListWidgetItem,
QGroupBox, QRadioButton, QHeaderView, QFileDialog, QWidgetItem, QLayout,
QToolBar,
)
STAMP_OPTS = ['', 'ASCII(YYYY-MM-DD HH:MM:SS)', 'ASCII(HH:MM:SS)',
@@ -132,6 +133,36 @@ class SendEdit(QPlainTextEdit):
super().keyPressEvent(e)
class RecvEdit(QPlainTextEdit):
"""接收区编辑框:Ctrl+滚轮 调整字体大小"""
MIN_FONT = 8
MAX_FONT = 40
DEFAULT_FONT = 11
def __init__(self, parent=None):
super().__init__(parent)
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):
self._font_size = max(self.MIN_FONT, min(self.MAX_FONT, int(size)))
font = self.font()
font.setPointSize(self._font_size)
self.setFont(font)
def font_size(self):
return self._font_size
class NoWheelSpinBox(QSpinBox):
def wheelEvent(self, event):
event.ignore()
@@ -191,6 +222,17 @@ def match_rule(hex_text: str, data: bytes) -> bool:
return find_match(hex_text, data) != -1
APP_NAME = "网络调试助手"
def app_config_path():
"""返回自动保存配置的文件路径(与主程序同目录)"""
if getattr(sys, 'frozen', False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, 'app_config.json')
BAUDS = ['1200', '2400', '4800', '9600', '19200', '38400', '57600', '115200']
PARITIES = {'无校验': serial.PARITY_NONE, '奇校验': serial.PARITY_ODD,
'偶校验': serial.PARITY_EVEN}
@@ -975,10 +1017,10 @@ class BasePanel(QWidget):
rv = QVBoxLayout(recv_widget)
rv.setContentsMargins(0, 0, 0, 0)
rv.addWidget(QLabel('接收区'))
self.recv = QPlainTextEdit()
self.recv = RecvEdit()
self.recv.setReadOnly(True)
self.recv.setMaximumBlockCount(2000)
self.recv.setStyleSheet('QPlainTextEdit{font-family:Consolas;font-size:11pt}')
self.recv.setFont(QFont('Consolas', RecvEdit.DEFAULT_FONT))
rv.addWidget(self.recv, 1)
splitter.addWidget(recv_widget)
@@ -1341,9 +1383,91 @@ class BasePanel(QWidget):
if dlg.exec() == QDialog.Accepted:
self.reply_rules = dlg.result()
def collect_config(self):
return {
'recv': {
'display': 'HEX' if self.rb_recv_hex.isChecked() else 'ASCII',
'ts': self.chk_ts.isChecked(),
'info': self.chk_info.isChecked(),
},
'send': {
'format': 'HEX' if self.rb_send_hex.isChecked() else '文本',
'enter_send': self.chk_enter_send.isChecked(),
'crlf': self.chk_crlf.isChecked(),
'auto': self.chk_auto.isChecked(),
'interval': self.spin_interval.value(),
},
'reply': {
'enabled': self.chk_reply.isChecked(),
'parse': 'HEX' if self.rb_parse_hex.isChecked() else 'ASCII',
'respond': 'HEX' if self.rb_reply_hex.isChecked() else 'ASCII',
},
'font_size': self.recv.font_size(),
'reply_rules': copy.deepcopy(self.reply_rules),
'batch_rows': copy.deepcopy(self.batch_rows),
}
def apply_config(self, cfg):
if not isinstance(cfg, dict):
return []
msg = []
recv = cfg.get('recv')
if isinstance(recv, dict):
display = recv.get('display', 'HEX')
self.rb_recv_hex.setChecked(display == 'HEX')
self.rb_recv_ascii.setChecked(display != 'HEX')
if 'ts' in recv:
self.chk_ts.setChecked(bool(recv['ts']))
if 'info' in recv:
self.chk_info.setChecked(bool(recv['info']))
msg.append('接收设置')
send = cfg.get('send')
if isinstance(send, dict):
fmt = send.get('format', '文本')
self.rb_send_hex.setChecked(fmt == 'HEX')
self.rb_send_text.setChecked(fmt != 'HEX')
if 'enter_send' in send:
self.chk_enter_send.setChecked(bool(send['enter_send']))
if 'crlf' in send:
self.chk_crlf.setChecked(bool(send['crlf']))
if 'auto' in send:
self.chk_auto.setChecked(bool(send['auto']))
if 'interval' in send:
self.spin_interval.setValue(int(send['interval']))
msg.append('发送设置')
reply = cfg.get('reply')
if isinstance(reply, dict):
if 'enabled' in reply:
self.chk_reply.setChecked(bool(reply['enabled']))
if 'parse' in reply:
self.rb_parse_hex.setChecked(reply['parse'] == 'HEX')
self.rb_parse_ascii.setChecked(reply['parse'] != 'HEX')
if 'respond' in reply:
self.rb_reply_hex.setChecked(reply['respond'] == 'HEX')
self.rb_reply_ascii.setChecked(reply['respond'] != 'HEX')
msg.append('应答设置')
if 'font_size' in cfg:
try:
self.recv.set_font_size(int(cfg['font_size']))
except (TypeError, ValueError):
pass
msg.append('字体大小')
rr = cfg.get('reply_rules')
if isinstance(rr, list) and all(isinstance(x, dict) for x in rr):
for r in rr:
r.setdefault('enabled', True)
self.reply_rules = rr
msg.append(f'应答配置 {len(rr)}')
br = cfg.get('batch_rows')
if isinstance(br, list) and all(isinstance(x, dict) for x in br):
self.batch_rows = [{'enabled': bool(x.get('enabled', True)),
'delay': int(x.get('delay', 0)),
'text': str(x.get('text', ''))} for x in br]
msg.append(f'批量发送 {len(br)}')
return msg
def export_config(self):
data = {'reply_rules': copy.deepcopy(self.reply_rules),
'batch_rows': copy.deepcopy(self.batch_rows)}
data = self.collect_config()
default = f'config_{datetime.now():%Y%m%d_%H%M%S}.json'
path, _ = QFileDialog.getSaveFileName(self, '导出配置', default,
'JSON 文件 (*.json);;所有文件 (*)')
@@ -1370,22 +1494,7 @@ class BasePanel(QWidget):
if not isinstance(data, dict):
QMessageBox.warning(self, '导入失败', '配置文件格式无效')
return
msg = []
rr = data.get('reply_rules')
if isinstance(rr, list) and rr and all(isinstance(x, dict) for x in rr):
for r in rr:
r.setdefault('enabled', True)
self.reply_rules = rr
msg.append(f'应答配置 {len(rr)}')
br = data.get('batch_rows')
if isinstance(br, list):
if not all(isinstance(x, dict) for x in br):
QMessageBox.warning(self, '导入失败', '批量发送配置格式无效')
return
self.batch_rows = [{'enabled': bool(x.get('enabled', True)),
'delay': int(x.get('delay', 0)),
'text': str(x.get('text', ''))} for x in br]
msg.append(f'批量发送 {len(br)}')
msg = self.apply_config(data)
if not msg:
QMessageBox.warning(self, '导入失败', '配置文件中没有可导入的数据')
return
@@ -1847,12 +1956,8 @@ class NetPanel(BasePanel):
QMessageBox.warning(self, '错误', f'发送失败\n{e}')
return False
# TCP 服务器
sel = self.combox_client.currentText()
with self.lock:
if sel and sel in self.clients:
targets = [self.clients[sel]]
else:
targets = list(self.clients.values())
targets = list(self.clients.values())
if not targets:
QMessageBox.warning(self, '提示', '没有已连接的客户端')
return False
@@ -1885,10 +1990,85 @@ class MainWindow(QMainWindow):
if icon:
self.setWindowIcon(QIcon(icon))
tabs = QTabWidget()
tabs.addTab(SerialPanel(), '串口')
tabs.addTab(NetPanel('tcp'), 'TCP')
tabs.addTab(NetPanel('udp'), 'UDP')
self.serial_panel = SerialPanel()
self.tcp_panel = NetPanel('tcp')
self.udp_panel = NetPanel('udp')
tabs.addTab(self.serial_panel, '串口')
tabs.addTab(self.tcp_panel, 'TCP')
tabs.addTab(self.udp_panel, 'UDP')
self.setCentralWidget(tabs)
self.top_btn = QCheckBox('窗口置顶')
self.top_btn.setToolTip('选中后窗口保持在最前端')
self.top_btn.toggled.connect(self._toggle_topmost)
tb = QToolBar('选项')
tb.setMovable(False)
tb.addWidget(self.top_btn)
self.addToolBar(tb)
self._load_app_config()
def _toggle_topmost(self, on: bool):
try:
import ctypes
user32 = ctypes.windll.user32
user32.SetWindowPos.argtypes = [
ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
ctypes.c_uint,
]
user32.SetWindowPos.restype = ctypes.c_int
HWND_TOPMOST = -1
HWND_NOTOPMOST = -2
SWP_NOMOVE = 0x0002
SWP_NOSIZE = 0x0001
SWP_NOACTIVATE = 0x0010
ok = user32.SetWindowPos(
ctypes.c_void_p(int(self.winId())),
ctypes.c_void_p(HWND_TOPMOST if on else HWND_NOTOPMOST),
0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) != 0
if ok:
return
except Exception:
pass
self._fallback_topmost(on)
def _fallback_topmost(self, on: bool):
QTimer.singleShot(0, lambda: (self.setWindowFlag(Qt.WindowStaysOnTopHint, on),
self.show()))
def _save_app_config(self):
data = {'topmost': self.top_btn.isChecked(),
'tabs': {
'serial': self.serial_panel.collect_config(),
'tcp': self.tcp_panel.collect_config(),
'udp': self.udp_panel.collect_config(),
}}
try:
with open(app_config_path(), 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except OSError:
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
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):
return
for panel, key in ((self.serial_panel, 'serial'),
(self.tcp_panel, 'tcp'),
(self.udp_panel, 'udp')):
cfg = tabs_cfg.get(key)
if isinstance(cfg, dict):
panel.apply_config(cfg)
def closeEvent(self, event):
self._save_app_config()
super().closeEvent(event)
if __name__ == '__main__':