更新bug

This commit is contained in:
Your Name
2026-08-20 17:47:14 +08:00
parent 35f91ee37a
commit 5794f60c5d
67 changed files with 9257 additions and 1287 deletions
+34 -33
View File
@@ -1,33 +1,34 @@
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
/.idea
/.codex-tasks
/.trellis
/.claude
/.agent
/.shared
/.cursor
/.codex
/.agents
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
/.codegraph
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
# 测试临时目录
app/.test-tmp-stream/
/.idea
/.codex-tasks
/.trellis
/.claude
/.agent
/.shared
/.cursor
/.codex
/.agents
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
/.codegraph
+59
View File
@@ -0,0 +1,59 @@
# 2026-08-20 — 医生工作站 AI 问诊对话框布局优化
## 改动
- 文件: `app/src/doctor_workstation/ui/dialogs/ai_consult.py`
- 模块: `AiConsultDialog` 右侧「智能分析」卡片 + 问诊对话卡片密度优化
- 新增 helpers: `_InsightItem`, `_split_insight_segments`, `_split_numbered_list`, `_split_plain_paragraph`, `_split_label_or_sentence`, `_escape_html`
- `_set_insights` 改成将稠密段落拆成 lead / list / plain 三类, 每类单独 QLabel 显示
- CSS 新增 `AiConsultInsightMarker` / `AiConsultInsightText` / `AiConsultInsightPlaceholder`
- 卡片 padding `10,8,10,8``14,12,14,14`; 卡间距 `8``10`
- 聊天气泡垂直 margin `4``6`, chat_layout spacing `10``12`
## 拆分策略
- 文本以 `1./2、/3)/4` 起头 → 视为完整编号列表
- 文本中嵌入 `…:1.…2.…` → 拆出序言 + 编号列表
- 短中文标签 `XXYY` 开头 → 视为 lead, 标签部分加粗
- 其余按 `。//;/` 分句 → 单独成行
## 测试
- 现有 `tests/test_ai_consult_ui.py` 通过 `widget.objectName() == "AiConsultRecordTitle"` 校验标题仍在, 未受影响
- 暂未运行 UI 测试 (开发环境未装 PySide6); 解析逻辑单独脚本验证通过
## 注意
- PySide6 QSS 对 `QLabel``line-height` 支持有限; 主要靠 item 间距 `8px``padding:1px 0` 提供节奏
- 如果后续接入 `AiConsultInsightText` 还嫌密集, 可再调 `body_host.setSpacing`
---
# 接诊台 AI 智能分析详情对话框 (`ReceptionAiAnalysisDialog`) 优化
用户截图(`clipboard-2026-08-20T08-42-40`...)反馈: 「问诊详情 → AI 智能分析」卡片里的诊断建议 / 风险评估 / 治疗建议三段文字太稠密, 不利于阅读。
## 关键差异
这次的目标文件和上次不同 — 用户截图里出现 "诊断建议 / 风险评估 / 治疗建议" 标题, 而 `ai_consult.py` 用的是 "血糖控制评估 / 并发症风险评估 / 用药合理性评估"。实际是 `app/src/doctor_workstation/ui/pages/reception.py` 中的 `_ReceptionAiAnalysisDialog` 类。
## 改动
- 文件: `app/src/doctor_workstation/ui/pages/reception.py`
- 新增 helpers (与 ai_consult.py 类似但导出名加 `_ai_` 前缀):
- `_ai_split_into_segments` / `_ai_split_numbered_list` / `_ai_split_plain_paragraph`
- `_ai_segments_to_html` / `_ai_narrative_structured_html`
- `_ai_split_label_or_sentence` / `_ai_escape_html`
- 正则: `_AI_INSIGHT_LIST_RE`, `_AI_INSIGHT_LIST_LOOKAHEAD_RE`, `_AI_INSIGHT_LABEL_RE`
- `add_text_section` 闭包内多挂一个 RichText `_structured_labels[name]` 标签 (`.setObjectName(name + "Structured")`, `property="dialogAiStructured"`)
- 原 PlainText `value_label` 仍存在 (`hide()` + `setMaximumHeight(0)`) 用于兼容既有 findChild 测试
- `_render_model` 同步刷新两个结构化标签; 空 HTML 时回退显示原 label 文本
- 风险评估 FlowLayout spacing `7×7``9×9`
- `body_layout.setSpacing` `12``14`
- 卡片 contentsMargins `14,13,16,15``16,14,18,16`
- QSS: `dialogAiBody``line-height: 1.85`, 新增 `dialogAiStructured` 选择器 (line-height 175%); 风险 pill `min-height` `28``30`, padding `4 10``5 12`, font 12 → 12.5, border-radius `7``8`
## 兼容约束
- `tests/test_reception_parity_ui.py` 严格校验:
- `findChild(QLabel, "ReceptionAiAnalysisDialogDiagnosisText")` 必须能找到
- `.text()` 必须等于 `_ai_narrative_text` 归一化结果
- `.textFormat() == Qt.TextFormat.PlainText`
- 因此 PlainText 标签必须原样保留, 仅做 `.hide()` + `setMaximumHeight(0)` (避免 QSS `>` 子选择器不被 Qt 支持)
## 验证
- `py_compile` 通过, 无 `SyntaxWarning`
- 单独脚本跑过 4 组样本文本 (稠密段落 / 嵌入式编号列表 / 短句 / 纯编号列表), 拆分结果符合预期
- 未在本机跑 UI 测试 (缺 PySide6)
@@ -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>
@@ -335,7 +335,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: '业务订单,按创建人归属并过滤无效单' },
+12 -5
View File
@@ -215,6 +215,7 @@ const PAGE_SIZE = 15
interface QueueRow {
id: number
patient_id: number
source_patient_id?: number
patient_name: string
patient_phone?: string
diagnosis_id?: number
@@ -436,19 +437,25 @@ const handleSearch = async () => {
}
const handleCall = async (row: QueueRow) => {
if (!row.patient_id) {
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
feedback.msgWarning('患者信息不完整')
return
}
if (!diagnosisId) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
patient_id: sourcePatientId,
diagnosis_id: diagnosisId
})
chatDialogRef.value?.open({
patientId: row.patient_id,
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
diagnosisId,
signatureData: res
})
} catch (error: any) {
+28 -28
View File
@@ -996,34 +996,34 @@ const handleEdit = (row: any) => {
}
// 聊天
const handleChat = async (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
// 检查是否有诊单ID
if (!row.diagnosis_id && !row.id) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
// 获取聊天签名信息
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
})
console.log('获取聊天签名成功:', res)
// 直接打开聊天对话框,传入必要的参数
chatDialogRef.value?.open({
patientId: row.patient_id,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
signatureData: res // 传入签名数据
})
const handleChat = async (row: any) => {
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
feedback.msgWarning('患者信息不完整')
return
}
if (!diagnosisId) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
// 获取聊天签名信息
const res = await getCallSignature({
patient_id: sourcePatientId,
diagnosis_id: diagnosisId
})
console.log('获取聊天签名成功:', res)
// 直接打开聊天对话框,传入必要的参数
chatDialogRef.value?.open({
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
signatureData: res // 传入签名数据
})
} catch (error: any) {
console.error('获取聊天签名失败:', error)
feedback.msgError(error.message || '获取聊天签名失败')
+22 -16
View File
@@ -642,22 +642,28 @@ const handleEdit = (row: any) => {
}
editRef.value?.open('edit', row.patient_id)
}
const handleChat = async (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
try {
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
})
chatDialogRef.value?.open({
patientId: row.patient_id,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
signatureData: res
})
const handleChat = async (row: any) => {
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
feedback.msgWarning('患者信息不完整')
return
}
if (!diagnosisId) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
const res = await getCallSignature({
patient_id: sourcePatientId,
diagnosis_id: diagnosisId
})
chatDialogRef.value?.open({
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
signatureData: res
})
} catch (e: any) {
feedback.msgError(e?.msg || e?.message || '获取通话签名失败')
}
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

