This commit is contained in:
Your Name
2026-08-27 14:23:23 +08:00
parent b5b14516a1
commit 2fa8492c56
27 changed files with 3832 additions and 1435 deletions
+347 -49
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import logging
import os
import sys
import time
from contextlib import suppress
import time
from collections.abc import Mapping, Sequence
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
@@ -34,17 +35,179 @@ from doctor_workstation.services import (
build_repository,
)
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
from doctor_workstation.ui.widgets import (
friendly_error,
run_async,
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
from doctor_workstation.ui.widgets import (
first_value,
friendly_error,
gender_text,
get_value,
run_async,
set_authentication_expired_handler,
show_toast,
)
from doctor_workstation.video import BackendMode, launch_video_call
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
LOGGER = logging.getLogger(__name__)
LOGGER = logging.getLogger(__name__)
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
if value in (None, "", [], {}):
return ""
if isinstance(value, Mapping):
parts = [
f"{key}{_video_case_text(item, limit=limit)}"
for key, item in value.items()
if item not in (None, "", [], {})
]
return "".join(part for part in parts if not part.endswith(""))[:limit]
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return "".join(
part
for item in value
if (part := _video_case_text(item, limit=limit))
)[:limit]
return str(value).strip()[:limit]
def _video_identity(value: Any) -> str:
if value in (None, "") or isinstance(value, bool):
return ""
text = str(value).strip()
if not text:
return ""
with suppress(ValueError, TypeError):
return str(int(text))
return text
def _video_identity_matches(expected: Any, actual: Any) -> bool:
normalized_actual = _video_identity(actual)
return not normalized_actual or normalized_actual == _video_identity(expected)
def _build_video_patient_case(
detail: Any,
fallback_record: Any,
*,
diagnosis_id: Any,
patient_id: Any,
patient_name: str,
) -> dict[str, str]:
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
nested = get_value(detail, "data", None)
if isinstance(nested, Mapping):
detail = nested
diagnosis = get_value(detail, "diagnosis", None)
if not diagnosis and isinstance(detail, Mapping):
diagnosis = detail
diagnosis = diagnosis or {}
patient = get_value(detail, "patient", None) or {}
appointment = get_value(detail, "appointment", None) or {}
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
detail_patient_id = first_value(
diagnosis,
"source_patient_id",
default=first_value(
patient,
"source_patient_id",
default=None,
),
)
if not _video_identity_matches(
diagnosis_id,
detail_diagnosis_id,
) or not _video_identity_matches(patient_id, detail_patient_id):
detail = diagnosis = patient = appointment = {}
fallback_diagnosis_id = first_value(
fallback_record,
"diagnosis_id",
"id",
default=None,
)
fallback_patient_id = first_value(
fallback_record,
"source_patient_id",
"patient_id",
default=None,
)
if not _video_identity_matches(
diagnosis_id,
fallback_diagnosis_id,
) or not _video_identity_matches(patient_id, fallback_patient_id):
fallback_record = {}
sources = (diagnosis, patient, appointment, fallback_record)
def pick(*keys: str, limit: int = 2000) -> str:
for source in sources:
value = first_value(source, *keys, default=None)
text = _video_case_text(value, limit=limit)
if text:
return text
return ""
raw_gender = next(
(
first_value(source, "gender_desc", "gender", default=None)
for source in sources
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
),
None,
)
return {
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
"name": pick("patient_name", "name", limit=120)
or _video_case_text(patient_name, limit=120)
or "患者",
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
"age": pick("age", limit=20),
"height": pick("height", limit=20),
"weight": pick("weight", limit=20),
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
"appointmentDate": pick(
"appointment_date",
"latest_appointment_date",
limit=80,
),
"clinicalDiagnosis": pick(
"clinical_diagnosis",
"diagnosis_name",
"disease_name",
),
"chiefComplaint": pick("chief_complaint", "complaint"),
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
"allergyHistory": pick(
"allergy_history_text",
"allergy_history_desc",
"allergy_history",
),
"personalHistory": pick(
"personal_history_text",
"personal_history_desc",
"personal_history",
),
"familyHistory": pick(
"family_history_text",
"family_history_desc",
"family_history",
),
"currentMedication": pick(
"current_medications",
"current_medicine",
"current_medication",
),
"tongue": pick("tongue", "tongue_coating"),
"pulse": pick("pulse", "pulse_condition"),
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
"remark": pick("remark"),
}
class _ChineseQtTranslator(QTranslator):
@@ -247,9 +410,11 @@ class ApplicationController(QObject):
self.shell_window: ShellWindow | None = None
self.current_repository: Any = None
self.current_demo_mode = config.demo_mode
self.video_calls: dict[str, Any] = {}
self.video_pending: dict[str, object] = {}
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
self.video_calls: dict[str, Any] = {}
self.video_pending: dict[str, object] = {}
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
self._video_preview_state: dict[str, Any] | None = None
self._video_preview_generation = 0
self._restore_generation = 0
self._restore_in_progress = False
self._restore_worker: Any = None
@@ -547,16 +712,17 @@ class ApplicationController(QObject):
self._logout(message="登录状态已失效,请重新登录。")
return True
def _logout(self, *, message: str = "") -> None:
"""Clear authenticated resources and return to the login window."""
calls = tuple(self.video_calls.values())
def _logout(self, *, message: str = "") -> None:
"""Clear authenticated resources and return to the login window."""
self._restore_video_preview(activate=False)
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
self._wait_for_video_lifecycle(calls, timeout=1.25)
self.video_calls.clear()
self.video_pending.clear()
self.video_calls.clear()
self.video_pending.clear()
for dialog in self.demo_video_dialogs.values():
dialog.close()
self.demo_video_dialogs.clear()
@@ -579,7 +745,8 @@ class ApplicationController(QObject):
patient_id = payload.get("patient_id")
diagnosis_id = payload.get("diagnosis_id")
patient_name = str(payload.get("patient_name") or "患者")
open_im = str(payload.get("mode") or "video").lower() == "im"
open_im = str(payload.get("mode") or "video").lower() == "im"
fallback_record = payload.get("record")
if patient_id in (None, "") or diagnosis_id in (None, ""):
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
return
@@ -633,27 +800,49 @@ class ApplicationController(QObject):
marker = object()
self.video_pending[call_key] = marker
def get_ticket() -> Any:
return repository.get_call_ticket(
patient_id=int(patient_id),
diagnosis_id=int(diagnosis_id),
)
def get_video_context() -> tuple[Any, dict[str, str]]:
ticket = repository.get_call_ticket(
patient_id=int(patient_id),
diagnosis_id=int(diagnosis_id),
)
detail: Any = {}
detail_loader = getattr(repository, "patient_detail", None)
if callable(detail_loader):
try:
detail = detail_loader(int(diagnosis_id))
except Exception as error:
LOGGER.warning(
"patient detail could not be loaded for video call",
extra={
"diagnosis_id": diagnosis_id,
"error_type": type(error).__name__,
},
)
patient_case = _build_video_patient_case(
detail,
fallback_record,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
patient_name=patient_name,
)
return ticket, patient_case
def request_ticket() -> None:
if self.video_pending.get(call_key) is not marker:
return
run_async(
get_ticket,
on_success=lambda ticket: self._launch_video(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
run_async(
get_video_context,
on_success=lambda context: self._launch_video(
context[0],
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
call_key=call_key,
marker=marker,
open_im=open_im,
patient_name=patient_name,
),
open_im=open_im,
patient_name=patient_name,
patient_case=context[1],
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
@@ -696,8 +885,9 @@ class ApplicationController(QObject):
repository: Any,
call_key: str,
marker: object,
open_im: bool = False,
patient_name: str = "患者",
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
) -> None:
if self.video_pending.get(call_key) is not marker:
return
@@ -723,9 +913,13 @@ class ApplicationController(QObject):
local_dist=video_dist_path(),
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
open_im=open_im,
patient_name=patient_name,
)
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=lambda current_id=diagnosis_id: (
self._open_video_diagnosis(current_id)
),
)
except Exception as error:
LOGGER.exception("video call could not be launched")
show_toast(
@@ -734,20 +928,123 @@ class ApplicationController(QObject):
"danger",
5600,
)
return
self.video_calls[call_key] = call
return
self.video_calls[call_key] = call
qt_window = getattr(call, "qt_window", None)
if qt_window is not None:
qt_window.destroyed.connect(
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
key,
expected,
)
)
def _release_video_call(self, call_key: str, call: Any) -> None:
if self.video_calls.get(call_key) is call:
self.video_calls.pop(call_key, None)
)
)
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
"""Open the diagnosis while keeping its live video visible as a preview."""
shell = self.shell_window
if shell is None or self.current_repository is None:
return
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
if dialog is None:
return
call = self.video_calls.get(str(diagnosis_id))
video_window = getattr(call, "qt_window", None)
if video_window is not None:
self._show_video_preview(video_window, dialog)
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
"""Pin a compact call window above the modeless diagnosis drawer."""
current = self._video_preview_state
if current is not None and current.get("window") is not video_window:
self._restore_video_preview(activate=False)
self._video_preview_generation += 1
generation = self._video_preview_generation
if current is None or current.get("window") is not video_window:
try:
state = {
"window": video_window,
"geometry": video_window.geometry(),
"minimum_size": video_window.minimumSize(),
"maximized": video_window.isMaximized(),
"full_screen": video_window.isFullScreen(),
"stays_on_top": bool(
video_window.windowFlags()
& Qt.WindowType.WindowStaysOnTopHint
),
}
screen = video_window.screen() or QGuiApplication.primaryScreen()
available = screen.availableGeometry()
preview_width = min(540, max(460, round(available.width() * 0.29)))
preview_height = min(380, max(320, round(preview_width * 0.66)))
margin = 18
video_window.showNormal()
video_window.setMinimumSize(440, 300)
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
video_window.resize(preview_width, preview_height)
video_window.move(
available.x() + available.width() - preview_width - margin,
available.y() + margin,
)
video_window.show()
self._video_preview_state = state
except RuntimeError:
self._video_preview_state = None
return
dialog.finished.connect(
lambda _result, expected=generation: self._restore_video_preview(expected)
)
try:
video_window.raise_()
dialog.raise_()
dialog.activateWindow()
except RuntimeError:
self._video_preview_state = None
def _restore_video_preview(
self,
generation: int | None = None,
*,
activate: bool = True,
) -> None:
if generation is not None and generation != self._video_preview_generation:
return
state = self._video_preview_state
if state is None:
return
self._video_preview_state = None
self._video_preview_generation += 1
window = state.get("window")
try:
window.setWindowFlag(
Qt.WindowType.WindowStaysOnTopHint,
bool(state.get("stays_on_top")),
)
window.setMinimumSize(state["minimum_size"])
window.setGeometry(state["geometry"])
if state.get("full_screen"):
window.showFullScreen()
elif state.get("maximized"):
window.showMaximized()
else:
window.showNormal()
window.raise_()
if activate:
window.activateWindow()
except (AttributeError, RuntimeError):
return
def _release_video_call(self, call_key: str, call: Any) -> None:
preview = self._video_preview_state
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
self._video_preview_state = None
self._video_preview_generation += 1
if self.video_calls.get(call_key) is call:
self.video_calls.pop(call_key, None)
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
if self.demo_video_dialogs.get(call_key) is dialog:
@@ -778,15 +1075,16 @@ class ApplicationController(QObject):
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
def shutdown(self) -> None:
def shutdown(self) -> None:
"""Invalidate asynchronous restoration and release owned resources."""
if self._shutting_down:
return
self._shutting_down = True
self._cancel_session_restore()
set_authentication_expired_handler(None)
calls = tuple(self.video_calls.values())
self._cancel_session_restore()
set_authentication_expired_handler(None)
self._restore_video_preview(activate=False)
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
@@ -15,6 +15,7 @@ from urllib.parse import urlsplit
from PySide6.QtCore import QObject, QPointF, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
from PySide6.QtGui import (
QColor,
QFont,
QIcon,
QKeyEvent,
QPainter,
@@ -22,6 +23,9 @@ from PySide6.QtGui import (
QPen,
QPixmap,
QPolygonF,
QTextBlockFormat,
QTextCharFormat,
QTextCursor,
)
from PySide6.QtWidgets import (
QDialog,
@@ -44,7 +48,7 @@ from PySide6.QtWidgets import (
from ..diagnosis_drawer import CaseGrid, _RemoteImageButton
from ..diagnosis_media import open_safe_http_url, safe_http_url
from ..theme import crisp_pixmap, mark_business_dialog
from ..theme import apply_reading_rhythm, crisp_pixmap, mark_business_dialog
from ..widgets import (
display_text,
first_value,
@@ -790,14 +794,24 @@ _DOCTOR_DOCUMENT_CSS = (
"li { margin:3px 0; line-height:1.58; } "
"a { color:#DDE1FF; } code,pre { color:#FFFFFF; }"
)
# 模型回复几乎都是“分节标题 + 两层要点”的长文。早期样式里标题只比正文大 1px、
# 段落与列表项的间距也几乎相同,整段读起来就是一堵字墙。这里把层级、行距和分节
# 留白拉开:标题明显更大更重,顶层小节之间留出整行空白,嵌套要点单独收紧缩进。
_AI_DOCUMENT_CSS = (
"body { color:#34436B; font-size:14px; line-height:1.68; } "
"h1,h2,h3,h4 { color:#15224A; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
"p { margin:6px 0; line-height:1.68; } ul,ol { margin:7px 0 7px 21px; } "
"li { margin:4px 0; line-height:1.62; } "
"strong { color:#15224A; } a { color:#5B67F1; } "
"body { color:#3A4870; font-size:14px; line-height:200%; } "
"h1,h2 { color:#111B3F; font-size:17px; margin:22px 0 10px; line-height:160%; } "
"h3,h4 { color:#15224A; font-size:15px; margin:18px 0 8px; line-height:160%; } "
"p { margin:10px 0; line-height:200%; } "
"ol { margin:12px 0 12px 22px; } "
"ul { margin:8px 0 8px 20px; } "
"li { margin:6px 0; line-height:190%; } "
# 顶层小节之间留出整行空白,让四个小节一眼可数。
"ol > li { margin:16px 0; } "
"ol > li > strong { color:#111B3F; font-size:15px; } "
"strong { color:#1B2A55; } a { color:#5B67F1; } "
"hr { margin:20px 0; } "
"code { color:#33406B; background:#EEF1FF; } "
"pre { color:#33406B; background:#F0F3FA; margin:8px 0; padding:8px; }"
"pre { color:#33406B; background:#F0F3FA; margin:12px 0; padding:10px; }"
)
_STRUCTURED_FIELD_LABELS = {
"diagnosis": "诊断建议",
@@ -1257,6 +1271,7 @@ def render_chat_payload(browser: QTextBrowser, raw: Any, *, role: str = "ai") ->
return
if isinstance(raw, (Mapping, list, tuple)):
browser.setMarkdown(_structured_markdown(raw))
apply_reading_rhythm(browser, role=role)
return
text = str(raw).strip()
if not text:
@@ -1269,12 +1284,15 @@ def render_chat_payload(browser: QTextBrowser, raw: Any, *, role: str = "ai") ->
parsed = None
if parsed is not None:
browser.setMarkdown(_structured_markdown(parsed))
apply_reading_rhythm(browser, role=role)
return
lowered = text[:240].lower()
if text.startswith("<") and any(marker in lowered for marker in _HTML_MARKERS):
browser.setHtml(text)
apply_reading_rhythm(browser, role=role)
return
browser.setMarkdown(text)
apply_reading_rhythm(browser, role=role)
def _unwrap_analysis(payload: Any) -> dict[str, Any]:
@@ -1894,6 +1912,75 @@ def _parse_clinical_analysis(raw: Any) -> _ClinicalAnalysisModel | None:
)
#: One section of a generic structured reply: a title plus its bullet points.
_ReportSection = namedtuple("_ReportSection", "title items")
_REPORT_NUMBERED_RE = re.compile(r"^\s*(\d{1,2})\s*[.、)]\s*(.+?)\s*$")
_REPORT_BULLET_RE = re.compile(r"^\s*[-*+•○]\s*(.+?)\s*$")
def parse_structured_report(raw: Any) -> tuple[str, tuple[_ReportSection, ...], str] | None:
"""Split a sectioned reply into ``(intro, sections, disclaimer)``.
The clinical panel only recognises four fixed section names (证候分析,
信息缺口, 建议下一步, 风险提示), so the sectioned answers models actually
return — "症状演变与疗效评估", "血糖控制与监测细节", … — fell through to plain
text. This accepts any reply that is genuinely sectioned: two or more
numbered or ``#`` headings, each carrying at least one bullet.
"""
if not isinstance(raw, str):
return None
text = raw.strip()
if len(text) < 120 or text[0] in "[{<" or "```" in text:
return None
intro: list[str] = []
disclaimer: list[str] = []
sections: list[_ReportSection] = []
collecting_disclaimer = False
for line in text.replace("\r\n", "\n").splitlines():
stripped = line.strip()
if not stripped or set(stripped) <= {"-", "*", "_"}:
continue
plain = _plain_markdown(stripped)
if collecting_disclaimer or plain.startswith(("重要提示", "免责声明", "提示:", "安全提示")):
collecting_disclaimer = True
disclaimer.append(plain)
continue
bullet = _REPORT_BULLET_RE.match(line)
if bullet is not None and sections:
sections[-1].items.append(_plain_markdown(bullet.group(1)))
continue
heading = ""
if stripped.startswith("#"):
heading = _plain_markdown(stripped.lstrip("#").strip())
else:
numbered = _REPORT_NUMBERED_RE.match(_plain_markdown(stripped))
# 顶层小节标题很短;带句号的长句是正文,不是标题。
if numbered is not None and len(numbered.group(2)) <= 28:
heading = numbered.group(2)
if heading:
sections.append(_ReportSection(heading, []))
continue
if sections:
sections[-1].items.append(plain)
elif bullet is None:
intro.append(plain)
usable = [section for section in sections if section.items]
if len(usable) < 2:
return None
return (
" ".join(intro).strip(),
tuple(_ReportSection(s.title, tuple(s.items)) for s in usable),
" ".join(disclaimer).strip(),
)
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、)]\s*)")
@@ -2088,6 +2175,110 @@ class _RichMessage(QTextBrowser):
self._fitting = False
class _StructuredReportPanel(QWidget):
"""Scan-first report for any sectioned reply.
Renders each section as its own card with a numbered chip, so a doctor can
count and jump between sections instead of reading a continuous column of
bullets.
"""
def __init__(
self,
intro: str,
sections: Sequence[_ReportSection],
disclaimer: str,
*,
time_text: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("AiConsultClinicalPanel")
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.sections = tuple(sections)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(12)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.setSpacing(9)
title = QLabel("AI 结构化报告")
title.setObjectName("AiConsultClinicalTitle")
header.addWidget(title)
self.meta_label = QLabel(time_text)
self.meta_label.setObjectName("AiConsultClinicalMeta")
header.addWidget(self.meta_label)
header.addStretch(1)
badge = QLabel(f"{len(self.sections)} 个要点")
badge.setObjectName("AiConsultClinicalBadge")
badge.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
header.addWidget(badge, 0, Qt.AlignmentFlag.AlignVCenter)
root.addLayout(header)
if intro:
summary = QFrame(self)
summary.setObjectName("AiConsultClinicalSummary")
summary.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
_style_surface(summary)
summary_layout = QVBoxLayout(summary)
summary_layout.setContentsMargins(13, 11, 13, 11)
summary_layout.setSpacing(4)
body = QLabel(intro, summary)
body.setObjectName("AiConsultClinicalSummaryBody")
body.setWordWrap(True)
summary_layout.addWidget(body)
root.addWidget(summary)
for index, section in enumerate(self.sections, start=1):
root.addWidget(self._section_card(index, section))
if disclaimer:
note = QLabel(disclaimer, self)
note.setObjectName("AiConsultClinicalDisclaimer")
note.setWordWrap(True)
note.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
root.addWidget(note)
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
root.addStretch(1)
def _section_card(self, index: int, section: _ReportSection) -> QFrame:
card = QFrame(self)
card.setObjectName("AiConsultClinicalSection")
card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
_style_surface(card)
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(8)
head = QHBoxLayout()
head.setContentsMargins(0, 0, 0, 0)
head.setSpacing(8)
chip = QLabel(str(index), card)
chip.setObjectName("AiConsultHypothesisTag")
chip.setAlignment(Qt.AlignmentFlag.AlignCenter)
head.addWidget(chip)
title = QLabel(section.title, card)
title.setObjectName("AiConsultClinicalSectionTitle")
title.setWordWrap(True)
head.addWidget(title, 1)
layout.addLayout(head)
for item in section.items:
row = QFrame(card)
row.setObjectName("AiConsultGapRow")
_style_surface(row)
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(11, 9, 11, 9)
row_layout.setSpacing(8)
body = QLabel(item, row)
body.setObjectName("AiConsultClinicalBody")
body.setWordWrap(True)
row_layout.addWidget(body, 1)
layout.addWidget(row)
return card
class _ClinicalAnalysisPanel(QWidget):
"""High-contrast, scan-first presentation for completed clinical replies."""
@@ -2499,14 +2690,27 @@ class _ChatBubble(QWidget):
if self._role != "ai" or self.body is None:
return False
model = _parse_clinical_analysis(self._raw_payload)
if model is None:
panel: QWidget | None = None
if model is not None:
panel = _ClinicalAnalysisPanel(
model,
time_text=self.stamp.text(),
parent=self._bubble_frame,
)
else:
# 临床面板只认四个固定小节名;其余同样分好节的长回复走通用结构化报告,
# 而不是退回成一整段纯文本。
report = parse_structured_report(self._raw_payload)
if report is not None:
panel = _StructuredReportPanel(
*report,
time_text=self.stamp.text(),
parent=self._bubble_frame,
)
if panel is None:
return False
self._reset_clinical_panel()
panel = _ClinicalAnalysisPanel(
model,
time_text=self.stamp.text(),
parent=self._bubble_frame,
)
panel.setParent(self._bubble_frame)
self._clinical_panel = panel
self._role_name.hide()
self.stamp.hide()
@@ -2691,6 +2895,14 @@ class _ClickCard(QFrame):
# the doctor can see exactly what material is going to the model.
AI_CONTEXT_MAX_CHARS = 320
AI_PROMPT_LIMIT = 500
#: 首个增量到达前显示的占位文案,避免留下一块没有任何说明的空白气泡。
AI_STREAM_PENDING_TEXT = "正在生成…"
#: 连接结束但一个字都没收到时的兜底文案。
AI_STREAM_SILENT_TEXT = (
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
)
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
@@ -2998,6 +3210,10 @@ class AiConsultDialog(QDialog):
self._patient_ai_context: str = ""
self._patient_ai_context_labels: list[str] = []
self._patient_ai_context_bubble: _ChatBubble | None = None
# 全量上下文说明每轮问答都一样,只在本次会话第一次提问时提示一次。
self._context_notice_shown = False
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
self._stream_completed = False
self._follow_chat = True
self._flush_timer = QTimer(self)
self._flush_timer.setSingleShot(True)
@@ -3005,6 +3221,14 @@ class AiConsultDialog(QDialog):
self._flush_timer.timeout.connect(self._flush_stream_chunks)
self.setObjectName("AiConsultDialog")
self.setWindowTitle("问诊详情")
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
# 显式补上最大化/最小化按钮,并允许拖拽边角调整大小。
self.setWindowFlags(
self.windowFlags()
| Qt.WindowType.WindowMinimizeButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
)
self.setSizeGripEnabled(True)
self.resize(1280, 820)
self.setMinimumSize(1080, 680)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
@@ -3047,6 +3271,8 @@ class AiConsultDialog(QDialog):
self._patient_ai_context = ""
self._patient_ai_context_labels = []
self._patient_ai_context_bubble = None
# 换患者等于开启新会话,全量上下文说明需要再提示一次。
self._context_notice_shown = False
# Appointment seeds are presentation hints only. In the admin list,
# ``patient_id`` may actually be the diagnosis id, so it must never
# replace the separately supplied patient owner.
@@ -5281,12 +5507,18 @@ class AiConsultDialog(QDialog):
self._append_bubble(
"doctor", text, time_text=datetime.now().strftime("%H:%M")
)
self._append_bubble(
"ai",
"服务端将按当前诊单实时附带患者全部纵向资料(病历、备注/舌苔、报告、日常记录、视频转写及历史处方)。",
time_text="全量上下文",
)
self._stream_bubble = self._append_bubble("ai", "")
if not self._context_notice_shown:
self._append_bubble(
"ai",
"服务端将按当前诊单实时附带患者全部纵向资料"
"(病历、备注/舌苔、报告、日常记录、视频转写及历史处方)。",
time_text="全量上下文",
)
self._context_notice_shown = True
# 首帧到达前先占位。空气泡在后端无响应时会一直是一块空白卡片,
# 医生无法判断是还在生成还是已经失败。
self._stream_bubble = self._append_bubble("ai", AI_STREAM_PENDING_TEXT)
self._stream_completed = False
self._stream_text = ""
self._pending_chunks.clear()
self._stream_meta.clear()
@@ -5341,6 +5573,7 @@ class AiConsultDialog(QDialog):
if kind != "done":
return
self._stream_meta.update(payload)
self._stream_completed = True
if not self._stream_text and not self._pending_chunks:
answer = first_value(payload, "answer", "content", default="")
if answer not in (None, ""):
@@ -5396,6 +5629,16 @@ class AiConsultDialog(QDialog):
return
if self._stream_worker is worker:
self._stream_worker = None
# 连接结束却既没有 done 也没有报错时(服务端静默断开),占位气泡会永远
# 停在“正在生成…”。这里补一条明确说明,而不是留一块空白卡片。
if (
not self._stream_completed
and not self._stream_text
and self._stream_bubble is not None
):
self._stream_text = AI_STREAM_SILENT_TEXT
self._stream_bubble.set_payload(self._stream_text)
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
self._asking = False
self.send_button.setEnabled(True)
@@ -5408,6 +5651,7 @@ class AiConsultDialog(QDialog):
self._stream_bubble = None
self._pending_chunks.clear()
self._stream_text = ""
self._stream_completed = False
self._stream_meta.clear()
self._asking = False
self.send_button.setEnabled(True)
@@ -1911,8 +1911,20 @@ class DiagnosisDialog(QDialog):
)
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
def open_view_only(self, diagnosis_id: int, *, seed: Any = None) -> None:
self.open_for(diagnosis_id, editable=False, seed=seed, view_only=True)
def open_view_only(
self,
diagnosis_id: int,
*,
seed: Any = None,
modeless: bool = False,
) -> None:
self.open_for(
diagnosis_id,
editable=False,
seed=seed,
view_only=True,
modeless=modeless,
)
def open_for(
self,
@@ -1921,6 +1933,7 @@ class DiagnosisDialog(QDialog):
editable: bool = False,
seed: Any = None,
view_only: bool = False,
modeless: bool = False,
) -> None:
"""Open immediately, then replace the seed with authoritative server data."""
@@ -1973,7 +1986,7 @@ class DiagnosisDialog(QDialog):
self.view_stack.setCurrentWidget(
self.readonly_page if self._standalone_readonly else self.drawer_overlay
)
self.setModal(not self._standalone_readonly)
self.setModal(not modeless and not self._standalone_readonly)
self.setWindowTitle(
"患者信息详情"
if self._standalone_readonly
File diff suppressed because it is too large Load Diff
+51
View File
@@ -43,6 +43,7 @@ from doctor_workstation.resources import app_icon_path
from .dialogs.ai_consult import can_open_ai_consult
from .dialogs.ai_consult_picker import select_and_present_ai_consult
from .dialogs.diagnosis import DiagnosisDialog
from .dialogs.local_audio_queue import LocalAudioQueueDialog
from .pages import (
AppointmentsPage,
@@ -59,6 +60,7 @@ from .widgets import (
display_text,
first_value,
get_value,
has_permission,
show_toast,
)
@@ -895,6 +897,7 @@ class ShellWindow(QMainWindow):
self._activation_generation = 0
self._activation_refreshed = False
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
self._global_diagnosis_dialog: DiagnosisDialog | None = None
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
screen = self.screen() or QApplication.primaryScreen()
@@ -1845,6 +1848,54 @@ class ShellWindow(QMainWindow):
if callable(refresh):
refresh()
def _ensure_global_diagnosis_dialog(self) -> DiagnosisDialog:
dialog = self._global_diagnosis_dialog
if dialog is None:
dialog = DiagnosisDialog(
self.repository,
self,
permissions=self.permissions,
)
dialog.saved.connect(self.refresh_current_page)
self._global_diagnosis_dialog = dialog
else:
dialog.refresh_permissions(self.permissions)
return dialog
def open_diagnosis_by_id(
self,
diagnosis_id: Any,
*,
modeless: bool = False,
) -> DiagnosisDialog | None:
"""Open one authoritative diagnosis in the strongest permitted mode."""
try:
normalized_id = int(diagnosis_id)
except (TypeError, ValueError):
normalized_id = 0
if normalized_id <= 0:
show_toast(self, "当前视频缺少有效诊单编号。", "warning", 3600)
return None
if has_permission(self.permissions, "tcm.diagnosis/edit", default=False):
dialog = self._ensure_global_diagnosis_dialog()
dialog.open_for(normalized_id, editable=True, modeless=modeless)
elif has_permission(
self.permissions,
"tcm.diagnosis/readonlyDetail",
default=False,
):
dialog = self._ensure_global_diagnosis_dialog()
dialog.open_view_only(normalized_id, modeless=modeless)
else:
show_toast(self, "当前账号没有查看该诊单的权限。", "danger", 4200)
return None
dialog.raise_()
dialog.activateWindow()
return dialog
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().showEvent(event)
page = self.stack.currentWidget()
+158 -3
View File
@@ -15,9 +15,19 @@ import os
import sys
from pathlib import Path
from string import Template
from typing import Any
from PySide6.QtCore import QEvent, QObject, Qt
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette, QPixmap
from PySide6.QtGui import (
QColor,
QFont,
QFontDatabase,
QPalette,
QPixmap,
QTextBlockFormat,
QTextCharFormat,
QTextCursor,
)
from PySide6.QtWidgets import (
QApplication,
QDialog,
@@ -25,6 +35,7 @@ from PySide6.QtWidgets import (
QFileDialog,
QInputDialog,
QMessageBox,
QTextBrowser,
)
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
@@ -997,6 +1008,111 @@ QToolTip {
).substitute(_QSS_TOKENS)
#: Per-block reading rhythm, in device pixels. ``(top, bottom, line%)``.
#: QTextDocument's markdown importer builds block formats directly and ignores
#: ``setDefaultStyleSheet``, so spacing has to be applied to the parsed
#: document instead of being declared in CSS.
# 行内收紧、行间放开:一条要点自己的折行必须比两条要点之间更紧,否则整段会散成
# 一行一行的碎片,反而更难读。
_BLOCK_RHYTHM = {
"heading": (22, 10, 148),
"subheading": (18, 8, 148),
# 顶层列表项通常就是小节标题,之间留出整行空白让各小节一眼可数。
"section": (20, 8, 150),
"item": (13, 5, 158), # nested bullet under a section
"paragraph": (10, 10, 168),
}
def _block_is_all_bold(block: Any) -> bool:
"""True when every visible run in the block is bold.
Models title their sections as ``1. **症状演变与疗效评估**`` rather than as
real markdown headings, so a fully bold top-level list item is the only
reliable signal that a block is a section title and not a content bullet.
"""
iterator = block.begin()
seen = False
while not iterator.atEnd():
fragment = iterator.fragment()
iterator += 1
if not fragment.isValid() or not fragment.text().strip():
continue
seen = True
if fragment.charFormat().fontWeight() < QFont.Weight.DemiBold.value:
return False
return seen
def apply_reading_rhythm(browser: QTextBrowser, *, role: str) -> None:
"""Give a parsed reply real hierarchy and breathing room.
Model answers are long "section heading + two levels of bullets" documents.
Qt renders every one of those blocks at the same size with 6px margins, so
the reply arrives as a wall of text. This walks the parsed document once
and applies a heading scale plus per-level spacing.
"""
document = browser.document()
base_font = browser.font()
base_px = base_font.pixelSize()
if base_px <= 0:
base_px = max(12, round(base_font.pointSizeF() * 96 / 72))
cursor = QTextCursor(document)
cursor.beginEditBlock()
try:
block = document.begin()
while block.isValid():
heading = block.blockFormat().headingLevel()
text_list = block.textList()
indent = text_list.format().indent() if text_list is not None else 0
if heading:
kind = "heading" if heading <= 2 else "subheading"
size = base_px + (3 if heading <= 2 else 1)
elif indent >= 2:
kind, size = "item", None
elif indent == 1:
kind = "section"
# 只有整行加粗的顶层条目才是小节标题,普通要点保持正文字号。
size = base_px + 2 if _block_is_all_bold(block) else None
else:
kind, size = "paragraph", None
top, bottom, line = _BLOCK_RHYTHM[kind]
# 首块的上边距会在气泡顶部留出一段空白,视觉上像是排版错位。
if block.position() == 0:
top = 0
block_format = QTextBlockFormat(block.blockFormat())
block_format.setTopMargin(top)
block_format.setBottomMargin(bottom)
block_format.setLineHeight(
line, QTextBlockFormat.LineHeightTypes.ProportionalHeight.value
)
cursor.setPosition(block.position())
cursor.setBlockFormat(block_format)
if size is not None and block.length() > 1:
heading_font = QFont(base_font)
heading_font.setPixelSize(size)
heading_font.setBold(True)
char_format = QTextCharFormat()
char_format.setFont(heading_font)
if role != "doctor":
char_format.setForeground(QColor("#111B3F"))
cursor.setPosition(block.position())
cursor.setPosition(
block.position() + block.length() - 1,
QTextCursor.MoveMode.KeepAnchor,
)
cursor.mergeCharFormat(char_format)
block = block.next()
finally:
cursor.endEditBlock()
def _apply_group(
palette: QPalette,
group: QPalette.ColorGroup,
@@ -1085,17 +1201,48 @@ def _polish_dialog_buttons(button_box: QDialogButtonBox) -> None:
_refresh_widget_style(button)
def allow_dialog_resize(dialog: QDialog) -> None:
"""Give a business subwindow the usual minimise/maximise affordances.
``QDialog`` ships with a close button only, so every AI panel, report and
editor was stuck at whatever size it was constructed with unusable for the
long, dense clinical replies these windows carry. Transient prompts
(message and input boxes) are deliberately left alone, and a dialog that has
pinned itself to a fixed size keeps that decision.
"""
if isinstance(dialog, (QMessageBox, QInputDialog, QFileDialog)):
return
if dialog.minimumSize() == dialog.maximumSize():
return
# Qt hides a window whose flags change while it is visible, so a dialog that
# is already on screen keeps the flags it was shown with.
if dialog.isVisible():
dialog.setSizeGripEnabled(True)
return
flags = dialog.windowFlags()
if flags & Qt.WindowType.WindowMaximizeButtonHint:
return
dialog.setWindowFlags(
flags
| Qt.WindowType.WindowMinimizeButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
)
dialog.setSizeGripEnabled(True)
def mark_business_dialog(dialog: QDialog, object_name: str | None = None) -> None:
"""Opt a business subwindow into the shared visual contract.
The helper intentionally does not change modality, ownership or result
handling. It only supplies stable styling metadata and semantic button
roles, so existing workflows keep their original behavior.
handling. It only supplies stable styling metadata, semantic button roles
and window affordances, so existing workflows keep their original behavior.
"""
if object_name and not dialog.objectName():
dialog.setObjectName(object_name)
dialog.setProperty("businessDialog", True)
allow_dialog_resize(dialog)
for button_box in dialog.findChildren(QDialogButtonBox):
_polish_dialog_buttons(button_box)
_refresh_widget_style(dialog)
@@ -1119,6 +1266,12 @@ class _BusinessDialogStyleFilter(QObject):
QEvent.Type.Show,
}:
_polish_dialog_buttons(watched)
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
# Window flags can only be changed before the dialog is on screen,
# so dynamically-created dialogs get their affordances at polish
# time rather than when they are already visible.
if not self._is_native_or_overlay(watched):
allow_dialog_resize(watched)
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
for button_box in watched.findChildren(QDialogButtonBox):
_polish_dialog_buttons(button_box)
@@ -1215,6 +1368,8 @@ def apply_theme(app: QApplication) -> None:
__all__ = [
"COLORS",
"allow_dialog_resize",
"apply_reading_rhythm",
"GLOBAL_QSS",
"METRICS",
"TYPE",
@@ -378,6 +378,8 @@ class VideoCallLauncher:
patient_id: Any = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> Any:
request = self.prepare(
ticket,
@@ -395,6 +397,8 @@ class VideoCallLauncher:
browser_opener=self.browser_opener,
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
)
@@ -411,6 +415,8 @@ def launch_video_call(
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
@@ -427,4 +433,6 @@ def launch_video_call(
patient_id=patient_id,
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
)
@@ -296,6 +296,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
lifecycle_factory: Callable[[], OrderedCallLifecycle],
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> None:
super().__init__()
self.request = request
@@ -306,6 +308,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.logger = logger
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.patient_case = dict(patient_case or {})
self._on_open_diagnosis = on_open_diagnosis
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
@@ -451,6 +455,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
config = {
**self.request.to_web_config(),
"patientName": self.patient_name,
"patientCase": self.patient_case,
"mode": "chat" if self.open_im else "video",
}
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
@@ -499,6 +504,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if self._closing:
return
event = str(message.get("event", ""))
if event == "open-diagnosis-request":
if self._on_open_diagnosis is not None:
QTimer.singleShot(0, self._open_diagnosis_safely)
return
if event == "call-start-request":
self._start_call_cycle()
return
@@ -580,6 +589,17 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-error")
def _open_diagnosis_safely(self) -> None:
if self._closing or self._on_open_diagnosis is None:
return
try:
self._on_open_diagnosis()
except Exception:
self.logger.exception(
"diagnosis drawer could not be opened from video companion",
extra={"video_call": self.request.safe_log_context()},
)
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
@@ -1214,6 +1234,8 @@ class VideoCallWindow:
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
@@ -1231,6 +1253,8 @@ class VideoCallWindow:
self.repository = repository
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.patient_case = dict(patient_case or {})
self.on_open_diagnosis = on_open_diagnosis
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
local_dist=local_dist,
@@ -1259,6 +1283,8 @@ class VideoCallWindow:
lifecycle_factory=self._new_lifecycle,
open_im=self.open_im,
patient_name=self.patient_name,
patient_case=self.patient_case,
on_open_diagnosis=self.on_open_diagnosis,
)
except Exception:
self.lifecycle.end("window-open-failed")
@@ -1302,6 +1328,8 @@ def open_video_call(
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
@@ -1316,6 +1344,8 @@ def open_video_call(
browser_opener=browser_opener,
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
).open()
+231
View File
@@ -1291,3 +1291,234 @@ def test_ask_sends_only_question_and_relies_on_server_full_context(
assert any("请结合资料分析当前证候" in text for text in all_bubble_texts)
assert any("服务端将按当前诊单实时附带患者全部纵向资料" in text for text in all_bubble_texts)
dialog.close()
def _bubble_texts(dialog: AiConsultDialog) -> list[str]:
return [
widget.toPlainText()
for widget in dialog.findChildren(QTextBrowser)
if widget.objectName() == "AiConsultBubbleText"
]
def _silent_dialog(monkeypatch: pytest.MonkeyPatch) -> AiConsultDialog:
"""A dialog whose stream workers are never actually started."""
monkeypatch.setattr(
ai_consult_module,
"QThreadPool",
SimpleNamespace(
globalInstance=lambda: SimpleNamespace(start=lambda worker: None)
),
)
dialog = AiConsultDialog(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.open_for(diagnosis_id=501, patient_id=301)
return dialog
def test_full_context_notice_is_shown_once_per_conversation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# 每轮问答都重复同一句全量上下文说明会把真正的回答挤出可视区。
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
notice = "服务端将按当前诊单实时附带患者全部纵向资料"
counts = []
for question in ("第一个问题", "第二个问题", "第三个问题"):
dialog._cancel_stream()
dialog._ask(question)
counts.append(sum(1 for text in _bubble_texts(dialog) if notice in text))
assert counts == [1, 1, 1]
# 换患者视为新会话,需要重新提示一次。
dialog.open_for(diagnosis_id=502, patient_id=302)
application.processEvents()
dialog._ask("新患者的问题")
assert sum(1 for text in _bubble_texts(dialog) if notice in text) == 1
dialog.close()
def test_pending_and_silently_closed_streams_never_show_a_blank_bubble(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# 服务端既不发 done 也不报错时,占位气泡会永远停在空白状态。
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
dialog._ask("请评估当前用药是否合理")
assert dialog._stream_bubble is not None
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
assert any(
ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in _bubble_texts(dialog)
)
dialog._stream_finished(
dialog._generation, dialog._stream_generation, dialog._stream_worker
)
application.processEvents()
assert dialog._stream_text == ai_consult_module.AI_STREAM_SILENT_TEXT
assert any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in _bubble_texts(dialog))
assert dialog.send_button.isEnabled()
dialog.close()
def test_answered_stream_replaces_the_pending_placeholder(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
dialog._ask("请总结当前病情")
generation, stream_generation = dialog._generation, dialog._stream_generation
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "脾肾两虚"})
dialog._stream_event(generation, stream_generation, {"event": "done", "model_label": "千问"})
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
application.processEvents()
assert dialog._stream_text == "证候:脾肾两虚"
texts = _bubble_texts(dialog)
assert not any(ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in texts)
assert not any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in texts)
dialog.close()
def test_ai_consult_window_can_be_maximized(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# 这个窗口信息密度很高,必须允许医生放大到整屏。
dialog = _silent_dialog(monkeypatch)
flags = dialog.windowFlags()
assert flags & Qt.WindowType.WindowMaximizeButtonHint
assert flags & Qt.WindowType.WindowMinimizeButtonHint
assert dialog.isSizeGripEnabled()
dialog.showMaximized()
application.processEvents()
assert dialog.isMaximized()
dialog.close()
def test_long_reply_gets_section_hierarchy_and_breathing_room(
application: QApplication,
) -> None:
"""QTextDocument's markdown importer ignores setDefaultStyleSheet.
Spacing therefore has to be applied to the parsed document; without it every
block renders at one size with 6px margins and the reply reads as a wall.
"""
browser = ai_consult_module._RichMessage("ai")
browser.resize(660, 400)
browser.set_payload(
"概述段落。\n\n"
"1. **症状演变与疗效评估**\n"
" - **麻木症状:** 服药十四天后是否缓解?\n"
" - **皮肤瘙痒:** 目前是否仍有发作?\n"
"2. **血糖控制与监测细节**\n"
" - **监测习惯:** 是否规律监测餐后血糖?\n"
)
document = browser.document()
base_px = browser.font().pixelSize()
seen: dict[str, list] = {"section": [], "item": [], "paragraph": []}
block = document.begin()
while block.isValid():
text_list = block.textList()
indent = text_list.format().indent() if text_list is not None else 0
kind = "item" if indent >= 2 else "section" if indent == 1 else "paragraph"
seen[kind].append(block)
block = block.next()
assert seen["section"] and seen["item"], "both list levels must be present"
# 小节标题比正文更大更重,否则四个小节无法一眼分辨。
section = seen["section"][0]
section_size = section.begin().fragment().charFormat().font().pixelSize()
assert section_size > base_px
# 小节之间的留白必须大于同一小节内要点之间的留白。
section_top = seen["section"][0].blockFormat().topMargin()
item_top = seen["item"][0].blockFormat().topMargin()
assert section_top > item_top > 0
# 一条要点的折行必须比两条要点之间更紧,否则整段会散成碎片。
item_line = seen["item"][0].blockFormat().lineHeight()
assert 0 < item_line < 170
# 首块不带上边距,避免气泡顶部出现一段空白。
assert document.begin().blockFormat().topMargin() == 0
def test_short_reply_is_not_over_spaced(application: QApplication) -> None:
browser = ai_consult_module._RichMessage("ai")
browser.resize(660, 200)
browser.set_payload("血糖控制尚可,暂无需调整降糖方案。")
block = browser.document().begin()
assert block.blockFormat().topMargin() == 0
assert block.next().isValid() is False
def test_sectioned_reply_becomes_a_structured_report(application: QApplication) -> None:
"""The clinical panel only knows four fixed section names.
Real answers are sectioned as 症状演变 / 血糖控制 / 用药依从性 , which matched
none of them and therefore fell back to a plain wall of text.
"""
reply = (
"以下是针对该患者当前情况,建议向患者确认的关键问诊问题,用于补充现有病历中的信息缺口:\n\n"
"1. **症状演变与疗效评估**\n"
" - **麻木症状:** 服药十四天后四肢麻木是否有所缓解?\n"
" - **皮肤瘙痒:** 目前是否仍有发作?是否与血糖波动有关?\n"
"2. **血糖控制与监测细节**\n"
" - **空腹血糖波动:** 近期是否有反复的低血糖发作?\n"
"3. **用药依从性与生活方式**\n"
" - **西药服用情况:** 近期是否有漏服或自行调整剂量?\n\n"
"**提示:** 以上问题基于现有脱敏病例资料梳理,需由执业医师复核后确定。\n"
)
parsed = ai_consult_module.parse_structured_report(reply)
assert parsed is not None
intro, sections, disclaimer = parsed
assert [section.title for section in sections] == [
"症状演变与疗效评估",
"血糖控制与监测细节",
"用药依从性与生活方式",
]
assert [len(section.items) for section in sections] == [2, 1, 1]
assert "信息缺口" in intro
assert "执业医师" in disclaimer
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 11:01")
assert bubble.finalize_clinical_analysis() is True
panel = bubble.findChild(ai_consult_module._StructuredReportPanel)
assert panel is not None
titles = [
label.text()
for label in panel.findChildren(QLabel)
if label.objectName() == "AiConsultClinicalSectionTitle"
]
assert titles == ["症状演变与疗效评估", "血糖控制与监测细节", "用药依从性与生活方式"]
bubble.deleteLater()
@pytest.mark.parametrize(
"reply",
[
"血糖控制尚可,暂无需调整降糖方案。",
"1. **只有一个小节**\n - 一条要点\n",
'{"summary": "结构化 JSON 走既有解析路径"}',
],
)
def test_unsectioned_replies_stay_plain_text(reply: str, application: QApplication) -> None:
assert ai_consult_module.parse_structured_report(reply) is None
+49 -4
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QLabel
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
from doctor_workstation.core import PermissionSet
from doctor_workstation.core.errors import ApiTimeoutError
@@ -385,7 +385,10 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
task: str,
) -> dict[str, Any]:
calls.append({"diagnosis_id": diagnosis_id, "prompt": prompt, "task": task})
return {"answer": "建议复核肾功能与眼底。", "model_key": "openai"}
return {
"answer": "### 核心建议\n\n**重点复核**\n\n- 肾功能\n- 眼底",
"model_key": "openai",
}
dialog = DiagnosisAiAssistantDialog(Repository())
dialog.open_for(501, "并发症筛查", task="complication_risk")
@@ -393,15 +396,57 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
assert calls == [
{"diagnosis_id": 501, "prompt": "并发症筛查", "task": "complication_risk"}
]
assert dialog.answer_label.text() == "建议复核肾功能与眼底"
assert dialog.answer_label.toPlainText() == "核心建议\n重点复核\n肾功能\n眼底"
assert "###" not in dialog.answer_label.toPlainText()
assert "**" not in dialog.answer_label.toPlainText()
rendered_html = dialog.answer_label.toHtml().lower()
assert "<h3" in rendered_html
assert "font-weight:700" in rendered_html.replace(" ", "")
assert "openai" in dialog.model_label.text()
assert dialog.answer_scroll.widget().findChild(QLabel, "PrescriptionAiBody") is dialog.answer_label
assert isinstance(dialog.answer_label, QTextBrowser)
assert dialog.answer_label.objectName() == "PrescriptionAiAnswer"
assert dialog.answer_label.openLinks() is False
assert dialog.answer_label.openExternalLinks() is False
assert dialog.loading is False
assert dialog.retry_button.isEnabled()
dialog.close()
application.processEvents()
def test_diagnosis_assistant_markdown_disables_model_supplied_html(
application: QApplication,
immediate_async: None,
) -> None:
class Repository:
def analyze_diagnosis_ai(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
return {
"answer": "### 安全内容\n\n<img src=\"https://invalid.example/pixel\">\n\n- 建议复诊",
"model_key": "qwen",
}
dialog = DiagnosisAiAssistantDialog(Repository())
dialog.open_for(501, "复诊建议", task="custom")
assert "安全内容" in dialog.answer_label.toPlainText()
assert "建议复诊" in dialog.answer_label.toPlainText()
assert "<img" in dialog.answer_label.toPlainText()
assert 'src="https://invalid.example/pixel"' not in dialog.answer_label.toHtml()
assert (
dialog.answer_label.loadResource(
ai_module.QTextDocument.ResourceType.ImageResource,
"https://invalid.example/pixel",
)
is None
)
dialog.answer_label.selectAll()
copied = dialog.answer_label.createMimeDataFromSelection()
assert copied.hasText()
assert copied.hasHtml() is False
assert set(copied.formats()) == {"text/plain"}
dialog.close()
application.processEvents()
def test_diagnosis_assistant_timeout_is_visible_and_retryable(
application: QApplication,
immediate_async: None,
+76
View File
@@ -288,6 +288,82 @@ def test_shell_ai_entry_always_opens_patient_picker_even_with_current_selection(
assert shell_window.stack.currentWidget() is current
def test_shell_global_diagnosis_entry_reuses_dialog_and_obeys_permissions(
shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[tuple[str, int]] = []
refresh_callbacks: list[Any] = []
created: list[Any] = []
class _SavedSignal:
def connect(self, callback: Any) -> None:
refresh_callbacks.append(callback)
class _DiagnosisDialogDouble:
def __init__(
self,
repository: Any,
parent: Any,
*,
permissions: Any,
) -> None:
self.repository = repository
self.parent = parent
self.permissions = permissions
self.saved = _SavedSignal()
self.raise_count = 0
self.activate_count = 0
created.append(self)
def refresh_permissions(self, permissions: Any) -> None:
self.permissions = permissions
def open_for(
self,
diagnosis_id: int,
*,
editable: bool,
modeless: bool,
) -> None:
assert editable is True
assert modeless is True
opened.append(("edit", diagnosis_id))
def open_view_only(self, diagnosis_id: int, *, modeless: bool) -> None:
assert modeless is True
opened.append(("view", diagnosis_id))
def raise_(self) -> None:
self.raise_count += 1
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible test double
self.activate_count += 1
monkeypatch.setattr(shell_module, "DiagnosisDialog", _DiagnosisDialogDouble)
shell_window._global_diagnosis_dialog = None
shell_window.permissions = {"tcm.diagnosis/edit"}
assert shell_window.open_diagnosis_by_id(501, modeless=True) is created[0]
shell_window.permissions = {"tcm.diagnosis/readonlyDetail"}
assert shell_window.open_diagnosis_by_id("502", modeless=True) is created[0]
shell_window.permissions = {"tcm.diagnosis/*"}
assert shell_window.open_diagnosis_by_id(503, modeless=True) is created[0]
assert opened == [("edit", 501), ("view", 502), ("edit", 503)]
assert len(created) == 1
assert created[0].parent is shell_window
assert len(refresh_callbacks) == 1
assert created[0].raise_count == 3
assert created[0].activate_count == 3
shell_window.permissions = set()
assert shell_window.open_diagnosis_by_id(504, modeless=True) is None
assert shell_window.open_diagnosis_by_id(0, modeless=True) is None
assert shell_window.open_diagnosis_by_id("invalid", modeless=True) is None
assert opened == [("edit", 501), ("view", 502), ("edit", 503)]
def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
application: QApplication,
shell_window: ShellWindow,
+41 -1
View File
@@ -95,7 +95,13 @@ def test_navigation_requires_each_pages_actual_list_capability() -> None:
def test_video_release_does_not_remove_a_newer_call() -> None:
older = object()
newer = object()
controller = SimpleNamespace(video_calls={"501": newer})
# _release_video_call also clears the preview slot, so the double needs the
# same attributes the real controller sets up in __init__.
controller = SimpleNamespace(
video_calls={"501": newer},
_video_preview_state=None,
_video_preview_generation=0,
)
ApplicationController._release_video_call(controller, "501", older)
assert controller.video_calls == {"501": newer}
@@ -505,3 +511,37 @@ def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
assert "信任自签名证书" in window.error_banner.label.text()
window.close()
application.processEvents()
def test_business_dialogs_can_be_maximized_but_prompts_cannot() -> None:
"""Dense AI panels and editors were stuck at their constructed size."""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox
from doctor_workstation.ui.theme import allow_dialog_resize
application = QApplication.instance() or QApplication([])
assert application is not None
dialog = QDialog()
dialog.resize(600, 400)
allow_dialog_resize(dialog)
flags = dialog.windowFlags()
assert flags & Qt.WindowType.WindowMaximizeButtonHint
assert flags & Qt.WindowType.WindowMinimizeButtonHint
assert dialog.isSizeGripEnabled()
dialog.deleteLater()
# Transient prompts keep their plain frame.
prompt = QMessageBox()
allow_dialog_resize(prompt)
assert not (prompt.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
prompt.deleteLater()
# A dialog that pinned itself to a fixed size keeps that decision.
fixed = QDialog()
fixed.setFixedSize(420, 300)
allow_dialog_resize(fixed)
assert not (fixed.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
fixed.deleteLater()
+363 -2
View File
@@ -5,7 +5,8 @@ import sys
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from types import MethodType, SimpleNamespace
from typing import Any
import pytest
@@ -52,7 +53,7 @@ def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> No
assert "context.createMediaStreamDestination()" in source
assert "cloud.getAudioTrack({ processed: true })" in source
assert "userId: activeConfig.targetUserId" in source
assert "attachPatientAudioTrack(cloud, activeConfig.targetUserId)" in source
assert "new MediaRecorder(destination.stream" in source
assert "recorder.start(1000)" in source
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
@@ -134,6 +135,12 @@ def test_companion_local_recording_waits_for_real_audio_and_has_runtime_fallback
assert "stream.getAudioTracks()" in source
assert "navigator.mediaDevices.getUserMedia" in source
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
assert "if (!attached)" in source
assert "cloud.getAudioTrack(userId)" in source
assert "event.sourceTrack" in source
assert "cloud.on('remote-audio-available'" in source
assert "localAudioCloud.off('remote-audio-available'" in source
assert "!event.userId || event.userId === activeConfig?.userID" in source
assert "localRecordingAttachedSourceCount <= 0" in source
assert "localRecordingBytes < 1024" in source
assert "已阻止上传空文件" in source
@@ -201,6 +208,360 @@ def test_companion_shows_incremental_subtitles_but_only_persists_final_segments(
assert "caption.text" in component_source
def test_companion_keeps_transcript_and_patient_case_visible_in_a_side_rail() -> None:
companion_root = PROJECT_ROOT / "video_companion" / "src"
main_source = (companion_root / "main.ts").read_text(encoding="utf-8")
component_source = (companion_root / "App.vue").read_text(encoding="utf-8")
styles = (companion_root / "style.css").read_text(encoding="utf-8")
window_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "liveCaptions.value = [...previous, caption].slice(-120)" in main_source
assert "liveCaptionClearTimer" not in main_source
assert 'aria-label="患者病例与实时对话"' in component_source
assert 'id="patient-case-title"' in component_source
assert 'id="live-transcript-title"' in component_source
assert 'aria-label="打开完整诊单"' in component_source
assert "runAction(onOpenDiagnosis)" in component_source
assert "detail.clinicalDiagnosis" in component_source
assert 'v-for="field in caseFields"' in component_source
assert "{{ field.value }}" in component_source
assert "{{ caption.time }}" in component_source
assert "'caption-entry--partial': !caption.completed" in component_source
assert ':allowed-full-screen="false"' in component_source
assert ".video-layer--with-rail" in styles
assert ".consultation-rail" in styles
assert '"patientCase": self.patient_case' in window_source
assert 'event == "open-diagnosis-request"' in window_source
assert "QTimer.singleShot(0, self._open_diagnosis_safely)" in window_source
stop_source = main_source.split("async function stopTranscription", 1)[1].split(
"function transcriptionResult", 1
)[0]
assert "clearLiveCaptions()" not in stop_source
def test_video_diagnosis_entry_uses_existing_permission_scoped_drawer() -> None:
app_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "app.py"
).read_text(encoding="utf-8")
launcher_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "launcher.py"
).read_text(encoding="utf-8")
main_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
shell_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "shell.py"
).read_text(encoding="utf-8")
diagnosis_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "dialogs" / "diagnosis.py"
).read_text(encoding="utf-8")
styles = (
PROJECT_ROOT / "video_companion" / "src" / "style.css"
).read_text(encoding="utf-8")
assert "shell.open_diagnosis_by_id(diagnosis_id, modeless=True)" in app_source
assert "self._show_video_preview(video_window, dialog)" in app_source
assert "WindowStaysOnTopHint" in app_source
assert "dialog.finished.connect" in app_source
assert "modeless=modeless" in shell_source
assert "not modeless and not self._standalone_readonly" in diagnosis_source
compact_styles = styles.split("@media (max-width: 700px)", 1)[1]
assert ".consultation-rail { display: none; }" in compact_styles
assert ".capture-button { display: none; }" in compact_styles
assert "on_open_diagnosis=on_open_diagnosis" in launcher_source
assert "event: 'open-diagnosis-request'" in main_source
def test_video_preview_is_compact_and_restores_after_diagnosis_closes() -> None:
from PySide6.QtCore import Qt
from doctor_workstation.app import ApplicationController
class _Rect:
def x(self) -> int:
return 0
def y(self) -> int:
return 0
def width(self) -> int:
return 1920
def height(self) -> int:
return 1040
class _Screen:
def availableGeometry(self) -> _Rect: # noqa: N802 - Qt-compatible double
return _Rect()
class _Window:
def __init__(self) -> None:
self.original_geometry = object()
self.original_minimum = object()
self.minimum = self.original_minimum
self.geometry_value = self.original_geometry
self.size = (900, 600)
self.position = (30, 40)
self.stays_on_top = False
self.activated = 0
def geometry(self) -> object:
return self.geometry_value
def minimumSize(self) -> object: # noqa: N802 - Qt-compatible double
return self.minimum
def isMaximized(self) -> bool: # noqa: N802 - Qt-compatible double
return False
def isFullScreen(self) -> bool: # noqa: N802 - Qt-compatible double
return False
def windowFlags(self) -> Qt.WindowType: # noqa: N802 - Qt-compatible double
return Qt.WindowType.Window
def screen(self) -> _Screen:
return _Screen()
def showNormal(self) -> None: # noqa: N802 - Qt-compatible double
return None
def showMaximized(self) -> None: # noqa: N802 - Qt-compatible double
return None
def showFullScreen(self) -> None: # noqa: N802 - Qt-compatible double
return None
def setMinimumSize(self, *value: object) -> None: # noqa: N802
self.minimum = value[0] if len(value) == 1 else value
def setWindowFlag(self, _flag: Any, enabled: bool) -> None: # noqa: N802
self.stays_on_top = enabled
def resize(self, width: int, height: int) -> None:
self.size = (width, height)
def move(self, x: int, y: int) -> None:
self.position = (x, y)
def setGeometry(self, geometry: object) -> None: # noqa: N802
self.geometry_value = geometry
def show(self) -> None:
return None
def raise_(self) -> None:
return None
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible double
self.activated += 1
class _Signal:
def __init__(self) -> None:
self.callbacks: list[Any] = []
def connect(self, callback: Any) -> None:
self.callbacks.append(callback)
class _Dialog:
def __init__(self) -> None:
self.finished = _Signal()
def raise_(self) -> None:
return None
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible double
return None
controller = SimpleNamespace(
_video_preview_state=None,
_video_preview_generation=0,
)
controller._restore_video_preview = MethodType( # type: ignore[attr-defined]
ApplicationController._restore_video_preview,
controller,
)
window = _Window()
dialog = _Dialog()
ApplicationController._show_video_preview(controller, window, dialog)
assert window.minimum == (440, 300)
assert window.size == (540, 356)
assert window.position == (1362, 18)
assert window.stays_on_top is True
assert len(dialog.finished.callbacks) == 1
dialog.finished.callbacks[0](0)
assert window.minimum is window.original_minimum
assert window.geometry_value is window.original_geometry
assert window.stays_on_top is False
assert window.activated == 1
def test_video_patient_case_snapshot_is_bounded_and_clinically_useful() -> None:
from doctor_workstation.app import _build_video_patient_case
summary = _build_video_patient_case(
{
"diagnosis": {
"patient_name": "张三",
"id": 8279,
"source_patient_id": 42,
"gender": 1,
"age": 47,
"chief_complaint": "反复口渴三个月",
"present_illness": "近期空腹血糖偏高",
"allergy_history": "青霉素",
"current_medicine": ["二甲双胍", "阿卡波糖"],
"clinical_diagnosis": "2 型糖尿病",
},
"appointment": {"appointment_date": "2026-08-26"},
"internal_audit": {"token": "must-not-cross-the-bridge"},
},
{},
diagnosis_id=8279,
patient_id=42,
patient_name="患者",
)
assert summary["diagnosisId"] == "8279"
assert summary["name"] == "张三"
assert summary["gender"] == ""
assert summary["age"] == "47"
assert summary["chiefComplaint"] == "反复口渴三个月"
assert summary["currentMedication"] == "二甲双胍、阿卡波糖"
assert summary["allergyHistory"] == "青霉素"
assert "internal_audit" not in summary
assert set(summary) == {
"diagnosisId",
"name",
"gender",
"age",
"height",
"weight",
"diagnosisDate",
"appointmentDate",
"clinicalDiagnosis",
"chiefComplaint",
"presentIllness",
"pastHistory",
"allergyHistory",
"personalHistory",
"familyHistory",
"currentMedication",
"tongue",
"pulse",
"prescriptionOpinion",
"remark",
}
bounded = _build_video_patient_case(
{},
{
"diagnosis_id": 8279,
"patient_id": 42,
"patient_name": "" * 180,
"age": "4" * 40,
"remark": "" * 2400,
},
diagnosis_id=8279,
patient_id=42,
patient_name="患者",
)
assert len(bounded["name"]) == 120
assert len(bounded["age"]) == 20
assert len(bounded["remark"]) == 2000
@pytest.mark.parametrize(
"detail",
[
{
"id": 8279,
"source_patient_id": 42,
"patient_name": "张三",
"chief_complaint": "口渴",
},
{
"data": {
"id": 8279,
"source_patient_id": 42,
"patient_name": "张三",
"chief_complaint": "口渴",
}
},
{
"diagnosis": {
"id": 8279,
"source_patient_id": 42,
"patient_name": "张三",
"chief_complaint": "口渴",
}
},
],
)
def test_video_patient_case_accepts_supported_readonly_detail_shapes(detail: object) -> None:
from doctor_workstation.app import _build_video_patient_case
summary = _build_video_patient_case(
detail,
{},
diagnosis_id=8279,
patient_id=42,
patient_name="患者",
)
assert summary["name"] == "张三"
assert summary["chiefComplaint"] == "口渴"
def test_video_patient_case_fails_closed_on_identity_mismatch() -> None:
from doctor_workstation.app import _build_video_patient_case
summary = _build_video_patient_case(
{
"diagnosis": {
"id": 9001,
"source_patient_id": 7,
"patient_name": "其他患者",
"chief_complaint": "不得展示",
"allergy_history": "不得展示",
}
},
{
"diagnosis_id": 9001,
"patient_id": 7,
"chief_complaint": "也不得展示",
},
diagnosis_id=8279,
patient_id=42,
patient_name="张三",
)
assert summary["name"] == "张三"
assert summary["chiefComplaint"] == ""
assert summary["allergyHistory"] == ""
def test_built_video_companion_contains_the_patient_case_rail() -> None:
dist_root = PROJECT_ROOT / "video_companion" / "dist"
styles = "\n".join(
path.read_text(encoding="utf-8") for path in (dist_root / "assets").glob("*.css")
)
scripts = "\n".join(
path.read_text(encoding="utf-8") for path in (dist_root / "assets").glob("*.js")
)
assert ".consultation-rail" in styles
assert ".video-layer--with-rail" in styles
assert "patientCase" in scripts
assert "患者病例与实时对话" in scripts
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="color-scheme" content="light" />
<link rel="icon" type="image/png" href="./favicon.png" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-DhmAWjut.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BsDbRyxy.css">
<script type="module" crossorigin src="./assets/index-B_ek5NUi.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
</head>
<body>
<div id="app"></div>
+220 -57
View File
@@ -18,9 +18,39 @@ interface LiveCaption {
id: string
speaker: string
text: string
time: string
completed: boolean
}
interface PatientCase {
diagnosisId: string
name: string
gender: string
age: string
height: string
weight: string
diagnosisDate: string
appointmentDate: string
clinicalDiagnosis: string
chiefComplaint: string
presentIllness: string
pastHistory: string
allergyHistory: string
personalHistory: string
familyHistory: string
currentMedication: string
tongue: string
pulse: string
prescriptionOpinion: string
remark: string
}
interface CaseField {
label: string
value: string
risk?: boolean
}
const props = defineProps<{
phase: Readonly<Ref<string>>
statusText: Readonly<Ref<string>>
@@ -34,12 +64,14 @@ const props = defineProps<{
transcriptionState: Readonly<Ref<string>>
localRecordingState: Readonly<Ref<string>>
liveCaptions: Readonly<Ref<LiveCaption[]>>
patientCase: Readonly<Ref<PatientCase>>
onSendText: (text: string) => Promise<void>
onSendAttachment: (file: File) => Promise<void>
onLoadMore: () => Promise<void>
onReconnectChat: () => Promise<void>
onStartVideo: () => Promise<void>
onHangup: () => Promise<void>
onOpenDiagnosis: () => Promise<void>
onSaveScreenshot: (dataUrl: string) => Promise<void>
}>()
@@ -50,6 +82,7 @@ const messageList = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const screenshotPreview = ref('')
const stickToMessageBottom = ref(true)
const transcriptList = ref<HTMLElement | null>(null)
const isChat = computed(() => props.mode.value === 'chat')
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
@@ -84,6 +117,41 @@ const transcriptionStatusText = computed(() => {
if (textState === 'error') return '实时转写失败,本机录音仍在运行'
return '录音与转写已结束'
})
const patientMetaText = computed(() => {
const detail = props.patientCase.value
return [
detail.gender,
detail.age ? `${detail.age}` : '',
detail.height ? `${detail.height} cm` : '',
detail.weight ? `${detail.weight} kg` : '',
].filter(Boolean).join(' · ') || '基础资料待补充'
})
const patientVisitText = computed(() => {
const detail = props.patientCase.value
if (detail.appointmentDate) return `预约 ${detail.appointmentDate}`
if (detail.diagnosisDate) return `诊断 ${detail.diagnosisDate}`
return `诊单 ${detail.diagnosisId || '—'}`
})
const caseFields = computed<CaseField[]>(() => {
const detail = props.patientCase.value
const allergyRisk = Boolean(detail.allergyHistory) && !/^(无|否|未发现|无过敏史|none|no)$/i.test(
detail.allergyHistory.trim(),
)
return [
{ label: '临床诊断', value: detail.clinicalDiagnosis },
{ label: '主诉', value: detail.chiefComplaint },
{ label: '现病史', value: detail.presentIllness },
{ label: '当前用药', value: detail.currentMedication },
{ label: '过敏史', value: detail.allergyHistory, risk: allergyRisk },
{ label: '既往史', value: detail.pastHistory },
{ label: '个人史', value: detail.personalHistory },
{ label: '家族史', value: detail.familyHistory },
{ label: '舌象', value: detail.tongue },
{ label: '脉象', value: detail.pulse },
{ label: '处方意见', value: detail.prescriptionOpinion },
{ label: '病例备注', value: detail.remark },
].filter((item) => Boolean(item.value))
})
watch(
() => props.messages.value.length,
@@ -103,6 +171,17 @@ watch(
},
)
watch(
() => {
const last = props.liveCaptions.value.at(-1)
return `${props.liveCaptions.value.length}:${last?.id || ''}:${last?.text || ''}`
},
async () => {
await nextTick()
if (transcriptList.value) transcriptList.value.scrollTop = transcriptList.value.scrollHeight
},
)
function handleMessageScroll(): void {
const container = messageList.value
if (!container) return
@@ -335,70 +414,154 @@ watch(
</footer>
</section>
<section v-if="videoVisible" class="video-layer" :class="{ 'video-layer--overlay': isChat }">
<TUICallKit
class="call-kit"
:allowed-minimized="false"
:allowed-full-screen="true"
/>
<section
v-if="videoVisible"
class="video-layer"
:class="{
'video-layer--overlay': isChat,
'video-layer--with-rail': isCalling,
}"
>
<div class="video-stage">
<TUICallKit
class="call-kit"
:allowed-minimized="false"
:allowed-full-screen="false"
/>
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
<div>
<p class="eyebrow">中医视频问诊</p>
<h2>{{ statusText.value }}</h2>
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
<div>
<p class="eyebrow">中医视频问诊</p>
<h2>{{ statusText.value }}</h2>
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
</div>
</section>
<div v-else class="live-status" role="status">
<span class="status-dot status-dot--live" aria-hidden="true" />
{{ statusText.value }}
</div>
</section>
<div v-else class="live-status" role="status">
<span class="status-dot status-dot--live" aria-hidden="true" />
{{ statusText.value }}
</div>
<div v-if="isCalling" class="video-actions">
<div
v-if="canCapture"
class="recording-status"
:class="{
'recording-status--active': transcriptionActive,
'recording-status--error': transcriptionFailed,
}"
role="status"
aria-live="polite"
>
<span class="recording-indicator" aria-hidden="true" />
{{ transcriptionStatusText }}
</div>
<button
class="capture-button"
type="button"
:disabled="!canCapture || actionBusy"
@click="captureScreenshot"
>
截屏预览
</button>
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
结束视频
</button>
</div>
<div
v-if="canCapture && liveCaptions.value.length"
class="live-captions"
role="log"
aria-live="polite"
aria-label="实时语音字幕"
>
<p v-for="caption in liveCaptions.value" :key="caption.id">
<strong>{{ caption.speaker }}</strong>
<span>{{ caption.text }}</span>
</p>
</div>
<div v-if="isCalling" class="video-actions">
<div
v-if="canCapture"
class="recording-status"
:class="{
'recording-status--active': transcriptionActive,
'recording-status--error': transcriptionFailed,
}"
role="status"
aria-live="polite"
>
<span class="recording-indicator" aria-hidden="true" />
{{ transcriptionStatusText }}
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
{{ localError || notice.value }}
</div>
<button
class="capture-button"
type="button"
:disabled="!canCapture || actionBusy"
@click="captureScreenshot"
>
截屏预览
</button>
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
结束视频
</button>
</div>
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
{{ localError || notice.value }}
</div>
<aside v-if="isCalling" class="consultation-rail" aria-label="患者病例与实时对话">
<header class="consultation-rail__header">
<div class="consultation-rail__avatar" aria-hidden="true">
{{ (patientCase.value.name || patientName.value).slice(0, 1) }}
</div>
<div class="consultation-rail__identity">
<strong>{{ patientCase.value.name || patientName.value }}</strong>
<span>{{ patientMetaText }}</span>
</div>
<div class="consultation-rail__actions">
<span class="diagnosis-chip">诊单 {{ patientCase.value.diagnosisId || '—' }}</span>
<button
class="open-diagnosis-button"
type="button"
:disabled="actionBusy"
aria-label="打开完整诊单"
@click="runAction(onOpenDiagnosis)"
>
打开诊单
<span aria-hidden="true"></span>
</button>
</div>
</header>
<section class="case-panel" aria-labelledby="patient-case-title">
<header class="rail-section-heading">
<div>
<span class="rail-section-heading__index">01</span>
<h2 id="patient-case-title">患者病例</h2>
</div>
<span>{{ patientVisitText }}</span>
</header>
<div class="case-panel__content">
<div v-if="caseFields.length" class="case-field-list">
<article
v-for="field in caseFields"
:key="field.label"
class="case-field"
:class="{ 'case-field--risk': field.risk }"
>
<span>{{ field.label }}</span>
<p>{{ field.value }}</p>
</article>
</div>
<div v-else class="rail-empty rail-empty--case">
<strong>暂无已填写的病例内容</strong>
<span>可继续通话已补充的病例会在下次打开时显示</span>
</div>
</div>
</section>
<section class="transcript-panel" aria-labelledby="live-transcript-title">
<header class="rail-section-heading">
<div>
<span class="rail-section-heading__index">02</span>
<h2 id="live-transcript-title">实时对话</h2>
</div>
<span class="transcript-state" :class="{ 'transcript-state--active': transcriptionActive }">
{{ transcriptionActive ? '转写中' : '等待语音' }}
</span>
</header>
<div
ref="transcriptList"
class="live-captions"
role="log"
aria-live="polite"
aria-label="实时语音字幕"
>
<div v-if="!liveCaptions.value.length" class="rail-empty">
<strong>对话文字会显示在这里</strong>
<span>接通后自动识别医生与患者语音并保留本次通话内容</span>
</div>
<article
v-for="caption in liveCaptions.value"
:key="caption.id"
class="caption-entry"
:class="{ 'caption-entry--partial': !caption.completed }"
>
<header>
<strong>{{ caption.speaker }}</strong>
<time>{{ caption.time }}</time>
</header>
<p>{{ caption.text }}</p>
</article>
</div>
</section>
</aside>
<div
v-if="screenshotPreview"
+24
View File
@@ -15,9 +15,33 @@ interface DoctorCallConfig {
patientUserId?: string
diagnosisId: number | string
patientName?: string
patientCase?: DoctorPatientCase
mode?: 'chat' | 'video'
}
interface DoctorPatientCase {
diagnosisId?: number | string
name?: string
gender?: string
age?: string | number
height?: string | number
weight?: string | number
diagnosisDate?: string
appointmentDate?: string
clinicalDiagnosis?: string
chiefComplaint?: string
presentIllness?: string
pastHistory?: string
allergyHistory?: string
personalHistory?: string
familyHistory?: string
currentMedication?: string
tongue?: string
pulse?: string
prescriptionOpinion?: string
remark?: string
}
interface DoctorCallApi {
start(config: DoctorCallConfig): Promise<void>
hangup(): Promise<void>
+196 -44
View File
@@ -26,6 +26,7 @@ interface NormalizedCallConfig {
targetUserId: string
diagnosisId: number | string
patientName: string
patientCase: UiPatientCase
mode: CompanionMode
}
@@ -44,9 +45,33 @@ interface UiLiveCaption {
id: string
speaker: string
text: string
time: string
completed: boolean
}
interface UiPatientCase {
diagnosisId: string
name: string
gender: string
age: string
height: string
weight: string
diagnosisDate: string
appointmentDate: string
clinicalDiagnosis: string
chiefComplaint: string
presentIllness: string
pastHistory: string
allergyHistory: string
personalHistory: string
familyHistory: string
currentMedication: string
tongue: string
pulse: string
prescriptionOpinion: string
remark: string
}
interface BridgeMessage {
source: 'doctor-call'
event:
@@ -56,6 +81,7 @@ interface BridgeMessage {
| 'room'
| 'hangup'
| 'error'
| 'open-diagnosis-request'
| 'transcription-start-request'
| 'transcription-segment'
| 'transcription-stop'
@@ -127,6 +153,11 @@ interface PendingLocalRecordingReply {
interface TrtcAudioTrackEvent {
userId?: string
track?: MediaStreamTrack
sourceTrack?: MediaStreamTrack
}
interface TrtcRemoteAudioEvent {
userId?: string
}
interface TrtcAudioCloud {
@@ -135,7 +166,9 @@ interface TrtcAudioCloud {
processed?: boolean
} | string): MediaStreamTrack | null
on?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
on?(event: 'remote-audio-available', handler: (event: TrtcRemoteAudioEvent) => void): void
off?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
off?(event: 'remote-audio-available', handler: (event: TrtcRemoteAudioEvent) => void): void
}
const phase = ref<CallPhase>('ready')
@@ -150,6 +183,7 @@ const hasMoreMessages = ref(false)
const transcriptionState = ref<TranscriptionState>('idle')
const localRecordingState = ref<LocalRecordingState>('idle')
const liveCaptions = ref<UiLiveCaption[]>([])
const patientCase = ref<UiPatientCase>(emptyPatientCase())
let activeConfig: NormalizedCallConfig | null = null
let chat: any = null
@@ -192,6 +226,7 @@ let localAudioContext: AudioContext | null = null
let localAudioDestination: MediaStreamAudioDestinationNode | null = null
let localAudioCloud: TrtcAudioCloud | null = null
let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null
let localRemoteAudioAvailableHandler: ((event: TrtcRemoteAudioEvent) => void) | null = null
let localAudioSources: MediaStreamAudioSourceNode[] = []
let localAudioTrackIds = new Set<string>()
let localAudioOwnedTracks: MediaStreamTrack[] = []
@@ -205,8 +240,6 @@ let localRecordingChunkChain: Promise<void> = Promise.resolve()
let localRecordingStartPromise: Promise<void> | null = null
let localRecordingStopPromise: Promise<void> | null = null
let localRecordingFatalError = ''
let liveCaptionClearTimer: number | null = null
function initializeQtWebChannel(): void {
const transport = window.qt?.webChannelTransport
const QWebChannel = window.QWebChannel
@@ -259,11 +292,80 @@ function emit(message: BridgeMessage): void {
}
}
async function openDiagnosis(): Promise<void> {
if (!activeConfig) throw new Error('诊单上下文尚未就绪')
emit({
source: 'doctor-call',
event: 'open-diagnosis-request',
diagnosisId: activeConfig.diagnosisId,
})
}
function cleanString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
return value.trim()
}
function optionalText(value: unknown, maxLength = 2000): string {
if (value === undefined || value === null) return ''
return String(value).trim().slice(0, maxLength)
}
function emptyPatientCase(): UiPatientCase {
return {
diagnosisId: '',
name: '',
gender: '',
age: '',
height: '',
weight: '',
diagnosisDate: '',
appointmentDate: '',
clinicalDiagnosis: '',
chiefComplaint: '',
presentIllness: '',
pastHistory: '',
allergyHistory: '',
personalHistory: '',
familyHistory: '',
currentMedication: '',
tongue: '',
pulse: '',
prescriptionOpinion: '',
remark: '',
}
}
function normalizePatientCase(
value: DoctorPatientCase | undefined,
diagnosisId: number | string,
fallbackName: string,
): UiPatientCase {
const source = value && typeof value === 'object' ? value : {}
return {
diagnosisId: optionalText(source.diagnosisId ?? diagnosisId, 80),
name: optionalText(source.name || fallbackName, 120) || fallbackName,
gender: optionalText(source.gender, 20),
age: optionalText(source.age, 20),
height: optionalText(source.height, 20),
weight: optionalText(source.weight, 20),
diagnosisDate: optionalText(source.diagnosisDate, 80),
appointmentDate: optionalText(source.appointmentDate, 80),
clinicalDiagnosis: optionalText(source.clinicalDiagnosis),
chiefComplaint: optionalText(source.chiefComplaint),
presentIllness: optionalText(source.presentIllness),
pastHistory: optionalText(source.pastHistory),
allergyHistory: optionalText(source.allergyHistory),
personalHistory: optionalText(source.personalHistory),
familyHistory: optionalText(source.familyHistory),
currentMedication: optionalText(source.currentMedication),
tongue: optionalText(source.tongue),
pulse: optionalText(source.pulse),
prescriptionOpinion: optionalText(source.prescriptionOpinion),
remark: optionalText(source.remark),
}
}
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
@@ -274,15 +376,17 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
throw new Error('诊单ID不能为空')
}
const normalizedPatientName = typeof config.patientName === 'string' && config.patientName.trim()
? config.patientName.trim()
: '患者'
return {
SDKAppID,
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
userSig: cleanString(config.userSig, '用户签名'),
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
patientName: typeof config.patientName === 'string' && config.patientName.trim()
? config.patientName.trim()
: '患者',
patientName: normalizedPatientName,
patientCase: normalizePatientCase(config.patientCase, diagnosisId, normalizedPatientName),
mode: config.mode === 'chat' ? 'chat' : 'video',
}
}
@@ -747,30 +851,50 @@ function attachLocalRecordingTrack(
return true
}
function attachDoctorAudioTrack(cloud: TrtcAudioCloud): boolean {
if (typeof cloud.getAudioTrack !== 'function') return false
let attached = false
try {
attached = attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
} catch {
// Some TRTC versions do not support processed tracks.
}
if (!attached) {
try {
attached = attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
} catch {
// The microphone fallback below remains available.
}
}
return attached
}
function attachPatientAudioTrack(cloud: TrtcAudioCloud, userId: string): boolean {
if (typeof cloud.getAudioTrack !== 'function' || !userId) return false
let attached = false
try {
attached = attachLocalRecordingTrack(cloud.getAudioTrack({
userId,
processed: true,
}), 'patient')
} catch {
// Some TRTC versions do not expose a processed remote track.
}
if (!attached) {
try {
attached = attachLocalRecordingTrack(cloud.getAudioTrack(userId), 'patient')
} catch {
// Remote audio can become available a few frames after connected.
}
}
return attached
}
function attachCurrentCallAudioTracks(cloud: TrtcAudioCloud | null): void {
if (!activeConfig) return
if (typeof cloud?.getAudioTrack === 'function') {
try {
attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
} catch {
try {
attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
} catch {
// The rendered media elements below remain a supported fallback.
}
}
try {
attachLocalRecordingTrack(cloud.getAudioTrack({
userId: activeConfig.targetUserId,
processed: true,
}), 'patient')
} catch {
try {
attachLocalRecordingTrack(cloud.getAudioTrack(activeConfig.targetUserId), 'patient')
} catch {
// Remote audio can become available a few frames after connected.
}
}
if (cloud) {
attachDoctorAudioTrack(cloud)
attachPatientAudioTrack(cloud, activeConfig.targetUserId)
}
for (const media of document.querySelectorAll<HTMLMediaElement>('video, audio')) {
@@ -852,8 +976,20 @@ async function cleanupLocalRecordingGraph(): Promise<void> {
// The call engine may already have released its event dispatcher.
}
}
if (
localAudioCloud
&& localRemoteAudioAvailableHandler
&& typeof localAudioCloud.off === 'function'
) {
try {
localAudioCloud.off('remote-audio-available', localRemoteAudioAvailableHandler)
} catch {
// The call engine may already have released its event dispatcher.
}
}
localAudioCloud = null
localAudioTrackHandler = null
localRemoteAudioAvailableHandler = null
for (const source of localAudioSources) {
try {
source.disconnect()
@@ -910,15 +1046,26 @@ async function performStartLocalRecording(): Promise<void> {
localAudioDestination = context.createMediaStreamDestination()
const cloud = getTrtcAudioCloud()
localAudioCloud = cloud
localAudioTrackHandler = (event) => attachLocalRecordingTrack(
event.track,
event.userId === activeConfig?.userID
localAudioTrackHandler = (event) => {
const sourceKind = !event.userId || event.userId === activeConfig?.userID
? 'doctor'
: event.userId === activeConfig?.targetUserId
? 'patient'
: 'unknown',
)
if (typeof cloud?.on === 'function') cloud.on('track', localAudioTrackHandler)
: 'unknown'
if (!attachLocalRecordingTrack(event.track, sourceKind)) {
attachLocalRecordingTrack(event.sourceTrack, sourceKind)
}
}
localRemoteAudioAvailableHandler = (event) => {
const userId = event.userId || activeConfig?.targetUserId || ''
if (cloud && userId === activeConfig?.targetUserId) {
attachPatientAudioTrack(cloud, userId)
}
}
if (typeof cloud?.on === 'function') {
cloud.on('track', localAudioTrackHandler)
cloud.on('remote-audio-available', localRemoteAudioAvailableHandler)
}
if (context.state === 'suspended') await context.resume()
await waitForCallAudioTracks(cloud, sessionId)
startCallAudioDiscovery(cloud)
@@ -1151,10 +1298,6 @@ function getTranscriberManager(): RealtimeTranscriberManager {
}
function clearLiveCaptions(): void {
if (liveCaptionClearTimer !== null) {
window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = null
}
liveCaptions.value = []
}
@@ -1170,19 +1313,26 @@ function showLiveCaption(message: RealtimeTranscriberMessage): void {
: speakerUserId === activeConfig.targetUserId
? patientName.value || '患者'
: '对话'
const rawTimestamp = Number(message.timestamp)
const captionTimestamp = Number.isFinite(rawTimestamp) && rawTimestamp > 0
? (rawTimestamp < 1_000_000_000_000 ? rawTimestamp * 1000 : rawTimestamp)
: Date.now()
const caption: UiLiveCaption = {
id,
speaker,
text: text.slice(0, 500),
time: new Date(captionTimestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
completed: message.isCompleted === true,
}
const previous = liveCaptions.value.filter((item) => item.id !== id)
liveCaptions.value = [...previous, caption].slice(-2)
if (liveCaptionClearTimer !== null) window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = window.setTimeout(() => {
liveCaptions.value = []
liveCaptionClearTimer = null
}, message.isCompleted === true ? 9000 : 5000)
// Keep the current call's transcript visible in the side rail. Repeated
// partial updates replace the same segment and the bounded history prevents
// long calls from growing memory without limit.
liveCaptions.value = [...previous, caption].slice(-120)
}
function handleTranscriberMessage(
@@ -1421,7 +1571,6 @@ async function stopTranscription(
transcriptionSessionId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
clearLiveCaptions()
})().finally(() => {
transcriptionStopPromise = null
})
@@ -1720,6 +1869,7 @@ async function open(config: DoctorCallConfig): Promise<void> {
activeConfig = normalizeConfig(config)
mode.value = activeConfig.mode
patientName.value = activeConfig.patientName
patientCase.value = activeConfig.patientCase
messages.value = []
nextReqMessageID = ''
hasMoreMessages.value = false
@@ -1805,12 +1955,14 @@ createApp(App, {
transcriptionState: readonly(transcriptionState),
localRecordingState: readonly(localRecordingState),
liveCaptions: readonly(liveCaptions),
patientCase: readonly(patientCase),
onSendText: sendText,
onSendAttachment: sendAttachment,
onLoadMore: () => loadMessages(true),
onReconnectChat: reconnectChat,
onStartVideo: startVideo,
onHangup: hangup,
onOpenDiagnosis: openDiagnosis,
onSaveScreenshot: saveScreenshot,
}).mount('#app')
+299 -29
View File
@@ -272,6 +272,8 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.video-layer {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 420px;
@@ -281,8 +283,21 @@ button:disabled { cursor: not-allowed; opacity: .55; }
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
#090d14;
}
.video-layer--with-rail {
grid-template-columns: minmax(0, 1fr) clamp(330px, 31vw, 380px);
}
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
.video-stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background:
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
#090d14;
}
.call-kit,
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
width: 100% !important;
@@ -336,40 +351,249 @@ button:disabled { cursor: not-allowed; opacity: .55; }
}
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
.live-captions {
position: absolute;
z-index: 38;
left: 50%;
bottom: 92px;
.consultation-rail {
position: relative;
z-index: 55;
display: grid;
gap: 7px;
width: min(820px, calc(100% - 360px));
transform: translateX(-50%);
pointer-events: none;
grid-template-rows: auto minmax(0, 1.08fr) minmax(0, .92fr);
min-width: 0;
min-height: 0;
overflow: hidden;
border-left: 1px solid #dce3f0;
color: #15233a;
background: #f6f8fc;
box-shadow: -16px 0 38px rgba(4, 12, 26, .16);
}
.live-captions p {
.consultation-rail__header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
width: fit-content;
max-width: 100%;
margin: 0 auto;
padding: 8px 14px;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 10px;
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
min-height: 74px;
padding: 13px 16px;
border-bottom: 1px solid #e2e7f1;
background: #fff;
}
.consultation-rail__avatar {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 13px;
color: #fff;
background: rgba(9, 13, 20, .78);
box-shadow: 0 6px 24px rgba(0, 0, 0, .2);
font-size: 16px;
line-height: 1.55;
backdrop-filter: blur(8px);
background: #5761f4;
font-size: 17px;
font-weight: 700;
}
.live-captions strong {
color: #aeb8ff;
.consultation-rail__identity {
display: grid;
gap: 3px;
min-width: 0;
}
.consultation-rail__identity strong {
overflow: hidden;
color: #111f46;
font-size: 16px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-captions span { min-width: 0; word-break: break-word; }
.consultation-rail__identity span {
overflow: hidden;
color: #7886a3;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.diagnosis-chip {
padding: 5px 7px;
border-radius: 6px;
color: #4a56d5;
background: #eef0ff;
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.consultation-rail__actions {
display: grid;
flex: 0 0 auto;
justify-items: end;
gap: 5px;
}
.open-diagnosis-button {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 28px;
padding: 5px 8px;
border: 1px solid #cfd5ff;
border-radius: 7px;
color: #3f4bc5;
background: #fff;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.open-diagnosis-button:hover,
.open-diagnosis-button:focus-visible {
border-color: #6874e7;
background: #f3f4ff;
outline: none;
}
.open-diagnosis-button:disabled { cursor: not-allowed; opacity: .55; }
.case-panel,
.transcript-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.case-panel { border-bottom: 1px solid #dde4f0; }
.rail-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 10px 16px;
border-bottom: 1px solid #e5e9f2;
background: rgba(255, 255, 255, .72);
}
.rail-section-heading > div {
display: flex;
align-items: center;
gap: 8px;
}
.rail-section-heading h2 {
margin: 0;
color: #1b2945;
font-size: 14px;
font-weight: 700;
letter-spacing: -.01em;
}
.rail-section-heading > span {
overflow: hidden;
max-width: 154px;
color: #8b97ad;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-section-heading__index {
color: #6874e7;
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
font-size: 10px;
font-weight: 700;
}
.case-panel__content,
.live-captions {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-color: #b9c3d5 transparent;
scrollbar-width: thin;
}
.case-panel__content { padding: 5px 16px 14px; }
.case-field-list { display: grid; }
.case-field {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #e5e9f2;
}
.case-field:last-child { border-bottom: 0; }
.case-field > span {
padding-top: 2px;
color: #7a879d;
font-size: 11px;
font-weight: 600;
}
.case-field p {
margin: 0;
color: #263551;
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.case-field--risk > span { color: #bc3e4e; }
.case-field--risk p {
color: #9f3141;
font-weight: 600;
}
.live-captions {
display: grid;
align-content: start;
gap: 9px;
padding: 12px 14px 18px;
background: #f2f5fa;
}
.caption-entry {
padding: 10px 11px;
border-left: 2px solid #6571e8;
border-radius: 0 9px 9px 0;
background: #fff;
box-shadow: 0 4px 14px rgba(30, 46, 76, .045);
}
.caption-entry--partial { border-left-color: #9ca6b7; opacity: .78; }
.caption-entry header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 5px;
}
.caption-entry strong {
color: #4a56d5;
font-size: 11px;
}
.caption-entry time {
color: #9aa5b7;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.caption-entry p {
margin: 0;
color: #24334e;
font-size: 13px;
line-height: 1.58;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.transcript-state {
position: relative;
padding-left: 12px;
}
.transcript-state::before {
position: absolute;
top: 50%;
left: 0;
width: 6px;
height: 6px;
transform: translateY(-50%);
border-radius: 50%;
background: #a5adbb;
content: "";
}
.transcript-state--active { color: #168260 !important; }
.transcript-state--active::before {
background: #24b987;
box-shadow: 0 0 0 3px rgba(36, 185, 135, .12);
}
.rail-empty {
display: grid;
gap: 5px;
align-content: center;
min-height: 112px;
padding: 18px;
color: #8a96aa;
text-align: center;
}
.rail-empty strong { color: #53617a; font-size: 12px; }
.rail-empty span { font-size: 11px; line-height: 1.55; }
.rail-empty--case { min-height: 100%; }
.video-actions {
position: absolute;
@@ -494,8 +718,54 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.consultation-shell { min-width: 620px; }
.message-list { padding-inline: 18px; }
.message-bubble { max-width: 82%; }
.live-captions { width: calc(100% - 36px); bottom: 88px; }
.live-captions p { font-size: 14px; }
}
@media (max-width: 980px) {
.consultation-shell { min-width: 760px; }
.video-layer--with-rail { grid-template-columns: minmax(0, 1fr) 310px; }
.video-actions {
right: 12px;
bottom: 14px;
left: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.recording-status {
flex-basis: 100%;
width: fit-content;
margin-left: auto;
font-size: 11px;
}
.consultation-rail__header { padding-inline: 12px; }
.diagnosis-chip { display: none; }
.case-panel__content { padding-inline: 12px; }
.rail-section-heading { padding-inline: 12px; }
}
@media (max-width: 700px) {
.consultation-shell { min-width: 0; }
.video-layer,
.video-stage { min-height: 280px; }
.video-layer--with-rail { grid-template-columns: minmax(0, 1fr); }
.consultation-rail { display: none; }
.video-actions {
right: 10px;
bottom: 10px;
left: auto;
gap: 6px;
}
.recording-status,
.capture-button { display: none; }
.video-actions button {
padding: 8px 11px;
border-radius: 8px;
font-size: 11px;
}
.live-status {
top: 10px;
padding: 7px 10px;
font-size: 11px;
}
}
/* Doctor workstation blue-white subwindow contract. Video pixels remain on