feat(voice-client): 双层配置持久化、精简手术号 UI 与 WS/服务端排查日志

- machine_config:系统级 + 用户级 voice_client.json 合并,界面失焦保存至用户目录
- 移除「当前手术号」表单项与占位文案;指派后仅在窗口标题显示手术号
- WebSocket 连接日志附带绑定/开录路径排查说明
- 开录未推送时服务端 WARNING(无站点绑定或 camera_ids 不匹配)
- 测试、README、.env.example 同步

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-27 11:45:11 +08:00
parent 6b3adb4ad8
commit 9941e0d131
8 changed files with 352 additions and 53 deletions

View File

@@ -5,13 +5,14 @@ from __future__ import annotations
import json
from typing import Any
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtGui import QCloseEvent, QShowEvent
from PySide6.QtCore import Qt, QObject, QTimer, Signal
from PySide6.QtGui import QCloseEvent, QFont, QShowEvent
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGroupBox,
QHBoxLayout,
QLabel,
@@ -26,9 +27,14 @@ from PySide6.QtWidgets import (
from loguru import logger
from voice_confirmation_client.core.assignment_listener import (
VoiceAssignmentListener,
default_terminal_id_from_env,
from voice_confirmation_client.core.assignment_listener import VoiceAssignmentListener
from voice_confirmation_client.core.machine_config import (
http_base_url_from_config,
load_voice_client_config,
machine_config_file_path,
save_user_voice_client_config,
user_voice_client_config_path,
voice_terminal_id_from_config,
)
from voice_confirmation_client.core.monitor_worker import MonitorWorker
from voice_confirmation_client.logging_config import setup_voice_client_logging
@@ -52,54 +58,40 @@ class MainWindow(QMainWindow):
self.resize(920, 640)
self._assignment_listener: VoiceAssignmentListener | None = None
self._assigned_surgery_id: str = ""
self._rec_banner_pulse_phase: bool = False
self._rec_pulse_timer = QTimer(self)
self._rec_pulse_timer.setInterval(550)
self._rec_pulse_timer.timeout.connect(self._pulse_recording_banner)
self._bridge = _Bridge()
self._bridge.log_line.connect(self._append_log_plain)
self._bridge.pending_payload.connect(self._show_pending)
self._bridge.voice_assign_start.connect(self._on_server_assign_start)
self._bridge.voice_assign_end.connect(self._on_server_assign_end)
setup_voice_client_logging(
gui_line_sink=lambda m: self._bridge.log_line.emit(m.rstrip("\n")),
)
logger.info(
"语音确认客户端已启动;请填写「本机语音终端 ID」与 OR_SITE_CONFIG 一致,"
"开录后由服务端自动填入手术号并监控"
)
self._worker = MonitorWorker(
on_log=None,
on_state=lambda s: self._bridge.state_text.emit(s),
on_pending=lambda p: self._bridge.pending_payload.emit(p),
)
self._worker.start_thread()
_mc = load_voice_client_config()
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
form_box = QGroupBox("连接与当前手术")
form_box = QGroupBox("连接")
form = QFormLayout(form_box)
self._base_url = QLineEdit("http://127.0.0.1:38080")
self._surgery_id_display = QLabel("—(等待服务端开录指派)")
self._surgery_id_display.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self._base_url = QLineEdit()
self._record_sec = QDoubleSpinBox()
self._record_sec.setRange(2.0, 60.0)
self._record_sec.setValue(8.0)
self._record_sec.setSuffix(" s")
form.addRow("服务端 Base URL", self._base_url)
self._terminal_id = QLineEdit(default_terminal_id_from_env())
self._base_url.setText(http_base_url_from_config(_mc))
self._terminal_id = QLineEdit(voice_terminal_id_from_config(_mc))
self._terminal_id.setPlaceholderText("与 OR_SITE_CONFIG 中 voice_terminal_id 一致")
self._auto_assign = QCheckBox("启用服务端自动指派(开录后自动监控该手术)")
self._auto_assign.setChecked(True)
form.addRow("本机语音终端 ID", self._terminal_id)
form.addRow(self._auto_assign)
self._terminal_id.editingFinished.connect(self._sync_assignment_listener)
self._base_url.editingFinished.connect(self._sync_assignment_listener)
self._terminal_id.editingFinished.connect(self._on_connection_fields_edited)
self._base_url.editingFinished.connect(self._on_connection_fields_edited)
self._auto_assign.toggled.connect(self._on_auto_assign_toggled)
form.addRow("当前手术号(仅展示,由服务端指派)", self._surgery_id_display)
form.addRow("录音时长", self._record_sec)
self._record_sec.valueChanged.connect(lambda _: self._apply_settings_silent())
root.addWidget(form_box)
@@ -130,9 +122,25 @@ class MainWindow(QMainWindow):
btn_row.addStretch()
root.addLayout(btn_row)
self._recording_banner = QFrame()
self._recording_banner.setObjectName("recordingBanner")
self._recording_banner.setVisible(False)
bl = QHBoxLayout(self._recording_banner)
bl.setContentsMargins(14, 12, 14, 12)
self._recording_banner_label = QLabel("● 正在录音 — 请对着麦克风清晰作答")
self._recording_banner_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
bf = QFont()
bf.setPointSize(15)
bf.setBold(True)
self._recording_banner_label.setFont(bf)
self._recording_banner_label.setStyleSheet("color: #ffffff; background: transparent;")
bl.addWidget(self._recording_banner_label, stretch=1)
self._apply_recording_banner_style("#b71c1c")
root.addWidget(self._recording_banner)
self._status_label = QLabel("待机")
root.addWidget(self._status_label)
self._bridge.state_text.connect(self._status_label.setText)
self._bridge.state_text.connect(self._on_worker_state)
split = QSplitter(Qt.Orientation.Horizontal)
self._pending_view = QPlainTextEdit()
@@ -146,6 +154,27 @@ class MainWindow(QMainWindow):
split.setSizes([360, 520])
root.addWidget(split, stretch=1)
self._bridge.log_line.connect(self._append_log_plain)
setup_voice_client_logging(
gui_line_sink=lambda m: self._bridge.log_line.emit(m.rstrip("\n")),
)
logger.info(
"语音确认客户端已启动;本机终端 ID 须与 OR_SITE_CONFIG 中 voice_terminal_id 一致"
)
_sys_cfg = machine_config_file_path()
if _sys_cfg.is_file():
logger.info("系统级配置: {}", _sys_cfg)
_user_cfg = user_voice_client_config_path()
if _user_cfg.is_file():
logger.info("用户级配置(界面保存): {}", _user_cfg)
self._worker = MonitorWorker(
on_log=None,
on_state=lambda s: self._bridge.state_text.emit(s),
on_pending=lambda p: self._bridge.pending_payload.emit(p),
)
self._worker.start_thread()
self._btn_stop.clicked.connect(self._stop_monitoring)
self._btn_retry.clicked.connect(self._worker.retry_failed)
self._btn_replay.clicked.connect(self._worker.replay_prompt_only)
@@ -156,16 +185,60 @@ class MainWindow(QMainWindow):
super().showEvent(event)
self._sync_assignment_listener()
def _apply_recording_banner_style(self, bg_hex: str) -> None:
self._recording_banner.setStyleSheet(
f"QFrame#recordingBanner {{ background-color: {bg_hex}; border-radius: 8px; }}"
)
def _pulse_recording_banner(self) -> None:
self._rec_banner_pulse_phase = not self._rec_banner_pulse_phase
self._apply_recording_banner_style(
"#c62828" if self._rec_banner_pulse_phase else "#b71c1c"
)
def _set_recording_banner_active(self, active: bool) -> None:
if active:
self._recording_banner.setVisible(True)
self._rec_banner_pulse_phase = False
self._apply_recording_banner_style("#b71c1c")
if not self._rec_pulse_timer.isActive():
self._rec_pulse_timer.start()
else:
self._rec_pulse_timer.stop()
self._recording_banner.setVisible(False)
self._apply_recording_banner_style("#b71c1c")
def _on_worker_state(self, s: str) -> None:
self._status_label.setText(s)
self._set_recording_banner_active("录音中" in s)
def _refresh_window_title(self) -> None:
base = "手术室耗材语音确认客户端"
if self._assigned_surgery_id:
self.setWindowTitle(f"{base} — 手术 {self._assigned_surgery_id}")
else:
self.setWindowTitle(base)
def _on_auto_assign_toggled(self, _checked: bool) -> None:
if not self._auto_assign.isChecked():
self._worker.set_monitoring(False)
self._assigned_surgery_id = ""
self._surgery_id_display.setText("—(未启用自动指派)")
self._refresh_window_title()
self._btn_stop.setEnabled(False)
self._status_label.setText("已关闭自动指派")
self._on_worker_state("已关闭自动指派")
self._apply_settings_silent()
self._sync_assignment_listener()
def _on_connection_fields_edited(self) -> None:
try:
save_user_voice_client_config(
voice_terminal_id=self._terminal_id.text(),
http_base_url=self._base_url.text(),
)
except OSError as exc:
logger.warning("无法保存用户级配置: {}", exc)
self._sync_assignment_listener()
def _sync_assignment_listener(self) -> None:
if self._assignment_listener:
self._assignment_listener.stop()
@@ -174,7 +247,7 @@ class MainWindow(QMainWindow):
logger.info("未勾选「启用服务端自动指派」,不连接 WebSocket")
self._worker.set_monitoring(False)
self._assigned_surgery_id = ""
self._surgery_id_display.setText("—(未启用自动指派)")
self._refresh_window_title()
self._btn_stop.setEnabled(False)
self._apply_settings_silent()
return
@@ -182,7 +255,7 @@ class MainWindow(QMainWindow):
base = self._base_url.text().strip()
if not tid:
logger.warning(
"「本机语音终端 ID」为空无法接收开录指派请在环境变量 VOICE_TERMINAL_ID 或界面中填写(须与 OR_SITE_CONFIG 中 voice_terminal_id 一致)"
"「本机语音终端 ID」为空无法接收开录指派请在每机配置文件或界面中填写(须与 OR_SITE_CONFIG 中 voice_terminal_id 一致)"
)
return
if not base:
@@ -201,7 +274,7 @@ class MainWindow(QMainWindow):
logger.warning("服务端指派无效手术号: {!r}(须为 6 位数字)", sid)
return
self._assigned_surgery_id = sid
self._surgery_id_display.setText(sid)
self._refresh_window_title()
self._apply_settings_silent()
self._worker.set_monitoring(True)
self._btn_stop.setEnabled(True)
@@ -210,10 +283,10 @@ class MainWindow(QMainWindow):
def _on_server_assign_end(self, sid: str) -> None:
self._worker.set_monitoring(False)
self._assigned_surgery_id = ""
self._surgery_id_display.setText("—(等待服务端开录指派)")
self._refresh_window_title()
self._btn_stop.setEnabled(False)
self._apply_settings_silent()
self._status_label.setText("已停止(服务端结束)")
self._on_worker_state("已停止(服务端结束)")
logger.info("服务端已结束手术 {},已自动停止监控", sid)
def _show_pending(self, payload: object) -> None:
@@ -262,20 +335,25 @@ class MainWindow(QMainWindow):
def _stop_monitoring(self) -> None:
self._worker.set_monitoring(False)
self._assigned_surgery_id = ""
self._surgery_id_display.setText("—(等待服务端开录指派)")
self._refresh_window_title()
self._btn_stop.setEnabled(False)
self._apply_settings_silent()
logger.info("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——")
self._status_label.setText("已停止(本地)")
self._on_worker_state("已停止(本地)")
def _append_log_plain(self, line: str) -> None:
"""由 loguru GUI sink 写入,已含时间与级别,不再加前缀。"""
self._log.appendPlainText(line)
sb = self._log.verticalScrollBar()
w = getattr(self, "_log", None)
if w is None:
return
w.appendPlainText(line)
sb = w.verticalScrollBar()
sb.setValue(sb.maximum())
def shutdown(self) -> None:
"""停止后台线程;窗口关闭与 Ctrl+CaboutToQuit共用。"""
self._rec_pulse_timer.stop()
self._set_recording_banner_active(False)
if self._assignment_listener:
self._assignment_listener.stop()
self._assignment_listener = None