更新
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user