This commit is contained in:
Your Name
2026-08-12 11:03:28 +08:00
parent 09d3fcbf82
commit bc9ad1d3cd
32 changed files with 4586 additions and 2341 deletions
@@ -145,7 +145,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/预约接诊率=接诊诊单/加粉</p>
<p>展开部门可查看人员明细加粉=区间有效加粉按员工+客户去重须会话同意剔除已删客户挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/预约预约接诊率=接诊诊单/面诊面诊接诊率=面诊/挂号接诊率=接诊诊单/加粉</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -196,9 +196,12 @@
<el-table-column label="面诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right">
<el-table-column label="预约接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column>
@@ -327,7 +330,7 @@ const timeOptions = [
{ label: '自定义', value: 'custom' }
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -22,7 +22,7 @@ Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项
## 已实现范围
- 账号密码登录、token 会话、记住账号(不保存密码和退出登录。
- 账号密码登录、token 会话、通过系统凭据管理器安全记住密码和退出登录。
- 登录后读取 `/adminapi/auth.admin/mySelf`,按 `permissions` 动态控制页面和操作按钮;`*` 超级权限兼容现有后台。
- 接诊台:今日待接诊/已过号、患者详情、医生备注、通知医助、完成接诊、发起视频面诊。
- 挂号列表:对齐管理端 `tcm/appointment/list`,支持状态 Tab、日期/确认/部门筛选、通话、完成、开方与取消挂号。
@@ -76,7 +76,7 @@ DOCTOR_VERIFY_SSL=true
- 响应 envelope`code=1` 成功、`0` 业务失败、`-1` 登录失效、`10` 需绑定企业微信。
- 权限与数据范围:完全以后端 `/auth.admin/mySelf` 返回为准。
环境配置不会保存密码、TRTC SecretKey 或腾讯云长期凭据。登录 token 优先存入系统凭据库无法使用时仅回退到用户配置目录中的受限文件。
环境配置不会保存明文密码、TRTC SecretKey 或腾讯云长期凭据。登录密码优先存入系统凭据管理器;Windows 凭据后端不可用时仅保存当前 Windows 用户可解密的 DPAPI 密文。登录 token 优先存入系统凭据库无法使用时仅回退到用户配置目录中的受限文件。
## 视频伴随页
+151 -44
View File
@@ -9,7 +9,7 @@ import time
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QObject, Qt, QTimer
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtWidgets import (
QApplication,
@@ -43,7 +43,71 @@ from doctor_workstation.ui.widgets import (
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__)
class _ChineseQtTranslator(QTranslator):
"""Guarantee Chinese labels for common Qt standard buttons.
Qt's packaged ``qtbase_zh_CN`` catalog remains the primary source for
framework text. This small fallback also keeps release builds localized
when a packager omits the optional ``.qm`` files.
"""
_BUTTON_TEXT = {
"OK": "确定",
"Open": "打开",
"Save": "保存",
"Save All": "全部保存",
"Cancel": "取消",
"Close": "关闭",
"Yes": "",
"Yes to All": "全部确认",
"No": "",
"No to All": "全部否定",
"Abort": "中止",
"Retry": "重试",
"Ignore": "忽略",
"Discard": "放弃",
"Help": "帮助",
"Apply": "应用",
"Reset": "重置",
"Restore Defaults": "恢复默认设置",
"Don't Save": "不保存",
}
def translate(
self,
context: str,
source_text: str,
disambiguation: str | None = None,
n: int = -1,
) -> str:
del context, disambiguation, n
return self._BUTTON_TEXT.get(source_text.replace("&", ""), "")
def _install_chinese_translations(application: QApplication) -> None:
"""Install Simplified Chinese Qt catalogs once for the whole process."""
if getattr(application, "_doctor_workstation_chinese_translators", None):
return
QLocale.setDefault(QLocale("zh_CN"))
translators: list[QTranslator] = []
translations_path = QLibraryInfo.path(
QLibraryInfo.LibraryPath.TranslationsPath
)
for catalog in ("qt_zh_CN", "qtbase_zh_CN"):
translator = QTranslator(application)
if translator.load(catalog, translations_path):
application.installTranslator(translator)
translators.append(translator)
fallback = _ChineseQtTranslator(application)
application.installTranslator(fallback)
translators.append(fallback)
application._doctor_workstation_chinese_translators = translators # type: ignore[attr-defined]
class _UnconfiguredRepository:
@@ -200,11 +264,12 @@ class ApplicationController(QObject):
def _show_login(self) -> None:
if self.login_window is None:
self.login_window = LoginWindow(
self._base_repository(),
self.config,
self.demo_repository,
)
self.login_window = LoginWindow(
self._base_repository(),
self.config,
self.demo_repository,
credential_store=self.token_store,
)
self.login_window.login_succeeded.connect(self._on_login_succeeded)
self.login_window.config_changed.connect(self._on_config_changed)
self.login_window.demo_mode_changed.connect(self._on_demo_mode_changed)
@@ -212,8 +277,9 @@ class ApplicationController(QObject):
else:
self.login_window.repository = self._base_repository()
self.login_window.config = self.config
if not self.login_window.demo_check.isChecked():
self.login_window.active_repository = self._base_repository()
if not self.login_window.demo_check.isChecked():
self.login_window.active_repository = self._base_repository()
self.login_window.restore_remembered_credentials()
self.login_window.show()
self.login_window.raise_()
self.login_window.activateWindow()
@@ -498,20 +564,40 @@ class ApplicationController(QObject):
if parent is None or self.current_repository is None:
return
patient_id = payload.get("patient_id")
diagnosis_id = payload.get("diagnosis_id")
patient_name = str(payload.get("patient_name") or "患者")
diagnosis_id = payload.get("diagnosis_id")
patient_name = str(payload.get("patient_name") or "患者")
open_im = str(payload.get("mode") or "video").lower() == "im"
if patient_id in (None, "") or diagnosis_id in (None, ""):
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
return
call_key = str(diagnosis_id)
if (
call_key in self.video_pending
or call_key in self.video_calls
or call_key in self.demo_video_dialogs
):
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
return
call_key = str(diagnosis_id)
existing_call = self.video_calls.get(call_key)
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
qt_window = getattr(existing_call, "qt_window", None)
if qt_window is not None:
qt_window.show()
qt_window.raise_()
qt_window.activateWindow()
show_toast(parent, "该患者的 IM 会话已经打开。", "info", 3200)
return
if (
call_key in self.video_pending
or existing_call is not None
or call_key in self.demo_video_dialogs
):
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
return
closed_previous_im = False
if open_im:
for key, call in tuple(self.video_calls.items()):
if key == call_key or not getattr(call, "open_im", False):
continue
closed_previous_im = True
self.video_calls.pop(key, None)
with suppress(Exception):
call.close()
if self.current_demo_mode:
dialog = DemoVideoDialog(patient_name, parent)
@@ -525,7 +611,11 @@ class ApplicationController(QObject):
dialog.show()
return
show_toast(parent, "正在获取安全通话凭证…", "info")
show_toast(
parent,
"正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…",
"info",
)
repository = self.current_repository
marker = object()
self.video_pending[call_key] = marker
@@ -536,23 +626,35 @@ class ApplicationController(QObject):
diagnosis_id=int(diagnosis_id),
)
run_async(
get_ticket,
on_success=lambda ticket: self._launch_video(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
call_key=call_key,
marker=marker,
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
parent,
error,
),
)
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,
call_key=call_key,
marker=marker,
open_im=open_im,
patient_name=patient_name,
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
parent,
error,
),
)
# Tencent IM may take a brief moment to release the previous browser
# connection. The admin version also has only one ChatDialog instance.
if closed_previous_im:
QTimer.singleShot(400, request_ticket)
else:
request_ticket()
def _video_ticket_error(
self,
@@ -579,9 +681,11 @@ class ApplicationController(QObject):
diagnosis_id: Any,
patient_id: Any,
repository: Any,
call_key: str,
marker: object,
) -> None:
call_key: str,
marker: object,
open_im: bool = False,
patient_name: str = "患者",
) -> None:
if self.video_pending.get(call_key) is not marker:
return
self.video_pending.pop(call_key, None)
@@ -604,9 +708,11 @@ class ApplicationController(QObject):
patient_id=patient_id,
backend_mode=mode,
local_dist=video_dist_path(),
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
)
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
open_im=open_im,
patient_name=patient_name,
)
except Exception as error:
LOGGER.exception("video call could not be launched")
show_toast(
@@ -682,7 +788,8 @@ def _create_application(argv: list[str]) -> QApplication:
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
application = QApplication(argv)
application = QApplication(argv)
_install_chinese_translations(application)
application.setApplicationName("甄养堂医生工作站")
application.setApplicationDisplayName("甄养堂医生工作站")
application.setOrganizationName("ZhenYangTang")
@@ -593,6 +593,28 @@ class DemoDoctorRepository:
safe_name = source.name.replace("\\", "_").replace("/", "_")
return f"/demo/uploads/{kind}/{material_id}-{safe_name}"
def upload_material_bytes(
self,
content: bytes,
filename: str,
material_type: Literal["image", "video", "file"],
cid: int = 0,
) -> str:
"""Return a synthetic server URI for an in-memory demo capture."""
if cid < 0:
raise ValueError("cid must be non-negative")
if not content:
raise ValueError("material content must not be empty")
safe_name = Path(filename).name.strip().replace("\\", "_").replace("/", "_")
if not safe_name:
raise ValueError("filename is required")
kind = _material_kind(material_type)
with self._lock:
material_id = self._next_material_id
self._next_material_id += 1
return f"/demo/uploads/{kind}/{material_id}-{safe_name}"
def add_doctor_note(
self,
diagnosis_id: int,
@@ -8,6 +8,7 @@ import time
from collections.abc import Mapping
from contextlib import suppress
from datetime import date
from io import BytesIO
from os import PathLike
from pathlib import Path
from typing import Any, Final, Literal, Protocol
@@ -109,6 +110,15 @@ class DoctorRepository(Protocol):
) -> str:
"""Upload one local note material and return its server URI."""
def upload_material_bytes(
self,
content: bytes,
filename: str,
material_type: Literal["image", "video", "file"],
cid: int = 0,
) -> str:
"""Upload an in-memory capture and return its server URI."""
def get_prescription_template(self, template_id: int) -> PrescriptionTemplate:
"""Return one prescription-library record."""
@@ -960,6 +970,34 @@ class RemoteDoctorRepository:
)
return _normalise_material_reference(payload, endpoint)
def upload_material_bytes(
self,
content: bytes,
filename: str,
material_type: Literal["image", "video", "file"],
cid: int = 0,
) -> str:
"""Upload a trusted in-memory capture without a local plaintext file."""
if cid < 0:
raise ValueError("cid must be non-negative")
if not content:
raise ValueError("material content must not be empty")
if len(content) > 10 * 1024 * 1024:
raise ValueError("material content exceeds 10 MB")
safe_name = Path(filename).name.strip()
if not safe_name or safe_name in {".", ".."}:
raise ValueError("filename is required")
kind = _material_kind(material_type)
mime_type = mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
endpoint = f"upload/{kind}"
payload = self.client.post_multipart(
endpoint,
files={"file": (safe_name, BytesIO(content), mime_type)},
data={"cid": str(cid)},
)
return _normalise_material_reference(payload, endpoint)
def notify_assistant(self, appointment_id: int) -> Any:
"""Ask the server to notify the assigned medical assistant."""
@@ -2,6 +2,9 @@
from __future__ import annotations
import base64
import ctypes
import hashlib
import json
import os
import stat
@@ -28,12 +31,13 @@ _AUTO_KEYRING = object()
class TokenStore:
"""Store access tokens, but never user passwords.
"""Store tokens and optional login passwords with OS-protected storage.
If a working ``keyring`` backend is importable, the token is stored there
and the JSON file contains at most the remembered account name and the
non-secret API scope. Otherwise the JSON file is atomically written with
owner-only ``0600`` permissions.
non-secret API scope. Login passwords use the keyring first; on Windows,
an unavailable keyring falls back to a DPAPI-encrypted blob that only the
current Windows user can decrypt. Plaintext passwords are never written.
"""
def __init__(
@@ -103,7 +107,7 @@ class TokenStore:
"""Persist a token plus optional account and API-scope metadata.
Passing an empty ``account`` explicitly forgets a previously remembered
account. Passwords are never accepted or persisted.
account and its keyring password.
"""
cleaned = token.strip()
@@ -115,6 +119,17 @@ class TokenStore:
data = self._read_file()
if account is not None:
account_value = account.strip()
previous_account = str(data.get("account") or "").strip()
previous_scope = self._normalise_scope(data.get("scope"))
next_scope = (
self._normalise_scope(scope) if scope is not None else previous_scope
)
if previous_account and (
previous_account != account_value
or (account_value and previous_scope != next_scope)
):
self.clear_password(account=previous_account, scope=previous_scope)
data.pop("credential", None)
if account_value:
data["account"] = account_value
else:
@@ -137,7 +152,7 @@ class TokenStore:
self._write_file(data)
def clear_token(self) -> None:
"""Delete the token while retaining an explicitly remembered account."""
"""Delete the token while retaining remembered login credentials."""
if self._uses_keyring and self._keyring is not None:
try:
@@ -146,11 +161,12 @@ class TokenStore:
self._uses_keyring = False
data = self._read_file()
data.pop("token", None)
data.pop("scope", None)
if not data.get("account"):
data.pop("scope", None)
self._write_file(data)
def load_account(self) -> str | None:
"""Load the remembered login account; no password is ever stored."""
"""Load the non-secret remembered login account."""
value = self._read_file().get("account")
return str(value) if isinstance(value, str) and value else None
@@ -167,13 +183,104 @@ class TokenStore:
self._write_file(data)
def clear_account(self) -> None:
"""Forget the remembered account without changing the stored token."""
"""Forget the remembered account and its keyring password."""
data = self._read_file()
self.clear_password(
account=str(data.get("account") or ""),
scope=str(data.get("scope") or ""),
)
self.save_account("")
def load_password(self, *, account: str, scope: str) -> str | None:
"""Load one scoped password from keyring or a Windows DPAPI blob."""
target = self._password_target(account, scope)
if not target:
return None
if self._uses_keyring and self._keyring is not None:
try:
value = self._keyring.get_password(self.service_name, target)
if value:
return str(value)
except Exception:
self._uses_keyring = False
data = self._read_file()
if self._password_target(
str(data.get("account") or ""),
str(data.get("scope") or ""),
) != target:
return None
credential = data.get("credential")
if not isinstance(credential, str) or not credential:
return None
return self._unprotect_password(credential, target)
def save_password(self, password: str, *, account: str, scope: str) -> bool:
"""Save one password using keyring or Windows user-scoped DPAPI."""
target = self._password_target(account, scope)
if not target or not password:
return False
data = self._read_file()
previous_target = self._password_target(
str(data.get("account") or ""),
str(data.get("scope") or ""),
)
if (
previous_target
and previous_target != target
and self._uses_keyring
and self._keyring is not None
):
with suppress(Exception):
self._keyring.delete_password(self.service_name, previous_target)
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.set_password(self.service_name, target, password)
except Exception:
self._uses_keyring = False
else:
data["account"] = account.strip()
data["scope"] = self._normalise_scope(scope)
data.pop("credential", None)
self._write_file(data)
return True
protected = self._protect_password(password, target)
if not protected:
return False
data["account"] = account.strip()
data["scope"] = self._normalise_scope(scope)
data["credential"] = protected
self._write_file(data)
return True
def clear_password(self, *, account: str, scope: str) -> None:
"""Delete one scoped login password from the OS keyring."""
target = self._password_target(account, scope)
if not target:
return
if self._uses_keyring and self._keyring is not None:
with suppress(Exception):
self._keyring.delete_password(self.service_name, target)
data = self._read_file()
stored_target = self._password_target(
str(data.get("account") or ""),
str(data.get("scope") or ""),
)
if stored_target == target and "credential" in data:
data.pop("credential", None)
self._write_file(data)
def clear(self) -> None:
"""Delete both token and remembered account information."""
data = self._read_file()
self.clear_password(
account=str(data.get("account") or ""),
scope=str(data.get("scope") or ""),
)
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.delete_password(self.service_name, self.token_name)
@@ -225,6 +332,120 @@ class TokenStore:
return str(value or "").strip().rstrip("/")
def _password_target(self, account: str, scope: str) -> str:
"""Return an opaque keyring name isolated by account and API server."""
clean_account = account.strip().casefold()
clean_scope = self._normalise_scope(scope).casefold()
if not clean_account or not clean_scope:
return ""
digest = hashlib.sha256(f"{clean_scope}\n{clean_account}".encode()).hexdigest()
return f"login-password:{digest}"
def _protect_password(self, password: str, target: str) -> str | None:
"""Return a Windows DPAPI blob, or ``None`` on unsupported systems."""
if os.name != "nt":
return None
try:
encrypted = self._crypt_protect(
password.encode("utf-8"),
self._credential_entropy(target),
)
except (OSError, ValueError):
return None
return base64.b64encode(encrypted).decode("ascii")
def _unprotect_password(self, protected: str, target: str) -> str | None:
"""Decrypt a Windows DPAPI blob for this account/server target."""
if os.name != "nt":
return None
try:
encrypted = base64.b64decode(protected, validate=True)
plaintext = self._crypt_unprotect(
encrypted,
self._credential_entropy(target),
)
return plaintext.decode("utf-8")
except (OSError, UnicodeError, ValueError):
return None
def _credential_entropy(self, target: str) -> bytes:
return hashlib.sha256(f"{self.service_name}\n{target}".encode()).digest()
@staticmethod
def _crypt_protect(data: bytes, entropy: bytes) -> bytes:
from ctypes import wintypes
class DataBlob(ctypes.Structure):
_fields_ = [
("size", wintypes.DWORD),
("data", ctypes.POINTER(ctypes.c_ubyte)),
]
def make_blob(value: bytes) -> tuple[DataBlob, ctypes.Array[Any]]:
buffer = ctypes.create_string_buffer(value)
blob = DataBlob(
len(value),
ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)),
)
return blob, buffer
source, _source_buffer = make_blob(data)
optional_entropy, _entropy_buffer = make_blob(entropy)
destination = DataBlob()
if not ctypes.windll.crypt32.CryptProtectData(
ctypes.byref(source),
None,
ctypes.byref(optional_entropy),
None,
None,
0x01,
ctypes.byref(destination),
):
raise ctypes.WinError()
try:
return ctypes.string_at(destination.data, destination.size)
finally:
ctypes.windll.kernel32.LocalFree(destination.data)
@staticmethod
def _crypt_unprotect(data: bytes, entropy: bytes) -> bytes:
from ctypes import wintypes
class DataBlob(ctypes.Structure):
_fields_ = [
("size", wintypes.DWORD),
("data", ctypes.POINTER(ctypes.c_ubyte)),
]
def make_blob(value: bytes) -> tuple[DataBlob, ctypes.Array[Any]]:
buffer = ctypes.create_string_buffer(value)
blob = DataBlob(
len(value),
ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)),
)
return blob, buffer
source, _source_buffer = make_blob(data)
optional_entropy, _entropy_buffer = make_blob(entropy)
destination = DataBlob()
if not ctypes.windll.crypt32.CryptUnprotectData(
ctypes.byref(source),
None,
ctypes.byref(optional_entropy),
None,
None,
0x01,
ctypes.byref(destination),
):
raise ctypes.WinError()
try:
return ctypes.string_at(destination.data, destination.size)
finally:
ctypes.windll.kernel32.LocalFree(destination.data)
def _read_file(self) -> dict[str, Any]:
try:
content = self.path.read_text(encoding="utf-8")
@@ -234,10 +455,18 @@ class TokenStore:
if not isinstance(value, dict):
return {}
# Explicit allow-list guarantees accidental password-like fields are ignored.
return {key: value[key] for key in ("token", "account", "scope") if key in value}
return {
key: value[key]
for key in ("token", "account", "scope", "credential")
if key in value
}
def _write_file(self, data: dict[str, Any]) -> None:
safe = {key: data[key] for key in ("token", "account", "scope") if data.get(key)}
safe = {
key: data[key]
for key in ("token", "account", "scope", "credential")
if data.get(key)
}
if not safe:
with suppress(OSError):
self.path.unlink(missing_ok=True)
+74 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QPoint, QSettings, Qt, QTimer, Signal
@@ -70,8 +71,9 @@ class LoginWindow(QMainWindow):
``login_succeeded`` emits a dictionary containing ``user``, ``session``,
``repository`` and ``demo_mode``. Keeping the selected repository in the
payload lets the composition root construct the shell without guessing.
``remember_account`` is captured before the worker starts and is forwarded
to the repository so every account metadata store follows the same choice.
The remember-password choice is captured before the worker starts. The
repository receives the corresponding account-metadata flag, while this
window stores the password only after authentication succeeds.
"""
login_succeeded = Signal(object)
@@ -87,6 +89,7 @@ class LoginWindow(QMainWindow):
config: Any | None = None,
demo_repository: Any | None = None,
settings: QSettings | None = None,
credential_store: Any | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
@@ -96,9 +99,12 @@ class LoginWindow(QMainWindow):
demo_repository = getattr(config, "demo_repository", None)
self.demo_repository = demo_repository
self.settings = settings or QSettings("ZhenYangTang", "DoctorWorkstation")
self.credential_store = credential_store
self.active_repository = repository
self.authenticated_user: Any = None
self._loading = False
self._restored_account = ""
self._restored_scope = ""
self.setWindowTitle("甄养堂 · 医生工作站")
self.setMinimumSize(860, 590)
@@ -214,7 +220,7 @@ class LoginWindow(QMainWindow):
layout.addLayout(brand_row)
layout.addStretch(2)
eyebrow = QLabel("DOCTOR WORKSTATION")
eyebrow = QLabel("医生工作站")
eyebrow.setStyleSheet(
"color:#4F63D9; font-size:11px; font-weight:700; letter-spacing:1px;"
)
@@ -300,8 +306,8 @@ class LoginWindow(QMainWindow):
card_layout.addLayout(password_row)
choices = QHBoxLayout()
self.remember_check = _VisibleCheckBox("记住账号")
self.remember_check.setToolTip("仅保存账号,不保存密码")
self.remember_check = _VisibleCheckBox("记住密码")
self.remember_check.setToolTip("密码使用系统安全凭据或 Windows DPAPI 加密,不保存明文")
choices.addWidget(self.remember_check)
choices.addStretch(1)
self.demo_check = _VisibleCheckBox("演示模式")
@@ -394,8 +400,6 @@ class LoginWindow(QMainWindow):
def _restore_settings(self) -> None:
configured_account = getattr(self.config, "remembered_account", "")
remembered = str(self.settings.value("auth/remembered_account", configured_account) or "")
self.account_edit.setText(remembered)
self.remember_check.setChecked(bool(remembered))
configured_url = getattr(self.config, "base_url", "") or getattr(
self.config, "api_base_url", ""
)
@@ -418,11 +422,49 @@ class LoginWindow(QMainWindow):
self.allow_self_signed_check.setChecked(not verify_ssl)
if self.demo_repository is not None and bool(getattr(self.config, "demo_mode", False)):
self.demo_check.setChecked(True)
self.account_edit.setText(remembered)
self.restore_remembered_credentials()
if remembered:
self.password_edit.setFocus()
else:
self.account_edit.setFocus()
def _credential_scope(self) -> str:
if hasattr(self, "server_url_edit"):
scope = self.server_url_edit.text().strip()
if scope:
return scope.rstrip("/")
return str(
getattr(self.config, "base_url", "")
or getattr(self.config, "api_base_url", "")
or ""
).strip().rstrip("/")
def restore_remembered_credentials(self) -> None:
"""Restore a password from the OS keyring without touching config files."""
configured_account = getattr(self.config, "remembered_account", "")
account = str(
self.settings.value("auth/remembered_account", configured_account) or ""
).strip()
scope = self._credential_scope()
password = ""
should_restore = _setting_bool(
self.settings.value("auth/remember_password", False),
False,
)
loader = getattr(self.credential_store, "load_password", None)
if should_restore and account and scope and callable(loader):
try:
password = str(loader(account=account, scope=scope) or "")
except Exception:
password = ""
self.account_edit.setText(account)
self.password_edit.setText(password)
self.remember_check.setChecked(bool(password))
self._restored_account = account if password else ""
self._restored_scope = scope if password else ""
def _toggle_password(self, visible: bool) -> None:
self.password_edit.setEchoMode(
QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password
@@ -557,6 +599,7 @@ class LoginWindow(QMainWindow):
payload,
account,
remember_account,
password,
),
on_error=self._on_login_error,
on_finished=lambda: self._set_loading(False),
@@ -604,15 +647,39 @@ class LoginWindow(QMainWindow):
payload: dict[str, Any],
account: str,
remember_account: bool | None = None,
password: str | None = None,
) -> None:
if remember_account is None:
remember_account = self.remember_check.isChecked()
scope = self._credential_scope()
is_demo = bool(payload.get("demo_mode"))
password_saved = False
clearer = getattr(self.credential_store, "clear_password", None)
if callable(clearer) and self._restored_account and (
not remember_account
or self._restored_account != account
or self._restored_scope != scope
):
with suppress(Exception):
clearer(account=self._restored_account, scope=self._restored_scope)
if remember_account:
self.settings.setValue("auth/remembered_account", account)
saver = getattr(self.credential_store, "save_password", None)
if not is_demo and password and scope and callable(saver):
try:
password_saved = bool(saver(password, account=account, scope=scope))
except Exception:
password_saved = False
else:
self.settings.remove("auth/remembered_account")
if callable(clearer):
with suppress(Exception):
clearer(account=account, scope=scope)
self.settings.setValue("auth/remember_password", password_saved)
self.settings.sync()
self._emit_config_update(remembered_account=account if remember_account else "")
self._restored_account = account if password_saved else ""
self._restored_scope = scope if password_saved else ""
self.password_edit.clear()
self.authenticated_user = payload.get("user")
self.login_succeeded.emit(payload)
@@ -843,6 +843,7 @@ class AppointmentsPage(QWidget):
"patient_id": patient_id,
"diagnosis_id": diagnosis_id,
"patient_name": first_value(row, "patient_name", default="患者"),
"mode": "im",
"record": row,
}
)
@@ -8,9 +8,10 @@ from datetime import date, timedelta
from pathlib import Path
from typing import Any
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QTextCursor
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QPixmap, QTextCursor
from PySide6.QtWidgets import (
QDialog,
QFileDialog,
QFrame,
QGridLayout,
@@ -22,6 +23,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSplitter,
QStackedWidget,
QTabBar,
@@ -118,6 +120,13 @@ def _attachment_name(value: object) -> str:
return name or text
def _is_image_attachment(value: object) -> bool:
"""Return whether a server attachment can be previewed as an image."""
path = str(value or "").split("?", 1)[0].split("#", 1)[0].lower()
return path.endswith((".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"))
def _is_local_material_reference(value: str) -> bool:
"""Reject local filesystem references before a note JSON is constructed."""
@@ -139,12 +148,14 @@ class QueueRow(QWidget):
layout.setContentsMargins(12, 9, 12, 9)
layout.setSpacing(5)
top = QHBoxLayout()
top.setSpacing(8)
name = QLabel(
display_text(first_value(record, "patient_name", "name", default="未命名患者"))
)
name.setStyleSheet("font-size:14px; font-weight:700; color:#172033;")
top.addWidget(name)
top.addStretch(1)
name.setMinimumWidth(0)
name.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
top.addWidget(name, 1)
status_number = _as_int(first_value(record, "status", default=1), 1) or 1
badge = StatusBadge(
display_text(
@@ -157,7 +168,12 @@ class QueueRow(QWidget):
),
STATUS_KIND.get(status_number, "neutral"),
)
top.addWidget(badge)
badge.setMinimumWidth(max(54, badge.sizeHint().width()))
top.addWidget(
badge,
0,
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop,
)
layout.addLayout(top)
time = first_value(
record, "appointment_time_text", "appointment_time", "time", default="时间待确认"
@@ -168,6 +184,10 @@ class QueueRow(QWidget):
f"{display_text(first_value(record, 'age'))}"
)
meta.setProperty("role", "muted")
# Long timestamps must not dictate the minimum width of the whole
# queue row and push the status badge underneath the viewport edge.
meta.setMinimumWidth(0)
meta.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
layout.addWidget(meta)
assistant = first_value(record, "assistant_name", default=None)
if assistant:
@@ -211,6 +231,7 @@ class ReceptionPage(QWidget):
self._pending_tongue_images: list[str] = []
self._pending_report_files: list[str] = []
self._note_busy = False
self._attachment_preview_generation = 0
self._can_complete = has_permission(self.permissions, "doctor.appointment/complete")
self._can_note = has_permission(self.permissions, "doctor.appointment/addDoctorNote")
@@ -335,7 +356,8 @@ class ReceptionPage(QWidget):
self.notify_button = QPushButton("通知医助")
self.notify_button.clicked.connect(self._notify_assistant)
action_row.addWidget(self.notify_button)
self.video_button = QPushButton("发起视频")
self.video_button = QPushButton("IM 问诊")
self.video_button.setToolTip("打开患者 IM,可发送消息并从会话中发起视频")
self.video_button.setProperty("variant", "secondary")
self.video_button.clicked.connect(self._request_video)
action_row.addWidget(self.video_button)
@@ -1245,13 +1267,25 @@ class ReceptionPage(QWidget):
("report_files", "检查报告"),
):
for path in _sequence(first_value(note, image_type, default=[])):
path_text = str(path).strip()
attachment = QWidget()
attachment_layout = QHBoxLayout(attachment)
attachment_layout.setContentsMargins(0, 0, 0, 0)
label = QLabel(f"{caption}{_attachment_name(path)}")
label.setToolTip(str(path))
label = QLabel(f"{caption}{_attachment_name(path_text)}")
label.setToolTip(path_text)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
attachment_layout.addWidget(label, 1)
preview_button = QPushButton(
"预览" if _is_image_attachment(path_text) else "打开"
)
preview_button.setProperty("variant", "secondary")
preview_button.setEnabled(bool(path_text))
preview_button.clicked.connect(
lambda _checked=False, current_path=path_text, button=preview_button: (
self._preview_note_attachment(current_path, button)
)
)
attachment_layout.addWidget(preview_button)
if self._can_note and note_id is not None:
delete_button = QPushButton("删除")
delete_button.setProperty("variant", "secondary")
@@ -1266,6 +1300,97 @@ class ReceptionPage(QWidget):
layout.addWidget(attachment)
self.notes_layout.addWidget(card)
def _preview_note_attachment(self, path: str, button: QPushButton) -> None:
"""Preview note images in-app and open non-image reports safely."""
target = str(path or "").strip()
if not target:
return
if not _is_image_attachment(target):
url = QUrl(target)
if (
not url.isValid()
or url.scheme().lower() not in {"https", "http"}
or not url.host()
):
show_toast(self, "附件地址无效,无法打开。", "warning", 4200)
return
if not QDesktopServices.openUrl(url):
show_toast(self, "系统未能打开该附件。", "danger", 4200)
return
download = getattr(self.repository, "download_public_image", None)
if not callable(download):
show_toast(self, "当前数据源不支持图片预览。", "warning", 4200)
return
self._attachment_preview_generation += 1
generation = self._attachment_preview_generation
button.setEnabled(False)
button.setText("加载中…")
run_async(
lambda: invoke(self.repository, "download_public_image", url=target),
on_success=lambda payload: self._show_note_image_preview(
target, payload, button, generation
),
on_error=lambda error: self._note_image_preview_failed(
error, button, generation
),
)
def _show_note_image_preview(
self,
path: str,
payload: Any,
button: QPushButton,
generation: int,
) -> None:
if generation != self._attachment_preview_generation:
return
button.setEnabled(True)
button.setText("预览")
content = bytes(payload or b"")
pixmap = QPixmap()
if not content or len(content) > 10 * 1024 * 1024 or not pixmap.loadFromData(content):
show_toast(self, "服务器返回的图片无法预览。", "danger", 4600)
return
dialog = QDialog(self)
dialog.setWindowTitle(f"预览 · {_attachment_name(path)}")
dialog.setModal(True)
dialog.resize(920, 680)
layout = QVBoxLayout(dialog)
layout.setContentsMargins(14, 14, 14, 14)
scroll = QScrollArea(dialog)
scroll.setWidgetResizable(True)
image = QLabel()
image.setAlignment(Qt.AlignmentFlag.AlignCenter)
image.setPixmap(
pixmap.scaled(
880,
620,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
scroll.setWidget(image)
layout.addWidget(scroll, 1)
close_button = QPushButton("关闭")
close_button.setProperty("variant", "primary")
close_button.clicked.connect(dialog.accept)
layout.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight)
dialog.exec()
def _note_image_preview_failed(
self,
error: Exception,
button: QPushButton,
generation: int,
) -> None:
if generation != self._attachment_preview_generation:
return
button.setEnabled(True)
button.setText("预览")
show_toast(self, f"图片预览失败:{friendly_error(error)}", "danger", 5200)
def _reset_detail_content(self, seed: Any = None) -> None:
self.patient_name_label.setText(
display_text(first_value(seed, "patient_name", "name", default=""))
@@ -1753,6 +1878,7 @@ class ReceptionPage(QWidget):
"patient_name",
default=first_value(diagnosis, "patient_name", default="患者"),
),
"mode": "im",
"record": self._selected_record,
}
self.video_requested.emit(payload)
+50 -2
View File
@@ -25,7 +25,13 @@ from PySide6.QtWidgets import (
QWidget,
)
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.core.errors import (
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
)
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
@@ -331,7 +337,45 @@ def friendly_error(error: Any) -> str:
"服务器证书不受系统信任。若这是可信内网的自签名服务器,请展开“服务器设置”,"
"勾选“信任自签名证书(仅内网调试)”后重新登录,设置会自动应用。"
)
return text or "操作未完成,请稍后重试。"
if isinstance(error, AuthenticationExpiredError):
return "登录状态已失效,请重新登录。"
if isinstance(error, ApiTimeoutError) or "timed out" in lowered or "timeout" in lowered:
return "连接服务器超时,请检查网络后重试。"
if isinstance(error, ApiProtocolError) or any(
marker in lowered
for marker in (
"api response envelope",
"api response is not valid json",
"invalid json",
)
):
return "服务器返回的数据格式不正确,请联系管理员检查接口。"
if isinstance(error, ApiHttpError):
status_code = getattr(error, "status_code", None)
suffix = f"(状态码 {status_code}" if status_code else ""
return f"服务器请求失败{suffix},请稍后重试。"
if isinstance(error, ApiTransportError) or any(
marker in lowered
for marker in (
"connection refused",
"connecterror",
"connection error",
"failed to connect",
"getaddrinfo failed",
"name or service not known",
"network is unreachable",
)
):
return "无法连接服务器,请检查服务器地址与网络。"
if any(marker in lowered for marker in ("unauthorized", "forbidden", "permission denied")):
return "当前账号无权执行此操作。"
if "not found" in lowered:
return "未找到所需数据。"
if any("\u4e00" <= character <= "\u9fff" for character in text):
return text
if isinstance(error, TypeError):
return "程序执行失败,请重试;若问题持续出现,请联系管理员。"
return "操作未完成,请稍后重试。"
class PageHeader(QWidget):
@@ -379,6 +423,10 @@ class StatusBadge(QLabel):
self.setObjectName("StatusBadge")
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
# QSS padding alone is not a reliable minimum on every Windows DPI
# scale. Keep enough physical row height so badge text is never
# squeezed into the thin coloured strip seen in list item widgets.
self.setMinimumHeight(24)
self.set_kind(kind)
def set_kind(self, kind: str) -> None:
@@ -359,6 +359,8 @@ class VideoCallLauncher:
*,
diagnosis_id: Any = None,
patient_id: Any = None,
open_im: bool = False,
patient_name: str = "患者",
) -> Any:
request = self.prepare(
ticket,
@@ -374,6 +376,8 @@ class VideoCallLauncher:
remote_url=self.remote_url,
logger=self.logger,
browser_opener=self.browser_opener,
open_im=open_im,
patient_name=patient_name,
)
@@ -388,6 +392,8 @@ def launch_video_call(
remote_url: str | None = None,
logger: Any = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
@@ -402,4 +408,6 @@ def launch_video_call(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
open_im=open_im,
patient_name=patient_name,
)
@@ -262,6 +262,53 @@ class OrderedCallLifecycle:
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
def save_screenshot(self, content: bytes, filename: str) -> Future[str]:
"""Upload one video frame and append it to the diagnosis doctor notes."""
if not content:
raise ValueError("screenshot content must not be empty")
if len(content) > 10 * 1024 * 1024:
raise ValueError("screenshot content exceeds 10 MB")
clean_name = str(filename or "callshot.jpg").strip() or "callshot.jpg"
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
upload = getattr(self.repository, "upload_material_bytes", None)
add_note = getattr(self.repository, "add_doctor_note", None)
if not callable(upload) or not callable(add_note):
raise ValueError("video repository does not implement screenshot storage")
def operation() -> str:
reference = str(
_call_repository_method(
upload,
{
"content": content,
"filename": clean_name,
"material_type": "image",
"cid": 0,
},
)
or ""
).strip()
if not reference:
raise ValueError("screenshot upload returned no server reference")
_call_repository_method(
add_note,
{
"diagnosis_id": self.request.diagnosis_id,
"content": "",
"tongue_images": [reference],
},
)
self.logger.info(
"video screenshot stored in doctor notes",
extra={"video_call": self.request.safe_log_context()},
)
return reference
return self._worker.submit("screenshot", operation)
def end(self, reason: str) -> Future[bool]:
with self._lock:
if self._end_future is not None:
+169 -35
View File
@@ -7,6 +7,8 @@ actual call requires an isolated QtWebEngine profile and an active QApplication.
from __future__ import annotations
import base64
import binascii
import json
import logging
import sys
@@ -181,11 +183,33 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
self._callback(message)
@Slot(str) # type: ignore[misc]
def saveScreenshot(self, data_url: str) -> None: # noqa: N802 - Qt bridge API
"""Forward one bounded JPEG data URL to the trusted desktop host."""
if not isinstance(data_url, str) or len(data_url) > 14 * 1024 * 1024:
self._callback(
{
"source": "doctor-call",
"event": "screenshot-invalid",
"message": "截屏图片过大,无法保存。",
}
)
return
self._callback(
{
"source": "doctor-call",
"event": "screenshot",
"dataUrl": data_url,
}
)
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
status_changed = Signal(str) # type: ignore[misc]
call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
def __init__(
self,
@@ -194,12 +218,19 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
lifecycle: OrderedCallLifecycle,
*,
logger: logging.Logger,
lifecycle_factory: Callable[[], OrderedCallLifecycle],
open_im: bool = False,
patient_name: str = "患者",
) -> None:
super().__init__()
self.request = request
self.location = location
self.lifecycle = lifecycle
self._lifecycle_factory = lifecycle_factory
self._lifecycles = [lifecycle]
self.logger = logger
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
@@ -213,10 +244,14 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._companion_ended = False
self._released = False
self._close_reason = "window-closed"
self._call_cycle_closed = False
self._start_requested = False
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self.setWindowTitle("视频面诊")
self.setWindowTitle(
f"{self.patient_name} IM 问诊" if self.open_im else "视频面诊"
)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.resize(1120, 760)
self.setMinimumSize(760, 520)
@@ -248,6 +283,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self._screenshot_completed.connect(self._on_screenshot_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
@@ -326,17 +362,24 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.close()
return
try:
start_future = self.lifecycle.start()
except Exception:
self.logger.error(
"video call record could not be queued",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "record-start-queue-failed"
self.close()
return
start_future.add_done_callback(self._notify_start_completed)
self._media_active = True
config = {
**self.request.to_web_config(),
"patientName": self.patient_name,
"mode": "chat" if self.open_im else "video",
}
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
script = f"""
(() => {{
if (!window.doctorConsultation
|| typeof window.doctorConsultation.open !== 'function') {{
return false;
}}
void window.doctorConsultation.open({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _notify_start_completed(self, future: Future[bool]) -> None:
try:
@@ -350,25 +393,16 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if self._closing:
return
if not succeeded:
self._close_reason = "record-start-failed"
self.close()
self._start_requested = False
self._call_cycle_closed = True
self._page.runJavaScript(
"window.doctorConsultation?.hostCallReady?.(false, "
'"通话记录创建失败,请稍后重试。");'
)
return
self._media_active = True
config_json = json.dumps(
self.request.to_web_config(),
ensure_ascii=True,
separators=(",", ":"),
self._page.runJavaScript(
"window.doctorConsultation?.hostCallReady?.(true, '');"
)
script = f"""
(() => {{
if (!window.doctorCall || typeof window.doctorCall.start !== 'function') {{
return false;
}}
void window.doctorCall.start({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _after_injection(self, result: Any) -> None:
self._injected = result is not False
@@ -380,6 +414,18 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if self._closing:
return
event = str(message.get("event", ""))
if event == "call-start-request":
self._start_call_cycle()
return
if event == "screenshot":
self._save_screenshot(str(message.get("dataUrl") or ""))
return
if event == "screenshot-invalid":
self._on_screenshot_completed(
False,
str(message.get("message") or "截屏图片无效。")[:200],
)
return
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
@@ -388,16 +434,80 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if event == "status":
status = str(message.get("status", "unknown"))[:80]
self.status_changed.emit(status)
if status == "idle":
if status == "idle" and not self.open_im:
self._close_from_companion("remote-idle")
elif event == "hangup":
status = str(message.get("status", "ended"))[:80]
self.call_ended.emit(status)
self._close_from_companion("companion-hangup")
self.lifecycle.end(f"companion-{status}")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
self.call_error.emit(message_text)
self._close_from_companion("companion-error")
if self._start_requested:
self.lifecycle.end("companion-error")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
self._close_from_companion("companion-error")
def _start_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
if self._call_cycle_closed:
self.lifecycle = self._lifecycle_factory()
self._lifecycles.append(self.lifecycle)
self._call_cycle_closed = False
self._start_requested = True
try:
future = self.lifecycle.start()
except Exception:
self._start_requested = False
self._on_lifecycle_started(False)
return
future.add_done_callback(self._notify_start_completed)
def _save_screenshot(self, data_url: str) -> None:
prefix = "data:image/jpeg;base64,"
if not data_url.startswith(prefix):
self._on_screenshot_completed(False, "截屏图片格式不正确。")
return
try:
content = base64.b64decode(data_url[len(prefix) :], validate=True)
except (ValueError, binascii.Error):
self._on_screenshot_completed(False, "截屏图片解析失败。")
return
try:
future = self.lifecycle.save_screenshot(
content,
f"callshot-{self.request.diagnosis_id}.jpg",
)
except Exception as error:
self._on_screenshot_completed(False, str(error)[:200])
return
future.add_done_callback(self._notify_screenshot_completed)
def _notify_screenshot_completed(self, future: Future[str]) -> None:
try:
future.result()
except Exception as error:
succeeded = False
message = str(error)[:200] or "截屏保存失败。"
else:
succeeded = True
message = "截屏已保存到患者信息。"
with suppress(RuntimeError):
self._screenshot_completed.emit(succeeded, message)
def _on_screenshot_completed(self, succeeded: bool, message: str) -> None:
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
f"window.doctorConsultation?.screenshotResult?.({state}, {payload});"
)
def _close_from_companion(self, reason: str) -> None:
self._companion_ended = True
@@ -415,11 +525,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._media_active = False
if self._injected and not self._companion_ended and not self._released:
self._page.runJavaScript(
"void window.doctorCall?.hangup?.().catch(() => undefined)"
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self.lifecycle.end(self._close_reason)
if self._start_requested and not self._call_cycle_closed:
self.lifecycle.end(self._close_reason)
self._release_webengine()
def wait_for_lifecycles(self, timeout: float) -> bool:
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
def _release_webengine(self) -> None:
if self._released:
return
@@ -486,6 +600,8 @@ class VideoCallWindow:
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
@@ -500,14 +616,23 @@ class VideoCallWindow:
raise VideoWindowError("embedded video requires an active QApplication")
self.request = request
self.repository = repository
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
local_dist=local_dist,
remote_url=remote_url,
)
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
self._lifecycles = [self.lifecycle]
self._session: Any = None
def _new_lifecycle(self) -> OrderedCallLifecycle:
lifecycle = OrderedCallLifecycle(self.request, self.repository, self.logger)
self._lifecycles.append(lifecycle)
return lifecycle
@property
def qt_window(self) -> Any:
return self._session
@@ -519,6 +644,9 @@ class VideoCallWindow:
self.location,
self.lifecycle,
logger=self.logger,
lifecycle_factory=self._new_lifecycle,
open_im=self.open_im,
patient_name=self.patient_name,
)
except Exception:
self.lifecycle.end("window-open-failed")
@@ -545,7 +673,9 @@ class VideoCallWindow:
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
"""Wait briefly for ordered backend writes; timeout is capped at five seconds."""
return self.lifecycle.wait(timeout)
if self._session is not None:
return self._session.wait_for_lifecycles(timeout)
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
wait = wait_for_lifecycle
@@ -558,6 +688,8 @@ def open_video_call(
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
@@ -570,6 +702,8 @@ def open_video_call(
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
open_im=open_im,
patient_name=patient_name,
).open()
+52 -3
View File
@@ -215,21 +215,43 @@ def test_invalid_envelope_raises_protocol_error() -> None:
client.get("broken")
def test_token_store_file_fallback_never_persists_password(tmp_path: Path) -> None:
"""The fallback contains only an access token and an optional account name."""
def test_token_store_file_fallback_never_persists_plaintext_password(tmp_path: Path) -> None:
"""The fallback may contain a Windows DPAPI blob, but never plaintext."""
path = tmp_path / "credentials.json"
store = TokenStore(path, keyring_backend=None)
store.save_token("token-value", account="doctor")
password_saved = store.save_password(
"must-not-reach-disk",
account="doctor",
scope="https://example.test/adminapi",
)
assert store.load_token() == "token-value"
assert store.load_account() == "doctor"
restored = store.load_password(account="doctor", scope="https://example.test/adminapi")
if password_saved:
assert restored == "must-not-reach-disk"
else:
assert restored is None
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload == {"token": "token-value", "account": "doctor"}
assert payload["token"] == "token-value"
assert payload["account"] == "doctor"
assert "password" not in path.read_text(encoding="utf-8").lower()
assert "must-not-reach-disk" not in path.read_text(encoding="utf-8")
store.clear_token()
assert store.load_token() is None
assert store.load_account() == "doctor"
after_logout = store.load_password(
account="doctor",
scope="https://example.test/adminapi",
)
if password_saved:
assert after_logout == "must-not-reach-disk"
else:
assert after_logout is None
store.clear_account()
assert store.load_password(account="doctor", scope="https://example.test/adminapi") is None
class _MemoryKeyring:
@@ -267,6 +289,33 @@ def test_token_store_prefers_available_keyring(tmp_path: Path) -> None:
assert json.loads(path.read_text(encoding="utf-8")) == {"account": "doctor"}
def test_token_store_keeps_login_password_in_scoped_keyring_only(tmp_path: Path) -> None:
backend = _MemoryKeyring()
path = tmp_path / "credentials.json"
store = TokenStore(path, keyring_backend=backend)
scope = "https://example.test/adminapi/"
assert store.save_password("secret-value", account="doctor", scope=scope)
assert (
store.load_password(account="doctor", scope="https://example.test/adminapi")
== "secret-value"
)
assert store.load_password(account="doctor", scope="https://other.test/adminapi") is None
assert "secret-value" not in path.read_text(encoding="utf-8")
assert json.loads(path.read_text(encoding="utf-8")) == {
"account": "doctor",
"scope": "https://example.test/adminapi",
}
next_scope = "https://next.test/adminapi"
assert store.save_password("next-secret", account="doctor", scope=next_scope)
assert store.load_password(account="doctor", scope=scope) is None
assert store.load_password(account="doctor", scope=next_scope) == "next-secret"
store.clear_password(account="doctor", scope=next_scope)
assert store.load_password(account="doctor", scope=next_scope) is None
def test_token_store_scopes_automatic_restore_and_forgets_account(tmp_path: Path) -> None:
"""Automatic restore never returns a token issued for another API base."""
+75 -2
View File
@@ -10,13 +10,20 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import NOTE_LIMIT, ReceptionPage
from doctor_workstation.ui.pages.reception import (
NOTE_LIMIT,
QueueRow,
ReceptionPage,
_is_image_attachment,
)
from doctor_workstation.ui.widgets import StatusBadge
@pytest.fixture(scope="module")
@@ -50,6 +57,71 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(reception_module, "run_async", run_immediately)
@pytest.mark.parametrize(
("path", "expected"),
[
("https://cdn.test/tongue.JPG?token=1", True),
("https://cdn.test/report.webp", True),
("https://cdn.test/report.pdf", False),
],
)
def test_note_attachment_preview_type_is_extension_aware(path: str, expected: bool) -> None:
assert _is_image_attachment(path) is expected
def test_note_attachments_offer_image_preview_and_file_open(
application: QApplication,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
page._render_notes(
[
{
"id": 1,
"note_date": "2026-08-12",
"tongue_images": ["https://cdn.test/tongue.jpg"],
"report_files": [
"https://cdn.test/check.png",
"https://cdn.test/check.pdf",
],
}
]
)
labels = [button.text() for button in page.notes_container.findChildren(QPushButton)]
assert labels.count("预览") == 2
assert labels.count("打开") == 1
page.close()
application.processEvents()
def test_queue_status_badge_is_not_clipped_in_narrow_panel(
application: QApplication,
) -> None:
row = QueueRow(
{
"patient_name": "张蒙",
"status": 1,
"status_desc": "待接诊",
"appointment_time": "12:45:00",
"gender": 1,
"age": 36,
"assistant_name": "苏亚梅",
}
)
row.setFixedWidth(280)
row.show()
application.processEvents()
badge = row.findChild(StatusBadge)
assert badge is not None
assert badge.height() >= 24
assert badge.width() >= 54
assert badge.geometry().right() < row.width()
row.close()
application.processEvents()
def _detail(
appointment_id: int,
*,
@@ -361,6 +433,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
"patient_id": 141,
"diagnosis_id": 241,
"patient_name": "视频患者",
"mode": "im",
"record": detail["appointment"],
}
]
+70 -5
View File
@@ -4,7 +4,7 @@ from types import SimpleNamespace
from typing import Any
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QMessageBox
from doctor_workstation import app as app_module
from doctor_workstation.app import ApplicationController
@@ -232,11 +232,28 @@ def test_real_demo_login_reaches_success_without_widget_adapter(
application.processEvents()
def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
def test_remembered_password_survives_a_new_login_window(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings_path = tmp_path / "remember-account.ini"
settings_path = tmp_path / "remember-password.ini"
secrets: dict[tuple[str, str], str] = {}
def load_password(*, account: str, scope: str) -> str | None:
return secrets.get((account, scope.rstrip("/")))
def save_password(password: str, *, account: str, scope: str) -> bool:
secrets[(account, scope.rstrip("/"))] = password
return True
def clear_password(*, account: str, scope: str) -> None:
secrets.pop((account, scope.rstrip("/")), None)
credentials = SimpleNamespace(
load_password=load_password,
save_password=save_password,
clear_password=clear_password,
)
config = SimpleNamespace(
api_base_url="",
api_base_url="https://example.test/adminapi",
request_timeout=30,
verify_ssl=True,
demo_mode=False,
@@ -246,8 +263,9 @@ def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
object(),
config=config,
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
credential_store=credentials,
)
first._on_login_success({}, "admin", True)
first._on_login_success({}, "admin", True, "secret-value")
first.close()
application.processEvents()
@@ -255,12 +273,28 @@ def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
object(),
config=config,
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
credential_store=credentials,
)
assert restored.account_edit.text() == "admin"
assert restored.password_edit.text() == "secret-value"
assert restored.remember_check.text() == "记住密码"
assert restored.remember_check.isChecked()
restored._on_login_success({}, "admin", False, "secret-value")
restored.close()
application.processEvents()
forgotten = LoginWindow(
object(),
config=config,
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
credential_store=credentials,
)
assert forgotten.password_edit.text() == ""
assert not forgotten.remember_check.isChecked()
forgotten.close()
application.processEvents()
def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
tmp_path: Any,
@@ -386,6 +420,37 @@ def test_certificate_error_explains_self_signed_server_setting() -> None:
assert "服务器设置" in message
def test_qt_standard_dialog_buttons_are_localized_to_chinese() -> None:
application = QApplication.instance() or QApplication([])
app_module._install_chinese_translations(application)
question = QMessageBox()
question.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
)
assert question.button(QMessageBox.StandardButton.Yes).text() == ""
assert question.button(QMessageBox.StandardButton.Cancel).text() == "取消"
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Save
| QDialogButtonBox.StandardButton.Close
)
assert buttons.button(QDialogButtonBox.StandardButton.Ok).text() == "确定"
assert buttons.button(QDialogButtonBox.StandardButton.Save).text() == "保存"
assert buttons.button(QDialogButtonBox.StandardButton.Close).text() == "关闭"
def test_friendly_error_hides_english_technical_messages() -> None:
message = friendly_error(
TypeError("invoke() takes 2 positional arguments but 3 were given")
)
assert message == "程序执行失败,请重试;若问题持续出现,请联系管理员。"
assert friendly_error(RuntimeError("API response envelope must be an object")) == (
"服务器返回的数据格式不正确,请联系管理员检查接口。"
)
def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "certificate-error.ini"), QSettings.Format.IniFormat)
+51
View File
@@ -274,6 +274,57 @@ def test_failed_start_prevents_bind_and_end_writes() -> None:
assert events == ["start"]
def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() -> None:
events: list[tuple[object, ...]] = []
class Repository:
def start_call(self, diagnosis_id: int, *, call_type: int) -> None:
events.append(("start", diagnosis_id, call_type))
def upload_material_bytes(
self,
content: bytes,
filename: str,
material_type: str,
cid: int = 0,
) -> str:
events.append(("upload", content, filename, material_type, cid))
return "/uploads/image/callshot-123.jpg"
def add_doctor_note(
self,
diagnosis_id: int,
content: str,
tongue_images: list[str],
) -> None:
events.append(("note", diagnosis_id, content, tongue_images))
def end_call(self, diagnosis_id: int) -> None:
events.append(("end", diagnosis_id))
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
lifecycle.start()
screenshot = lifecycle.save_screenshot(b"jpeg-frame", "callshot-123.jpg")
lifecycle.end("test")
assert screenshot.result(timeout=2) == "/uploads/image/callshot-123.jpg"
assert lifecycle.wait(1) is True
assert events == [
("start", 123, 2),
("upload", b"jpeg-frame", "callshot-123.jpg", "image", 0),
("note", 123, "", ["/uploads/image/callshot-123.jpg"]),
("end", 123),
]
def test_https_document_policy_is_exact_and_origin_scoped() -> None:
policy = TrustedDocumentPolicy.from_url(
"https://RTC.Example.com/doctor-call/index.html?tenant=a#boot",
-1
View File
@@ -1 +0,0 @@
:root{font-family:Inter,PingFang SC,Microsoft YaHei,system-ui,sans-serif;color:#f7f8fa;background:#0b0f14;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}html,body,#app{width:100%;height:100%;margin:0;overflow:hidden}button,input{font:inherit}.call-stage{position:relative;width:100%;height:100%;min-height:420px;overflow:hidden;background:radial-gradient(circle at 50% 35%,rgba(39,74,83,.22),transparent 38%),#0b0f14}.call-kit,.call-stage :is(.TUICallKit-desktop,.TUICallKit-mobile,#tuicallkit-id){width:100%!important;height:100%!important;max-width:none!important;max-height:none!important}.status-card{position:absolute;inset:50% auto auto 50%;display:grid;grid-template-columns:12px minmax(0,1fr);gap:18px;width:min(520px,calc(100% - 48px));padding:30px 32px;transform:translate(-50%,-50%);border:1px solid rgba(255,255,255,.09);border-radius:20px;background:#131920e6;box-shadow:0 24px 70px #00000052;backdrop-filter:blur(18px)}.eyebrow{margin:0 0 12px;color:#8f9ba8;font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase}.status-card h1{margin:0;font-size:clamp(22px,3.2vw,34px);font-weight:600;line-height:1.25}.status-hint{margin:14px 0 0;color:#9aa5b1;font-size:14px}.status-dot{width:10px;height:10px;margin-top:5px;border-radius:50%;background:#77818c;box-shadow:0 0 0 5px #77818c1f}.status-dot--starting,.status-dot--live{background:#52c99a;box-shadow:0 0 0 5px #52c99a24}.status-dot--error{background:#f26d6d;box-shadow:0 0 0 5px #f26d6d24}.live-status{position:absolute;z-index:20;top:18px;left:50%;display:flex;align-items:center;gap:10px;padding:9px 14px;transform:translate(-50%);border:1px solid rgba(255,255,255,.1);border-radius:999px;background:#0b0f14c2;color:#e8edf2;font-size:13px;backdrop-filter:blur(14px)}.live-status .status-dot{width:7px;height:7px;margin:0;box-shadow:none}
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
+3 -3
View File
@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="color-scheme" content="light" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-le5ZH3pL.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BuO_uDya.css">
<script type="module" crossorigin src="./assets/index-DwSVWep6.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-qOBmgxQV.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="color-scheme" content="light" />
<title>视频面诊</title>
</head>
<body>
+8
View File
@@ -8,7 +8,9 @@
"name": "doctor-video-companion",
"version": "0.1.0",
"dependencies": {
"@tencentcloud/lite-chat": "1.6.18",
"@trtc/calls-uikit-vue": "4.4.6",
"tim-upload-plugin": "1.4.3",
"vue": "3.5.13"
},
"devDependencies": {
@@ -1432,6 +1434,12 @@
"node": ">=0.10.0"
}
},
"node_modules/tim-upload-plugin": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/tim-upload-plugin/-/tim-upload-plugin-1.4.3.tgz",
"integrity": "sha512-3ZmbA36dr3eG9YGDon9MLBUtbNawYWkL+TBa+VS0Uviguc7PlVSOIVRG2C4irXX16slDT2Kj+HAZapp+Xqp2xg==",
"license": "ISC"
},
"node_modules/trtc-cloud-js-sdk": {
"version": "2.10.19",
"resolved": "https://registry.npmjs.org/trtc-cloud-js-sdk/-/trtc-cloud-js-sdk-2.10.19.tgz",
+2
View File
@@ -9,7 +9,9 @@
"preview": "vite preview --port 4173"
},
"dependencies": {
"@tencentcloud/lite-chat": "1.6.18",
"@trtc/calls-uikit-vue": "4.4.6",
"tim-upload-plugin": "1.4.3",
"vue": "3.5.13"
},
"devDependencies": {
+247 -17
View File
@@ -1,33 +1,263 @@
<script setup lang="ts">
import { TUICallKit } from '@trtc/calls-uikit-vue'
import { computed, nextTick, ref, watch } from 'vue'
import type { Ref } from 'vue'
defineProps<{
interface ChatMessage {
id: string
mine: boolean
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
text: string
url: string
name: string
time: string
}
const props = defineProps<{
phase: Readonly<Ref<string>>
statusText: Readonly<Ref<string>>
patientName: Readonly<Ref<string>>
mode: Readonly<Ref<string>>
messages: Readonly<Ref<ChatMessage[]>>
chatReady: Readonly<Ref<boolean>>
chatBusy: Readonly<Ref<boolean>>
notice: Readonly<Ref<string>>
hasMoreMessages: Readonly<Ref<boolean>>
onSendText: (text: string) => Promise<void>
onSendAttachment: (file: File) => Promise<void>
onLoadMore: () => Promise<void>
onReconnectChat: () => Promise<void>
onStartVideo: () => Promise<void>
onHangup: () => Promise<void>
onSaveScreenshot: (dataUrl: string) => Promise<void>
}>()
const draft = ref('')
const actionBusy = ref(false)
const localError = ref('')
const messageList = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const isChat = computed(() => props.mode.value === 'chat')
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
const videoVisible = computed(() => !isChat.value || isCalling.value)
const canCapture = computed(() => props.phase.value === 'connected')
watch(
() => props.messages.value.length,
async () => {
await nextTick()
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
},
)
async function runAction(action: () => Promise<void>): Promise<void> {
if (actionBusy.value) return
actionBusy.value = true
localError.value = ''
try {
await action()
} catch (error) {
localError.value = error instanceof Error ? error.message : '操作失败,请稍后重试'
} finally {
actionBusy.value = false
}
}
async function sendText(): Promise<void> {
const content = draft.value.trim()
if (!content) return
await runAction(async () => {
await props.onSendText(content)
draft.value = ''
})
}
async function selectAttachment(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
await runAction(() => props.onSendAttachment(file))
}
function findRemoteVideo(): HTMLVideoElement | null {
const videos = Array.from(document.querySelectorAll('video'))
.filter((video) => video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0)
if (!videos.length) return null
return videos.sort((left, right) => {
const leftArea = left.getBoundingClientRect().width * left.getBoundingClientRect().height
const rightArea = right.getBoundingClientRect().width * right.getBoundingClientRect().height
return rightArea - leftArea
})[0]
}
async function captureScreenshot(): Promise<void> {
await runAction(async () => {
const video = findRemoteVideo()
if (!video) throw new Error('尚未检测到患者视频画面')
const scale = Math.min(1, 1920 / video.videoWidth)
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(video.videoWidth * scale))
canvas.height = Math.max(1, Math.round(video.videoHeight * scale))
const context = canvas.getContext('2d')
if (!context) throw new Error('无法创建截图画布')
context.drawImage(video, 0, 0, canvas.width, canvas.height)
await props.onSaveScreenshot(canvas.toDataURL('image/jpeg', 0.9))
})
}
</script>
<template>
<main class="call-stage">
<TUICallKit
class="call-kit"
:allowed-minimized="false"
:allowed-full-screen="true"
/>
<main class="consultation-shell" :class="{ 'consultation-shell--video-only': !isChat }">
<section v-if="isChat" class="chat-panel">
<header class="chat-header">
<div class="patient-avatar" aria-hidden="true">{{ patientName.value.slice(0, 1) }}</div>
<div class="chat-heading">
<h1>{{ patientName.value }}</h1>
<p>
<span class="connection-dot" :class="{ 'connection-dot--online': chatReady.value }" />
{{ chatReady.value ? 'IM 已连接' : statusText.value }}
</p>
</div>
<button
v-if="!chatReady.value"
class="secondary-action"
type="button"
:disabled="actionBusy"
@click="runAction(onReconnectChat)"
>
重新连接 IM
</button>
<button
class="primary-action"
type="button"
:disabled="actionBusy || isCalling || !chatReady.value"
@click="runAction(onStartVideo)"
>
<span aria-hidden="true"></span>
{{ isCalling ? '视频通话中' : '发起视频' }}
</button>
</header>
<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>
<h1>{{ statusText.value }}</h1>
<p v-if="phase.value === 'ready'" class="status-hint">通话凭证仅由业务后端签发</p>
<div ref="messageList" class="message-list" aria-live="polite">
<button
v-if="hasMoreMessages.value"
class="load-more"
type="button"
:disabled="chatBusy.value"
@click="runAction(onLoadMore)"
>
{{ chatBusy.value ? '正在读取…' : '查看更早消息' }}
</button>
<div v-if="!messages.value.length && !chatBusy.value" class="empty-chat">
<div class="empty-chat__icon" aria-hidden="true">IM</div>
<h2>开始问诊沟通</h2>
<p>消息会通过腾讯云 IM 实时发送给患者</p>
</div>
<article
v-for="message in messages.value"
:key="message.id"
class="message-row"
:class="{ 'message-row--mine': message.mine }"
>
<div class="message-meta">{{ message.mine ? '我' : patientName.value }} · {{ message.time }}</div>
<div class="message-bubble">
<p v-if="message.type === 'text'">{{ message.text }}</p>
<img
v-else-if="message.type === 'image' && message.url"
class="message-image"
:src="message.url"
alt="问诊图片"
>
<a
v-else-if="message.type === 'file' && message.url"
class="message-file"
:href="message.url"
target="_blank"
rel="noreferrer"
>
<span aria-hidden="true"></span>{{ message.name || '查看文件' }}
</a>
<audio v-else-if="message.type === 'audio' && message.url" :src="message.url" controls />
<video v-else-if="message.type === 'video' && message.url" class="message-video" :src="message.url" controls />
<p v-else>{{ message.text }}</p>
</div>
</article>
</div>
<footer class="composer">
<div v-if="localError || notice.value" class="inline-notice" :class="{ 'inline-notice--error': localError }">
{{ localError || notice.value }}
</div>
<div class="composer-toolbar">
<button type="button" title="发送图片或文件" :disabled="!chatReady.value || actionBusy" @click="fileInput?.click()">
图片/文件
</button>
<input ref="fileInput" type="file" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" hidden @change="selectAttachment">
<span>Enter 发送Shift + Enter 换行</span>
</div>
<div class="composer-row">
<textarea
v-model="draft"
rows="3"
maxlength="3000"
placeholder="输入问诊消息…"
:disabled="!chatReady.value"
@keydown.enter.exact.prevent="sendText"
/>
<button
class="send-button"
type="button"
:disabled="!chatReady.value || !draft.trim() || actionBusy"
@click="sendText"
>
发送
</button>
</div>
</footer>
</section>
<div v-else class="live-status" role="status">
<span class="status-dot status-dot--live" aria-hidden="true" />
{{ statusText.value }}
</div>
<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="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>
<div v-if="isCalling" class="video-actions">
<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>
</section>
</main>
</template>
+18
View File
@@ -1,5 +1,10 @@
/// <reference types="vite/client" />
declare module 'tim-upload-plugin' {
const plugin: unknown
export default plugin
}
interface DoctorCallConfig {
SDKAppID?: number | string
sdkAppId?: number | string
@@ -9,6 +14,8 @@ interface DoctorCallConfig {
targetUserId?: string
patientUserId?: string
diagnosisId: number | string
patientName?: string
mode?: 'chat' | 'video'
}
interface DoctorCallApi {
@@ -16,12 +23,23 @@ interface DoctorCallApi {
hangup(): Promise<void>
}
interface DoctorConsultationApi {
open(config: DoctorCallConfig): Promise<void>
close(): Promise<void>
startVideo(): Promise<void>
hangup(): Promise<void>
hostCallReady(ok: boolean, message?: string): void
screenshotResult(ok: boolean, message: string): void
}
interface QtVideoBridge {
notify?: (payload: string) => void
saveScreenshot?: (dataUrl: string) => void
}
interface Window {
doctorCall: DoctorCallApi
doctorConsultation: DoctorConsultationApi
qtVideoBridge?: QtVideoBridge
qt?: { webChannelTransport?: unknown }
QWebChannel?: new (
+470 -92
View File
@@ -1,3 +1,5 @@
import TencentCloudChat from '@tencentcloud/lite-chat'
import TIMUploadPlugin from 'tim-upload-plugin'
import { createApp, nextTick, readonly, ref } from 'vue'
import {
NAME,
@@ -11,6 +13,8 @@ import App from './App.vue'
import './style.css'
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
type CompanionMode = 'chat' | 'video'
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
interface NormalizedCallConfig {
SDKAppID: number
@@ -18,11 +22,23 @@ interface NormalizedCallConfig {
userSig: string
targetUserId: string
diagnosisId: number | string
patientName: string
mode: CompanionMode
}
interface UiChatMessage {
id: string
mine: boolean
type: ChatMessageType
text: string
url: string
name: string
time: string
}
interface BridgeMessage {
source: 'doctor-call'
event: 'ready' | 'status' | 'room' | 'hangup' | 'error'
event: 'ready' | 'call-start-request' | 'status' | 'room' | 'hangup' | 'error'
diagnosisId?: number | string
status?: string
roomId?: string
@@ -30,11 +46,25 @@ interface BridgeMessage {
}
const phase = ref<CallPhase>('ready')
const statusText = ref('等待桌面端发起视频面诊')
const statusText = ref('正在连接问诊服务')
const patientName = ref('患者')
const mode = ref<CompanionMode>('video')
const messages = ref<UiChatMessage[]>([])
const chatReady = ref(false)
const chatBusy = ref(false)
const notice = ref('')
const hasMoreMessages = ref(false)
let activeConfig: NormalizedCallConfig | null = null
let endNotified = false
let chat: any = null
let chatReadyPromise: Promise<void> | null = null
let resolveChatReady: (() => void) | null = null
let rejectChatReady: ((reason?: unknown) => void) | null = null
let nextReqMessageID = ''
let endNotified = true
let starting = false
let emittedRoomId = ''
let resolveHostCallReady: ((value: boolean) => void) | null = null
function initializeQtWebChannel(): void {
const transport = window.qt?.webChannelTransport
@@ -48,31 +78,28 @@ function initializeQtWebChannel(): void {
emit({ source: 'doctor-call', event: 'ready' })
})
} catch {
console.warn('[doctor-call] Qt WebChannel 初始化失败,将使用 postMessage 通知')
console.warn('[doctor-consultation] Qt 通信通道初始化失败')
}
}
function postToHost(message: BridgeMessage): boolean {
let delivered = false
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, '*')
delivered = true
}
} catch {
// Cross-origin host may reject access; opener remains available as a fallback.
// The Qt bridge remains the primary transport.
}
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, '*')
delivered = true
}
} catch {
// The console fallback below is intentionally non-sensitive.
// A detached opener is harmless.
}
return delivered
}
@@ -83,62 +110,358 @@ function emit(message: BridgeMessage): void {
bridge.notify(JSON.stringify(message))
return
} catch {
// A detached WebChannel object is equivalent to an unavailable bridge.
// Fall through to browser messaging when the bridge has detached.
}
}
if (!postToHost(message)) {
console.info('[doctor-call]', message.event, message.status ?? message.message ?? '')
console.info('[doctor-consultation]', message.event, message.status ?? message.message ?? '')
}
}
function cleanString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${field} 不能为空`)
}
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
return value.trim()
}
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
if (!config || typeof config !== 'object') throw new Error('通话配置无效')
const rawSdkAppId = config.SDKAppID ?? config.sdkAppId
const SDKAppID = Number(rawSdkAppId)
if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) {
throw new Error('SDKAppID 必须是正整数')
}
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) throw new Error('SDKAppID 必须是正整数')
const diagnosisId = config.diagnosisId
if (
diagnosisId === undefined
|| diagnosisId === null
|| (typeof diagnosisId === 'string' && diagnosisId.trim() === '')
) {
throw new Error('diagnosisId 不能为空')
if (diagnosisId === undefined || diagnosisId === null || String(diagnosisId).trim() === '') {
throw new Error('诊单ID不能为空')
}
return {
SDKAppID,
userID: cleanString(config.userID ?? config.userId, 'userID'),
userSig: cleanString(config.userSig, 'userSig'),
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, 'targetUserId'),
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()
: '患者',
mode: config.mode === 'chat' ? 'chat' : 'video',
}
}
function safeErrorMessage(error: unknown): string {
let message = error instanceof Error ? error.message : '视频通话发生未知错误'
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[REDACTED]')
return message
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[REDACTED]')
function safeErrorMessage(error: unknown, fallback = '问诊服务发生未知错误'): string {
let message = error instanceof Error ? error.message : fallback
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[已隐藏]')
message = message
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[已隐藏]')
.slice(0, 400)
if (/user\s+not\s+logged\s+in|not\s+logged\s+in/i.test(message)) {
return 'IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话'
}
if (/sdk.*not.*ready|not.*ready/i.test(message)) {
return 'IM 连接尚未就绪,请稍后重试'
}
if (/user\s*sig.*expired|signature.*expired/i.test(message)) {
return 'IM 登录凭证已过期,请关闭窗口后重新打开'
}
return message || fallback
}
function timeText(raw: unknown): string {
const seconds = Number(raw)
const date = Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000) : new Date()
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function messageUrl(payload: any): string {
if (!payload || typeof payload !== 'object') return ''
if (typeof payload.url === 'string') return payload.url
if (typeof payload.fileUrl === 'string') return payload.fileUrl
if (typeof payload.videoUrl === 'string') return payload.videoUrl
if (typeof payload.remoteAudioUrl === 'string') return payload.remoteAudioUrl
const images = Array.isArray(payload.imageInfoArray) ? payload.imageInfoArray : []
const image = images.find((item: any) => item?.type === 0) ?? images.at(-1) ?? images[0]
return typeof image?.url === 'string' ? image.url : ''
}
function parseJsonObject(value: unknown): Record<string, any> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, any>
}
if (typeof value !== 'string' || !value.trim()) return null
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, any>
: null
} catch {
return null
}
}
function customMessageContent(payload: any): { hidden: boolean; text: string } {
const outer = parseJsonObject(payload?.data) ?? parseJsonObject(payload) ?? {}
const inner = parseJsonObject(outer.data) ?? {}
const businessID = outer.businessID ?? outer.businessId ?? inner.businessID ?? inner.businessId
if (businessID === 1 || String(businessID).toLowerCase().includes('call')) {
return { hidden: true, text: '' }
}
const command = String(outer.command ?? outer.cmd ?? inner.command ?? inner.cmd ?? '').toLowerCase()
if (command.includes('call') || command.includes('invite')) {
return { hidden: true, text: '' }
}
const text = [
payload?.description,
outer.text,
outer.content,
outer.message,
outer.tips,
inner.text,
inner.content,
inner.message,
].find((value) => typeof value === 'string' && value.trim())
return { hidden: false, text: text ? String(text).trim() : '系统自定义消息' }
}
function normalizeMessage(raw: any): UiChatMessage | null {
const payload = raw?.payload ?? {}
const rawType = String(raw?.type ?? '')
let type: ChatMessageType = 'system'
let text = '系统消息'
if (rawType === TencentCloudChat.TYPES.MSG_TEXT) {
type = 'text'
text = String(payload.text ?? '')
} else if (rawType === TencentCloudChat.TYPES.MSG_IMAGE) {
type = 'image'
text = '[图片]'
} else if (rawType === TencentCloudChat.TYPES.MSG_FILE) {
type = 'file'
text = '[文件]'
} else if (rawType === TencentCloudChat.TYPES.MSG_AUDIO) {
type = 'audio'
text = '[语音]'
} else if (rawType === TencentCloudChat.TYPES.MSG_VIDEO) {
type = 'video'
text = '[视频]'
} else if (rawType === TencentCloudChat.TYPES.MSG_CUSTOM) {
const custom = customMessageContent(payload)
if (custom.hidden) return null
text = custom.text
} else if (rawType === TencentCloudChat.TYPES.MSG_FACE) {
text = '[表情消息]'
} else if (rawType === TencentCloudChat.TYPES.MSG_LOCATION) {
text = `[位置] ${String(payload.description ?? payload.name ?? '').trim()}`.trim()
} else if (rawType === TencentCloudChat.TYPES.MSG_MERGER) {
text = `[合并消息] ${String(payload.title ?? '').trim()}`.trim()
} else if (rawType === TencentCloudChat.TYPES.MSG_GRP_TIP) {
text = '[群组通知]'
} else if (rawType === TencentCloudChat.TYPES.MSG_GRP_SYS_NOTICE) {
text = '[群组系统通知]'
}
return {
id: String(raw?.ID ?? raw?.id ?? `${raw?.time ?? Date.now()}-${Math.random()}`),
mine: Boolean(raw?.flow === 'out' || raw?.from === activeConfig?.userID),
type,
text,
url: messageUrl(payload),
name: String(payload.fileName ?? payload.name ?? (type === 'file' ? '文件' : '')),
time: timeText(raw?.time),
}
}
function mergeMessages(rawList: any[], prepend = false): void {
const incoming = rawList
.map(normalizeMessage)
.filter((item): item is UiChatMessage => item !== null)
const combined = prepend ? [...incoming, ...messages.value] : [...messages.value, ...incoming]
const seen = new Set<string>()
messages.value = combined.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
function onMessageReceived(event: any): void {
if (!activeConfig) return
const expected = `C2C${activeConfig.targetUserId}`
const list = Array.isArray(event?.data) ? event.data : []
const matched = list.filter((item: any) => {
const conversationID = String(item?.conversationID ?? '')
return conversationID === expected || item?.from === activeConfig?.targetUserId
})
if (!matched.length) return
mergeMessages(matched)
void chat?.setMessageRead?.({ conversationID: expected })
}
function onSdkReady(): void {
chatReady.value = true
statusText.value = 'IM 已连接'
notice.value = ''
resolveChatReady?.()
resolveChatReady = null
rejectChatReady = null
}
function onSdkNotReady(event: any): void {
const wasReady = chatReady.value
chatReady.value = false
statusText.value = 'IM 连接已断开'
if (wasReady) {
notice.value = safeErrorMessage(event?.data?.message ?? event?.message, 'IM 连接已断开,请重新打开会话')
}
}
function onKickedOut(event: any): void {
chatReady.value = false
statusText.value = 'IM 已在其他窗口登录'
const type = String(event?.data?.type ?? event?.data ?? '')
notice.value = type.includes('userSigExpired')
? 'IM 登录凭证已过期,请关闭窗口后重新打开'
: '当前账号已在另一个 IM 窗口登录,请关闭其他患者 IM 窗口后重新打开本会话'
}
function onNetStateChange(event: any): void {
const state = String(event?.data?.state ?? event?.data ?? '').toUpperCase()
if (state.includes('DISCONNECTED')) {
statusText.value = 'IM 网络连接中断,正在自动恢复'
} else if (state.includes('CONNECTED') && chatReady.value) {
statusText.value = 'IM 已连接'
}
}
async function loginChat(): Promise<void> {
if (!activeConfig) throw new Error('问诊配置尚未准备好')
chatReady.value = false
statusText.value = '正在连接患者 IM'
chat = TencentCloudChat.create({ SDKAppID: activeConfig.SDKAppID })
chat.setLogLevel(2)
chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin })
chat.on(TencentCloudChat.EVENT.SDK_READY, onSdkReady)
chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady)
chat.on(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut)
chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange)
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived)
chatReadyPromise = new Promise<void>((resolve, reject) => {
resolveChatReady = resolve
rejectChatReady = reject
})
await chat.login({ userID: activeConfig.userID, userSig: activeConfig.userSig })
await Promise.race([
chatReadyPromise,
new Promise<void>((_, reject) => window.setTimeout(() => reject(new Error('IM 连接超时')), 15000)),
])
await loadMessages(false)
}
async function logoutChat(): Promise<void> {
if (!chat) return
try {
chat.off(TencentCloudChat.EVENT.SDK_READY, onSdkReady)
chat.off(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady)
chat.off(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut)
chat.off(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange)
chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived)
await chat.logout()
} catch {
// Closing the desktop window must not be blocked by a stale IM connection.
} finally {
chat = null
chatReady.value = false
chatReadyPromise = null
resolveChatReady = null
rejectChatReady = null
}
}
async function reconnectChat(): Promise<void> {
if (!activeConfig || activeConfig.mode !== 'chat') throw new Error('当前不是 IM 会话')
notice.value = ''
await logoutChat()
await loginChat()
}
async function loadMessages(prepend: boolean): Promise<void> {
if (!chat || !activeConfig || chatBusy.value) return
chatBusy.value = true
try {
const response = await chat.getMessageList({
conversationID: `C2C${activeConfig.targetUserId}`,
nextReqMessageID: prepend ? nextReqMessageID : '',
count: 30,
})
const data = response?.data ?? {}
const list = Array.isArray(data.messageList) ? data.messageList : []
nextReqMessageID = String(data.nextReqMessageID ?? '')
hasMoreMessages.value = !Boolean(data.isCompleted) && Boolean(nextReqMessageID)
mergeMessages(list, prepend)
await chat.setMessageRead({ conversationID: `C2C${activeConfig.targetUserId}` })
} catch (error) {
notice.value = `读取消息失败:${safeErrorMessage(error)}`
} finally {
chatBusy.value = false
}
}
async function sendText(text: string): Promise<void> {
if (
!chat
|| !activeConfig
|| !chatReady.value
|| !chat.isReady?.()
|| chat.getLoginUser?.() !== activeConfig.userID
) {
chatReady.value = false
statusText.value = 'IM 登录已失效'
throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话')
}
const content = text.trim()
if (!content) return
try {
const message = chat.createTextMessage({
to: activeConfig.targetUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { text: content },
})
const response = await chat.sendMessage(message)
mergeMessages([response?.data?.message ?? response?.data ?? message])
} catch (error) {
throw new Error(safeErrorMessage(error, '消息发送失败'))
}
}
async function sendAttachment(file: File): Promise<void> {
if (
!chat
|| !activeConfig
|| !chatReady.value
|| !chat.isReady?.()
|| chat.getLoginUser?.() !== activeConfig.userID
) {
chatReady.value = false
statusText.value = 'IM 登录已失效'
throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话')
}
if (file.size > 20 * 1024 * 1024) throw new Error('附件不能超过 20MB')
const base = {
to: activeConfig.targetUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { file },
}
const message = file.type.startsWith('image/')
? chat.createImageMessage(base)
: chat.createFileMessage(base)
try {
const response = await chat.sendMessage(message)
mergeMessages([response?.data?.message ?? response?.data ?? message])
} catch (error) {
throw new Error(safeErrorMessage(error, '附件发送失败'))
}
}
function notifyHangup(status = 'ended'): void {
if (endNotified) return
endNotified = true
phase.value = 'ended'
statusText.value = '视频面诊已结束'
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
emit({
source: 'doctor-call',
event: 'hangup',
@@ -158,12 +481,7 @@ function emitRoomId(): boolean {
const roomId = readRoomId()
if (!roomId || roomId === emittedRoomId) return Boolean(roomId)
emittedRoomId = roomId
emit({
source: 'doctor-call',
event: 'room',
diagnosisId: activeConfig?.diagnosisId,
roomId,
})
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig?.diagnosisId, roomId })
return true
}
@@ -179,10 +497,9 @@ function handleStatusChanged(payload: unknown): void {
? (payload as { newStatus?: unknown }).newStatus
: payload
const status = typeof value === 'string' ? value : 'unknown'
if (status === 'connected' || status.startsWith('calling-')) {
phase.value = 'connected'
statusText.value = '视频诊进行中'
statusText.value = '视频诊进行中'
void pollRoomId()
} else if (status === 'calling' || status.startsWith('dialing')) {
phase.value = 'dialing'
@@ -190,70 +507,71 @@ function handleStatusChanged(payload: unknown): void {
} else if (status === 'idle' && activeConfig && !starting) {
notifyHangup(status)
}
emit({
source: 'doctor-call',
event: 'status',
diagnosisId: activeConfig?.diagnosisId,
status,
})
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig?.diagnosisId, status })
}
TUICallKitAPI.setCallback({
statusChanged: handleStatusChanged,
afterCalling: () => notifyHangup('after-calling'),
})
TUICallKitAPI.setLanguage('zh-cn')
TUICallKitAPI.enableFloatWindow(false)
async function start(config: DoctorCallConfig): Promise<void> {
if (starting || (activeConfig && !endNotified)) {
throw new Error('已有视频通话正在进行')
}
function requestHostCallStart(): Promise<boolean> {
if (!activeConfig) return Promise.resolve(false)
if (!window.qtVideoBridge?.notify) return Promise.resolve(true)
return new Promise<boolean>((resolve) => {
const currentResolver = resolve
resolveHostCallReady = currentResolver
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId })
window.setTimeout(() => {
if (resolveHostCallReady !== currentResolver) return
resolveHostCallReady = null
resolve(false)
}, 15000)
})
}
function hostCallReady(ok: boolean, message = ''): void {
const resolver = resolveHostCallReady
resolveHostCallReady = null
if (message) notice.value = message
resolver?.(Boolean(ok))
}
async function startVideo(): Promise<void> {
if (!activeConfig) throw new Error('问诊配置尚未准备好')
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
starting = true
endNotified = false
phase.value = 'starting'
statusText.value = '正在初始化安全通话'
statusText.value = '正在创建安全视频通话'
notice.value = ''
try {
const normalized = normalizeConfig(config)
activeConfig = normalized
emittedRoomId = ''
const allowed = await requestHostCallStart()
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
await TUICallKitAPI.init({
SDKAppID: normalized.SDKAppID,
userID: normalized.userID,
userSig: normalized.userSig,
SDKAppID: activeConfig.SDKAppID,
userID: activeConfig.userID,
userSig: activeConfig.userSig,
...(chat ? { tim: chat, isFromChat: true } : {}),
})
await nextTick()
phase.value = 'dialing'
statusText.value = '正在呼叫患者'
emittedRoomId = ''
await TUICallKitAPI.calls({
userIDList: [normalized.targetUserId],
userIDList: [activeConfig.targetUserId],
type: TUICallType.VIDEO_CALL,
})
void pollRoomId()
emit({
source: 'doctor-call',
event: 'status',
diagnosisId: normalized.diagnosisId,
status: 'dialing',
})
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
} catch (error) {
const message = safeErrorMessage(error)
const message = safeErrorMessage(error, '无法发起视频通话')
phase.value = 'error'
statusText.value = message
emit({
source: 'doctor-call',
event: 'error',
diagnosisId: activeConfig?.diagnosisId,
message,
})
activeConfig = null
endNotified = true
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
throw new Error(message)
} finally {
starting = false
@@ -262,28 +580,88 @@ async function start(config: DoctorCallConfig): Promise<void> {
async function hangup(): Promise<void> {
if (!activeConfig || endNotified) return
try {
await TUICallKitAPI.hangup()
notifyHangup('local-hangup')
} catch (error) {
const message = safeErrorMessage(error)
emit({
source: 'doctor-call',
event: 'error',
diagnosisId: activeConfig.diagnosisId,
message,
})
const message = safeErrorMessage(error, '结束视频通话失败')
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
throw new Error(message)
}
}
window.doctorCall = { start, hangup }
async function saveScreenshot(dataUrl: string): Promise<void> {
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.saveScreenshot !== 'function') throw new Error('桌面端截图保存通道不可用')
bridge.saveScreenshot(dataUrl)
notice.value = '正在保存截图到患者舌像资料…'
}
function screenshotResult(ok: boolean, message: string): void {
notice.value = message || (ok ? '截图已保存到患者舌像资料' : '截图保存失败')
}
async function open(config: DoctorCallConfig): Promise<void> {
if (activeConfig) await close()
activeConfig = normalizeConfig(config)
mode.value = activeConfig.mode
patientName.value = activeConfig.patientName
messages.value = []
nextReqMessageID = ''
hasMoreMessages.value = false
endNotified = true
phase.value = 'ready'
notice.value = ''
if (activeConfig.mode === 'chat') {
try {
await loginChat()
} catch (error) {
const message = safeErrorMessage(error, 'IM 连接失败')
phase.value = 'error'
statusText.value = message
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
throw new Error(message)
}
} else {
await startVideo()
}
}
async function close(): Promise<void> {
if (!endNotified) await hangup()
await logoutChat()
activeConfig = null
phase.value = 'ended'
}
window.doctorConsultation = {
open,
close,
startVideo,
hangup,
hostCallReady,
screenshotResult,
}
window.doctorCall = { start: open, hangup }
initializeQtWebChannel()
createApp(App, {
phase: readonly(phase),
statusText: readonly(statusText),
patientName: readonly(patientName),
mode: readonly(mode),
messages: readonly(messages),
chatReady: readonly(chatReady),
chatBusy: readonly(chatBusy),
notice: readonly(notice),
hasMoreMessages: readonly(hasMoreMessages),
onSendText: sendText,
onSendAttachment: sendAttachment,
onLoadMore: () => loadMessages(true),
onReconnectChat: reconnectChat,
onStartVideo: startVideo,
onHangup: hangup,
onSaveScreenshot: saveScreenshot,
}).mount('#app')
emit({ source: 'doctor-call', event: 'ready' })
+251 -60
View File
@@ -1,14 +1,12 @@
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #f7f8fa;
background: #0b0f14;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
color: #132238;
background: #eef3fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
* { box-sizing: border-box; }
html,
body,
@@ -20,23 +18,222 @@ body,
}
button,
input {
font: inherit;
textarea,
input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: .55; }
.consultation-shell {
width: 100%;
height: 100%;
min-width: 760px;
min-height: 540px;
background: #eef3fb;
}
.call-stage {
.chat-panel {
display: grid;
grid-template-rows: 74px minmax(0, 1fr) auto;
width: 100%;
height: 100%;
background: #f7f9fd;
}
.chat-header {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 20px;
border-bottom: 1px solid #d7dfef;
background: rgba(255, 255, 255, .96);
}
.patient-avatar {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 14px;
color: #fff;
background: #5267df;
font-size: 18px;
font-weight: 700;
}
.chat-heading { min-width: 0; flex: 1; }
.chat-heading h1 { margin: 0; font-size: 18px; line-height: 1.4; }
.chat-heading p {
display: flex;
align-items: center;
gap: 7px;
margin: 3px 0 0;
color: #71809a;
font-size: 12px;
}
.connection-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #a3adbd;
}
.connection-dot--online { background: #24a77c; box-shadow: 0 0 0 3px rgba(36, 167, 124, .12); }
.primary-action,
.send-button,
.capture-button {
border: 1px solid #5166df;
border-radius: 9px;
color: #fff;
background: #5267df;
font-weight: 600;
}
.secondary-action {
padding: 9px 13px;
border: 1px solid #c8d2e7;
border-radius: 9px;
color: #43526b;
background: #fff;
font-weight: 600;
}
.secondary-action:hover { border-color: #7385e9; color: #4055ca; background: #f5f7ff; }
.primary-action { display: flex; gap: 8px; align-items: center; padding: 10px 16px; }
.primary-action:hover,
.send-button:hover,
.capture-button:hover { background: #4055ca; }
.message-list {
overflow-y: auto;
padding: 18px max(24px, calc((100% - 920px) / 2));
background:
radial-gradient(circle at 12% 16%, rgba(82, 103, 223, .055), transparent 25%),
#f3f6fb;
}
.load-more {
display: block;
margin: 0 auto 18px;
padding: 6px 12px;
border: 1px solid #d6deed;
border-radius: 999px;
color: #66758e;
background: #fff;
font-size: 12px;
}
.empty-chat {
display: grid;
justify-items: center;
margin-top: min(16vh, 110px);
color: #7c89a0;
text-align: center;
}
.empty-chat__icon {
display: grid;
place-items: center;
width: 62px;
height: 62px;
margin-bottom: 12px;
border: 1px solid #d2daeb;
border-radius: 20px;
color: #5267df;
background: #fff;
font-weight: 800;
}
.empty-chat h2 { margin: 0; color: #34425a; font-size: 17px; }
.empty-chat p { margin: 7px 0; font-size: 13px; }
.message-row {
display: flex;
flex-direction: column;
align-items: flex-start;
margin: 12px 0;
}
.message-row--mine { align-items: flex-end; }
.message-meta { margin: 0 8px 5px; color: #8995a9; font-size: 11px; }
.message-bubble {
max-width: min(72%, 620px);
padding: 10px 13px;
border: 1px solid #d8e0ed;
border-radius: 5px 15px 15px 15px;
background: #fff;
box-shadow: 0 4px 14px rgba(29, 47, 80, .05);
line-height: 1.6;
word-break: break-word;
}
.message-row--mine .message-bubble {
border-color: #5267df;
border-radius: 15px 5px 15px 15px;
color: #fff;
background: #5267df;
}
.message-bubble p { margin: 0; white-space: pre-wrap; }
.message-image,
.message-video { display: block; max-width: 360px; max-height: 280px; border-radius: 9px; }
.message-file { display: flex; align-items: center; gap: 8px; color: inherit; text-decoration: none; }
.message-bubble audio { max-width: 320px; }
.composer {
padding: 10px 18px 14px;
border-top: 1px solid #d7dfef;
background: #fff;
}
.composer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 30px;
color: #8a96a9;
font-size: 11px;
}
.composer-toolbar button {
padding: 5px 9px;
border: 0;
border-radius: 7px;
color: #5267df;
background: #eef1ff;
font-size: 12px;
}
.composer-row { display: grid; grid-template-columns: minmax(0, 1fr) 86px; gap: 12px; }
.composer textarea {
width: 100%;
min-height: 68px;
max-height: 150px;
padding: 10px 12px;
resize: vertical;
border: 1px solid #ced8ea;
border-radius: 10px;
outline: none;
color: #15233a;
background: #fbfcff;
line-height: 1.5;
}
.composer textarea:focus { border-color: #6678ed; box-shadow: 0 0 0 3px rgba(82, 103, 223, .11); }
.send-button { align-self: end; height: 40px; }
.inline-notice {
margin-bottom: 7px;
color: #3f6f62;
font-size: 12px;
}
.inline-notice--error { color: #c14455; }
.video-layer {
position: relative;
width: 100%;
height: 100%;
min-height: 420px;
overflow: hidden;
color: #f7f8fa;
background:
radial-gradient(circle at 50% 35%, rgba(39, 74, 83, 0.22), transparent 38%),
#0b0f14;
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
#090d14;
}
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
.call-kit,
.call-stage :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
width: 100% !important;
height: 100% !important;
max-width: none !important;
@@ -52,55 +249,25 @@ input {
width: min(520px, calc(100% - 48px));
padding: 30px 32px;
transform: translate(-50%, -50%);
border: 1px solid rgba(255, 255, 255, 0.09);
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 20px;
background: rgba(19, 25, 32, 0.9);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(18px);
background: rgba(19, 25, 32, .92);
box-shadow: 0 24px 70px rgba(0, 0, 0, .32);
}
.eyebrow {
margin: 0 0 12px;
color: #8f9ba8;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.status-card h1 {
margin: 0;
font-size: clamp(22px, 3.2vw, 34px);
font-weight: 600;
line-height: 1.25;
}
.status-hint {
margin: 14px 0 0;
color: #9aa5b1;
font-size: 14px;
}
.eyebrow { margin: 0 0 12px; color: #a3afbf; font-size: 12px; font-weight: 700; letter-spacing: .12em; }
.status-card h2 { margin: 0; font-size: clamp(22px, 3.2vw, 34px); font-weight: 600; line-height: 1.25; }
.status-hint { margin: 14px 0 0; color: #9aa5b1; font-size: 14px; }
.status-dot {
width: 10px;
height: 10px;
margin-top: 5px;
border-radius: 50%;
background: #77818c;
box-shadow: 0 0 0 5px rgba(119, 129, 140, 0.12);
box-shadow: 0 0 0 5px rgba(119, 129, 140, .12);
}
.status-dot--starting,
.status-dot--live {
background: #52c99a;
box-shadow: 0 0 0 5px rgba(82, 201, 154, 0.14);
}
.status-dot--error {
background: #f26d6d;
box-shadow: 0 0 0 5px rgba(242, 109, 109, 0.14);
}
.status-dot--live { background: #52c99a; box-shadow: 0 0 0 5px rgba(82, 201, 154, .14); }
.status-dot--error { background: #f26d6d; box-shadow: 0 0 0 5px rgba(242, 109, 109, .14); }
.live-status {
position: absolute;
z-index: 20;
@@ -111,17 +278,41 @@ input {
gap: 10px;
padding: 9px 14px;
transform: translateX(-50%);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 999px;
background: rgba(11, 15, 20, 0.76);
color: #e8edf2;
background: rgba(11, 15, 20, .8);
font-size: 13px;
backdrop-filter: blur(14px);
}
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
.live-status .status-dot {
width: 7px;
height: 7px;
margin: 0;
box-shadow: none;
.video-actions {
position: absolute;
z-index: 40;
right: 22px;
bottom: 24px;
display: flex;
gap: 10px;
}
.video-actions button { padding: 10px 15px; border-radius: 10px; font-weight: 600; }
.hangup-button { border: 1px solid #b44755; color: #fff; background: rgba(161, 47, 61, .9); }
.hangup-button:hover { background: #be394d; }
.video-notice {
position: absolute;
z-index: 40;
left: 22px;
bottom: 26px;
max-width: calc(100% - 420px);
padding: 9px 12px;
border: 1px solid rgba(82, 201, 154, .35);
border-radius: 9px;
color: #d9f8eb;
background: rgba(20, 78, 62, .84);
font-size: 13px;
}
.video-notice--error { border-color: rgba(242, 109, 109, .4); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
@media (max-width: 820px) {
.consultation-shell { min-width: 620px; }
.message-list { padding-inline: 18px; }
.message-bubble { max-width: 82%; }
}
File diff suppressed because it is too large Load Diff
@@ -957,8 +957,13 @@ class ConversionLogic
}
/**
* Aggregate add-contact events by WeCom user once, then project that raw
* snapshot to departments, members and virtual buckets in PHP.
* 区间有效加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径(对齐企微客户列表,而非原始回调条数):
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中。
*
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds null means all active/unbound WeCom users
@@ -981,7 +986,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans', [
$baseKey = self::requestRowsCacheKey('fans-effective-v2', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1019,11 +1024,30 @@ class ConversionLogic
}
}
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
->whereRaw(
'EXISTS (SELECT 1 FROM `' . $eventTable . '` audit_e'
. ' WHERE audit_e.user_id = e.user_id'
. ' AND audit_e.external_userid = e.external_userid'
. ' AND audit_e.change_type = ?'
. ' AND audit_e.event_time >= e.event_time'
. ' AND audit_e.event_time <= ?)',
['msg_audit_approved', $endTimestamp]
)
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
. ' WHERE del_e.user_id = e.user_id'
. ' AND del_e.external_userid = e.external_userid'
. ' AND del_e.change_type = ?'
. ' AND del_e.event_time >= e.event_time'
. ' AND del_e.event_time <= ?)',
['del_external_contact', $endTimestamp]
)
->fieldRaw('e.user_id, COUNT(DISTINCT e.external_userid) AS add_fans_count')
->group('e.user_id');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
@@ -1604,6 +1628,8 @@ class ConversionLogic
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
// 面诊接诊率:面诊 / 挂号
$entity['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
$entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
@@ -1837,6 +1863,7 @@ class ConversionLogic
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$node['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount);
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
$node['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
@@ -2258,6 +2285,7 @@ class ConversionLogic
'interview_rate' => 0.0,
'receive_rate' => 0.0,
'interview_receive_rate' => 0.0,
'interview_paid_rate' => 0.0,
'open_receive_rate' => 0.0,
'avg_unit_price' => 0.0,
'cash_cost' => 0.0,
@@ -2324,6 +2352,7 @@ class ConversionLogic
'member'
),
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
'interview_paid_rate' => self::percent($interviewCount, $paidAppointmentCount),
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
'cash_cost' => self::safeDivideMoney($effectiveAccountCost, $addFansCount),
@@ -2500,6 +2529,7 @@ class ConversionLogic
$dimension
);
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
$summary['interview_paid_rate'] = self::percent($summary['interview_count'], $summary['paid_appointment_count']);
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
$summary['cash_cost'] = self::safeDivideMoney($summary['account_cost'], $summary['add_fans_count']);