feat: 站点 JSON、语音终端 WebSocket 指派与客户端联调
- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings) - VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify - 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS - 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长 - 新增/调整契约与绑定测试,文档与示例配置同步 Made-with: Cursor
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
## 环境
|
||||
|
||||
- Python **3.13+**(与主项目一致)
|
||||
- 安装可选依赖组 `**voice-client`**(PySide6、httpx、numpy、sounddevice)
|
||||
- 安装可选依赖组 **voice-client**(PySide6、httpx、numpy、sounddevice、websocket-client)
|
||||
|
||||
```bash
|
||||
cd /path/to/operation-room-monitor-server
|
||||
@@ -38,7 +38,15 @@ start_voice_confirmation_client.bat
|
||||
uv run --group voice-client voice-confirmation-client
|
||||
```
|
||||
|
||||
在界面中填写 **服务端 Base URL**、**6 位手术号**,点击 **开始监控**。
|
||||
**术间 / 摄像头 / 语音终端对应关系**只在服务端 `**OR_SITE_CONFIG_JSON_FILE`** 里维护一份(`voice_or_room_bindings`);桌面程序不读该文件。
|
||||
|
||||
本机要做的只有两件事:**服务端 Base URL**,以及 **本机语音终端 ID**(须等于 JSON 里某条 `voice_terminal_id`)。**手术号不在客户端输入**:勾选 **启用服务端自动指派** 后,开录/停录仅通过 **WebSocket** `voice_assignment` 下发(断线后自动重连,不用 HTTP 轮询);界面只读展示当前手术号;停录后自动停止。可用 **停止监控(本机)** 做本地紧急中断。
|
||||
|
||||
可选:用环境变量 `**VOICE_TERMINAL_ID`** 预填界面里的「本机语音终端 ID」(仅客户端进程使用,**不要**写进监控服务的 `.env`)。
|
||||
|
||||
## 日志(loguru)
|
||||
|
||||
客户端使用 **loguru**:**右侧日志区**与**启动终端 stderr** 会同时输出。开录无反应时请看是否出现「本机语音终端 ID 为空」、`WebSocket 已连接`、`收到 voice_assignment start`、或反复「WebSocket 断开」等行。
|
||||
|
||||
## 音频说明
|
||||
|
||||
@@ -76,5 +84,5 @@ uv run --group voice-client-build python scripts/build_voice_client.py
|
||||
## 与浏览器 Demo 的差异
|
||||
|
||||
- 浏览器 Demo(`scripts/demo_client/`)默认 **10 秒** 轮询;本客户端默认 **5 秒**,可在界面修改。
|
||||
- 本客户端无「开始/结束手术」按钮;手术需由既有流程或他端调用 `POST /client/surgeries/start` 启动。
|
||||
- 本客户端无「开始/结束手术」按钮;手术需由既有流程或他端调用 `POST /client/surgeries/start` 启动;若启用自动指派,开录成功后本机将自动开始待确认轮询。
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import signal
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from voice_confirmation_client.gui.main_window import MainWindow
|
||||
@@ -13,6 +15,19 @@ def main() -> None:
|
||||
app = QApplication(sys.argv)
|
||||
win = MainWindow()
|
||||
win.show()
|
||||
|
||||
app.aboutToQuit.connect(win.shutdown)
|
||||
|
||||
# Qt 事件循环长时间跑在 native 代码里时,Python 无法处理 SIGINT;定时器让解释器周期性醒来。
|
||||
_pulse = QTimer()
|
||||
_pulse.timeout.connect(lambda: None)
|
||||
_pulse.start(200)
|
||||
|
||||
def _on_sigint(_signum: int, _frame: object | None) -> None:
|
||||
app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, _on_sigint)
|
||||
|
||||
raise SystemExit(app.exec())
|
||||
|
||||
|
||||
|
||||
143
voice_confirmation_client/core/assignment_listener.py
Normal file
143
voice_confirmation_client/core/assignment_listener.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""WebSocket:接收服务端 voice_assignment(开录/停录自动启停监控)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlparse, urlunparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def http_base_to_ws_root(http_base: str) -> str:
|
||||
p = urlparse(http_base.strip())
|
||||
scheme = "wss" if p.scheme == "https" else "ws"
|
||||
return urlunparse((scheme, p.netloc, "", "", "", ""))
|
||||
|
||||
|
||||
class VoiceAssignmentListener:
|
||||
"""后台线程:仅 WebSocket;断线后等待一小段时间再重连。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
http_base_url: str,
|
||||
terminal_id: str,
|
||||
on_start: Callable[[str], None],
|
||||
on_end: Callable[[str], None],
|
||||
reconnect_delay_sec: float = 2.0,
|
||||
) -> None:
|
||||
self._http_base = http_base_url.rstrip("/")
|
||||
self._terminal_id = terminal_id.strip()
|
||||
self._on_start = on_start
|
||||
self._on_end = on_end
|
||||
self._reconnect_delay = reconnect_delay_sec
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._last_assignment: str | None = None
|
||||
|
||||
@property
|
||||
def terminal_id(self) -> str:
|
||||
return self._terminal_id
|
||||
|
||||
def start(self) -> None:
|
||||
if not self._terminal_id:
|
||||
logger.warning("未配置 terminal_id,跳过语音终端指派监听")
|
||||
return
|
||||
if self._thread and self._thread.is_alive():
|
||||
logger.debug("指派监听线程已在运行,忽略重复 start")
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run, name="VoiceAssignment", daemon=True)
|
||||
self._thread.start()
|
||||
logger.info(
|
||||
"已启动指派监听(仅 WebSocket)terminal_id={!r} base={!r}",
|
||||
self._terminal_id,
|
||||
self._http_base,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
logger.debug("已请求停止指派监听线程")
|
||||
|
||||
def _run(self) -> None:
|
||||
import websocket
|
||||
|
||||
ws_root = http_base_to_ws_root(self._http_base)
|
||||
path = f"/client/voice-terminals/ws?terminal_id={quote(self._terminal_id)}"
|
||||
ws_url = ws_root.rstrip("/") + path
|
||||
logger.info("WebSocket 目标: {}", ws_url)
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
|
||||
def _on_open(_ws: Any) -> None:
|
||||
logger.info("WebSocket 已连接,等待服务端 voice_assignment 消息")
|
||||
|
||||
def _on_close(_ws: Any, close_status_code: Any, close_msg: Any) -> None:
|
||||
logger.warning(
|
||||
"WebSocket 断开 code={} msg={!r}",
|
||||
close_status_code,
|
||||
close_msg,
|
||||
)
|
||||
|
||||
def _on_error(_ws: Any, err: Any) -> None:
|
||||
if err is None:
|
||||
return
|
||||
if type(err).__name__ == "ABNF":
|
||||
logger.debug("WebSocket 内部帧回调(已忽略): {}", type(err).__name__)
|
||||
return
|
||||
logger.warning("WebSocket 错误: {}", err)
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_open=_on_open,
|
||||
on_close=_on_close,
|
||||
on_message=self._ws_on_message,
|
||||
on_error=_on_error,
|
||||
)
|
||||
ws.run_forever(ping_interval=None, ping_timeout=None)
|
||||
except Exception as exc:
|
||||
logger.exception("WebSocket run_forever 异常: {}", exc)
|
||||
|
||||
if self._stop.is_set():
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"{:.1f}s 后重连 WebSocket…",
|
||||
self._reconnect_delay,
|
||||
)
|
||||
time.sleep(self._reconnect_delay)
|
||||
|
||||
def _ws_on_message(self, _ws: Any, message: str) -> None:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("WebSocket 非 JSON 消息(已忽略): {!r}", message[:200])
|
||||
return
|
||||
if data.get("type") != "voice_assignment":
|
||||
logger.debug("WebSocket 非 voice_assignment 消息 type={!r}", data.get("type"))
|
||||
return
|
||||
action = data.get("action")
|
||||
sid = str(data.get("surgery_id") or "")
|
||||
if not sid:
|
||||
return
|
||||
if action == "start":
|
||||
logger.info("收到 voice_assignment start surgery_id={!r}", sid)
|
||||
self._last_assignment = sid
|
||||
self._on_start(sid)
|
||||
elif action == "end":
|
||||
logger.info("收到 voice_assignment end surgery_id={!r}", sid)
|
||||
if self._last_assignment == sid:
|
||||
self._last_assignment = None
|
||||
self._on_end(sid)
|
||||
else:
|
||||
logger.debug("忽略 voice_assignment action={!r}", action)
|
||||
|
||||
|
||||
def default_terminal_id_from_env() -> str:
|
||||
return (os.environ.get("VOICE_TERMINAL_ID") or "").strip()
|
||||
@@ -9,6 +9,8 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from voice_confirmation_client.core.api import ConfirmationApiClient
|
||||
from voice_confirmation_client.core.playback import play_mp3_from_base64
|
||||
from voice_confirmation_client.core.record import record_wav_16k_mono
|
||||
@@ -100,9 +102,13 @@ class MonitorWorker:
|
||||
|
||||
def set_monitoring(self, active: bool) -> None:
|
||||
if active:
|
||||
with self._settings_lock:
|
||||
sid = self._settings.surgery_id
|
||||
logger.info("监控已开启 surgery_id={!r}", sid)
|
||||
self._monitoring.set()
|
||||
self._wake.set()
|
||||
else:
|
||||
logger.info("监控已关闭")
|
||||
self._monitoring.clear()
|
||||
with self._state_lock:
|
||||
self._state.generation += 1
|
||||
@@ -135,6 +141,7 @@ class MonitorWorker:
|
||||
self._emit_state("待机")
|
||||
|
||||
def _log(self, msg: str) -> None:
|
||||
logger.info("{}", msg)
|
||||
if self._on_log:
|
||||
self._on_log(msg)
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, QObject
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtGui import QCloseEvent, QShowEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -18,7 +17,6 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QPlainTextEdit,
|
||||
QSplitter,
|
||||
@@ -26,13 +24,25 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from voice_confirmation_client.core.assignment_listener import (
|
||||
VoiceAssignmentListener,
|
||||
default_terminal_id_from_env,
|
||||
)
|
||||
from voice_confirmation_client.core.monitor_worker import MonitorWorker
|
||||
from voice_confirmation_client.logging_config import setup_voice_client_logging
|
||||
|
||||
# 待确认接口仍为轮询;界面不再暴露,固定默认间隔。
|
||||
_DEFAULT_PENDING_POLL_INTERVAL_SEC = 5.0
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
log_line = Signal(str)
|
||||
state_text = Signal(str)
|
||||
pending_payload = Signal(object)
|
||||
voice_assign_start = Signal(str)
|
||||
voice_assign_end = Signal(str)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
@@ -40,13 +50,25 @@ class MainWindow(QMainWindow):
|
||||
super().__init__()
|
||||
self.setWindowTitle("手术室耗材语音确认客户端")
|
||||
self.resize(920, 640)
|
||||
self._assignment_listener: VoiceAssignmentListener | None = None
|
||||
self._assigned_surgery_id: str = ""
|
||||
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.log_line.connect(self._append_log)
|
||||
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=lambda m: self._bridge.log_line.emit(m),
|
||||
on_log=None,
|
||||
on_state=lambda s: self._bridge.state_text.emit(s),
|
||||
on_pending=lambda p: self._bridge.pending_payload.emit(p),
|
||||
)
|
||||
@@ -56,23 +78,30 @@ class MainWindow(QMainWindow):
|
||||
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 = QLineEdit("")
|
||||
self._surgery_id.setPlaceholderText("6 位数字,如 123456")
|
||||
self._interval = QDoubleSpinBox()
|
||||
self._interval.setRange(1.0, 120.0)
|
||||
self._interval.setValue(5.0)
|
||||
self._interval.setSuffix(" s")
|
||||
self._surgery_id_display = QLabel("—(等待服务端开录指派)")
|
||||
self._surgery_id_display.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
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)
|
||||
form.addRow("手术号 surgery_id", self._surgery_id)
|
||||
form.addRow("轮询间隔", self._interval)
|
||||
self._terminal_id = QLineEdit(default_terminal_id_from_env())
|
||||
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._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)
|
||||
|
||||
adv = QGroupBox("音频 / 调试")
|
||||
@@ -91,12 +120,10 @@ class MainWindow(QMainWindow):
|
||||
root.addWidget(adv)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
self._btn_start = QPushButton("开始监控")
|
||||
self._btn_stop = QPushButton("停止监控")
|
||||
self._btn_stop = QPushButton("停止监控(本机)")
|
||||
self._btn_stop.setEnabled(False)
|
||||
self._btn_retry = QPushButton("重试本轮(播放+录音+上传)")
|
||||
self._btn_replay = QPushButton("仅重播话术")
|
||||
btn_row.addWidget(self._btn_start)
|
||||
btn_row.addWidget(self._btn_stop)
|
||||
btn_row.addWidget(self._btn_retry)
|
||||
btn_row.addWidget(self._btn_replay)
|
||||
@@ -119,13 +146,76 @@ class MainWindow(QMainWindow):
|
||||
split.setSizes([360, 520])
|
||||
root.addWidget(split, stretch=1)
|
||||
|
||||
self._btn_start.clicked.connect(self._start_monitoring)
|
||||
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)
|
||||
|
||||
self._apply_settings_silent()
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None:
|
||||
super().showEvent(event)
|
||||
self._sync_assignment_listener()
|
||||
|
||||
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._btn_stop.setEnabled(False)
|
||||
self._status_label.setText("已关闭自动指派")
|
||||
self._apply_settings_silent()
|
||||
self._sync_assignment_listener()
|
||||
|
||||
def _sync_assignment_listener(self) -> None:
|
||||
if self._assignment_listener:
|
||||
self._assignment_listener.stop()
|
||||
self._assignment_listener = None
|
||||
if not self._auto_assign.isChecked():
|
||||
logger.info("未勾选「启用服务端自动指派」,不连接 WebSocket")
|
||||
self._worker.set_monitoring(False)
|
||||
self._assigned_surgery_id = ""
|
||||
self._surgery_id_display.setText("—(未启用自动指派)")
|
||||
self._btn_stop.setEnabled(False)
|
||||
self._apply_settings_silent()
|
||||
return
|
||||
tid = self._terminal_id.text().strip()
|
||||
base = self._base_url.text().strip()
|
||||
if not tid:
|
||||
logger.warning(
|
||||
"「本机语音终端 ID」为空,无法接收开录指派;请在环境变量 VOICE_TERMINAL_ID 或界面中填写(须与 OR_SITE_CONFIG 中 voice_terminal_id 一致)"
|
||||
)
|
||||
return
|
||||
if not base:
|
||||
logger.warning("服务端 Base URL 为空,无法连接指派接口")
|
||||
return
|
||||
self._assignment_listener = VoiceAssignmentListener(
|
||||
http_base_url=base,
|
||||
terminal_id=tid,
|
||||
on_start=lambda s: self._bridge.voice_assign_start.emit(s),
|
||||
on_end=lambda s: self._bridge.voice_assign_end.emit(s),
|
||||
)
|
||||
self._assignment_listener.start()
|
||||
|
||||
def _on_server_assign_start(self, sid: str) -> None:
|
||||
if len(sid) != 6 or not sid.isdigit():
|
||||
logger.warning("服务端指派无效手术号: {!r}(须为 6 位数字)", sid)
|
||||
return
|
||||
self._assigned_surgery_id = sid
|
||||
self._surgery_id_display.setText(sid)
|
||||
self._apply_settings_silent()
|
||||
self._worker.set_monitoring(True)
|
||||
self._btn_stop.setEnabled(True)
|
||||
logger.info("服务端已指派手术 {},已自动开始监控(WebSocket 指派)", sid)
|
||||
|
||||
def _on_server_assign_end(self, sid: str) -> None:
|
||||
self._worker.set_monitoring(False)
|
||||
self._assigned_surgery_id = ""
|
||||
self._surgery_id_display.setText("—(等待服务端开录指派)")
|
||||
self._btn_stop.setEnabled(False)
|
||||
self._apply_settings_silent()
|
||||
self._status_label.setText("已停止(服务端结束)")
|
||||
logger.info("服务端已结束手术 {},已自动停止监控", sid)
|
||||
|
||||
def _show_pending(self, payload: object) -> None:
|
||||
if payload is None:
|
||||
self._pending_view.clear()
|
||||
@@ -160,8 +250,8 @@ class MainWindow(QMainWindow):
|
||||
dev_data = self._device_combo.currentData()
|
||||
self._worker.set_settings(
|
||||
base_url=self._base_url.text().strip(),
|
||||
surgery_id=self._surgery_id.text().strip(),
|
||||
interval_sec=float(self._interval.value()),
|
||||
surgery_id=self._assigned_surgery_id,
|
||||
interval_sec=_DEFAULT_PENDING_POLL_INTERVAL_SEC,
|
||||
record_seconds=float(self._record_sec.value()),
|
||||
dry_run=self._dry_run.isChecked(),
|
||||
hide_404_logs=self._hide_404.isChecked(),
|
||||
@@ -169,30 +259,28 @@ class MainWindow(QMainWindow):
|
||||
sounddevice_device=dev_data,
|
||||
)
|
||||
|
||||
def _start_monitoring(self) -> None:
|
||||
sid = self._surgery_id.text().strip()
|
||||
if len(sid) != 6 or not sid.isdigit():
|
||||
QMessageBox.warning(self, "校验失败", "手术号必须为 6 位数字。")
|
||||
return
|
||||
self._apply_settings_silent()
|
||||
self._worker.set_monitoring(True)
|
||||
self._btn_start.setEnabled(False)
|
||||
self._btn_stop.setEnabled(True)
|
||||
self._append_log("—— 开始监控 ——")
|
||||
|
||||
def _stop_monitoring(self) -> None:
|
||||
self._worker.set_monitoring(False)
|
||||
self._btn_start.setEnabled(True)
|
||||
self._assigned_surgery_id = ""
|
||||
self._surgery_id_display.setText("—(等待服务端开录指派)")
|
||||
self._btn_stop.setEnabled(False)
|
||||
self._append_log("—— 已停止监控 ——")
|
||||
self._status_label.setText("已停止")
|
||||
self._apply_settings_silent()
|
||||
logger.info("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——")
|
||||
self._status_label.setText("已停止(本地)")
|
||||
|
||||
def _append_log(self, line: str) -> None:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
self._log.appendPlainText(f"[{ts}] {line}")
|
||||
def _append_log_plain(self, line: str) -> None:
|
||||
"""由 loguru GUI sink 写入,已含时间与级别,不再加前缀。"""
|
||||
self._log.appendPlainText(line)
|
||||
sb = self._log.verticalScrollBar()
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
def shutdown(self) -> None:
|
||||
"""停止后台线程;窗口关闭与 Ctrl+C(aboutToQuit)共用。"""
|
||||
if self._assignment_listener:
|
||||
self._assignment_listener.stop()
|
||||
self._assignment_listener = None
|
||||
self._worker.stop_thread()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
self.shutdown()
|
||||
event.accept()
|
||||
|
||||
35
voice_confirmation_client/logging_config.py
Normal file
35
voice_confirmation_client/logging_config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""桌面客户端 loguru:终端 + 可选 GUI 日志区。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def setup_voice_client_logging(
|
||||
*,
|
||||
gui_line_sink: Callable[[str], None] | None = None,
|
||||
stderr_level: str = "INFO",
|
||||
gui_level: str = "DEBUG",
|
||||
) -> None:
|
||||
"""初始化 sink:彩色 stderr + 可选一行一条写入 Qt(由 sink 内 Signal 线程安全投递)。"""
|
||||
logger.remove()
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
format=(
|
||||
"<green>{time:HH:mm:ss}</green> | "
|
||||
"<level>{level:<8}</level> | "
|
||||
"<cyan>{name}</cyan>:<cyan>{function}</cyan> | "
|
||||
"{message}"
|
||||
),
|
||||
level=stderr_level,
|
||||
colorize=sys.stderr.isatty(),
|
||||
)
|
||||
if gui_line_sink:
|
||||
logger.add(
|
||||
gui_line_sink,
|
||||
format="{time:HH:mm:ss.SSS} | {level:<8} | {message}",
|
||||
level=gui_level,
|
||||
)
|
||||
Reference in New Issue
Block a user