更新bug

This commit is contained in:
Your Name
2026-08-20 17:47:14 +08:00
parent 35f91ee37a
commit 5794f60c5d
67 changed files with 9257 additions and 1287 deletions
+108 -21
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
import json
import time
from collections.abc import Callable, Iterator, Mapping
from threading import Lock, RLock
from contextlib import contextmanager
from threading import Condition, RLock
from typing import Any
from urllib.parse import urljoin, urlsplit, urlunsplit
@@ -23,15 +24,6 @@ from doctor_workstation.core.errors import (
WorkWechatBindingRequiredError,
)
# One shared transport lock serializes every HTTP exchange in the process.
# The GUI fires page loads, dialog bundles and polling timers from many
# QThreadPool workers that all share a single ``httpx.Client``. Concurrent
# TLS handshakes on one shared SSLContext have repeatedly crashed the
# process inside libcrypto (access violation at a stable offset), so all
# network traffic now goes through this single lock. Requests stay
# off-thread, so the GUI never blocks; only the network itself is serialized.
_HTTP_TRANSPORT_LOCK = Lock()
class ApiClient:
"""A small, testable client implementing the admin API contract.
@@ -42,6 +34,7 @@ class ApiClient:
"""
API_VERSION = "1.9.4"
DEFAULT_MAX_PARALLEL_REQUESTS = 8
def __init__(
self,
@@ -55,6 +48,7 @@ class ApiClient:
transport: httpx.BaseTransport | None = None,
client: httpx.Client | None = None,
sleep: Callable[[float], None] = time.sleep,
max_parallel_requests: int = DEFAULT_MAX_PARALLEL_REQUESTS,
) -> None:
"""Create a client without performing any network requests."""
@@ -64,6 +58,8 @@ class ApiClient:
raise ValueError("retry_backoff must be non-negative")
if client is not None and transport is not None:
raise ValueError("pass either client or transport, not both")
if max_parallel_requests <= 0:
raise ValueError("max_parallel_requests must be positive")
self.base_url = self.normalise_base_url(base_url)
self.timeout = timeout
self.max_retries = max_retries
@@ -71,10 +67,68 @@ class ApiClient:
self._sleep = sleep
self._token = token.strip()
self._lock = RLock()
self._transport_lock = RLock()
self._client_condition = Condition(RLock())
self._available_clients: list[httpx.Client] = []
self._pooled_clients: list[httpx.Client] = []
self._active_requests = 0
self._closing = False
self._closed = False
self._max_parallel_requests = max_parallel_requests
# Production requests use a bounded pool of independent clients. This
# permits unrelated patient requests to run concurrently without
# sharing the SSLContext that previously caused native TLS crashes.
# A pool also works across PySide QRunnable boundaries, where Python
# thread-local state is not retained reliably.
# Explicit clients/transports remain on one locked client because
# their ownership and thread-safety contracts are unknown.
self._uses_client_pool = client is None and transport is None
self._owns_client = client is None
self._stream_transport = transport
self._verify = verify
self._client = client or httpx.Client(transport=transport, verify=verify)
self._client = (
None
if self._uses_client_pool
else client or httpx.Client(transport=transport, verify=verify)
)
@contextmanager
def _borrow_client(self) -> Iterator[httpx.Client]:
"""Yield a safe synchronous client for the calling worker thread."""
if not self._uses_client_pool:
with self._transport_lock:
with self._client_condition:
if self._closing or self._closed:
raise RuntimeError("ApiClient is closed")
if self._client is None: # Defensive; this mode always has one.
raise RuntimeError("ApiClient transport is unavailable")
yield self._client
return
with self._client_condition:
while True:
if self._closing or self._closed:
raise RuntimeError("ApiClient is closed")
if self._available_clients:
pooled_client = self._available_clients.pop()
break
if len(self._pooled_clients) < self._max_parallel_requests:
pooled_client = httpx.Client(verify=self._verify)
self._pooled_clients.append(pooled_client)
break
self._client_condition.wait()
self._active_requests += 1
try:
yield pooled_client
finally:
with self._client_condition:
self._active_requests -= 1
self._available_clients.append(pooled_client)
if self._active_requests == 0:
self._client_condition.notify_all()
else:
self._client_condition.notify()
@staticmethod
def normalise_base_url(base_url: str) -> str:
@@ -184,9 +238,9 @@ class ApiClient:
) -> Iterator[dict[str, Any]]:
"""POST JSON and yield parsed server-sent events on an isolated client.
Streaming deliberately does not use ``_HTTP_TRANSPORT_LOCK`` or the
process-wide JSON client. A diagnosis response can remain open for
more than a minute and must not block unrelated page requests.
Streaming deliberately does not use the bounded short-request client
pool. A diagnosis response can remain open for more than a minute and
must not consume capacity needed by unrelated page requests.
"""
url = self._endpoint_url(endpoint)
@@ -245,8 +299,8 @@ class ApiClient:
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must resolve to an absolute http(s) URL")
try:
with _HTTP_TRANSPORT_LOCK:
response = self._client.get(
with self._borrow_client() as client:
response = client.get(
target,
headers={"Accept": "image/*,application/octet-stream;q=0.8"},
timeout=self.timeout,
@@ -324,10 +378,10 @@ class ApiClient:
attempts = self.max_retries + 1 if verb == "GET" else 1
response: httpx.Response | None = None
request_timeout = self.timeout if timeout is None else timeout
with _HTTP_TRANSPORT_LOCK:
with self._borrow_client() as client:
for attempt in range(attempts):
try:
response = self._client.request(
response = client.request(
verb,
url,
params=dict(params) if params is not None else None,
@@ -360,9 +414,42 @@ class ApiClient:
def close(self) -> None:
"""Close the internally-created HTTP transport."""
if self._owns_client:
with _HTTP_TRANSPORT_LOCK:
self._client.close()
with self._client_condition:
if self._closed:
return
if self._closing:
while not self._closed:
self._client_condition.wait()
return
self._closing = True
self._client_condition.notify_all()
clients: list[httpx.Client] = []
if self._uses_client_pool:
with self._client_condition:
while self._active_requests:
self._client_condition.wait()
clients = list(self._pooled_clients)
self._available_clients.clear()
self._pooled_clients.clear()
else:
with self._transport_lock:
if self._owns_client and self._client is not None:
clients = [self._client]
close_error: Exception | None = None
for client in clients:
try:
client.close()
except Exception as error: # Close every transport before surfacing one failure.
if close_error is None:
close_error = error
with self._client_condition:
self._closed = True
self._closing = False
self._client_condition.notify_all()
if close_error is not None:
raise close_error
def __enter__(self) -> ApiClient:
"""Return this client for use as a context manager."""
@@ -0,0 +1,602 @@
"""Persistent local-audio capture and COS upload queue.
Local call recordings are medical records. They must survive application
restarts and failed network requests, so the queue deliberately keeps the
audio file after a successful upload as well as after a failed one.
"""
from __future__ import annotations
import queue
import sqlite3
import threading
from collections.abc import Callable
from concurrent.futures import Future
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from os import PathLike
from pathlib import Path
from typing import Any
from uuid import uuid4
from platformdirs import user_data_path
_MAX_UPLOAD_WORKERS = 3
_RETRYABLE_STATUSES = {"pending", "failed"}
_RECOVERY_LOCK = threading.Lock()
_RECOVERED_DATABASES: set[Path] = set()
_MANAGER_LOCK = threading.Lock()
_MANAGERS: dict[tuple[int, Path], LocalAudioUploadManager] = {}
def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _clean_room_id(value: Any) -> str:
room_id = str(value or "").strip()
return "" if room_id == "0" else room_id
def _clean_call_record_id(value: Any, *, required: bool) -> int | None:
if value in (None, "") and not required:
return None
try:
call_record_id = int(value)
except (TypeError, ValueError) as error:
raise ValueError("通话记录 ID 无效。") from error
if call_record_id <= 0:
raise ValueError("通话记录 ID 无效。")
return call_record_id
def _default_root() -> Path:
return (
user_data_path(
"ZhenyangDoctorWorkstation",
appauthor="ZhenYangTang",
ensure_exists=True,
)
/ "local-call-audio"
)
@dataclass(frozen=True, slots=True)
class LocalAudioRecord:
id: int
session_id: str
diagnosis_id: int
call_record_id: int | None
room_id: str
mime_type: str
file_path: Path
size_bytes: int
status: str
error_text: str
uploaded_url: str
attempts: int
created_at: str
updated_at: str
uploaded_at: str
@property
def exists(self) -> bool:
return self.file_path.is_file()
class LocalAudioQueueStore:
"""SQLite-backed manifest for durable local call recordings."""
def __init__(self, root: str | PathLike[str] | None = None) -> None:
self.root = Path(root).expanduser().resolve() if root else _default_root().resolve()
self.files_dir = self.root / "files"
self.files_dir.mkdir(parents=True, exist_ok=True)
self.database_path = self.root / "queue.sqlite3"
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=10.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 10000")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode = WAL")
# Serialise schema inspection and ALTER TABLE across two workstation
# processes. The second process waits, then re-checks the columns.
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS local_audio_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
diagnosis_id INTEGER NOT NULL,
call_record_id INTEGER,
room_id TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL,
file_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_text TEXT NOT NULL DEFAULT '',
uploaded_url TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT ''
)
"""
)
columns = {
str(row["name"])
for row in connection.execute(
"PRAGMA table_info(local_audio_uploads)"
).fetchall()
}
if "room_id" not in columns:
connection.execute(
"ALTER TABLE local_audio_uploads "
"ADD COLUMN room_id TEXT NOT NULL DEFAULT ''"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS idx_local_audio_diagnosis "
"ON local_audio_uploads(diagnosis_id, id DESC)"
)
# Recover interrupted work once per process. Creating the management
# dialog while a worker is active must not move that worker back to
# ``pending`` and accidentally upload the same medical file twice.
with _RECOVERY_LOCK:
if self.database_path in _RECOVERED_DATABASES:
return
with self._connect() as connection:
now = _utc_now()
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'pending',
error_text = '应用上次退出时上传尚未完成,可重试。',
updated_at = ?
WHERE status = 'uploading'
""",
(now,),
)
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'invalid',
error_text = '应用上次退出时录音尚未完成。',
updated_at = ?
WHERE status = 'recording'
""",
(now,),
)
_RECOVERED_DATABASES.add(self.database_path)
@staticmethod
def _from_row(row: sqlite3.Row) -> LocalAudioRecord:
raw_call_record_id = row["call_record_id"]
return LocalAudioRecord(
id=int(row["id"]),
session_id=str(row["session_id"]),
diagnosis_id=int(row["diagnosis_id"]),
call_record_id=(
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
),
room_id=_clean_room_id(row["room_id"]),
mime_type=str(row["mime_type"]),
file_path=Path(str(row["file_path"])).resolve(),
size_bytes=int(row["size_bytes"] or 0),
status=str(row["status"]),
error_text=str(row["error_text"] or ""),
uploaded_url=str(row["uploaded_url"] or ""),
attempts=int(row["attempts"] or 0),
created_at=str(row["created_at"]),
updated_at=str(row["updated_at"]),
uploaded_at=str(row["uploaded_at"] or ""),
)
def begin_recording(
self,
*,
session_id: str,
diagnosis_id: int,
mime_type: str,
call_record_id: int | None = None,
room_id: str = "",
) -> LocalAudioRecord:
suffix = ".ogg" if mime_type.startswith("audio/ogg") else ".webm"
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
name = f"call-{int(diagnosis_id)}-{timestamp}-{uuid4().hex[:10]}{suffix}.part"
path = (self.files_dir / name).resolve()
now = _utc_now()
clean_call_record_id = _clean_call_record_id(call_record_id, required=False)
clean_room_id = _clean_room_id(room_id)
with self._connect() as connection:
cursor = connection.execute(
"""
INSERT INTO local_audio_uploads (
session_id, diagnosis_id, call_record_id, room_id,
mime_type, file_path,
status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'recording', ?, ?)
""",
(
session_id,
int(diagnosis_id),
clean_call_record_id,
clean_room_id,
mime_type,
str(path),
now,
now,
),
)
record_id = int(cursor.lastrowid)
record = self.get(record_id)
if record is None: # pragma: no cover - SQLite insert/read invariant.
raise RuntimeError("无法创建本地录音记录。")
return record
def finalize_recording(self, record_id: int, *, size_bytes: int) -> LocalAudioRecord:
record = self.require(record_id)
if record.status != "recording":
raise RuntimeError("本地录音记录状态不允许完成。")
if record.file_path.suffix == ".part":
final_path = record.file_path.with_suffix("")
record.file_path.replace(final_path)
else:
final_path = record.file_path
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET file_path = ?, size_bytes = ?, status = 'pending',
error_text = '', updated_at = ?
WHERE id = ?
""",
(str(final_path), int(size_bytes), now, int(record_id)),
)
return self.require(record_id)
def mark_invalid(self, record_id: int, message: str) -> LocalAudioRecord:
record = self.require(record_id)
size = record.file_path.stat().st_size if record.file_path.is_file() else 0
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET size_bytes = ?, status = 'invalid', error_text = ?, updated_at = ?
WHERE id = ?
""",
(size, str(message)[:1000], now, int(record_id)),
)
return self.require(record_id)
def bind_identity(
self,
record_id: int,
*,
call_record_id: int,
room_id: str = "",
) -> LocalAudioRecord:
"""Bind one recording to a call without overwriting a different identity."""
clean_call_record_id = _clean_call_record_id(call_record_id, required=True)
assert clean_call_record_id is not None
clean_room_id = _clean_room_id(room_id)
now = _utc_now()
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE local_audio_uploads
SET call_record_id = CASE
WHEN call_record_id IS NULL THEN ?
ELSE call_record_id
END,
room_id = CASE
WHEN room_id = '' AND ? <> '' THEN ?
ELSE room_id
END,
updated_at = ?
WHERE id = ?
AND (call_record_id IS NULL OR call_record_id = ?)
AND (? = '' OR room_id = '' OR room_id = ?)
""",
(
clean_call_record_id,
clean_room_id,
clean_room_id,
now,
int(record_id),
clean_call_record_id,
clean_room_id,
clean_room_id,
),
)
if cursor.rowcount != 1:
exists = connection.execute(
"SELECT 1 FROM local_audio_uploads WHERE id = ?",
(int(record_id),),
).fetchone()
if exists is None:
raise LookupError("本地录音记录不存在。")
raise RuntimeError("本地录音的通话记录 ID 或房间号发生冲突。")
return self.require(record_id)
def set_call_record_id(self, record_id: int, call_record_id: int) -> None:
self.bind_identity(record_id, call_record_id=call_record_id)
def update_status(self, record_id: int, status: str, message: str = "") -> None:
if status not in {"pending", "uploading", "uploaded", "failed", "invalid"}:
raise ValueError("unsupported local-audio status")
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = ?, error_text = ?, updated_at = ?
WHERE id = ?
""",
(status, str(message)[:1000], now, int(record_id)),
)
def get(self, record_id: int) -> LocalAudioRecord | None:
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM local_audio_uploads WHERE id = ?",
(int(record_id),),
).fetchone()
return self._from_row(row) if row is not None else None
def require(self, record_id: int) -> LocalAudioRecord:
record = self.get(record_id)
if record is None:
raise LookupError("本地录音记录不存在。")
return record
def list_records(self, *, diagnosis_id: int | None = None) -> list[LocalAudioRecord]:
with self._connect() as connection:
if diagnosis_id is None:
rows = connection.execute(
"SELECT * FROM local_audio_uploads ORDER BY id DESC"
).fetchall()
else:
rows = connection.execute(
"""
SELECT * FROM local_audio_uploads
WHERE diagnosis_id = ?
ORDER BY id DESC
""",
(int(diagnosis_id),),
).fetchall()
return [self._from_row(row) for row in rows]
def claim_upload(self, record_id: int) -> LocalAudioRecord | None:
now = _utc_now()
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'uploading', error_text = '', attempts = attempts + 1,
updated_at = ?
WHERE id = ? AND status IN ('pending', 'failed')
""",
(now, int(record_id)),
)
if cursor.rowcount != 1:
return None
return self.require(record_id)
def mark_uploaded(self, record_id: int, uploaded_url: str = "") -> None:
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'uploaded', uploaded_url = ?, error_text = '',
uploaded_at = ?, updated_at = ?
WHERE id = ?
""",
(str(uploaded_url)[:2000], now, now, int(record_id)),
)
def mark_failed(self, record_id: int, message: str) -> None:
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'failed', error_text = ?, updated_at = ?
WHERE id = ?
""",
(str(message)[:1000] or "上传失败。", now, int(record_id)),
)
def retry(self, record_id: int) -> LocalAudioRecord:
record = self.require(record_id)
if record.status not in {"failed", "pending"}:
raise RuntimeError("当前录音状态不允许重试上传。")
if not record.file_path.is_file() or record.file_path.stat().st_size <= 0:
raise RuntimeError("本地录音文件不存在或为空。")
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'pending', error_text = '', updated_at = ?
WHERE id = ?
""",
(now, int(record_id)),
)
return self.require(record_id)
def _upload_url(result: Any) -> str:
if not isinstance(result, dict):
return ""
data = result.get("data")
payload = data if isinstance(data, dict) else result
for key in ("file_url", "fileUrl", "url", "recording_url"):
value = payload.get(key)
if value:
return str(value)
return ""
class LocalAudioUploadManager:
"""Small daemon worker pool that uploads up to three recordings at once."""
def __init__(
self,
repository: Any,
store: LocalAudioQueueStore,
*,
max_workers: int = _MAX_UPLOAD_WORKERS,
) -> None:
self.repository = repository
self.store = store
self.max_workers = max(1, min(int(max_workers), 8))
self._jobs: queue.Queue[tuple[int, Future[bool]]] = queue.Queue()
self._active: dict[int, Future[bool]] = {}
self._upload_listeners: set[Callable[[LocalAudioRecord], None]] = set()
self._lock = threading.Lock()
for index in range(self.max_workers):
threading.Thread(
target=self._worker,
name=f"local-audio-upload-{index + 1}",
daemon=True,
).start()
def submit(self, record_id: int) -> Future[bool]:
record_id = int(record_id)
with self._lock:
existing = self._active.get(record_id)
if existing is not None:
return existing
future: Future[bool] = Future()
self._active[record_id] = future
self._jobs.put((record_id, future))
return future
def add_upload_listener(
self,
listener: Callable[[LocalAudioRecord], None],
) -> None:
with self._lock:
self._upload_listeners.add(listener)
def remove_upload_listener(
self,
listener: Callable[[LocalAudioRecord], None],
) -> None:
with self._lock:
self._upload_listeners.discard(listener)
def _notify_uploaded(self, record_id: int) -> None:
try:
record = self.store.require(record_id)
except Exception:
return
with self._lock:
listeners = tuple(self._upload_listeners)
for listener in listeners:
with suppress(Exception):
listener(record)
def submit_retryable(self, *, diagnosis_id: int | None = None) -> list[Future[bool]]:
futures: list[Future[bool]] = []
for record in self.store.list_records(diagnosis_id=diagnosis_id):
if record.status in _RETRYABLE_STATUSES:
futures.append(self.submit(record.id))
return futures
def submit_pending(self, *, diagnosis_id: int | None = None) -> list[Future[bool]]:
"""Resume only queued work; failed uploads require an explicit retry."""
return [
self.submit(record.id)
for record in self.store.list_records(diagnosis_id=diagnosis_id)
if record.status == "pending"
]
def _worker(self) -> None:
while True:
record_id, future = self._jobs.get()
try:
try:
succeeded = self._upload(record_id)
except Exception as error: # Defensive boundary around a daemon worker.
with suppress(Exception):
self.store.mark_failed(
record_id,
str(error) or "本地录音上传失败。",
)
succeeded = False
if not future.done():
future.set_result(succeeded)
finally:
with self._lock:
self._active.pop(record_id, None)
self._jobs.task_done()
def _upload(self, record_id: int) -> bool:
record = self.store.claim_upload(record_id)
if record is None:
current = self.store.get(record_id)
return bool(current and current.status == "uploaded")
if not record.file_path.is_file() or record.file_path.stat().st_size <= 0:
self.store.mark_failed(record.id, "本地录音文件不存在或为空。")
return False
if record.call_record_id is None or record.call_record_id <= 0:
self.store.mark_failed(record.id, "未保存对应的通话记录编号,暂不能上传。")
return False
upload = getattr(self.repository, "upload_call_recording", None)
if not callable(upload):
self.store.mark_failed(record.id, "当前服务未提供本地录音上传接口。")
return False
try:
result = upload(
path=record.file_path,
diagnosis_id=record.diagnosis_id,
call_record_id=record.call_record_id,
mime_type=record.mime_type,
)
except Exception as error:
self.store.mark_failed(record.id, str(error) or "本地录音上传 COS 失败。")
return False
uploaded_url = _upload_url(result)
if not uploaded_url:
self.store.mark_failed(record.id, "服务器未返回本地录音文件地址。")
return False
self.store.mark_uploaded(record.id, uploaded_url)
self._notify_uploaded(record.id)
return True
def get_local_audio_upload_manager(
repository: Any,
store: LocalAudioQueueStore | None = None,
) -> tuple[LocalAudioQueueStore, LocalAudioUploadManager]:
"""Return one three-worker uploader per repository and manifest.
Video windows and the recording-management dialog may be opened many times
during a workstation session. Sharing the pool prevents each window from
creating another set of background threads while SQLite's atomic claim still
protects against duplicate uploads across processes.
"""
resolved_store = store or LocalAudioQueueStore()
key = (id(repository), resolved_store.database_path)
with _MANAGER_LOCK:
manager = _MANAGERS.get(key)
if manager is None:
manager = LocalAudioUploadManager(
repository,
resolved_store,
max_workers=_MAX_UPLOAD_WORKERS,
)
_MANAGERS[key] = manager
return resolved_store, manager
@@ -2588,8 +2588,11 @@ class DemoDoctorRepository:
"room_id": "manual-upload",
"status": 2,
"status_text": "已结束",
"recording_status_text": "待上传",
"recording_urls_list": [],
"recording_status_text": "待上传",
"recording_urls_list": [],
"local_audio_status": 0,
"local_audio_status_text": "无本地录音",
"local_audio_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
@@ -2633,25 +2636,60 @@ class DemoDoctorRepository:
record["recording_status_text"] = "录制完成"
return deepcopy(record)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload then attach a demo video using the production sequence."""
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id)["id"])
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Upload then attach a demo local-audio file using the production sequence."""
source_path = Path(path)
is_audio = source_path.suffix.lower() in {
".ogg",
".opus",
".mp3",
".wav",
".m4a",
".aac",
".amr",
".wma",
} or (
source_path.suffix.lower() == ".webm"
and str(mime_type or "").lower().startswith("audio/")
)
file_url = self.upload_material(path, "file" if is_audio else "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id)["id"])
if is_audio:
with self._lock:
record = next(
(
row
for row in self._call_records.setdefault(diagnosis_id, [])
if int(row.get("id") or 0) == int(target_id)
),
None,
)
if record is None:
raise RepositoryNotFoundError("call record not found")
urls = record.setdefault("local_audio_urls_list", [])
if file_url not in urls:
urls.append(file_url)
record["local_audio_status"] = 2
record["local_audio_status_text"] = "已保存"
else:
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
"media_kind": "local_audio" if is_audio else "video",
"completed": True,
}
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -2890,8 +2928,11 @@ class DemoDoctorRepository:
replay = {
**record,
"status_text": "呼叫中",
"recording_status_text": "未录制",
"recording_urls_list": [],
"recording_status_text": "未录制",
"recording_urls_list": [],
"local_audio_status": 0,
"local_audio_status_text": "无本地录音",
"local_audio_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
@@ -2903,13 +2944,21 @@ class DemoDoctorRepository:
self._call_records.setdefault(diagnosis_id, []).insert(0, replay)
return deepcopy(record)
def end_call(self, diagnosis_id: int) -> dict[str, Any]:
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> dict[str, Any]:
"""Persistently mark the demo call record ended."""
with self._lock:
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
if call_record_id is not None and str(record.get("id") or "") != str(
call_record_id
):
raise RepositoryNotFoundError(
f"active call record {call_record_id} for {diagnosis_id} not found"
)
record["status"] = "ended"
record["ended_at"] = datetime.now().replace(microsecond=0).isoformat(sep=" ")
replay = next(
@@ -2930,15 +2979,27 @@ class DemoDoctorRepository:
)
return deepcopy(record)
def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]:
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> dict[str, Any]:
"""Persist the room identifier on the active demo call."""
if not room_id.strip():
raise ValueError("room_id is required")
with self._lock:
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
if call_record_id is not None and str(record.get("id") or "") != str(
call_record_id
):
raise RepositoryNotFoundError(
f"active call record {call_record_id} for {diagnosis_id} not found"
)
record["room_id"] = room_id.strip()
record["status"] = "connected"
replay = next(
@@ -2957,9 +3018,10 @@ class DemoDoctorRepository:
"status_text": "通话中",
}
)
return {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
return {
"diagnosis_id": diagnosis_id,
"call_record_id": record["id"],
"room_id": room_id.strip(),
"cloud_recording": {"started": False, "message": "演示模式不录制"},
}
+175 -71
View File
@@ -2,16 +2,18 @@
from __future__ import annotations
import mimetypes
import re
import time
import mimetypes
import re
import time
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import suppress
from datetime import date
from contextlib import suppress
from datetime import date
from inspect import Parameter, signature
from io import BytesIO
from os import PathLike
from pathlib import Path
from typing import Any, Final, Literal, Protocol
from pathlib import Path
from typing import Any, Final, Literal, Protocol
from uuid import uuid4
from doctor_workstation.core.errors import (
ApiBusinessError,
@@ -631,14 +633,15 @@ class DoctorRepository(Protocol):
) -> Any:
"""Attach one uploaded replay URI to a call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload a local video then attach it to a real call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Upload a locally captured call recording to a real call record."""
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -699,11 +702,19 @@ class DoctorRepository(Protocol):
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create a call record."""
def end_call(self, diagnosis_id: int) -> Any:
"""End the active diagnosis call."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> Any:
"""End the exact active call when its server identity is available."""
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> Any:
"""Bind a TRTC room to the exact active call and start cloud recording."""
def start_call_transcription(
self,
@@ -2322,31 +2333,75 @@ class RemoteDoctorRepository:
body["call_record_id"] = call_record_id
return self.client.post("tcm.diagnosis/attachLocalCallRecording", body)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Perform the same upload/create/attach sequence as ``CallRecordPanel``."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
# Match the admin component exactly: upload first so a failed upload does
# not leave behind an empty synthetic call record.
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id).get("id") or 0)
if target_id <= 0:
raise ApiProtocolError("tcm.diagnosis/createManualCallRecord returned no id")
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Chunk-upload one local audio recording and attach it atomically."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if call_record_id is not None and call_record_id <= 0:
raise ValueError("call_record_id must be positive")
recording_path = Path(path).expanduser().resolve()
if not recording_path.is_file():
raise ValueError(f"recording file does not exist: {recording_path}")
file_size = recording_path.stat().st_size
if file_size <= 0:
raise ValueError("recording file must not be empty")
chunk_size = 4 * 1024 * 1024
chunk_total = (file_size + chunk_size - 1) // chunk_size
upload_id = f"local_audio_{uuid4().hex}"
file_name = recording_path.name
clean_mime = str(mime_type or "audio/webm").strip()[:120] or "audio/webm"
final_result: Mapping[str, Any] | None = None
bound_call_record_id = call_record_id
with recording_path.open("rb") as stream:
for chunk_index in range(chunk_total):
content = stream.read(chunk_size)
if not content:
raise ApiProtocolError("local recording ended before all chunks were read")
result = self.client.post_multipart(
"tcm.diagnosis/uploadCallRecording",
files={"file": (file_name, content, clean_mime)},
data={
"diagnosis_id": diagnosis_id,
"call_record_id": bound_call_record_id or 0,
"upload_id": upload_id,
"file_name": file_name,
"file_size": file_size,
"mime_type": clean_mime,
"chunk_index": chunk_index,
"chunk_total": chunk_total,
},
)
final_result = _require_mapping(
result,
"tcm.diagnosis/uploadCallRecording",
)
returned_id = int(final_result.get("call_record_id") or 0)
if returned_id <= 0:
raise ApiProtocolError(
"uploadCallRecording returned no call_record_id"
)
if bound_call_record_id is None:
bound_call_record_id = returned_id
elif returned_id != bound_call_record_id:
raise ApiProtocolError(
"uploadCallRecording returned a different call_record_id"
)
if final_result is None or not bool(final_result.get("completed")):
raise ApiProtocolError("uploadCallRecording did not complete the local audio upload")
file_url = str(final_result.get("file_url") or "").strip()
if not file_url:
raise ApiProtocolError("uploadCallRecording returned no COS file URL")
return dict(final_result)
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -2546,20 +2601,53 @@ class RemoteDoctorRepository:
)
return {"call_record_id": call_record_id}
def end_call(self, diagnosis_id: int) -> Any:
"""End the active call/recording associated with a diagnosis."""
return self.client.post("tcm.diagnosis/endCall", {"diagnosis_id": diagnosis_id})
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
if not room_id.strip():
raise ValueError("room_id is required")
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
@staticmethod
def _optional_call_record_id(call_record_id: int | str | None) -> int | str | None:
if call_record_id is None:
return None
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id must be a positive identifier")
try:
normalized = int(str(call_record_id).strip())
except (TypeError, ValueError) as exc:
raise ValueError("call_record_id must be a positive identifier") from exc
if normalized <= 0:
raise ValueError("call_record_id must be a positive identifier")
return normalized
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> Any:
"""Stop COS cloud recording and end the exact call record."""
body: dict[str, Any] = {"diagnosis_id": diagnosis_id}
normalized_id = self._optional_call_record_id(call_record_id)
if normalized_id is not None:
body["call_record_id"] = normalized_id
return self.client.post("tcm.diagnosis/endCall", body)
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> Any:
"""Bind the actual TRTC room and auto-start COS cloud recording."""
if not room_id.strip():
raise ValueError("room_id is required")
body: dict[str, Any] = {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
}
normalized_id = self._optional_call_record_id(call_record_id)
if normalized_id is not None:
body["call_record_id"] = normalized_id
return self.client.post(
"tcm.diagnosis/bindCallRoom",
body,
)
@staticmethod
def _transcription_identity(
@@ -2767,24 +2855,40 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
return value
def _client_request(
def _client_request(
client: Any,
method: str,
endpoint: str,
payload: Mapping[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
"""Call get/post, ignoring timeout kwargs that test doubles do not accept."""
fn = getattr(client, method)
params = dict(payload or {})
try:
if timeout is None:
return fn(endpoint, params)
return fn(endpoint, params, timeout=timeout)
except TypeError:
return fn(endpoint, params)
) -> Any:
"""Call get/post once, omitting timeout for legacy test doubles.
Capability is determined before invocation. Catching ``TypeError`` from
the call itself is unsafe for POST because the transport may already have
submitted the write before raising, and a fallback call would duplicate it.
"""
fn = getattr(client, method)
params = dict(payload or {})
if timeout is not None:
try:
parameters = signature(fn).parameters.values()
except (TypeError, ValueError):
parameters = ()
accepts_timeout = any(
parameter.kind is Parameter.VAR_KEYWORD
or (
parameter.name == "timeout"
and parameter.kind
in {Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY}
)
for parameter in parameters
)
if accepts_timeout:
return fn(endpoint, params, timeout=timeout)
return fn(endpoint, params)
def _material_kind(
@@ -260,11 +260,32 @@ def _appointment_status(record: Any) -> int:
return _as_int(status, -1)
def _appointment_active(record: Any) -> bool:
return _has_appointment(record) and _appointment_status(record) == 1
def _single_cancellable_appointment(record: Any) -> Any | None:
def _appointment_active(record: Any) -> bool:
return _has_appointment(record) and _appointment_status(record) == 1
def video_call_state(record: Any) -> str:
"""Return the server-owned state of the latest video-call session."""
return str(
first_value(
record,
"video_call_hint.state",
"video_hint.state",
"video_hint",
default="none",
)
or "none"
).strip().lower()
def video_call_is_live(record: Any) -> bool:
"""Only a room already opened by the doctor may be joined from this list."""
return video_call_state(record) == "live"
def _single_cancellable_appointment(record: Any) -> Any | None:
"""Return the sole cancellable appointment, never an ambiguous row-level fallback."""
if not _has_appointment(record) or _appointment_status(record) not in {1, 4}:
@@ -317,12 +338,34 @@ def _video_ids_complete(record: Any) -> bool:
)
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
if force_open:
return "开方"
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
return "查看处方" if audit == 1 and voided != 1 else "开方"
def prescription_action(record: Any, *, force_open: bool = False) -> tuple[str, str]:
"""Return the label and immutable click intent for the current appointment."""
if force_open:
return "开方", "open"
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
explicit_current = first_value(record, "current_has_prescription", default=None)
if explicit_current is not None:
has_current = _as_bool(explicit_current)
elif _as_int(first_value(record, "current_prescription_id", default=0)) > 0:
has_current = True
else:
explicit_legacy = first_value(record, "has_prescription", default=None)
has_current = (
_as_bool(explicit_legacy)
if explicit_legacy is not None
else audit in {0, 1, 2}
)
if not has_current:
return "开方", "open"
if audit == 1 and voided != 1:
return "查看处方", "view"
return "编辑处方", "edit"
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
return prescription_action(record, force_open=force_open)[0]
class FlowLayout(QLayout):
@@ -1486,25 +1529,32 @@ class DiagnosisTableHost(QFrame):
layout.setContentsMargins(3, 2, 3, 2)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
video_capable = self.action_policy.get("video_call", False)
if video_capable and _appointment_active(record):
button = QToolButton(host)
button.setText("进入视频问诊")
button.setProperty("rowLink", "primary")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip("进入医生端视频问诊(将使用摄像头和麦克风)")
button.setEnabled(_video_ids_complete(record))
if not button.isEnabled():
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
button.clicked.connect(
lambda _checked=False, item=record: self.video_requested.emit(item)
)
layout.addWidget(button)
else:
status = "—"
if video_capable:
status = {3: "已完成", 4: "已过号"}.get(
_appointment_status(record), "未挂号" if not _has_appointment(record) else "—"
)
call_state = video_call_state(record)
if video_capable and _appointment_active(record) and call_state == "live":
button = QToolButton(host)
button.setText("进入视频问诊")
button.setProperty("rowLink", "primary")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip("医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
button.setEnabled(_video_ids_complete(record))
if not button.isEnabled():
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
button.clicked.connect(
lambda _checked=False, item=record: self.video_requested.emit(item)
)
layout.addWidget(button)
else:
status = "—"
if video_capable:
if call_state == "pending_room":
status = "等待接通"
elif _appointment_active(record):
status = "暂无通话"
else:
status = {3: "已完成", 4: "已过号"}.get(
_appointment_status(record),
"未挂号" if not _has_appointment(record) else "—",
)
label = QLabel(display_text(status), host)
label.setProperty("fixedMuted", True)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -2201,7 +2251,8 @@ __all__ = [
"DiagnosisLoadingOverlay",
"DiagnosisPager",
"DiagnosisTableHost",
"DiagnosisTableModel",
"FlowLayout",
"FlowWidget",
]
"DiagnosisTableModel",
"FlowLayout",
"FlowWidget",
"prescription_action",
]
@@ -3,7 +3,9 @@
from __future__ import annotations
import json
import re
from collections.abc import Mapping, Sequence
from collections import namedtuple
from datetime import datetime
from threading import Event
from typing import Any
@@ -214,6 +216,32 @@ QDialog#AiConsultDialog QLabel#AiConsultBody {
background-color: transparent;
font-size: 12px;
}
QDialog#AiConsultDialog QLabel#AiConsultInsightMarker {
color: #5B67F1;
background-color: #EEF1FF;
border: 1px solid #DDE3FA;
border-radius: 9px;
min-width: 26px;
max-width: 26px;
min-height: 22px;
max-height: 22px;
font-size: 11px;
font-weight: 700;
padding: 0;
}
QDialog#AiConsultDialog QLabel#AiConsultInsightText {
color: #3F4E75;
background-color: transparent;
font-size: 12.5px;
padding: 1px 0;
line-height: 22px;
}
QDialog#AiConsultDialog QLabel#AiConsultInsightPlaceholder {
color: #8A94B3;
background-color: transparent;
font-size: 12px;
padding: 2px 0;
}
QDialog#AiConsultDialog QLabel#AiConsultTag[level="high"] {
color: #F15B67;
background-color: #FFF1F3;
@@ -994,6 +1022,149 @@ def _analysis_markdown(analysis: Mapping[str, Any]) -> str:
return "\n\n".join(parts)
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
_INSIGHT_LIST_LOOKAHEAD_RE = re.compile(r"(?:^|(?<=[::;;。\s]))(\d{1,2})\s*([\.、))])\s*")
_INSIGHT_LABEL_RE = re.compile(r"^([\u4e00-\u9fa5A-Za-z()()·\s]{1,12})[::]\s*(.*)$")
def _split_insight_segments(text: str) -> list[_InsightItem]:
"""把较稠密的分析段落拆成结构化条目,便于分条阅读。
识别模式:
- 数字编号开头 (1. / 2、 / 3) / 4)) 作为 list_item
- 文本中嵌入的编号列表 (如 "...阶段:1. 肾功评估:... 2. 代谢评估:...")
也会被识别,编号前的内容视为序言
- "短中文标签:内容" 中含 已知事实 / 建议 / 信息缺口 / 综合推断 等
可作为 lead_item, 标签部分加粗
- 其余按 。 / ; 分句, 每句成一个 plain_item
每种类型都会渲染成独立 QLabel, 以改善阅读节奏。
"""
raw = display_text(text, "").strip()
if not raw:
return []
# 检查是否包含嵌入式编号列表: 先找第一次出现 "。 数字。 " 或 "...:数字。 "
lookahead_match = _INSIGHT_LIST_LOOKAHEAD_RE.search(raw)
starts_with_list = _INSIGHT_LIST_RE.match(raw) is not None
if not starts_with_list and lookahead_match is not None:
# 切出序言 + 多个列表条目; 序言尽量停在最近的句末标点 / 冒号处
# 注意: lookahead 是 zero-width 的 lookbehind, 所以 start() 即编号数字位置
list_start = lookahead_match.start()
preamble_candidate = raw[:list_start].rstrip()
sentence_tail = re.search(r"[。;;][^。;;]*$", preamble_candidate)
if sentence_tail is not None:
preamble = preamble_candidate[: sentence_tail.end()].rstrip("。;;:: \t")
else:
preamble = preamble_candidate.rstrip(":: \t")
body = raw[list_start:].lstrip()
items: list[_InsightItem] = []
if preamble:
items.extend(_split_plain_paragraph(preamble))
items.extend(_split_numbered_list(body))
return items
if starts_with_list:
return _split_numbered_list(raw)
return _split_plain_paragraph(raw)
def _split_numbered_list(text: str) -> list[_InsightItem]:
"""把一段文本按编号列表解析;开头的非编号内容视为序言。"""
items: list[_InsightItem] = []
cursor = 0
length = len(text)
prefix_consumed = False
while cursor < length:
remainder = text[cursor:]
list_match = _INSIGHT_LIST_RE.match(remainder)
if list_match is None:
# 剩余部分按含序言的段落处理 (无编号前缀, 仍按句末分条)
tail = remainder.strip()
if tail and not prefix_consumed:
items.extend(_split_plain_paragraph(tail))
elif tail:
# 已消费过编号, 余下尾段按段落继续分条
items.extend(_split_plain_paragraph(tail))
break
prefix_consumed = True
marker = f"{list_match.group(1)}."
after_marker = remainder[list_match.end():]
# 在本条内找到最近的句末标点; 或下一个编号之前
sentence_match = re.search(r"[。;;\n]", after_marker)
candidate_ends: list[int] = []
if sentence_match is not None:
candidate_ends.append(sentence_match.end())
next_marker_match = re.search(
r"\s\d{1,2}\s*[\.、))]",
after_marker,
)
if next_marker_match is not None:
candidate_ends.append(next_marker_match.start() + 1)
if candidate_ends:
inner_end = min(candidate_ends)
else:
inner_end = len(after_marker)
segment = after_marker[:inner_end].strip("。;; \n").strip()
if segment:
items.append(_split_label_or_sentence(segment, marker=marker))
cursor += list_match.end() + inner_end
while cursor < length and text[cursor] in "。;;\n":
cursor += 1
return items
def _split_plain_paragraph(text: str) -> list[_InsightItem]:
items: list[_InsightItem] = []
if not text.strip():
return items
cursor = 0
length = len(text)
while cursor < length:
remainder = text[cursor:]
sentence_match = re.search(r"[。;;\n]", remainder)
if sentence_match is not None:
segment = remainder[: sentence_match.start()].strip()
if segment:
items.append(_split_label_or_sentence(segment))
cursor += sentence_match.end()
continue
tail = remainder.strip()
if tail:
items.append(_split_label_or_sentence(tail))
break
return items
def _split_label_or_sentence(segment: str, *, marker: str | None = None) -> _InsightItem:
"""对单段片段进一步检查是否含 '标签:内容' 结构。"""
label_match = _INSIGHT_LABEL_RE.match(segment)
if label_match:
label = label_match.group(1).strip()
body = label_match.group(2).strip()
if label and body:
kind = "list" if marker else "lead"
return _InsightItem(kind=kind, marker=marker or "", label=label, text=body)
if marker:
return _InsightItem(kind="list", marker=marker, label="", text=segment)
return _InsightItem(kind="plain", marker="", label="", text=segment)
def _escape_html(value: str) -> str:
"""转义富文本片段,防止分析文本中带 HTML/Markdown 误伤样式。"""
return (
(value or "")
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
class _FlowRow(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -1059,7 +1230,7 @@ class _ChatBubble(QWidget):
) -> None:
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(8, 4, 8, 4)
row.setContentsMargins(8, 6, 8, 6)
row.setSpacing(8)
avatar = QLabel("医" if role == "doctor" else "患" if role == "patient" else "AI")
avatar.setObjectName("AiConsultRole")
@@ -1383,7 +1554,7 @@ class AiConsultDialog(QDialog):
self.chat_host = QWidget()
self.chat_layout = QVBoxLayout(self.chat_host)
self.chat_layout.setContentsMargins(16, 16, 16, 8)
self.chat_layout.setSpacing(10)
self.chat_layout.setSpacing(12)
self.chat_layout.addStretch(1)
self.chat_scroll.setWidget(self.chat_host)
scroll_bar = self.chat_scroll.verticalScrollBar()
@@ -1576,7 +1747,7 @@ class AiConsultDialog(QDialog):
insight_title.setObjectName("AiConsultSection")
layout.addWidget(insight_title)
self.insight_host = QVBoxLayout()
self.insight_host.setSpacing(8)
self.insight_host.setSpacing(10)
layout.addLayout(self.insight_host)
tools_title = QLabel("快捷工具")
@@ -1708,27 +1879,78 @@ class AiConsultDialog(QDialog):
card = QFrame()
card.setObjectName("AiConsultInsightRow")
_style_surface(card)
inner = QHBoxLayout(card)
inner.setContentsMargins(10, 8, 10, 8)
text = QVBoxLayout()
text.setSpacing(2)
outer = QVBoxLayout(card)
outer.setContentsMargins(14, 12, 14, 14)
outer.setSpacing(10)
header_row = QHBoxLayout()
header_row.setSpacing(8)
header_row.setContentsMargins(0, 0, 0, 0)
heading = QLabel(title)
heading.setObjectName("AiConsultRecordTitle")
body = QLabel(detail)
body.setObjectName("AiConsultBody")
body.setWordWrap(True)
text.addWidget(heading)
text.addWidget(body)
inner.addLayout(text, 1)
header_row.addWidget(heading, 1)
badge = QLabel(tag)
badge.setObjectName("AiConsultTag")
_style_surface(badge)
badge.setProperty("level", level)
badge.style().unpolish(badge)
badge.style().polish(badge)
inner.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
header_row.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
outer.addLayout(header_row)
body_host = QVBoxLayout()
body_host.setSpacing(8)
body_host.setContentsMargins(0, 0, 0, 0)
segments = _split_insight_segments(detail)
if not segments:
placeholder = QLabel(detail or "暂无数据")
placeholder.setObjectName("AiConsultInsightPlaceholder")
placeholder.setWordWrap(True)
body_host.addWidget(placeholder)
else:
for segment in segments:
body_host.addWidget(self._build_insight_item(segment))
outer.addLayout(body_host)
self.insight_host.addWidget(card)
def _build_insight_item(self, item: _InsightItem) -> QWidget:
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
marker_label: QLabel | None = None
if item.kind == "list" and item.marker:
marker_label = QLabel(item.marker)
marker_label.setObjectName("AiConsultInsightMarker")
marker_label.setAlignment(
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter
)
text_label = QLabel()
text_label.setObjectName("AiConsultInsightText")
text_label.setWordWrap(True)
text_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
text_label.setTextFormat(Qt.TextFormat.RichText)
if item.label:
html_label = _escape_html(item.label)
html_body = _escape_html(item.text or "")
text_label.setText(
f"<span style='color:#15224A;font-weight:600;'>{html_label}</span>"
f"<span style='color:#7A87A8;'>:</span>"
f"<span style='color:#3F4E75;'>{html_body}</span>"
)
else:
text_label.setText(_escape_html(item.text or ""))
if marker_label is not None:
layout.addWidget(marker_label, 0, Qt.AlignmentFlag.AlignTop)
layout.addWidget(text_label, 1)
return row
def _apply_header(
self,
payload: Mapping[str, Any],
@@ -3,6 +3,7 @@
from __future__ import annotations
import re
import sqlite3
from collections.abc import Mapping, Sequence
from contextlib import suppress
from typing import Any
@@ -69,6 +70,7 @@ from ..widgets import (
page_total,
run_async,
)
from .local_audio_queue import LocalAudioQueueDialog
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
@@ -747,6 +749,7 @@ class DiagnosisDialog(QDialog):
"""
saved = Signal()
_local_audio_upload_completed = Signal(int, int)
def __init__(
self,
@@ -841,6 +844,10 @@ class DiagnosisDialog(QDialog):
self._recording_players: list[RecordingPlayerDialog] = []
self._inline_recording_cells: list[RecordingPlaybackCell] = []
self._last_order_detail_dialog: QDialog | None = None
self._local_audio_dialog: LocalAudioQueueDialog | None = None
self._local_audio_upload_manager: Any = None
self._local_audio_upload_listener: Any = None
self._video_reload_pending = False
self._orders_page = 1
self._orders_page_size = 10
self._orders_total = 0
@@ -868,6 +875,10 @@ class DiagnosisDialog(QDialog):
self._daily_panels: list[DailyRecordPanel] = []
self._owner = None
self._owner_filter_installed = False
self._local_audio_upload_completed.connect(
self._local_audio_upload_succeeded,
type=Qt.ConnectionType.QueuedConnection,
)
self.setObjectName("DiagnosisDialogRoot")
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
@@ -1433,6 +1444,11 @@ class DiagnosisDialog(QDialog):
hint.setWordWrap(True)
toolbar.addWidget(hint, 1)
toolbar.addStretch(1)
self.local_audio_queue_button = QPushButton("本机录音文件")
self.local_audio_queue_button.setObjectName("DiagnosisLocalAudioQueueButton")
self.local_audio_queue_button.setAccessibleName("查看本机录音文件及上传状态")
self.local_audio_queue_button.clicked.connect(self._open_local_audio_queue)
toolbar.addWidget(self.local_audio_queue_button)
self.video_upload_button = QPushButton("上传本地回放")
self.video_upload_button.setProperty("variant", "primary")
self.video_upload_button.clicked.connect(
@@ -1556,10 +1572,10 @@ class DiagnosisDialog(QDialog):
),
"video": (
("录制回放", 440),
("房间号", 180),
("开始时间", 170),
("结束时间", 170),
("通话类型", 100),
("房间号", 180),
("时长", 110),
("状态", 90),
("录制 / 文字", 120),
@@ -1695,6 +1711,10 @@ class DiagnosisDialog(QDialog):
)
self.prescribe_button.setVisible(self._editable and self._can_prescribe)
self.video_upload_button.setVisible(self._editable and self._can_video_upload)
self.local_audio_queue_button.setVisible(self._can_video_upload)
self.local_audio_queue_button.setEnabled(
self._diagnosis_id > 0 and self._can_video_upload
)
offset_visible = self._editable and self._can_offset
self.order_offset.setVisible(offset_visible)
self.order_offset_save.setVisible(offset_visible)
@@ -1914,6 +1934,10 @@ class DiagnosisDialog(QDialog):
with suppress(RuntimeError):
self._last_order_detail_dialog.close()
self._last_order_detail_dialog = None
if self._local_audio_dialog is not None:
with suppress(RuntimeError):
self._local_audio_dialog.close()
self._local_audio_dialog = None
self._diagnosis_id = int(diagnosis_id)
self._patient_id = 0
self._editable = bool(
@@ -1939,6 +1963,7 @@ class DiagnosisDialog(QDialog):
self._tab_generations[key] += 1
self._loaded_tabs.clear()
self._loading_tabs.clear()
self._video_reload_pending = False
self._daily_todo_status = None
self._orders_page = 1
self._orders_total = 0
@@ -2208,6 +2233,8 @@ class DiagnosisDialog(QDialog):
self._ensure_tab_loaded(self._current_tab_key(), force=True)
def _invalidate_requests(self) -> None:
self._stop_watching_local_audio_uploads()
self._video_reload_pending = False
self._generation += 1
self._save_generation += 1
self._orders_generation += 1
@@ -2232,6 +2259,11 @@ class DiagnosisDialog(QDialog):
with suppress(RuntimeError):
order_dialog.close()
self._last_order_detail_dialog = None
local_audio_dialog = getattr(self, "_local_audio_dialog", None)
if local_audio_dialog is not None:
with suppress(RuntimeError):
local_audio_dialog.close()
self._local_audio_dialog = None
if hasattr(self, "readonly_loading"):
self.readonly_loading.hide()
if hasattr(self, "drawer_loading"):
@@ -2474,7 +2506,11 @@ class DiagnosisDialog(QDialog):
def _tab_changed(self, _index: int) -> None:
self._sync_save_button()
if self._authoritative_detail_loaded and not self._standalone_readonly:
self._ensure_tab_loaded(self._current_tab_key())
key = self._current_tab_key()
if key == "video":
self._reload_video_records(self._diagnosis_id)
else:
self._ensure_tab_loaded(key)
def _load_visible_readonly_sections(self) -> None:
permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
@@ -2635,6 +2671,8 @@ class DiagnosisDialog(QDialog):
panel.set_messages(rows)
if not self._standalone_readonly and self._current_tab_key() == key:
self._clear_message()
if key == "video":
self._flush_video_reload_if_pending(diagnosis_id)
def _tab_load_error(
self,
@@ -2650,6 +2688,8 @@ class DiagnosisDialog(QDialog):
self._set_tab_error(key, text)
if not self._standalone_readonly and self._current_tab_key() == key:
self._show_message(text, "danger", action_text="重试")
if key == "video":
self._flush_video_reload_if_pending(diagnosis_id)
def _load_daily_range(self, start: str, end: str, *, force: bool = False) -> None:
key = "daily"
@@ -3116,6 +3156,91 @@ class DiagnosisDialog(QDialog):
"video",
)
def _open_local_audio_queue(self) -> None:
if self._diagnosis_id <= 0 or not self._can_video_upload:
self._show_message("当前账号无本机录音上传权限或接口不可用。", "warning")
return
current = self._local_audio_dialog
if current is not None and current.isVisible():
current.raise_()
current.activateWindow()
return
try:
dialog = LocalAudioQueueDialog(
self.repository,
self._diagnosis_id,
self,
)
except (OSError, RuntimeError, sqlite3.Error) as error:
self._show_message(f"无法打开本机录音文件:{error}", "danger")
return
self._local_audio_dialog = dialog
opened_diagnosis_id = self._diagnosis_id
self._watch_local_audio_uploads(dialog.manager)
def clear_dialog(_result: int) -> None:
if self._local_audio_dialog is dialog:
self._local_audio_dialog = None
dialog.finished.connect(clear_dialog)
dialog.open()
# Opening the queue commonly follows a background upload that completed
# before this dialog existed, so also refresh the server-backed rows now.
self._reload_video_records(opened_diagnosis_id)
def _local_audio_upload_succeeded(
self,
diagnosis_id: int,
_call_record_id: int,
) -> None:
if self._local_audio_upload_manager is None:
return
self._reload_video_records(diagnosis_id)
def _reload_video_records(self, diagnosis_id: int) -> None:
if int(diagnosis_id) != self._diagnosis_id:
return
if "video" in self._loading_tabs:
self._video_reload_pending = True
return
self._video_reload_pending = False
self._loaded_tabs.discard("video")
self._ensure_tab_loaded("video", force=True)
def _watch_local_audio_uploads(self, manager: Any) -> None:
if self._local_audio_upload_manager is manager:
return
self._stop_watching_local_audio_uploads()
def notify(record: Any) -> None:
with suppress(RuntimeError):
self._local_audio_upload_completed.emit(
int(record.diagnosis_id),
int(record.call_record_id or 0),
)
manager.add_upload_listener(notify)
self._local_audio_upload_manager = manager
self._local_audio_upload_listener = notify
def _stop_watching_local_audio_uploads(self) -> None:
manager = self._local_audio_upload_manager
listener = self._local_audio_upload_listener
self._local_audio_upload_manager = None
self._local_audio_upload_listener = None
if manager is not None and listener is not None:
with suppress(Exception):
manager.remove_upload_listener(listener)
def _flush_video_reload_if_pending(self, diagnosis_id: int) -> None:
if not self._video_reload_pending or diagnosis_id != self._diagnosis_id:
return
self._video_reload_pending = False
QTimer.singleShot(
0,
lambda expected_id=diagnosis_id: self._reload_video_records(expected_id),
)
def _sync_chat_archive(self) -> None:
if not self._can_chat_sync or self._diagnosis_id <= 0:
self._show_message("当前账号无聊天归档同步权限或接口不可用。", "warning")
@@ -3616,6 +3741,7 @@ class DiagnosisDialog(QDialog):
self._stop_inline_recordings()
matrix: list[tuple[Any, ...]] = []
row_urls: list[list[str]] = []
row_audio_urls: list[list[str]] = []
record_ids: list[int] = []
transcripts: list[str] = []
for row in rows:
@@ -3629,6 +3755,22 @@ class DiagnosisDialog(QDialog):
if url and url not in normalized:
normalized.append(url)
row_urls.append(normalized)
raw_audio_urls = first_value(
row,
"local_audio_urls_list",
"local_audio_urls",
default=[],
) or []
if isinstance(raw_audio_urls, str) or not isinstance(
raw_audio_urls, Sequence
):
raw_audio_urls = [raw_audio_urls]
normalized_audio: list[str] = []
for candidate in raw_audio_urls:
url = str(candidate or "").strip()
if url and url not in normalized_audio:
normalized_audio.append(url)
row_audio_urls.append(normalized_audio)
record_ids.append(_int(first_value(row, "id", "call_record_id"), 0))
transcript = self._call_transcript_text(row)
transcripts.append(transcript)
@@ -3651,22 +3793,41 @@ class DiagnosisDialog(QDialog):
recording_status = first_value(
row, "recording_status_text", "record_status_text", "record_status"
)
local_audio_status = first_value(
row,
"local_audio_status_text",
default="本机录音已保存" if normalized_audio else "无本机录音",
)
transcript_status = first_value(
row,
"transcription_status_text",
"transcript_status_text",
default="文字已生成" if transcript else "未生成文字",
)
room_id = str(
first_value(
row,
"room_id_text",
"room_id",
"room_no",
default="",
)
or ""
).strip()
matrix.append(
(
"" if normalized else "暂无录制回放",
room_id or "历史记录未保存",
first_value(row, "start_time_text", "start_time"),
first_value(row, "end_time_text", "end_time"),
call_type,
first_value(row, "room_id", "room_no"),
first_value(row, "duration_text", "duration"),
status,
f"{display_text(recording_status)}\n{display_text(transcript_status)}",
(
f"云端视频:{display_text(recording_status)}\n"
f"本机录音:{display_text(local_audio_status)}\n"
f"转写文字:{display_text(transcript_status)}"
),
"",
)
)
@@ -3692,6 +3853,19 @@ class DiagnosisDialog(QDialog):
item.setText("")
call_record_id = record_ids[row_index]
actions: list[QPushButton] = []
audio_urls = row_audio_urls[row_index]
if audio_urls and call_record_id > 0:
play_audio = self._action_button(
"播放录音", f"播放通话记录 #{call_record_id} 的本机混音录音"
)
play_audio.setObjectName("DiagnosisLocalAudioPlayback")
play_audio.setProperty("callRecordId", call_record_id)
play_audio.clicked.connect(
lambda _checked=False, target=audio_urls[0]: self._open_recording_player(
target
)
)
actions.append(play_audio)
transcript = transcripts[row_index]
if transcript and call_record_id > 0:
view_transcript = self._action_button(
@@ -0,0 +1,625 @@
"""Local call-audio upload queue shown from a diagnosis video-history tab."""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from PySide6.QtCore import Qt, QTimer, QUrl
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtWidgets import (
QAbstractItemView,
QDialog,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from ...services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioRecord,
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from ..widgets import run_async
_STATUS_LABELS = {
"recording": "录制中",
"pending": "待上传",
"uploading": "上传中",
"uploaded": "已上传",
"failed": "上传失败",
"invalid": "无效录音",
}
_STATUS_COLORS = {
"recording": "#5364F5",
"pending": "#B26A00",
"uploading": "#2F6FEB",
"uploaded": "#07966B",
"failed": "#DC4054",
"invalid": "#7886AA",
}
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
_LOCAL_AUDIO_QSS = """
QDialog#LocalAudioQueueDialog {
background: #F6F8FD;
color: #111F46;
}
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
background: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 14px;
}
QLabel#LocalAudioQueueTitle {
color: #111F46;
font-size: 20px;
font-weight: 700;
}
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
color: #6E7C9F;
font-size: 13px;
}
QLabel[queueSummary="true"] {
background: #F3F5FB;
border: 1px solid #E6EAF5;
border-radius: 10px;
color: #3F4E75;
font-size: 13px;
font-weight: 600;
padding: 8px 12px;
}
QPushButton {
min-height: 34px;
border: 1px solid #D9E0F2;
border-radius: 9px;
background: #FFFFFF;
color: #354365;
padding: 0 14px;
font-weight: 600;
}
QPushButton:hover { background: #F1F3FF; border-color: #AEB8FF; }
QPushButton:disabled { color: #A5AFC6; background: #F7F8FC; }
QPushButton[variant="primary"] {
color: #FFFFFF;
background: #5661F4;
border-color: #5661F4;
}
QPushButton[variant="danger"] { color: #D83E51; background: #FFF6F7; }
QTableWidget#LocalAudioQueueTable {
background: #FFFFFF;
alternate-background-color: #FAFBFE;
border: 0;
gridline-color: #E8ECF5;
color: #263452;
selection-background-color: #EEF1FF;
selection-color: #111F46;
}
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
QHeaderView::section {
background: #F5F7FC;
color: #53617F;
border: 0;
border-bottom: 1px solid #E1E6F1;
padding: 10px 8px;
font-weight: 700;
}
"""
def _human_size(size: int) -> str:
value = max(0, int(size))
if value < 1024:
return f"{value} B"
if value < 1024 * 1024:
return f"{value / 1024:.1f} KB"
return f"{value / 1024 / 1024:.1f} MB"
def _display_time(value: str) -> str:
raw = str(value or "").strip()
if not raw:
return "—"
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return raw.replace("T", " ")
if parsed.tzinfo is not None:
parsed = parsed.astimezone(_BUSINESS_TIMEZONE)
return parsed.strftime("%Y-%m-%d %H:%M:%S")
class LocalAudioQueueDialog(QDialog):
"""Persistent local-audio list with concurrent upload and retry."""
def __init__(
self,
repository: Any,
diagnosis_id: int | None,
parent: QWidget | None = None,
*,
store: LocalAudioQueueStore | None = None,
manager: LocalAudioUploadManager | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self._global_scope = diagnosis_id is None
self.diagnosis_id = int(diagnosis_id) if diagnosis_id is not None else None
if manager is None:
self.store, self.manager = get_local_audio_upload_manager(repository, store)
else:
self.store = store or manager.store
self.manager = manager
self._signature: tuple[Any, ...] = ()
self._room_fetch_generation = 0
self._room_fetch_inflight: set[int] = set()
self._room_fetch_attempted: set[int] = set()
self._room_fetch_errors: dict[int, str] = {}
self._closed = False
self.setObjectName("LocalAudioQueueDialog")
self.setProperty("businessDialog", True)
self.setWindowTitle(
"本机录音上传管理" if self._global_scope else "本机录音文件"
)
self.setModal(True)
self.setMinimumSize(760, 480)
self.resize(1160, 620)
self.setStyleSheet(_LOCAL_AUDIO_QSS)
self._build_ui()
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(500)
self.poll_timer.timeout.connect(self.refresh_records)
self.poll_timer.start()
self.manager.submit_pending(diagnosis_id=self.diagnosis_id)
self.refresh_records(force=True)
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(18, 18, 18, 18)
root.setSpacing(12)
header = QFrame()
header.setObjectName("LocalAudioQueueHeader")
header_layout = QVBoxLayout(header)
header_layout.setContentsMargins(18, 14, 18, 14)
header_layout.setSpacing(4)
self.title_label = QLabel(
"本机录音上传管理" if self._global_scope else "本机录音文件"
)
self.title_label.setObjectName("LocalAudioQueueTitle")
header_layout.addWidget(self.title_label)
if self._global_scope:
subtitle_text = (
"集中查看所有诊单的本机录音上传记录;成功文件和未成功文件都会保留。"
"最多同时上传 3 个文件,失败后可重试。"
)
else:
subtitle_text = (
"每次通话的本地录音会先保存在本机,再上传到 COS;最多同时上传 3 个文件。"
"上传失败不会删除文件,可随时重试。"
)
subtitle = QLabel(subtitle_text)
subtitle.setObjectName("LocalAudioQueueSubtitle")
subtitle.setWordWrap(True)
header_layout.addWidget(subtitle)
root.addWidget(header)
summary = QFrame()
summary.setObjectName("LocalAudioQueueSummary")
summary_layout = QHBoxLayout(summary)
summary_layout.setContentsMargins(12, 10, 12, 10)
summary_layout.setSpacing(8)
self.summary_total = self._summary_label()
self.summary_pending = self._summary_label()
self.summary_uploading = self._summary_label()
self.summary_uploaded = self._summary_label()
self.summary_failed = self._summary_label()
for label in (
self.summary_total,
self.summary_pending,
self.summary_uploading,
self.summary_uploaded,
self.summary_failed,
):
summary_layout.addWidget(label)
summary_layout.addStretch(1)
self.upload_pending_button = QPushButton("上传待处理")
self.upload_pending_button.setObjectName("LocalAudioUploadPendingButton")
self.upload_pending_button.setProperty("variant", "primary")
self.upload_pending_button.clicked.connect(self._upload_pending)
summary_layout.addWidget(self.upload_pending_button)
self.retry_failed_button = QPushButton("重试全部失败")
self.retry_failed_button.setObjectName("LocalAudioRetryAllButton")
self.retry_failed_button.setProperty("variant", "danger")
self.retry_failed_button.clicked.connect(self._retry_all_failed)
summary_layout.addWidget(self.retry_failed_button)
refresh_button = QPushButton("刷新")
refresh_button.setObjectName("LocalAudioRefreshButton")
refresh_button.clicked.connect(self._manual_refresh)
summary_layout.addWidget(refresh_button)
root.addWidget(summary)
card = QFrame()
card.setObjectName("LocalAudioQueueTableCard")
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(1, 1, 1, 1)
headers = ["录制时间"]
if self._global_scope:
headers.append("诊单 ID")
headers.extend(
[
"通话记录 ID",
"房间号",
"音频文件",
"大小",
"上传状态",
"失败原因",
"操作",
]
)
self._diagnosis_column = 1 if self._global_scope else None
self._call_column = 2 if self._global_scope else 1
self._room_column = 3 if self._global_scope else 2
self._file_column = 4 if self._global_scope else 3
self._size_column = 5 if self._global_scope else 4
self._status_column = 6 if self._global_scope else 5
self._error_column = 7 if self._global_scope else 6
self._action_column = 8 if self._global_scope else 7
self.table = QTableWidget(0, len(headers))
self.table.setObjectName("LocalAudioQueueTable")
self.table.setHorizontalHeaderLabels(headers)
self.table.verticalHeader().hide()
self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setWordWrap(False)
header_view = self.table.horizontalHeader()
header_view.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
header_view.setSectionResizeMode(
self._file_column, QHeaderView.ResizeMode.Stretch
)
header_view.setSectionResizeMode(
self._error_column, QHeaderView.ResizeMode.Stretch
)
self.table.setColumnWidth(0, 160)
if self._diagnosis_column is not None:
self.table.setColumnWidth(self._diagnosis_column, 90)
self.table.setColumnWidth(self._call_column, 90)
self.table.setColumnWidth(self._room_column, 180)
self.table.setColumnWidth(self._size_column, 88)
self.table.setColumnWidth(self._status_column, 100)
self.table.setColumnWidth(self._action_column, 190)
card_layout.addWidget(self.table)
root.addWidget(card, 1)
footer = QFrame()
footer.setObjectName("LocalAudioQueueFooter")
footer_layout = QHBoxLayout(footer)
footer_layout.setContentsMargins(14, 9, 14, 9)
hint = QLabel("已上传文件仍保留在本机;请按机构的数据保留制度定期归档。")
hint.setObjectName("LocalAudioQueueHint")
footer_layout.addWidget(hint, 1)
close_button = QPushButton("关闭")
close_button.setObjectName("LocalAudioQueueCloseButton")
close_button.setProperty("variant", "primary")
close_button.clicked.connect(self.accept)
footer_layout.addWidget(close_button)
root.addWidget(footer)
@staticmethod
def _summary_label() -> QLabel:
label = QLabel()
label.setProperty("queueSummary", True)
return label
def _records(self) -> list[LocalAudioRecord]:
return self.store.list_records(diagnosis_id=self.diagnosis_id)
def refresh_records(self, *, force: bool = False) -> None:
records = self._records()
self._schedule_room_lookups(records)
signature = tuple(
(
record.id,
record.diagnosis_id,
record.status,
record.error_text,
record.call_record_id,
record.room_id,
record.size_bytes,
record.uploaded_url,
record.exists,
record.attempts,
self._room_fetch_state(record),
)
for record in records
)
if not force and signature == self._signature:
return
self._signature = signature
self._update_summary(records)
self.table.setRowCount(len(records))
for row, record in enumerate(records):
self.table.setRowHeight(row, 54)
self._set_item(row, 0, _display_time(record.created_at))
if self._diagnosis_column is not None:
self._set_item(row, self._diagnosis_column, str(record.diagnosis_id))
self._set_item(
row, self._call_column, str(record.call_record_id or "待关联")
)
room_text, room_tooltip = self._room_display(record)
room_item = self._set_item(row, self._room_column, room_text)
room_item.setToolTip(room_tooltip)
file_item = self._set_item(row, self._file_column, record.file_path.name)
file_item.setToolTip(str(record.file_path))
self._set_item(row, self._size_column, _human_size(record.size_bytes))
status_item = self._set_item(
row,
self._status_column,
_STATUS_LABELS.get(record.status, record.status),
)
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#53617F")))
status_item.setToolTip(
f"已尝试 {record.attempts} 次"
+ (f"\nCOS:{record.uploaded_url}" if record.uploaded_url else "")
)
error_item = self._set_item(
row, self._error_column, record.error_text or "—"
)
error_item.setToolTip(record.error_text)
self.table.setCellWidget(row, self._action_column, self._actions(record))
def _room_fetch_state(self, record: LocalAudioRecord) -> str:
diagnosis_id = record.diagnosis_id
if record.room_id:
return record.room_id
if diagnosis_id in self._room_fetch_inflight:
return "loading"
if diagnosis_id in self._room_fetch_errors:
return f"error:{self._room_fetch_errors[diagnosis_id]}"
if diagnosis_id in self._room_fetch_attempted:
return "missing"
return "idle"
def _room_display(self, record: LocalAudioRecord) -> tuple[str, str]:
if record.room_id:
return record.room_id, record.room_id
if record.call_record_id is None:
return "—", "尚未关联通话记录,无法取得房间号。"
diagnosis_id = record.diagnosis_id
if diagnosis_id in self._room_fetch_inflight:
return "查询中…", "正在从服务端通话记录查询房间号。"
error = self._room_fetch_errors.get(diagnosis_id)
if error:
return "获取失败", error
return "未记录", "服务端通话记录未保存房间号。"
def _schedule_room_lookups(self, records: list[LocalAudioRecord]) -> None:
if self._closed:
return
method = getattr(self.repository, "list_call_records", None)
if not callable(method):
method = getattr(self.repository, "get_call_records", None)
if not callable(method):
return
target_ids: dict[int, set[int]] = {}
for record in records:
call_record_id = int(record.call_record_id or 0)
if record.room_id or call_record_id <= 0:
continue
target_ids.setdefault(record.diagnosis_id, set()).add(call_record_id)
diagnosis_ids = tuple(
sorted(
diagnosis_id
for diagnosis_id in target_ids
if diagnosis_id not in self._room_fetch_inflight
and diagnosis_id not in self._room_fetch_attempted
)
)
if not diagnosis_ids:
return
generation = self._room_fetch_generation
self._room_fetch_inflight.update(diagnosis_ids)
self._room_fetch_attempted.update(diagnosis_ids)
def load_rooms() -> tuple[dict[tuple[int, int], str], dict[int, str]]:
rooms: dict[tuple[int, int], str] = {}
errors: dict[int, str] = {}
for diagnosis_id in diagnosis_ids:
try:
payload = method(diagnosis_id)
rows = payload if isinstance(payload, list) else []
for row in rows:
if not isinstance(row, Mapping):
continue
raw_record_id = row.get("id", row.get("call_record_id"))
try:
call_record_id = int(raw_record_id or 0)
except (TypeError, ValueError):
continue
if call_record_id not in target_ids[diagnosis_id]:
continue
room_id = str(
row.get("room_id")
or row.get("roomId")
or row.get("room_id_text")
or row.get("room_no")
or ""
).strip()
if room_id and room_id != "0":
rooms[(diagnosis_id, call_record_id)] = room_id
except Exception as error:
errors[diagnosis_id] = str(error)[:300] or "房间号获取失败。"
return rooms, errors
run_async(
load_rooms,
on_success=lambda result: self._apply_room_lookups(
generation,
diagnosis_ids,
result,
),
on_error=lambda error: self._fail_room_lookups(
generation,
diagnosis_ids,
error,
),
)
def _apply_room_lookups(
self,
generation: int,
diagnosis_ids: tuple[int, ...],
result: tuple[dict[tuple[int, int], str], dict[int, str]],
) -> None:
if self._closed or generation != self._room_fetch_generation:
return
rooms, errors = result
self._room_fetch_inflight.difference_update(diagnosis_ids)
self._room_fetch_errors.update(errors)
records_by_key: dict[tuple[int, int], list[LocalAudioRecord]] = {}
for record in self._records():
if record.call_record_id is None:
continue
key = (record.diagnosis_id, record.call_record_id)
records_by_key.setdefault(key, []).append(record)
for key, room_id in rooms.items():
for record in records_by_key.get(key, []):
try:
self.store.bind_identity(
record.id,
call_record_id=key[1],
room_id=room_id,
)
except (LookupError, RuntimeError, ValueError) as error:
self._room_fetch_errors[key[0]] = str(error)[:300]
self.refresh_records(force=True)
def _fail_room_lookups(
self,
generation: int,
diagnosis_ids: tuple[int, ...],
error: Exception,
) -> None:
if self._closed or generation != self._room_fetch_generation:
return
self._room_fetch_inflight.difference_update(diagnosis_ids)
message = str(error)[:300] or "房间号获取失败。"
self._room_fetch_errors.update(
{diagnosis_id: message for diagnosis_id in diagnosis_ids}
)
self.refresh_records(force=True)
def _manual_refresh(self) -> None:
self._room_fetch_generation += 1
self._room_fetch_inflight.clear()
self._room_fetch_attempted.clear()
self._room_fetch_errors.clear()
self.refresh_records(force=True)
def _set_item(self, row: int, column: int, text: str) -> QTableWidgetItem:
item = QTableWidgetItem(str(text))
item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
self.table.setItem(row, column, item)
return item
def _update_summary(self, records: list[LocalAudioRecord]) -> None:
counts = {status: 0 for status in _STATUS_LABELS}
for record in records:
counts[record.status] = counts.get(record.status, 0) + 1
self.summary_total.setText(f"全部 {len(records)}")
self.summary_pending.setText(f"待上传 {counts['pending']}")
self.summary_uploading.setText(f"上传中 {counts['uploading']}")
self.summary_uploaded.setText(f"已上传 {counts['uploaded']}")
self.summary_failed.setText(f"失败 {counts['failed']}")
self.upload_pending_button.setEnabled(counts["pending"] > 0)
self.retry_failed_button.setEnabled(counts["failed"] > 0)
def _actions(self, record: LocalAudioRecord) -> QWidget:
host = QWidget()
layout = QHBoxLayout(host)
layout.setContentsMargins(4, 5, 4, 5)
layout.setSpacing(5)
if record.status == "pending":
upload = QPushButton("上传")
upload.setAccessibleName(f"上传录音 {record.file_path.name}")
upload.clicked.connect(lambda _checked=False, record_id=record.id: self._upload_one(record_id))
layout.addWidget(upload)
elif record.status == "failed":
retry = QPushButton("重试")
retry.setProperty("variant", "danger")
retry.setAccessibleName(f"重试上传录音 {record.file_path.name}")
retry.clicked.connect(lambda _checked=False, record_id=record.id: self._retry_one(record_id))
layout.addWidget(retry)
elif record.status == "uploading":
busy = QPushButton("上传中")
busy.setEnabled(False)
layout.addWidget(busy)
if record.exists:
reveal = QPushButton("打开位置")
reveal.setAccessibleName(f"打开录音文件位置 {record.file_path.name}")
reveal.clicked.connect(
lambda _checked=False, path=record.file_path: self._open_location(path)
)
layout.addWidget(reveal)
layout.addStretch(1)
return host
def _upload_pending(self) -> None:
self.manager.submit_pending(diagnosis_id=self.diagnosis_id)
self.refresh_records(force=True)
def _upload_one(self, record_id: int) -> None:
self.manager.submit(record_id)
self.refresh_records(force=True)
def _retry_one(self, record_id: int) -> None:
try:
self.store.retry(record_id)
except (OSError, RuntimeError, LookupError) as error:
self._show_local_error(str(error))
return
self.manager.submit(record_id)
self.refresh_records(force=True)
def _retry_all_failed(self) -> None:
for record in self._records():
if record.status != "failed":
continue
try:
self.store.retry(record.id)
except (OSError, RuntimeError, LookupError):
continue
self.manager.submit(record.id)
self.refresh_records(force=True)
def _show_local_error(self, message: str) -> None:
self.summary_failed.setText(str(message)[:160])
self.summary_failed.setToolTip(str(message))
@staticmethod
def _open_location(path: Path) -> None:
target = path.parent if path.parent.is_dir() else path
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
def done(self, result: int) -> None:
self._closed = True
self._room_fetch_generation += 1
if hasattr(self, "poll_timer"):
self.poll_timer.stop()
super().done(result)
@@ -8,7 +8,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from PySide6.QtCore import Qt
from PySide6.QtCore import QCoreApplication, QEvent, Qt
from PySide6.QtWidgets import (
QDialog,
QFrame,
@@ -1086,11 +1086,15 @@ class PrescriptionAiReportDialog(QDialog):
def _clear_host(self) -> None:
while self.host_layout.count():
item = self.host_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.setParent(None)
widget.deleteLater()
item = self.host_layout.takeAt(0)
widget = item.widget()
if widget is not None:
# Keep the parent until Qt processes the deferred deletion.
# Detaching a visible child promotes it to a native top-level
# window on Windows and causes a small title-bar window to flash.
widget.hide()
widget.deleteLater()
QCoreApplication.sendPostedEvents(widget, QEvent.Type.DeferredDelete)
def _add_label(
self,
@@ -9,8 +9,8 @@ from html import escape
from types import MappingProxyType
from typing import Any
from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QAction, QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
@@ -21,10 +21,9 @@ from PySide6.QtWidgets import (
QDialogButtonBox,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMenu,
QMessageBox,
QPushButton,
@@ -104,8 +103,7 @@ APPOINTMENTS_LIGHT_QSS = """
border: 1px solid #E2E7F4;
border-radius: 10px;
}
#AppointmentsPage QFrame#AppointmentMainCard,
#AppointmentsPage QFrame#VideoConsultPanel {
#AppointmentsPage QFrame#AppointmentMainCard {
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -209,6 +207,34 @@ APPOINTMENTS_LIGHT_QSS = """
border-radius: 4px;
font-size: 10px;
}
#AppointmentsPage QWidget[appointmentImHost="true"] {
background-color: transparent;
}
#AppointmentsPage QPushButton[appointmentImAction="true"] {
min-width: 74px;
min-height: 26px;
max-height: 26px;
padding: 0 9px;
color: #5265F6;
background-color: #F0F2FF;
border: 1px solid #D7DEFF;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover {
color: #FFFFFF;
background-color: #5265F6;
border-color: #5265F6;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:focus {
border-color: #8D9BFF;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled {
color: #98A3BC;
background-color: #F7F8FC;
border-color: #E4E8F2;
}
#AppointmentsPage QWidget[appointmentInfoHost="true"] {
background-color: #FFFFFF;
}
@@ -257,77 +283,6 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #EEF1FF;
border-bottom-color: #5265F6;
}
#AppointmentsPage QLabel#VideoConsultTitle {
color: #15224A;
font-size: 16px;
font-weight: 700;
}
#AppointmentsPage QListWidget#VideoConsultList {
background-color: transparent;
border: 0;
border-radius: 0;
outline: 0;
}
#AppointmentsPage QListWidget#VideoConsultList::item {
min-height: 80px;
margin: 0 0 7px 0;
padding: 0;
background-color: transparent;
border: 0;
}
#AppointmentsPage QListWidget#VideoConsultList::item:hover {
background-color: transparent;
}
#AppointmentsPage QListWidget#VideoConsultList::item:selected {
background-color: transparent;
}
#AppointmentsPage QFrame[videoPatientCard="true"] {
background-color: #FCFDFF;
border: 1px solid #E1E6F1;
border-radius: 9px;
}
#AppointmentsPage QFrame[videoPatientCard="true"]:hover {
background-color: #F9FAFF;
border-color: #C9D2FF;
}
#AppointmentsPage QLabel[videoStatus="true"] {
min-height: 20px;
max-height: 20px;
padding: 0 7px;
color: #5265F6;
background-color: #EEF1FF;
border-radius: 5px;
font-size: 10px;
font-weight: 600;
}
#AppointmentsPage QLabel[videoStatusKind="warning"] {
color: #B97715;
background-color: #FFF4DF;
}
#AppointmentsPage QLabel[videoIdentity="true"] {
color: #24335E;
font-size: 12px;
font-weight: 600;
}
#AppointmentsPage QLabel[videoAssistant="true"] {
color: #59698E;
font-size: 11px;
}
#AppointmentsPage QPushButton[videoAction="true"] {
min-height: 20px;
max-height: 20px;
padding: 0 5px;
color: #5265F6;
background-color: transparent;
border: 0;
border-radius: 4px;
font-size: 10px;
}
#AppointmentsPage QPushButton[videoAction="true"]:hover {
background-color: #EEF1FF;
}
#AppointmentsPage QPushButton[videoActionKind="success"] { color: #159C79; }
#AppointmentsPage QPushButton[videoActionKind="warning"] { color: #C17A16; }
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 28px;
max-height: 28px;
@@ -342,11 +297,6 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #F0F1FF;
border-color: #E0E4FF;
}
#AppointmentsPage QPushButton#VideoMoreButton {
min-height: 36px;
color: #405074;
background-color: #FFFFFF;
}
"""
@@ -452,14 +402,37 @@ def _status_value(row: Any) -> int:
return _as_int(first_value(row, "status", default=0))
_IM_CONSULT_ACTIVE_STATUSES = frozenset({1, 4})
def _im_consult_status_error(row: Any) -> str:
"""Return why a queue row cannot open the doctor's IM consultation."""
status = _status_value(row)
if status in _IM_CONSULT_ACTIVE_STATUSES:
return ""
if status == 2:
return "已取消的挂号不可进入 IM 问诊"
if status == 3:
return "已完成的挂号不可再进入 IM 问诊"
return "当前挂号状态不可进入 IM 问诊"
def prescription_action_label(row: Any) -> str:
audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0)
explicit = first_value(row, "has_prescription", default=None)
explicit = first_value(row, "current_has_prescription", default=None)
if explicit is None and _as_int(first_value(row, "current_prescription_id", default=0)) <= 0:
explicit = first_value(row, "has_prescription", default=None)
has_prescription = (
_as_bool(explicit)
if explicit is not None
else _as_int(first_value(row, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
else _as_int(
first_value(row, "current_prescription_id", "prescription_id", default=0),
0,
)
> 0
or audit in {0, 1, 2}
)
if not has_prescription:
return "开方"
@@ -689,7 +662,6 @@ class AppointmentsPage(QWidget):
self._dict_labels: dict[str, dict[str, str]] = {}
self._last_render_signature: tuple[Any, ...] | None = None
self._responsive_narrow: bool | None = None
self._video_panel_user_open = False
self._can_plain_phone = _canonical_allowed(
permissions, "tcm.diagnosis/phonePlain", default=False
)
@@ -889,35 +861,9 @@ class AppointmentsPage(QWidget):
layout.setSpacing(4)
self.content_layout = layout
self.table_card = self._build_table_card()
self.video_panel = self._build_video_panel()
layout.addWidget(self.table_card, 3)
layout.addWidget(self.video_panel, 1)
layout.addWidget(self.table_card, 1)
return host
def _build_video_panel(self) -> QWidget:
panel = QFrame()
panel.setObjectName("VideoConsultPanel")
panel.setMinimumWidth(0)
panel.setMaximumWidth(420)
layout = QVBoxLayout(panel)
layout.setContentsMargins(14, 12, 14, 8)
layout.setSpacing(8)
title = QLabel("视频问诊")
title.setObjectName("VideoConsultTitle")
layout.addWidget(title)
self.video_list = QListWidget()
self.video_list.setObjectName("VideoConsultList")
self.video_list.setSpacing(0)
self.video_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.video_list.currentRowChanged.connect(self._video_row_selected)
self.video_list.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.video_list, 1)
more = QPushButton("查看更多患者 ﹀")
more.setObjectName("VideoMoreButton")
more.clicked.connect(lambda: self.pager.next.click())
layout.addWidget(more)
return panel
def _build_table_card(self) -> QWidget:
card = QFrame()
card.setObjectName("AppointmentMainCard")
@@ -1004,12 +950,6 @@ class AppointmentsPage(QWidget):
self.toolbar_cancel_button.setEnabled(False)
self.toolbar_cancel_button.clicked.connect(self._cancel_selected)
actions.addWidget(self.toolbar_cancel_button)
self.video_panel_button = QPushButton("视频问诊", card)
self.video_panel_button.setCheckable(True)
self.video_panel_button.setProperty("variant", "ghost")
self.video_panel_button.setProperty("compactAction", True)
self.video_panel_button.clicked.connect(self._toggle_video_panel)
actions.addWidget(self.video_panel_button)
actions.addStretch(1)
refresh = QPushButton("刷新", card)
refresh.setProperty("variant", "ghost")
@@ -1034,13 +974,23 @@ class AppointmentsPage(QWidget):
TableColumn("assistant_name", "助理", 110),
TableColumn("has_prescription", "开方", 108, formatter=_prescription_cell),
TableColumn("unserved_days", "未服务天数", 112, formatter=_unserved_cell),
TableColumn(
"_im_consult",
"IM 问诊",
104,
alignment=Qt.AlignmentFlag.AlignCenter,
),
]
)
self.table.setObjectName("AppointmentTable")
self.table.setMinimumHeight(0)
self.table.setWordWrap(True)
self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.horizontalHeader().setFixedHeight(34)
header = self.table.horizontalHeader()
header.setFixedHeight(34)
header.setStretchLastSection(False)
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(10, QHeaderView.ResizeMode.Fixed)
self.table.verticalHeader().setDefaultSectionSize(60)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._open_detail())
@@ -1051,157 +1001,21 @@ class AppointmentsPage(QWidget):
layout.addWidget(self.pager)
return card
def _toggle_video_panel(self, checked: bool) -> None:
self._video_panel_user_open = checked
self._apply_responsive_layout()
def _apply_responsive_layout(self) -> None:
"""Keep the table usable while retaining a video-list entry at low widths."""
"""Collapse only the date filters when horizontal space is limited."""
panel = getattr(self, "video_panel", None)
button = getattr(self, "video_panel_button", None)
if not isinstance(panel, QWidget) or not isinstance(button, QPushButton):
return
narrow = self.width() < 1120
for preset in ("yesterday", "day_before", "tomorrow", "day_after"):
self.date_buttons[preset].setVisible(not narrow)
self.date_overflow_button.setVisible(narrow)
self.status_filter_label.setVisible(not narrow)
self.confirm_filter_label.setVisible(not narrow)
if narrow:
if self._responsive_narrow is not True:
self._video_panel_user_open = False
button.show()
button.setChecked(self._video_panel_user_open)
panel.setVisible(self._video_panel_user_open)
if self._video_panel_user_open:
panel.setFixedWidth(max(250, min(300, round(self.width() * 0.32))))
else:
button.hide()
button.setChecked(False)
panel.show()
panel.setFixedWidth(max(300, min(420, round(self.width() * 0.30))))
self._responsive_narrow = narrow
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
self._apply_responsive_layout()
def _sync_video_list(self, rows: Sequence[Any]) -> None:
selected_id = (
_appointment_id(self.video_list.currentItem().data(Qt.ItemDataRole.UserRole))
if self.video_list.currentItem()
else 0
)
self.video_list.clear()
row_to_select = -1
for index, row in enumerate(rows):
status = display_text(first_value(row, "status_desc"), "已预约")
name = display_text(first_value(row, "patient_name"), "患者")
gender = gender_text(first_value(row, "gender", "gender_desc", default=None))
age = first_value(row, "age", default="")
identity = " · ".join(
part
for part in (name, gender, f"{age}岁" if age not in (None, "") else "")
if part and part != "—"
)
assistant = display_text(first_value(row, "assistant_name"), "未分配")
item = QListWidgetItem(
f"{status} {identity} {assistant}\n"
"进入视频问诊 查看 诊单 开方 预约 补全身份"
)
item.setData(Qt.ItemDataRole.UserRole, row)
item.setSizeHint(QSize(0, 84))
self.video_list.addItem(item)
self.video_list.setItemWidget(item, self._build_video_patient_card(row))
if selected_id and _appointment_id(row) == selected_id:
row_to_select = index
if row_to_select >= 0:
self.video_list.setCurrentRow(row_to_select)
else:
self.video_list.setCurrentRow(-1)
self.video_panel_button.setText(f"视频问诊 {len(rows)}")
def _build_video_patient_card(self, row: Any) -> QWidget:
card = QFrame()
card.setProperty("videoPatientCard", True)
layout = QVBoxLayout(card)
layout.setContentsMargins(10, 7, 10, 6)
layout.setSpacing(5)
status_text = display_text(first_value(row, "status_desc"), "已预约")
name = display_text(first_value(row, "patient_name"), "患者")
gender = gender_text(first_value(row, "gender", "gender_desc", default=None))
age = first_value(row, "age", default="")
identity = " · ".join(
part
for part in (name, gender, f"{age}岁" if age not in (None, "") else "")
if part and part != "—"
)
assistant = display_text(first_value(row, "assistant_name"), "未分配")
heading = QHBoxLayout()
heading.setContentsMargins(0, 0, 0, 0)
heading.setSpacing(7)
status = QLabel(status_text)
status.setProperty("videoStatus", True)
status.setProperty("videoStatusKind", "warning" if "挂号" in status_text else "primary")
heading.addWidget(status)
identity_label = QLabel(identity)
identity_label.setProperty("videoIdentity", True)
heading.addWidget(identity_label)
heading.addStretch(1)
assistant_label = QLabel(assistant)
assistant_label.setProperty("videoAssistant", True)
heading.addWidget(assistant_label)
layout.addLayout(heading)
actions = QHBoxLayout()
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(1)
def add_action(
label: str,
callback: Any,
permission: str | None = None,
kind: str = "",
) -> None:
if permission and not _canonical_allowed(self.permissions, permission, default=False):
return
button = QPushButton(label, card)
button.setProperty("videoAction", True)
if kind:
button.setProperty("videoActionKind", kind)
button.clicked.connect(
lambda _checked=False, source=row, slot=callback: self._run_video_row_action(
source, slot
)
)
actions.addWidget(button)
add_action(
"进入视频问诊",
self._request_video,
"doctor.appointment/prescription",
)
add_action("查看", self._open_detail)
add_action("诊单", self._edit_patient, "tcm.diagnosis/edit")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
add_action("开方", self._open_prescription, "tcm.diagnosis/kaifang")
add_action("二维码", self._request_video_qr, "tcm.diagnosis/videoQr", "success")
more = QPushButton("更多", card)
more.setProperty("videoAction", True)
more.setProperty("videoActionKind", "warning")
more.clicked.connect(
lambda _checked=False, source=row, anchor=more: self._show_video_more_menu(
anchor, source
)
)
actions.addWidget(more)
actions.addStretch(1)
layout.addLayout(actions)
return card
def _run_video_row_action(self, row: Any, callback: Any) -> None:
appointment_id = _appointment_id(row)
for row_index in range(self.table.rowCount()):
@@ -1214,47 +1028,6 @@ class AppointmentsPage(QWidget):
break
callback()
def _show_video_more_menu(self, anchor: QPushButton, row: Any) -> None:
menu = QMenu(anchor)
def add_action(label: str, callback: Any, permission: str | None = None) -> None:
if permission and not _canonical_allowed(self.permissions, permission, default=False):
return
action = QAction(label, menu)
action.triggered.connect(
lambda _checked=False, source=row, slot=callback: self._run_video_row_action(
source, slot
)
)
menu.addAction(action)
add_action("完成问诊", self._complete_selected, "doctor.appointment/complete")
add_action("查看病历", self._view_case, "tcm.diagnosis/kaifang")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
if can_open_diagnosis_ai_report(self.permissions):
add_action("AI 报告", self._open_ai_report)
add_action("取消挂号", self._cancel_selected, "doctor.appointment/cancel")
if menu.actions():
menu.exec(anchor.mapToGlobal(anchor.rect().bottomLeft()))
def _video_row_selected(self, index: int) -> None:
if index < 0:
return
item = self.video_list.item(index)
source = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
appointment_id = _appointment_id(source)
if appointment_id <= 0:
return
for row_index in range(self.table.rowCount()):
cell = self.table.item(row_index, 0)
if (
cell is not None
and _appointment_id(cell.data(Qt.ItemDataRole.UserRole)) == appointment_id
):
self.table.selectRow(row_index)
break
def _action_button(
self,
label: str,
@@ -1484,7 +1257,7 @@ class AppointmentsPage(QWidget):
self.table.set_rows(rows)
self._install_table_selectors()
self._install_appointment_info_cells()
self._sync_video_list(rows)
self._install_im_consult_actions()
self._fit_table_rows()
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
@@ -1634,6 +1407,67 @@ class AppointmentsPage(QWidget):
font.setBold(True)
patient.setFont(font)
def _install_im_consult_actions(self) -> None:
"""Render a doctor-initiated IM entry for every appointment row."""
action_column = 10
has_permission = _canonical_allowed(
self.permissions,
"doctor.appointment/prescription",
default=False,
)
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
row = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
host = QWidget(self.table)
host.setProperty("appointmentImHost", True)
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QHBoxLayout(host)
layout.setContentsMargins(5, 0, 5, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
button = QPushButton("IM 问诊", host)
button.setObjectName("AppointmentImConsultButton")
button.setProperty("appointmentImAction", True)
patient_name = display_text(first_value(row, "patient_name"), "患者")
button.setAccessibleName(f"与{patient_name}进行 IM 问诊")
identifiers_ready = (
_appointment_id(row) > 0
and _diagnosis_id(row) > 0
and _video_patient_id(row) > 0
)
status_error = _im_consult_status_error(row)
enabled = (
has_permission
and self._native_video_capable
and identifiers_ready
and not status_error
)
button.setEnabled(enabled)
if not has_permission:
tooltip = "当前账号无 IM 问诊权限"
elif not self._native_video_capable:
tooltip = "当前工作站未配置完整的视频问诊能力"
elif not identifiers_ready:
tooltip = "患者、诊单或挂号标识不完整"
elif status_error:
tooltip = status_error
else:
tooltip = "打开患者 IM,可发送消息并从会话中发起视频"
button.setToolTip(tooltip)
button.clicked.connect(
lambda _checked=False, source=row: self._run_video_row_action(
source,
self._request_video,
)
)
layout.addWidget(button)
self.table.setCellWidget(row_index, action_column, host)
item = self.table.item(row_index, action_column)
if item is not None:
item.setText("")
def _fit_table_rows(self) -> None:
"""Give each appointment row enough height for its real line count."""
@@ -1685,7 +1519,12 @@ class AppointmentsPage(QWidget):
and _as_int(first_value(row, "doctor_id", default=0)) > 0
)
self.call_button.setEnabled(
not_completed and self._native_video_capable and _appointment_id(row) > 0
has_row
and not _im_consult_status_error(row)
and self._native_video_capable
and _appointment_id(row) > 0
and _diagnosis_id(row) > 0
and _video_patient_id(row) > 0
)
self.complete_button.setEnabled(not_completed and _appointment_id(row) > 0)
self.prescription_button.setEnabled(has_row and _diagnosis_id(row) > 0)
@@ -1760,15 +1599,15 @@ class AppointmentsPage(QWidget):
row = self._current_row()
if row is None:
return
status = _status_value(row)
if status == 3:
show_toast(self, "已完成的挂号不可再发起通话。", "warning")
status_error = _im_consult_status_error(row)
if status_error:
show_toast(self, f"{status_error}。", "warning")
return
appointment_id = _appointment_id(row)
diagnosis_id = _diagnosis_id(row)
patient_id = _video_patient_id(row)
if appointment_id <= 0 or diagnosis_id <= 0 or patient_id <= 0:
show_toast(self, "患者、诊单或挂号标识不完整,无法进入视频问诊。", "warning")
show_toast(self, "患者、诊单或挂号标识不完整,无法进入 IM 问诊。", "warning")
return
self.video_requested.emit(
{
@@ -1777,6 +1616,8 @@ class AppointmentsPage(QWidget):
"patient_id": patient_id,
"diagnosis_id": diagnosis_id,
"patient_name": first_value(row, "patient_name", default="患者"),
# Open IM first. The doctor explicitly starts video from the
# conversation instead of requiring an already-live call.
"mode": "im",
"record": row,
}
@@ -2069,7 +1910,8 @@ class AppointmentsPage(QWidget):
if _diagnosis_id(row) <= 0 or _appointment_id(row) <= 0:
show_toast(self, "该预约缺少诊单信息,请先编辑患者。", "warning")
return
self._begin_prescription_load(row)
mode = "view" if prescription_action_label(row) == "查看" else "open"
self._begin_prescription_load(row, mode=mode)
def _view_case(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang", default=False):
@@ -2112,7 +1954,7 @@ class AppointmentsPage(QWidget):
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
dialog.exec()
def _begin_prescription_load(self, row: Any) -> None:
def _begin_prescription_load(self, row: Any, *, mode: str = "open") -> None:
snapshot = deepcopy(row)
self._prescription_generation += 1
generation = self._prescription_generation
@@ -2126,18 +1968,51 @@ class AppointmentsPage(QWidget):
"get_prescription_by_appointment",
appointment_id=appointment_id,
),
on_success=lambda existing: self._prescription_loaded(existing, snapshot, generation),
on_success=lambda existing: self._prescription_loaded(
existing,
snapshot,
generation,
mode,
),
on_error=lambda error: self._prescription_error(error, generation),
on_finished=lambda: self._prescription_finished(generation),
)
def _prescription_loaded(self, existing: Any, row: Any, generation: int) -> None:
def _prescription_loaded(
self,
existing: Any,
row: Any,
generation: int,
mode: str = "open",
) -> None:
if generation != self._prescription_generation:
return
self.banner.clear()
if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0:
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
actual_id = _as_int(first_value(existing, "id", "prescription_id", default=0))
expected_id = _as_int(
first_value(row, "current_prescription_id", default=0)
)
actual_appointment_id = _as_int(
first_value(existing, "appointment_id", default=0)
)
expected_appointment_id = _appointment_id(row)
identity_changed = (
(expected_id > 0 and actual_id != expected_id)
or (
actual_appointment_id > 0
and expected_appointment_id > 0
and actual_appointment_id != expected_appointment_id
)
)
if mode == "view" and (not approved or voided or identity_changed):
self.banner.show_message(
"当前挂号的处方状态已变化,请刷新列表后重试。",
"warning",
)
return
if not approved or voided:
self._open_existing_prescription_editor(existing)
else:
@@ -2151,6 +2026,12 @@ class AppointmentsPage(QWidget):
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
dialog.exec()
return
if mode == "view":
self.banner.show_message(
"当前挂号没有可查看的处方,请刷新列表后重试。",
"warning",
)
return
self._begin_case_record_load(row)
def _open_existing_prescription_editor(self, prescription: Any) -> None:
@@ -45,6 +45,8 @@ from ..diagnosis_index_widgets import (
DiagnosisPager,
DiagnosisTableHost,
FlowWidget,
prescription_action,
video_call_is_live,
)
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
@@ -373,6 +375,9 @@ def _video_payload(record: Any) -> dict[str, Any]:
"patient_id": first_value(record, "patient_id", "source_patient_id"),
"diagnosis_id": first_value(record, "diagnosis_id", "id"),
"patient_name": first_value(record, "patient_name", default="患者"),
# This page is the assistant/admin receiver. IM mode logs in and waits
# for the doctor's existing invitation; it must never call startCall.
"mode": "im",
"record": record,
}
@@ -389,17 +394,7 @@ def is_diagnosis_confirmed(record: Any) -> bool:
def prescription_action_label(record: Any) -> str:
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
explicit = first_value(record, "has_prescription", default=None)
has_prescription = (
_as_bool(explicit)
if explicit is not None
else _as_int(first_value(record, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
)
if not has_prescription:
return "开方"
return "查看处方" if audit == 1 and voided != 1 else "编辑处方"
return prescription_action(record)[0]
def can_void_prescription(record: Any) -> bool:
@@ -2931,10 +2926,21 @@ class ConsultationsPage(QWidget):
self.edit_button.setEnabled(has_record)
self.delete_button.setEnabled(has_record)
self.prescription_button.setText(
prescription_action_label(record) if record is not None else "开方"
prescription_action(
record,
force_open=self.table_host.force_open_prescription,
)[0]
if record is not None
else "开方"
)
self.prescription_button.setEnabled(has_record and not self._prescription_busy)
row_has_prescription = _as_bool(first_value(record, "has_prescription", default=False))
row_has_prescription = _as_bool(
first_value(
record,
"current_has_prescription",
default=first_value(record, "has_prescription", default=False),
)
)
self.void_button.setEnabled(
has_record
and row_has_prescription
@@ -2946,7 +2952,12 @@ class ConsultationsPage(QWidget):
_as_int(payload.get(key), 0) > 0
for key in ("appointment_id", "patient_id", "diagnosis_id")
)
self.video_button.setEnabled(has_record and is_video_available(record) and valid_ids)
self.video_button.setEnabled(
has_record
and is_video_available(record)
and video_call_is_live(record)
and valid_ids
)
@property
def _diagnosis_dialog(self) -> DiagnosisDialog:
@@ -3076,7 +3087,11 @@ class ConsultationsPage(QWidget):
record = self.table.current_data()
if _as_int(first_value(record, "diagnosis_id", "id", default=0)) <= 0:
return
self._begin_prescription_load(record, mode="open")
_label, mode = prescription_action(
record,
force_open=self.table_host.force_open_prescription,
)
self._begin_prescription_load(record, mode=mode)
def _void_selected_prescription(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang"):
@@ -3114,6 +3129,28 @@ class ConsultationsPage(QWidget):
if existing is not None:
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
actual_id = _as_int(first_value(existing, "id", "prescription_id", default=0))
expected_id = _as_int(
first_value(record, "current_prescription_id", default=0)
)
actual_appointment_id = _as_int(
first_value(existing, "appointment_id", default=0)
)
expected_appointment_id = _appointment_id(record)
identity_changed = (
(expected_id > 0 and actual_id != expected_id)
or (
actual_appointment_id > 0
and expected_appointment_id > 0
and actual_appointment_id != expected_appointment_id
)
)
if mode == "view" and (not approved or voided or identity_changed):
self.banner.show_message(
"当前挂号的处方状态已变化,请刷新列表后重试。",
"warning",
)
return
if not approved or voided:
self._open_existing_prescription_editor(existing)
else:
@@ -3127,6 +3164,12 @@ class ConsultationsPage(QWidget):
detail.diagnosis_requested.connect(self._open_diagnosis_id)
detail.exec()
return
if mode == "view":
self.banner.show_message(
"当前挂号没有可查看的处方,请刷新列表后重试。",
"warning",
)
return
self._begin_case_record_load(record)
def _open_existing_prescription_editor(self, prescription: Any) -> None:
@@ -3339,6 +3382,9 @@ class ConsultationsPage(QWidget):
if record is None or not is_video_available(record):
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
return
if not video_call_is_live(record):
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
return
payload = _video_payload(record)
if not all(
_as_int(payload.get(key), 0) > 0
+40 -13
View File
@@ -1317,9 +1317,13 @@ class PatientListWorkspace(QWidget):
panel.setSpacing(6)
top_row = QHBoxLayout()
top_row.setObjectName("PatientFilterTopRow")
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(6)
top_row.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.keyword_edit = QLineEdit()
self.keyword_edit.setObjectName("PatientKeywordInput")
self.keyword_edit.setMaximumWidth(620)
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.returnPressed.connect(self.search)
@@ -1335,6 +1339,8 @@ class PatientListWorkspace(QWidget):
self.status_combo.addItem("已过号", "missed")
self.status_combo.hide()
self.status_host = QWidget()
self.status_host.setObjectName("PatientStatusFilterHost")
self.status_host.setMaximumWidth(440)
status_row = QHBoxLayout(self.status_host)
status_row.setContentsMargins(0, 0, 0, 0)
status_row.setSpacing(2)
@@ -1370,21 +1376,27 @@ class PatientListWorkspace(QWidget):
status_row.addWidget(button, 1)
self.status_buttons[""].setChecked(True)
top_row.addWidget(self.status_host, 2)
search = QPushButton("查询")
search.setProperty("variant", "primary")
search.setProperty("patientSearchAction", True)
search.clicked.connect(self.search)
top_row.addWidget(search)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.clicked.connect(self.reset_filters)
top_row.addWidget(reset)
self.search_button = QPushButton("查询")
self.search_button.setObjectName("PatientSearchButton")
self.search_button.setProperty("variant", "primary")
self.search_button.setProperty("patientSearchAction", True)
self.search_button.clicked.connect(self.search)
top_row.addWidget(self.search_button)
self.reset_button = QPushButton("重置")
self.reset_button.setObjectName("PatientResetButton")
self.reset_button.setProperty("variant", "ghost")
self.reset_button.clicked.connect(self.reset_filters)
top_row.addWidget(self.reset_button)
panel.addLayout(top_row)
bottom_row = QHBoxLayout()
bottom_row.setObjectName("PatientFilterDateRow")
bottom_row.setContentsMargins(0, 0, 0, 0)
bottom_row.setSpacing(6)
bottom_row.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.quick_host = QWidget()
self.quick_host.setObjectName("PatientQuickDateHost")
self.quick_host.setMaximumWidth(620)
quick = QHBoxLayout(self.quick_host)
quick.setContentsMargins(0, 0, 0, 0)
quick.setSpacing(2)
@@ -1411,10 +1423,13 @@ class PatientListWorkspace(QWidget):
bottom_row.addWidget(self.quick_host, 3)
self.date_host = QWidget()
self.date_host.setObjectName("PatientDateRangeHost")
self.date_host.setMaximumWidth(420)
dates = QHBoxLayout(self.date_host)
dates.setContentsMargins(0, 0, 0, 0)
dates.setSpacing(6)
self.start_date = QDateEdit(QDate.currentDate())
self.start_date.setObjectName("PatientStartDateEdit")
self.start_date.setCalendarPopup(True)
self.start_date.setDisplayFormat("yyyy-MM-dd")
self.start_date.setEnabled(False)
@@ -1425,22 +1440,26 @@ class PatientListWorkspace(QWidget):
separator.setProperty("role", "muted")
dates.addWidget(separator)
self.end_date = QDateEdit(QDate.currentDate())
self.end_date.setObjectName("PatientEndDateEdit")
self.end_date.setCalendarPopup(True)
self.end_date.setDisplayFormat("yyyy-MM-dd")
self.end_date.setEnabled(False)
self.end_date.editingFinished.connect(self._custom_date_changed)
dates.addWidget(self.end_date, 1)
bottom_row.addWidget(self.date_host, 2)
custom = QPushButton("自定义")
custom.setProperty("variant", "ghost")
custom.clicked.connect(lambda: self.set_date_mode("custom"))
bottom_row.addWidget(custom)
self.custom_date_button = QPushButton("自定义")
self.custom_date_button.setObjectName("PatientCustomDateButton")
self.custom_date_button.setProperty("variant", "ghost")
self.custom_date_button.clicked.connect(lambda: self.set_date_mode("custom"))
bottom_row.addWidget(self.custom_date_button)
panel.addLayout(bottom_row)
return card
def _build_summary(self) -> QHBoxLayout:
layout = QHBoxLayout()
layout.setObjectName("PatientSummaryRow")
layout.setSpacing(8)
layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.summary_buttons: dict[str, QPushButton] = {}
for key, label, mode in (
("today", "今日预约", "today"),
@@ -1448,8 +1467,16 @@ class PatientListWorkspace(QWidget):
("day_after", "后天预约", "day_after"),
):
button = QPushButton(f"{label}\n0 人")
button.setObjectName(
{
"today": "PatientSummaryToday",
"tomorrow": "PatientSummaryTomorrow",
"day_after": "PatientSummaryDayAfter",
}[key]
)
button.setProperty("summaryCard", True)
button.setFixedHeight(42)
button.setMaximumWidth(420)
button.setIcon(_summary_calendar_icon())
button.setIconSize(QSize(34, 34))
button.clicked.connect(lambda _checked=False, value=mode: self.set_date_mode(value))
File diff suppressed because it is too large Load Diff
+108 -24
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import sqlite3
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import wraps
@@ -22,6 +23,7 @@ from PySide6.QtGui import (
QShortcut,
)
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QFrame,
QHBoxLayout,
@@ -38,6 +40,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from .dialogs.local_audio_queue import LocalAudioQueueDialog
from .pages import (
AppointmentsPage,
ConsultationsPage,
@@ -61,6 +64,25 @@ _SHELL_TOPBAR_HEIGHT = 62
_SHELL_TABS_HEIGHT = 0
_SHELL_OUTER_GUTTER = 13
_SHELL_PANEL_GAP = 0
_SHELL_DESIGN_SIZE = QSize(1710, 920)
_SHELL_MINIMUM_SIZE = QSize(1024, 640)
def _bounded_initial_window_size(available_size: QSize | None) -> QSize:
"""Fit the design viewport inside the screen's logical available geometry."""
if available_size is None or not available_size.isValid():
return QSize(_SHELL_DESIGN_SIZE.width(), _SHELL_DESIGN_SIZE.height())
return QSize(
max(
_SHELL_MINIMUM_SIZE.width(),
min(_SHELL_DESIGN_SIZE.width(), available_size.width()),
),
max(
_SHELL_MINIMUM_SIZE.height(),
min(_SHELL_DESIGN_SIZE.height(), available_size.height()),
),
)
# The references place the workspace at page-specific global x anchors while
# keeping a 13 px outer gutter. These are the actual rail widths inside that
@@ -660,24 +682,29 @@ class _PaintedIconButton(QToolButton):
self.setFixedSize(size, size)
self.setIcon(_painted_shell_icon(kind, icon_size))
self.setIconSize(QSize(icon_size, icon_size))
content_size = max(0, size - 2)
self.setStyleSheet(
"""
QToolButton#ShellPaintedIconButton {
f"""
QToolButton#ShellPaintedIconButton {{
min-width: {content_size}px;
max-width: {content_size}px;
min-height: {content_size}px;
max-height: {content_size}px;
background: transparent;
border: 1px solid transparent;
border-radius: 9px;
}
QToolButton#ShellPaintedIconButton:hover {
}}
QToolButton#ShellPaintedIconButton:hover {{
background: #F0F3FC;
border-color: #E2E7F4;
}
QToolButton#ShellPaintedIconButton:pressed { background: #EEF1FF; }
QToolButton#ShellPaintedIconButton:focus { border-color: #8D9BFF; }
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {
}}
QToolButton#ShellPaintedIconButton:pressed {{ background: #EEF1FF; }}
QToolButton#ShellPaintedIconButton:focus {{ border-color: #8D9BFF; }}
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {{
background: #FFF0F2;
border-color: #F4C4CC;
}
QToolButton#ShellPaintedIconButton::menu-indicator { image: none; width: 0; }
}}
QToolButton#ShellPaintedIconButton::menu-indicator {{ image: none; width: 0; }}
"""
)
@@ -868,9 +895,12 @@ class ShellWindow(QMainWindow):
self._activation_page: QWidget | None = None
self._activation_generation = 0
self._activation_refreshed = False
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
self.setMinimumSize(1024, 640)
self.resize(1710, 920)
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
screen = self.screen() or QApplication.primaryScreen()
available_size = screen.availableGeometry().size() if screen is not None else None
self.resize(_bounded_initial_window_size(available_size))
canvas = _ShellCanvas(self)
canvas.setObjectName("AppCanvas")
@@ -1002,7 +1032,20 @@ class ShellWindow(QMainWindow):
font-weight: 700;
}
QPushButton#ShellAssistantButton:hover { background-color: #4658E8; }
QLabel#ShellModelLabel { color: #8A95AF; font-size: 10px; }
QPushButton#ShellSettingsButton {
min-height: 42px;
color: #6E7C9F;
font-size: 11px;
font-weight: 600;
background-color: transparent;
border: 1px solid transparent;
border-radius: 10px;
}
QPushButton#ShellSettingsButton:hover {
color: #5265F6;
background-color: #EEF1FF;
}
QPushButton#ShellSettingsButton:focus { border-color: #8D9BFF; }
"""
)
layout = QVBoxLayout(sidebar)
@@ -1074,11 +1117,17 @@ class ShellWindow(QMainWindow):
outer_assistant.addWidget(self.assistant_card)
layout.addLayout(outer_assistant)
self.model_label = QLabel("模型:GPT-4o 医疗版 ›", sidebar)
self.model_label.setObjectName("ShellModelLabel")
self.model_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.model_label.setFixedHeight(76)
layout.addWidget(self.model_label)
self.upload_settings_button = QPushButton("设置 ›", sidebar)
self.upload_settings_button.setObjectName("ShellSettingsButton")
self.upload_settings_button.setAccessibleName("本机录音上传设置")
self.upload_settings_button.setToolTip("查看本机录音上传记录")
self.upload_settings_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.upload_settings_button.setFixedHeight(76)
self.upload_settings_button.clicked.connect(self._open_local_audio_settings)
# Keep the former attribute as a compatibility alias for integrations
# that inspect the bottom sidebar control.
self.model_label = self.upload_settings_button
layout.addWidget(self.upload_settings_button)
return sidebar
def _build_topbar(self) -> QWidget:
@@ -1095,10 +1144,6 @@ class ShellWindow(QMainWindow):
border-top-right-radius: 16px;
}
QFrame#ShellGlobalSearch {
min-width: 265px;
max-width: 265px;
min-height: 36px;
max-height: 36px;
background-color: #F8FAFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -1112,6 +1157,23 @@ class ShellWindow(QMainWindow):
border: 0;
border-radius: 9px;
}
QLineEdit#ShellGlobalSearchInput QToolButton {
min-width: 22px;
max-width: 22px;
min-height: 18px;
max-height: 18px;
margin: 0;
padding: 0;
background-color: transparent;
border: 0;
border-radius: 0;
}
QLineEdit#ShellGlobalSearchInput QToolButton:hover,
QLineEdit#ShellGlobalSearchInput QToolButton:pressed,
QLineEdit#ShellGlobalSearchInput QToolButton:focus {
background-color: transparent;
border: 0;
}
QLabel#ShellShortcutHint {
color: #8A95AF;
background-color: #F0F3FC;
@@ -1207,7 +1269,9 @@ class ShellWindow(QMainWindow):
search_layout.addWidget(self.global_search, 1)
shortcut_hint = QLabel("Ctrl K", search_host)
shortcut_hint.setObjectName("ShellShortcutHint")
search_layout.addWidget(shortcut_hint)
shortcut_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
shortcut_hint.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
search_layout.addWidget(shortcut_hint, 0, Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(search_host)
self.context_label = QLabel("工作台", topbar)
@@ -1393,7 +1457,7 @@ class ShellWindow(QMainWindow):
)
self.brand_copy.setVisible(not self._sidebar_collapsed)
self.assistant_card.setVisible(not self._sidebar_collapsed)
self.model_label.setVisible(not self._sidebar_collapsed)
self.upload_settings_button.setVisible(not self._sidebar_collapsed)
margins = (7, 14, 7, 0) if self._sidebar_collapsed else (12, 19, 12, 0)
self.nav_layout.setContentsMargins(*margins)
self.fold_button.kind = "expand" if self._sidebar_collapsed else "fold"
@@ -1486,6 +1550,26 @@ class ShellWindow(QMainWindow):
3600,
)
def _open_local_audio_settings(self) -> None:
current = self._local_audio_settings_dialog
if current is not None and current.isVisible():
current.raise_()
current.activateWindow()
return
try:
dialog = LocalAudioQueueDialog(self.repository, None, self)
except (OSError, RuntimeError, ValueError, sqlite3.Error) as error:
show_toast(self, f"无法打开本机录音上传设置:{error}", "error")
return
self._local_audio_settings_dialog = dialog
def clear_dialog(_result: int) -> None:
if self._local_audio_settings_dialog is dialog:
self._local_audio_settings_dialog = None
dialog.finished.connect(clear_dialog)
dialog.open()
def _tab_index_for_key(self, key: str) -> int:
for index in range(self.tab_bar.count()):
if self.tab_bar.tabData(index) == key:
+7 -2
View File
@@ -312,9 +312,14 @@ def run_async(
on_error: Callable[[Exception], None] | None = None,
on_finished: Callable[[], None] | None = None,
pool: QThreadPool | None = None,
priority: int = 0,
**kwargs: Any,
) -> Worker:
"""Run ``function`` off the GUI thread and return its Worker handle."""
"""Run ``function`` off the GUI thread and return its Worker handle.
Higher-priority queued work starts first when a pool thread becomes free.
Running work is never interrupted.
"""
worker = Worker(function, *args, **kwargs)
_RUNNING_WORKERS.add(worker)
@@ -324,7 +329,7 @@ def run_async(
if on_finished is not None:
worker.signals.finished.connect(on_finished)
worker.signals.finished.connect(lambda: _RUNNING_WORKERS.discard(worker))
(pool or QThreadPool.globalInstance()).start(worker)
(pool or QThreadPool.globalInstance()).start(worker, priority)
return worker
+133 -5
View File
@@ -15,6 +15,7 @@ import threading
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
from .launcher import VideoCallRequest
@@ -110,6 +111,29 @@ def _extract_call_record_id(result: Any) -> int | str | None:
return None
def _extract_cloud_recording_outcome(result: Any) -> tuple[bool, str] | None:
"""Read the cloud mixed-video recording outcome returned by room binding."""
pending = [result]
visited: set[int] = set()
while pending:
candidate = pending.pop(0)
mapping = _mapping_candidate(candidate)
if mapping is None or id(mapping) in visited:
continue
visited.add(id(mapping))
recording = mapping.get("cloud_recording", mapping.get("cloudRecording"))
if isinstance(recording, Mapping):
started = bool(recording.get("started"))
message = str(recording.get("message") or "").strip()[:200]
return started, message
for key in ("data", "result"):
nested = mapping.get(key)
if isinstance(nested, Mapping):
pending.append(nested)
return None
def _clean_transcript_segment(segment: Mapping[str, Any], session_id: str) -> dict[str, Any]:
segment_id = str(segment.get("segment_id", segment.get("segmentId", ""))).strip()
text = str(segment.get("text", segment.get("sourceText", ""))).strip()
@@ -239,6 +263,7 @@ class OrderedCallLifecycle:
self._claimed_room_id: str | None = None
self._start_future: Future[bool] | None = None
self._bind_future: Future[bool] | None = None
self._local_recording_future: Future[bool] | None = None
self._end_future: Future[bool] | None = None
self._transcription_start_future: Future[bool] | None = None
self._transcription_finish_future: Future[bool] | None = None
@@ -256,6 +281,13 @@ class OrderedCallLifecycle:
def transcription_session_id(self) -> str | None:
return self._transcription_session_id
@property
def current_room_id(self) -> str | None:
"""Return the room observed for this call cycle, including an active bind."""
with self._lock:
return self.bound_room_id or self._claimed_room_id
def start(self) -> Future[bool]:
with self._lock:
if self._start_future is not None:
@@ -318,18 +350,31 @@ class OrderedCallLifecycle:
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
if not callable(method):
self.logger.warning(
"video repository does not implement bind_call_room",
extra={"video_call": self.request.safe_log_context()},
)
return False
_call_repository_method(
result = _call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id, "room_id": cleaned},
{
"diagnosis_id": self.request.diagnosis_id,
"room_id": cleaned,
"call_record_id": record_id,
},
)
recording_outcome = _extract_cloud_recording_outcome(result)
if recording_outcome is not None and not recording_outcome[0]:
raise RuntimeError(
recording_outcome[1]
or "automatic Tencent cloud mixed-video recording did not start"
)
with self._lock:
self.bound_room_id = cleaned
self.logger.info(
@@ -338,8 +383,85 @@ class OrderedCallLifecycle:
)
return True
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
future = self._worker.submit("bind", operation)
self._bind_future = future
def release_failed_claim(completed: Future[bool]) -> None:
try:
succeeded = bool(completed.result())
except Exception:
succeeded = False
if succeeded:
return
# A transient bridge/API failure must not permanently pin the
# room to a failed Future. The companion retries the exact
# same room after the host acknowledgement, so release only
# this failed claim while preserving successful bindings.
with self._lock:
if self._bind_future is completed and self.bound_room_id is None:
self._bind_future = None
self._claimed_room_id = None
future.add_done_callback(release_failed_claim)
return future
def save_local_audio_recording(
self,
path: str | Path,
*,
mime_type: str = "audio/webm",
) -> Future[bool]:
"""Upload one locally mixed audio file to this exact call record.
The operation shares the lifecycle FIFO, so a caller that queues this
before :meth:`end` is guaranteed to attach the COS object before the
call record is marked ended.
"""
recording_path = Path(path).expanduser().resolve()
clean_mime = str(mime_type or "audio/webm").strip()[:120] or "audio/webm"
if not recording_path.is_file() or recording_path.stat().st_size <= 0:
raise ValueError("local audio recording is empty or missing")
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
if self._local_recording_future is not None:
return self._local_recording_future
if self._start_future is None:
self.start()
method = getattr(self.repository, "upload_call_recording", None)
if not callable(method):
raise ValueError("video repository does not implement local recording upload")
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
result = _call_repository_method(
method,
{
"path": recording_path,
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"mime_type": clean_mime,
},
)
if result is False:
raise RuntimeError("local audio recording upload failed")
self.logger.info(
"local call audio uploaded and attached",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._local_recording_future = self._worker.submit(
"local-audio-upload", operation
)
return self._local_recording_future
def save_screenshot(self, content: bytes, filename: str) -> Future[str]:
"""Upload one video frame and append it to the diagnosis doctor notes."""
@@ -554,15 +676,21 @@ class OrderedCallLifecycle:
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
with self._lock:
self.ended = True
return False
if not callable(method):
raise ValueError("video repository does not implement end_call")
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id},
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
},
)
with self._lock:
self.ended = True
+504 -10
View File
@@ -11,6 +11,9 @@ import base64
import binascii
import json
import logging
import os
import re
import sqlite3
import sys
from collections.abc import Callable, Mapping
from concurrent.futures import Future
@@ -20,6 +23,11 @@ from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
from ..services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from .launcher import (
VideoCallRequest,
VideoTicketError,
@@ -29,7 +37,7 @@ from .lifecycle import OrderedCallLifecycle
from .security import TrustedDocumentError, TrustedDocumentPolicy
try: # Optional by design: core-only builds must still import this module.
from PySide6.QtCore import QObject, Qt, QUrl, Signal, Slot
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal, Slot
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import (
QWebEnginePage,
@@ -39,7 +47,7 @@ try: # Optional by design: core-only builds must still import this module.
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QApplication, QMainWindow
except (ImportError, OSError) as _qt_import_error: # pragma: no cover - no Qt runtime.
QObject = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QObject = QTimer = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QWebChannel = QWebEnginePage = QWebEngineProfile = None # type: ignore[assignment]
QWebEngineSettings = QWebEngineView = None # type: ignore[assignment]
QApplication = QMainWindow = None # type: ignore[assignment]
@@ -63,6 +71,18 @@ class CompanionLocation:
is_local: bool
@dataclass(slots=True)
class _LocalAudioCapture:
record_id: int
session_id: str
mime_type: str
path: Path
handle: Any
lifecycle: OrderedCallLifecycle
next_sequence: int = 0
bytes_written: int = 0
def _validate_remote_url(value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme.lower() != "https" or not parsed.hostname:
@@ -204,13 +224,67 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
}
)
@Slot(str, str) # type: ignore[misc]
def startLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, mime_type: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-start",
"sessionId": session_id,
"mimeType": mime_type,
}
)
@Slot(str, int, str) # type: ignore[misc]
def appendLocalAudioChunk( # noqa: N802 - Qt bridge API
self, session_id: str, sequence: int, encoded: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-chunk",
"sessionId": session_id,
"sequence": sequence,
"data": encoded,
}
)
@Slot(str, int) # type: ignore[misc]
def finishLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, total_bytes: int
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-finish",
"sessionId": session_id,
"totalBytes": total_bytes,
}
)
@Slot(str) # type: ignore[misc]
def abortLocalAudioRecording(self, session_id: str) -> None: # noqa: N802
self._callback(
{
"source": "doctor-call",
"event": "local-audio-abort",
"sessionId": session_id,
}
)
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
status_changed = Signal(str) # type: ignore[misc]
call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
_room_completed = Signal(str, bool, str) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
_local_recording_completed = Signal( # type: ignore[misc]
str, str, int, bool, str
)
def __init__(
self,
@@ -247,8 +321,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._close_reason = "window-closed"
self._call_cycle_closed = False
self._start_requested = False
self._shutdown_requested = False
self._local_audio_capture: _LocalAudioCapture | None = None
self._local_audio_store: LocalAudioQueueStore | None = None
self._local_audio_uploads: LocalAudioUploadManager | None = None
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self._shutdown_timer = QTimer(self)
self._shutdown_timer.setSingleShot(True)
self._shutdown_timer.timeout.connect(self._force_requested_shutdown)
self.setWindowTitle(
f"与 {self.patient_name} IM 问诊" if self.open_im else "视频面诊"
@@ -284,8 +365,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self._room_completed.connect(self._on_room_completed)
self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed)
self._local_recording_completed.connect(self._on_local_recording_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
@@ -428,6 +511,28 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
str(message.get("message") or "截屏图片无效。")[:200],
)
return
if event == "local-audio-start":
self._start_local_audio_recording(
str(message.get("sessionId") or ""),
str(message.get("mimeType") or "audio/webm"),
)
return
if event == "local-audio-chunk":
self._append_local_audio_chunk(
str(message.get("sessionId") or ""),
message.get("sequence"),
str(message.get("data") or ""),
)
return
if event == "local-audio-finish":
self._finish_local_audio_recording(
str(message.get("sessionId") or ""),
message.get("totalBytes"),
)
return
if event == "local-audio-abort":
self._abort_local_audio_recording(str(message.get("sessionId") or ""))
return
if event == "transcription-start-request":
self._start_transcription(
str(message.get("sessionId") or ""),
@@ -445,7 +550,13 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
clean_room_id = str(room_id).strip()
future = self.lifecycle.bind_room(clean_room_id)
future.add_done_callback(
lambda completed, current_room_id=clean_room_id: (
self._notify_room_completed(current_room_id, completed)
)
)
if event == "room":
return
if event == "status":
@@ -457,7 +568,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.lifecycle.end(f"companion-{status}")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
@@ -466,9 +577,343 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.lifecycle.end("companion-error")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-error")
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
except Exception as error:
succeeded = False
message = str(error)[:200] or "腾讯云混流视频录制未启动。"
else:
message = (
"腾讯云混流视频已启动;本机录音将在结束后另行上传 COS。"
if succeeded
else "通话房间尚未绑定,云端视频和本机录音无法关联通话记录。"
)
with suppress(RuntimeError):
self._room_completed.emit(room_id, succeeded, message)
def _on_room_completed(
self,
room_id: str,
succeeded: bool,
message: str,
) -> None:
if self._closing:
return
room_payload = json.dumps(str(room_id)[:160], ensure_ascii=True)
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
"window.doctorConsultation?.roomBindingResult?.("
f"{room_payload}, {state}, {payload});"
)
def _emit_local_recording_result(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
with suppress(RuntimeError):
self._local_recording_completed.emit(
operation,
session_id,
sequence,
succeeded,
str(message)[:200],
)
def _on_local_recording_completed(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
if self._closing or self._released:
return
self._page.runJavaScript(
"window.doctorConsultation?.localRecordingResult?.("
f"{json.dumps(operation)}, {json.dumps(session_id)}, {sequence}, "
f"{'true' if succeeded else 'false'}, "
f"{json.dumps(str(message)[:200], ensure_ascii=True)});"
)
def _start_local_audio_recording(self, session_id: str, mime_type: str) -> None:
cleaned = str(session_id or "").strip()
clean_mime = str(mime_type or "audio/webm").strip().lower()[:120]
if not re.fullmatch(r"[A-Za-z0-9_-]{12,64}", cleaned):
self._emit_local_recording_result(
"start", cleaned, -1, False, "本地录音会话标识无效。"
)
return
if not clean_mime.startswith(("audio/webm", "audio/ogg")):
self._emit_local_recording_result(
"start", cleaned, -1, False, "当前浏览器录音格式不受支持。"
)
return
if self._local_audio_capture is not None:
existing = self._local_audio_capture.session_id == cleaned
self._emit_local_recording_result(
"start",
cleaned,
-1,
existing,
"本地录音已启动。" if existing else "已有另一条本地录音正在进行。",
)
return
try:
lifecycle = self.lifecycle
raw_call_record_id = lifecycle.call_record_id
call_record_id = (
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
)
store, uploads = get_local_audio_upload_manager(
lifecycle.repository,
self._local_audio_store,
)
self._local_audio_store = store
self._local_audio_uploads = uploads
record = store.begin_recording(
session_id=cleaned,
diagnosis_id=self.request.diagnosis_id,
mime_type=clean_mime,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
# The handle intentionally remains open across WebChannel chunks.
handle = record.file_path.open("w+b")
except (OSError, RuntimeError, sqlite3.Error) as error:
self._emit_local_recording_result(
"start", cleaned, -1, False, str(error)[:200]
)
return
self._local_audio_capture = _LocalAudioCapture(
record_id=record.id,
session_id=cleaned,
mime_type=clean_mime,
path=record.file_path,
handle=handle,
lifecycle=lifecycle,
)
self._emit_local_recording_result(
"start", cleaned, -1, True, "本机语音录音已启动。"
)
def _append_local_audio_chunk(
self,
session_id: str,
sequence_value: Any,
encoded: str,
) -> None:
capture = self._local_audio_capture
try:
sequence = int(sequence_value)
except (TypeError, ValueError):
sequence = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音会话标识不匹配。"
)
return
if sequence != capture.next_sequence:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片顺序不连续。"
)
return
if not encoded or len(encoded) > 16_384:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片过大或为空。"
)
return
try:
content = base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error):
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片解析失败。"
)
return
if not content or len(content) > 12 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片大小无效。"
)
return
if capture.bytes_written + len(content) > 512 * 1024 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音超过 512 MB 限制。"
)
self._abort_local_audio_recording(session_id)
return
try:
capture.handle.write(content)
except OSError as error:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, str(error)[:200]
)
self._abort_local_audio_recording(session_id)
return
capture.bytes_written += len(content)
capture.next_sequence += 1
def _finish_local_audio_recording(
self, session_id: str, total_bytes_value: Any
) -> None:
capture = self._local_audio_capture
try:
total_bytes = int(total_bytes_value)
except (TypeError, ValueError):
total_bytes = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音会话标识不匹配。"
)
return
self._local_audio_capture = None
try:
capture.handle.flush()
os.fsync(capture.handle.fileno())
capture.handle.close()
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
if total_bytes != capture.bytes_written or total_bytes <= 0:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件不完整。"
)
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音文件不完整。"
)
return
if capture.bytes_written < 1024:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件为空或只有容器信息。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音文件为空或只有容器信息,已阻止上传。",
)
return
try:
with capture.path.open("rb") as recording:
signature = recording.read(4)
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
valid_signature = (
capture.mime_type.startswith("audio/webm")
and signature == b"\x1aE\xdf\xa3"
) or (
capture.mime_type.startswith("audio/ogg") and signature == b"OggS"
)
if not valid_signature:
self._mark_local_audio_invalid(
capture.record_id, "本地录音格式校验失败。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音格式校验失败,已阻止上传无效文件。",
)
return
store = self._local_audio_store
uploads = self._local_audio_uploads
if store is None or uploads is None:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本机录音队列尚未初始化。"
)
return
try:
store.finalize_recording(
capture.record_id,
size_bytes=capture.bytes_written,
)
except (OSError, RuntimeError, sqlite3.Error) as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
lifecycle = capture.lifecycle
def enqueue_upload(start_result: Future[bool] | None = None) -> None:
try:
if start_result is not None and not bool(start_result.result()):
raise RuntimeError("通话记录创建失败,录音已保存在本机,可稍后重试。")
raw_call_record_id = lifecycle.call_record_id
call_record_id = int(raw_call_record_id or 0)
if call_record_id <= 0:
raise RuntimeError("未取得通话记录编号,录音已保存在本机,可稍后重试。")
store.bind_identity(
capture.record_id,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
uploads.submit(capture.record_id)
except Exception as error:
with suppress(Exception):
store.update_status(
capture.record_id,
"failed",
str(error)[:1000],
)
if lifecycle.call_record_id:
enqueue_upload()
else:
try:
lifecycle.start().add_done_callback(enqueue_upload)
except Exception as error:
store.update_status(
capture.record_id,
"failed",
str(error)[:1000] or "通话记录创建失败。",
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
True,
"本地录音已保存,正在后台上传 COS。",
)
def _mark_local_audio_invalid(self, record_id: int, message: str) -> None:
store = self._local_audio_store
if store is None:
return
with suppress(Exception):
store.mark_invalid(record_id, message)
def _abort_local_audio_recording(self, session_id: str) -> None:
capture = self._local_audio_capture
if capture is None or (session_id and capture.session_id != session_id):
return
self._local_audio_capture = None
with suppress(OSError):
capture.handle.flush()
capture.handle.close()
self._mark_local_audio_invalid(
capture.record_id,
"本次本地录音未正常结束,文件已保留以便排查。",
)
def _start_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
@@ -633,7 +1078,49 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.close()
def hangup(self) -> None:
self._close_reason = "desktop-hangup"
self._request_companion_shutdown("desktop-hangup")
def _request_companion_shutdown(self, reason: str) -> None:
"""Let MediaRecorder finish and upload before WebEngine is destroyed."""
self._close_reason = reason
if self._closing or self._released:
return
should_wait_for_companion = (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
)
if not should_wait_for_companion:
self.close()
return
if self._shutdown_requested:
return
self._shutdown_requested = True
# doctorConsultation.close() stops MediaRecorder, drains every queued
# WebChannel chunk, waits for the Qt/COS finish acknowledgement, and
# only then emits hangup. Keeping _closing false here is essential:
# bridge callbacks are deliberately rejected once final destruction
# begins.
self._page.runJavaScript(
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self._shutdown_timer.start(190_000)
def _force_requested_shutdown(self) -> None:
"""Bound a failed companion shutdown without racing queued uploads."""
if not self._shutdown_requested or self._closing or self._released:
return
if self._start_requested and not self._call_cycle_closed:
# OrderedCallLifecycle places end after any upload that already
# reached the Qt bridge.
self.lifecycle.end(f"{self._close_reason}-timeout")
self._call_cycle_closed = True
self._start_requested = False
self._abort_local_audio_recording("")
self._companion_ended = True
self.close()
def _begin_shutdown(self) -> None:
@@ -641,12 +1128,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return
self._closing = True
self._media_active = False
if self._injected and not self._companion_ended and not self._released:
self._page.runJavaScript(
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self._shutdown_timer.stop()
if self._start_requested and not self._call_cycle_closed:
self.lifecycle.end(self._close_reason)
self._abort_local_audio_recording("")
self._release_webengine()
def wait_for_lifecycles(self, timeout: float) -> bool:
@@ -698,6 +1183,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._profile.deleteLater()
def closeEvent(self, event: Any) -> None:
if (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
):
event.ignore()
self._request_companion_shutdown(self._close_reason)
return
self._begin_shutdown()
event.accept()