@@ -61,6 +61,7 @@ def _appointment_rows() -> list[dict[str, Any]]:
"id": 801 + index,
"diagnosis_id": 901 + index,
"patient_id": 401 + index,
"source_patient_id": 401 + index,
"patient_name": names[index % len(names)],
"patient_phone": f"1380013{8000 + index}",
"gender": 2 if index % 2 == 0 else 1,
@@ -77,6 +78,11 @@ def _appointment_rows() -> list[dict[str, Any]]:
"status_desc": "已挂号",
"revisit_time": "复诊" if index % 2 else "初诊",
"unserved_days": index,
"video_call_hint": (
{"state": "live", "label": "视频通话进行中"}
if index == 0
else {"state": "idle", "label": "等待医生发起"}
),
}
)
return rows
@@ -161,15 +167,17 @@ def render() -> list[Path]:
raise RuntimeError(
f"appointment table only exposes {appointment_slots} ordinary rows"
)
if width == 1024 and not appointments.video_panel.isHidden():
raise RuntimeError("narrow appointment viewport did not collapse the video panel")
if appointments.content_layout.count() != 1:
raise RuntimeError("appointment list still reserves a secondary side panel")
if appointments.table_card.width() != appointments.content_host.width():
raise RuntimeError("appointment table does not fill the content viewport")
appointment_path = output / f"appointments_{width}x{height}.png"
if not shell.grab().save(str(appointment_path), "PNG"):
raise RuntimeError(f"failed to save {appointment_path}")
paths.append(appointment_path)
print(
f"{width}x{height}: patient_slots={patient_slots}, "
f"appointment_slots={appointment_slots}, video_hidden={appointments.video_panel.isHidden()}"
f"appointment_slots={appointment_slots}, table_full_width=True"
)
shell.close()
application.processEvents()
+108 -21
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
import json
import time
from collections.abc import Callable, Iterator, Mapping
from threading import Lock, RLock
from contextlib import contextmanager
from threading import Condition, RLock
from typing import Any
from urllib.parse import urljoin, urlsplit, urlunsplit
@@ -23,15 +24,6 @@ from doctor_workstation.core.errors import (
WorkWechatBindingRequiredError,
)
# One shared transport lock serializes every HTTP exchange in the process.
# The GUI fires page loads, dialog bundles and polling timers from many
# QThreadPool workers that all share a single ``httpx.Client``. Concurrent
# TLS handshakes on one shared SSLContext have repeatedly crashed the
# process inside libcrypto (access violation at a stable offset), so all
# network traffic now goes through this single lock. Requests stay
# off-thread, so the GUI never blocks; only the network itself is serialized.
_HTTP_TRANSPORT_LOCK = Lock()
class ApiClient:
"""A small, testable client implementing the admin API contract.
@@ -42,6 +34,7 @@ class ApiClient:
"""
API_VERSION = "1.9.4"
DEFAULT_MAX_PARALLEL_REQUESTS = 8
def __init__(
self,
@@ -55,6 +48,7 @@ class ApiClient:
transport: httpx.BaseTransport | None = None,
client: httpx.Client | None = None,
sleep: Callable[[float], None] = time.sleep,
max_parallel_requests: int = DEFAULT_MAX_PARALLEL_REQUESTS,
) -> None:
"""Create a client without performing any network requests."""
@@ -64,6 +58,8 @@ class ApiClient:
raise ValueError("retry_backoff must be non-negative")
if client is not None and transport is not None:
raise ValueError("pass either client or transport, not both")
if max_parallel_requests <= 0:
raise ValueError("max_parallel_requests must be positive")
self.base_url = self.normalise_base_url(base_url)
self.timeout = timeout
self.max_retries = max_retries
@@ -71,10 +67,68 @@ class ApiClient:
self._sleep = sleep
self._token = token.strip()
self._lock = RLock()
self._transport_lock = RLock()
self._client_condition = Condition(RLock())
self._available_clients: list[httpx.Client] = []
self._pooled_clients: list[httpx.Client] = []
self._active_requests = 0
self._closing = False
self._closed = False
self._max_parallel_requests = max_parallel_requests
# Production requests use a bounded pool of independent clients. This
# permits unrelated patient requests to run concurrently without
# sharing the SSLContext that previously caused native TLS crashes.
# A pool also works across PySide QRunnable boundaries, where Python
# thread-local state is not retained reliably.
# Explicit clients/transports remain on one locked client because
# their ownership and thread-safety contracts are unknown.
self._uses_client_pool = client is None and transport is None
self._owns_client = client is None
self._stream_transport = transport
self._verify = verify
self._client = client or httpx.Client(transport=transport, verify=verify)
self._client = (
None
if self._uses_client_pool
else client or httpx.Client(transport=transport, verify=verify)
)
@contextmanager
def _borrow_client(self) -> Iterator[httpx.Client]:
"""Yield a safe synchronous client for the calling worker thread."""
if not self._uses_client_pool:
with self._transport_lock:
with self._client_condition:
if self._closing or self._closed:
raise RuntimeError("ApiClient is closed")
if self._client is None: # Defensive; this mode always has one.
raise RuntimeError("ApiClient transport is unavailable")
yield self._client
return
with self._client_condition:
while True:
if self._closing or self._closed:
raise RuntimeError("ApiClient is closed")
if self._available_clients:
pooled_client = self._available_clients.pop()
break
if len(self._pooled_clients) < self._max_parallel_requests:
pooled_client = httpx.Client(verify=self._verify)
self._pooled_clients.append(pooled_client)
break
self._client_condition.wait()
self._active_requests += 1
try:
yield pooled_client
finally:
with self._client_condition:
self._active_requests -= 1
self._available_clients.append(pooled_client)
if self._active_requests == 0:
self._client_condition.notify_all()
else:
self._client_condition.notify()
@staticmethod
def normalise_base_url(base_url: str) -> str:
@@ -184,9 +238,9 @@ class ApiClient:
) -> Iterator[dict[str, Any]]:
"""POST JSON and yield parsed server-sent events on an isolated client.
Streaming deliberately does not use ``_HTTP_TRANSPORT_LOCK`` or the
process-wide JSON client. A diagnosis response can remain open for
more than a minute and must not block unrelated page requests.
Streaming deliberately does not use the bounded short-request client
pool. A diagnosis response can remain open for more than a minute and
must not consume capacity needed by unrelated page requests.
"""
url = self._endpoint_url(endpoint)
@@ -245,8 +299,8 @@ class ApiClient:
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must resolve to an absolute http(s) URL")
try:
with _HTTP_TRANSPORT_LOCK:
response = self._client.get(
with self._borrow_client() as client:
response = client.get(
target,
headers={"Accept": "image/*,application/octet-stream;q=0.8"},
timeout=self.timeout,
@@ -324,10 +378,10 @@ class ApiClient:
attempts = self.max_retries + 1 if verb == "GET" else 1
response: httpx.Response | None = None
request_timeout = self.timeout if timeout is None else timeout
with _HTTP_TRANSPORT_LOCK:
with self._borrow_client() as client:
for attempt in range(attempts):
try:
response = self._client.request(
response = client.request(
verb,
url,
params=dict(params) if params is not None else None,
@@ -360,9 +414,42 @@ class ApiClient:
def close(self) -> None:
"""Close the internally-created HTTP transport."""
if self._owns_client:
with _HTTP_TRANSPORT_LOCK:
self._client.close()
with self._client_condition:
if self._closed:
return
if self._closing:
while not self._closed:
self._client_condition.wait()
return
self._closing = True
self._client_condition.notify_all()
clients: list[httpx.Client] = []
if self._uses_client_pool:
with self._client_condition:
while self._active_requests:
self._client_condition.wait()
clients = list(self._pooled_clients)
self._available_clients.clear()
self._pooled_clients.clear()
else:
with self._transport_lock:
if self._owns_client and self._client is not None:
clients = [self._client]
close_error: Exception | None = None
for client in clients:
try:
client.close()
except Exception as error: # Close every transport before surfacing one failure.
if close_error is None:
close_error = error
with self._client_condition:
self._closed = True
self._closing = False
self._client_condition.notify_all()
if close_error is not None:
raise close_error
def __enter__(self) -> ApiClient:
"""Return this client for use as a context manager."""
@@ -0,0 +1,602 @@
"""Persistent local-audio capture and COS upload queue.
Local call recordings are medical records. They must survive application
restarts and failed network requests, so the queue deliberately keeps the
audio file after a successful upload as well as after a failed one.
"""
from __future__ import annotations
import queue
import sqlite3
import threading
from collections.abc import Callable
from concurrent.futures import Future
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from os import PathLike
from pathlib import Path
from typing import Any
from uuid import uuid4
from platformdirs import user_data_path
_MAX_UPLOAD_WORKERS = 3
_RETRYABLE_STATUSES = {"pending", "failed"}
_RECOVERY_LOCK = threading.Lock()
_RECOVERED_DATABASES: set[Path] = set()
_MANAGER_LOCK = threading.Lock()
_MANAGERS: dict[tuple[int, Path], LocalAudioUploadManager] = {}
def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _clean_room_id(value: Any) -> str:
room_id = str(value or "").strip()
return "" if room_id == "0" else room_id
def _clean_call_record_id(value: Any, *, required: bool) -> int | None:
if value in (None, "") and not required:
return None
try:
call_record_id = int(value)
except (TypeError, ValueError) as error:
raise ValueError("通话记录 ID 无效。") from error
if call_record_id <= 0:
raise ValueError("通话记录 ID 无效。")
return call_record_id
def _default_root() -> Path:
return (
user_data_path(
"ZhenyangDoctorWorkstation",
appauthor="ZhenYangTang",
ensure_exists=True,
)
/ "local-call-audio"
)
@dataclass(frozen=True, slots=True)
class LocalAudioRecord:
id: int
session_id: str
diagnosis_id: int
call_record_id: int | None
room_id: str
mime_type: str
file_path: Path
size_bytes: int
status: str
error_text: str
uploaded_url: str
attempts: int
created_at: str
updated_at: str
uploaded_at: str
@property
def exists(self) -> bool:
return self.file_path.is_file()
class LocalAudioQueueStore:
"""SQLite-backed manifest for durable local call recordings."""
def __init__(self, root: str | PathLike[str] | None = None) -> None:
self.root = Path(root).expanduser().resolve() if root else _default_root().resolve()
self.files_dir = self.root / "files"
self.files_dir.mkdir(parents=True, exist_ok=True)
self.database_path = self.root / "queue.sqlite3"
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=10.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 10000")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode = WAL")
# Serialise schema inspection and ALTER TABLE across two workstation
# processes. The second process waits, then re-checks the columns.
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS local_audio_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
diagnosis_id INTEGER NOT NULL,
call_record_id INTEGER,
room_id TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL,
file_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_text TEXT NOT NULL DEFAULT '',
uploaded_url TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT ''
)
"""
)
columns = {
str(row["name"])
for row in connection.execute(
"PRAGMA table_info(local_audio_uploads)"
).fetchall()
}
if "room_id" not in columns:
connection.execute(
"ALTER TABLE local_audio_uploads "
"ADD COLUMN room_id TEXT NOT NULL DEFAULT ''"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS idx_local_audio_diagnosis "
"ON local_audio_uploads(diagnosis_id, id DESC)"
)
# Recover interrupted work once per process. Creating the management
# dialog while a worker is active must not move that worker back to
# ``pending`` and accidentally upload the same medical file twice.
with _RECOVERY_LOCK:
if self.database_path in _RECOVERED_DATABASES:
return
with self._connect() as connection:
now = _utc_now()
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'pending',
error_text = '应用上次退出时上传尚未完成,可重试。',
updated_at = ?
WHERE status = 'uploading'
""",
(now,),
)
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'invalid',
error_text = '应用上次退出时录音尚未完成。',
updated_at = ?
WHERE status = 'recording'
""",
(now,),
)
_RECOVERED_DATABASES.add(self.database_path)
@staticmethod
def _from_row(row: sqlite3.Row) -> LocalAudioRecord:
raw_call_record_id = row["call_record_id"]
return LocalAudioRecord(
id=int(row["id"]),
session_id=str(row["session_id"]),
diagnosis_id=int(row["diagnosis_id"]),
call_record_id=(
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
),
room_id=_clean_room_id(row["room_id"]),
mime_type=str(row["mime_type"]),
file_path=Path(str(row["file_path"])).resolve(),
size_bytes=int(row["size_bytes"] or 0),
status=str(row["status"]),
error_text=str(row["error_text"] or ""),
uploaded_url=str(row["uploaded_url"] or ""),
attempts=int(row["attempts"] or 0),
created_at=str(row["created_at"]),
updated_at=str(row["updated_at"]),
uploaded_at=str(row["uploaded_at"] or ""),
)
def begin_recording(
self,
*,
session_id: str,
diagnosis_id: int,
mime_type: str,
call_record_id: int | None = None,
room_id: str = "",
) -> LocalAudioRecord:
suffix = ".ogg" if mime_type.startswith("audio/ogg") else ".webm"
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
name = f"call-{int(diagnosis_id)}-{timestamp}-{uuid4().hex[:10]}{suffix}.part"
path = (self.files_dir / name).resolve()
now = _utc_now()
clean_call_record_id = _clean_call_record_id(call_record_id, required=False)
clean_room_id = _clean_room_id(room_id)
with self._connect() as connection:
cursor = connection.execute(
"""
INSERT INTO local_audio_uploads (
session_id, diagnosis_id, call_record_id, room_id,
mime_type, file_path,
status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'recording', ?, ?)
""",
(
session_id,
int(diagnosis_id),
clean_call_record_id,
clean_room_id,
mime_type,
str(path),
now,
now,
),
)
record_id = int(cursor.lastrowid)
record = self.get(record_id)
if record is None: # pragma: no cover - SQLite insert/read invariant.
raise RuntimeError("无法创建本地录音记录。")
return record
def finalize_recording(self, record_id: int, *, size_bytes: int) -> LocalAudioRecord:
record = self.require(record_id)
if record.status != "recording":
raise RuntimeError("本地录音记录状态不允许完成。")
if record.file_path.suffix == ".part":
final_path = record.file_path.with_suffix("")
record.file_path.replace(final_path)
else:
final_path = record.file_path
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET file_path = ?, size_bytes = ?, status = 'pending',
error_text = '', updated_at = ?
WHERE id = ?
""",
(str(final_path), int(size_bytes), now, int(record_id)),
)
return self.require(record_id)
def mark_invalid(self, record_id: int, message: str) -> LocalAudioRecord:
record = self.require(record_id)
size = record.file_path.stat().st_size if record.file_path.is_file() else 0
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET size_bytes = ?, status = 'invalid', error_text = ?, updated_at = ?
WHERE id = ?
""",
(size, str(message)[:1000], now, int(record_id)),
)
return self.require(record_id)
def bind_identity(
self,
record_id: int,
*,
call_record_id: int,
room_id: str = "",
) -> LocalAudioRecord:
"""Bind one recording to a call without overwriting a different identity."""
clean_call_record_id = _clean_call_record_id(call_record_id, required=True)
assert clean_call_record_id is not None
clean_room_id = _clean_room_id(room_id)
now = _utc_now()
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE local_audio_uploads
SET call_record_id = CASE
WHEN call_record_id IS NULL THEN ?
ELSE call_record_id
END,
room_id = CASE
WHEN room_id = '' AND ? <> '' THEN ?
ELSE room_id
END,
updated_at = ?
WHERE id = ?
AND (call_record_id IS NULL OR call_record_id = ?)
AND (? = '' OR room_id = '' OR room_id = ?)
""",
(
clean_call_record_id,
clean_room_id,
clean_room_id,
now,
int(record_id),
clean_call_record_id,
clean_room_id,
clean_room_id,
),
)
if cursor.rowcount != 1:
exists = connection.execute(
"SELECT 1 FROM local_audio_uploads WHERE id = ?",
(int(record_id),),
).fetchone()
if exists is None:
raise LookupError("本地录音记录不存在。")
raise RuntimeError("本地录音的通话记录 ID 或房间号发生冲突。")
return self.require(record_id)
def set_call_record_id(self, record_id: int, call_record_id: int) -> None:
self.bind_identity(record_id, call_record_id=call_record_id)
def update_status(self, record_id: int, status: str, message: str = "") -> None:
if status not in {"pending", "uploading", "uploaded", "failed", "invalid"}:
raise ValueError("unsupported local-audio status")
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = ?, error_text = ?, updated_at = ?
WHERE id = ?
""",
(status, str(message)[:1000], now, int(record_id)),
)
def get(self, record_id: int) -> LocalAudioRecord | None:
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM local_audio_uploads WHERE id = ?",
(int(record_id),),
).fetchone()
return self._from_row(row) if row is not None else None
def require(self, record_id: int) -> LocalAudioRecord:
record = self.get(record_id)
if record is None:
raise LookupError("本地录音记录不存在。")
return record
def list_records(self, *, diagnosis_id: int | None = None) -> list[LocalAudioRecord]:
with self._connect() as connection:
if diagnosis_id is None:
rows = connection.execute(
"SELECT * FROM local_audio_uploads ORDER BY id DESC"
).fetchall()
else:
rows = connection.execute(
"""
SELECT * FROM local_audio_uploads
WHERE diagnosis_id = ?
ORDER BY id DESC
""",
(int(diagnosis_id),),
).fetchall()
return [self._from_row(row) for row in rows]
def claim_upload(self, record_id: int) -> LocalAudioRecord | None:
now = _utc_now()
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'uploading', error_text = '', attempts = attempts + 1,
updated_at = ?
WHERE id = ? AND status IN ('pending', 'failed')
""",
(now, int(record_id)),
)
if cursor.rowcount != 1:
return None
return self.require(record_id)
def mark_uploaded(self, record_id: int, uploaded_url: str = "") -> None:
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'uploaded', uploaded_url = ?, error_text = '',
uploaded_at = ?, updated_at = ?
WHERE id = ?
""",
(str(uploaded_url)[:2000], now, now, int(record_id)),
)
def mark_failed(self, record_id: int, message: str) -> None:
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'failed', error_text = ?, updated_at = ?
WHERE id = ?
""",
(str(message)[:1000] or "上传失败。", now, int(record_id)),
)
def retry(self, record_id: int) -> LocalAudioRecord:
record = self.require(record_id)
if record.status not in {"failed", "pending"}:
raise RuntimeError("当前录音状态不允许重试上传。")
if not record.file_path.is_file() or record.file_path.stat().st_size <= 0:
raise RuntimeError("本地录音文件不存在或为空。")
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'pending', error_text = '', updated_at = ?
WHERE id = ?
""",
(now, int(record_id)),
)
return self.require(record_id)
def _upload_url(result: Any) -> str:
if not isinstance(result, dict):
return ""
data = result.get("data")
payload = data if isinstance(data, dict) else result
for key in ("file_url", "fileUrl", "url", "recording_url"):
value = payload.get(key)
if value:
return str(value)
return ""
class LocalAudioUploadManager:
"""Small daemon worker pool that uploads up to three recordings at once."""
def __init__(
self,
repository: Any,
store: LocalAudioQueueStore,
*,
max_workers: int = _MAX_UPLOAD_WORKERS,
) -> None:
self.repository = repository
self.store = store
self.max_workers = max(1, min(int(max_workers), 8))
self._jobs: queue.Queue[tuple[int, Future[bool]]] = queue.Queue()
self._active: dict[int, Future[bool]] = {}
self._upload_listeners: set[Callable[[LocalAudioRecord], None]] = set()
self._lock = threading.Lock()
for index in range(self.max_workers):
threading.Thread(
target=self._worker,
name=f"local-audio-upload-{index + 1}",
daemon=True,
).start()
def submit(self, record_id: int) -> Future[bool]:
record_id = int(record_id)
with self._lock:
existing = self._active.get(record_id)
if existing is not None:
return existing
future: Future[bool] = Future()
self._active[record_id] = future
self._jobs.put((record_id, future))
return future
def add_upload_listener(
self,
listener: Callable[[LocalAudioRecord], None],
) -> None:
with self._lock:
self._upload_listeners.add(listener)
def remove_upload_listener(
self,
listener: Callable[[LocalAudioRecord], None],
) -> None:
with self._lock:
self._upload_listeners.discard(listener)
def _notify_uploaded(self, record_id: int) -> None:
try:
record = self.store.require(record_id)
except Exception:
return
with self._lock:
listeners = tuple(self._upload_listeners)
for listener in listeners:
with suppress(Exception):
listener(record)
def submit_retryable(self, *, diagnosis_id: int | None = None) -> list[Future[bool]]:
futures: list[Future[bool]] = []
for record in self.store.list_records(diagnosis_id=diagnosis_id):
if record.status in _RETRYABLE_STATUSES:
futures.append(self.submit(record.id))
return futures
def submit_pending(self, *, diagnosis_id: int | None = None) -> list[Future[bool]]:
"""Resume only queued work; failed uploads require an explicit retry."""
return [
self.submit(record.id)
for record in self.store.list_records(diagnosis_id=diagnosis_id)
if record.status == "pending"
]
def _worker(self) -> None:
while True:
record_id, future = self._jobs.get()
try:
try:
succeeded = self._upload(record_id)
except Exception as error: # Defensive boundary around a daemon worker.
with suppress(Exception):
self.store.mark_failed(
record_id,
str(error) or "本地录音上传失败。",
)
succeeded = False
if not future.done():
future.set_result(succeeded)
finally:
with self._lock:
self._active.pop(record_id, None)
self._jobs.task_done()
def _upload(self, record_id: int) -> bool:
record = self.store.claim_upload(record_id)
if record is None:
current = self.store.get(record_id)
return bool(current and current.status == "uploaded")
if not record.file_path.is_file() or record.file_path.stat().st_size <= 0:
self.store.mark_failed(record.id, "本地录音文件不存在或为空。")
return False
if record.call_record_id is None or record.call_record_id <= 0:
self.store.mark_failed(record.id, "未保存对应的通话记录编号,暂不能上传。")
return False
upload = getattr(self.repository, "upload_call_recording", None)
if not callable(upload):
self.store.mark_failed(record.id, "当前服务未提供本地录音上传接口。")
return False
try:
result = upload(
path=record.file_path,
diagnosis_id=record.diagnosis_id,
call_record_id=record.call_record_id,
mime_type=record.mime_type,
)
except Exception as error:
self.store.mark_failed(record.id, str(error) or "本地录音上传 COS 失败。")
return False
uploaded_url = _upload_url(result)
if not uploaded_url:
self.store.mark_failed(record.id, "服务器未返回本地录音文件地址。")
return False
self.store.mark_uploaded(record.id, uploaded_url)
self._notify_uploaded(record.id)
return True
def get_local_audio_upload_manager(
repository: Any,
store: LocalAudioQueueStore | None = None,
) -> tuple[LocalAudioQueueStore, LocalAudioUploadManager]:
"""Return one three-worker uploader per repository and manifest.
Video windows and the recording-management dialog may be opened many times
during a workstation session. Sharing the pool prevents each window from
creating another set of background threads while SQLite's atomic claim still
protects against duplicate uploads across processes.
"""
resolved_store = store or LocalAudioQueueStore()
key = (id(repository), resolved_store.database_path)
with _MANAGER_LOCK:
manager = _MANAGERS.get(key)
if manager is None:
manager = LocalAudioUploadManager(
repository,
resolved_store,
max_workers=_MAX_UPLOAD_WORKERS,
)
_MANAGERS[key] = manager
return resolved_store, manager
@@ -2588,8 +2588,11 @@ class DemoDoctorRepository:
"room_id": "manual-upload",
"status": 2,
"status_text": "已结束",
"recording_status_text": "待上传",
"recording_urls_list": [],
"recording_status_text": "待上传",
"recording_urls_list": [],
"local_audio_status": 0,
"local_audio_status_text": "无本地录音",
"local_audio_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
@@ -2633,25 +2636,60 @@ class DemoDoctorRepository:
record["recording_status_text"] = "录制完成"
return deepcopy(record)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload then attach a demo video using the production sequence."""
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id)["id"])
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Upload then attach a demo local-audio file using the production sequence."""
source_path = Path(path)
is_audio = source_path.suffix.lower() in {
".ogg",
".opus",
".mp3",
".wav",
".m4a",
".aac",
".amr",
".wma",
} or (
source_path.suffix.lower() == ".webm"
and str(mime_type or "").lower().startswith("audio/")
)
file_url = self.upload_material(path, "file" if is_audio else "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id)["id"])
if is_audio:
with self._lock:
record = next(
(
row
for row in self._call_records.setdefault(diagnosis_id, [])
if int(row.get("id") or 0) == int(target_id)
),
None,
)
if record is None:
raise RepositoryNotFoundError("call record not found")
urls = record.setdefault("local_audio_urls_list", [])
if file_url not in urls:
urls.append(file_url)
record["local_audio_status"] = 2
record["local_audio_status_text"] = "已保存"
else:
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
"media_kind": "local_audio" if is_audio else "video",
"completed": True,
}
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -2890,8 +2928,11 @@ class DemoDoctorRepository:
replay = {
**record,
"status_text": "呼叫中",
"recording_status_text": "未录制",
"recording_urls_list": [],
"recording_status_text": "未录制",
"recording_urls_list": [],
"local_audio_status": 0,
"local_audio_status_text": "无本地录音",
"local_audio_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
@@ -2903,13 +2944,21 @@ class DemoDoctorRepository:
self._call_records.setdefault(diagnosis_id, []).insert(0, replay)
return deepcopy(record)
def end_call(self, diagnosis_id: int) -> dict[str, Any]:
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> dict[str, Any]:
"""Persistently mark the demo call record ended."""
with self._lock:
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
if call_record_id is not None and str(record.get("id") or "") != str(
call_record_id
):
raise RepositoryNotFoundError(
f"active call record {call_record_id} for {diagnosis_id} not found"
)
record["status"] = "ended"
record["ended_at"] = datetime.now().replace(microsecond=0).isoformat(sep=" ")
replay = next(
@@ -2930,15 +2979,27 @@ class DemoDoctorRepository:
)
return deepcopy(record)
def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]:
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> dict[str, Any]:
"""Persist the room identifier on the active demo call."""
if not room_id.strip():
raise ValueError("room_id is required")
with self._lock:
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
if call_record_id is not None and str(record.get("id") or "") != str(
call_record_id
):
raise RepositoryNotFoundError(
f"active call record {call_record_id} for {diagnosis_id} not found"
)
record["room_id"] = room_id.strip()
record["status"] = "connected"
replay = next(
@@ -2957,9 +3018,10 @@ class DemoDoctorRepository:
"status_text": "通话中",
}
)
return {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
return {
"diagnosis_id": diagnosis_id,
"call_record_id": record["id"],
"room_id": room_id.strip(),
"cloud_recording": {"started": False, "message": "演示模式不录制"},
}
+175 -71
View File
@@ -2,16 +2,18 @@
from __future__ import annotations
import mimetypes
import re
import time
import mimetypes
import re
import time
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import suppress
from datetime import date
from contextlib import suppress
from datetime import date
from inspect import Parameter, signature
from io import BytesIO
from os import PathLike
from pathlib import Path
from typing import Any, Final, Literal, Protocol
from pathlib import Path
from typing import Any, Final, Literal, Protocol
from uuid import uuid4
from doctor_workstation.core.errors import (
ApiBusinessError,
@@ -631,14 +633,15 @@ class DoctorRepository(Protocol):
) -> Any:
"""Attach one uploaded replay URI to a call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload a local video then attach it to a real call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Upload a locally captured call recording to a real call record."""
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -699,11 +702,19 @@ class DoctorRepository(Protocol):
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create a call record."""
def end_call(self, diagnosis_id: int) -> Any:
"""End the active diagnosis call."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> Any:
"""End the exact active call when its server identity is available."""
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> Any:
"""Bind a TRTC room to the exact active call and start cloud recording."""
def start_call_transcription(
self,
@@ -2322,31 +2333,75 @@ class RemoteDoctorRepository:
body["call_record_id"] = call_record_id
return self.client.post("tcm.diagnosis/attachLocalCallRecording", body)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Perform the same upload/create/attach sequence as ``CallRecordPanel``."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
# Match the admin component exactly: upload first so a failed upload does
# not leave behind an empty synthetic call record.
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id).get("id") or 0)
if target_id <= 0:
raise ApiProtocolError("tcm.diagnosis/createManualCallRecord returned no id")
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Chunk-upload one local audio recording and attach it atomically."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if call_record_id is not None and call_record_id <= 0:
raise ValueError("call_record_id must be positive")
recording_path = Path(path).expanduser().resolve()
if not recording_path.is_file():
raise ValueError(f"recording file does not exist: {recording_path}")
file_size = recording_path.stat().st_size
if file_size <= 0:
raise ValueError("recording file must not be empty")
chunk_size = 4 * 1024 * 1024
chunk_total = (file_size + chunk_size - 1) // chunk_size
upload_id = f"local_audio_{uuid4().hex}"
file_name = recording_path.name
clean_mime = str(mime_type or "audio/webm").strip()[:120] or "audio/webm"
final_result: Mapping[str, Any] | None = None
bound_call_record_id = call_record_id
with recording_path.open("rb") as stream:
for chunk_index in range(chunk_total):
content = stream.read(chunk_size)
if not content:
raise ApiProtocolError("local recording ended before all chunks were read")
result = self.client.post_multipart(
"tcm.diagnosis/uploadCallRecording",
files={"file": (file_name, content, clean_mime)},
data={
"diagnosis_id": diagnosis_id,
"call_record_id": bound_call_record_id or 0,
"upload_id": upload_id,
"file_name": file_name,
"file_size": file_size,
"mime_type": clean_mime,
"chunk_index": chunk_index,
"chunk_total": chunk_total,
},
)
final_result = _require_mapping(
result,
"tcm.diagnosis/uploadCallRecording",
)
returned_id = int(final_result.get("call_record_id") or 0)
if returned_id <= 0:
raise ApiProtocolError(
"uploadCallRecording returned no call_record_id"
)
if bound_call_record_id is None:
bound_call_record_id = returned_id
elif returned_id != bound_call_record_id:
raise ApiProtocolError(
"uploadCallRecording returned a different call_record_id"
)
if final_result is None or not bool(final_result.get("completed")):
raise ApiProtocolError("uploadCallRecording did not complete the local audio upload")
file_url = str(final_result.get("file_url") or "").strip()
if not file_url:
raise ApiProtocolError("uploadCallRecording returned no COS file URL")
return dict(final_result)
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -2546,20 +2601,53 @@ class RemoteDoctorRepository:
)
return {"call_record_id": call_record_id}
def end_call(self, diagnosis_id: int) -> Any:
"""End the active call/recording associated with a diagnosis."""
return self.client.post("tcm.diagnosis/endCall", {"diagnosis_id": diagnosis_id})
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
if not room_id.strip():
raise ValueError("room_id is required")
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
@staticmethod
def _optional_call_record_id(call_record_id: int | str | None) -> int | str | None:
if call_record_id is None:
return None
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id must be a positive identifier")
try:
normalized = int(str(call_record_id).strip())
except (TypeError, ValueError) as exc:
raise ValueError("call_record_id must be a positive identifier") from exc
if normalized <= 0:
raise ValueError("call_record_id must be a positive identifier")
return normalized
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> Any:
"""Stop COS cloud recording and end the exact call record."""
body: dict[str, Any] = {"diagnosis_id": diagnosis_id}
normalized_id = self._optional_call_record_id(call_record_id)
if normalized_id is not None:
body["call_record_id"] = normalized_id
return self.client.post("tcm.diagnosis/endCall", body)
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> Any:
"""Bind the actual TRTC room and auto-start COS cloud recording."""
if not room_id.strip():
raise ValueError("room_id is required")
body: dict[str, Any] = {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
}
normalized_id = self._optional_call_record_id(call_record_id)
if normalized_id is not None:
body["call_record_id"] = normalized_id
return self.client.post(
"tcm.diagnosis/bindCallRoom",
body,
)
@staticmethod
def _transcription_identity(
@@ -2767,24 +2855,40 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
return value
def _client_request(
def _client_request(
client: Any,
method: str,
endpoint: str,
payload: Mapping[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
"""Call get/post, ignoring timeout kwargs that test doubles do not accept."""
fn = getattr(client, method)
params = dict(payload or {})
try:
if timeout is None:
return fn(endpoint, params)
return fn(endpoint, params, timeout=timeout)
except TypeError:
return fn(endpoint, params)
) -> Any:
"""Call get/post once, omitting timeout for legacy test doubles.
Capability is determined before invocation. Catching ``TypeError`` from
the call itself is unsafe for POST because the transport may already have
submitted the write before raising, and a fallback call would duplicate it.
"""
fn = getattr(client, method)
params = dict(payload or {})
if timeout is not None:
try:
parameters = signature(fn).parameters.values()
except (TypeError, ValueError):
parameters = ()
accepts_timeout = any(
parameter.kind is Parameter.VAR_KEYWORD
or (
parameter.name == "timeout"
and parameter.kind
in {Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY}
)
for parameter in parameters
)
if accepts_timeout:
return fn(endpoint, params, timeout=timeout)
return fn(endpoint, params)
def _material_kind(
@@ -260,11 +260,32 @@ def _appointment_status(record: Any) -> int:
return _as_int(status, -1)
def _appointment_active(record: Any) -> bool:
return _has_appointment(record) and _appointment_status(record) == 1
def _single_cancellable_appointment(record: Any) -> Any | None:
def _appointment_active(record: Any) -> bool:
return _has_appointment(record) and _appointment_status(record) == 1
def video_call_state(record: Any) -> str:
"""Return the server-owned state of the latest video-call session."""
return str(
first_value(
record,
"video_call_hint.state",
"video_hint.state",
"video_hint",
default="none",
)
or "none"
).strip().lower()
def video_call_is_live(record: Any) -> bool:
"""Only a room already opened by the doctor may be joined from this list."""
return video_call_state(record) == "live"
def _single_cancellable_appointment(record: Any) -> Any | None:
"""Return the sole cancellable appointment, never an ambiguous row-level fallback."""
if not _has_appointment(record) or _appointment_status(record) not in {1, 4}:
@@ -317,12 +338,34 @@ def _video_ids_complete(record: Any) -> bool:
)
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
if force_open:
return "开方"
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
return "查看处方" if audit == 1 and voided != 1 else "开方"
def prescription_action(record: Any, *, force_open: bool = False) -> tuple[str, str]:
"""Return the label and immutable click intent for the current appointment."""
if force_open:
return "开方", "open"
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
explicit_current = first_value(record, "current_has_prescription", default=None)
if explicit_current is not None:
has_current = _as_bool(explicit_current)
elif _as_int(first_value(record, "current_prescription_id", default=0)) > 0:
has_current = True
else:
explicit_legacy = first_value(record, "has_prescription", default=None)
has_current = (
_as_bool(explicit_legacy)
if explicit_legacy is not None
else audit in {0, 1, 2}
)
if not has_current:
return "开方", "open"
if audit == 1 and voided != 1:
return "查看处方", "view"
return "编辑处方", "edit"
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
return prescription_action(record, force_open=force_open)[0]
class FlowLayout(QLayout):
@@ -1486,25 +1529,32 @@ class DiagnosisTableHost(QFrame):
layout.setContentsMargins(3, 2, 3, 2)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
video_capable = self.action_policy.get("video_call", False)
if video_capable and _appointment_active(record):
button = QToolButton(host)
button.setText("进入视频问诊")
button.setProperty("rowLink", "primary")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip("进入医生端视频问诊(将使用摄像头和麦克风)")
button.setEnabled(_video_ids_complete(record))
if not button.isEnabled():
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
button.clicked.connect(
lambda _checked=False, item=record: self.video_requested.emit(item)
)
layout.addWidget(button)
else:
status = ""
if video_capable:
status = {3: "已完成", 4: "已过号"}.get(
_appointment_status(record), "未挂号" if not _has_appointment(record) else ""
)
call_state = video_call_state(record)
if video_capable and _appointment_active(record) and call_state == "live":
button = QToolButton(host)
button.setText("进入视频问诊")
button.setProperty("rowLink", "primary")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip("医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
button.setEnabled(_video_ids_complete(record))
if not button.isEnabled():
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
button.clicked.connect(
lambda _checked=False, item=record: self.video_requested.emit(item)
)
layout.addWidget(button)
else:
status = ""
if video_capable:
if call_state == "pending_room":
status = "等待接通"
elif _appointment_active(record):
status = "暂无通话"
else:
status = {3: "已完成", 4: "已过号"}.get(
_appointment_status(record),
"未挂号" if not _has_appointment(record) else "",
)
label = QLabel(display_text(status), host)
label.setProperty("fixedMuted", True)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -2201,7 +2251,8 @@ __all__ = [
"DiagnosisLoadingOverlay",
"DiagnosisPager",
"DiagnosisTableHost",
"DiagnosisTableModel",
"FlowLayout",
"FlowWidget",
]
"DiagnosisTableModel",
"FlowLayout",
"FlowWidget",
"prescription_action",
]
@@ -3,7 +3,9 @@
from __future__ import annotations
import json
import re
from collections.abc import Mapping, Sequence
from collections import namedtuple
from datetime import datetime
from threading import Event
from typing import Any
@@ -214,6 +216,32 @@ QDialog#AiConsultDialog QLabel#AiConsultBody {
background-color: transparent;
font-size: 12px;
}
QDialog#AiConsultDialog QLabel#AiConsultInsightMarker {
color: #5B67F1;
background-color: #EEF1FF;
border: 1px solid #DDE3FA;
border-radius: 9px;
min-width: 26px;
max-width: 26px;
min-height: 22px;
max-height: 22px;
font-size: 11px;
font-weight: 700;
padding: 0;
}
QDialog#AiConsultDialog QLabel#AiConsultInsightText {
color: #3F4E75;
background-color: transparent;
font-size: 12.5px;
padding: 1px 0;
line-height: 22px;
}
QDialog#AiConsultDialog QLabel#AiConsultInsightPlaceholder {
color: #8A94B3;
background-color: transparent;
font-size: 12px;
padding: 2px 0;
}
QDialog#AiConsultDialog QLabel#AiConsultTag[level="high"] {
color: #F15B67;
background-color: #FFF1F3;
@@ -994,6 +1022,149 @@ def _analysis_markdown(analysis: Mapping[str, Any]) -> str:
return "\n\n".join(parts)
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、)]\s*)")
_INSIGHT_LIST_LOOKAHEAD_RE = re.compile(r"(?:^|(?<=[:;。\s]))(\d{1,2})\s*([\.、)])\s*")
_INSIGHT_LABEL_RE = re.compile(r"^([\u4e00-\u9fa5A-Za-z()()·\s]{1,12})[:]\s*(.*)$")
def _split_insight_segments(text: str) -> list[_InsightItem]:
"""把较稠密的分析段落拆成结构化条目,便于分条阅读。
识别模式:
- 数字编号开头 (1. / 2 / 3) / 4) 作为 list_item
- 文本中嵌入的编号列表 ( "...阶段:1. 肾功评估:... 2. 代谢评估:...")
也会被识别,编号前的内容视为序言
- "短中文标签:内容" 中含 已知事实 / 建议 / 信息缺口 / 综合推断
可作为 lead_item, 标签部分加粗
- 其余按 / 分句, 每句成一个 plain_item
每种类型都会渲染成独立 QLabel, 以改善阅读节奏
"""
raw = display_text(text, "").strip()
if not raw:
return []
# 检查是否包含嵌入式编号列表: 先找第一次出现 "。 数字。 " 或 "...:数字。 "
lookahead_match = _INSIGHT_LIST_LOOKAHEAD_RE.search(raw)
starts_with_list = _INSIGHT_LIST_RE.match(raw) is not None
if not starts_with_list and lookahead_match is not None:
# 切出序言 + 多个列表条目; 序言尽量停在最近的句末标点 / 冒号处
# 注意: lookahead 是 zero-width 的 lookbehind, 所以 start() 即编号数字位置
list_start = lookahead_match.start()
preamble_candidate = raw[:list_start].rstrip()
sentence_tail = re.search(r"[。;;][^。;;]*$", preamble_candidate)
if sentence_tail is not None:
preamble = preamble_candidate[: sentence_tail.end()].rstrip("。;;: \t")
else:
preamble = preamble_candidate.rstrip(": \t")
body = raw[list_start:].lstrip()
items: list[_InsightItem] = []
if preamble:
items.extend(_split_plain_paragraph(preamble))
items.extend(_split_numbered_list(body))
return items
if starts_with_list:
return _split_numbered_list(raw)
return _split_plain_paragraph(raw)
def _split_numbered_list(text: str) -> list[_InsightItem]:
"""把一段文本按编号列表解析;开头的非编号内容视为序言。"""
items: list[_InsightItem] = []
cursor = 0
length = len(text)
prefix_consumed = False
while cursor < length:
remainder = text[cursor:]
list_match = _INSIGHT_LIST_RE.match(remainder)
if list_match is None:
# 剩余部分按含序言的段落处理 (无编号前缀, 仍按句末分条)
tail = remainder.strip()
if tail and not prefix_consumed:
items.extend(_split_plain_paragraph(tail))
elif tail:
# 已消费过编号, 余下尾段按段落继续分条
items.extend(_split_plain_paragraph(tail))
break
prefix_consumed = True
marker = f"{list_match.group(1)}."
after_marker = remainder[list_match.end():]
# 在本条内找到最近的句末标点; 或下一个编号之前
sentence_match = re.search(r"[。;;\n]", after_marker)
candidate_ends: list[int] = []
if sentence_match is not None:
candidate_ends.append(sentence_match.end())
next_marker_match = re.search(
r"\s\d{1,2}\s*[\.、)]",
after_marker,
)
if next_marker_match is not None:
candidate_ends.append(next_marker_match.start() + 1)
if candidate_ends:
inner_end = min(candidate_ends)
else:
inner_end = len(after_marker)
segment = after_marker[:inner_end].strip("。;; \n").strip()
if segment:
items.append(_split_label_or_sentence(segment, marker=marker))
cursor += list_match.end() + inner_end
while cursor < length and text[cursor] in "。;;\n":
cursor += 1
return items
def _split_plain_paragraph(text: str) -> list[_InsightItem]:
items: list[_InsightItem] = []
if not text.strip():
return items
cursor = 0
length = len(text)
while cursor < length:
remainder = text[cursor:]
sentence_match = re.search(r"[。;;\n]", remainder)
if sentence_match is not None:
segment = remainder[: sentence_match.start()].strip()
if segment:
items.append(_split_label_or_sentence(segment))
cursor += sentence_match.end()
continue
tail = remainder.strip()
if tail:
items.append(_split_label_or_sentence(tail))
break
return items
def _split_label_or_sentence(segment: str, *, marker: str | None = None) -> _InsightItem:
"""对单段片段进一步检查是否含 '标签:内容' 结构。"""
label_match = _INSIGHT_LABEL_RE.match(segment)
if label_match:
label = label_match.group(1).strip()
body = label_match.group(2).strip()
if label and body:
kind = "list" if marker else "lead"
return _InsightItem(kind=kind, marker=marker or "", label=label, text=body)
if marker:
return _InsightItem(kind="list", marker=marker, label="", text=segment)
return _InsightItem(kind="plain", marker="", label="", text=segment)
def _escape_html(value: str) -> str:
"""转义富文本片段,防止分析文本中带 HTML/Markdown 误伤样式。"""
return (
(value or "")
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
class _FlowRow(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -1059,7 +1230,7 @@ class _ChatBubble(QWidget):
) -> None:
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(8, 4, 8, 4)
row.setContentsMargins(8, 6, 8, 6)
row.setSpacing(8)
avatar = QLabel("" if role == "doctor" else "" if role == "patient" else "AI")
avatar.setObjectName("AiConsultRole")
@@ -1383,7 +1554,7 @@ class AiConsultDialog(QDialog):
self.chat_host = QWidget()
self.chat_layout = QVBoxLayout(self.chat_host)
self.chat_layout.setContentsMargins(16, 16, 16, 8)
self.chat_layout.setSpacing(10)
self.chat_layout.setSpacing(12)
self.chat_layout.addStretch(1)
self.chat_scroll.setWidget(self.chat_host)
scroll_bar = self.chat_scroll.verticalScrollBar()
@@ -1576,7 +1747,7 @@ class AiConsultDialog(QDialog):
insight_title.setObjectName("AiConsultSection")
layout.addWidget(insight_title)
self.insight_host = QVBoxLayout()
self.insight_host.setSpacing(8)
self.insight_host.setSpacing(10)
layout.addLayout(self.insight_host)
tools_title = QLabel("快捷工具")
@@ -1708,27 +1879,78 @@ class AiConsultDialog(QDialog):
card = QFrame()
card.setObjectName("AiConsultInsightRow")
_style_surface(card)
inner = QHBoxLayout(card)
inner.setContentsMargins(10, 8, 10, 8)
text = QVBoxLayout()
text.setSpacing(2)
outer = QVBoxLayout(card)
outer.setContentsMargins(14, 12, 14, 14)
outer.setSpacing(10)
header_row = QHBoxLayout()
header_row.setSpacing(8)
header_row.setContentsMargins(0, 0, 0, 0)
heading = QLabel(title)
heading.setObjectName("AiConsultRecordTitle")
body = QLabel(detail)
body.setObjectName("AiConsultBody")
body.setWordWrap(True)
text.addWidget(heading)
text.addWidget(body)
inner.addLayout(text, 1)
header_row.addWidget(heading, 1)
badge = QLabel(tag)
badge.setObjectName("AiConsultTag")
_style_surface(badge)
badge.setProperty("level", level)
badge.style().unpolish(badge)
badge.style().polish(badge)
inner.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
header_row.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
outer.addLayout(header_row)
body_host = QVBoxLayout()
body_host.setSpacing(8)
body_host.setContentsMargins(0, 0, 0, 0)
segments = _split_insight_segments(detail)
if not segments:
placeholder = QLabel(detail or "暂无数据")
placeholder.setObjectName("AiConsultInsightPlaceholder")
placeholder.setWordWrap(True)
body_host.addWidget(placeholder)
else:
for segment in segments:
body_host.addWidget(self._build_insight_item(segment))
outer.addLayout(body_host)
self.insight_host.addWidget(card)
def _build_insight_item(self, item: _InsightItem) -> QWidget:
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
marker_label: QLabel | None = None
if item.kind == "list" and item.marker:
marker_label = QLabel(item.marker)
marker_label.setObjectName("AiConsultInsightMarker")
marker_label.setAlignment(
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter
)
text_label = QLabel()
text_label.setObjectName("AiConsultInsightText")
text_label.setWordWrap(True)
text_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
text_label.setTextFormat(Qt.TextFormat.RichText)
if item.label:
html_label = _escape_html(item.label)
html_body = _escape_html(item.text or "")
text_label.setText(
f"<span style='color:#15224A;font-weight:600;'>{html_label}</span>"
f"<span style='color:#7A87A8;'></span>"
f"<span style='color:#3F4E75;'>{html_body}</span>"
)
else:
text_label.setText(_escape_html(item.text or ""))
if marker_label is not None:
layout.addWidget(marker_label, 0, Qt.AlignmentFlag.AlignTop)
layout.addWidget(text_label, 1)
return row
def _apply_header(
self,
payload: Mapping[str, Any],
@@ -3,6 +3,7 @@
from __future__ import annotations
import re
import sqlite3
from collections.abc import Mapping, Sequence
from contextlib import suppress
from typing import Any
@@ -69,6 +70,7 @@ from ..widgets import (
page_total,
run_async,
)
from .local_audio_queue import LocalAudioQueueDialog
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
@@ -747,6 +749,7 @@ class DiagnosisDialog(QDialog):
"""
saved = Signal()
_local_audio_upload_completed = Signal(int, int)
def __init__(
self,
@@ -841,6 +844,10 @@ class DiagnosisDialog(QDialog):
self._recording_players: list[RecordingPlayerDialog] = []
self._inline_recording_cells: list[RecordingPlaybackCell] = []
self._last_order_detail_dialog: QDialog | None = None
self._local_audio_dialog: LocalAudioQueueDialog | None = None
self._local_audio_upload_manager: Any = None
self._local_audio_upload_listener: Any = None
self._video_reload_pending = False
self._orders_page = 1
self._orders_page_size = 10
self._orders_total = 0
@@ -868,6 +875,10 @@ class DiagnosisDialog(QDialog):
self._daily_panels: list[DailyRecordPanel] = []
self._owner = None
self._owner_filter_installed = False
self._local_audio_upload_completed.connect(
self._local_audio_upload_succeeded,
type=Qt.ConnectionType.QueuedConnection,
)
self.setObjectName("DiagnosisDialogRoot")
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
@@ -1433,6 +1444,11 @@ class DiagnosisDialog(QDialog):
hint.setWordWrap(True)
toolbar.addWidget(hint, 1)
toolbar.addStretch(1)
self.local_audio_queue_button = QPushButton("本机录音文件")
self.local_audio_queue_button.setObjectName("DiagnosisLocalAudioQueueButton")
self.local_audio_queue_button.setAccessibleName("查看本机录音文件及上传状态")
self.local_audio_queue_button.clicked.connect(self._open_local_audio_queue)
toolbar.addWidget(self.local_audio_queue_button)
self.video_upload_button = QPushButton("上传本地回放")
self.video_upload_button.setProperty("variant", "primary")
self.video_upload_button.clicked.connect(
@@ -1556,10 +1572,10 @@ class DiagnosisDialog(QDialog):
),
"video": (
("录制回放", 440),
("房间号", 180),
("开始时间", 170),
("结束时间", 170),
("通话类型", 100),
("房间号", 180),
("时长", 110),
("状态", 90),
("录制 / 文字", 120),
@@ -1695,6 +1711,10 @@ class DiagnosisDialog(QDialog):
)
self.prescribe_button.setVisible(self._editable and self._can_prescribe)
self.video_upload_button.setVisible(self._editable and self._can_video_upload)
self.local_audio_queue_button.setVisible(self._can_video_upload)
self.local_audio_queue_button.setEnabled(
self._diagnosis_id > 0 and self._can_video_upload
)
offset_visible = self._editable and self._can_offset
self.order_offset.setVisible(offset_visible)
self.order_offset_save.setVisible(offset_visible)
@@ -1914,6 +1934,10 @@ class DiagnosisDialog(QDialog):
with suppress(RuntimeError):
self._last_order_detail_dialog.close()
self._last_order_detail_dialog = None
if self._local_audio_dialog is not None:
with suppress(RuntimeError):
self._local_audio_dialog.close()
self._local_audio_dialog = None
self._diagnosis_id = int(diagnosis_id)
self._patient_id = 0
self._editable = bool(
@@ -1939,6 +1963,7 @@ class DiagnosisDialog(QDialog):
self._tab_generations[key] += 1
self._loaded_tabs.clear()
self._loading_tabs.clear()
self._video_reload_pending = False
self._daily_todo_status = None
self._orders_page = 1
self._orders_total = 0
@@ -2208,6 +2233,8 @@ class DiagnosisDialog(QDialog):
self._ensure_tab_loaded(self._current_tab_key(), force=True)
def _invalidate_requests(self) -> None:
self._stop_watching_local_audio_uploads()
self._video_reload_pending = False
self._generation += 1
self._save_generation += 1
self._orders_generation += 1
@@ -2232,6 +2259,11 @@ class DiagnosisDialog(QDialog):
with suppress(RuntimeError):
order_dialog.close()
self._last_order_detail_dialog = None
local_audio_dialog = getattr(self, "_local_audio_dialog", None)
if local_audio_dialog is not None:
with suppress(RuntimeError):
local_audio_dialog.close()
self._local_audio_dialog = None
if hasattr(self, "readonly_loading"):
self.readonly_loading.hide()
if hasattr(self, "drawer_loading"):
@@ -2474,7 +2506,11 @@ class DiagnosisDialog(QDialog):
def _tab_changed(self, _index: int) -> None:
self._sync_save_button()
if self._authoritative_detail_loaded and not self._standalone_readonly:
self._ensure_tab_loaded(self._current_tab_key())
key = self._current_tab_key()
if key == "video":
self._reload_video_records(self._diagnosis_id)
else:
self._ensure_tab_loaded(key)
def _load_visible_readonly_sections(self) -> None:
permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
@@ -2635,6 +2671,8 @@ class DiagnosisDialog(QDialog):
panel.set_messages(rows)
if not self._standalone_readonly and self._current_tab_key() == key:
self._clear_message()
if key == "video":
self._flush_video_reload_if_pending(diagnosis_id)
def _tab_load_error(
self,
@@ -2650,6 +2688,8 @@ class DiagnosisDialog(QDialog):
self._set_tab_error(key, text)
if not self._standalone_readonly and self._current_tab_key() == key:
self._show_message(text, "danger", action_text="重试")
if key == "video":
self._flush_video_reload_if_pending(diagnosis_id)
def _load_daily_range(self, start: str, end: str, *, force: bool = False) -> None:
key = "daily"
@@ -3116,6 +3156,91 @@ class DiagnosisDialog(QDialog):
"video",
)
def _open_local_audio_queue(self) -> None:
if self._diagnosis_id <= 0 or not self._can_video_upload:
self._show_message("当前账号无本机录音上传权限或接口不可用。", "warning")
return
current = self._local_audio_dialog
if current is not None and current.isVisible():
current.raise_()
current.activateWindow()
return
try:
dialog = LocalAudioQueueDialog(
self.repository,
self._diagnosis_id,
self,
)
except (OSError, RuntimeError, sqlite3.Error) as error:
self._show_message(f"无法打开本机录音文件:{error}", "danger")
return
self._local_audio_dialog = dialog
opened_diagnosis_id = self._diagnosis_id
self._watch_local_audio_uploads(dialog.manager)
def clear_dialog(_result: int) -> None:
if self._local_audio_dialog is dialog:
self._local_audio_dialog = None
dialog.finished.connect(clear_dialog)
dialog.open()
# Opening the queue commonly follows a background upload that completed
# before this dialog existed, so also refresh the server-backed rows now.
self._reload_video_records(opened_diagnosis_id)
def _local_audio_upload_succeeded(
self,
diagnosis_id: int,
_call_record_id: int,
) -> None:
if self._local_audio_upload_manager is None:
return
self._reload_video_records(diagnosis_id)
def _reload_video_records(self, diagnosis_id: int) -> None:
if int(diagnosis_id) != self._diagnosis_id:
return
if "video" in self._loading_tabs:
self._video_reload_pending = True
return
self._video_reload_pending = False
self._loaded_tabs.discard("video")
self._ensure_tab_loaded("video", force=True)
def _watch_local_audio_uploads(self, manager: Any) -> None:
if self._local_audio_upload_manager is manager:
return
self._stop_watching_local_audio_uploads()
def notify(record: Any) -> None:
with suppress(RuntimeError):
self._local_audio_upload_completed.emit(
int(record.diagnosis_id),
int(record.call_record_id or 0),
)
manager.add_upload_listener(notify)
self._local_audio_upload_manager = manager
self._local_audio_upload_listener = notify
def _stop_watching_local_audio_uploads(self) -> None:
manager = self._local_audio_upload_manager
listener = self._local_audio_upload_listener
self._local_audio_upload_manager = None
self._local_audio_upload_listener = None
if manager is not None and listener is not None:
with suppress(Exception):
manager.remove_upload_listener(listener)
def _flush_video_reload_if_pending(self, diagnosis_id: int) -> None:
if not self._video_reload_pending or diagnosis_id != self._diagnosis_id:
return
self._video_reload_pending = False
QTimer.singleShot(
0,
lambda expected_id=diagnosis_id: self._reload_video_records(expected_id),
)
def _sync_chat_archive(self) -> None:
if not self._can_chat_sync or self._diagnosis_id <= 0:
self._show_message("当前账号无聊天归档同步权限或接口不可用。", "warning")
@@ -3616,6 +3741,7 @@ class DiagnosisDialog(QDialog):
self._stop_inline_recordings()
matrix: list[tuple[Any, ...]] = []
row_urls: list[list[str]] = []
row_audio_urls: list[list[str]] = []
record_ids: list[int] = []
transcripts: list[str] = []
for row in rows:
@@ -3629,6 +3755,22 @@ class DiagnosisDialog(QDialog):
if url and url not in normalized:
normalized.append(url)
row_urls.append(normalized)
raw_audio_urls = first_value(
row,
"local_audio_urls_list",
"local_audio_urls",
default=[],
) or []
if isinstance(raw_audio_urls, str) or not isinstance(
raw_audio_urls, Sequence
):
raw_audio_urls = [raw_audio_urls]
normalized_audio: list[str] = []
for candidate in raw_audio_urls:
url = str(candidate or "").strip()
if url and url not in normalized_audio:
normalized_audio.append(url)
row_audio_urls.append(normalized_audio)
record_ids.append(_int(first_value(row, "id", "call_record_id"), 0))
transcript = self._call_transcript_text(row)
transcripts.append(transcript)
@@ -3651,22 +3793,41 @@ class DiagnosisDialog(QDialog):
recording_status = first_value(
row, "recording_status_text", "record_status_text", "record_status"
)
local_audio_status = first_value(
row,
"local_audio_status_text",
default="本机录音已保存" if normalized_audio else "无本机录音",
)
transcript_status = first_value(
row,
"transcription_status_text",
"transcript_status_text",
default="文字已生成" if transcript else "未生成文字",
)
room_id = str(
first_value(
row,
"room_id_text",
"room_id",
"room_no",
default="",
)
or ""
).strip()
matrix.append(
(
"" if normalized else "暂无录制回放",
room_id or "历史记录未保存",
first_value(row, "start_time_text", "start_time"),
first_value(row, "end_time_text", "end_time"),
call_type,
first_value(row, "room_id", "room_no"),
first_value(row, "duration_text", "duration"),
status,
f"{display_text(recording_status)}\n{display_text(transcript_status)}",
(
f"云端视频:{display_text(recording_status)}\n"
f"本机录音:{display_text(local_audio_status)}\n"
f"转写文字:{display_text(transcript_status)}"
),
"",
)
)
@@ -3692,6 +3853,19 @@ class DiagnosisDialog(QDialog):
item.setText("")
call_record_id = record_ids[row_index]
actions: list[QPushButton] = []
audio_urls = row_audio_urls[row_index]
if audio_urls and call_record_id > 0:
play_audio = self._action_button(
"播放录音", f"播放通话记录 #{call_record_id} 的本机混音录音"
)
play_audio.setObjectName("DiagnosisLocalAudioPlayback")
play_audio.setProperty("callRecordId", call_record_id)
play_audio.clicked.connect(
lambda _checked=False, target=audio_urls[0]: self._open_recording_player(
target
)
)
actions.append(play_audio)
transcript = transcripts[row_index]
if transcript and call_record_id > 0:
view_transcript = self._action_button(
@@ -0,0 +1,625 @@
"""Local call-audio upload queue shown from a diagnosis video-history tab."""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from PySide6.QtCore import Qt, QTimer, QUrl
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtWidgets import (
QAbstractItemView,
QDialog,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from ...services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioRecord,
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from ..widgets import run_async
_STATUS_LABELS = {
"recording": "录制中",
"pending": "待上传",
"uploading": "上传中",
"uploaded": "已上传",
"failed": "上传失败",
"invalid": "无效录音",
}
_STATUS_COLORS = {
"recording": "#5364F5",
"pending": "#B26A00",
"uploading": "#2F6FEB",
"uploaded": "#07966B",
"failed": "#DC4054",
"invalid": "#7886AA",
}
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
_LOCAL_AUDIO_QSS = """
QDialog#LocalAudioQueueDialog {
background: #F6F8FD;
color: #111F46;
}
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
background: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 14px;
}
QLabel#LocalAudioQueueTitle {
color: #111F46;
font-size: 20px;
font-weight: 700;
}
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
color: #6E7C9F;
font-size: 13px;
}
QLabel[queueSummary="true"] {
background: #F3F5FB;
border: 1px solid #E6EAF5;
border-radius: 10px;
color: #3F4E75;
font-size: 13px;
font-weight: 600;
padding: 8px 12px;
}
QPushButton {
min-height: 34px;
border: 1px solid #D9E0F2;
border-radius: 9px;
background: #FFFFFF;
color: #354365;
padding: 0 14px;
font-weight: 600;
}
QPushButton:hover { background: #F1F3FF; border-color: #AEB8FF; }
QPushButton:disabled { color: #A5AFC6; background: #F7F8FC; }
QPushButton[variant="primary"] {
color: #FFFFFF;
background: #5661F4;
border-color: #5661F4;
}
QPushButton[variant="danger"] { color: #D83E51; background: #FFF6F7; }
QTableWidget#LocalAudioQueueTable {
background: #FFFFFF;
alternate-background-color: #FAFBFE;
border: 0;
gridline-color: #E8ECF5;
color: #263452;
selection-background-color: #EEF1FF;
selection-color: #111F46;
}
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
QHeaderView::section {
background: #F5F7FC;
color: #53617F;
border: 0;
border-bottom: 1px solid #E1E6F1;
padding: 10px 8px;
font-weight: 700;
}
"""
def _human_size(size: int) -> str:
value = max(0, int(size))
if value < 1024:
return f"{value} B"
if value < 1024 * 1024:
return f"{value / 1024:.1f} KB"
return f"{value / 1024 / 1024:.1f} MB"
def _display_time(value: str) -> str:
raw = str(value or "").strip()
if not raw:
return ""
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return raw.replace("T", " ")
if parsed.tzinfo is not None:
parsed = parsed.astimezone(_BUSINESS_TIMEZONE)
return parsed.strftime("%Y-%m-%d %H:%M:%S")
class LocalAudioQueueDialog(QDialog):
"""Persistent local-audio list with concurrent upload and retry."""
def __init__(
self,
repository: Any,
diagnosis_id: int | None,
parent: QWidget | None = None,
*,
store: LocalAudioQueueStore | None = None,
manager: LocalAudioUploadManager | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self._global_scope = diagnosis_id is None
self.diagnosis_id = int(diagnosis_id) if diagnosis_id is not None else None
if manager is None:
self.store, self.manager = get_local_audio_upload_manager(repository, store)
else:
self.store = store or manager.store
self.manager = manager
self._signature: tuple[Any, ...] = ()
self._room_fetch_generation = 0
self._room_fetch_inflight: set[int] = set()
self._room_fetch_attempted: set[int] = set()
self._room_fetch_errors: dict[int, str] = {}
self._closed = False
self.setObjectName("LocalAudioQueueDialog")
self.setProperty("businessDialog", True)
self.setWindowTitle(
"本机录音上传管理" if self._global_scope else "本机录音文件"
)
self.setModal(True)
self.setMinimumSize(760, 480)
self.resize(1160, 620)
self.setStyleSheet(_LOCAL_AUDIO_QSS)
self._build_ui()
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(500)
self.poll_timer.timeout.connect(self.refresh_records)
self.poll_timer.start()
self.manager.submit_pending(diagnosis_id=self.diagnosis_id)
self.refresh_records(force=True)
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(18, 18, 18, 18)
root.setSpacing(12)
header = QFrame()
header.setObjectName("LocalAudioQueueHeader")
header_layout = QVBoxLayout(header)
header_layout.setContentsMargins(18, 14, 18, 14)
header_layout.setSpacing(4)
self.title_label = QLabel(
"本机录音上传管理" if self._global_scope else "本机录音文件"
)
self.title_label.setObjectName("LocalAudioQueueTitle")
header_layout.addWidget(self.title_label)
if self._global_scope:
subtitle_text = (
"集中查看所有诊单的本机录音上传记录;成功文件和未成功文件都会保留。"
"最多同时上传 3 个文件,失败后可重试。"
)
else:
subtitle_text = (
"每次通话的本地录音会先保存在本机,再上传到 COS;最多同时上传 3 个文件。"
"上传失败不会删除文件,可随时重试。"
)
subtitle = QLabel(subtitle_text)
subtitle.setObjectName("LocalAudioQueueSubtitle")
subtitle.setWordWrap(True)
header_layout.addWidget(subtitle)
root.addWidget(header)
summary = QFrame()
summary.setObjectName("LocalAudioQueueSummary")
summary_layout = QHBoxLayout(summary)
summary_layout.setContentsMargins(12, 10, 12, 10)
summary_layout.setSpacing(8)
self.summary_total = self._summary_label()
self.summary_pending = self._summary_label()
self.summary_uploading = self._summary_label()
self.summary_uploaded = self._summary_label()
self.summary_failed = self._summary_label()
for label in (
self.summary_total,
self.summary_pending,
self.summary_uploading,
self.summary_uploaded,
self.summary_failed,
):
summary_layout.addWidget(label)
summary_layout.addStretch(1)
self.upload_pending_button = QPushButton("上传待处理")
self.upload_pending_button.setObjectName("LocalAudioUploadPendingButton")
self.upload_pending_button.setProperty("variant", "primary")
self.upload_pending_button.clicked.connect(self._upload_pending)
summary_layout.addWidget(self.upload_pending_button)
self.retry_failed_button = QPushButton("重试全部失败")
self.retry_failed_button.setObjectName("LocalAudioRetryAllButton")
self.retry_failed_button.setProperty("variant", "danger")
self.retry_failed_button.clicked.connect(self._retry_all_failed)
summary_layout.addWidget(self.retry_failed_button)
refresh_button = QPushButton("刷新")
refresh_button.setObjectName("LocalAudioRefreshButton")
refresh_button.clicked.connect(self._manual_refresh)
summary_layout.addWidget(refresh_button)
root.addWidget(summary)
card = QFrame()
card.setObjectName("LocalAudioQueueTableCard")
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(1, 1, 1, 1)
headers = ["录制时间"]
if self._global_scope:
headers.append("诊单 ID")
headers.extend(
[
"通话记录 ID",
"房间号",
"音频文件",
"大小",
"上传状态",
"失败原因",
"操作",
]
)
self._diagnosis_column = 1 if self._global_scope else None
self._call_column = 2 if self._global_scope else 1
self._room_column = 3 if self._global_scope else 2
self._file_column = 4 if self._global_scope else 3
self._size_column = 5 if self._global_scope else 4
self._status_column = 6 if self._global_scope else 5
self._error_column = 7 if self._global_scope else 6
self._action_column = 8 if self._global_scope else 7
self.table = QTableWidget(0, len(headers))
self.table.setObjectName("LocalAudioQueueTable")
self.table.setHorizontalHeaderLabels(headers)
self.table.verticalHeader().hide()
self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setWordWrap(False)
header_view = self.table.horizontalHeader()
header_view.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
header_view.setSectionResizeMode(
self._file_column, QHeaderView.ResizeMode.Stretch
)
header_view.setSectionResizeMode(
self._error_column, QHeaderView.ResizeMode.Stretch
)
self.table.setColumnWidth(0, 160)
if self._diagnosis_column is not None:
self.table.setColumnWidth(self._diagnosis_column, 90)
self.table.setColumnWidth(self._call_column, 90)
self.table.setColumnWidth(self._room_column, 180)
self.table.setColumnWidth(self._size_column, 88)
self.table.setColumnWidth(self._status_column, 100)
self.table.setColumnWidth(self._action_column, 190)
card_layout.addWidget(self.table)
root.addWidget(card, 1)
footer = QFrame()
footer.setObjectName("LocalAudioQueueFooter")
footer_layout = QHBoxLayout(footer)
footer_layout.setContentsMargins(14, 9, 14, 9)
hint = QLabel("已上传文件仍保留在本机;请按机构的数据保留制度定期归档。")
hint.setObjectName("LocalAudioQueueHint")
footer_layout.addWidget(hint, 1)
close_button = QPushButton("关闭")
close_button.setObjectName("LocalAudioQueueCloseButton")
close_button.setProperty("variant", "primary")
close_button.clicked.connect(self.accept)
footer_layout.addWidget(close_button)
root.addWidget(footer)
@staticmethod
def _summary_label() -> QLabel:
label = QLabel()
label.setProperty("queueSummary", True)
return label
def _records(self) -> list[LocalAudioRecord]:
return self.store.list_records(diagnosis_id=self.diagnosis_id)
def refresh_records(self, *, force: bool = False) -> None:
records = self._records()
self._schedule_room_lookups(records)
signature = tuple(
(
record.id,
record.diagnosis_id,
record.status,
record.error_text,
record.call_record_id,
record.room_id,
record.size_bytes,
record.uploaded_url,
record.exists,
record.attempts,
self._room_fetch_state(record),
)
for record in records
)
if not force and signature == self._signature:
return
self._signature = signature
self._update_summary(records)
self.table.setRowCount(len(records))
for row, record in enumerate(records):
self.table.setRowHeight(row, 54)
self._set_item(row, 0, _display_time(record.created_at))
if self._diagnosis_column is not None:
self._set_item(row, self._diagnosis_column, str(record.diagnosis_id))
self._set_item(
row, self._call_column, str(record.call_record_id or "待关联")
)
room_text, room_tooltip = self._room_display(record)
room_item = self._set_item(row, self._room_column, room_text)
room_item.setToolTip(room_tooltip)
file_item = self._set_item(row, self._file_column, record.file_path.name)
file_item.setToolTip(str(record.file_path))
self._set_item(row, self._size_column, _human_size(record.size_bytes))
status_item = self._set_item(
row,
self._status_column,
_STATUS_LABELS.get(record.status, record.status),
)
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#53617F")))
status_item.setToolTip(
f"已尝试 {record.attempts}"
+ (f"\nCOS{record.uploaded_url}" if record.uploaded_url else "")
)
error_item = self._set_item(
row, self._error_column, record.error_text or ""
)
error_item.setToolTip(record.error_text)
self.table.setCellWidget(row, self._action_column, self._actions(record))
def _room_fetch_state(self, record: LocalAudioRecord) -> str:
diagnosis_id = record.diagnosis_id
if record.room_id:
return record.room_id
if diagnosis_id in self._room_fetch_inflight:
return "loading"
if diagnosis_id in self._room_fetch_errors:
return f"error:{self._room_fetch_errors[diagnosis_id]}"
if diagnosis_id in self._room_fetch_attempted:
return "missing"
return "idle"
def _room_display(self, record: LocalAudioRecord) -> tuple[str, str]:
if record.room_id:
return record.room_id, record.room_id
if record.call_record_id is None:
return "", "尚未关联通话记录,无法取得房间号。"
diagnosis_id = record.diagnosis_id
if diagnosis_id in self._room_fetch_inflight:
return "查询中…", "正在从服务端通话记录查询房间号。"
error = self._room_fetch_errors.get(diagnosis_id)
if error:
return "获取失败", error
return "未记录", "服务端通话记录未保存房间号。"
def _schedule_room_lookups(self, records: list[LocalAudioRecord]) -> None:
if self._closed:
return
method = getattr(self.repository, "list_call_records", None)
if not callable(method):
method = getattr(self.repository, "get_call_records", None)
if not callable(method):
return
target_ids: dict[int, set[int]] = {}
for record in records:
call_record_id = int(record.call_record_id or 0)
if record.room_id or call_record_id <= 0:
continue
target_ids.setdefault(record.diagnosis_id, set()).add(call_record_id)
diagnosis_ids = tuple(
sorted(
diagnosis_id
for diagnosis_id in target_ids
if diagnosis_id not in self._room_fetch_inflight
and diagnosis_id not in self._room_fetch_attempted
)
)
if not diagnosis_ids:
return
generation = self._room_fetch_generation
self._room_fetch_inflight.update(diagnosis_ids)
self._room_fetch_attempted.update(diagnosis_ids)
def load_rooms() -> tuple[dict[tuple[int, int], str], dict[int, str]]:
rooms: dict[tuple[int, int], str] = {}
errors: dict[int, str] = {}
for diagnosis_id in diagnosis_ids:
try:
payload = method(diagnosis_id)
rows = payload if isinstance(payload, list) else []
for row in rows:
if not isinstance(row, Mapping):
continue
raw_record_id = row.get("id", row.get("call_record_id"))
try:
call_record_id = int(raw_record_id or 0)
except (TypeError, ValueError):
continue
if call_record_id not in target_ids[diagnosis_id]:
continue
room_id = str(
row.get("room_id")
or row.get("roomId")
or row.get("room_id_text")
or row.get("room_no")
or ""
).strip()
if room_id and room_id != "0":
rooms[(diagnosis_id, call_record_id)] = room_id
except Exception as error:
errors[diagnosis_id] = str(error)[:300] or "房间号获取失败。"
return rooms, errors
run_async(
load_rooms,
on_success=lambda result: self._apply_room_lookups(
generation,
diagnosis_ids,
result,
),
on_error=lambda error: self._fail_room_lookups(
generation,
diagnosis_ids,
error,
),
)
def _apply_room_lookups(
self,
generation: int,
diagnosis_ids: tuple[int, ...],
result: tuple[dict[tuple[int, int], str], dict[int, str]],
) -> None:
if self._closed or generation != self._room_fetch_generation:
return
rooms, errors = result
self._room_fetch_inflight.difference_update(diagnosis_ids)
self._room_fetch_errors.update(errors)
records_by_key: dict[tuple[int, int], list[LocalAudioRecord]] = {}
for record in self._records():
if record.call_record_id is None:
continue
key = (record.diagnosis_id, record.call_record_id)
records_by_key.setdefault(key, []).append(record)
for key, room_id in rooms.items():
for record in records_by_key.get(key, []):
try:
self.store.bind_identity(
record.id,
call_record_id=key[1],
room_id=room_id,
)
except (LookupError, RuntimeError, ValueError) as error:
self._room_fetch_errors[key[0]] = str(error)[:300]
self.refresh_records(force=True)
def _fail_room_lookups(
self,
generation: int,
diagnosis_ids: tuple[int, ...],
error: Exception,
) -> None:
if self._closed or generation != self._room_fetch_generation:
return
self._room_fetch_inflight.difference_update(diagnosis_ids)
message = str(error)[:300] or "房间号获取失败。"
self._room_fetch_errors.update(
{diagnosis_id: message for diagnosis_id in diagnosis_ids}
)
self.refresh_records(force=True)
def _manual_refresh(self) -> None:
self._room_fetch_generation += 1
self._room_fetch_inflight.clear()
self._room_fetch_attempted.clear()
self._room_fetch_errors.clear()
self.refresh_records(force=True)
def _set_item(self, row: int, column: int, text: str) -> QTableWidgetItem:
item = QTableWidgetItem(str(text))
item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
self.table.setItem(row, column, item)
return item
def _update_summary(self, records: list[LocalAudioRecord]) -> None:
counts = {status: 0 for status in _STATUS_LABELS}
for record in records:
counts[record.status] = counts.get(record.status, 0) + 1
self.summary_total.setText(f"全部 {len(records)}")
self.summary_pending.setText(f"待上传 {counts['pending']}")
self.summary_uploading.setText(f"上传中 {counts['uploading']}")
self.summary_uploaded.setText(f"已上传 {counts['uploaded']}")
self.summary_failed.setText(f"失败 {counts['failed']}")
self.upload_pending_button.setEnabled(counts["pending"] > 0)
self.retry_failed_button.setEnabled(counts["failed"] > 0)
def _actions(self, record: LocalAudioRecord) -> QWidget:
host = QWidget()
layout = QHBoxLayout(host)
layout.setContentsMargins(4, 5, 4, 5)
layout.setSpacing(5)
if record.status == "pending":
upload = QPushButton("上传")
upload.setAccessibleName(f"上传录音 {record.file_path.name}")
upload.clicked.connect(lambda _checked=False, record_id=record.id: self._upload_one(record_id))
layout.addWidget(upload)
elif record.status == "failed":
retry = QPushButton("重试")
retry.setProperty("variant", "danger")
retry.setAccessibleName(f"重试上传录音 {record.file_path.name}")
retry.clicked.connect(lambda _checked=False, record_id=record.id: self._retry_one(record_id))
layout.addWidget(retry)
elif record.status == "uploading":
busy = QPushButton("上传中")
busy.setEnabled(False)
layout.addWidget(busy)
if record.exists:
reveal = QPushButton("打开位置")
reveal.setAccessibleName(f"打开录音文件位置 {record.file_path.name}")
reveal.clicked.connect(
lambda _checked=False, path=record.file_path: self._open_location(path)
)
layout.addWidget(reveal)
layout.addStretch(1)
return host
def _upload_pending(self) -> None:
self.manager.submit_pending(diagnosis_id=self.diagnosis_id)
self.refresh_records(force=True)
def _upload_one(self, record_id: int) -> None:
self.manager.submit(record_id)
self.refresh_records(force=True)
def _retry_one(self, record_id: int) -> None:
try:
self.store.retry(record_id)
except (OSError, RuntimeError, LookupError) as error:
self._show_local_error(str(error))
return
self.manager.submit(record_id)
self.refresh_records(force=True)
def _retry_all_failed(self) -> None:
for record in self._records():
if record.status != "failed":
continue
try:
self.store.retry(record.id)
except (OSError, RuntimeError, LookupError):
continue
self.manager.submit(record.id)
self.refresh_records(force=True)
def _show_local_error(self, message: str) -> None:
self.summary_failed.setText(str(message)[:160])
self.summary_failed.setToolTip(str(message))
@staticmethod
def _open_location(path: Path) -> None:
target = path.parent if path.parent.is_dir() else path
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
def done(self, result: int) -> None:
self._closed = True
self._room_fetch_generation += 1
if hasattr(self, "poll_timer"):
self.poll_timer.stop()
super().done(result)
@@ -8,7 +8,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from PySide6.QtCore import Qt
from PySide6.QtCore import QCoreApplication, QEvent, Qt
from PySide6.QtWidgets import (
QDialog,
QFrame,
@@ -1086,11 +1086,15 @@ class PrescriptionAiReportDialog(QDialog):
def _clear_host(self) -> None:
while self.host_layout.count():
item = self.host_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.setParent(None)
widget.deleteLater()
item = self.host_layout.takeAt(0)
widget = item.widget()
if widget is not None:
# Keep the parent until Qt processes the deferred deletion.
# Detaching a visible child promotes it to a native top-level
# window on Windows and causes a small title-bar window to flash.
widget.hide()
widget.deleteLater()
QCoreApplication.sendPostedEvents(widget, QEvent.Type.DeferredDelete)
def _add_label(
self,
@@ -9,8 +9,8 @@ from html import escape
from types import MappingProxyType
from typing import Any
from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QAction, QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
@@ -21,10 +21,9 @@ from PySide6.QtWidgets import (
QDialogButtonBox,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMenu,
QMessageBox,
QPushButton,
@@ -104,8 +103,7 @@ APPOINTMENTS_LIGHT_QSS = """
border: 1px solid #E2E7F4;
border-radius: 10px;
}
#AppointmentsPage QFrame#AppointmentMainCard,
#AppointmentsPage QFrame#VideoConsultPanel {
#AppointmentsPage QFrame#AppointmentMainCard {
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -209,6 +207,34 @@ APPOINTMENTS_LIGHT_QSS = """
border-radius: 4px;
font-size: 10px;
}
#AppointmentsPage QWidget[appointmentImHost="true"] {
background-color: transparent;
}
#AppointmentsPage QPushButton[appointmentImAction="true"] {
min-width: 74px;
min-height: 26px;
max-height: 26px;
padding: 0 9px;
color: #5265F6;
background-color: #F0F2FF;
border: 1px solid #D7DEFF;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover {
color: #FFFFFF;
background-color: #5265F6;
border-color: #5265F6;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:focus {
border-color: #8D9BFF;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled {
color: #98A3BC;
background-color: #F7F8FC;
border-color: #E4E8F2;
}
#AppointmentsPage QWidget[appointmentInfoHost="true"] {
background-color: #FFFFFF;
}
@@ -257,77 +283,6 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #EEF1FF;
border-bottom-color: #5265F6;
}
#AppointmentsPage QLabel#VideoConsultTitle {
color: #15224A;
font-size: 16px;
font-weight: 700;
}
#AppointmentsPage QListWidget#VideoConsultList {
background-color: transparent;
border: 0;
border-radius: 0;
outline: 0;
}
#AppointmentsPage QListWidget#VideoConsultList::item {
min-height: 80px;
margin: 0 0 7px 0;
padding: 0;
background-color: transparent;
border: 0;
}
#AppointmentsPage QListWidget#VideoConsultList::item:hover {
background-color: transparent;
}
#AppointmentsPage QListWidget#VideoConsultList::item:selected {
background-color: transparent;
}
#AppointmentsPage QFrame[videoPatientCard="true"] {
background-color: #FCFDFF;
border: 1px solid #E1E6F1;
border-radius: 9px;
}
#AppointmentsPage QFrame[videoPatientCard="true"]:hover {
background-color: #F9FAFF;
border-color: #C9D2FF;
}
#AppointmentsPage QLabel[videoStatus="true"] {
min-height: 20px;
max-height: 20px;
padding: 0 7px;
color: #5265F6;
background-color: #EEF1FF;
border-radius: 5px;
font-size: 10px;
font-weight: 600;
}
#AppointmentsPage QLabel[videoStatusKind="warning"] {
color: #B97715;
background-color: #FFF4DF;
}
#AppointmentsPage QLabel[videoIdentity="true"] {
color: #24335E;
font-size: 12px;
font-weight: 600;
}
#AppointmentsPage QLabel[videoAssistant="true"] {
color: #59698E;
font-size: 11px;
}
#AppointmentsPage QPushButton[videoAction="true"] {
min-height: 20px;
max-height: 20px;
padding: 0 5px;
color: #5265F6;
background-color: transparent;
border: 0;
border-radius: 4px;
font-size: 10px;
}
#AppointmentsPage QPushButton[videoAction="true"]:hover {
background-color: #EEF1FF;
}
#AppointmentsPage QPushButton[videoActionKind="success"] { color: #159C79; }
#AppointmentsPage QPushButton[videoActionKind="warning"] { color: #C17A16; }
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 28px;
max-height: 28px;
@@ -342,11 +297,6 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #F0F1FF;
border-color: #E0E4FF;
}
#AppointmentsPage QPushButton#VideoMoreButton {
min-height: 36px;
color: #405074;
background-color: #FFFFFF;
}
"""
@@ -452,14 +402,37 @@ def _status_value(row: Any) -> int:
return _as_int(first_value(row, "status", default=0))
_IM_CONSULT_ACTIVE_STATUSES = frozenset({1, 4})
def _im_consult_status_error(row: Any) -> str:
"""Return why a queue row cannot open the doctor's IM consultation."""
status = _status_value(row)
if status in _IM_CONSULT_ACTIVE_STATUSES:
return ""
if status == 2:
return "已取消的挂号不可进入 IM 问诊"
if status == 3:
return "已完成的挂号不可再进入 IM 问诊"
return "当前挂号状态不可进入 IM 问诊"
def prescription_action_label(row: Any) -> str:
audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0)
explicit = first_value(row, "has_prescription", default=None)
explicit = first_value(row, "current_has_prescription", default=None)
if explicit is None and _as_int(first_value(row, "current_prescription_id", default=0)) <= 0:
explicit = first_value(row, "has_prescription", default=None)
has_prescription = (
_as_bool(explicit)
if explicit is not None
else _as_int(first_value(row, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
else _as_int(
first_value(row, "current_prescription_id", "prescription_id", default=0),
0,
)
> 0
or audit in {0, 1, 2}
)
if not has_prescription:
return "开方"
@@ -689,7 +662,6 @@ class AppointmentsPage(QWidget):
self._dict_labels: dict[str, dict[str, str]] = {}
self._last_render_signature: tuple[Any, ...] | None = None
self._responsive_narrow: bool | None = None
self._video_panel_user_open = False
self._can_plain_phone = _canonical_allowed(
permissions, "tcm.diagnosis/phonePlain", default=False
)
@@ -889,35 +861,9 @@ class AppointmentsPage(QWidget):
layout.setSpacing(4)
self.content_layout = layout
self.table_card = self._build_table_card()
self.video_panel = self._build_video_panel()
layout.addWidget(self.table_card, 3)
layout.addWidget(self.video_panel, 1)
layout.addWidget(self.table_card, 1)
return host
def _build_video_panel(self) -> QWidget:
panel = QFrame()
panel.setObjectName("VideoConsultPanel")
panel.setMinimumWidth(0)
panel.setMaximumWidth(420)
layout = QVBoxLayout(panel)
layout.setContentsMargins(14, 12, 14, 8)
layout.setSpacing(8)
title = QLabel("视频问诊")
title.setObjectName("VideoConsultTitle")
layout.addWidget(title)
self.video_list = QListWidget()
self.video_list.setObjectName("VideoConsultList")
self.video_list.setSpacing(0)
self.video_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.video_list.currentRowChanged.connect(self._video_row_selected)
self.video_list.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.video_list, 1)
more = QPushButton("查看更多患者 ﹀")
more.setObjectName("VideoMoreButton")
more.clicked.connect(lambda: self.pager.next.click())
layout.addWidget(more)
return panel
def _build_table_card(self) -> QWidget:
card = QFrame()
card.setObjectName("AppointmentMainCard")
@@ -1004,12 +950,6 @@ class AppointmentsPage(QWidget):
self.toolbar_cancel_button.setEnabled(False)
self.toolbar_cancel_button.clicked.connect(self._cancel_selected)
actions.addWidget(self.toolbar_cancel_button)
self.video_panel_button = QPushButton("视频问诊", card)
self.video_panel_button.setCheckable(True)
self.video_panel_button.setProperty("variant", "ghost")
self.video_panel_button.setProperty("compactAction", True)
self.video_panel_button.clicked.connect(self._toggle_video_panel)
actions.addWidget(self.video_panel_button)
actions.addStretch(1)
refresh = QPushButton("刷新", card)
refresh.setProperty("variant", "ghost")
@@ -1034,13 +974,23 @@ class AppointmentsPage(QWidget):
TableColumn("assistant_name", "助理", 110),
TableColumn("has_prescription", "开方", 108, formatter=_prescription_cell),
TableColumn("unserved_days", "未服务天数", 112, formatter=_unserved_cell),
TableColumn(
"_im_consult",
"IM 问诊",
104,
alignment=Qt.AlignmentFlag.AlignCenter,
),
]
)
self.table.setObjectName("AppointmentTable")
self.table.setMinimumHeight(0)
self.table.setWordWrap(True)
self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.horizontalHeader().setFixedHeight(34)
header = self.table.horizontalHeader()
header.setFixedHeight(34)
header.setStretchLastSection(False)
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(10, QHeaderView.ResizeMode.Fixed)
self.table.verticalHeader().setDefaultSectionSize(60)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._open_detail())
@@ -1051,157 +1001,21 @@ class AppointmentsPage(QWidget):
layout.addWidget(self.pager)
return card
def _toggle_video_panel(self, checked: bool) -> None:
self._video_panel_user_open = checked
self._apply_responsive_layout()
def _apply_responsive_layout(self) -> None:
"""Keep the table usable while retaining a video-list entry at low widths."""
"""Collapse only the date filters when horizontal space is limited."""
panel = getattr(self, "video_panel", None)
button = getattr(self, "video_panel_button", None)
if not isinstance(panel, QWidget) or not isinstance(button, QPushButton):
return
narrow = self.width() < 1120
for preset in ("yesterday", "day_before", "tomorrow", "day_after"):
self.date_buttons[preset].setVisible(not narrow)
self.date_overflow_button.setVisible(narrow)
self.status_filter_label.setVisible(not narrow)
self.confirm_filter_label.setVisible(not narrow)
if narrow:
if self._responsive_narrow is not True:
self._video_panel_user_open = False
button.show()
button.setChecked(self._video_panel_user_open)
panel.setVisible(self._video_panel_user_open)
if self._video_panel_user_open:
panel.setFixedWidth(max(250, min(300, round(self.width() * 0.32))))
else:
button.hide()
button.setChecked(False)
panel.show()
panel.setFixedWidth(max(300, min(420, round(self.width() * 0.30))))
self._responsive_narrow = narrow
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
self._apply_responsive_layout()
def _sync_video_list(self, rows: Sequence[Any]) -> None:
selected_id = (
_appointment_id(self.video_list.currentItem().data(Qt.ItemDataRole.UserRole))
if self.video_list.currentItem()
else 0
)
self.video_list.clear()
row_to_select = -1
for index, row in enumerate(rows):
status = display_text(first_value(row, "status_desc"), "已预约")
name = display_text(first_value(row, "patient_name"), "患者")
gender = gender_text(first_value(row, "gender", "gender_desc", default=None))
age = first_value(row, "age", default="")
identity = " · ".join(
part
for part in (name, gender, f"{age}" if age not in (None, "") else "")
if part and part != ""
)
assistant = display_text(first_value(row, "assistant_name"), "未分配")
item = QListWidgetItem(
f"{status} {identity} {assistant}\n"
"进入视频问诊 查看 诊单 开方 预约 补全身份"
)
item.setData(Qt.ItemDataRole.UserRole, row)
item.setSizeHint(QSize(0, 84))
self.video_list.addItem(item)
self.video_list.setItemWidget(item, self._build_video_patient_card(row))
if selected_id and _appointment_id(row) == selected_id:
row_to_select = index
if row_to_select >= 0:
self.video_list.setCurrentRow(row_to_select)
else:
self.video_list.setCurrentRow(-1)
self.video_panel_button.setText(f"视频问诊 {len(rows)}")
def _build_video_patient_card(self, row: Any) -> QWidget:
card = QFrame()
card.setProperty("videoPatientCard", True)
layout = QVBoxLayout(card)
layout.setContentsMargins(10, 7, 10, 6)
layout.setSpacing(5)
status_text = display_text(first_value(row, "status_desc"), "已预约")
name = display_text(first_value(row, "patient_name"), "患者")
gender = gender_text(first_value(row, "gender", "gender_desc", default=None))
age = first_value(row, "age", default="")
identity = " · ".join(
part
for part in (name, gender, f"{age}" if age not in (None, "") else "")
if part and part != ""
)
assistant = display_text(first_value(row, "assistant_name"), "未分配")
heading = QHBoxLayout()
heading.setContentsMargins(0, 0, 0, 0)
heading.setSpacing(7)
status = QLabel(status_text)
status.setProperty("videoStatus", True)
status.setProperty("videoStatusKind", "warning" if "挂号" in status_text else "primary")
heading.addWidget(status)
identity_label = QLabel(identity)
identity_label.setProperty("videoIdentity", True)
heading.addWidget(identity_label)
heading.addStretch(1)
assistant_label = QLabel(assistant)
assistant_label.setProperty("videoAssistant", True)
heading.addWidget(assistant_label)
layout.addLayout(heading)
actions = QHBoxLayout()
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(1)
def add_action(
label: str,
callback: Any,
permission: str | None = None,
kind: str = "",
) -> None:
if permission and not _canonical_allowed(self.permissions, permission, default=False):
return
button = QPushButton(label, card)
button.setProperty("videoAction", True)
if kind:
button.setProperty("videoActionKind", kind)
button.clicked.connect(
lambda _checked=False, source=row, slot=callback: self._run_video_row_action(
source, slot
)
)
actions.addWidget(button)
add_action(
"进入视频问诊",
self._request_video,
"doctor.appointment/prescription",
)
add_action("查看", self._open_detail)
add_action("诊单", self._edit_patient, "tcm.diagnosis/edit")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
add_action("开方", self._open_prescription, "tcm.diagnosis/kaifang")
add_action("二维码", self._request_video_qr, "tcm.diagnosis/videoQr", "success")
more = QPushButton("更多", card)
more.setProperty("videoAction", True)
more.setProperty("videoActionKind", "warning")
more.clicked.connect(
lambda _checked=False, source=row, anchor=more: self._show_video_more_menu(
anchor, source
)
)
actions.addWidget(more)
actions.addStretch(1)
layout.addLayout(actions)
return card
def _run_video_row_action(self, row: Any, callback: Any) -> None:
appointment_id = _appointment_id(row)
for row_index in range(self.table.rowCount()):
@@ -1214,47 +1028,6 @@ class AppointmentsPage(QWidget):
break
callback()
def _show_video_more_menu(self, anchor: QPushButton, row: Any) -> None:
menu = QMenu(anchor)
def add_action(label: str, callback: Any, permission: str | None = None) -> None:
if permission and not _canonical_allowed(self.permissions, permission, default=False):
return
action = QAction(label, menu)
action.triggered.connect(
lambda _checked=False, source=row, slot=callback: self._run_video_row_action(
source, slot
)
)
menu.addAction(action)
add_action("完成问诊", self._complete_selected, "doctor.appointment/complete")
add_action("查看病历", self._view_case, "tcm.diagnosis/kaifang")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
if can_open_diagnosis_ai_report(self.permissions):
add_action("AI 报告", self._open_ai_report)
add_action("取消挂号", self._cancel_selected, "doctor.appointment/cancel")
if menu.actions():
menu.exec(anchor.mapToGlobal(anchor.rect().bottomLeft()))
def _video_row_selected(self, index: int) -> None:
if index < 0:
return
item = self.video_list.item(index)
source = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
appointment_id = _appointment_id(source)
if appointment_id <= 0:
return
for row_index in range(self.table.rowCount()):
cell = self.table.item(row_index, 0)
if (
cell is not None
and _appointment_id(cell.data(Qt.ItemDataRole.UserRole)) == appointment_id
):
self.table.selectRow(row_index)
break
def _action_button(
self,
label: str,
@@ -1484,7 +1257,7 @@ class AppointmentsPage(QWidget):
self.table.set_rows(rows)
self._install_table_selectors()
self._install_appointment_info_cells()
self._sync_video_list(rows)
self._install_im_consult_actions()
self._fit_table_rows()
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
@@ -1634,6 +1407,67 @@ class AppointmentsPage(QWidget):
font.setBold(True)
patient.setFont(font)
def _install_im_consult_actions(self) -> None:
"""Render a doctor-initiated IM entry for every appointment row."""
action_column = 10
has_permission = _canonical_allowed(
self.permissions,
"doctor.appointment/prescription",
default=False,
)
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
row = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
host = QWidget(self.table)
host.setProperty("appointmentImHost", True)
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QHBoxLayout(host)
layout.setContentsMargins(5, 0, 5, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
button = QPushButton("IM 问诊", host)
button.setObjectName("AppointmentImConsultButton")
button.setProperty("appointmentImAction", True)
patient_name = display_text(first_value(row, "patient_name"), "患者")
button.setAccessibleName(f"{patient_name}进行 IM 问诊")
identifiers_ready = (
_appointment_id(row) > 0
and _diagnosis_id(row) > 0
and _video_patient_id(row) > 0
)
status_error = _im_consult_status_error(row)
enabled = (
has_permission
and self._native_video_capable
and identifiers_ready
and not status_error
)
button.setEnabled(enabled)
if not has_permission:
tooltip = "当前账号无 IM 问诊权限"
elif not self._native_video_capable:
tooltip = "当前工作站未配置完整的视频问诊能力"
elif not identifiers_ready:
tooltip = "患者、诊单或挂号标识不完整"
elif status_error:
tooltip = status_error
else:
tooltip = "打开患者 IM,可发送消息并从会话中发起视频"
button.setToolTip(tooltip)
button.clicked.connect(
lambda _checked=False, source=row: self._run_video_row_action(
source,
self._request_video,
)
)
layout.addWidget(button)
self.table.setCellWidget(row_index, action_column, host)
item = self.table.item(row_index, action_column)
if item is not None:
item.setText("")
def _fit_table_rows(self) -> None:
"""Give each appointment row enough height for its real line count."""
@@ -1685,7 +1519,12 @@ class AppointmentsPage(QWidget):
and _as_int(first_value(row, "doctor_id", default=0)) > 0
)
self.call_button.setEnabled(
not_completed and self._native_video_capable and _appointment_id(row) > 0
has_row
and not _im_consult_status_error(row)
and self._native_video_capable
and _appointment_id(row) > 0
and _diagnosis_id(row) > 0
and _video_patient_id(row) > 0
)
self.complete_button.setEnabled(not_completed and _appointment_id(row) > 0)
self.prescription_button.setEnabled(has_row and _diagnosis_id(row) > 0)
@@ -1760,15 +1599,15 @@ class AppointmentsPage(QWidget):
row = self._current_row()
if row is None:
return
status = _status_value(row)
if status == 3:
show_toast(self, "已完成的挂号不可再发起通话", "warning")
status_error = _im_consult_status_error(row)
if status_error:
show_toast(self, f"{status_error}", "warning")
return
appointment_id = _appointment_id(row)
diagnosis_id = _diagnosis_id(row)
patient_id = _video_patient_id(row)
if appointment_id <= 0 or diagnosis_id <= 0 or patient_id <= 0:
show_toast(self, "患者、诊单或挂号标识不完整,无法进入视频问诊。", "warning")
show_toast(self, "患者、诊单或挂号标识不完整,无法进入 IM 问诊。", "warning")
return
self.video_requested.emit(
{
@@ -1777,6 +1616,8 @@ class AppointmentsPage(QWidget):
"patient_id": patient_id,
"diagnosis_id": diagnosis_id,
"patient_name": first_value(row, "patient_name", default="患者"),
# Open IM first. The doctor explicitly starts video from the
# conversation instead of requiring an already-live call.
"mode": "im",
"record": row,
}
@@ -2069,7 +1910,8 @@ class AppointmentsPage(QWidget):
if _diagnosis_id(row) <= 0 or _appointment_id(row) <= 0:
show_toast(self, "该预约缺少诊单信息,请先编辑患者。", "warning")
return
self._begin_prescription_load(row)
mode = "view" if prescription_action_label(row) == "查看" else "open"
self._begin_prescription_load(row, mode=mode)
def _view_case(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang", default=False):
@@ -2112,7 +1954,7 @@ class AppointmentsPage(QWidget):
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
dialog.exec()
def _begin_prescription_load(self, row: Any) -> None:
def _begin_prescription_load(self, row: Any, *, mode: str = "open") -> None:
snapshot = deepcopy(row)
self._prescription_generation += 1
generation = self._prescription_generation
@@ -2126,18 +1968,51 @@ class AppointmentsPage(QWidget):
"get_prescription_by_appointment",
appointment_id=appointment_id,
),
on_success=lambda existing: self._prescription_loaded(existing, snapshot, generation),
on_success=lambda existing: self._prescription_loaded(
existing,
snapshot,
generation,
mode,
),
on_error=lambda error: self._prescription_error(error, generation),
on_finished=lambda: self._prescription_finished(generation),
)
def _prescription_loaded(self, existing: Any, row: Any, generation: int) -> None:
def _prescription_loaded(
self,
existing: Any,
row: Any,
generation: int,
mode: str = "open",
) -> None:
if generation != self._prescription_generation:
return
self.banner.clear()
if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0:
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
actual_id = _as_int(first_value(existing, "id", "prescription_id", default=0))
expected_id = _as_int(
first_value(row, "current_prescription_id", default=0)
)
actual_appointment_id = _as_int(
first_value(existing, "appointment_id", default=0)
)
expected_appointment_id = _appointment_id(row)
identity_changed = (
(expected_id > 0 and actual_id != expected_id)
or (
actual_appointment_id > 0
and expected_appointment_id > 0
and actual_appointment_id != expected_appointment_id
)
)
if mode == "view" and (not approved or voided or identity_changed):
self.banner.show_message(
"当前挂号的处方状态已变化,请刷新列表后重试。",
"warning",
)
return
if not approved or voided:
self._open_existing_prescription_editor(existing)
else:
@@ -2151,6 +2026,12 @@ class AppointmentsPage(QWidget):
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
dialog.exec()
return
if mode == "view":
self.banner.show_message(
"当前挂号没有可查看的处方,请刷新列表后重试。",
"warning",
)
return
self._begin_case_record_load(row)
def _open_existing_prescription_editor(self, prescription: Any) -> None:
@@ -45,6 +45,8 @@ from ..diagnosis_index_widgets import (
DiagnosisPager,
DiagnosisTableHost,
FlowWidget,
prescription_action,
video_call_is_live,
)
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
@@ -373,6 +375,9 @@ def _video_payload(record: Any) -> dict[str, Any]:
"patient_id": first_value(record, "patient_id", "source_patient_id"),
"diagnosis_id": first_value(record, "diagnosis_id", "id"),
"patient_name": first_value(record, "patient_name", default="患者"),
# This page is the assistant/admin receiver. IM mode logs in and waits
# for the doctor's existing invitation; it must never call startCall.
"mode": "im",
"record": record,
}
@@ -389,17 +394,7 @@ def is_diagnosis_confirmed(record: Any) -> bool:
def prescription_action_label(record: Any) -> str:
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
explicit = first_value(record, "has_prescription", default=None)
has_prescription = (
_as_bool(explicit)
if explicit is not None
else _as_int(first_value(record, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
)
if not has_prescription:
return "开方"
return "查看处方" if audit == 1 and voided != 1 else "编辑处方"
return prescription_action(record)[0]
def can_void_prescription(record: Any) -> bool:
@@ -2931,10 +2926,21 @@ class ConsultationsPage(QWidget):
self.edit_button.setEnabled(has_record)
self.delete_button.setEnabled(has_record)
self.prescription_button.setText(
prescription_action_label(record) if record is not None else "开方"
prescription_action(
record,
force_open=self.table_host.force_open_prescription,
)[0]
if record is not None
else "开方"
)
self.prescription_button.setEnabled(has_record and not self._prescription_busy)
row_has_prescription = _as_bool(first_value(record, "has_prescription", default=False))
row_has_prescription = _as_bool(
first_value(
record,
"current_has_prescription",
default=first_value(record, "has_prescription", default=False),
)
)
self.void_button.setEnabled(
has_record
and row_has_prescription
@@ -2946,7 +2952,12 @@ class ConsultationsPage(QWidget):
_as_int(payload.get(key), 0) > 0
for key in ("appointment_id", "patient_id", "diagnosis_id")
)
self.video_button.setEnabled(has_record and is_video_available(record) and valid_ids)
self.video_button.setEnabled(
has_record
and is_video_available(record)
and video_call_is_live(record)
and valid_ids
)
@property
def _diagnosis_dialog(self) -> DiagnosisDialog:
@@ -3076,7 +3087,11 @@ class ConsultationsPage(QWidget):
record = self.table.current_data()
if _as_int(first_value(record, "diagnosis_id", "id", default=0)) <= 0:
return
self._begin_prescription_load(record, mode="open")
_label, mode = prescription_action(
record,
force_open=self.table_host.force_open_prescription,
)
self._begin_prescription_load(record, mode=mode)
def _void_selected_prescription(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang"):
@@ -3114,6 +3129,28 @@ class ConsultationsPage(QWidget):
if existing is not None:
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
actual_id = _as_int(first_value(existing, "id", "prescription_id", default=0))
expected_id = _as_int(
first_value(record, "current_prescription_id", default=0)
)
actual_appointment_id = _as_int(
first_value(existing, "appointment_id", default=0)
)
expected_appointment_id = _appointment_id(record)
identity_changed = (
(expected_id > 0 and actual_id != expected_id)
or (
actual_appointment_id > 0
and expected_appointment_id > 0
and actual_appointment_id != expected_appointment_id
)
)
if mode == "view" and (not approved or voided or identity_changed):
self.banner.show_message(
"当前挂号的处方状态已变化,请刷新列表后重试。",
"warning",
)
return
if not approved or voided:
self._open_existing_prescription_editor(existing)
else:
@@ -3127,6 +3164,12 @@ class ConsultationsPage(QWidget):
detail.diagnosis_requested.connect(self._open_diagnosis_id)
detail.exec()
return
if mode == "view":
self.banner.show_message(
"当前挂号没有可查看的处方,请刷新列表后重试。",
"warning",
)
return
self._begin_case_record_load(record)
def _open_existing_prescription_editor(self, prescription: Any) -> None:
@@ -3339,6 +3382,9 @@ class ConsultationsPage(QWidget):
if record is None or not is_video_available(record):
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
return
if not video_call_is_live(record):
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
return
payload = _video_payload(record)
if not all(
_as_int(payload.get(key), 0) > 0
+40 -13
View File
@@ -1317,9 +1317,13 @@ class PatientListWorkspace(QWidget):
panel.setSpacing(6)
top_row = QHBoxLayout()
top_row.setObjectName("PatientFilterTopRow")
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(6)
top_row.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.keyword_edit = QLineEdit()
self.keyword_edit.setObjectName("PatientKeywordInput")
self.keyword_edit.setMaximumWidth(620)
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.returnPressed.connect(self.search)
@@ -1335,6 +1339,8 @@ class PatientListWorkspace(QWidget):
self.status_combo.addItem("已过号", "missed")
self.status_combo.hide()
self.status_host = QWidget()
self.status_host.setObjectName("PatientStatusFilterHost")
self.status_host.setMaximumWidth(440)
status_row = QHBoxLayout(self.status_host)
status_row.setContentsMargins(0, 0, 0, 0)
status_row.setSpacing(2)
@@ -1370,21 +1376,27 @@ class PatientListWorkspace(QWidget):
status_row.addWidget(button, 1)
self.status_buttons[""].setChecked(True)
top_row.addWidget(self.status_host, 2)
search = QPushButton("查询")
search.setProperty("variant", "primary")
search.setProperty("patientSearchAction", True)
search.clicked.connect(self.search)
top_row.addWidget(search)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.clicked.connect(self.reset_filters)
top_row.addWidget(reset)
self.search_button = QPushButton("查询")
self.search_button.setObjectName("PatientSearchButton")
self.search_button.setProperty("variant", "primary")
self.search_button.setProperty("patientSearchAction", True)
self.search_button.clicked.connect(self.search)
top_row.addWidget(self.search_button)
self.reset_button = QPushButton("重置")
self.reset_button.setObjectName("PatientResetButton")
self.reset_button.setProperty("variant", "ghost")
self.reset_button.clicked.connect(self.reset_filters)
top_row.addWidget(self.reset_button)
panel.addLayout(top_row)
bottom_row = QHBoxLayout()
bottom_row.setObjectName("PatientFilterDateRow")
bottom_row.setContentsMargins(0, 0, 0, 0)
bottom_row.setSpacing(6)
bottom_row.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.quick_host = QWidget()
self.quick_host.setObjectName("PatientQuickDateHost")
self.quick_host.setMaximumWidth(620)
quick = QHBoxLayout(self.quick_host)
quick.setContentsMargins(0, 0, 0, 0)
quick.setSpacing(2)
@@ -1411,10 +1423,13 @@ class PatientListWorkspace(QWidget):
bottom_row.addWidget(self.quick_host, 3)
self.date_host = QWidget()
self.date_host.setObjectName("PatientDateRangeHost")
self.date_host.setMaximumWidth(420)
dates = QHBoxLayout(self.date_host)
dates.setContentsMargins(0, 0, 0, 0)
dates.setSpacing(6)
self.start_date = QDateEdit(QDate.currentDate())
self.start_date.setObjectName("PatientStartDateEdit")
self.start_date.setCalendarPopup(True)
self.start_date.setDisplayFormat("yyyy-MM-dd")
self.start_date.setEnabled(False)
@@ -1425,22 +1440,26 @@ class PatientListWorkspace(QWidget):
separator.setProperty("role", "muted")
dates.addWidget(separator)
self.end_date = QDateEdit(QDate.currentDate())
self.end_date.setObjectName("PatientEndDateEdit")
self.end_date.setCalendarPopup(True)
self.end_date.setDisplayFormat("yyyy-MM-dd")
self.end_date.setEnabled(False)
self.end_date.editingFinished.connect(self._custom_date_changed)
dates.addWidget(self.end_date, 1)
bottom_row.addWidget(self.date_host, 2)
custom = QPushButton("自定义")
custom.setProperty("variant", "ghost")
custom.clicked.connect(lambda: self.set_date_mode("custom"))
bottom_row.addWidget(custom)
self.custom_date_button = QPushButton("自定义")
self.custom_date_button.setObjectName("PatientCustomDateButton")
self.custom_date_button.setProperty("variant", "ghost")
self.custom_date_button.clicked.connect(lambda: self.set_date_mode("custom"))
bottom_row.addWidget(self.custom_date_button)
panel.addLayout(bottom_row)
return card
def _build_summary(self) -> QHBoxLayout:
layout = QHBoxLayout()
layout.setObjectName("PatientSummaryRow")
layout.setSpacing(8)
layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.summary_buttons: dict[str, QPushButton] = {}
for key, label, mode in (
("today", "今日预约", "today"),
@@ -1448,8 +1467,16 @@ class PatientListWorkspace(QWidget):
("day_after", "后天预约", "day_after"),
):
button = QPushButton(f"{label}\n0 人")
button.setObjectName(
{
"today": "PatientSummaryToday",
"tomorrow": "PatientSummaryTomorrow",
"day_after": "PatientSummaryDayAfter",
}[key]
)
button.setProperty("summaryCard", True)
button.setFixedHeight(42)
button.setMaximumWidth(420)
button.setIcon(_summary_calendar_icon())
button.setIconSize(QSize(34, 34))
button.clicked.connect(lambda _checked=False, value=mode: self.set_date_mode(value))
File diff suppressed because it is too large Load Diff
+108 -24
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import sqlite3
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import wraps
@@ -22,6 +23,7 @@ from PySide6.QtGui import (
QShortcut,
)
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QFrame,
QHBoxLayout,
@@ -38,6 +40,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from .dialogs.local_audio_queue import LocalAudioQueueDialog
from .pages import (
AppointmentsPage,
ConsultationsPage,
@@ -61,6 +64,25 @@ _SHELL_TOPBAR_HEIGHT = 62
_SHELL_TABS_HEIGHT = 0
_SHELL_OUTER_GUTTER = 13
_SHELL_PANEL_GAP = 0
_SHELL_DESIGN_SIZE = QSize(1710, 920)
_SHELL_MINIMUM_SIZE = QSize(1024, 640)
def _bounded_initial_window_size(available_size: QSize | None) -> QSize:
"""Fit the design viewport inside the screen's logical available geometry."""
if available_size is None or not available_size.isValid():
return QSize(_SHELL_DESIGN_SIZE.width(), _SHELL_DESIGN_SIZE.height())
return QSize(
max(
_SHELL_MINIMUM_SIZE.width(),
min(_SHELL_DESIGN_SIZE.width(), available_size.width()),
),
max(
_SHELL_MINIMUM_SIZE.height(),
min(_SHELL_DESIGN_SIZE.height(), available_size.height()),
),
)
# The references place the workspace at page-specific global x anchors while
# keeping a 13 px outer gutter. These are the actual rail widths inside that
@@ -660,24 +682,29 @@ class _PaintedIconButton(QToolButton):
self.setFixedSize(size, size)
self.setIcon(_painted_shell_icon(kind, icon_size))
self.setIconSize(QSize(icon_size, icon_size))
content_size = max(0, size - 2)
self.setStyleSheet(
"""
QToolButton#ShellPaintedIconButton {
f"""
QToolButton#ShellPaintedIconButton {{
min-width: {content_size}px;
max-width: {content_size}px;
min-height: {content_size}px;
max-height: {content_size}px;
background: transparent;
border: 1px solid transparent;
border-radius: 9px;
}
QToolButton#ShellPaintedIconButton:hover {
}}
QToolButton#ShellPaintedIconButton:hover {{
background: #F0F3FC;
border-color: #E2E7F4;
}
QToolButton#ShellPaintedIconButton:pressed { background: #EEF1FF; }
QToolButton#ShellPaintedIconButton:focus { border-color: #8D9BFF; }
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {
}}
QToolButton#ShellPaintedIconButton:pressed {{ background: #EEF1FF; }}
QToolButton#ShellPaintedIconButton:focus {{ border-color: #8D9BFF; }}
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {{
background: #FFF0F2;
border-color: #F4C4CC;
}
QToolButton#ShellPaintedIconButton::menu-indicator { image: none; width: 0; }
}}
QToolButton#ShellPaintedIconButton::menu-indicator {{ image: none; width: 0; }}
"""
)
@@ -868,9 +895,12 @@ class ShellWindow(QMainWindow):
self._activation_page: QWidget | None = None
self._activation_generation = 0
self._activation_refreshed = False
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
self.setMinimumSize(1024, 640)
self.resize(1710, 920)
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
screen = self.screen() or QApplication.primaryScreen()
available_size = screen.availableGeometry().size() if screen is not None else None
self.resize(_bounded_initial_window_size(available_size))
canvas = _ShellCanvas(self)
canvas.setObjectName("AppCanvas")
@@ -1002,7 +1032,20 @@ class ShellWindow(QMainWindow):
font-weight: 700;
}
QPushButton#ShellAssistantButton:hover { background-color: #4658E8; }
QLabel#ShellModelLabel { color: #8A95AF; font-size: 10px; }
QPushButton#ShellSettingsButton {
min-height: 42px;
color: #6E7C9F;
font-size: 11px;
font-weight: 600;
background-color: transparent;
border: 1px solid transparent;
border-radius: 10px;
}
QPushButton#ShellSettingsButton:hover {
color: #5265F6;
background-color: #EEF1FF;
}
QPushButton#ShellSettingsButton:focus { border-color: #8D9BFF; }
"""
)
layout = QVBoxLayout(sidebar)
@@ -1074,11 +1117,17 @@ class ShellWindow(QMainWindow):
outer_assistant.addWidget(self.assistant_card)
layout.addLayout(outer_assistant)
self.model_label = QLabel("模型:GPT-4o 医疗版 ", sidebar)
self.model_label.setObjectName("ShellModelLabel")
self.model_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.model_label.setFixedHeight(76)
layout.addWidget(self.model_label)
self.upload_settings_button = QPushButton("设置 ", sidebar)
self.upload_settings_button.setObjectName("ShellSettingsButton")
self.upload_settings_button.setAccessibleName("本机录音上传设置")
self.upload_settings_button.setToolTip("查看本机录音上传记录")
self.upload_settings_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.upload_settings_button.setFixedHeight(76)
self.upload_settings_button.clicked.connect(self._open_local_audio_settings)
# Keep the former attribute as a compatibility alias for integrations
# that inspect the bottom sidebar control.
self.model_label = self.upload_settings_button
layout.addWidget(self.upload_settings_button)
return sidebar
def _build_topbar(self) -> QWidget:
@@ -1095,10 +1144,6 @@ class ShellWindow(QMainWindow):
border-top-right-radius: 16px;
}
QFrame#ShellGlobalSearch {
min-width: 265px;
max-width: 265px;
min-height: 36px;
max-height: 36px;
background-color: #F8FAFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -1112,6 +1157,23 @@ class ShellWindow(QMainWindow):
border: 0;
border-radius: 9px;
}
QLineEdit#ShellGlobalSearchInput QToolButton {
min-width: 22px;
max-width: 22px;
min-height: 18px;
max-height: 18px;
margin: 0;
padding: 0;
background-color: transparent;
border: 0;
border-radius: 0;
}
QLineEdit#ShellGlobalSearchInput QToolButton:hover,
QLineEdit#ShellGlobalSearchInput QToolButton:pressed,
QLineEdit#ShellGlobalSearchInput QToolButton:focus {
background-color: transparent;
border: 0;
}
QLabel#ShellShortcutHint {
color: #8A95AF;
background-color: #F0F3FC;
@@ -1207,7 +1269,9 @@ class ShellWindow(QMainWindow):
search_layout.addWidget(self.global_search, 1)
shortcut_hint = QLabel("Ctrl K", search_host)
shortcut_hint.setObjectName("ShellShortcutHint")
search_layout.addWidget(shortcut_hint)
shortcut_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
shortcut_hint.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
search_layout.addWidget(shortcut_hint, 0, Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(search_host)
self.context_label = QLabel("工作台", topbar)
@@ -1393,7 +1457,7 @@ class ShellWindow(QMainWindow):
)
self.brand_copy.setVisible(not self._sidebar_collapsed)
self.assistant_card.setVisible(not self._sidebar_collapsed)
self.model_label.setVisible(not self._sidebar_collapsed)
self.upload_settings_button.setVisible(not self._sidebar_collapsed)
margins = (7, 14, 7, 0) if self._sidebar_collapsed else (12, 19, 12, 0)
self.nav_layout.setContentsMargins(*margins)
self.fold_button.kind = "expand" if self._sidebar_collapsed else "fold"
@@ -1486,6 +1550,26 @@ class ShellWindow(QMainWindow):
3600,
)
def _open_local_audio_settings(self) -> None:
current = self._local_audio_settings_dialog
if current is not None and current.isVisible():
current.raise_()
current.activateWindow()
return
try:
dialog = LocalAudioQueueDialog(self.repository, None, self)
except (OSError, RuntimeError, ValueError, sqlite3.Error) as error:
show_toast(self, f"无法打开本机录音上传设置:{error}", "error")
return
self._local_audio_settings_dialog = dialog
def clear_dialog(_result: int) -> None:
if self._local_audio_settings_dialog is dialog:
self._local_audio_settings_dialog = None
dialog.finished.connect(clear_dialog)
dialog.open()
def _tab_index_for_key(self, key: str) -> int:
for index in range(self.tab_bar.count()):
if self.tab_bar.tabData(index) == key:
+7 -2
View File
@@ -312,9 +312,14 @@ def run_async(
on_error: Callable[[Exception], None] | None = None,
on_finished: Callable[[], None] | None = None,
pool: QThreadPool | None = None,
priority: int = 0,
**kwargs: Any,
) -> Worker:
"""Run ``function`` off the GUI thread and return its Worker handle."""
"""Run ``function`` off the GUI thread and return its Worker handle.
Higher-priority queued work starts first when a pool thread becomes free.
Running work is never interrupted.
"""
worker = Worker(function, *args, **kwargs)
_RUNNING_WORKERS.add(worker)
@@ -324,7 +329,7 @@ def run_async(
if on_finished is not None:
worker.signals.finished.connect(on_finished)
worker.signals.finished.connect(lambda: _RUNNING_WORKERS.discard(worker))
(pool or QThreadPool.globalInstance()).start(worker)
(pool or QThreadPool.globalInstance()).start(worker, priority)
return worker
+133 -5
View File
@@ -15,6 +15,7 @@ import threading
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
from .launcher import VideoCallRequest
@@ -110,6 +111,29 @@ def _extract_call_record_id(result: Any) -> int | str | None:
return None
def _extract_cloud_recording_outcome(result: Any) -> tuple[bool, str] | None:
"""Read the cloud mixed-video recording outcome returned by room binding."""
pending = [result]
visited: set[int] = set()
while pending:
candidate = pending.pop(0)
mapping = _mapping_candidate(candidate)
if mapping is None or id(mapping) in visited:
continue
visited.add(id(mapping))
recording = mapping.get("cloud_recording", mapping.get("cloudRecording"))
if isinstance(recording, Mapping):
started = bool(recording.get("started"))
message = str(recording.get("message") or "").strip()[:200]
return started, message
for key in ("data", "result"):
nested = mapping.get(key)
if isinstance(nested, Mapping):
pending.append(nested)
return None
def _clean_transcript_segment(segment: Mapping[str, Any], session_id: str) -> dict[str, Any]:
segment_id = str(segment.get("segment_id", segment.get("segmentId", ""))).strip()
text = str(segment.get("text", segment.get("sourceText", ""))).strip()
@@ -239,6 +263,7 @@ class OrderedCallLifecycle:
self._claimed_room_id: str | None = None
self._start_future: Future[bool] | None = None
self._bind_future: Future[bool] | None = None
self._local_recording_future: Future[bool] | None = None
self._end_future: Future[bool] | None = None
self._transcription_start_future: Future[bool] | None = None
self._transcription_finish_future: Future[bool] | None = None
@@ -256,6 +281,13 @@ class OrderedCallLifecycle:
def transcription_session_id(self) -> str | None:
return self._transcription_session_id
@property
def current_room_id(self) -> str | None:
"""Return the room observed for this call cycle, including an active bind."""
with self._lock:
return self.bound_room_id or self._claimed_room_id
def start(self) -> Future[bool]:
with self._lock:
if self._start_future is not None:
@@ -318,18 +350,31 @@ class OrderedCallLifecycle:
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
if not callable(method):
self.logger.warning(
"video repository does not implement bind_call_room",
extra={"video_call": self.request.safe_log_context()},
)
return False
_call_repository_method(
result = _call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id, "room_id": cleaned},
{
"diagnosis_id": self.request.diagnosis_id,
"room_id": cleaned,
"call_record_id": record_id,
},
)
recording_outcome = _extract_cloud_recording_outcome(result)
if recording_outcome is not None and not recording_outcome[0]:
raise RuntimeError(
recording_outcome[1]
or "automatic Tencent cloud mixed-video recording did not start"
)
with self._lock:
self.bound_room_id = cleaned
self.logger.info(
@@ -338,8 +383,85 @@ class OrderedCallLifecycle:
)
return True
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
future = self._worker.submit("bind", operation)
self._bind_future = future
def release_failed_claim(completed: Future[bool]) -> None:
try:
succeeded = bool(completed.result())
except Exception:
succeeded = False
if succeeded:
return
# A transient bridge/API failure must not permanently pin the
# room to a failed Future. The companion retries the exact
# same room after the host acknowledgement, so release only
# this failed claim while preserving successful bindings.
with self._lock:
if self._bind_future is completed and self.bound_room_id is None:
self._bind_future = None
self._claimed_room_id = None
future.add_done_callback(release_failed_claim)
return future
def save_local_audio_recording(
self,
path: str | Path,
*,
mime_type: str = "audio/webm",
) -> Future[bool]:
"""Upload one locally mixed audio file to this exact call record.
The operation shares the lifecycle FIFO, so a caller that queues this
before :meth:`end` is guaranteed to attach the COS object before the
call record is marked ended.
"""
recording_path = Path(path).expanduser().resolve()
clean_mime = str(mime_type or "audio/webm").strip()[:120] or "audio/webm"
if not recording_path.is_file() or recording_path.stat().st_size <= 0:
raise ValueError("local audio recording is empty or missing")
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
if self._local_recording_future is not None:
return self._local_recording_future
if self._start_future is None:
self.start()
method = getattr(self.repository, "upload_call_recording", None)
if not callable(method):
raise ValueError("video repository does not implement local recording upload")
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
result = _call_repository_method(
method,
{
"path": recording_path,
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"mime_type": clean_mime,
},
)
if result is False:
raise RuntimeError("local audio recording upload failed")
self.logger.info(
"local call audio uploaded and attached",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._local_recording_future = self._worker.submit(
"local-audio-upload", operation
)
return self._local_recording_future
def save_screenshot(self, content: bytes, filename: str) -> Future[str]:
"""Upload one video frame and append it to the diagnosis doctor notes."""
@@ -554,15 +676,21 @@ class OrderedCallLifecycle:
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
with self._lock:
self.ended = True
return False
if not callable(method):
raise ValueError("video repository does not implement end_call")
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id},
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
},
)
with self._lock:
self.ended = True
+504 -10
View File
@@ -11,6 +11,9 @@ import base64
import binascii
import json
import logging
import os
import re
import sqlite3
import sys
from collections.abc import Callable, Mapping
from concurrent.futures import Future
@@ -20,6 +23,11 @@ from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
from ..services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from .launcher import (
VideoCallRequest,
VideoTicketError,
@@ -29,7 +37,7 @@ from .lifecycle import OrderedCallLifecycle
from .security import TrustedDocumentError, TrustedDocumentPolicy
try: # Optional by design: core-only builds must still import this module.
from PySide6.QtCore import QObject, Qt, QUrl, Signal, Slot
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal, Slot
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import (
QWebEnginePage,
@@ -39,7 +47,7 @@ try: # Optional by design: core-only builds must still import this module.
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QApplication, QMainWindow
except (ImportError, OSError) as _qt_import_error: # pragma: no cover - no Qt runtime.
QObject = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QObject = QTimer = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QWebChannel = QWebEnginePage = QWebEngineProfile = None # type: ignore[assignment]
QWebEngineSettings = QWebEngineView = None # type: ignore[assignment]
QApplication = QMainWindow = None # type: ignore[assignment]
@@ -63,6 +71,18 @@ class CompanionLocation:
is_local: bool
@dataclass(slots=True)
class _LocalAudioCapture:
record_id: int
session_id: str
mime_type: str
path: Path
handle: Any
lifecycle: OrderedCallLifecycle
next_sequence: int = 0
bytes_written: int = 0
def _validate_remote_url(value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme.lower() != "https" or not parsed.hostname:
@@ -204,13 +224,67 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
}
)
@Slot(str, str) # type: ignore[misc]
def startLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, mime_type: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-start",
"sessionId": session_id,
"mimeType": mime_type,
}
)
@Slot(str, int, str) # type: ignore[misc]
def appendLocalAudioChunk( # noqa: N802 - Qt bridge API
self, session_id: str, sequence: int, encoded: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-chunk",
"sessionId": session_id,
"sequence": sequence,
"data": encoded,
}
)
@Slot(str, int) # type: ignore[misc]
def finishLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, total_bytes: int
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-finish",
"sessionId": session_id,
"totalBytes": total_bytes,
}
)
@Slot(str) # type: ignore[misc]
def abortLocalAudioRecording(self, session_id: str) -> None: # noqa: N802
self._callback(
{
"source": "doctor-call",
"event": "local-audio-abort",
"sessionId": session_id,
}
)
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]
_room_completed = Signal(str, bool, str) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
_local_recording_completed = Signal( # type: ignore[misc]
str, str, int, bool, str
)
def __init__(
self,
@@ -247,8 +321,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._close_reason = "window-closed"
self._call_cycle_closed = False
self._start_requested = False
self._shutdown_requested = False
self._local_audio_capture: _LocalAudioCapture | None = None
self._local_audio_store: LocalAudioQueueStore | None = None
self._local_audio_uploads: LocalAudioUploadManager | None = None
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self._shutdown_timer = QTimer(self)
self._shutdown_timer.setSingleShot(True)
self._shutdown_timer.timeout.connect(self._force_requested_shutdown)
self.setWindowTitle(
f"{self.patient_name} IM 问诊" if self.open_im else "视频面诊"
@@ -284,8 +365,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self._room_completed.connect(self._on_room_completed)
self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed)
self._local_recording_completed.connect(self._on_local_recording_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
@@ -428,6 +511,28 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
str(message.get("message") or "截屏图片无效。")[:200],
)
return
if event == "local-audio-start":
self._start_local_audio_recording(
str(message.get("sessionId") or ""),
str(message.get("mimeType") or "audio/webm"),
)
return
if event == "local-audio-chunk":
self._append_local_audio_chunk(
str(message.get("sessionId") or ""),
message.get("sequence"),
str(message.get("data") or ""),
)
return
if event == "local-audio-finish":
self._finish_local_audio_recording(
str(message.get("sessionId") or ""),
message.get("totalBytes"),
)
return
if event == "local-audio-abort":
self._abort_local_audio_recording(str(message.get("sessionId") or ""))
return
if event == "transcription-start-request":
self._start_transcription(
str(message.get("sessionId") or ""),
@@ -445,7 +550,13 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
clean_room_id = str(room_id).strip()
future = self.lifecycle.bind_room(clean_room_id)
future.add_done_callback(
lambda completed, current_room_id=clean_room_id: (
self._notify_room_completed(current_room_id, completed)
)
)
if event == "room":
return
if event == "status":
@@ -457,7 +568,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.lifecycle.end(f"companion-{status}")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
@@ -466,9 +577,343 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.lifecycle.end("companion-error")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-error")
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
except Exception as error:
succeeded = False
message = str(error)[:200] or "腾讯云混流视频录制未启动。"
else:
message = (
"腾讯云混流视频已启动;本机录音将在结束后另行上传 COS。"
if succeeded
else "通话房间尚未绑定,云端视频和本机录音无法关联通话记录。"
)
with suppress(RuntimeError):
self._room_completed.emit(room_id, succeeded, message)
def _on_room_completed(
self,
room_id: str,
succeeded: bool,
message: str,
) -> None:
if self._closing:
return
room_payload = json.dumps(str(room_id)[:160], ensure_ascii=True)
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
"window.doctorConsultation?.roomBindingResult?.("
f"{room_payload}, {state}, {payload});"
)
def _emit_local_recording_result(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
with suppress(RuntimeError):
self._local_recording_completed.emit(
operation,
session_id,
sequence,
succeeded,
str(message)[:200],
)
def _on_local_recording_completed(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
if self._closing or self._released:
return
self._page.runJavaScript(
"window.doctorConsultation?.localRecordingResult?.("
f"{json.dumps(operation)}, {json.dumps(session_id)}, {sequence}, "
f"{'true' if succeeded else 'false'}, "
f"{json.dumps(str(message)[:200], ensure_ascii=True)});"
)
def _start_local_audio_recording(self, session_id: str, mime_type: str) -> None:
cleaned = str(session_id or "").strip()
clean_mime = str(mime_type or "audio/webm").strip().lower()[:120]
if not re.fullmatch(r"[A-Za-z0-9_-]{12,64}", cleaned):
self._emit_local_recording_result(
"start", cleaned, -1, False, "本地录音会话标识无效。"
)
return
if not clean_mime.startswith(("audio/webm", "audio/ogg")):
self._emit_local_recording_result(
"start", cleaned, -1, False, "当前浏览器录音格式不受支持。"
)
return
if self._local_audio_capture is not None:
existing = self._local_audio_capture.session_id == cleaned
self._emit_local_recording_result(
"start",
cleaned,
-1,
existing,
"本地录音已启动。" if existing else "已有另一条本地录音正在进行。",
)
return
try:
lifecycle = self.lifecycle
raw_call_record_id = lifecycle.call_record_id
call_record_id = (
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
)
store, uploads = get_local_audio_upload_manager(
lifecycle.repository,
self._local_audio_store,
)
self._local_audio_store = store
self._local_audio_uploads = uploads
record = store.begin_recording(
session_id=cleaned,
diagnosis_id=self.request.diagnosis_id,
mime_type=clean_mime,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
# The handle intentionally remains open across WebChannel chunks.
handle = record.file_path.open("w+b")
except (OSError, RuntimeError, sqlite3.Error) as error:
self._emit_local_recording_result(
"start", cleaned, -1, False, str(error)[:200]
)
return
self._local_audio_capture = _LocalAudioCapture(
record_id=record.id,
session_id=cleaned,
mime_type=clean_mime,
path=record.file_path,
handle=handle,
lifecycle=lifecycle,
)
self._emit_local_recording_result(
"start", cleaned, -1, True, "本机语音录音已启动。"
)
def _append_local_audio_chunk(
self,
session_id: str,
sequence_value: Any,
encoded: str,
) -> None:
capture = self._local_audio_capture
try:
sequence = int(sequence_value)
except (TypeError, ValueError):
sequence = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音会话标识不匹配。"
)
return
if sequence != capture.next_sequence:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片顺序不连续。"
)
return
if not encoded or len(encoded) > 16_384:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片过大或为空。"
)
return
try:
content = base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error):
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片解析失败。"
)
return
if not content or len(content) > 12 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片大小无效。"
)
return
if capture.bytes_written + len(content) > 512 * 1024 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音超过 512 MB 限制。"
)
self._abort_local_audio_recording(session_id)
return
try:
capture.handle.write(content)
except OSError as error:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, str(error)[:200]
)
self._abort_local_audio_recording(session_id)
return
capture.bytes_written += len(content)
capture.next_sequence += 1
def _finish_local_audio_recording(
self, session_id: str, total_bytes_value: Any
) -> None:
capture = self._local_audio_capture
try:
total_bytes = int(total_bytes_value)
except (TypeError, ValueError):
total_bytes = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音会话标识不匹配。"
)
return
self._local_audio_capture = None
try:
capture.handle.flush()
os.fsync(capture.handle.fileno())
capture.handle.close()
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
if total_bytes != capture.bytes_written or total_bytes <= 0:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件不完整。"
)
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音文件不完整。"
)
return
if capture.bytes_written < 1024:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件为空或只有容器信息。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音文件为空或只有容器信息,已阻止上传。",
)
return
try:
with capture.path.open("rb") as recording:
signature = recording.read(4)
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
valid_signature = (
capture.mime_type.startswith("audio/webm")
and signature == b"\x1aE\xdf\xa3"
) or (
capture.mime_type.startswith("audio/ogg") and signature == b"OggS"
)
if not valid_signature:
self._mark_local_audio_invalid(
capture.record_id, "本地录音格式校验失败。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音格式校验失败,已阻止上传无效文件。",
)
return
store = self._local_audio_store
uploads = self._local_audio_uploads
if store is None or uploads is None:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本机录音队列尚未初始化。"
)
return
try:
store.finalize_recording(
capture.record_id,
size_bytes=capture.bytes_written,
)
except (OSError, RuntimeError, sqlite3.Error) as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
lifecycle = capture.lifecycle
def enqueue_upload(start_result: Future[bool] | None = None) -> None:
try:
if start_result is not None and not bool(start_result.result()):
raise RuntimeError("通话记录创建失败,录音已保存在本机,可稍后重试。")
raw_call_record_id = lifecycle.call_record_id
call_record_id = int(raw_call_record_id or 0)
if call_record_id <= 0:
raise RuntimeError("未取得通话记录编号,录音已保存在本机,可稍后重试。")
store.bind_identity(
capture.record_id,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
uploads.submit(capture.record_id)
except Exception as error:
with suppress(Exception):
store.update_status(
capture.record_id,
"failed",
str(error)[:1000],
)
if lifecycle.call_record_id:
enqueue_upload()
else:
try:
lifecycle.start().add_done_callback(enqueue_upload)
except Exception as error:
store.update_status(
capture.record_id,
"failed",
str(error)[:1000] or "通话记录创建失败。",
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
True,
"本地录音已保存,正在后台上传 COS。",
)
def _mark_local_audio_invalid(self, record_id: int, message: str) -> None:
store = self._local_audio_store
if store is None:
return
with suppress(Exception):
store.mark_invalid(record_id, message)
def _abort_local_audio_recording(self, session_id: str) -> None:
capture = self._local_audio_capture
if capture is None or (session_id and capture.session_id != session_id):
return
self._local_audio_capture = None
with suppress(OSError):
capture.handle.flush()
capture.handle.close()
self._mark_local_audio_invalid(
capture.record_id,
"本次本地录音未正常结束,文件已保留以便排查。",
)
def _start_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
@@ -633,7 +1078,49 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.close()
def hangup(self) -> None:
self._close_reason = "desktop-hangup"
self._request_companion_shutdown("desktop-hangup")
def _request_companion_shutdown(self, reason: str) -> None:
"""Let MediaRecorder finish and upload before WebEngine is destroyed."""
self._close_reason = reason
if self._closing or self._released:
return
should_wait_for_companion = (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
)
if not should_wait_for_companion:
self.close()
return
if self._shutdown_requested:
return
self._shutdown_requested = True
# doctorConsultation.close() stops MediaRecorder, drains every queued
# WebChannel chunk, waits for the Qt/COS finish acknowledgement, and
# only then emits hangup. Keeping _closing false here is essential:
# bridge callbacks are deliberately rejected once final destruction
# begins.
self._page.runJavaScript(
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self._shutdown_timer.start(190_000)
def _force_requested_shutdown(self) -> None:
"""Bound a failed companion shutdown without racing queued uploads."""
if not self._shutdown_requested or self._closing or self._released:
return
if self._start_requested and not self._call_cycle_closed:
# OrderedCallLifecycle places end after any upload that already
# reached the Qt bridge.
self.lifecycle.end(f"{self._close_reason}-timeout")
self._call_cycle_closed = True
self._start_requested = False
self._abort_local_audio_recording("")
self._companion_ended = True
self.close()
def _begin_shutdown(self) -> None:
@@ -641,12 +1128,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return
self._closing = True
self._media_active = False
if self._injected and not self._companion_ended and not self._released:
self._page.runJavaScript(
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self._shutdown_timer.stop()
if self._start_requested and not self._call_cycle_closed:
self.lifecycle.end(self._close_reason)
self._abort_local_audio_recording("")
self._release_webengine()
def wait_for_lifecycles(self, timeout: float) -> bool:
@@ -698,6 +1183,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._profile.deleteLater()
def closeEvent(self, event: Any) -> None:
if (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
):
event.ignore()
self._request_companion_shutdown(self._close_reason)
return
self._begin_shutdown()
event.accept()
+39
View File
@@ -3,7 +3,10 @@
from __future__ import annotations
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Barrier, get_ident
from typing import Any
import httpx
import pytest
@@ -17,6 +20,7 @@ from doctor_workstation.core.errors import (
OpenPageRequiredError,
WorkWechatBindingRequiredError,
)
from doctor_workstation.services import api_client as api_client_module
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.token_store import TokenStore
@@ -48,6 +52,41 @@ def test_get_normalises_adminapi_and_sends_contract_headers() -> None:
)
def test_default_client_allows_parallel_requests_with_independent_transports(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Production workers must not queue behind one process-wide HTTP lock."""
rendezvous = Barrier(2, timeout=2)
created: list[Any] = []
request_threads: set[int] = set()
class PooledClient:
def __init__(self, **_options: Any) -> None:
self.closed = False
created.append(self)
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
request_threads.add(get_ident())
rendezvous.wait()
return httpx.Response(200, json={"code": 1, "data": url.rsplit("/", 1)[-1]})
def close(self) -> None:
self.closed = True
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
client = ApiClient("https://example.test", max_retries=0)
with ThreadPoolExecutor(max_workers=2) as executor:
first = executor.submit(client.get, "patient/first")
second = executor.submit(client.get, "patient/second")
assert {first.result(timeout=3), second.result(timeout=3)} == {"first", "second"}
client.close()
assert len(created) == 2
assert len(request_threads) == 2
assert all(item.closed for item in created)
def test_post_uses_json_and_never_retries_timeout() -> None:
"""Writes use JSON and a timeout never causes an automatic duplicate POST."""
+248 -22
View File
@@ -10,7 +10,14 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QAbstractItemView, QApplication, QDialog, QDialogButtonBox, QLabel
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QDialog,
QDialogButtonBox,
QLabel,
QPushButton,
)
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
@@ -188,6 +195,106 @@ def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
)
assert prescription_action_label(pending) == "编辑处方"
historical_only = Appointment.from_dict(
{
"id": 104,
"prescription_audit_status": 1,
"prescription_void_status": 0,
"has_prescription": 1,
"current_has_prescription": 0,
"current_prescription_id": 0,
}
)
assert prescription_action_label(historical_only) == "开方"
def test_appointment_view_intent_never_degrades_to_edit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row = {
"id": 101,
"appointment_id": 101,
"diagnosis_id": 501,
"current_has_prescription": 1,
"current_prescription_id": 81,
"prescription_audit_status": 1,
"prescription_void_status": 0,
}
page = AppointmentsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
monkeypatch.setattr(page, "_current_row", lambda: row)
requested: list[tuple[Any, str]] = []
monkeypatch.setattr(
page,
"_begin_prescription_load",
lambda source, *, mode="open": requested.append((source, mode)),
)
page._open_prescription()
assert requested == [(row, "view")]
monkeypatch.setattr(
page,
"_open_existing_prescription_editor",
lambda _existing: pytest.fail("view intent must never open the editor"),
)
page._prescription_loaded(
{
"id": 81,
"appointment_id": 101,
"audit_status": 2,
"void_status": 0,
},
row,
page._prescription_generation,
"view",
)
assert "状态已变化" in page.banner.label.text()
page.close()
application.processEvents()
def test_appointment_im_action_opens_chat_without_an_existing_live_call(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
row = {
"id": 101,
"appointment_id": 101,
"diagnosis_id": 501,
"patient_id": 501,
"source_patient_id": 301,
"patient_name": "测试患者",
"status": 1,
}
emitted: list[dict[str, Any]] = []
monkeypatch.setattr(page, "_current_row", lambda: row)
page.video_requested.connect(emitted.append)
page._request_video()
assert len(emitted) == 1
assert emitted[0]["mode"] == "im"
assert emitted[0]["appointment_id"] == 101
assert emitted[0]["diagnosis_id"] == 501
assert emitted[0]["patient_id"] == 301
warnings: list[str] = []
monkeypatch.setattr(
appointments_module,
"show_toast",
lambda _parent, message, _kind: warnings.append(message),
)
row["status"] = 2
page._request_video()
assert len(emitted) == 1
assert warnings == ["已取消的挂号不可进入 IM 问诊。"]
page.close()
application.processEvents()
def test_appointment_pending_prescription_uses_full_edit_contract(
application: QApplication,
@@ -508,15 +615,20 @@ def test_appointment_ai_report_button_visible_with_reception_permission(
application.processEvents()
def test_appointments_reference_split_layout_and_video_list(
def test_appointments_use_full_width_table_with_per_row_im_consult(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(appointments_module, "run_async", lambda *_args, **_kwargs: object())
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/lists"]),
permissions=PermissionSet(
["doctor.appointment/lists", "doctor.appointment/prescription"]
),
current_user={"id": 1001, "role_id": 1},
)
page.resize(1460, 820)
page.show()
page._apply_responsive_layout()
page._loaded(
{
@@ -524,6 +636,7 @@ def test_appointments_reference_split_layout_and_video_list(
{
"id": 101,
"diagnosis_id": 501,
"source_patient_id": 301,
"patient_name": "赵俊霞",
"gender": 0,
"age": 53,
@@ -532,6 +645,7 @@ def test_appointments_reference_split_layout_and_video_list(
"appointment_time": "09:50",
"status": 1,
"status_desc": "已挂号",
"video_call_hint": {"state": "live", "label": "视频通话进行中"},
}
],
"count": 1,
@@ -541,32 +655,143 @@ def test_appointments_reference_split_layout_and_video_list(
)
application.processEvents()
assert page.video_list.count() == 1
assert "赵俊霞" in page.video_list.item(0).text()
assert 300 <= page.video_panel.width() <= 420
assert page.content_layout.count() == 1
assert not hasattr(page, "video_panel")
assert not hasattr(page, "video_list")
assert page.table_card.width() == page.content_host.width()
assert page.table.objectName() == "AppointmentTable"
assert (
page.table.verticalScrollMode()
== QAbstractItemView.ScrollMode.ScrollPerPixel
)
assert (
page.video_list.verticalScrollMode()
== QAbstractItemView.ScrollMode.ScrollPerPixel
)
assert page.table.horizontalHeaderItem(10).text() == "IM 问诊"
im_host = page.table.cellWidget(0, 10)
assert im_host is not None
im_button = im_host.findChild(QPushButton, "AppointmentImConsultButton")
assert im_button is not None
assert im_button.text() == "IM 问诊"
assert im_button.isEnabled()
assert im_button.accessibleName() == "与赵俊霞进行 IM 问诊"
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
im_button.click()
assert emitted and emitted[0]["mode"] == "im"
assert emitted[0]["appointment_id"] == 101
assert page.date_buttons["today"].isChecked()
page.resize(1024, 640)
page._apply_responsive_layout()
assert page.video_panel.isHidden()
assert not page.video_panel_button.isHidden()
application.processEvents()
assert page.table_card.width() == page.content_host.width()
assert not page.date_overflow_button.isHidden()
assert page.date_buttons["yesterday"].isHidden()
assert not page.date_buttons["today"].isHidden()
page.video_panel_button.click()
assert not page.video_panel.isHidden()
assert 250 <= page.video_panel.width() <= 300
page.video_panel_button.click()
assert page.video_panel.isHidden()
page.close()
application.processEvents()
def test_im_entry_does_not_require_a_live_video_hint(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
rows = [
{
"id": 101,
"diagnosis_id": 501,
"source_patient_id": 301,
"patient_name": "已接通患者",
"status": 1,
"video_call_hint": {"state": "ended", "label": "视频通话已结束"},
},
{
"id": 102,
"diagnosis_id": 502,
"source_patient_id": 302,
"patient_name": "等待患者",
"status": 1,
},
]
page._loaded({"lists": rows, "count": 2}, page._generation, False)
application.processEvents()
buttons = [
page.table.cellWidget(index, 10).findChild(
QPushButton,
"AppointmentImConsultButton",
)
for index in range(page.table.rowCount())
]
buttons_by_name = {
button.accessibleName(): button for button in buttons if button is not None
}
assert set(buttons_by_name) == {
"与已接通患者进行 IM 问诊",
"与等待患者进行 IM 问诊",
}
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
waiting = buttons_by_name["与等待患者进行 IM 问诊"]
assert waiting.isEnabled()
assert waiting.toolTip() == "打开患者 IM,可发送消息并从会话中发起视频"
page.close()
application.processEvents()
def test_im_entry_allows_fulfillable_statuses_and_rejects_terminal_or_unknown_statuses(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
rows = [
{
"id": index + 100,
"diagnosis_id": index + 500,
"source_patient_id": index + 300,
"patient_name": name,
"status": status,
}
for index, (name, status) in enumerate(
(
("已预约患者", 1),
("已过号患者", 4),
("已取消患者", 2),
("已完成患者", 3),
("未知状态患者", 0),
)
)
]
page._loaded({"lists": rows, "count": len(rows)}, page._generation, False)
application.processEvents()
buttons = {
button.accessibleName(): button
for row_index in range(page.table.rowCount())
if (
button := page.table.cellWidget(row_index, 10).findChild(
QPushButton,
"AppointmentImConsultButton",
)
)
is not None
}
assert buttons["与已预约患者进行 IM 问诊"].isEnabled()
assert buttons["与已过号患者进行 IM 问诊"].isEnabled()
assert not buttons["与已取消患者进行 IM 问诊"].isEnabled()
assert buttons["与已取消患者进行 IM 问诊"].toolTip() == (
"已取消的挂号不可进入 IM 问诊"
)
assert not buttons["与已完成患者进行 IM 问诊"].isEnabled()
assert buttons["与已完成患者进行 IM 问诊"].toolTip() == (
"已完成的挂号不可再进入 IM 问诊"
)
assert not buttons["与未知状态患者进行 IM 问诊"].isEnabled()
assert buttons["与未知状态患者进行 IM 问诊"].toolTip() == (
"当前挂号状态不可进入 IM 问诊"
)
page.close()
application.processEvents()
@@ -601,19 +826,20 @@ def test_identical_appointment_poll_keeps_existing_cell_widgets(
page._loaded(result, page._generation, True)
selector = page.table.cellWidget(0, 0)
appointment_info = page.table.cellWidget(0, 4)
video_card = page.video_list.itemWidget(page.video_list.item(0))
im_action = page.table.cellWidget(0, 10)
page._loaded(deepcopy(result), page._generation, True)
assert page.table.cellWidget(0, 0) is selector
assert page.table.cellWidget(0, 4) is appointment_info
assert page.video_list.itemWidget(page.video_list.item(0)) is video_card
assert page.table.cellWidget(0, 10) is im_action
changed = deepcopy(result)
changed["lists"][0]["assistant_name"] = "新医助"
page._loaded(changed, page._generation, True)
assert page.table.cellWidget(0, 0) is not selector
assert page.table.cellWidget(0, 4) is not appointment_info
assert page.table.cellWidget(0, 10) is not im_action
page.close()
application.processEvents()
@@ -665,11 +891,11 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
assert all(60 <= height <= 66 for height in heights)
assert page.table.viewport().height() // max(heights) >= 4
assert page.pager.isVisibleTo(page)
assert 300 <= page.video_panel.width() < 420
assert page.content_layout.count() == 1
assert page.table_card.width() == page.content_host.width()
page.resize(1024, 640)
application.processEvents()
assert page.video_panel.isHidden()
assert not page.video_panel_button.isHidden()
assert page.table_card.width() == page.content_host.width()
page.close()
application.processEvents()
+118 -12
View File
@@ -169,7 +169,7 @@ def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
assert payload["patient_id"] == 301
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
row = _row(
appointment_id=None,
appointment_status=None,
@@ -195,6 +195,62 @@ def test_nested_appointments_confirmation_and_prescription_labels() -> None:
)
== "编辑处方"
)
assert (
prescription_action_label(
{
"has_prescription": 1,
"current_has_prescription": 0,
"prescription_audit_status": 1,
"prescription_void_status": 0,
}
)
== "开方"
)
def test_view_prescription_intent_is_readonly_and_fails_closed_on_state_change(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row = _row(
has_prescription=1,
current_has_prescription=1,
current_prescription_id=701,
prescription_audit_status=1,
prescription_void_status=0,
)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.table_host.set_rows([row])
page.table.selectRow(0)
requested: list[tuple[Any, str]] = []
monkeypatch.setattr(
page,
"_begin_prescription_load",
lambda record, *, mode: requested.append((record, mode)),
)
page._open_prescription()
assert requested == [(row, "view")]
monkeypatch.setattr(
page,
"_open_existing_prescription_editor",
lambda _existing: pytest.fail("view intent must never open the editor"),
)
page._prescription_loaded(
{
"id": 701,
"appointment_id": 101,
"audit_status": 0,
"void_status": 0,
},
row,
"view",
page._prescription_generation,
)
assert "状态已变化" in page.banner.label.text()
page.close()
application.processEvents()
def test_default_query_matches_admin_today_and_page_size_contract(
@@ -611,7 +667,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
application.processEvents()
def test_native_call_does_not_reuse_video_qr_permission(
def test_native_call_does_not_reuse_video_qr_permission(
application: QApplication,
) -> None:
class VideoRepository:
@@ -627,10 +683,17 @@ def test_native_call_does_not_reuse_video_qr_permission(
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row()])
live_row = _row(
video_call_hint={
"state": "live",
"label": "视频通话进行中",
"start_time": 1787102100,
}
)
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([live_row])
page.table.selectRow(0)
application.processEvents()
@@ -638,12 +701,55 @@ def test_native_call_does_not_reuse_video_qr_permission(
assert page.video_button.isEnabled()
video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
video_action = next(button for button in video_cell.findChildren(QToolButton))
assert video_action.text() == "进入视频问诊"
assert "摄像头和麦克风" in video_action.toolTip()
page._request_video()
assert emitted == [_video_payload(_row())]
page.close()
application.processEvents()
assert video_action.text() == "进入视频问诊"
assert emitted == []
assert "摄像头和麦克风" in video_action.toolTip()
page._request_video()
assert emitted == [_video_payload(live_row)]
assert emitted[0]["mode"] == "im"
page.close()
application.processEvents()
@pytest.mark.parametrize(
("hint", "status_text"),
[
({"state": "none", "label": ""}, "暂无通话"),
({"state": "pending_room", "label": "通话发起中,待同步房间"}, "等待接通"),
],
)
def test_video_join_action_is_hidden_until_doctor_session_is_live(
application: QApplication,
hint: dict[str, Any],
status_text: str,
) -> None:
class VideoRepository:
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> None:
pass
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> None:
pass
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
pass
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row(video_call_hint=hint)])
page.table.selectRow(0)
application.processEvents()
video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
assert video_cell.findChildren(QToolButton) == []
assert status_text in " ".join(label.text() for label in video_cell.findChildren(QLabel))
page._request_video()
assert emitted == []
page.close()
application.processEvents()
@pytest.mark.parametrize(
+2
View File
@@ -146,6 +146,8 @@ def _row(identifier: int, **changes: Any) -> dict[str, Any]:
"assistant_name": "赵医助",
"assign_read_at": None,
"has_prescription": 1,
"current_has_prescription": 0,
"current_prescription_id": 0,
"followup_time_text": "2026-08-17 09:00",
"followup_doctor_name": "陈医生",
"unserved_days": 2,
+136 -1
View File
@@ -1,12 +1,14 @@
from __future__ import annotations
import os
import threading
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton, QVBoxLayout
from PySide6.QtWidgets import QApplication, QDialog, QFrame, QLabel, QPushButton, QVBoxLayout
from doctor_workstation.ui import diagnosis_media
from doctor_workstation.ui.diagnosis_drawer import RecordTable
@@ -378,6 +380,7 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
[
{
"id": 48,
"room_id": "doctor-501-20260819-143247",
"recording_urls_list": [
"https://media.example.invalid/replay.mp4",
"https://media.example.invalid/replay-backup.m3u8",
@@ -407,6 +410,12 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
application.processEvents()
table = dialog._table_registry["video"][1]
assert table.horizontalHeaderItem(1).text() == "房间号"
assert table.item(0, 1).text() == "doctor-501-20260819-143247"
assert table.item(1, 1).text() == "历史记录未保存"
room_rect = table.visualItemRect(table.item(0, 1))
assert room_rect.left() >= 0
assert room_rect.right() < table.viewport().width()
playback = table.cellWidget(0, 0)
assert isinstance(playback, RecordingPlaybackCell)
assert playback.property("callRecordId") == 48
@@ -465,6 +474,132 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
application.processEvents()
def test_video_table_exposes_local_audio_separately_from_cloud_video_and_text(
application: QApplication,
) -> None:
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
dialog._editable = False
dialog._can_video_upload = False
dialog._diagnosis_id = 501
opened: list[str] = []
dialog._open_recording_player = lambda target: opened.append(target) # type: ignore[method-assign]
audio_url = "https://cos.example.invalid/calls/local-audio.webm"
dialog._fill_video(
[
{
"id": 49,
"recording_urls_list": [
"https://cos.example.invalid/calls/cloud-mixed-video.mp4"
],
"local_audio_urls_list": [audio_url],
"local_audio_status_text": "已保存",
"transcript_text": "医生:请描述症状。\n患者:最近口渴。",
"transcription_status_text": "已完成",
"call_type": 2,
"status": 2,
"recording_status_text": "已生成",
}
]
)
table = dialog._table_registry["video"][1]
action_host = table.cellWidget(0, 8)
assert action_host is not None
audio = action_host.findChild(QPushButton, "DiagnosisLocalAudioPlayback")
transcript = action_host.findChild(QPushButton, "DiagnosisVideoTranscriptView")
assert audio is not None and audio.property("callRecordId") == 49
assert transcript is not None and transcript.property("callRecordId") == 49
status = table.item(0, 7).text()
assert "云端视频:已生成" in status
assert "本机录音:已保存" in status
assert "转写文字:已完成" in status
audio.click()
assert opened == [audio_url]
dialog.close()
application.processEvents()
def test_local_audio_upload_and_queue_close_refresh_server_backed_video_rows(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created: list[QDialog] = []
class _UploadManager:
def __init__(self) -> None:
self.listeners: set[Any] = set()
def add_upload_listener(self, listener: Any) -> None:
self.listeners.add(listener)
def remove_upload_listener(self, listener: Any) -> None:
self.listeners.discard(listener)
def complete(self, diagnosis_id: int, call_record_id: int) -> None:
record = SimpleNamespace(
diagnosis_id=diagnosis_id,
call_record_id=call_record_id,
)
for listener in tuple(self.listeners):
listener(record)
manager = _UploadManager()
class _QueueDialog(QDialog):
def __init__(
self,
_repository: Any,
_diagnosis_id: int,
parent: QDialog,
) -> None:
super().__init__(parent)
self.manager = manager
created.append(self)
monkeypatch.setattr(diagnosis_module, "LocalAudioQueueDialog", _QueueDialog)
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
dialog._diagnosis_id = 501
dialog._can_video_upload = True
reloads: list[tuple[str, bool]] = []
dialog._ensure_tab_loaded = ( # type: ignore[method-assign]
lambda key, force=False: reloads.append((key, force))
)
dialog._open_local_audio_queue()
assert len(created) == 1
queue_dialog = created[0]
assert reloads == [("video", True)]
dialog._loading_tabs.add("video")
manager.complete(501, 49)
manager.complete(501, 50)
manager.complete(999, 51)
application.processEvents()
assert reloads == [("video", True)]
assert dialog._video_reload_pending is True
dialog._loading_tabs.discard("video")
dialog._flush_video_reload_if_pending(501)
application.processEvents()
assert reloads == [("video", True), ("video", True)]
queue_dialog.accept()
application.processEvents()
assert reloads == [("video", True), ("video", True)]
assert manager.listeners
worker = threading.Thread(target=lambda: manager.complete(501, 52))
worker.start()
worker.join(timeout=5)
assert worker.is_alive() is False
application.processEvents()
assert reloads == [("video", True), ("video", True), ("video", True)]
dialog.reject()
application.processEvents()
assert manager.listeners == set()
def test_inline_player_rejects_stale_owner_generation(application: QApplication) -> None:
class _Owner:
_tab_generations = {"video": 4}
+482
View File
@@ -0,0 +1,482 @@
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
from typing import Any
import pytest
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioUploadManager,
)
from doctor_workstation.ui.dialogs.local_audio_queue import (
LocalAudioQueueDialog,
_display_time,
)
def _application() -> QApplication:
return QApplication.instance() or QApplication([])
def _ready_record(
store: LocalAudioQueueStore,
*,
diagnosis_id: int,
call_record_id: int,
session_id: str,
room_id: str = "",
) -> int:
record = store.begin_recording(
session_id=session_id,
diagnosis_id=diagnosis_id,
mime_type="audio/webm",
call_record_id=call_record_id,
room_id=room_id,
)
payload = b"\x1aE\xdf\xa3" + (b"local-call-audio" * 128)
record.file_path.write_bytes(payload)
finalized = store.finalize_recording(record.id, size_bytes=len(payload))
return finalized.id
class _ConcurrentRepository:
def __init__(self, expected: int) -> None:
self.expected = expected
self.lock = threading.Lock()
self.release = threading.Event()
self.all_started = threading.Event()
self.active = 0
self.maximum_active = 0
self.calls: list[dict[str, Any]] = []
def upload_call_recording(self, **payload: Any) -> dict[str, str]:
path = Path(payload["path"])
assert path.is_file()
with self.lock:
self.active += 1
self.maximum_active = max(self.maximum_active, self.active)
self.calls.append(payload)
if self.active >= self.expected:
self.all_started.set()
try:
assert self.release.wait(5), "concurrent uploads did not receive release"
return {"file_url": f"cos://recordings/{path.name}"}
finally:
with self.lock:
self.active -= 1
class _RetryRepository:
def __init__(self) -> None:
self.calls = 0
self.call_records: dict[int, list[dict[str, Any]]] = {}
self.list_calls: list[int] = []
def upload_call_recording(self, **payload: Any) -> dict[str, str]:
self.calls += 1
if self.calls == 1:
raise RuntimeError("COS 暂时不可用")
return {"file_url": f"cos://recordings/{Path(payload['path']).name}"}
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
self.list_calls.append(int(diagnosis_id))
return list(self.call_records.get(int(diagnosis_id), []))
def test_existing_queue_schema_adds_room_id_without_losing_rows(
tmp_path: Path,
) -> None:
root = tmp_path / "old-audio-queue"
root.mkdir()
database = root / "queue.sqlite3"
with sqlite3.connect(database) as connection:
connection.execute(
"""
CREATE TABLE local_audio_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
diagnosis_id INTEGER NOT NULL,
call_record_id INTEGER,
mime_type TEXT NOT NULL,
file_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_text TEXT NOT NULL DEFAULT '',
uploaded_url TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT ''
)
"""
)
connection.execute(
"""
INSERT INTO local_audio_uploads (
session_id, diagnosis_id, call_record_id, mime_type, file_path,
size_bytes, status, error_text, uploaded_url, attempts,
created_at, updated_at, uploaded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"legacy-uploaded-session",
8169,
901,
"audio/webm",
str(root / "legacy.webm"),
2048,
"uploaded",
"",
"cos://recordings/legacy.webm",
2,
"2026-08-20T01:00:00+00:00",
"2026-08-20T01:02:00+00:00",
"2026-08-20T01:02:00+00:00",
),
)
store = LocalAudioQueueStore(root)
record = store.list_records()[0]
with sqlite3.connect(database) as connection:
columns = {
str(row[1])
for row in connection.execute(
"PRAGMA table_info(local_audio_uploads)"
).fetchall()
}
assert "room_id" in columns
assert record.room_id == ""
assert record.status == "uploaded"
assert record.uploaded_url == "cos://recordings/legacy.webm"
assert record.attempts == 2
assert LocalAudioQueueStore(root).require(record.id).room_id == ""
def test_local_audio_identity_binding_is_idempotent_and_rejects_conflicts(
tmp_path: Path,
) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record = store.begin_recording(
session_id="identity-binding-session",
diagnosis_id=8169,
mime_type="audio/webm",
call_record_id=901,
room_id="00123456",
)
assert record.call_record_id == 901
assert record.room_id == "00123456"
assert store.bind_identity(
record.id,
call_record_id=901,
room_id="00123456",
).room_id == "00123456"
assert store.bind_identity(record.id, call_record_id=901).room_id == "00123456"
with pytest.raises(RuntimeError, match="房间号发生冲突"):
store.bind_identity(record.id, call_record_id=901, room_id="99887766")
with pytest.raises(RuntimeError, match="通话记录 ID 或房间号发生冲突"):
store.bind_identity(record.id, call_record_id=902, room_id="00123456")
assert store.require(record.id).call_record_id == 901
assert store.require(record.id).room_id == "00123456"
def test_local_audio_queue_uploads_three_files_concurrently(tmp_path: Path) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_ids = [
_ready_record(
store,
diagnosis_id=8169,
call_record_id=900 + index,
session_id=f"session-{index}",
)
for index in range(3)
]
repository = _ConcurrentRepository(expected=3)
manager = LocalAudioUploadManager(repository, store, max_workers=3)
futures = [manager.submit(record_id) for record_id in record_ids]
try:
assert repository.all_started.wait(5)
assert repository.maximum_active == 3
finally:
repository.release.set()
assert [future.result(timeout=5) for future in futures] == [True, True, True]
records = [store.require(record_id) for record_id in record_ids]
assert all(record.status == "uploaded" for record in records)
assert all(record.exists for record in records)
assert all(record.uploaded_url.startswith("cos://recordings/") for record in records)
assert {call["call_record_id"] for call in repository.calls} == {900, 901, 902}
def test_failed_local_audio_is_kept_and_can_be_retried(tmp_path: Path) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="retry-session",
)
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
assert manager.submit(record_id).result(timeout=5) is False
failed = store.require(record_id)
assert failed.status == "failed"
assert failed.exists
assert failed.attempts == 1
assert "COS 暂时不可用" in failed.error_text
store.retry(record_id)
assert manager.submit(record_id).result(timeout=5) is True
uploaded = store.require(record_id)
assert uploaded.status == "uploaded"
assert uploaded.exists
assert uploaded.attempts == 2
assert uploaded.uploaded_url.startswith("cos://recordings/")
def test_local_audio_manager_notifies_when_an_upload_reaches_uploaded(
tmp_path: Path,
) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
repository.calls = 1
manager = LocalAudioUploadManager(repository, store, max_workers=1)
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="upload-notification-session",
)
uploads: list[tuple[int, int, str]] = []
def record_upload(record: Any) -> None:
uploads.append(
(record.diagnosis_id, int(record.call_record_id or 0), record.uploaded_url)
)
manager.add_upload_listener(record_upload)
assert manager.submit(record_id).result(timeout=5) is True
assert manager.submit(record_id).result(timeout=5) is True
manager.remove_upload_listener(record_upload)
assert uploads == [
(
8169,
901,
store.require(record_id).uploaded_url,
)
]
def test_local_audio_manager_does_not_report_success_without_uploaded_url(
tmp_path: Path,
) -> None:
class _IncompleteRepository:
@staticmethod
def upload_call_recording(**_payload: Any) -> dict[str, bool]:
return {"completed": True}
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="missing-url-session",
)
manager = LocalAudioUploadManager(_IncompleteRepository(), store, max_workers=1)
uploads: list[Any] = []
manager.add_upload_listener(uploads.append)
assert manager.submit(record_id).result(timeout=5) is False
record = store.require(record_id)
assert record.status == "failed"
assert "文件地址" in record.error_text
assert uploads == []
def test_local_audio_dialog_displays_utc_recording_time_in_business_timezone() -> None:
assert _display_time("2026-08-20T01:51:00+00:00") == (
"2026-08-20 09:51:00"
)
def test_local_audio_dialog_lists_status_and_retry_controls(tmp_path: Path) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
failed_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="failed-session",
room_id="67534825",
)
store.update_status(failed_id, "failed", "等待医生重试")
uploaded_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=902,
session_id="uploaded-session",
room_id="1692231119",
)
store.mark_uploaded(uploaded_id, "cos://recordings/uploaded.webm")
dialog = LocalAudioQueueDialog(
repository,
8169,
store=store,
manager=manager,
)
dialog.show()
application.processEvents()
try:
assert dialog.objectName() == "LocalAudioQueueDialog"
assert dialog.table.columnCount() == 8
assert dialog.table.rowCount() == 2
assert dialog.summary_failed.text() == "失败 1"
assert dialog.summary_uploaded.text() == "已上传 1"
assert dialog.retry_failed_button.isEnabled()
assert dialog.table.horizontalHeaderItem(1).text() == "通话记录 ID"
assert dialog.table.horizontalHeaderItem(2).text() == "房间号"
assert dialog.table.horizontalHeaderItem(5).text() == "上传状态"
assert dialog.table.horizontalHeaderItem(6).text() == "失败原因"
assert dialog.table.columnWidth(dialog._room_column) == 180
rooms = {
dialog.table.item(row, 2).text() for row in range(dialog.table.rowCount())
}
assert rooms == {"67534825", "1692231119"}
statuses = {
dialog.table.item(row, 5).text() for row in range(dialog.table.rowCount())
}
assert statuses == {"上传失败", "已上传"}
finally:
dialog.close()
application.processEvents()
def test_global_local_audio_dialog_lists_all_diagnoses_and_outcomes(
tmp_path: Path,
) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
failed_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="global-failed-session",
room_id="67534825",
)
store.update_status(failed_id, "failed", "等待医生重试")
uploaded_id = _ready_record(
store,
diagnosis_id=9001,
call_record_id=902,
session_id="global-uploaded-session",
room_id="407179477",
)
store.mark_uploaded(uploaded_id, "cos://recordings/global-uploaded.webm")
dialog = LocalAudioQueueDialog(
repository,
None,
store=store,
manager=manager,
)
dialog.show()
application.processEvents()
try:
assert dialog.title_label.text() == "本机录音上传管理"
assert dialog.table.columnCount() == 9
assert dialog.table.rowCount() == 2
assert dialog.table.horizontalHeaderItem(1).text() == "诊单 ID"
assert dialog.table.horizontalHeaderItem(2).text() == "通话记录 ID"
assert dialog.table.horizontalHeaderItem(3).text() == "房间号"
assert dialog.table.horizontalHeaderItem(6).text() == "上传状态"
diagnosis_ids = {
dialog.table.item(row, 1).text()
for row in range(dialog.table.rowCount())
}
assert diagnosis_ids == {"8169", "9001"}
statuses = {
dialog.table.item(row, 6).text()
for row in range(dialog.table.rowCount())
}
assert statuses == {"上传失败", "已上传"}
rooms = {
dialog.table.item(row, 3).text()
for row in range(dialog.table.rowCount())
}
assert rooms == {"67534825", "407179477"}
assert dialog.summary_failed.text() == "失败 1"
assert dialog.summary_uploaded.text() == "已上传 1"
finally:
dialog.close()
application.processEvents()
def test_dialog_fetches_and_persists_historical_room_ids_once_per_diagnosis(
tmp_path: Path,
) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
repository.call_records[8169] = [
{"id": 902, "room_id": "1692231119"},
{"id": 901, "room_id": "67534825"},
]
manager = LocalAudioUploadManager(repository, store, max_workers=1)
first_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="legacy-room-first",
)
second_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=902,
session_id="legacy-room-second",
)
dialog = LocalAudioQueueDialog(
repository,
8169,
store=store,
manager=manager,
)
dialog.show()
try:
for _ in range(200):
application.processEvents()
if store.require(first_id).room_id and store.require(second_id).room_id:
break
QTest.qWait(10)
assert store.require(first_id).room_id == "67534825"
assert store.require(second_id).room_id == "1692231119"
assert repository.list_calls == [8169]
for _ in range(3):
dialog.refresh_records()
application.processEvents()
assert repository.list_calls == [8169]
assert {
dialog.table.item(row, 2).text()
for row in range(dialog.table.rowCount())
} == {"67534825", "1692231119"}
finally:
dialog.close()
application.processEvents()
+28
View File
@@ -295,6 +295,34 @@ def test_demo_transcript_upsert_and_finish_round_trip_in_call_records(
assert "final words" in record["transcript_text"]
def test_demo_local_audio_is_preserved_separately_from_cloud_video_and_text(
repository: DemoDoctorRepository,
tmp_path: Path,
) -> None:
started = repository.start_call(501, 301)
call_record_id = int(started["id"])
audio = tmp_path / "local-call.webm"
audio.write_bytes(b"webm-opus-audio")
result = repository.upload_call_recording(
audio,
501,
call_record_id=call_record_id,
mime_type="audio/webm;codecs=opus",
)
record = next(
row for row in repository.list_call_records(501) if row["id"] == call_record_id
)
assert result["media_kind"] == "local_audio"
assert result["call_record_id"] == call_record_id
assert record["local_audio_status"] == 2
assert record["local_audio_status_text"] == "已保存"
assert record["local_audio_urls_list"] == [result["file_url"]]
assert record["recording_urls_list"] == []
assert record["transcript_text"] == ""
def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None:
"""List parsing handles nullable fields, aliases and non-object rows safely."""
+620
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from datetime import date
from typing import Any
@@ -40,8 +41,11 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
pool: Any = None,
priority: int = 0,
**kwargs: Any,
) -> object:
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
@@ -305,6 +309,64 @@ def test_openai_failure_keeps_new_qwen_snapshot(
page.close()
def test_finished_only_cached_regeneration_unlocks_retry_and_keeps_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(109, 301, 509)
saved = _snapshot("qwen", 1, "2026-08-14 10:45:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": [saved]})
jobs[1]["on_finished"]()
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_analysis_regenerate_button.isEnabled()
page.ai_analysis_regenerate_button.click()
assert len(jobs) == 3
assert page._ai_analysis_regenerating
assert not page.ai_analysis_regenerate_button.isEnabled()
jobs[2]["on_finished"]()
assert not page._ai_analysis_regenerating
assert page._ai_analysis_regeneration_model is None
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
assert page.ai_analysis_regenerate_button.isEnabled()
assert "未返回有效结果" in page.ai_analysis_secondary_status.text()
page.close()
def test_late_patient_history_response_is_discarded_after_switch(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -353,6 +415,7 @@ def test_late_patient_history_response_is_discarded_after_switch(
first_history_job = jobs[1]
page._select_record(second["appointment"])
assert first_history_job["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
finish(jobs[2])
second_history_job = jobs[3]
finish(second_history_job)
@@ -364,6 +427,563 @@ def test_late_patient_history_response_is_discarded_after_switch(
page.close()
def test_aba_switch_attaches_to_inflight_patient_generation_without_duplicate_post(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(110, 301, 510)
second = _detail(111, 302, 511)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 1, "2026-08-14 12:00:00")
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
first_qwen_job = jobs[2]
qwen_result = first_qwen_job["function"]()
page._select_record(second["appointment"])
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 5
first_qwen_job["on_success"](qwen_result)
first_qwen_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == 6
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
def test_patient_history_get_is_singleflight_across_aba_switch(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(112, 301, 512)
second = _detail(113, 302, 513)
saved = _snapshot("qwen", 1, "2026-08-14 12:10:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"history exists: {patient_id}/{model}")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
first_list_job = jobs[1]
list_result = first_list_job["function"]()
page._select_record(second["appointment"])
page._select_record(first["appointment"])
jobs[3]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 4
assert repository.list_calls == [301]
first_list_job["on_success"](list_result)
first_list_job["on_finished"]()
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
def test_patient_ai_finished_only_tracks_qwen_workers_not_unrelated_openai(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(114, 301, 514)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"worker must remain queued: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
current_list_job = jobs[1]
stale_qwen = (page._ai_analysis_generation - 2, 114, 301, "qwen")
stale_openai = (page._ai_analysis_generation - 1, 114, 301, "openai")
page._patient_ai_generation_requests.add(stale_qwen)
page._patient_ai_generation_finished(*stale_qwen)
assert page._ai_analysis_model_states["qwen"] == "loading"
page._patient_ai_generation_requests.add(stale_openai)
current_list_job["on_finished"]()
page._patient_ai_generation_finished(*stale_openai)
assert not page._patient_ai_list_requests
assert not page._patient_ai_generation_requests
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert "未返回有效结果" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_aba_cancelled_generation_is_replaced_instead_of_becoming_false_error(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(115, 301, 515)
second = _detail(116, 302, 516)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
5.0,
)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 1, "2026-08-14 12:20:00")
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
old_qwen_job = jobs[2]
with ThreadPoolExecutor(max_workers=1) as executor:
cancelled_future = executor.submit(old_qwen_job["function"])
page._select_record(second["appointment"])
cancelled = cancelled_future.result(timeout=1.0)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
assert cancelled is reception_module._ASYNC_REQUEST_CANCELLED
assert repository.generate_calls == []
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 6
current_list_job = jobs[5]
current_list_job["on_success"]({"patient_id": 301, "reports": []})
assert len(jobs) == 7
replacement_qwen_job = jobs[6]
current_list_job["on_finished"]()
assert len(jobs) == 7
qwen_result = replacement_qwen_job["function"]()
replacement_qwen_job["on_success"](qwen_result)
replacement_qwen_job["on_finished"]()
jobs_after_replacement = len(jobs)
old_qwen_job["on_success"](cancelled)
old_qwen_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == jobs_after_replacement
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
@pytest.mark.parametrize("late_completion", ["cancelled", "error"])
def test_late_history_completion_cannot_clear_newer_generated_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
late_completion: str,
) -> None:
detail = _detail(117, 301, 517)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
raise AssertionError(f"history worker remains pending: {patient_id}")
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 9, "2026-08-14 12:30:00")
generated["id"] = 901 if model == "qwen" else 902
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
history_job = jobs[1]
current_context = page._current_patient_ai_context(301)
assert current_context is not None
generation, appointment_id, _patient_id = current_context
page._request_patient_ai_generation("qwen", generation, appointment_id, 301)
qwen_job = jobs[2]
qwen_result = qwen_job["function"]()
qwen_job["on_success"](qwen_result)
qwen_job["on_finished"]()
jobs_after_generation = len(jobs)
if late_completion == "cancelled":
history_job["on_success"](reception_module._ASYNC_REQUEST_CANCELLED)
else:
history_job["on_error"](RuntimeError("late history failure"))
history_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == jobs_after_generation
assert page._ai_analysis_model_states["qwen"] == "success"
assert page._ai_analysis_payloads["qwen"]["id"] == 901
assert page.ai_summary_label.text() == "千问第 9 版诊断建议"
page.close()
application.processEvents()
def test_cancelled_queued_generation_does_not_invalidate_valid_history_get(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(118, 301, 518)
second = _detail(119, 302, 519)
saved = _snapshot("qwen", 10, "2026-08-14 12:40:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
self.generate_calls.append((patient_id, model))
raise AssertionError("cancelled queued POST must not enter repository")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
history_job = jobs[1]
history_result = history_job["function"]()
current_context = page._current_patient_ai_context(301)
assert current_context is not None
generation, appointment_id, _patient_id = current_context
page._request_patient_ai_generation("qwen", generation, appointment_id, 301)
queued_qwen_job = jobs[2]
page._select_record(second["appointment"])
cancelled = queued_qwen_job["function"]()
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
queued_qwen_job["on_success"](cancelled)
queued_qwen_job["on_finished"]()
history_job["on_success"](history_result)
history_job["on_finished"]()
assert repository.list_calls == [301]
assert repository.generate_calls == []
assert len(jobs) == 5
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 10 版诊断建议"
page.close()
application.processEvents()
def test_saturated_automatic_ai_slots_end_in_retryable_state_without_post(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(120, 301, 520)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
self.generate_calls.append((patient_id, model))
raise AssertionError("busy automatic work must not submit a POST")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
automatic_qwen_job = jobs[2]
deferred = automatic_qwen_job["function"]()
automatic_qwen_job["on_success"](deferred)
automatic_qwen_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert repository.generate_calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert not page._ai_analysis_regenerating
assert page.ai_analysis_regenerate_button.isEnabled()
assert "后台分析任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_saturated_history_slots_end_in_retryable_state_without_get(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(121, 301, 521)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
raise AssertionError("busy automatic read must not enter repository")
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"history did not complete: {patient_id}/{model}")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
history_job = jobs[1]
deferred = history_job["function"]()
history_job["on_success"](deferred)
history_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert repository.list_calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert page.ai_analysis_retry_button.isEnabled()
assert "查询任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_get_history_requires_exact_top_level_and_row_patient_ids() -> None:
row = _snapshot("qwen", 1, "2026-08-14 11:00:00")
valid = {"patient_id": 301, "reports": [row]}
+25 -5
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtCore import QDate, QPoint
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
from doctor_workstation.core import PermissionSet
@@ -636,15 +636,35 @@ def test_patient_list_reference_geometry_and_row_actions(
button.minimumHeight() == 44 and button.maximumHeight() == 44
for button in workspace.summary_buttons.values()
)
assert all(
widget.minimumWidth() == 0 and widget.maximumWidth() > 1000
assert workspace.keyword_edit.objectName() == "PatientKeywordInput"
assert workspace.status_host.objectName() == "PatientStatusFilterHost"
assert workspace.quick_host.objectName() == "PatientQuickDateHost"
assert workspace.date_host.objectName() == "PatientDateRangeHost"
assert (
workspace.keyword_edit.maximumWidth(),
workspace.status_host.maximumWidth(),
workspace.quick_host.maximumWidth(),
workspace.date_host.maximumWidth(),
) == (620, 440, 620, 420)
assert workspace.search_button.objectName() == "PatientSearchButton"
assert workspace.reset_button.objectName() == "PatientResetButton"
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
for width in (1170, 1290, 1514):
page.resize(width, 680)
application.processEvents()
for widget in (
workspace.keyword_edit,
workspace.status_host,
workspace.search_button,
workspace.reset_button,
workspace.quick_host,
workspace.date_host,
)
)
workspace.custom_date_button,
):
top_left = widget.mapTo(workspace.filter_card, QPoint(0, 0))
assert top_left.x() >= 0
assert top_left.x() + widget.width() <= workspace.filter_card.width()
assert all(button.maximumWidth() == 420 for button in workspace.summary_buttons.values())
assert page.tabs.minimumHeight() == 0
assert workspace.content_stack.minimumHeight() == 0
assert workspace.table.minimumHeight() == 0
+413 -2
View File
@@ -10,14 +10,32 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtCore import (
QDate,
QEvent,
QEventLoop,
QObject,
QPoint,
Qt,
QThreadPool,
QTimer,
)
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea, QWidget
from PySide6.QtWidgets import (
QApplication,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import api_client as api_client_module
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 import widgets as widgets_module
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import (
NOTE_LIMIT,
@@ -41,8 +59,11 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
pool: Any = None,
priority: int = 0,
**kwargs: Any,
) -> object:
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
@@ -79,6 +100,135 @@ def queued_async(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
return jobs
def test_api_client_reuses_a_bounded_transport_across_qt_runnables(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created: list[Any] = []
class PooledClient:
def __init__(self, **_options: Any) -> None:
self.closed = False
created.append(self)
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
return httpx.Response(200, json={"code": 1, "data": url})
def close(self) -> None:
self.closed = True
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
client = ApiClient("https://example.test", max_parallel_requests=2)
pool = QThreadPool()
pool.setMaxThreadCount(1)
pool.setExpiryTimeout(-1)
remaining = 40
results: list[str] = []
loop = QEventLoop()
def finished() -> None:
nonlocal remaining
remaining -= 1
if remaining == 0:
loop.quit()
for index in range(remaining):
widgets_module.run_async(
lambda index=index: client.get(f"patient/{index}"),
on_success=results.append,
on_finished=finished,
pool=pool,
)
QTimer.singleShot(5_000, loop.quit)
loop.exec()
assert pool.waitForDone(2_000)
client.close()
assert remaining == 0
assert len(results) == 40
assert len(created) == 1
assert created[0].closed
application.processEvents()
def test_clearing_ai_layout_does_not_promote_children_to_windows(
application: QApplication,
) -> None:
host = QWidget()
layout = QVBoxLayout(host)
dynamic_label = QLabel("正在加载患者 AI 分析", host)
layout.addWidget(dynamic_label)
host.show()
application.processEvents()
destroyed: list[bool] = []
dynamic_label.destroyed.connect(lambda: destroyed.append(True))
reception_module._clear_ai_layout(layout)
assert layout.count() == 0
assert destroyed == [True]
assert dynamic_label not in application.topLevelWidgets()
host.close()
host.deleteLater()
def test_queue_row_never_shows_loading_fields_as_parentless_windows(
application: QApplication,
) -> None:
tracked_names = {
"StatusBadge",
"ReceptionQueueTime",
"ReceptionQueueMetric",
}
class OrphanShowRecorder(QObject):
def __init__(self) -> None:
super().__init__()
self.object_names: list[str] = []
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
if (
event.type() == QEvent.Type.Show
and isinstance(watched, QWidget)
and watched.objectName() in tracked_names
and watched.parentWidget() is None
and watched.isWindow()
):
self.object_names.append(watched.objectName())
return False
recorder = OrphanShowRecorder()
application.installEventFilter(recorder)
try:
active_row = QueueRow(
{
"patient_name": "问诊患者",
"status": 2,
"status_desc": "问诊中",
"fasting_blood_sugar": 8.6,
}
)
waiting_row = QueueRow(
{
"patient_name": "待接诊患者",
"status": 1,
"appointment_time": "14:30",
}
)
finally:
application.removeEventFilter(recorder)
assert recorder.object_names == []
for row in (active_row, waiting_row):
for object_name in tracked_names:
widget = row.findChild(QWidget, object_name)
assert widget is not None
assert widget.parentWidget() is row
assert not widget.isWindow()
row.deleteLater()
@pytest.mark.parametrize(
("path", "expected"),
[
@@ -714,6 +864,101 @@ def test_fast_patient_switch_rejects_late_detail(
application.processEvents()
def test_rapid_aba_switch_prioritizes_current_detail_and_skips_stale_bundles(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(73, name="甲患者")
second = _detail(74, name="乙患者")
class Repository:
def __init__(self) -> None:
self.calls: list[int] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
self.calls.append(appointment_id)
return first if appointment_id == 73 else second
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
page._select_record(second["appointment"])
page._select_record(first["appointment"])
assert [job["priority"] for job in queued_async] == [1, 2, 3]
for stale_job in queued_async[:2]:
assert stale_job["function"]() == {"cancelled": True}
stale_job["on_finished"]()
assert repository.calls == []
current_job = queued_async[2]
current_job["on_success"](current_job["function"]())
current_job["on_finished"]()
assert repository.calls == [73]
assert page._selected_appointment_id == 73
assert page.patient_name_label.text() == "甲患者"
assert not page._detail_loading
page.close()
application.processEvents()
def test_switching_patient_resets_stale_daily_panel_loading(
application: QApplication,
) -> None:
page = ReceptionPage(object(), PermissionSet([]))
page.daily_panel.set_loading(True)
assert page.daily_panel._loading
assert not page.daily_panel.refresh_button.isEnabled()
page._reset_detail_content(
seed={"id": 75, "patient_name": "新患者", "diagnosis_id": 275}
)
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert all(button.isEnabled() for button in page.daily_panel.range_buttons.values())
assert page.daily_panel.start_date.isEnabled()
assert page.daily_panel.end_date.isEnabled()
page.close()
application.processEvents()
def test_stale_daily_worker_skips_repository_after_patient_switch(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(76, name="日常甲患者")
second = _detail(77, name="日常乙患者")
class Repository:
def __init__(self) -> None:
self.tracking_calls: list[int] = []
def get_tracking_window(self, diagnosis_id: int, **_options: Any) -> dict[str, Any]:
self.tracking_calls.append(diagnosis_id)
return {}
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
queued_async[0]["on_success"]({"detail": first, "warnings": []})
page._request_daily_range("2026-08-13", "2026-08-19")
stale_daily_job = queued_async[1]
page._select_record(second["appointment"])
assert stale_daily_job["function"]() == {"cancelled": True}
assert repository.tracking_calls == []
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summary(
application: QApplication,
) -> None:
@@ -1169,6 +1414,28 @@ def test_phone_permission_and_ungated_notify_video_actions(
application.processEvents()
def test_im_consult_is_visible_immediately_after_history(
application: QApplication,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
page.resize(1280, 760)
page.detail_stack.setCurrentIndex(1)
page.show()
application.processEvents()
assert page.video_button.text() == "IM 问诊"
assert page.video_button.objectName() == "ReceptionImButton"
assert not page.video_button.isHidden()
assert page.history_button.geometry().right() < page.video_button.geometry().left()
assert page.video_button.geometry().right() < page.more_button.geometry().left()
assert "IM 问诊" not in [
action.text() for action in page.more_button.menu().actions()
]
page.close()
application.processEvents()
def test_reception_ai_report_button_follows_permission(
application: QApplication,
immediate_async: None,
@@ -1423,6 +1690,65 @@ def test_reception_dual_ai_queues_openai_only_after_qwen_is_visible(
application.processEvents()
def test_saturated_fallback_ai_slots_do_not_leave_current_analysis_loading(
application: QApplication,
queued_async: list[dict[str, Any]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(86, name="后台分析繁忙患者")
calls: list[tuple[int, str]] = []
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
calls.append((diagnosis_id, model))
raise AssertionError("busy automatic work must not enter repository")
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
automatic_qwen_job = queued_async[1]
deferred = automatic_qwen_job["function"](*automatic_qwen_job["args"])
automatic_qwen_job["on_success"](deferred)
automatic_qwen_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert page.ai_analysis_retry_button.isEnabled()
assert "后台分析任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_reception_openai_failure_keeps_qwen_success_visible(
application: QApplication,
queued_async: list[dict[str, Any]],
@@ -1886,6 +2212,89 @@ def test_detail_failure_stops_ai_loading_and_keeps_retry_available(
jobs[0]["on_finished"]()
assert not page._detail_loading
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_finished_only_detail_request_cannot_leave_loading_placeholders(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
page = ReceptionPage(object(), PermissionSet(["*"]))
record = {
"id": 86,
"patient_id": 186,
"diagnosis_id": 286,
"patient_name": "无结果患者",
}
page._select_record(record)
queued_async[0]["on_finished"]()
assert not page._detail_loading
assert page._selected_detail is None
assert page.detail_banner.property("kind") == "danger"
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert page._ai_analysis_state == "error"
page.close()
application.processEvents()
def test_finished_only_patient_ai_query_becomes_retryable(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
detail = _detail(87, name="AI 无结果患者")
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}")
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
assert len(queued_async) == 2
assert page._ai_analysis_state == "loading"
queued_async[1]["on_finished"]()
assert page._ai_analysis_state == "error"
assert not page._ai_analysis_loading
assert "请重试" in page.ai_analysis_state_label.text()
assert not page.ai_analysis_retry_button.isHidden()
page.ai_analysis_retry_button.click()
assert len(queued_async) == 3
assert page._ai_analysis_state == "loading"
page.close()
application.processEvents()
@@ -1960,6 +2369,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
assert len(queued_async) == 2
page._select_record(second["appointment"])
assert queued_async[1]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[2]["on_success"]({"detail": second, "warnings": []})
assert len(queued_async) == 4
second_qwen = _analysis_payload("第二位千问风险")
@@ -1968,6 +2378,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
assert len(queued_async) == 5
page._select_record(third["appointment"])
assert queued_async[4]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[5]["on_success"]({"detail": third, "warnings": []})
assert len(queued_async) == 7
third_qwen = _analysis_payload("第三位千问风险")
+104
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Any
import pytest
@@ -480,6 +481,81 @@ def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
]
def test_remote_call_record_identity_is_reused_for_room_recording_and_end() -> None:
"""Room binding and COS finalization must never select a different latest call."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.bind_call_room(501, " room-901 ", call_record_id=901)
repository.end_call(501, call_record_id=901)
assert client.post_calls == [
(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": 501, "room_id": "room-901", "call_record_id": 901},
),
(
"tcm.diagnosis/endCall",
{"diagnosis_id": 501, "call_record_id": 901},
),
]
def test_remote_local_audio_upload_keeps_exact_call_identity_and_mime(
tmp_path: Path,
) -> None:
class MultipartClient(RecordingClient):
def __init__(self) -> None:
super().__init__()
self.multipart_calls: list[
tuple[str, dict[str, tuple[str, bytes, str]], dict[str, Any]]
] = []
def post_multipart(
self,
endpoint: str,
*,
files: dict[str, tuple[str, bytes, str]],
data: dict[str, Any],
) -> dict[str, Any]:
self.multipart_calls.append((endpoint, files, data))
completed = int(data["chunk_index"]) == int(data["chunk_total"]) - 1
return {
"call_record_id": 901,
"completed": completed,
"file_url": "https://cos.example.test/calls/local-audio.webm"
if completed
else "",
"media_kind": "local_audio",
}
recording = tmp_path / "local-audio.webm"
recording.write_bytes(b"a" * (4 * 1024 * 1024 + 3))
client = MultipartClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
result = repository.upload_call_recording(
recording,
501,
call_record_id=901,
mime_type="audio/webm;codecs=opus",
)
assert result["completed"] is True
assert result["call_record_id"] == 901
assert result["media_kind"] == "local_audio"
assert len(client.multipart_calls) == 2
assert all(call[0] == "tcm.diagnosis/uploadCallRecording" for call in client.multipart_calls)
for _endpoint, files, data in client.multipart_calls:
assert data["diagnosis_id"] == 501
assert data["call_record_id"] == 901
assert data["mime_type"] == "audio/webm;codecs=opus"
assert str(data["upload_id"]).startswith("local_audio_")
assert files["file"][0] == "local-audio.webm"
assert files["file"][2] == "audio/webm;codecs=opus"
@pytest.mark.parametrize(
"response",
[None, {}, {"ok": True}, {"call_record_id": 0}, {"callRecordId": -1}, {"id": True}],
@@ -645,6 +721,34 @@ def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
assert client.timeouts == [105.0]
def test_ai_post_type_error_after_dispatch_is_never_retried() -> None:
class FailingAfterDispatchClient:
token = "token"
def __init__(self) -> None:
self.calls = 0
def post(
self,
endpoint: str,
payload: dict[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
assert endpoint == "tcm.diagnosis/generatePatientAiReport"
assert payload == {"patient_id": 301, "model": "qwen"}
assert timeout == 105.0
self.calls += 1
raise TypeError("transport failed after dispatch")
client = FailingAfterDispatchClient()
with pytest.raises(TypeError, match="after dispatch"):
RemoteDoctorRepository(client).generate_patient_ai_report(301, model="qwen")
assert client.calls == 1
def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None:
class StreamingClient(RecordingClient):
def post_event_stream(self, endpoint: str, payload: dict[str, Any], **kwargs: Any):
+118 -7
View File
@@ -6,11 +6,17 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QWidget
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QApplication, QDialog, QFrame, QToolButton, QWidget
from doctor_workstation.ui import shell as shell_module
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
from doctor_workstation.ui.theme import apply_theme
def _logical_pixel(image: Any, x: int, y: int):
device_scale = image.devicePixelRatio()
return image.pixelColor(round(x * device_scale), round(y * device_scale))
class _ShellPageDouble(QWidget):
@@ -39,7 +45,26 @@ class _ShellPageDouble(QWidget):
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.mark.parametrize(
("available_size", "expected_size"),
[
(QSize(1920, 1080), QSize(1710, 920)),
(QSize(1486, 1000), QSize(1486, 920)),
(QSize(1600, 800), QSize(1600, 800)),
(QSize(800, 600), QSize(1024, 640)),
(None, QSize(1710, 920)),
],
)
def test_shell_initial_size_is_bounded_by_logical_available_geometry(
available_size: QSize | None,
expected_size: QSize,
) -> None:
assert shell_module._bounded_initial_window_size(available_size) == expected_size
def test_patients_navigation_keeps_the_product_menu_title() -> None:
@@ -145,14 +170,60 @@ def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
image = shell_window.grab().toImage()
assert image.pixelColor(20, 300).name().lower() in {
assert _logical_pixel(image, 20, 300).name().lower() in {
"#f2f5fd",
"#f3f6fd",
"#f2f6fe",
"#f3f6fe",
}
assert image.pixelColor(610, 20).name().lower() == "#ffffff"
assert image.pixelColor(220, 90).name().lower() == "#fcfdfe"
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff"
assert _logical_pixel(image, 220, 90).name().lower() == "#fcfdfe"
def test_topbar_search_actions_and_navigation_controls_stay_aligned(
application: QApplication,
shell_window: ShellWindow,
) -> None:
for width, height in ((1024, 640), (1366, 768)):
shell_window.resize(width, height)
application.processEvents()
search_host = shell_window.topbar.findChild(QFrame, "ShellGlobalSearch")
shortcut_hint = search_host.findChild(QWidget, "ShellShortcutHint")
assert search_host.size().toTuple() == (265, 36)
assert shell_window.fold_button.size().toTuple() == (38, 38)
assert shortcut_hint.size() == shortcut_hint.sizeHint()
assert (
abs(search_host.geometry().center().y() - shell_window.fold_button.geometry().center().y())
<= 1
)
assert (
abs(
shortcut_hint.mapTo(search_host, shortcut_hint.rect().center()).y()
- search_host.rect().center().y()
)
<= 1
)
shell_window.global_search.setText("患者")
application.processEvents()
action_buttons = shell_window.global_search.findChildren(QToolButton)
assert len(action_buttons) == 2
for button in action_buttons:
assert button.size().toTuple() == (22, 18)
assert shell_window.global_search.rect().contains(button.geometry())
assert (
abs(
button.geometry().center().y()
- shell_window.global_search.rect().center().y()
)
<= 1
)
clear_button = max(action_buttons, key=lambda button: button.x())
clear_right = clear_button.mapTo(search_host, clear_button.rect().topRight()).x()
assert clear_right < shortcut_hint.x()
shell_window.global_search.clear()
def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -> None:
@@ -170,7 +241,11 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
)
assert shell_window.assistant_card.isVisible()
assert shell_window.assistant_button.text() == "开始对话"
assert "GPT-4o 医疗版" in shell_window.model_label.text()
assert shell_window.upload_settings_button.text().strip().startswith("设置")
assert (
shell_window.upload_settings_button.accessibleName() == "本机录音上传设置"
)
assert shell_window.model_label is shell_window.upload_settings_button
assert shell_window.minimize_button.text() == ""
assert shell_window.close_button.text() == ""
@@ -180,6 +255,42 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
assert "在线" in shell_window.assistant_status.text()
def test_shell_settings_opens_global_local_audio_upload_manager(
application: QApplication,
shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def dialog_factory(
repository: Any,
diagnosis_id: int | None,
parent: QWidget,
) -> QDialog:
dialog = QDialog(parent)
dialog.setObjectName("LocalAudioQueueDialog")
captured.update(
repository=repository,
diagnosis_id=diagnosis_id,
parent=parent,
dialog=dialog,
)
return dialog
monkeypatch.setattr(shell_module, "LocalAudioQueueDialog", dialog_factory)
shell_window.upload_settings_button.click()
application.processEvents()
assert captured["repository"] is shell_window.repository
assert captured["diagnosis_id"] is None
assert captured["parent"] is shell_window
assert captured["dialog"].isVisible()
captured["dialog"].reject()
application.processEvents()
assert shell_window._local_audio_settings_dialog is None
def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
shell_window: ShellWindow,
) -> None:
+386 -6
View File
@@ -28,6 +28,233 @@ from doctor_workstation.video.security import ( # noqa: E402
)
def test_companion_uses_legacy_safe_transcription_session_identity() -> None:
"""Generated session IDs stay below 32 chars so upgraded databases cannot truncate."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
function_source = source.split("function newTranscriptionSessionId", 1)[1].split(
"function requestTranscriptionStart", 1
)[0]
assert "replaceAll('-', '')" in function_source
assert ".slice(0, 28)" in function_source
assert "return `tr-${" in function_source
def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> None:
"""A connected call starts three independent artifacts before hangup."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
assert "context.createMediaStreamDestination()" in source
assert "cloud.getAudioTrack({ processed: true })" in source
assert "userId: activeConfig.targetUserId" in source
assert "new MediaRecorder(destination.stream" in source
assert "recorder.start(1000)" in source
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
assert "bridge.appendLocalAudioChunk(" in source
assert "bridge.finishLocalAudioRecording(sessionId, totalBytes)" in source
assert "operations.push(stopLocalRecording())" in source
assert "operations.push(stopTranscription('completed'))" in source
assert "Promise.allSettled(operations)" in source
assert "腾讯云混流视频、本机语音录音和实时转写均已启动" in source
def test_companion_watches_room_id_for_the_entire_call_cycle() -> None:
"""A slowly-created TRTC room must still bind to the exact call record."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
room_source = source.split("function readRoomId", 1)[1].split(
"function handleStatusChanged", 1
)[0]
assert "TUIStore.watch(StoreName.CALL, roomIdWatchOptions)" in room_source
assert "[NAME.ROOM_ID]: handleRoomIdChanged" in room_source
assert "cycle === callCycleGeneration" in room_source
assert "while (activeConfig && !endNotified" in room_source
assert "attempt < 40" not in room_source
assert "diagnosisId: activeConfig.diagnosisId" in room_source
def test_room_binding_is_acknowledged_and_transcriber_room_is_a_fallback() -> None:
companion_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
window_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "observeRoomId(roomId)" in companion_source
assert "onRealtimeTranscriberStarted: (roomId)" in companion_source
assert "function roomBindingResult(" in companion_source
assert "if (boundRoomId) return" in companion_source
assert "roomBindingResult?.(" in window_source
def test_im_conversation_renders_deduplicated_video_call_status_timeline() -> None:
"""Video lifecycle feedback belongs in the IM timeline as local status events."""
companion_root = PROJECT_ROOT / "video_companion" / "src"
source = (companion_root / "main.ts").read_text(encoding="utf-8")
app_source = (companion_root / "App.vue").read_text(encoding="utf-8")
styles = (companion_root / "style.css").read_text(encoding="utf-8")
timeline_source = source.split("function appendVideoCallStatus", 1)[1].split(
"function onMessageReceived", 1
)[0]
assert "activeConfig.mode !== 'chat'" in timeline_source
assert "local-video-call-${callCycleGeneration}-${callStatus}" in timeline_source
assert "findIndex((item) => item.id === id)" in timeline_source
assert "appendVideoCallStatus('starting', '正在创建安全视频通话')" in source
assert "appendVideoCallStatus('dialing', '正在呼叫患者')" in source
assert "appendVideoCallStatus('connected', '视频通话已接通')" in source
assert "appendVideoCallStatus('ended', '视频通话已结束')" in source
assert "appendVideoCallStatus('failed', `视频通话发起失败:${message}`)" in source
assert "message.type === 'call-status'" in app_source
assert 'class="call-status-event"' in app_source
assert 'role="status"' in app_source
assert "IM 已连接 · ${props.statusText.value}" in app_source
assert ".message-row--call-status" in styles
assert ".call-status-event--connected" in styles
assert ".call-status-event--failed" in styles
def test_companion_local_recording_waits_for_real_audio_and_has_runtime_fallbacks() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
assert "querySelectorAll<HTMLMediaElement>('video, audio')" in source
assert "stream.getAudioTracks()" in source
assert "navigator.mediaDevices.getUserMedia" in source
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
assert "localRecordingAttachedSourceCount <= 0" in source
assert "localRecordingBytes < 1024" in source
assert "已阻止上传空文件" in source
def test_qt_close_waits_for_local_audio_finish_before_destroying_webengine() -> None:
"""A title-bar/desktop hangup must keep accepting bridge chunks until COS ack."""
source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
request_shutdown = source.split(
"def _request_companion_shutdown", 1
)[1].split("def _force_requested_shutdown", 1)[0]
begin_shutdown = source.split("def _begin_shutdown", 1)[1].split(
"def wait_for_lifecycles", 1
)[0]
close_event = source.split("def closeEvent", 1)[1].split(
"else:", 1
)[0]
assert "window.doctorConsultation?.close?.()" in request_shutdown
assert "self._shutdown_requested = True" in request_shutdown
assert "self._closing = True" not in request_shutdown
assert "event.ignore()" in close_event
assert "self._request_companion_shutdown" in close_event
assert "self._shutdown_timer.stop()" in begin_shutdown
assert "window.doctorConsultation?.close?.()" not in begin_shutdown
assert "if not self.open_im or self._shutdown_requested" in source
def test_local_audio_capture_keeps_and_persists_its_own_call_room_identity() -> None:
"""A later IM call cycle must not relabel an earlier recording."""
source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "lifecycle: OrderedCallLifecycle" in source
assert "lifecycle = capture.lifecycle" in source
assert "call_record_id=call_record_id" in source
assert 'room_id=lifecycle.current_room_id or ""' in source
assert "store.bind_identity(" in source
def test_companion_shows_incremental_subtitles_but_only_persists_final_segments() -> None:
main_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
component_source = (
PROJECT_ROOT / "video_companion" / "src" / "App.vue"
).read_text(encoding="utf-8")
handler = main_source.split("function handleTranscriberMessage", 1)[1].split(
"function subscribeTranscriber", 1
)[0]
assert "showLiveCaption(message)" in handler
assert "if (message.isCompleted !== true) return" in handler
assert handler.index("showLiveCaption(message)") < handler.index(
"if (message.isCompleted !== true) return"
)
assert 'aria-label="实时语音字幕"' in component_source
assert "liveCaptions.value" in component_source
assert "caption.speaker" in component_source
assert "caption.text" in component_source
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
)
capture = source.split("async function captureScreenshot", 1)[1].split(
"function discardScreenshot", 1
)[0]
confirm = source.split("async function confirmScreenshot", 1)[1].split(
"watch(", 1
)[0]
assert "screenshotPreview.value = canvas.toDataURL" in capture
assert "onSaveScreenshot" not in capture
assert "await props.onSaveScreenshot(screenshotPreview.value)" in confirm
assert "确认画面后再保存到患者资料" in source
assert "确认并上传" in source
assert "取消" in source
def test_companion_loads_im_history_without_an_empty_first_page_cursor() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
load_messages = source.split("async function loadMessages", 1)[1].split(
"async function sendText", 1
)[0]
assert "nextReqMessageID: prepend ? nextReqMessageID : ''" not in load_messages
assert "nextReqMessageID?: string" in load_messages
assert (
"if (prepend && nextReqMessageID) request.nextReqMessageID = nextReqMessageID"
in load_messages
)
assert "chat.getMessageList(request)" in load_messages
def test_companion_preserves_im_scroll_position_for_live_and_older_messages() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
)
watcher = source.split("() => props.messages.value.length", 1)[1].split(
"watch(", 1
)[0]
load_earlier = source.split("async function loadEarlierMessages", 1)[1].split(
"async function runAction", 1
)[0]
assert "if (!stickToMessageBottom.value) return" in watcher
assert "stickToMessageBottom.value = false" in load_earlier
assert "container.scrollHeight - previousHeight" in load_earlier
assert '@scroll="handleMessageScroll"' in source
assert '@click="loadEarlierMessages"' in source
def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
request = normalize_backend_ticket(
{
@@ -196,11 +423,13 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 900}
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
events.append(("bind", diagnosis_id, room_id))
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> None:
events.append(("bind", diagnosis_id, room_id, call_record_id))
def end_call(self, diagnosis_id: int) -> None:
events.append(("end", diagnosis_id))
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
events.append(("end", diagnosis_id, call_record_id))
request = VideoCallRequest(
sdk_app_id=1400123456,
@@ -226,17 +455,19 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
assert lifecycle.wait(0.01) is False
assert duplicate_bind is bind_future
assert changed_bind.result(timeout=0) is False
assert lifecycle.current_room_id == "456789"
release_start.set()
assert start_future.result(timeout=2) is True
assert bind_future.result(timeout=2) is True
assert end_future.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert lifecycle.current_room_id == "456789"
assert events == [
("start", 123, 8, 2),
("bind", 123, "456789"),
("end", 123),
("bind", 123, "456789", 900),
("end", 123, 900),
]
@@ -479,6 +710,155 @@ def test_failed_start_prevents_bind_and_end_writes() -> None:
assert events == ["start"]
def test_explicit_cos_recording_failure_fails_room_binding_without_losing_call_identity() -> None:
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
del diagnosis_id, patient_id, call_type
return {"call_record_id": 904}
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> dict[str, object]:
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 904)
return {
"call_record_id": 904,
"cloud_recording": {
"started": False,
"message": "COS bucket is unavailable",
},
}
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
with pytest.raises(RuntimeError, match="COS bucket is unavailable"):
lifecycle.bind_room("456789").result(timeout=2)
assert lifecycle.call_record_id == 904
assert lifecycle.bound_room_id is None
assert lifecycle.wait(1) is True
def test_failed_room_binding_releases_claim_and_can_retry_same_room() -> None:
bind_attempts = 0
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
del diagnosis_id, patient_id, call_type
return {"call_record_id": 906}
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> dict[str, object]:
nonlocal bind_attempts
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 906)
bind_attempts += 1
if bind_attempts == 1:
raise RuntimeError("temporary bind failure")
return {
"call_record_id": 906,
"cloud_recording": {"started": True},
}
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
with pytest.raises(RuntimeError, match="temporary bind failure"):
lifecycle.bind_room("456789").result(timeout=2)
assert lifecycle.current_room_id is None
assert lifecycle.bind_room("456789").result(timeout=2) is True
assert lifecycle.bound_room_id == "456789"
assert lifecycle.current_room_id == "456789"
assert bind_attempts == 2
assert lifecycle.wait(1) is True
def test_local_audio_upload_uses_exact_started_record_and_precedes_end(
tmp_path: Path,
) -> None:
events: list[tuple[object, ...]] = []
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 905}
def upload_call_recording(
self,
path: Path,
diagnosis_id: int,
*,
call_record_id: int,
mime_type: str,
) -> dict[str, object]:
events.append(
(
"local-audio",
path.read_bytes(),
diagnosis_id,
call_record_id,
mime_type,
)
)
return {
"completed": True,
"call_record_id": call_record_id,
"media_kind": "local_audio",
}
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
events.append(("end", diagnosis_id, call_record_id))
recording = tmp_path / "call-audio.webm"
recording.write_bytes(b"opus-webm-audio")
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
lifecycle.start()
uploaded = lifecycle.save_local_audio_recording(
recording,
mime_type="audio/webm;codecs=opus",
)
ended = lifecycle.end("doctor-hangup")
assert uploaded.result(timeout=2) is True
assert ended.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert events == [
("start", 123, 8, 2),
("local-audio", b"opus-webm-audio", 123, 905, "audio/webm;codecs=opus"),
("end", 123, 905),
]
def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() -> None:
events: list[tuple[object, ...]] = []
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-DXNYj41g.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-sts1UBl3.css">
<script type="module" crossorigin src="./assets/index-DhmAWjut.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BsDbRyxy.css">
</head>
<body>
<div id="app"></div>
+165 -18
View File
@@ -6,11 +6,19 @@ import type { Ref } from 'vue'
interface ChatMessage {
id: string
mine: boolean
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system' | 'call-status'
text: string
url: string
name: string
time: string
callStatus?: 'starting' | 'dialing' | 'connected' | 'ended' | 'failed'
}
interface LiveCaption {
id: string
speaker: string
text: string
completed: boolean
}
const props = defineProps<{
@@ -24,6 +32,8 @@ const props = defineProps<{
notice: Readonly<Ref<string>>
hasMoreMessages: Readonly<Ref<boolean>>
transcriptionState: Readonly<Ref<string>>
localRecordingState: Readonly<Ref<string>>
liveCaptions: Readonly<Ref<LiveCaption[]>>
onSendText: (text: string) => Promise<void>
onSendAttachment: (file: File) => Promise<void>
onLoadMore: () => Promise<void>
@@ -38,29 +48,81 @@ const actionBusy = ref(false)
const localError = ref('')
const messageList = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const screenshotPreview = ref('')
const stickToMessageBottom = ref(true)
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')
const transcriptionActive = computed(() => props.transcriptionState.value === 'recording')
const transcriptionFailed = computed(() => props.transcriptionState.value === 'error')
const chatConnectionText = computed(() => {
if (!props.chatReady.value) return props.statusText.value
if (['starting', 'dialing', 'connected', 'ended', 'error'].includes(props.phase.value)) {
return `IM 已连接 · ${props.statusText.value}`
}
return 'IM 已连接'
})
const transcriptionActive = computed(() => (
props.transcriptionState.value === 'recording'
|| props.localRecordingState.value === 'recording'
))
const transcriptionFailed = computed(() => (
props.transcriptionState.value === 'error'
|| props.localRecordingState.value === 'error'
))
const transcriptionStatusText = computed(() => {
if (props.transcriptionState.value === 'starting') return '自动录音启动中…'
if (props.transcriptionState.value === 'recording') return '自动录音并转文字中'
if (props.transcriptionState.value === 'stopping') return '正在保存录音文字…'
if (props.transcriptionState.value === 'error') return '自动录音转文字失败'
return '自动录音已结束'
const localState = props.localRecordingState.value
const textState = props.transcriptionState.value
if (localState === 'uploading') return '正在上传本机录音到 COS…'
if (localState === 'stopping' || textState === 'stopping') return '正在保存录音与转写文字…'
if (localState === 'starting' || textState === 'starting') return '自动录音与转写启动中…'
if (localState === 'recording' && textState === 'recording') return '本机录音与实时转写中'
if (localState === 'recording') return '本机录音中,实时转写未就绪'
if (textState === 'recording') return '实时转写中,本机录音未就绪'
if (localState === 'error' && textState === 'error') return '本机录音与实时转写均失败'
if (localState === 'error') return '本机录音失败,实时转写仍在运行'
if (textState === 'error') return '实时转写失败,本机录音仍在运行'
return '录音与转写已结束'
})
watch(
() => props.messages.value.length,
async () => {
if (!stickToMessageBottom.value) return
await nextTick()
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
},
)
watch(
() => [props.patientName.value, props.mode.value],
async () => {
stickToMessageBottom.value = true
await nextTick()
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
},
)
function handleMessageScroll(): void {
const container = messageList.value
if (!container) return
stickToMessageBottom.value = (
container.scrollHeight - container.scrollTop - container.clientHeight <= 96
)
}
async function loadEarlierMessages(): Promise<void> {
const container = messageList.value
if (!container || actionBusy.value) return
const previousHeight = container.scrollHeight
const previousTop = container.scrollTop
stickToMessageBottom.value = false
await runAction(props.onLoadMore)
await nextTick()
container.scrollTop = previousTop + (container.scrollHeight - previousHeight)
handleMessageScroll()
}
async function runAction(action: () => Promise<void>): Promise<void> {
if (actionBusy.value) return
actionBusy.value = true
@@ -113,9 +175,35 @@ async function captureScreenshot(): Promise<void> {
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))
screenshotPreview.value = canvas.toDataURL('image/jpeg', 0.9)
})
}
function discardScreenshot(): void {
if (actionBusy.value) return
screenshotPreview.value = ''
}
async function confirmScreenshot(): Promise<void> {
if (!screenshotPreview.value || actionBusy.value) return
actionBusy.value = true
localError.value = ''
try {
await props.onSaveScreenshot(screenshotPreview.value)
screenshotPreview.value = ''
} catch (error) {
localError.value = error instanceof Error ? error.message : '截图上传失败,请稍后重试'
} finally {
actionBusy.value = false
}
}
watch(
() => props.phase.value,
(value) => {
if (value !== 'connected') screenshotPreview.value = ''
},
)
</script>
<template>
@@ -127,7 +215,7 @@ async function captureScreenshot(): Promise<void> {
<h1>{{ patientName.value }}</h1>
<p>
<span class="connection-dot" :class="{ 'connection-dot--online': chatReady.value }" />
{{ chatReady.value ? 'IM 已连接' : statusText.value }}
{{ chatConnectionText }}
</p>
</div>
<button
@@ -150,13 +238,13 @@ async function captureScreenshot(): Promise<void> {
</button>
</header>
<div ref="messageList" class="message-list" aria-live="polite">
<div ref="messageList" class="message-list" aria-live="polite" @scroll="handleMessageScroll">
<button
v-if="hasMoreMessages.value"
class="load-more"
type="button"
:disabled="chatBusy.value"
@click="runAction(onLoadMore)"
@click="loadEarlierMessages"
>
{{ chatBusy.value ? '正在读取…' : '查看更早消息' }}
</button>
@@ -171,10 +259,26 @@ async function captureScreenshot(): Promise<void> {
v-for="message in messages.value"
:key="message.id"
class="message-row"
:class="{ 'message-row--mine': message.mine }"
:class="{
'message-row--mine': message.mine,
'message-row--call-status': message.type === 'call-status',
}"
>
<div class="message-meta">{{ message.mine ? '我' : patientName.value }} · {{ message.time }}</div>
<div class="message-bubble">
<template v-if="message.type === 'call-status'">
<div
class="call-status-event"
:class="`call-status-event--${message.callStatus || 'ended'}`"
role="status"
:aria-label="`${message.text}${message.time}`"
>
<span class="call-status-event__icon" aria-hidden="true"></span>
<strong>{{ message.text }}</strong>
<time>{{ message.time }}</time>
</div>
</template>
<template v-else>
<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"
@@ -193,8 +297,9 @@ async function captureScreenshot(): Promise<void> {
</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>
<p v-else>{{ message.text }}</p>
</div>
</template>
</article>
</div>
@@ -251,6 +356,19 @@ async function captureScreenshot(): Promise<void> {
{{ statusText.value }}
</div>
<div
v-if="canCapture && liveCaptions.value.length"
class="live-captions"
role="log"
aria-live="polite"
aria-label="实时语音字幕"
>
<p v-for="caption in liveCaptions.value" :key="caption.id">
<strong>{{ caption.speaker }}</strong>
<span>{{ caption.text }}</span>
</p>
</div>
<div v-if="isCalling" class="video-actions">
<div
v-if="canCapture"
@@ -271,7 +389,7 @@ async function captureScreenshot(): Promise<void> {
:disabled="!canCapture || actionBusy"
@click="captureScreenshot"
>
截屏并保存患者资料
截屏预览
</button>
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
结束视频
@@ -281,6 +399,35 @@ async function captureScreenshot(): Promise<void> {
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
{{ localError || notice.value }}
</div>
<div
v-if="screenshotPreview"
class="screenshot-dialog-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="screenshot-preview-title"
@click.self="discardScreenshot"
>
<section class="screenshot-dialog">
<header>
<div>
<p class="eyebrow">视频截图确认</p>
<h2 id="screenshot-preview-title">确认画面后再保存到患者资料</h2>
</div>
<button type="button" aria-label="关闭截图预览" :disabled="actionBusy" @click="discardScreenshot">×</button>
</header>
<div class="screenshot-preview-frame">
<img :src="screenshotPreview" alt="本次视频截图预览">
</div>
<p class="screenshot-dialog__hint">只有点击确认并上传截图才会上传并写入患者资料</p>
<footer>
<button class="screenshot-cancel" type="button" :disabled="actionBusy" @click="discardScreenshot">取消</button>
<button class="screenshot-confirm" type="button" :disabled="actionBusy" @click="confirmScreenshot">
{{ actionBusy ? '正在上传…' : '确认并上传' }}
</button>
</footer>
</section>
</div>
</section>
</main>
</template>
+13
View File
@@ -29,6 +29,8 @@ interface DoctorConsultationApi {
startVideo(): Promise<void>
hangup(): Promise<void>
hostCallReady(ok: boolean, message?: string): void
recordingResult(ok: boolean, message: string): void
roomBindingResult(roomId: string, ok: boolean, message: string): void
screenshotResult(ok: boolean, message: string): void
transcriptionResult(
operation: 'start' | 'segment' | 'stop',
@@ -37,11 +39,22 @@ interface DoctorConsultationApi {
ok: boolean,
message: string,
): void
localRecordingResult(
operation: 'start' | 'chunk' | 'finish',
sessionId: string,
sequence: number,
ok: boolean,
message: string,
): void
}
interface QtVideoBridge {
notify?: (payload: string) => void
saveScreenshot?: (dataUrl: string) => void
startLocalAudioRecording?: (sessionId: string, mimeType: string) => void
appendLocalAudioChunk?: (sessionId: string, sequence: number, encoded: string) => void
finishLocalAudioRecording?: (sessionId: string, totalBytes: number) => void
abortLocalAudioRecording?: (sessionId: string) => void
}
interface Window {
+707 -33
View File
@@ -14,8 +14,10 @@ import './style.css'
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
type CompanionMode = 'chat' | 'video'
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system' | 'call-status'
type VideoCallStatus = 'starting' | 'dialing' | 'connected' | 'ended' | 'failed'
type TranscriptionState = 'idle' | 'starting' | 'recording' | 'stopping' | 'error'
type LocalRecordingState = 'idle' | 'starting' | 'recording' | 'stopping' | 'uploading' | 'error'
interface NormalizedCallConfig {
SDKAppID: number
@@ -35,6 +37,14 @@ interface UiChatMessage {
url: string
name: string
time: string
callStatus?: VideoCallStatus
}
interface UiLiveCaption {
id: string
speaker: string
text: string
completed: boolean
}
interface BridgeMessage {
@@ -109,6 +119,25 @@ interface PendingSegment {
attempts: number
}
interface PendingLocalRecordingReply {
resolve: (value: boolean) => void
promise: Promise<boolean>
}
interface TrtcAudioTrackEvent {
userId?: string
track?: MediaStreamTrack
}
interface TrtcAudioCloud {
getAudioTrack?(configOrUserId?: {
userId?: string
processed?: boolean
} | string): MediaStreamTrack | null
on?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
off?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
}
const phase = ref<CallPhase>('ready')
const statusText = ref('正在连接问诊服务')
const patientName = ref('患者')
@@ -119,6 +148,8 @@ const chatBusy = ref(false)
const notice = ref('')
const hasMoreMessages = ref(false)
const transcriptionState = ref<TranscriptionState>('idle')
const localRecordingState = ref<LocalRecordingState>('idle')
const liveCaptions = ref<UiLiveCaption[]>([])
let activeConfig: NormalizedCallConfig | null = null
let chat: any = null
@@ -129,6 +160,10 @@ let nextReqMessageID = ''
let endNotified = true
let starting = false
let emittedRoomId = ''
let pendingRoomId = ''
let boundRoomId = ''
let roomBindingSentAt = 0
let roomBindingAttempts = 0
let resolveHostCallReady: ((value: boolean) => void) | null = null
const pendingTranscriptionStarts = new Map<string, PendingTranscriptionReply>()
const pendingTranscriptionStops = new Map<string, PendingTranscriptionReply>()
@@ -143,10 +178,34 @@ let transcriptionStopPromise: Promise<void> | null = null
let hangupNotification: Promise<void> | null = null
let callCycleGeneration = 0
let autoTranscriptionAttemptedGeneration = -1
let autoLocalRecordingAttemptedGeneration = -1
let lastTranscriberMessageAt = 0
let transcriberStoppedAt = 0
const acknowledgedSegmentIds = new Set<string>()
const pendingSegments = new Map<string, PendingSegment>()
const pendingLocalRecordingStarts = new Map<string, PendingLocalRecordingReply>()
const pendingLocalRecordingFinishes = new Map<string, PendingLocalRecordingReply>()
let localRecordingSessionId = ''
let localRecordingMimeType = ''
let localRecorder: MediaRecorder | null = null
let localAudioContext: AudioContext | null = null
let localAudioDestination: MediaStreamAudioDestinationNode | null = null
let localAudioCloud: TrtcAudioCloud | null = null
let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null
let localAudioSources: MediaStreamAudioSourceNode[] = []
let localAudioTrackIds = new Set<string>()
let localAudioOwnedTracks: MediaStreamTrack[] = []
let localAudioDiscoveryTimer: number | null = null
let localAudioHasDoctorSource = false
let localAudioHasPatientSource = false
let localRecordingAttachedSourceCount = 0
let localRecordingChunkSequence = 0
let localRecordingBytes = 0
let localRecordingChunkChain: Promise<void> = Promise.resolve()
let localRecordingStartPromise: Promise<void> | null = null
let localRecordingStopPromise: Promise<void> | null = null
let localRecordingFatalError = ''
let liveCaptionClearTimer: number | null = null
function initializeQtWebChannel(): void {
const transport = window.qt?.webChannelTransport
@@ -361,6 +420,29 @@ function mergeMessages(rawList: any[], prepend = false): void {
})
}
function appendVideoCallStatus(callStatus: VideoCallStatus, text: string): void {
if (!activeConfig || activeConfig.mode !== 'chat') return
const id = `local-video-call-${callCycleGeneration}-${callStatus}`
const message: UiChatMessage = {
id,
mine: false,
type: 'call-status',
text,
url: '',
name: '',
time: timeText(undefined),
callStatus,
}
const existingIndex = messages.value.findIndex((item) => item.id === id)
if (existingIndex < 0) {
messages.value = [...messages.value, message]
return
}
const next = [...messages.value]
next[existingIndex] = { ...next[existingIndex], ...message }
messages.value = next
}
function onMessageReceived(event: any): void {
if (!activeConfig) return
const expected = `C2C${activeConfig.targetUserId}`
@@ -465,11 +547,19 @@ async function loadMessages(prepend: boolean): Promise<void> {
if (!chat || !activeConfig || chatBusy.value) return
chatBusy.value = true
try {
const response = await chat.getMessageList({
const request: {
conversationID: string
count: number
nextReqMessageID?: string
} = {
conversationID: `C2C${activeConfig.targetUserId}`,
nextReqMessageID: prepend ? nextReqMessageID : '',
count: 30,
})
}
// Tencent Cloud IM requires the first roaming-history request to omit the
// pagination cursor. Passing an empty string is not equivalent and can
// return an empty page even though the C2C conversation has history.
if (prepend && nextReqMessageID) request.nextReqMessageID = nextReqMessageID
const response = await chat.getMessageList(request)
const data = response?.data ?? {}
const list = Array.isArray(data.messageList) ? data.messageList : []
nextReqMessageID = String(data.nextReqMessageID ?? '')
@@ -539,9 +629,454 @@ async function sendAttachment(file: File): Promise<void> {
}
}
function newLocalRecordingSessionId(): string {
const random = window.crypto?.randomUUID?.().replaceAll('-', '')
const entropy = random || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
return `audio-${entropy.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32)}`
}
function selectLocalRecordingMimeType(): string {
if (typeof MediaRecorder === 'undefined') return ''
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/ogg',
]
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? ''
}
function requestLocalRecordingStart(sessionId: string, mimeType: string): Promise<boolean> {
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.startLocalAudioRecording !== 'function') {
return Promise.resolve(false)
}
const existing = pendingLocalRecordingStarts.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingLocalRecordingReply
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingLocalRecordingStarts.set(sessionId, pending)
bridge.startLocalAudioRecording(sessionId, mimeType)
window.setTimeout(() => {
if (pendingLocalRecordingStarts.get(sessionId) !== pending) return
pendingLocalRecordingStarts.delete(sessionId)
pending.resolve(false)
}, 15000)
return pending.promise
}
function requestLocalRecordingFinish(sessionId: string, totalBytes: number): Promise<boolean> {
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.finishLocalAudioRecording !== 'function') {
return Promise.resolve(false)
}
const existing = pendingLocalRecordingFinishes.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingLocalRecordingReply
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingLocalRecordingFinishes.set(sessionId, pending)
bridge.finishLocalAudioRecording(sessionId, totalBytes)
window.setTimeout(() => {
if (pendingLocalRecordingFinishes.get(sessionId) !== pending) return
pendingLocalRecordingFinishes.delete(sessionId)
pending.resolve(false)
}, 180000)
return pending.promise
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index])
}
return window.btoa(binary)
}
async function sendLocalRecordingBlob(blob: Blob, sessionId: string): Promise<void> {
if (!blob.size || sessionId !== localRecordingSessionId) return
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.appendLocalAudioChunk !== 'function') {
throw new Error('桌面端本地录音分片通道不可用')
}
const bytes = new Uint8Array(await blob.arrayBuffer())
const maxChunkBytes = 8 * 1024
for (let offset = 0; offset < bytes.length; offset += maxChunkBytes) {
const chunk = bytes.subarray(offset, Math.min(offset + maxChunkBytes, bytes.length))
bridge.appendLocalAudioChunk(
sessionId,
localRecordingChunkSequence,
bytesToBase64(chunk),
)
localRecordingChunkSequence += 1
localRecordingBytes += chunk.length
}
}
function getTrtcAudioCloud(): TrtcAudioCloud | null {
const engine = TUICallKitAPI.getTUICallEngineInstance?.()
const cloud = engine?.getTRTCCloudInstance?.() as Partial<TrtcAudioCloud> | null
return cloud ? cloud as TrtcAudioCloud : null
}
function attachLocalRecordingTrack(
track: MediaStreamTrack | null | undefined,
sourceKind: 'doctor' | 'patient' | 'unknown' = 'unknown',
): boolean {
const context = localAudioContext
const destination = localAudioDestination
if (
!context
|| !destination
|| !track
|| track.kind !== 'audio'
|| track.readyState === 'ended'
) return false
if (sourceKind === 'doctor') localAudioHasDoctorSource = true
if (sourceKind === 'patient') localAudioHasPatientSource = true
if (localAudioTrackIds.has(track.id)) return true
const source = context.createMediaStreamSource(new MediaStream([track]))
source.connect(destination)
localAudioSources.push(source)
localAudioTrackIds.add(track.id)
localRecordingAttachedSourceCount += 1
track.addEventListener('ended', () => localAudioTrackIds.delete(track.id), { once: true })
return true
}
function attachCurrentCallAudioTracks(cloud: TrtcAudioCloud | null): void {
if (!activeConfig) return
if (typeof cloud?.getAudioTrack === 'function') {
try {
attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
} catch {
try {
attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
} catch {
// The rendered media elements below remain a supported fallback.
}
}
try {
attachLocalRecordingTrack(cloud.getAudioTrack({
userId: activeConfig.targetUserId,
processed: true,
}), 'patient')
} catch {
try {
attachLocalRecordingTrack(cloud.getAudioTrack(activeConfig.targetUserId), 'patient')
} catch {
// Remote audio can become available a few frames after connected.
}
}
}
for (const media of document.querySelectorAll<HTMLMediaElement>('video, audio')) {
const stream = media.srcObject
if (!(stream instanceof MediaStream)) continue
const sourceKind = media.muted ? 'doctor' : 'patient'
for (const track of stream.getAudioTracks()) {
attachLocalRecordingTrack(track, sourceKind)
}
}
}
async function attachDoctorMicrophoneFallback(): Promise<void> {
if (localAudioHasDoctorSource || !navigator.mediaDevices?.getUserMedia) return
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
})
const tracks = stream.getAudioTracks()
localAudioOwnedTracks.push(...tracks)
for (const track of tracks) attachLocalRecordingTrack(track, 'doctor')
}
async function waitForCallAudioTracks(
cloud: TrtcAudioCloud | null,
sessionId: string,
timeoutMs = 7000,
): Promise<void> {
const startedAt = Date.now()
let microphoneAttempted = false
while (Date.now() - startedAt < timeoutMs) {
if (sessionId !== localRecordingSessionId || endNotified) {
throw new Error('视频已结束,本机录音未启动')
}
attachCurrentCallAudioTracks(cloud)
const elapsed = Date.now() - startedAt
if (!localAudioHasDoctorSource && !microphoneAttempted && elapsed >= 800) {
microphoneAttempted = true
try {
await attachDoctorMicrophoneFallback()
} catch (error) {
console.warn(
'[doctor-consultation] 本机麦克风录音兜底不可用',
safeErrorMessage(error),
)
}
}
if (
localAudioTrackIds.size > 0
&& (localAudioHasDoctorSource && localAudioHasPatientSource || elapsed >= 2200)
) return
await new Promise((resolve) => window.setTimeout(resolve, 160))
}
if (localAudioTrackIds.size <= 0) {
throw new Error('未检测到医生或患者的实时语音音轨,请检查麦克风权限')
}
}
function startCallAudioDiscovery(cloud: TrtcAudioCloud | null): void {
if (localAudioDiscoveryTimer !== null) window.clearInterval(localAudioDiscoveryTimer)
localAudioDiscoveryTimer = window.setInterval(() => {
attachCurrentCallAudioTracks(cloud)
}, 500)
}
async function cleanupLocalRecordingGraph(): Promise<void> {
if (localAudioDiscoveryTimer !== null) {
window.clearInterval(localAudioDiscoveryTimer)
localAudioDiscoveryTimer = null
}
if (localAudioCloud && localAudioTrackHandler && typeof localAudioCloud.off === 'function') {
try {
localAudioCloud.off('track', localAudioTrackHandler)
} catch {
// The call engine may already have released its event dispatcher.
}
}
localAudioCloud = null
localAudioTrackHandler = null
for (const source of localAudioSources) {
try {
source.disconnect()
} catch {
// A closed AudioContext has already disconnected its graph.
}
}
localAudioSources = []
localAudioTrackIds.clear()
for (const track of localAudioOwnedTracks) track.stop()
localAudioOwnedTracks = []
localAudioHasDoctorSource = false
localAudioHasPatientSource = false
localRecordingAttachedSourceCount = 0
const context = localAudioContext
localAudioContext = null
localAudioDestination = null
if (context && context.state !== 'closed') {
try {
await context.close()
} catch {
// Closing the consultation must not be blocked by a released device.
}
}
localRecorder = null
}
async function performStartLocalRecording(): Promise<void> {
if (!activeConfig || phase.value !== 'connected' || endNotified) {
throw new Error('视频接通后才能启动本机录音')
}
if (!window.qtVideoBridge?.startLocalAudioRecording) {
throw new Error('桌面端本机录音存储通道不可用')
}
const mimeType = selectLocalRecordingMimeType()
if (!mimeType) throw new Error('当前浏览器不支持 Opus 本机录音')
localRecordingState.value = 'starting'
localRecordingFatalError = ''
localRecordingChunkSequence = 0
localRecordingBytes = 0
localRecordingChunkChain = Promise.resolve()
localRecordingAttachedSourceCount = 0
localAudioHasDoctorSource = false
localAudioHasPatientSource = false
const sessionId = newLocalRecordingSessionId()
localRecordingSessionId = sessionId
localRecordingMimeType = mimeType
try {
const AudioContextConstructor = window.AudioContext
const context = new AudioContextConstructor()
localAudioContext = context
localAudioDestination = context.createMediaStreamDestination()
const cloud = getTrtcAudioCloud()
localAudioCloud = cloud
localAudioTrackHandler = (event) => attachLocalRecordingTrack(
event.track,
event.userId === activeConfig?.userID
? 'doctor'
: event.userId === activeConfig?.targetUserId
? 'patient'
: 'unknown',
)
if (typeof cloud?.on === 'function') cloud.on('track', localAudioTrackHandler)
if (context.state === 'suspended') await context.resume()
await waitForCallAudioTracks(cloud, sessionId)
startCallAudioDiscovery(cloud)
const storageReady = await requestLocalRecordingStart(sessionId, mimeType)
if (!storageReady || sessionId !== localRecordingSessionId || endNotified) {
throw new Error(notice.value || '服务端未能创建本机录音临时文件')
}
const destination = localAudioDestination
if (!destination) throw new Error('本机录音混音器未就绪')
if (localRecordingAttachedSourceCount <= 0) {
throw new Error('本机录音没有连接到医生或患者语音')
}
const recorder = new MediaRecorder(destination.stream, { mimeType })
localRecorder = recorder
recorder.addEventListener('dataavailable', (event) => {
if (!event.data.size || sessionId !== localRecordingSessionId) return
localRecordingChunkChain = localRecordingChunkChain
.then(() => sendLocalRecordingBlob(event.data, sessionId))
.catch((error) => {
localRecordingFatalError = safeErrorMessage(error, '本机录音分片保存失败')
localRecordingState.value = 'error'
notice.value = localRecordingFatalError
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
if (recorder.state !== 'inactive') recorder.stop()
})
})
recorder.addEventListener('error', (event) => {
localRecordingFatalError = safeErrorMessage(
(event as ErrorEvent).error,
'浏览器本机录音发生错误',
)
localRecordingState.value = 'error'
notice.value = localRecordingFatalError
})
recorder.start(1000)
localRecordingState.value = 'recording'
notice.value = '腾讯云混流视频、本机语音录音和实时转写均已启动'
} catch (error) {
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
await cleanupLocalRecordingGraph()
if (sessionId === localRecordingSessionId) {
localRecordingSessionId = ''
localRecordingMimeType = ''
localRecordingState.value = 'error'
}
throw new Error(safeErrorMessage(error, '本机语音录音启动失败'))
}
}
function startLocalRecording(): Promise<void> {
if (localRecordingStartPromise) return localRecordingStartPromise
if (localRecordingState.value === 'recording') return Promise.resolve()
const operation = performStartLocalRecording()
const tracked = operation.finally(() => {
if (localRecordingStartPromise === tracked) localRecordingStartPromise = null
})
localRecordingStartPromise = tracked
return tracked
}
async function performStopLocalRecording(): Promise<void> {
const startInFlight = localRecordingStartPromise
if (startInFlight) {
try {
await startInFlight
} catch {
return
}
}
const sessionId = localRecordingSessionId
const recorder = localRecorder
if (!sessionId || !recorder) {
if (localRecordingState.value !== 'error') localRecordingState.value = 'idle'
return
}
localRecordingState.value = 'stopping'
try {
if (recorder.state !== 'inactive') {
await new Promise<void>((resolve) => {
recorder.addEventListener('stop', () => resolve(), { once: true })
recorder.stop()
})
}
await localRecordingChunkChain
if (localRecordingFatalError) throw new Error(localRecordingFatalError)
if (localRecordingAttachedSourceCount <= 0) {
throw new Error('本机录音没有连接到有效语音音轨')
}
if (localRecordingBytes < 1024) {
throw new Error('本机录音文件为空或不完整,已阻止上传空文件')
}
localRecordingState.value = 'uploading'
notice.value = '正在把本机语音录音上传到 COS…'
const persisted = await requestLocalRecordingFinish(sessionId, localRecordingBytes)
if (!persisted) throw new Error(notice.value || '本机语音录音上传 COS 失败')
localRecordingState.value = 'idle'
notice.value = '混流视频、本机录音和转写文字均已归档到本次通话记录'
} catch (error) {
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
localRecordingState.value = 'error'
throw new Error(safeErrorMessage(error, '本机语音录音保存失败'))
} finally {
await cleanupLocalRecordingGraph()
if (sessionId === localRecordingSessionId) {
localRecordingSessionId = ''
localRecordingMimeType = ''
}
}
}
function stopLocalRecording(): Promise<void> {
if (localRecordingStopPromise) return localRecordingStopPromise
if (localRecordingState.value === 'idle' && !localRecordingSessionId) return Promise.resolve()
const operation = performStopLocalRecording()
const tracked = operation.finally(() => {
if (localRecordingStopPromise === tracked) localRecordingStopPromise = null
})
localRecordingStopPromise = tracked
return tracked
}
function localRecordingResult(
operation: 'start' | 'chunk' | 'finish',
sessionId: string,
_sequence: number,
ok: boolean,
message: string,
): void {
if (operation === 'start') {
const pending = pendingLocalRecordingStarts.get(sessionId)
if (!pending) return
pendingLocalRecordingStarts.delete(sessionId)
if (message && sessionId === localRecordingSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (operation === 'finish') {
const pending = pendingLocalRecordingFinishes.get(sessionId)
if (!pending) return
pendingLocalRecordingFinishes.delete(sessionId)
if (message && sessionId === localRecordingSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (!ok && sessionId === localRecordingSessionId) {
localRecordingFatalError = message || '本机录音分片保存失败'
localRecordingState.value = 'error'
notice.value = localRecordingFatalError
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
if (localRecorder && localRecorder.state !== 'inactive') localRecorder.stop()
}
}
function newTranscriptionSessionId(): string {
const random = window.crypto?.randomUUID?.()
return random ? `call-${random}` : `call-${Date.now()}-${Math.random().toString(16).slice(2)}`
// Keep this identity within 32 ASCII characters. Some upgraded deployments
// still have the legacy varchar(32) column; a UUID with hyphens was silently
// truncated there, so the first transcript segment no longer matched the
// session that startCallTranscription had acknowledged.
const random = window.crypto?.randomUUID?.().replaceAll('-', '')
const entropy = random || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
return `tr-${entropy.replace(/[^a-zA-Z0-9]/g, '').slice(0, 28)}`
}
function requestTranscriptionStart(sessionId: string): Promise<boolean> {
@@ -615,19 +1150,59 @@ function getTranscriberManager(): RealtimeTranscriberManager {
return manager as RealtimeTranscriberManager
}
function clearLiveCaptions(): void {
if (liveCaptionClearTimer !== null) {
window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = null
}
liveCaptions.value = []
}
function showLiveCaption(message: RealtimeTranscriberMessage): void {
if (!activeConfig) return
const text = String(message.sourceText ?? '').trim()
if (!text) return
const speakerUserId = String(message.speakerUserId ?? '').trim()
const segmentId = String(message.segmentId ?? '').trim()
const id = segmentId || `speaker-${speakerUserId || 'unknown'}`
const speaker = speakerUserId === activeConfig.userID
? '医生'
: speakerUserId === activeConfig.targetUserId
? patientName.value || '患者'
: '对话'
const caption: UiLiveCaption = {
id,
speaker,
text: text.slice(0, 500),
completed: message.isCompleted === true,
}
const previous = liveCaptions.value.filter((item) => item.id !== id)
liveCaptions.value = [...previous, caption].slice(-2)
if (liveCaptionClearTimer !== null) window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = window.setTimeout(() => {
liveCaptions.value = []
liveCaptionClearTimer = null
}, message.isCompleted === true ? 9000 : 5000)
}
function handleTranscriberMessage(
_roomId: string | number,
roomId: string | number,
message: RealtimeTranscriberMessage,
): void {
// The realtime transcriber always identifies the active TRTC room. Use it
// as a second authoritative source because some CallKit versions clear the
// ROOM_ID store before the desktop host has persisted it.
observeRoomId(roomId)
if (
!activeConfig
|| !['recording', 'stopping'].includes(transcriptionState.value)
|| !transcriptionSessionId
|| message.isCompleted !== true
) return
lastTranscriberMessageAt = Date.now()
const segmentId = String(message.segmentId ?? '').trim()
const text = String(message.sourceText ?? '').trim()
showLiveCaption(message)
if (message.isCompleted !== true) return
if (
!segmentId
|| acknowledgedSegmentIds.has(segmentId)
@@ -666,8 +1241,11 @@ function subscribeTranscriber(): {
const manager = getTranscriberManager()
const listener: RealtimeTranscriberListener = {
onReceiveTranscriberMessage: handleTranscriberMessage,
onRealtimeTranscriberStarted: () => undefined,
onRealtimeTranscriberStopped: (_roomId, robotId) => {
onRealtimeTranscriberStarted: (roomId) => {
observeRoomId(roomId)
},
onRealtimeTranscriberStopped: (roomId, robotId) => {
observeRoomId(roomId)
if (robotId !== transcriberRobotId) return
transcriberRunning = false
transcriberStoppedAt = Date.now()
@@ -675,7 +1253,8 @@ function subscribeTranscriber(): {
void stopTranscription('partial', true)
}
},
onRealtimeTranscriberError: (_roomId, robotId, _error, errorMessage) => {
onRealtimeTranscriberError: (roomId, robotId, _error, errorMessage) => {
observeRoomId(roomId)
if (robotId !== transcriberRobotId || transcriptionState.value === 'stopping') return
notice.value = safeErrorMessage(new Error(errorMessage), '实时语音转写发生错误')
void stopTranscription('partial', true)
@@ -738,6 +1317,7 @@ async function performStartTranscription(): Promise<void> {
transcriptionSessionId = sessionId
acknowledgedSegmentIds.clear()
pendingSegments.clear()
clearLiveCaptions()
lastTranscriberMessageAt = Date.now()
transcriberStoppedAt = 0
notice.value = '正在准备录音文字存储…'
@@ -841,6 +1421,7 @@ async function stopTranscription(
transcriptionSessionId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
clearLiveCaptions()
})().finally(() => {
transcriptionStopPromise = null
})
@@ -889,46 +1470,83 @@ function notifyHangup(status = 'ended'): Promise<void> {
endNotified = true
phase.value = 'ended'
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
appendVideoCallStatus('ended', '视频通话已结束')
hangupNotification = (async () => {
try {
if (transcriptionState.value !== 'idle') await stopTranscription('completed')
} catch (error) {
notice.value = safeErrorMessage(error, '录音文字收尾失败,请稍后检查面诊记录')
console.warn('[doctor-consultation] 录音文字收尾失败', notice.value)
} finally {
emit({
source: 'doctor-call',
event: 'hangup',
diagnosisId: activeConfig?.diagnosisId,
status,
})
const operations: Promise<void>[] = []
if (localRecordingState.value !== 'idle' || localRecordingSessionId) {
operations.push(stopLocalRecording())
}
if (transcriptionState.value !== 'idle') {
operations.push(stopTranscription('completed'))
}
const results = await Promise.allSettled(operations)
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => safeErrorMessage(result.reason, '录音资料收尾失败'))
if (failures.length) {
notice.value = failures.join('').slice(0, 400)
console.warn('[doctor-consultation] 录音资料收尾失败', notice.value)
}
emit({
source: 'doctor-call',
event: 'hangup',
diagnosisId: activeConfig?.diagnosisId,
status,
})
})()
return hangupNotification
}
function readRoomId(): string {
const raw = TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)
function normalizeRoomId(raw: unknown): string {
if (raw === undefined || raw === null) return ''
const value = String(raw).trim()
return value && value !== '0' ? value : ''
}
function emitRoomId(): boolean {
const roomId = readRoomId()
if (!roomId || roomId === emittedRoomId) return Boolean(roomId)
function readRoomId(): string {
return normalizeRoomId(TUIStore.getData(StoreName.CALL, NAME.ROOM_ID))
}
function observeRoomId(rawRoomId: unknown): boolean {
if (!activeConfig || endNotified) return false
const roomId = normalizeRoomId(rawRoomId)
if (!roomId) return false
if (boundRoomId) return boundRoomId === roomId
const now = Date.now()
const retryDelay = Math.min(10_000, 750 * (2 ** Math.min(roomBindingAttempts, 4)))
if (pendingRoomId === roomId && now - roomBindingSentAt < retryDelay) return false
emittedRoomId = roomId
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig?.diagnosisId, roomId })
return true
pendingRoomId = roomId
roomBindingSentAt = now
roomBindingAttempts += 1
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig.diagnosisId, roomId })
return false
}
function emitRoomId(): boolean {
return observeRoomId(readRoomId())
}
async function pollRoomId(): Promise<void> {
for (let attempt = 0; attempt < 40 && activeConfig && !endNotified; attempt += 1) {
if (emitRoomId()) return
await new Promise((resolve) => window.setTimeout(resolve, 50))
const cycle = callCycleGeneration
while (activeConfig && !endNotified && cycle === callCycleGeneration) {
if (boundRoomId) return
emitRoomId()
await new Promise((resolve) => window.setTimeout(resolve, 250))
}
}
function handleRoomIdChanged(): void {
emitRoomId()
}
const roomIdWatchOptions = {
[NAME.ROOM_ID]: handleRoomIdChanged,
}
TUIStore.watch(StoreName.CALL, roomIdWatchOptions)
function handleStatusChanged(payload: unknown): void {
const value = payload && typeof payload === 'object'
? (payload as { newStatus?: unknown }).newStatus
@@ -939,6 +1557,7 @@ function handleStatusChanged(payload: unknown): void {
const cycle = callCycleGeneration
phase.value = 'connected'
statusText.value = '视频问诊进行中'
appendVideoCallStatus('connected', '视频通话已接通')
void pollRoomId()
if (autoTranscriptionAttemptedGeneration !== cycle) {
autoTranscriptionAttemptedGeneration = cycle
@@ -947,10 +1566,18 @@ function handleStatusChanged(payload: unknown): void {
notice.value = safeErrorMessage(error, '自动录音转文字启动失败')
})
}
if (autoLocalRecordingAttemptedGeneration !== cycle) {
autoLocalRecordingAttemptedGeneration = cycle
void startLocalRecording().catch((error) => {
if (cycle !== callCycleGeneration || endNotified) return
notice.value = safeErrorMessage(error, '本机语音录音启动失败')
})
}
} else if (status === 'calling' || status.startsWith('dialing')) {
if (endNotified || !activeConfig) return
phase.value = 'dialing'
statusText.value = '正在等待患者接听'
appendVideoCallStatus('dialing', '正在等待患者接听')
} else if (status === 'idle' && activeConfig && !starting) {
void notifyHangup(status)
}
@@ -998,8 +1625,13 @@ async function startVideo(): Promise<void> {
endNotified = false
phase.value = 'starting'
statusText.value = '正在创建安全视频通话'
appendVideoCallStatus('starting', '正在创建安全视频通话')
transcriptionState.value = 'idle'
transcriptionSessionId = ''
clearLiveCaptions()
localRecordingState.value = 'idle'
localRecordingSessionId = ''
localRecordingMimeType = ''
notice.value = ''
const allowed = await requestHostCallStart()
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
@@ -1012,7 +1644,12 @@ async function startVideo(): Promise<void> {
await nextTick()
phase.value = 'dialing'
statusText.value = '正在呼叫患者'
appendVideoCallStatus('dialing', '正在呼叫患者')
emittedRoomId = ''
pendingRoomId = ''
boundRoomId = ''
roomBindingSentAt = 0
roomBindingAttempts = 0
await TUICallKitAPI.calls({
userIDList: [activeConfig.targetUserId],
type: TUICallType.VIDEO_CALL,
@@ -1023,6 +1660,7 @@ async function startVideo(): Promise<void> {
const message = safeErrorMessage(error, '无法发起视频通话')
phase.value = 'error'
statusText.value = message
appendVideoCallStatus('failed', `视频通话发起失败:${message}`)
endNotified = true
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
throw new Error(message)
@@ -1058,6 +1696,25 @@ function screenshotResult(ok: boolean, message: string): void {
notice.value = message || (ok ? '截图已保存到患者舌像资料' : '截图保存失败')
}
function recordingResult(ok: boolean, message: string): void {
notice.value = message || (ok
? '通话已自动录制,结束后将由云端完成 COS 文件收尾。'
: '自动云端录制未启动,请结束本次通话并检查 COS 配置。')
}
function roomBindingResult(roomId: string, ok: boolean, message: string): void {
const normalized = normalizeRoomId(roomId)
if (!normalized || (emittedRoomId && normalized !== emittedRoomId)) return
if (ok) {
boundRoomId = normalized
pendingRoomId = ''
} else if (pendingRoomId === normalized) {
// Keep the failed room pending so pollRoomId retries it with exponential
// backoff. The desktop lifecycle releases only failed claims.
}
recordingResult(ok, message)
}
async function open(config: DoctorCallConfig): Promise<void> {
if (activeConfig) await close()
activeConfig = normalizeConfig(config)
@@ -1070,14 +1727,20 @@ async function open(config: DoctorCallConfig): Promise<void> {
hangupNotification = null
callCycleGeneration += 1
autoTranscriptionAttemptedGeneration = -1
autoLocalRecordingAttemptedGeneration = -1
phase.value = 'ready'
transcriptionState.value = 'idle'
transcriptionSessionId = ''
clearLiveCaptions()
transcriptionGeneration += 1
transcriberRunning = false
transcriberRobotId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
localRecordingState.value = 'idle'
localRecordingSessionId = ''
localRecordingMimeType = ''
localRecordingFatalError = ''
notice.value = ''
if (activeConfig.mode === 'chat') {
try {
@@ -1103,6 +1766,12 @@ async function close(): Promise<void> {
for (const pending of pendingTranscriptionStops.values()) pending.resolve(false)
pendingTranscriptionStarts.clear()
pendingTranscriptionStops.clear()
clearLiveCaptions()
for (const pending of pendingLocalRecordingStarts.values()) pending.resolve(false)
for (const pending of pendingLocalRecordingFinishes.values()) pending.resolve(false)
pendingLocalRecordingStarts.clear()
pendingLocalRecordingFinishes.clear()
await cleanupLocalRecordingGraph()
await logoutChat()
activeConfig = null
phase.value = 'ended'
@@ -1114,8 +1783,11 @@ window.doctorConsultation = {
startVideo,
hangup,
hostCallReady,
recordingResult,
roomBindingResult,
screenshotResult,
transcriptionResult,
localRecordingResult,
}
window.doctorCall = { start: open, hangup }
initializeQtWebChannel()
@@ -1131,6 +1803,8 @@ createApp(App, {
notice: readonly(notice),
hasMoreMessages: readonly(hasMoreMessages),
transcriptionState: readonly(transcriptionState),
localRecordingState: readonly(localRecordingState),
liveCaptions: readonly(liveCaptions),
onSendText: sendText,
onSendAttachment: sendAttachment,
onLoadMore: () => loadMessages(true),
+156
View File
@@ -151,6 +151,57 @@ button:disabled { cursor: not-allowed; opacity: .55; }
margin: 12px 0;
}
.message-row--mine { align-items: flex-end; }
.message-row--call-status {
align-items: center;
margin: 16px 0;
}
.call-status-event {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 12px;
border: 1px solid #dce2f2;
border-radius: 999px;
color: #617092;
background: rgba(255, 255, 255, .92);
font-size: 12px;
box-shadow: 0 4px 14px rgba(17, 31, 70, .04);
}
.call-status-event__icon {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 7px;
color: #5761f4;
background: #eef0ff;
font-size: 11px;
}
.call-status-event strong {
color: #34425f;
font-weight: 700;
}
.call-status-event time {
color: #8b97b2;
font-variant-numeric: tabular-nums;
}
.call-status-event--connected {
border-color: #bdebdc;
background: #f2fbf7;
}
.call-status-event--connected .call-status-event__icon {
color: #11986f;
background: #dff7ee;
}
.call-status-event--failed {
border-color: #f3c8cf;
background: #fff5f6;
}
.call-status-event--failed .call-status-event__icon {
color: #c43f50;
background: #ffe6e9;
}
.message-meta { margin: 0 8px 5px; color: #8995a9; font-size: 11px; }
.message-bubble {
max-width: min(72%, 620px);
@@ -285,6 +336,41 @@ button:disabled { cursor: not-allowed; opacity: .55; }
}
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
.live-captions {
position: absolute;
z-index: 38;
left: 50%;
bottom: 92px;
display: grid;
gap: 7px;
width: min(820px, calc(100% - 360px));
transform: translateX(-50%);
pointer-events: none;
}
.live-captions p {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
width: fit-content;
max-width: 100%;
margin: 0 auto;
padding: 8px 14px;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 10px;
color: #fff;
background: rgba(9, 13, 20, .78);
box-shadow: 0 6px 24px rgba(0, 0, 0, .2);
font-size: 16px;
line-height: 1.55;
backdrop-filter: blur(8px);
}
.live-captions strong {
color: #aeb8ff;
white-space: nowrap;
}
.live-captions span { min-width: 0; word-break: break-word; }
.video-actions {
position: absolute;
z-index: 40;
@@ -336,10 +422,80 @@ button:disabled { cursor: not-allowed; opacity: .55; }
}
.video-notice--error { border-color: rgba(242, 109, 109, .4); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
.screenshot-dialog-backdrop {
position: absolute;
z-index: 120;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(7, 11, 19, .76);
backdrop-filter: blur(5px);
}
.screenshot-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
gap: 14px;
width: min(920px, 92vw);
max-height: calc(100vh - 48px);
padding: 20px;
overflow: hidden;
border: 1px solid #e2e7f4;
border-radius: 18px;
color: #111f46;
background: #fff;
box-shadow: 0 28px 90px rgba(4, 10, 27, .34);
}
.screenshot-dialog > header,
.screenshot-dialog > footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.screenshot-dialog h2 { margin: 2px 0 0; font-size: 20px; line-height: 1.35; }
.screenshot-dialog .eyebrow { margin: 0; color: #5761f4; }
.screenshot-dialog > header > button {
width: 36px;
height: 36px;
border: 0;
border-radius: 9px;
color: #7886aa;
background: #f2f4fb;
font-size: 24px;
}
.screenshot-preview-frame {
display: grid;
place-items: center;
min-height: 260px;
overflow: hidden;
border-radius: 12px;
background: #0b0f16;
}
.screenshot-preview-frame img {
display: block;
max-width: 100%;
max-height: min(62vh, 650px);
object-fit: contain;
}
.screenshot-dialog__hint { margin: 0; color: #617092; font-size: 13px; }
.screenshot-dialog > footer { justify-content: flex-end; }
.screenshot-dialog > footer button {
min-width: 112px;
padding: 10px 18px;
border-radius: 9px;
font-weight: 700;
}
.screenshot-cancel { border: 1px solid #dfe4f1; color: #3f4e75; background: #fff; }
.screenshot-confirm { border: 1px solid #5761f4; color: #fff; background: #5761f4; }
.screenshot-confirm:hover { background: #4c57e9; }
@media (max-width: 820px) {
.consultation-shell { min-width: 620px; }
.message-list { padding-inline: 18px; }
.message-bubble { max-width: 82%; }
.live-captions { width: calc(100% - 36px); bottom: 88px; }
.live-captions p { font-size: 14px; }
}
/* Doctor workstation blue-white subwindow contract. Video pixels remain on
@@ -172,7 +172,7 @@ class AppointmentController extends BaseAdminController
public function addDoctorNote()
{
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
if (!DiagnosisLogic::canManageDiagnosis(
(int) $params['diagnosis_id'],
$this->adminId,
$this->adminInfo
@@ -330,25 +330,15 @@ class DiagnosisController extends BaseAdminController
* @notes 获取通话签名
* @return \think\response\Json
*/
public function getCallSignature()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
if (empty($params['patient_id'])) {
return $this->fail('患者ID不能为空');
}
// 传递当前管理员ID
$params['admin_id'] = $this->adminId;
$result = DiagnosisLogic::getCallSignature($params);
if ($result) {
return $this->data($result);
}
public function getCallSignature()
{
$params = (new DiagnosisValidate())->post()->goCheck('callIdentity');
$params['admin_id'] = (int) $this->adminId;
$result = DiagnosisLogic::getCallSignature($params, $this->adminInfo);
if ($result) {
return $this->data($result);
}
return $this->fail(DiagnosisLogic::getError());
}
@@ -525,9 +515,9 @@ class DiagnosisController extends BaseAdminController
* @notes 接通后发起腾讯云云端混流录制(需配置 CAM 与云点播)
* @return \think\response\Json
*/
public function startCloudRecording()
{
$params = $this->request->post();
public function startCloudRecording()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
@@ -536,12 +526,32 @@ class DiagnosisController extends BaseAdminController
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 浏览器本地上传通话录制后,关联到通话记录(合并 recording_urls
return $this->data($result);
}
/**
* @notes 分片上传本机通话录音或手动视频回放
* @return \think\response\Json
*/
public function uploadCallRecording()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::uploadCallRecording($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 浏览器本地上传通话录制后,关联到通话记录(合并 recording_urls
* @return \think\response\Json
*/
public function attachLocalCallRecording()
@@ -293,20 +293,32 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$prescribedDiagnosisIds = $rxQ->column('diagnosis_id');
$prescribedDiagnosisIds = array_flip($prescribedDiagnosisIds ?: []);
}
// 当前页预约关联的处方(按 appointment_id取最新一条):用于「开方/查看」与审核状态
// 当前页预约关联的处方(按 appointment_id优先最新未作废,否则最新一条):
// 用于本次挂号的「开方/编辑/查看」与审核状态。
$appointmentIds = array_filter(array_map('intval', array_column($lists, 'id')));
$rxByAppointmentId = [];
$fallbackRxByAppointmentId = [];
if (!empty($appointmentIds)) {
$rxRows = Prescription::whereIn('appointment_id', $appointmentIds)
->whereNull('delete_time')
->where('void_status', 0)
->order('id', 'desc')
->field(['id', 'appointment_id', 'audit_status', 'void_status', 'is_system_auto'])
->select()
->toArray();
foreach ($rxRows as $rx) {
$aid = (int) ($rx['appointment_id'] ?? 0);
if ($aid > 0 && !isset($rxByAppointmentId[$aid])) {
if ($aid <= 0) {
continue;
}
if (!isset($fallbackRxByAppointmentId[$aid])) {
$fallbackRxByAppointmentId[$aid] = $rx;
}
if ((int) ($rx['void_status'] ?? 0) === 0 && !isset($rxByAppointmentId[$aid])) {
$rxByAppointmentId[$aid] = $rx;
}
}
foreach ($fallbackRxByAppointmentId as $aid => $rx) {
if (!isset($rxByAppointmentId[$aid])) {
$rxByAppointmentId[$aid] = $rx;
}
}
@@ -353,6 +365,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$apptId = (int) ($item['id'] ?? 0);
$apptRx = $rxByAppointmentId[$apptId] ?? null;
$item['current_has_prescription'] = $apptRx !== null ? 1 : 0;
$item['current_prescription_id'] = $apptRx !== null ? (int) ($apptRx['id'] ?? 0) : 0;
$item['prescription_audit_status'] = $apptRx !== null ? (int) ($apptRx['audit_status'] ?? -1) : -1;
$item['prescription_void_status'] = $apptRx !== null ? (int) ($apptRx['void_status'] ?? 0) : 0;
$item['prescription_is_system_auto'] = $apptRx !== null ? (int) ($apptRx['is_system_auto'] ?? 0) : 0;
@@ -282,17 +282,51 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
// 关联是否开方:诊单是否有处方记录
$diagnosisIds = array_column($lists, 'id');
$prescriptionMap = [];
if (!empty($diagnosisIds)) {
$prescriptionTbl = (new Prescription())->getTable();
$prescribedIds = Prescription::whereIn('diagnosis_id', $diagnosisIds)
->whereNull('delete_time')
->column('diagnosis_id');
$prescriptionMap = array_fill_keys($prescribedIds, 1);
}
foreach ($lists as &$item) {
$item['has_prescription'] = isset($prescriptionMap[$item['id']]) ? 1 : 0;
}
$prescriptionMap = [];
if (!empty($diagnosisIds)) {
$prescribedIds = Prescription::whereIn('diagnosis_id', $diagnosisIds)
->whereNull('delete_time')
->column('diagnosis_id');
$prescriptionMap = array_fill_keys($prescribedIds, 1);
}
foreach ($lists as &$item) {
$item['has_prescription'] = isset($prescriptionMap[$item['id']]) ? 1 : 0;
}
// 当前操作必须只认当前挂号的处方。has_prescription 继续表示诊单历史上曾开方,
// 不能再用它驱动本次挂号的“开方/编辑/查看”按钮。
$currentRxByAppointment = [];
$fallbackRxByAppointment = [];
$appointmentIds = array_values(array_filter(array_unique(array_map(
'intval',
array_column($lists, 'appointment_id')
))));
if ($appointmentIds !== []) {
$currentRxRows = Prescription::whereIn('appointment_id', $appointmentIds)
->whereNull('delete_time')
->field(['id', 'appointment_id', 'audit_status', 'void_status'])
->order('id', 'desc')
->select()
->toArray();
foreach ($currentRxRows as $rx) {
$appointmentId = (int) ($rx['appointment_id'] ?? 0);
if ($appointmentId <= 0) {
continue;
}
if (!isset($fallbackRxByAppointment[$appointmentId])) {
$fallbackRxByAppointment[$appointmentId] = $rx;
}
if ((int) ($rx['void_status'] ?? 0) === 0
&& !isset($currentRxByAppointment[$appointmentId])) {
$currentRxByAppointment[$appointmentId] = $rx;
}
}
foreach ($fallbackRxByAppointment as $appointmentId => $rx) {
if (!isset($currentRxByAppointment[$appointmentId])) {
$currentRxByAppointment[$appointmentId] = $rx;
}
}
}
// 复诊展示:业务上「已开方」即算有复诊,取该诊单下最新一条处方的时间与医师(优先未作废)
$followupByDiag = [];
@@ -346,17 +380,21 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
];
}
}
foreach ($lists as &$item) {
$did = (int) $item['id'];
$fu = $followupByDiag[$did] ?? null;
$item['followup_time_text'] = ($item['has_prescription'] && $fu) ? ($fu['time_text'] ?? '') : '';
$item['followup_doctor_name'] = ($item['has_prescription'] && $fu) ? ($fu['doctor_name'] ?? '—') : '';
$item['followup_rx_voided'] = ($item['has_prescription'] && $fu && !empty($fu['voided'])) ? 1 : 0;
$item['prescription_audit_status'] = ($item['has_prescription'] && $fu) ? (int) ($fu['audit_status'] ?? -1) : -1;
$item['prescription_void_status'] = ($item['has_prescription'] && $fu) ? (int) ($fu['void_status'] ?? 0) : 0;
// 开方次数即复诊次数:1 张处方 = 第 1 次复诊,以此类推
$item['followup_prescription_count'] = (int) ($rxCountByDiag[$did] ?? 0);
}
foreach ($lists as &$item) {
$did = (int) $item['id'];
$fu = $followupByDiag[$did] ?? null;
$currentAppointmentId = (int) ($item['appointment_id'] ?? 0);
$currentRx = $currentRxByAppointment[$currentAppointmentId] ?? null;
$item['followup_time_text'] = ($item['has_prescription'] && $fu) ? ($fu['time_text'] ?? '') : '';
$item['followup_doctor_name'] = ($item['has_prescription'] && $fu) ? ($fu['doctor_name'] ?? '—') : '';
$item['followup_rx_voided'] = ($item['has_prescription'] && $fu && !empty($fu['voided'])) ? 1 : 0;
$item['current_has_prescription'] = $currentRx !== null ? 1 : 0;
$item['current_prescription_id'] = $currentRx !== null ? (int) ($currentRx['id'] ?? 0) : 0;
$item['prescription_audit_status'] = $currentRx !== null ? (int) ($currentRx['audit_status'] ?? -1) : -1;
$item['prescription_void_status'] = $currentRx !== null ? (int) ($currentRx['void_status'] ?? 0) : 0;
// 开方次数即复诊次数:1 张处方 = 第 1 次复诊,以此类推
$item['followup_prescription_count'] = (int) ($rxCountByDiag[$did] ?? 0);
}
// 最近一条通话记录状态(列表行展示:通话中 / 已结束等)
$latestCallByDiag = [];
@@ -966,7 +966,8 @@ class ConversionLogic
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计);
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享}
@@ -993,7 +994,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-effective-v6', [
$baseKey = self::requestRowsCacheKey('fans-effective-v7', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1035,20 +1036,11 @@ class ConversionLogic
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->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('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->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 = ?'
+411 -204
View File
@@ -855,68 +855,77 @@ class DiagnosisLogic extends BaseLogic
}
/**
* @notes 获取通话签名
* @param array $params
* @return array|bool
*/
public static function getCallSignature(array $params)
{
try {
// 获取配置
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
return false;
}
// 获取当前管理员ID(从参数中获取)
$adminId = $params['admin_id'] ?? 0;
$patientId = $params['patient_id'] ?? 0;
if (!$adminId) {
self::setError('获取管理员信息失败');
return false;
}
if (!$patientId) {
self::setError('获取患者信息失败');
return false;
}
// 医生userId
$doctorUserId = 'doctor_' . $adminId;
$query = Diagnosis::where('id', $patientId)
->where('delete_time', null)->find();
if(!$query){
self::setError('患者诊单已被删除');
return false;
}
// 患者userId(必须与小程序端一致)
$patientUserId = 'patient_' . $patientId;
// 生成医生的 UserSig
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $doctorUserId);
if (!$userSig) {
self::setError('生成签名失败');
return false;
}
// 导入医生账号到IM
self::importDoctorAccountToIm($adminId, $doctorUserId);
// 确保患者账号也已导入IM(用于跨平台通话)
self::ensurePatientImAccount($patientId, $patientUserId);
return [
'sdkAppId' => (int)$config['sdkAppId'], // 确保返回整数
'userId' => $doctorUserId, // 医生的userId
'userSig' => $userSig,
'assistant_id'=>$query->assistant_id?'doctor_'.$query->assistant_id:'',
'patientUserId' => $patientUserId, // 患者的userId(用于发起通话)
'expireTime' => 86400, // 24小时
* @notes 获取通话签名
* @param array $params
* @param array $adminInfo
* @return array|bool
*/
public static function getCallSignature(array $params, array $adminInfo = [])
{
try {
$adminId = (int) ($params['admin_id'] ?? 0);
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
$patientId = (int) ($params['patient_id'] ?? 0);
if ($adminId <= 0) {
self::setError('获取管理员信息失败');
return false;
}
if ($diagnosisId <= 0 || $patientId <= 0) {
self::setError('诊单或患者信息不完整');
return false;
}
// Authorize the diagnosis before reading TRTC configuration or importing IM accounts.
if (!self::canManageDiagnosis($diagnosisId, $adminId, $adminInfo)) {
self::setError('诊单不存在、患者不匹配或无权访问');
return false;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)
->where('patient_id', $patientId)
->whereNull('delete_time')
->where('status', 1)
->find();
if (!$diagnosis) {
self::setError('诊单不存在、患者不匹配或无权访问');
return false;
}
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
return false;
}
$doctorUserId = 'doctor_' . $adminId;
// 患者userId(必须与小程序端一致)
$patientUserId = 'patient_' . $patientId;
// 生成医生的 UserSig
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $doctorUserId);
if (!$userSig) {
self::setError('生成签名失败');
return false;
}
// 导入医生账号到IM
self::importDoctorAccountToIm($adminId, $doctorUserId);
// 确保患者账号也已导入IM(用于跨平台通话)
self::ensurePatientImAccount($patientId, $patientUserId);
return [
'sdkAppId' => (int)$config['sdkAppId'], // 确保返回整数
'userId' => $doctorUserId, // 医生的userId
'userSig' => $userSig,
'assistant_id' => $diagnosis->assistant_id ? 'doctor_' . $diagnosis->assistant_id : '',
'patientUserId' => $patientUserId, // 患者的userId(用于发起通话)
'diagnosis_id' => $diagnosisId,
'patient_id' => $patientId,
'expireTime' => 86400, // 24小时
// 与 .env [trtc] ISLOCHOSTVOD 一致:true 允许浏览器本地录制并上传
'isLochostVod' => (bool)config('trtc.is_lochost_vod', false),
];
@@ -1645,12 +1654,13 @@ class DiagnosisLogic extends BaseLogic
* @param array $params
* @return bool
*/
public static function endCall(array $params): bool
{
try {
// 获取当前管理员ID(从参数中获取)
$adminId = (int)($params['admin_id'] ?? 0);
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
public static function endCall(array $params): bool
{
try {
// 获取当前管理员ID(从参数中获取)
$adminId = (int)($params['admin_id'] ?? 0);
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($adminId <= 0) {
self::setError('获取管理员信息失败');
@@ -1661,26 +1671,47 @@ class DiagnosisLogic extends BaseLogic
return false;
}
// 优先匹配「当前医生 + 进行中」,与 startCloudRecording / bindCallRoom 一致
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
if ($record) {
\think\facade\Log::warning('endCall: 未匹配 caller_id,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
'call_record_id' => $record['id'] ?? null,
]);
}
}
$record = null;
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('caller_type', 'doctor')
->find();
if (!$record) {
self::setError('通话记录不存在或无权操作');
return false;
}
if ((int)($record['status'] ?? 0) === 2) {
// afterCalling / Store idle may report the same exact call twice.
return true;
}
if ((int)($record['status'] ?? 0) !== 1) {
self::setError('通话记录状态不可结束');
return false;
}
} else {
// Legacy web callers may not yet send call_record_id.
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
if ($record) {
\think\facade\Log::warning('endCall: 未传 call_record_id 且未匹配 caller_id,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
'call_record_id' => $record['id'] ?? null,
]);
}
}
}
if (!$record) {
// 前端常重复回调 endCallafterCalling + Store idle),第一条已结束则不再告警
@@ -1861,9 +1892,20 @@ class DiagnosisLogic extends BaseLogic
if (is_array($decoded)) {
$urls = $decoded;
}
}
}
$record['recording_urls_list'] = $urls;
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
$localAudioUrls = [];
if (!empty($record['local_audio_urls'])) {
$decodedLocalAudioUrls = json_decode((string)$record['local_audio_urls'], true);
if (is_array($decodedLocalAudioUrls)) {
$localAudioUrls = $decodedLocalAudioUrls;
}
}
$record['local_audio_urls_list'] = $localAudioUrls;
$record['local_audio_status_text'] = self::localAudioStatusText(
(int)($record['local_audio_status'] ?? 0)
);
$record['transcription_status_text'] = self::transcriptionStatusText(
(string)($record['transcription_status'] ?? '')
);
@@ -2184,11 +2226,12 @@ class DiagnosisLogic extends BaseLogic
* @notes TRTC 房间号写入当前诊单通话记录,并尝试 API 合流云端录制
* @return array{cloud_recording?:array}|false 成功返回 data 数组(供接口带给前端);失败 false
*/
public static function bindCallRoom(array $params)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$roomId = trim((string)($params['room_id'] ?? ''));
public static function bindCallRoom(array $params)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$roomId = trim((string)($params['room_id'] ?? ''));
if ($diagnosisId <= 0 || $roomId === '') {
self::setError('诊单ID或房间号不能为空');
return false;
@@ -2196,24 +2239,34 @@ class DiagnosisLogic extends BaseLogic
$adminId = (int)($params['admin_id'] ?? 0);
// 必须与 startCloudRecording 使用同一条「进行中 + 当前管理员」记录写 room_id,否则会写到别的记录上,合流 API 读到 room_id 仍为空 → 关闭全局录制后无任何文件
$record = null;
if ($adminId > 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->where('caller_id', $adminId)
$record = null;
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('status', 1)
->where('caller_id', $adminId)
->where('caller_type', 'doctor')
->find();
if (!$record) {
self::setError('通话记录不存在或无权绑定房间');
return false;
}
} elseif ($adminId > 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->where('caller_id', $adminId)
->order('id', 'desc')
->find();
}
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
if (!$record && $callRecordId <= 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
}
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('room_id', '')
if (!$record && $callRecordId <= 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('room_id', '')
->order('id', 'desc')
->find();
}
@@ -2233,10 +2286,11 @@ class DiagnosisLogic extends BaseLogic
'message' => '未尝试合流录制(admin_id 为空)',
];
if ($adminId > 0) {
$cloudRec = self::startCloudRecording([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
], true);
$cloudRec = self::startCloudRecording([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'call_record_id' => (int)$record['id'],
], true);
if ($cloudRec === false) {
\think\facade\Log::warning('bindCallRoom: startCloudRecording 未执行或异常', [
'diagnosis_id' => $diagnosisId,
@@ -2286,10 +2340,11 @@ class DiagnosisLogic extends BaseLogic
*/
public static function startCloudRecording(array $params, bool $silent = false)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0) {
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0) {
if (!$silent) {
self::setError('参数错误');
}
@@ -2297,24 +2352,40 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
if ($record) {
\think\facade\Log::warning('startCloudRecording: 未找到 caller_id 匹配的进行中记录,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
]);
}
}
$record = null;
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('caller_type', 'doctor')
->where('status', 1)
->find();
if (!$record) {
if (!$silent) {
self::setError('通话记录不存在或无权开启云端录制');
}
return false;
}
} else {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
if ($record) {
\think\facade\Log::warning('startCloudRecording: 未传 call_record_id 且未找到 caller_id 匹配的进行中记录,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
]);
}
}
}
if (!$record) {
if (!$silent) {
self::setError('没有进行中的通话记录');
@@ -2389,9 +2460,9 @@ class DiagnosisLogic extends BaseLogic
/**
* @notes 医生端浏览器本地录制上传后,将文件访问地址合并写入当前诊单下该医生的最近一条通话记录
*/
public static function attachLocalCallRecording(array $params): bool
{
try {
public static function attachLocalCallRecording(array $params): bool
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$fileUrl = trim((string)($params['file_url'] ?? ''));
@@ -2428,12 +2499,61 @@ class DiagnosisLogic extends BaseLogic
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单回放视频分片上传,完成后显式关联到指定通话记录
return false;
}
}
/**
* 将医生工作站本机录音独立关联到通话记录
*
* 本机音频与腾讯云混流视频分别持久化,避免录音上传成功后被误计为
* 云端视频已生成。
*/
public static function attachLocalCallAudio(array $params): bool
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$fileUrl = trim((string)($params['file_url'] ?? ''));
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') {
self::setError('参数错误');
return false;
}
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId);
if (!$record) {
self::setError('未找到通话记录');
return false;
}
$previousUrls = [];
if (!empty($record->local_audio_urls)) {
$decodedUrls = json_decode((string)$record->local_audio_urls, true);
if (is_array($decodedUrls)) {
$previousUrls = $decodedUrls;
}
}
$mergedUrls = array_values(array_unique(array_merge($previousUrls, [$fileUrl])));
$record->save([
'local_audio_urls' => json_encode($mergedUrls, JSON_UNESCAPED_UNICODE),
'local_audio_status' => 2,
'update_time' => time(),
]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单回放视频分片上传,完成后显式关联到指定通话记录
* @param array $params
* @return array|false
*/
@@ -2445,11 +2565,12 @@ class DiagnosisLogic extends BaseLogic
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$uploadId = trim((string)($params['upload_id'] ?? ''));
$fileName = trim((string)($params['file_name'] ?? ''));
$fileSize = (int)($params['file_size'] ?? 0);
$chunkIndex = (int)($params['chunk_index'] ?? -1);
$chunkTotal = (int)($params['chunk_total'] ?? 0);
$uploadId = trim((string)($params['upload_id'] ?? ''));
$fileName = trim((string)($params['file_name'] ?? ''));
$mimeType = strtolower(trim((string)($params['mime_type'] ?? '')));
$fileSize = (int)($params['file_size'] ?? 0);
$chunkIndex = (int)($params['chunk_index'] ?? -1);
$chunkTotal = (int)($params['chunk_total'] ?? 0);
if (
$diagnosisId <= 0 ||
@@ -2462,13 +2583,27 @@ class DiagnosisLogic extends BaseLogic
) {
self::setError('上传参数不完整');
return false;
}
$ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext === '' || !in_array($ext, config('project.file_video'), true)) {
self::setError('视频格式不支持');
return false;
}
}
$ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$audioExtensions = ['webm', 'ogg', 'opus', 'mp3', 'wav', 'm4a', 'aac', 'amr', 'wma'];
$videoExtensions = (array)config('project.file_video');
$hasAudioMime = str_starts_with($mimeType, 'audio/');
$hasVideoMime = str_starts_with($mimeType, 'video/');
$isAmbiguousWebm = $ext === 'webm';
$isVideo = in_array($ext, $videoExtensions, true)
&& (
$mimeType === ''
|| $hasVideoMime
|| (!$isAmbiguousWebm && $hasAudioMime)
);
$isLocalAudio = !$isVideo
&& $hasAudioMime
&& in_array($ext, $audioExtensions, true);
if (!$isLocalAudio && !$isVideo) {
self::setError('音视频格式不支持');
return false;
}
if ($callRecordId <= 0) {
$record = self::createSyntheticCallRecord($diagnosisId, $adminId, $fileName);
@@ -2497,10 +2632,12 @@ class DiagnosisLogic extends BaseLogic
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_name' => $fileName,
'file_size' => $fileSize,
'chunk_total' => $chunkTotal,
], JSON_UNESCAPED_UNICODE));
'file_name' => $fileName,
'file_size' => $fileSize,
'mime_type' => $mimeType,
'media_kind' => $isLocalAudio ? 'local_audio' : 'video',
'chunk_total' => $chunkTotal,
], JSON_UNESCAPED_UNICODE));
$chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($chunkIndex);
$moved = $chunkFile->move($uploadDir, basename($chunkPath));
@@ -2527,21 +2664,29 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$uploadResult = self::storeMergedCallRecording($mergedPath, $fileName, $adminId);
if (empty($uploadResult['uri'])) {
self::setError('保存视频失败');
return false;
}
$attached = self::attachLocalCallRecording([
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_url' => (string)$uploadResult['uri'],
]);
if (!$attached) {
return false;
}
$uploadResult = self::storeMergedCallRecording(
$mergedPath,
$fileName,
$adminId,
$isLocalAudio
);
if (empty($uploadResult['uri'])) {
self::setError($isLocalAudio ? '保存本机录音失败' : '保存视频失败');
return false;
}
$attachmentParams = [
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_url' => (string)$uploadResult['uri'],
];
$attached = $isLocalAudio
? self::attachLocalCallAudio($attachmentParams)
: self::attachLocalCallRecording($attachmentParams);
if (!$attached) {
return false;
}
self::cleanupCallRecordingChunkDir($uploadDir);
@@ -2550,10 +2695,12 @@ class DiagnosisLogic extends BaseLogic
'uploaded_chunks' => $chunkTotal,
'chunk_total' => $chunkTotal,
'call_record_id' => $callRecordId,
'file_id' => $uploadResult['id'] ?? 0,
'file_url' => $uploadResult['uri'] ?? '',
'recording_status' => 2,
];
'file_id' => $uploadResult['id'] ?? 0,
'file_url' => $uploadResult['uri'] ?? '',
'media_kind' => $isLocalAudio ? 'local_audio' : 'video',
'recording_status' => $isLocalAudio ? 0 : 2,
'local_audio_status' => $isLocalAudio ? 2 : 0,
];
} catch (\Throwable $e) {
if ($mergedPath !== '' && is_file($mergedPath)) {
@unlink($mergedPath);
@@ -2656,19 +2803,31 @@ class DiagnosisLogic extends BaseLogic
}
}
private static function recordingStatusText(int $status): string
{
$map = [
private static function recordingStatusText(int $status): string
{
$map = [
0 => '无录制',
1 => '录制中',
2 => '已生成',
3 => '录制失败',
];
return $map[$status] ?? '未知';
}
/**
return $map[$status] ?? '未知';
}
private static function localAudioStatusText(int $status): string
{
$map = [
0 => '无本地录音',
1 => '上传中',
2 => '已保存',
3 => '上传失败',
];
return $map[$status] ?? '未知';
}
/**
* @notes 获取TRTC配置
* @return array|null
*/
@@ -4228,12 +4387,52 @@ class DiagnosisLogic extends BaseLogic
}
/**
* readonlyDetail 共用的诊单行级可见性入口。
* 与诊单列表共用语义的只读行级可见性入口。
*
* 复用“我的患者”统一行权策略:医生按有效接诊关系,医助按归属关系,
* 团队管理角色才使用 DataScope。不存在与越权使用同一错误避免枚举
* 医助仍仅能查看本人归属诊单;其他角色按 DataScope 查看列表范围内的诊单。
* “我的患者”的本人接诊关系只用于管理/写操作,不能限制通用诊单只读页
* 不存在与越权使用同一错误,避免枚举诊单。
*/
public static function canViewReadonlyDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
{
self::$error = '';
if ($diagnosisId <= 0 || $adminId <= 0) {
self::setError('诊单不存在或无权访问');
return false;
}
$query = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time');
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(2, $roleIds, true)) {
$query->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return false;
}
if (is_array($visibleIds)) {
$query->whereIn('assistant_id', $visibleIds);
}
}
if (!$query->find()) {
self::setError('诊单不存在或无权访问');
return false;
}
return true;
}
/**
* 诊单管理/写操作的行级权限入口,保留“我的患者”的本人关系约束。
*/
public static function canManageDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
{
self::$error = '';
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $adminId, $adminInfo)) {
@@ -4526,25 +4725,33 @@ class DiagnosisLogic extends BaseLogic
}
}
private static function storeMergedCallRecording(string $mergedPath, string $fileName, int $adminId): array
{
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
private static function storeMergedCallRecording(
string $mergedPath,
string $fileName,
int $adminId,
bool $isLocalAudio = false
): array
{
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
'engine' => ConfigService::get('storage') ?? ['local' => []],
];
$storageDriver = new StorageDriver($config);
$storageDriver->setUploadFileByReal($mergedPath);
$saveDir = 'uploads/video/' . date('Ymd');
if (!$storageDriver->upload($saveDir)) {
throw new \RuntimeException($storageDriver->getError() ?: '上传视频到存储失败');
}
$relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName());
$storedFile = FileModel::create([
'cid' => 0,
'type' => FileEnum::VIDEO_TYPE,
'name' => mb_substr($fileName, 0, 128),
$storageDriver = new StorageDriver($config);
$storageDriver->setUploadFileByReal($mergedPath);
$saveDir = ($isLocalAudio ? 'uploads/audio/' : 'uploads/video/') . date('Ymd');
if (!$storageDriver->upload($saveDir)) {
throw new \RuntimeException(
$storageDriver->getError()
?: ($isLocalAudio ? '上传本机录音到存储失败' : '上传视频到存储失败')
);
}
$relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName());
$storedFile = FileModel::create([
'cid' => 0,
'type' => $isLocalAudio ? FileEnum::FILE_TYPE : FileEnum::VIDEO_TYPE,
'name' => mb_substr($fileName, 0, 128),
'uri' => $relativePath,
'source' => FileEnum::SOURCE_ADMIN,
'source_id' => $adminId,
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\model\auth\Admin;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Medicine as DoctorMedicine;
@@ -938,8 +937,8 @@ class PrescriptionLogic
public static function listByDiagnosis(int $diagnosisId, int $viewerAdminId, array $viewerAdminInfo): array
{
self::$error = '';
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) {
self::setError('诊单不存在或无权访问');
if (!DiagnosisLogic::canViewReadonlyDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) {
self::setError(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
return [];
}
@@ -968,16 +967,29 @@ class PrescriptionLogic
/**
* 根据预约ID获取处方(带权限检查)
*/
public static function getByAppointment(int $appointmentId, int $viewerAdminId, array $viewerAdminInfo): ?array
{
public static function getByAppointment(int $appointmentId, int $viewerAdminId, array $viewerAdminInfo): ?array
{
self::$error = '';
$row = Prescription::where('appointment_id', $appointmentId)
$rows = Prescription::where('appointment_id', $appointmentId)
->whereNull('delete_time')
->order('id', 'desc')
->find();
if (!$row) {
return null;
->select();
$row = null;
$fallback = null;
foreach ($rows as $candidate) {
if ($fallback === null) {
$fallback = $candidate;
}
if ((int) ($candidate->void_status ?? 0) === 0) {
$row = $candidate;
break;
}
}
$row = $row ?? $fallback;
if (!$row) {
return null;
}
// 权限检查:只返回当前用户有权限查看的处方
@@ -133,12 +133,21 @@ class DiagnosisValidate extends BaseValidate
}
/** 拉取跟踪备注列表:诊单ID */
public function sceneTrackingNotes()
{
return $this->only(['diagnosis_id']);
}
public function sceneGenerateQrcode()
public function sceneTrackingNotes()
{
return $this->only(['diagnosis_id']);
}
/** IM / video identity: validate shape here; ownership is checked in the logic layer. */
public function sceneCallIdentity()
{
return $this->only(['diagnosis_id', 'patient_id'])
->remove('diagnosis_id', 'checkDiagnosisId')
->append('diagnosis_id', 'gt:0')
->append('patient_id', 'require|integer|gt:0');
}
public function sceneGenerateQrcode()
{
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
// The global diagnosis_id rule is required for diagnosis APIs, but
@@ -4,6 +4,7 @@ namespace app\common\service\storage\engine;
use think\Request;
use think\Exception;
use think\File;
/**
* 存储引擎抽象类
@@ -67,12 +68,22 @@ abstract class Server
{
// 设置为系统内部上传
$this->isInternal = true;
// 内部上传的是磁盘文件,不是 HTTP UploadedFile。这里统一成与
// 普通上传相同的存储契约:云存储需要 realPath,本地存储需要 File 对象。
$this->file = new File($filePath);
$realPath = $this->file->getRealPath();
if ($realPath === false || !$this->file->isReadable()) {
throw new Exception('内部上传文件不可读取');
}
// 文件信息
$this->fileInfo = [
'name' => basename($filePath),
'size' => filesize($filePath),
'tmp_name' => $filePath,
'error' => 0,
'ext' => strtolower($this->file->getExtension()),
'size' => $this->file->getSize(),
'mime' => $this->file->getMime(),
'name' => basename($filePath),
'realPath' => $realPath,
'tmp_name' => $realPath,
'error' => UPLOAD_ERR_OK,
];
// 生成保存文件名
$this->fileName = $this->buildSaveName();
@@ -118,7 +129,11 @@ abstract class Server
protected function getRealPath()
{
return $this->fileInfo['realPath'];
$realPath = $this->fileInfo['realPath'] ?? '';
if (!is_string($realPath) || $realPath === '' || !is_file($realPath)) {
throw new Exception('上传文件真实路径无效');
}
return $realPath;
}
/**
@@ -0,0 +1,34 @@
-- 本机录音必须与腾讯云混流视频分开保存。
-- 此迁移可重复执行,兼容已经手工补过字段的部署环境。
SET @local_audio_urls_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'zyt_tcm_call_record'
AND COLUMN_NAME = 'local_audio_urls'
);
SET @local_audio_urls_sql = IF(
@local_audio_urls_exists = 0,
'ALTER TABLE `zyt_tcm_call_record` ADD COLUMN `local_audio_urls` LONGTEXT NULL COMMENT ''本机录音地址JSON数组'' AFTER `recording_urls`',
'SELECT 1'
);
PREPARE local_audio_urls_statement FROM @local_audio_urls_sql;
EXECUTE local_audio_urls_statement;
DEALLOCATE PREPARE local_audio_urls_statement;
SET @local_audio_status_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'zyt_tcm_call_record'
AND COLUMN_NAME = 'local_audio_status'
);
SET @local_audio_status_sql = IF(
@local_audio_status_exists = 0,
'ALTER TABLE `zyt_tcm_call_record` ADD COLUMN `local_audio_status` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''本机录音状态:0无录音,1上传中,2已保存,3上传失败'' AFTER `local_audio_urls`',
'SELECT 1'
);
PREPARE local_audio_status_statement FROM @local_audio_status_sql;
EXECUTE local_audio_status_statement;
DEALLOCATE PREPARE local_audio_status_statement;
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisLogic;
function callRecordIdentityExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function callRecordIdentityMethodSource(ReflectionMethod $method): string
{
$lines = file($method->getFileName());
if (!is_array($lines)) {
throw new RuntimeException('call-record lifecycle source is readable');
}
return implode('', array_slice(
$lines,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
}
$logic = new ReflectionClass(DiagnosisLogic::class);
$endCall = callRecordIdentityMethodSource($logic->getMethod('endCall'));
$bindCallRoom = callRecordIdentityMethodSource($logic->getMethod('bindCallRoom'));
$startCloudRecording = callRecordIdentityMethodSource($logic->getMethod('startCloudRecording'));
foreach (
[
'endCall' => $endCall,
'bindCallRoom' => $bindCallRoom,
'startCloudRecording' => $startCloudRecording,
] as $methodName => $source
) {
callRecordIdentityExpect(
str_contains($source, "\$callRecordId = (int)(\$params['call_record_id'] ?? 0)")
&& str_contains($source, 'if ($callRecordId > 0)')
&& str_contains($source, "CallRecord::where('id', \$callRecordId)")
&& str_contains($source, "->where('diagnosis_id', \$diagnosisId)")
&& str_contains($source, "->where('caller_id', \$adminId)")
&& str_contains($source, "->where('caller_type', 'doctor')"),
$methodName . ' uses the exact doctor-owned call record when its id is supplied'
);
}
callRecordIdentityExpect(
str_contains($bindCallRoom, "'call_record_id' => (int)\$record['id']")
&& str_contains($bindCallRoom, 'startCloudRecording(['),
'room binding forwards the exact saved record to cloud recording'
);
callRecordIdentityExpect(
str_contains($endCall, "(int)(\$record['status'] ?? 0) === 2")
&& str_contains($endCall, 'return true;'),
'ending the same exact call remains idempotent'
);
$workspace = dirname(__DIR__, 2);
$repository = (string) file_get_contents(
$workspace . '/app/src/doctor_workstation/services/repository.py'
);
$lifecycle = (string) file_get_contents(
$workspace . '/app/src/doctor_workstation/video/lifecycle.py'
);
callRecordIdentityExpect(
substr_count($repository, 'body["call_record_id"] = normalized_id') >= 2,
'desktop repository sends call_record_id for bind and end requests'
);
callRecordIdentityExpect(
substr_count($lifecycle, '"call_record_id": record_id') >= 2,
'desktop lifecycle keeps the created call record identity through later phases'
);
echo "Call-record lifecycle identity contract: OK\n";
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\controller\tcm\DiagnosisController;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\validate\tcm\DiagnosisValidate;
function callSignatureExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function callSignatureMethodSource(ReflectionMethod $method): string
{
$lines = file($method->getFileName());
if (!is_array($lines)) {
throw new RuntimeException('call signature source is readable');
}
return implode('', array_slice(
$lines,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
}
$logicMethod = (new ReflectionClass(DiagnosisLogic::class))->getMethod('getCallSignature');
$logicSource = callSignatureMethodSource($logicMethod);
$controllerSource = callSignatureMethodSource(
(new ReflectionClass(DiagnosisController::class))->getMethod('getCallSignature')
);
$validatorSource = callSignatureMethodSource(
(new ReflectionClass(DiagnosisValidate::class))->getMethod('sceneCallIdentity')
);
callSignatureExpect(
$logicMethod->getNumberOfParameters() === 2,
'call signature accepts request params and authenticated admin context'
);
callSignatureExpect(
str_contains($logicSource, 'canManageDiagnosis($diagnosisId, $adminId, $adminInfo)')
&& strpos($logicSource, 'canManageDiagnosis($diagnosisId, $adminId, $adminInfo)')
< strpos($logicSource, "Diagnosis::where('id', \$diagnosisId)")
&& strpos($logicSource, "Diagnosis::where('id', \$diagnosisId)")
< strpos($logicSource, 'self::getTrtcConfig()'),
'row authorization and the exact diagnosis/patient pair are checked before TRTC work'
);
callSignatureExpect(
str_contains($logicSource, "->where('patient_id', \$patientId)")
&& str_contains($logicSource, "'diagnosis_id' => \$diagnosisId")
&& str_contains($logicSource, "'patient_id' => \$patientId")
&& !str_contains($logicSource, "Diagnosis::where('id', \$patientId)"),
'diagnosis id and source patient id stay distinct throughout the signature contract'
);
callSignatureExpect(
str_contains($controllerSource, "goCheck('callIdentity')")
&& str_contains(
$controllerSource,
'DiagnosisLogic::getCallSignature($params, $this->adminInfo)'
),
'controller validates both ids and forwards the authenticated row-scope context'
);
callSignatureExpect(
str_contains($validatorSource, "only(['diagnosis_id', 'patient_id'])")
&& str_contains($validatorSource, "append('patient_id', 'require|integer|gt:0')"),
'call identity validation requires positive diagnosis and patient ids'
);
$root = dirname(__DIR__, 2);
foreach (
[
$root . '/admin/src/views/tcm/appointment/list.vue',
$root . '/admin/src/views/tcm/appointment/list_h5.vue',
$root . '/admin/src/views/patient/reception/index.vue',
] as $callerPath
) {
$caller = (string) file_get_contents($callerPath);
callSignatureExpect(
str_contains($caller, 'patient_id: sourcePatientId')
&& str_contains($caller, 'patientId: sourcePatientId')
&& str_contains($caller, 'diagnosis_id: diagnosisId'),
basename($callerPath) . ' keeps appointment, diagnosis and source patient ids separate'
);
}
echo "Call signature identity contract: OK\n";
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\stats\ConversionLogic;
require dirname(__DIR__) . '/vendor/autoload.php';
function conversionFanRuleExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$method = new ReflectionMethod(ConversionLogic::class, 'loadFanRows');
$sourceLines = file($method->getFileName());
if ($sourceLines === false) {
throw new RuntimeException('无法读取加粉统计源码');
}
$methodSource = implode('', array_slice(
$sourceLines,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
conversionFanRuleExpect(
str_contains($methodSource, "->where('e.change_type', 'add_external_contact')"),
'加粉必须继续以企微 add_external_contact 事件为事实来源'
);
conversionFanRuleExpect(
!str_contains($methodSource, 'msg_audit_approved'),
'会话存档同意是独立能力,不能再次成为加粉统计的硬性条件'
);
conversionFanRuleExpect(
str_contains($methodSource, "['del_external_contact', \$endTimestamp]"),
'加粉统计必须继续排除区间内已删除客户'
);
conversionFanRuleExpect(
str_contains($methodSource, "['add_external_contact', \$startTimestamp]"),
'加粉统计必须继续排除区间开始前已添加的重加客户'
);
conversionFanRuleExpect(
str_contains($methodSource, "->group('e.user_id, e.external_userid')"),
'加粉统计必须继续按员工和客户去重'
);
$excludeMethod = new ReflectionMethod(ConversionLogic::class, 'excludeUncountedFanPairs');
$excludeSource = implode('', array_slice(
$sourceLines,
$excludeMethod->getStartLine() - 1,
$excludeMethod->getEndLine() - $excludeMethod->getStartLine() + 1
));
conversionFanRuleExpect(
str_contains($excludeSource, '[1, 2, 3, 201, 202]'),
'取消会话存档条件后仍须排除扫一扫、搜手机号、名片分享及继承/分配客户'
);
$pageSource = file_get_contents(
dirname(__DIR__, 2) . '/admin/src/views/first_visit/conversion/index.vue'
);
conversionFanRuleExpect(
is_string($pageSource) && !str_contains($pageSource, '须会话同意'),
'页面口径说明不能继续宣称加粉依赖会话存档同意'
);
echo "Conversion fan event rule: OK\n";
@@ -121,6 +121,9 @@ $myPatientScopeMethod = diagnosisWorkspaceMethodSource(
$diagnosisReadonlyAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('canViewReadonlyDiagnosis')
);
$diagnosisManageAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('canManageDiagnosis')
);
$diagnosisAiAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisAiLogic::class))->getMethod('loadAuthorizedDiagnosis')
);
@@ -161,9 +164,16 @@ diagnosisWorkspaceAuthExpect(
'DataScope ALL is reserved for team roles before ordinary doctor and assistant self-relations'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisReadonlyAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& !str_contains($diagnosisReadonlyAuthMethod, 'DataScopeService::getVisibleAdminIds'),
'readonly diagnosis authorization reuses the canonical patient row policy'
str_contains($diagnosisReadonlyAuthMethod, "Diagnosis::where('id', \$diagnosisId)")
&& str_contains($diagnosisReadonlyAuthMethod, "in_array(2, \$roleIds, true)")
&& str_contains($diagnosisReadonlyAuthMethod, "\$query->where('assistant_id', \$adminId)")
&& str_contains($diagnosisReadonlyAuthMethod, 'DataScopeService::getVisibleAdminIds(')
&& !str_contains($diagnosisReadonlyAuthMethod, 'MyPatientLogic::canAccessDiagnosis('),
'readonly diagnosis authorization matches diagnosis-list visibility instead of my-patient ownership'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisManageAuthMethod, 'MyPatientLogic::canAccessDiagnosis('),
'diagnosis write authorization keeps the canonical my-patient ownership policy'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
@@ -192,10 +202,10 @@ diagnosisWorkspaceAuthExpect(
'doctorNotes authorizes the diagnosis before reading notes'
);
diagnosisWorkspaceAuthExpect(
str_contains($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
str_contains($addDoctorNoteControllerMethod, 'DiagnosisLogic::canManageDiagnosis(')
&& strpos($addDoctorNoteControllerMethod, 'DiagnosisLogic::canManageDiagnosis(')
< strpos($addDoctorNoteControllerMethod, 'DoctorNoteLogic::addOrAppend('),
'addDoctorNote authorizes the diagnosis before writing any note data'
'addDoctorNote keeps the stricter ownership check before writing any note data'
);
diagnosisWorkspaceAuthExpect(
str_contains($receptionMethod, 'appointmentRowManageableByAdmin(')
@@ -204,10 +214,10 @@ diagnosisWorkspaceAuthExpect(
'reception authorizes the appointment before loading its detail DTO'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& strpos($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(')
str_contains($prescriptionListMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($prescriptionListMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($prescriptionListMethod, "Prescription::where('diagnosis_id', \$diagnosisId)"),
'listByDiagnosis authorizes its parent diagnosis before the first prescription SQL query'
'listByDiagnosis uses read visibility before the first prescription SQL query'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionLogicSource, 'self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)')
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/vendor/autoload.php';
final class LocalAudioUploadContractExpect
{
public static function contains(string $haystack, string $needle, string $message): void
{
if (!str_contains($haystack, $needle)) {
throw new RuntimeException($message . ': missing ' . $needle);
}
}
public static function isTrue(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
}
$root = dirname(__DIR__);
$controllerPath = $root . '/app/adminapi/controller/tcm/DiagnosisController.php';
$logicPath = $root . '/app/adminapi/logic/tcm/DiagnosisLogic.php';
$migrationPath = $root . '/database/migrations/2026_08_19_add_local_call_audio_fields.sql';
$controller = (string)file_get_contents($controllerPath);
$logic = (string)file_get_contents($logicPath);
$migration = (string)file_get_contents($migrationPath);
LocalAudioUploadContractExpect::contains(
$controller,
'public function uploadCallRecording()',
'The ThinkPHP controller must expose the desktop upload action'
);
LocalAudioUploadContractExpect::contains(
$controller,
'DiagnosisLogic::uploadCallRecording($params)',
'The controller must delegate multipart uploads to the diagnosis logic'
);
foreach (
[
"'webm'",
"'ogg'",
"'media_kind' => \$isLocalAudio ? 'local_audio' : 'video'",
'attachLocalCallAudio($attachmentParams)',
"'local_audio_urls'",
"'local_audio_status' => 2",
"'local_audio_urls_list'",
"'local_audio_status_text'",
"'uploads/audio/'",
'FileEnum::FILE_TYPE',
] as $needle
) {
LocalAudioUploadContractExpect::contains(
$logic,
$needle,
'Local audio upload and DTO contract must remain complete'
);
}
foreach (
[
"'uploads/video/'",
'FileEnum::VIDEO_TYPE',
'attachLocalCallRecording($attachmentParams)',
"'recording_status' => \$isLocalAudio ? 0 : 2",
] as $needle
) {
LocalAudioUploadContractExpect::contains(
$logic,
$needle,
'Manual video uploads must remain separate from local audio'
);
}
LocalAudioUploadContractExpect::isTrue(
str_contains($logic, '!$isAmbiguousWebm && $hasAudioMime'),
'Known non-WebM video extensions must not be misclassified when an old client sends audio/webm'
);
foreach (['zyt_tcm_call_record', 'local_audio_urls', 'local_audio_status'] as $needle) {
LocalAudioUploadContractExpect::contains(
$migration,
$needle,
'The database migration must include every local audio field'
);
}
// Exercise the real internal-file path used after all recording chunks are
// merged. A source-code assertion alone did not catch the missing realPath key
// (or the missing File object required by the local storage engine).
$sourceBase = tempnam(sys_get_temp_dir(), 'zyt-audio-source-');
LocalAudioUploadContractExpect::isTrue(
is_string($sourceBase),
'A temporary local-audio source file must be creatable'
);
$sourcePath = $sourceBase . '.webm';
LocalAudioUploadContractExpect::isTrue(
rename($sourceBase, $sourcePath),
'The temporary local-audio source must keep its WebM extension'
);
$payload = "\x1A\x45\xDF\xA3local-audio-regression";
LocalAudioUploadContractExpect::isTrue(
file_put_contents($sourcePath, $payload) === strlen($payload),
'The local-audio fixture must be written completely'
);
$targetDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR
. 'zyt-local-audio-target-' . bin2hex(random_bytes(6));
try {
$localStorage = new \app\common\service\storage\engine\Local();
$localStorage->setUploadFileByReal($sourcePath);
$fileInfo = $localStorage->getFileInfo();
LocalAudioUploadContractExpect::isTrue(
isset($fileInfo['realPath']) && $fileInfo['realPath'] === realpath($sourcePath),
'Internal storage metadata must include the merged recording realPath'
);
LocalAudioUploadContractExpect::isTrue(
($fileInfo['tmp_name'] ?? '') === realpath($sourcePath),
'Internal storage metadata must preserve a compatible temporary path'
);
LocalAudioUploadContractExpect::isTrue(
str_ends_with($localStorage->getFileName(), '.webm'),
'The generated storage name must preserve the recording extension'
);
LocalAudioUploadContractExpect::isTrue(
$localStorage->upload($targetDir),
'The local storage driver must accept a merged internal recording'
);
$storedPath = $targetDir . DIRECTORY_SEPARATOR . $localStorage->getFileName();
LocalAudioUploadContractExpect::isTrue(
is_file($storedPath) && file_get_contents($storedPath) === $payload,
'The internally uploaded local recording must remain byte-identical'
);
} finally {
if (isset($storedPath) && is_file($storedPath)) {
unlink($storedPath);
}
if (is_file($sourcePath)) {
unlink($sourcePath);
}
if (is_dir($targetDir)) {
rmdir($targetDir);
}
}
$missingPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR
. 'zyt-missing-local-audio-' . bin2hex(random_bytes(8)) . '.webm';
$missingRejected = false;
try {
$localStorage = new \app\common\service\storage\engine\Local();
$localStorage->setUploadFileByReal($missingPath);
} catch (Throwable $exception) {
$missingRejected = !str_contains($exception->getMessage(), 'realPath')
&& str_contains($exception->getMessage(), 'does not exist');
}
LocalAudioUploadContractExpect::isTrue(
$missingRejected,
'A missing merged recording must fail clearly before storage-name generation'
);
echo "Local audio upload contract: OK\n";
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\lists\doctor\AppointmentLists;
use app\adminapi\lists\tcm\DiagnosisLists;
use app\adminapi\logic\tcm\PrescriptionLogic;
function prescriptionAppointmentScopeExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function prescriptionAppointmentScopeMethod(ReflectionMethod $method): string
{
$lines = file($method->getFileName());
if (!is_array($lines)) {
throw new RuntimeException('method source must be readable');
}
return implode('', array_slice(
$lines,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
}
$diagnosisLists = prescriptionAppointmentScopeMethod(
(new ReflectionClass(DiagnosisLists::class))->getMethod('lists')
);
$appointmentLists = prescriptionAppointmentScopeMethod(
(new ReflectionClass(AppointmentLists::class))->getMethod('lists')
);
$getByAppointment = prescriptionAppointmentScopeMethod(
(new ReflectionClass(PrescriptionLogic::class))->getMethod('getByAppointment')
);
foreach ([$diagnosisLists, $appointmentLists] as $listSource) {
prescriptionAppointmentScopeExpect(
str_contains($listSource, "whereIn('appointment_id', \$appointmentIds)"),
'list action state must be selected by the current appointment ids'
);
prescriptionAppointmentScopeExpect(
str_contains($listSource, "['current_has_prescription']"),
'list DTO must expose an appointment-scoped prescription flag'
);
prescriptionAppointmentScopeExpect(
str_contains($listSource, "['current_prescription_id']"),
'list DTO must expose the exact appointment-scoped prescription id'
);
}
prescriptionAppointmentScopeExpect(
!str_contains(
$diagnosisLists,
"['prescription_audit_status'] = (\$item['has_prescription'] && \$fu)"
),
'diagnosis history must not drive the current appointment action state'
);
prescriptionAppointmentScopeExpect(
str_contains($getByAppointment, "(int) (\$candidate->void_status ?? 0) === 0")
&& str_contains($getByAppointment, '$row = $row ?? $fallback;'),
'detail lookup must prefer the same active prescription and only fall back when all are voided'
);
echo "Prescription appointment scope contract: OK\n";