diff --git a/.env.example b/.env.example index ef8e705..36eaf89 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,8 @@ POSTGRES_PORT=35432 # OR_SITE_CONFIG_JSON_FILE=app/resources/or_site_config.sample.json # VIDEO_RTSP_URL_TEMPLATE=rtsp://user:pass@host:554/path/{camera_id} # -# 语音桌面客户端的「本机是哪一个 voice_terminal_id」不在此文件配置:在客户端界面填写, -# 或在该客户端运行环境的 shell / systemd 里设 VOICE_TERMINAL_ID(与 JSON 中对应值一致即可)。 +# 语音桌面客户端的「本机是哪一个 voice_terminal_id」不在此文件配置:系统级 voice_client.json +# 或界面填写(保存到用户目录,见 voice_confirmation_client/README.md)。 # --- 海康 SDK(Linux x86_64;二进制勿提交仓库)--- # HIKVISION_LIB_DIR=/opt/hikvision/lib diff --git a/app/services/voice_terminal_hub.py b/app/services/voice_terminal_hub.py index 38c6118..f2dac64 100644 --- a/app/services/voice_terminal_hub.py +++ b/app/services/voice_terminal_hub.py @@ -29,11 +29,17 @@ async def assign_voice_terminal_after_recording_started( await hub.notify_start(voice_tid, surgery_id) elif hub.bindings is not None: logger.warning( - "voice or room bindings have no camera set matching start " + "开录未向任何语音终端推送:camera_ids 与 OR_SITE_CONFIG「voice_or_room_bindings」无匹配 " "surgery_id={} camera_ids={}", surgery_id, camera_ids, ) + else: + logger.warning( + "开录未推送语音终端:未加载 OR_SITE_CONFIG 或 voice_or_room_bindings 为空;" + "桌面端 WebSocket 不会收到 voice_assignment surgery_id={}", + surgery_id, + ) class VoiceTerminalHub: diff --git a/tests/test_voice_client_machine_config.py b/tests/test_voice_client_machine_config.py new file mode 100644 index 0000000..e05b45c --- /dev/null +++ b/tests/test_voice_client_machine_config.py @@ -0,0 +1,96 @@ +"""语音客户端配置:系统级 + 用户级合并与保存。""" + +from __future__ import annotations + +import json + +import pytest + +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, +) + + +def test_fields_from_system_file_only(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = tmp_path / "voice_client.json" + cfg.write_text( + json.dumps( + {"voice_terminal_id": "t-1", "http_base_url": "http://api.example:38080"}, + ), + encoding="utf-8", + ) + monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", str(cfg)) + monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(tmp_path / "none.json")) + (tmp_path / "none.json").write_text("{}", encoding="utf-8") + data = load_voice_client_config() + assert voice_terminal_id_from_config(data) == "t-1" + assert http_base_url_from_config(data) == "http://api.example:38080" + + +def test_user_file_overrides_system(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + sys_f = tmp_path / "sys.json" + sys_f.write_text( + json.dumps({"voice_terminal_id": "sys", "http_base_url": "http://sys:1"}), + encoding="utf-8", + ) + usr_f = tmp_path / "usr.json" + usr_f.write_text( + json.dumps({"voice_terminal_id": "usr", "http_base_url": "http://usr:2"}), + encoding="utf-8", + ) + monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", str(sys_f)) + monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(usr_f)) + data = load_voice_client_config() + assert voice_terminal_id_from_config(data) == "usr" + assert http_base_url_from_config(data) == "http://usr:2" + + +def test_http_base_default_when_missing_key( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + cfg = tmp_path / "voice_client.json" + cfg.write_text(json.dumps({"voice_terminal_id": "x"}), encoding="utf-8") + monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", str(cfg)) + monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(tmp_path / "empty.json")) + (tmp_path / "empty.json").write_text("{}", encoding="utf-8") + data = load_voice_client_config() + assert http_base_url_from_config(data) == "http://127.0.0.1:38080" + + +def test_machine_config_file_path_respects_override( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + p = tmp_path / "custom.json" + monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", str(p)) + assert machine_config_file_path() == p + + +def test_user_config_file_path_respects_override( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + p = tmp_path / "u.json" + monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(p)) + assert user_voice_client_config_path() == p + + +def test_missing_files_return_empty_merge(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", "/nonexistent/a.json") + monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", "/nonexistent/b.json") + assert load_voice_client_config() == {} + + +def test_save_user_voice_client_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + out = tmp_path / "out.json" + monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(out)) + save_user_voice_client_config( + voice_terminal_id=" t99 ", + http_base_url="http://host:38080/", + ) + assert out.is_file() + data = json.loads(out.read_text(encoding="utf-8")) + assert data == {"voice_terminal_id": "t99", "http_base_url": "http://host:38080"} diff --git a/voice_confirmation_client/README.md b/voice_confirmation_client/README.md index adafbf1..45f1cf4 100644 --- a/voice_confirmation_client/README.md +++ b/voice_confirmation_client/README.md @@ -40,9 +40,20 @@ uv run --group voice-client voice-confirmation-client **术间 / 摄像头 / 语音终端对应关系**只在服务端 `**OR_SITE_CONFIG_JSON_FILE`** 里维护一份(`voice_or_room_bindings`);桌面程序不读该文件。 -本机要做的只有两件事:**服务端 Base URL**,以及 **本机语音终端 ID**(须等于 JSON 里某条 `voice_terminal_id`)。**手术号不在客户端输入**:勾选 **启用服务端自动指派** 后,开录/停录仅通过 **WebSocket** `voice_assignment` 下发(断线后自动重连,不用 HTTP 轮询);界面只读展示当前手术号;停录后自动停止。可用 **停止监控(本机)** 做本地紧急中断。 +本机要做的只有两件事:**服务端 Base URL**,以及 **本机语音终端 ID**(须等于 JSON 里某条 `voice_terminal_id`)。**手术号不在客户端输入**:勾选 **启用服务端自动指派** 后,开录/停录仅通过 **WebSocket** `voice_assignment` 下发(断线后自动重连,不用 HTTP 轮询);当前手术号在**窗口标题**中显示;停录后自动停止。可用 **停止监控(本机)** 做本地紧急中断。 -可选:用环境变量 `**VOICE_TERMINAL_ID`** 预填界面里的「本机语音终端 ID」(仅客户端进程使用,**不要**写进监控服务的 `.env`)。 +### 配置文件(系统级 + 用户级) + +字段均为 **UTF-8 JSON**(`voice_terminal_id`、`http_base_url`,均可选)。启动时 **合并** 两层配置:**用户级覆盖系统级** 同名字段。 + +| 层级 | 用途 | 默认路径 | +|------|------|----------| +| **系统级** | 运维装机下发(只读亦可) | **Windows** `%ProgramData%\OperationRoomMonitor\voice_client.json`;**macOS** `/Library/Application Support/OperationRoomMonitor/voice_client.json`;**Linux** `/etc/operation-room-monitor/voice_client.json` | +| **用户级** | 在界面修改「服务端 Base URL」或「本机语音终端 ID」并 **离开输入框** 后自动保存 | **Windows** `%LOCALAPPDATA%\OperationRoomMonitor\voice_client.json`;**macOS** `~/Library/Application Support/OperationRoomMonitor/voice_client.json`;**Linux** `~/.config/operation-room-monitor/voice_client.json` | + +测试或定制安装可用环境变量 **`VOICE_CLIENT_MACHINE_CONFIG_FILE`**、**`VOICE_CLIENT_USER_CONFIG_FILE`** 分别覆盖上述两个路径。 + +示例见 `voice_confirmation_client/resources/voice_client.sample.json`(通常用作系统级模板)。 ## 日志(loguru) diff --git a/voice_confirmation_client/core/assignment_listener.py b/voice_confirmation_client/core/assignment_listener.py index c72d371..8408912 100644 --- a/voice_confirmation_client/core/assignment_listener.py +++ b/voice_confirmation_client/core/assignment_listener.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import os import threading import time from collections.abc import Callable @@ -76,7 +75,12 @@ class VoiceAssignmentListener: try: def _on_open(_ws: Any) -> None: - logger.info("WebSocket 已连接,等待服务端 voice_assignment 消息") + logger.info("WebSocket 已连接 terminal_id={!r}", self._terminal_id) + logger.info( + "若开录后仍无 voice_assignment:核对本机 ID 与 OR_SITE_CONFIG「voice_or_room_bindings」、" + "开录请求 camera_ids 能否解析到该终端;开录须 POST /client/surgeries/start " + "或(联调)POST /internal/demo/orchestrate-and-start。" + ) def _on_close(_ws: Any, close_status_code: Any, close_msg: Any) -> None: logger.warning( @@ -137,7 +141,3 @@ class VoiceAssignmentListener: 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() diff --git a/voice_confirmation_client/core/machine_config.py b/voice_confirmation_client/core/machine_config.py new file mode 100644 index 0000000..5fc50c5 --- /dev/null +++ b/voice_confirmation_client/core/machine_config.py @@ -0,0 +1,104 @@ +"""语音客户端配置:系统级(运维下发)+ 用户级(界面保存,覆盖同名字段)。 + +- ``VOICE_CLIENT_MACHINE_CONFIG_FILE``:仅改变**系统级**配置文件路径(测试或定制)。 +- ``VOICE_CLIENT_USER_CONFIG_FILE``:仅改变**用户级**配置文件路径(测试或定制)。 +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +from loguru import logger + +_DEFAULT_HTTP_BASE = "http://127.0.0.1:38080" +_CONFIG_FILENAME = "voice_client.json" + + +def machine_config_file_path() -> Path: + """系统级配置:运维部署;只读亦可。""" + override = (os.environ.get("VOICE_CLIENT_MACHINE_CONFIG_FILE") or "").strip() + if override: + return Path(override).expanduser() + if sys.platform == "win32": + base = os.environ.get("PROGRAMDATA", r"C:\ProgramData") + return Path(base) / "OperationRoomMonitor" / _CONFIG_FILENAME + if sys.platform == "darwin": + return ( + Path("/Library/Application Support/OperationRoomMonitor") + / _CONFIG_FILENAME + ) + return Path("/etc/operation-room-monitor") / _CONFIG_FILENAME + + +def user_voice_client_config_path() -> Path: + """用户级配置:当前登录用户可写,界面编辑后保存到此。""" + override = (os.environ.get("VOICE_CLIENT_USER_CONFIG_FILE") or "").strip() + if override: + return Path(override).expanduser() + if sys.platform == "win32": + base = os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local")) + return Path(base) / "OperationRoomMonitor" / _CONFIG_FILENAME + if sys.platform == "darwin": + return ( + Path.home() / "Library/Application Support/OperationRoomMonitor" / _CONFIG_FILENAME + ) + return Path.home() / ".config/operation-room-monitor" / _CONFIG_FILENAME + + +def _read_json_object(path: Path) -> dict[str, Any]: + if not path.is_file(): + return {} + try: + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + except OSError as exc: + logger.warning("无法读取语音客户端配置 {}: {}", path, exc) + return {} + except json.JSONDecodeError as exc: + logger.warning("语音客户端配置 JSON 无效 {}: {}", path, exc) + return {} + if not isinstance(data, dict): + logger.warning("语音客户端配置须为 JSON 对象: {}", path) + return {} + return data + + +def load_voice_client_config() -> dict[str, Any]: + """合并系统配置与用户配置;同键时用户覆盖系统。""" + system = _read_json_object(machine_config_file_path()) + user = _read_json_object(user_voice_client_config_path()) + merged: dict[str, Any] = dict(system) + merged.update(user) + return merged + + +def save_user_voice_client_config(*, voice_terminal_id: str, http_base_url: str) -> None: + """将当前界面上的连接参数写入用户级配置文件。""" + path = user_voice_client_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "voice_terminal_id": voice_terminal_id.strip(), + "http_base_url": http_base_url.strip().rstrip("/"), + } + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def voice_terminal_id_from_config(file_data: dict[str, Any]) -> str: + """合并后 dict 中的 ``voice_terminal_id``;缺省为空串。""" + v = file_data.get("voice_terminal_id") + return str(v).strip() if v is not None else "" + + +def http_base_url_from_config(file_data: dict[str, Any]) -> str: + """合并后 dict 中的 ``http_base_url``;缺省为 ``http://127.0.0.1:38080``。""" + v = file_data.get("http_base_url") + if v is not None and str(v).strip(): + return str(v).strip().rstrip("/") + return _DEFAULT_HTTP_BASE diff --git a/voice_confirmation_client/gui/main_window.py b/voice_confirmation_client/gui/main_window.py index 29b774c..f43bd02 100644 --- a/voice_confirmation_client/gui/main_window.py +++ b/voice_confirmation_client/gui/main_window.py @@ -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+C(aboutToQuit)共用。""" + self._rec_pulse_timer.stop() + self._set_recording_banner_active(False) if self._assignment_listener: self._assignment_listener.stop() self._assignment_listener = None diff --git a/voice_confirmation_client/resources/voice_client.sample.json b/voice_confirmation_client/resources/voice_client.sample.json new file mode 100644 index 0000000..51df23b --- /dev/null +++ b/voice_confirmation_client/resources/voice_client.sample.json @@ -0,0 +1,4 @@ +{ + "voice_terminal_id": "desktop-1", + "http_base_url": "http://127.0.0.1:38080" +}