更新bug
This commit is contained in:
@@ -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": "演示模式不录制"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user