This commit is contained in:
Your Name
2026-08-12 11:03:28 +08:00
parent 09d3fcbf82
commit bc9ad1d3cd
32 changed files with 4586 additions and 2341 deletions
@@ -359,6 +359,8 @@ class VideoCallLauncher:
*,
diagnosis_id: Any = None,
patient_id: Any = None,
open_im: bool = False,
patient_name: str = "患者",
) -> Any:
request = self.prepare(
ticket,
@@ -374,6 +376,8 @@ class VideoCallLauncher:
remote_url=self.remote_url,
logger=self.logger,
browser_opener=self.browser_opener,
open_im=open_im,
patient_name=patient_name,
)
@@ -388,6 +392,8 @@ def launch_video_call(
remote_url: str | None = None,
logger: Any = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
@@ -402,4 +408,6 @@ def launch_video_call(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
open_im=open_im,
patient_name=patient_name,
)
@@ -262,6 +262,53 @@ class OrderedCallLifecycle:
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
def save_screenshot(self, content: bytes, filename: str) -> Future[str]:
"""Upload one video frame and append it to the diagnosis doctor notes."""
if not content:
raise ValueError("screenshot content must not be empty")
if len(content) > 10 * 1024 * 1024:
raise ValueError("screenshot content exceeds 10 MB")
clean_name = str(filename or "callshot.jpg").strip() or "callshot.jpg"
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
upload = getattr(self.repository, "upload_material_bytes", None)
add_note = getattr(self.repository, "add_doctor_note", None)
if not callable(upload) or not callable(add_note):
raise ValueError("video repository does not implement screenshot storage")
def operation() -> str:
reference = str(
_call_repository_method(
upload,
{
"content": content,
"filename": clean_name,
"material_type": "image",
"cid": 0,
},
)
or ""
).strip()
if not reference:
raise ValueError("screenshot upload returned no server reference")
_call_repository_method(
add_note,
{
"diagnosis_id": self.request.diagnosis_id,
"content": "",
"tongue_images": [reference],
},
)
self.logger.info(
"video screenshot stored in doctor notes",
extra={"video_call": self.request.safe_log_context()},
)
return reference
return self._worker.submit("screenshot", operation)
def end(self, reason: str) -> Future[bool]:
with self._lock:
if self._end_future is not None:
+169 -35
View File
@@ -7,6 +7,8 @@ actual call requires an isolated QtWebEngine profile and an active QApplication.
from __future__ import annotations
import base64
import binascii
import json
import logging
import sys
@@ -181,11 +183,33 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
self._callback(message)
@Slot(str) # type: ignore[misc]
def saveScreenshot(self, data_url: str) -> None: # noqa: N802 - Qt bridge API
"""Forward one bounded JPEG data URL to the trusted desktop host."""
if not isinstance(data_url, str) or len(data_url) > 14 * 1024 * 1024:
self._callback(
{
"source": "doctor-call",
"event": "screenshot-invalid",
"message": "截屏图片过大,无法保存。",
}
)
return
self._callback(
{
"source": "doctor-call",
"event": "screenshot",
"dataUrl": data_url,
}
)
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]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
def __init__(
self,
@@ -194,12 +218,19 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
lifecycle: OrderedCallLifecycle,
*,
logger: logging.Logger,
lifecycle_factory: Callable[[], OrderedCallLifecycle],
open_im: bool = False,
patient_name: str = "患者",
) -> None:
super().__init__()
self.request = request
self.location = location
self.lifecycle = lifecycle
self._lifecycle_factory = lifecycle_factory
self._lifecycles = [lifecycle]
self.logger = logger
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
@@ -213,10 +244,14 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._companion_ended = False
self._released = False
self._close_reason = "window-closed"
self._call_cycle_closed = False
self._start_requested = False
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self.setWindowTitle("视频面诊")
self.setWindowTitle(
f"与 {self.patient_name} IM 问诊" if self.open_im else "视频面诊"
)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.resize(1120, 760)
self.setMinimumSize(760, 520)
@@ -248,6 +283,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self._screenshot_completed.connect(self._on_screenshot_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
@@ -326,17 +362,24 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.close()
return
try:
start_future = self.lifecycle.start()
except Exception:
self.logger.error(
"video call record could not be queued",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "record-start-queue-failed"
self.close()
return
start_future.add_done_callback(self._notify_start_completed)
self._media_active = True
config = {
**self.request.to_web_config(),
"patientName": self.patient_name,
"mode": "chat" if self.open_im else "video",
}
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
script = f"""
(() => {{
if (!window.doctorConsultation
|| typeof window.doctorConsultation.open !== 'function') {{
return false;
}}
void window.doctorConsultation.open({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _notify_start_completed(self, future: Future[bool]) -> None:
try:
@@ -350,25 +393,16 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if self._closing:
return
if not succeeded:
self._close_reason = "record-start-failed"
self.close()
self._start_requested = False
self._call_cycle_closed = True
self._page.runJavaScript(
"window.doctorConsultation?.hostCallReady?.(false, "
'"通话记录创建失败,请稍后重试。");'
)
return
self._media_active = True
config_json = json.dumps(
self.request.to_web_config(),
ensure_ascii=True,
separators=(",", ":"),
self._page.runJavaScript(
"window.doctorConsultation?.hostCallReady?.(true, '');"
)
script = f"""
(() => {{
if (!window.doctorCall || typeof window.doctorCall.start !== 'function') {{
return false;
}}
void window.doctorCall.start({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _after_injection(self, result: Any) -> None:
self._injected = result is not False
@@ -380,6 +414,18 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if self._closing:
return
event = str(message.get("event", ""))
if event == "call-start-request":
self._start_call_cycle()
return
if event == "screenshot":
self._save_screenshot(str(message.get("dataUrl") or ""))
return
if event == "screenshot-invalid":
self._on_screenshot_completed(
False,
str(message.get("message") or "截屏图片无效。")[:200],
)
return
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
@@ -388,16 +434,80 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if event == "status":
status = str(message.get("status", "unknown"))[:80]
self.status_changed.emit(status)
if status == "idle":
if status == "idle" and not self.open_im:
self._close_from_companion("remote-idle")
elif event == "hangup":
status = str(message.get("status", "ended"))[:80]
self.call_ended.emit(status)
self._close_from_companion("companion-hangup")
self.lifecycle.end(f"companion-{status}")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
self.call_error.emit(message_text)
self._close_from_companion("companion-error")
if self._start_requested:
self.lifecycle.end("companion-error")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
self._close_from_companion("companion-error")
def _start_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
if self._call_cycle_closed:
self.lifecycle = self._lifecycle_factory()
self._lifecycles.append(self.lifecycle)
self._call_cycle_closed = False
self._start_requested = True
try:
future = self.lifecycle.start()
except Exception:
self._start_requested = False
self._on_lifecycle_started(False)
return
future.add_done_callback(self._notify_start_completed)
def _save_screenshot(self, data_url: str) -> None:
prefix = "data:image/jpeg;base64,"
if not data_url.startswith(prefix):
self._on_screenshot_completed(False, "截屏图片格式不正确。")
return
try:
content = base64.b64decode(data_url[len(prefix) :], validate=True)
except (ValueError, binascii.Error):
self._on_screenshot_completed(False, "截屏图片解析失败。")
return
try:
future = self.lifecycle.save_screenshot(
content,
f"callshot-{self.request.diagnosis_id}.jpg",
)
except Exception as error:
self._on_screenshot_completed(False, str(error)[:200])
return
future.add_done_callback(self._notify_screenshot_completed)
def _notify_screenshot_completed(self, future: Future[str]) -> None:
try:
future.result()
except Exception as error:
succeeded = False
message = str(error)[:200] or "截屏保存失败。"
else:
succeeded = True
message = "截屏已保存到患者信息。"
with suppress(RuntimeError):
self._screenshot_completed.emit(succeeded, message)
def _on_screenshot_completed(self, succeeded: bool, message: str) -> None:
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
f"window.doctorConsultation?.screenshotResult?.({state}, {payload});"
)
def _close_from_companion(self, reason: str) -> None:
self._companion_ended = True
@@ -415,11 +525,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._media_active = False
if self._injected and not self._companion_ended and not self._released:
self._page.runJavaScript(
"void window.doctorCall?.hangup?.().catch(() => undefined)"
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self.lifecycle.end(self._close_reason)
if self._start_requested and not self._call_cycle_closed:
self.lifecycle.end(self._close_reason)
self._release_webengine()
def wait_for_lifecycles(self, timeout: float) -> bool:
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
def _release_webengine(self) -> None:
if self._released:
return
@@ -486,6 +600,8 @@ class VideoCallWindow:
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
@@ -500,14 +616,23 @@ class VideoCallWindow:
raise VideoWindowError("embedded video requires an active QApplication")
self.request = request
self.repository = repository
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
local_dist=local_dist,
remote_url=remote_url,
)
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
self._lifecycles = [self.lifecycle]
self._session: Any = None
def _new_lifecycle(self) -> OrderedCallLifecycle:
lifecycle = OrderedCallLifecycle(self.request, self.repository, self.logger)
self._lifecycles.append(lifecycle)
return lifecycle
@property
def qt_window(self) -> Any:
return self._session
@@ -519,6 +644,9 @@ class VideoCallWindow:
self.location,
self.lifecycle,
logger=self.logger,
lifecycle_factory=self._new_lifecycle,
open_im=self.open_im,
patient_name=self.patient_name,
)
except Exception:
self.lifecycle.end("window-open-failed")
@@ -545,7 +673,9 @@ class VideoCallWindow:
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
"""Wait briefly for ordered backend writes; timeout is capped at five seconds."""
return self.lifecycle.wait(timeout)
if self._session is not None:
return self._session.wait_for_lifecycles(timeout)
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
wait = wait_for_lifecycle
@@ -558,6 +688,8 @@ def open_video_call(
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
@@ -570,6 +702,8 @@ def open_video_call(
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
open_im=open_im,
patient_name=patient_name,
).open()