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

@@ -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)。
# --- 海康 SDKLinux x86_64二进制勿提交仓库---
# HIKVISION_LIB_DIR=/opt/hikvision/lib

View File

@@ -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:

View File

@@ -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"}

View File

@@ -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

View File

@@ -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()

View File

@@ -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

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

View File

@@ -0,0 +1,4 @@
{
"voice_terminal_id": "desktop-1",
"http_base_url": "http://127.0.0.1:38080"
}