更新
This commit is contained in:
@@ -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
@@ -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 优先存入系统凭据库,无法使用时仅回退到用户配置目录中的受限文件。
|
||||
|
||||
## 视频伴随页
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 +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}
|
||||
+632
File diff suppressed because one or more lines are too long
-532
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+3
-3
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Vendored
+18
@@ -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
@@ -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' })
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user