This commit is contained in:
Your Name
2026-08-10 17:29:05 +08:00
parent 2199887c07
commit 9add23e019
129 changed files with 34157 additions and 59 deletions
+21
View File
@@ -0,0 +1,21 @@
# 后端根地址。程序会自动追加 /adminapi;也可直接填写以 /adminapi 结尾的地址。
DOCTOR_API_BASE_URL=https://api.example.com
# 首次验收可设为 true,使用内置演示数据;生产必须设为 false。
DOCTOR_DEMO_MODE=true
# 当前只支持 embedded。browser 在后端提供一次性 handoff 前会被明确拒绝,且不会自动打开系统浏览器。
DOCTOR_VIDEO_MODE=embedded
# 可选:本地 dist 缺失时由 QtWebEngine 内嵌加载的可信 HTTPS 页面;不是 browser handoff URL,禁止携带 UserSig。
DOCTOR_VIDEO_WEB_URL=
# 生产环境必须保持 true。仅内网自签证书调试时临时关闭。
DOCTOR_VERIFY_SSL=true
DOCTOR_REQUEST_TIMEOUT=30
# 日志级别:DEBUG / INFO / WARNING / ERROR。日志会自动脱敏 token 与 UserSig。
DOCTOR_LOG_LEVEL=INFO
# 可选:仅供企业部署/自动化验收隔离用户数据目录。
# DOCTOR_CONFIG_DIR=
# DOCTOR_LOG_DIR=
+21
View File
@@ -0,0 +1,21 @@
.env
.venv/
.venv-build/
.uv-cache/
.uv-python/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
build/
dist/
!video_companion/dist/
!video_companion/dist/**
*.spec.bak
node_modules/
video_companion/node_modules/
*.log
.DS_Store
Thumbs.db
+22
View File
@@ -0,0 +1,22 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "PROJECT_ROOT=%~dp0"
set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\package_windows.ps1" %*
set "RESULT=%ERRORLEVEL%"
if "%RESULT%"=="0" (
echo.
echo Package ready in: %PROJECT_ROOT%dist
if /I not "%~1"=="-ValidateOnly" (
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
)
) else (
echo.
echo DoctorWorkstation packaging failed. Exit code: %RESULT%
echo Press any key to close this window.
pause >nul
)
endlocal & exit /b %RESULT%
+126
View File
@@ -0,0 +1,126 @@
# 臻阳堂医生工作站
一个以 Python + PySide6 编写的跨平台医生桌面端,面向 Windows 10/11 与 macOS 13+。项目按现有 `admin` 源码的真实接口契约实现,覆盖登录、接诊台、我的处方库、已开处方、患者列表、问诊列表和腾讯云视频面诊。
> 当前版本提供完整的演示数据模式,便于在没有后端账号或腾讯云配置时验收界面与流程。切换到生产模式后,数据与权限均由现有后端返回。
## 一键运行与一键打包
Windows 直接在项目根目录双击:
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,构建并冒烟验证,最后生成 `dist/DoctorWorkstation-Windows-x64-<版本>.zip` 和 SHA-256 文件。
英文稳定别名分别是 `Run_DoctorWorkstation.bat``Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`
macOS 在 Finder 中双击:
- `一键运行.command`:优先打开现有 `DoctorWorkstation.app`,否则自动准备源码环境并运行。
- `一键打包.command`:构建、签名检查和冒烟验证后,生成 `.app`、可分发 ZIP 及 SHA-256 文件。
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境和锁定依赖。首次打包需要联网下载依赖,之后会复用本机缓存。如果 macOS 传输过程丢失了执行权限,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`;若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
## 已实现范围
- 账号密码登录、token 会话、记住账号(不保存密码)和退出登录。
- 登录后读取 `/adminapi/auth.admin/mySelf`,按 `permissions` 动态控制页面和操作按钮;`*` 超级权限兼容现有后台。
- 接诊台:今日待接诊/已过号、患者详情、医生备注、通知医助、完成接诊、发起视频面诊。
- 我的处方库:主方/辅方与公开范围筛选,药材动态编辑,模板所有权和增删改权限。
- 已开处方:处方号/患者/审核状态筛选、状态展示和只读详情。
- 患者列表:复用 `/firstvisit.myPatient/lists` 的服务端数据范围,不在客户端伪造医生或部门过滤。
- 问诊列表:按日期、状态和患者筛选,支持从有效预约发起视频。
- 腾讯视频:沿用现有项目的 `@trtc/calls-uikit-vue` 主链;UserSig 只从后端短时获取,客户端不包含 SDKSecretKey。当前仅支持隔离的 QtWebEngine 内嵌模式。
- PyInstaller Windows/macOS 构建脚本、macOS 摄像头/麦克风权限配置与自动化测试。
## 手动运行
先安装 [uv](https://docs.astral.sh/uv/),然后在项目根目录执行:
```powershell
uv sync --extra dev --extra build
Copy-Item .env.example .env
uv run doctor-workstation
```
macOS/Linux
```bash
uv sync --extra dev --extra build
cp .env.example .env
uv run doctor-workstation
```
`.env.example` 默认启用演示模式。演示账号:`doctor`,密码:`doctor123`
## 连接现有后端
`.env` 调整为:
```dotenv
DOCTOR_API_BASE_URL=https://your-api.example.com
DOCTOR_DEMO_MODE=false
DOCTOR_VERIFY_SSL=true
```
程序会自动在地址末尾追加 `/adminapi`。它与现有管理端保持相同约定:
- 登录:`POST /login/account`,请求包含 `account``password``terminal=1`
- 鉴权请求头:`token: <登录 token>``version: 1.9.4`
- 响应 envelope`code=1` 成功、`0` 业务失败、`-1` 登录失效、`10` 需绑定企业微信。
- 权限与数据范围:完全以后端 `/auth.admin/mySelf` 返回为准。
环境配置不会保存密码、TRTC SecretKey 或腾讯云长期凭据。登录 token 优先存入系统凭据库;无法使用时仅回退到用户配置目录中的受限文件。
## 视频伴随页
腾讯云没有官方 Python/PySide6 客户端 SDK。本项目因此采用 Python 业务主程序 + 腾讯官方 Web TUICallKit 伴随页:
```powershell
Set-Location video_companion
npm ci
npm run build
```
构建输出位于 `video_companion/dist`,由桌面端内嵌加载。生产 UserSig 必须由现有 `/tcm.diagnosis/getCallSignature` 接口签发;不要把 SDKSecretKey 写入 `.env` 或 JavaScript。
当前仅支持 `embedded`。在业务后端提供服务端签发、一次性消费的 browser handoff 之前,`browser` 模式会被明确拒绝,QtWebEngine 不可用时也不会自动打开系统浏览器。这样可以避免后端已记录 `startCall`、浏览器页面却没有通话票据的“幽灵通话”。
```dotenv
DOCTOR_VIDEO_MODE=embedded
DOCTOR_VIDEO_WEB_URL=https://rtc.example.com/doctor-call
```
`DOCTOR_VIDEO_WEB_URL` 仅用于本地 `dist` 缺失时,在 QtWebEngine 中内嵌加载受信任的 HTTPS 主文档;它不是 browser handoff URL,也不得在 URL 中携带 UserSig 或其他 RTC 凭据。
## 测试与打包
```powershell
uv run pytest
uv run ruff check src tests
.\scripts\build_windows.ps1
```
macOS 必须在 macOS 机器上构建、签名和公证:
```bash
./scripts/build_macos.sh
```
Windows 与 macOS 的 Qt/媒体权限和签名产物不能交叉编译。首次生产发布前,应按 [research/tencent_rtc.md](research/tencent_rtc.md) 的准入清单完成摄像头、麦克风、设备插拔、休眠恢复和弱网实测。
## 工程结构
```text
src/doctor_workstation/
core/ 业务模型、权限、会话与异常
services/ HTTP 客户端、远程仓库、演示仓库与安全 token 存储
ui/ PySide6 登录、主框架、页面和对话框
video/ 视频请求规范化、异步生命周期与隔离的内嵌窗口
video_companion/ 腾讯 TUICallKit 页面
packaging/ PyInstaller 与 macOS 权限配置
scripts/ Windows/macOS 构建脚本
tests/ 不依赖真实后端和腾讯云的自动化测试
research/ admin 源码审计、架构和腾讯 RTC 官方资料研究
```
详细的接口/字段与权限审计见 [research/admin_audit.md](research/admin_audit.md)。
+16
View File
@@ -0,0 +1,16 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "PROJECT_ROOT=%~dp0"
set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\run_windows.ps1" %*
set "RESULT=%ERRORLEVEL%"
if not "%RESULT%"=="0" (
echo.
echo DoctorWorkstation failed to start. Exit code: %RESULT%
echo Press any key to close this window.
pause >nul
)
endlocal & exit /b %RESULT%
+14
View File
@@ -0,0 +1,14 @@
# 安全与隐私约束
医生工作台会处理患者身份、病历、处方和音视频等敏感信息。生产部署必须遵守以下基线:
- 客户端不包含腾讯云 `SDKSecretKey`、COS Secret 或后端数据库凭据。TRTC `UserSig` 由服务端按当前医生和单次通话短时签发。
- 不在 URL 查询参数、命令行或日志中传递 token、UserSig、患者身份证号和完整病历。项目日志过滤器会遮蔽常见凭据,但调用代码仍应避免记录完整请求/响应。
- 密码从不落盘;“记住账号”只保存账号。登录 token 优先写入 Windows Credential Manager / macOS Keychain。
- 生产 API 与浏览器视频页必须使用 HTTPS 并验证证书。`DOCTOR_VERIFY_SSL=false` 只允许在受控开发环境临时使用。
- 页面可见性与按钮权限来自 `/auth.admin/mySelf`,但客户端权限仅用于界面体验;服务端仍必须对每个接口执行身份、租户、数据范围和动作权限校验。
- 本地不缓存患者列表、病历、处方和通话票据。演示数据是完全虚构的静态数据。
- 通话录制、截图和报告上传属于单独的合规能力;启用前必须确认患者告知/同意、留存周期、访问审计和删除流程。
- 软件发布必须签名。Windows 建议 AuthenticodemacOS 需要 Developer ID、Hardened Runtime、摄像头/麦克风用途说明与公证。
发现凭据泄露、越权、患者数据写入日志或视频房间被未授权加入时,应立即停用相关凭据、保留审计证据并按组织的安全响应流程处置。
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+10
View File
@@ -0,0 +1,10 @@
"""Development entry point.
The installed application uses the ``doctor-workstation`` console script. Keeping
this tiny launcher makes ``python main.py`` convenient for local development.
"""
from doctor_workstation.app import main
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -u
project_root="$(cd "$(dirname "$0")" && pwd -P)"
exec /bin/bash "$project_root/scripts/package_macos.sh"
+25
View File
@@ -0,0 +1,25 @@
# Video-enabled desktop packaging
The spec creates an `onedir` build and embeds `video_companion/dist` as `video_companion_dist`. Explicit QtWebEngine imports activate PyInstaller's maintained PySide6 hooks; the build scripts then fail if the resulting artifact does not contain `QtWebEngineProcess` or Chromium `.pak` resources.
Both build scripts also launch the frozen entry point with `--smoke-test`. The smoke process uses a temporary user/config directory, demo mode, loopback-only proxy settings, and a 30-second deadline; a non-zero exit or an unhandled exception in its logs fails the build. This validates the packaged bootstrap without contacting a real backend.
Run the build on the target operating system. PyInstaller cannot cross-build Windows and macOS artifacts.
## Windows
```powershell
.\scripts\build_windows.ps1
```
The default interpreter is `.venv-build\Scripts\python.exe`; override it with `-Python C:\path\to\python.exe`.
## macOS
```bash
bash ./scripts/build_macos.sh
```
The default interpreter is `.venv-build/bin/python`. For release signing, export `MACOS_CODESIGN_IDENTITY` before building. The generated app includes camera/microphone usage descriptions and the main-process entitlements in `macos/entitlements.plist`.
Before notarization, verify the nested `QtWebEngineProcess.app` signature and preserve its Qt-provided helper entitlements. Sign nested code before the outer app, then notarize and staple the final distribution artifact.
+111
View File
@@ -0,0 +1,111 @@
# -*- mode: python ; coding: utf-8 -*-
"""Cross-platform PyInstaller onedir spec for the video-enabled workstation.
PyInstaller's official PySide6 QtWebEngine hooks are activated by the explicit
hidden imports below. Those hooks retain QtWebEngineProcess, Chromium .pak/
ICU resources, locales, Qt plugins, and the macOS framework/helper layout.
"""
import os
import sys
from pathlib import Path
PROJECT_ROOT = Path(SPEC).resolve().parent.parent
SOURCE_ROOT = PROJECT_ROOT / "src"
ENTRY_POINT = SOURCE_ROOT / "doctor_workstation" / "__main__.py"
VIDEO_DIST = PROJECT_ROOT / "video_companion" / "dist"
RESOURCES = PROJECT_ROOT / "resources"
ENTITLEMENTS = PROJECT_ROOT / "packaging" / "macos" / "entitlements.plist"
VERSION_FILE = PROJECT_ROOT / "packaging" / "windows" / "version_info.txt"
if not ENTRY_POINT.is_file():
raise SystemExit(f"Application entry point is missing: {ENTRY_POINT}")
if not (VIDEO_DIST / "index.html").is_file():
raise SystemExit("Build video_companion before running PyInstaller")
# Some Windows developer tools add an unrelated OpenSSL installation to PATH.
# PyInstaller's dependency scanner would then pair Python's ``_ssl.pyd`` with
# those incompatible DLLs. Put the running interpreter's DLL directory first
# and collect the exact same files explicitly so the build is reproducible.
python_runtime_binaries = []
if sys.platform == "win32":
python_dll_dir = Path(sys.base_prefix) / "DLLs"
for dll_name in ("libssl-3-x64.dll", "libcrypto-3-x64.dll"):
dll_path = python_dll_dir / dll_name
if not dll_path.is_file():
raise SystemExit(f"Python runtime dependency is missing: {dll_path}")
python_runtime_binaries.append((str(dll_path), "."))
os.environ["PATH"] = os.pathsep.join((str(python_dll_dir), os.environ.get("PATH", "")))
qt_webengine_hiddenimports = [
# Importing these modules lets PyInstaller's official Qt hooks collect the
# helper executable/app, resources, locales, frameworks, and plugins.
"PySide6.QtWebChannel",
"PySide6.QtWebEngineCore",
"PySide6.QtWebEngineWidgets",
"PySide6.QtNetwork",
"PySide6.QtPrintSupport",
]
analysis = Analysis(
[str(ENTRY_POINT)],
pathex=[str(SOURCE_ROOT)],
binaries=python_runtime_binaries,
datas=[
(str(VIDEO_DIST), "video_companion_dist"),
(str(RESOURCES), "resources"),
],
hiddenimports=qt_webengine_hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(analysis.pure)
is_macos = sys.platform == "darwin"
exe = EXE(
pyz,
analysis.scripts,
[],
exclude_binaries=True,
name="DoctorWorkstation",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=os.environ.get("MACOS_CODESIGN_IDENTITY") if is_macos else None,
entitlements_file=str(ENTITLEMENTS) if is_macos else None,
version=str(VERSION_FILE) if sys.platform == "win32" else None,
)
collection = COLLECT(
exe,
analysis.binaries,
analysis.datas,
strip=False,
upx=False,
name="DoctorWorkstation",
)
if is_macos:
app = BUNDLE(
collection,
name="DoctorWorkstation.app",
icon=None,
bundle_identifier="com.zyt.doctor-workstation",
info_plist={
"CFBundleDisplayName": "",
"NSCameraUsageDescription": "",
"NSMicrophoneUsageDescription": "",
"NSHighResolutionCapable": True,
},
)
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+19
View File
@@ -0,0 +1,19 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "APPLICATION=%~dp0DoctorWorkstation\DoctorWorkstation.exe"
if not exist "%APPLICATION%" (
echo DoctorWorkstation.exe was not found.
echo Please keep this launcher beside the DoctorWorkstation folder.
pause
exit /b 1
)
start "" "%APPLICATION%"
if errorlevel 1 (
echo DoctorWorkstation failed to start.
pause
exit /b 1
)
exit /b 0
+31
View File
@@ -0,0 +1,31 @@
# UTF-8
# Example PyInstaller version resource. Update all four version tuples together.
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(0, 1, 0, 0),
prodvers=(0, 1, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable(
'080404B0',
[
StringStruct('CompanyName', 'ZYT'),
StringStruct('FileDescription', '医生工作台'),
StringStruct('FileVersion', '0.1.0.0'),
StringStruct('InternalName', 'DoctorWorkstation'),
StringStruct('OriginalFilename', 'DoctorWorkstation.exe'),
StringStruct('ProductName', '医生工作台'),
StringStruct('ProductVersion', '0.1.0.0')
]
)
]),
VarFileInfo([VarStruct('Translation', [2052, 1200])])
]
)
+49
View File
@@ -0,0 +1,49 @@
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[project]
name = "zhenyang-doctor-workstation"
version = "0.1.0"
description = "Cross-platform doctor consultation workstation for Windows and macOS"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Proprietary" }
authors = [{ name = "Zhenyangtang" }]
dependencies = [
"httpx>=0.27.2,<1",
"keyring>=25.5,<26",
"platformdirs>=4.3,<5",
"PySide6>=6.8.2,<7",
"python-dotenv>=1.0.1,<2",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3,<9",
"pytest-cov>=6,<7",
"ruff>=0.9,<1",
]
build = [
"pyinstaller>=6.11,<7",
]
[project.scripts]
doctor-workstation = "doctor_workstation.app:main"
[tool.hatch.build.targets.wheel]
packages = ["src/doctor_workstation"]
[tool.pytest.ini_options]
addopts = "-q"
pythonpath = ["src"]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"]
+820
View File
@@ -0,0 +1,820 @@
# admin 医生端源码审计
审计日期:2026-08-10
参考项目:D:\web\zyt\admin
审计方式:只读检查 Vue/TypeScript 源码、API 封装、路由守卫、Pinia store、业务组件和权限判断;未修改 admin 项目,也未把 README 当作结论来源。
## 1. 结论摘要
1. admin 是 Vue 3 + TypeScript + Vite + Element Plus + Pinia 项目。医生端并不是一套独立的静态路由:除登录、H5 诊单和只读诊单外,页面路径、标题、组件和菜单权限都由登录后 GET /adminapi/auth.admin/mySelf 返回的 menu 动态注入。
2. 医生相关页面的全局状态很少。Pinia 只持有认证用户、权限、动态菜单、全局站点配置、布局与多标签;接诊台、处方、患者、问诊列表的查询条件和业务状态均保留在各页面的 ref/reactive 中,分页统一使用 usePaging。
3. “患者列表”有两个不同实现:
- 医生/一诊工作台的“我的患者”:src/views/first_visit/my_patients/index.vue,带患者、订单、面诊进度三个工作区,服务端按当前角色和部门数据范围收窄。
- 平台注册用户列表:src/views/consumer/lists/index.vue,仅展示头像、昵称、账号、手机号、渠道和注册时间,不是医生业务患者工作台。
4. “问诊列表”也有两个相关实现:
- 挂号/问诊执行列表:src/views/tcm/appointment/list.vue,默认“今天 + 待接诊”,支持通话、视频二维码、完成、开方、取消。
- 诊单/患者业务列表:src/views/tcm/diagnosis/index.vue,围绕诊单、挂号、确认、开方、医助指派、二维码和视频旁观。
最终菜单叫什么、URL 是什么取决于后端 menu 配置,不应仅根据文件名硬编码。
5. 实际视频问诊主链是 src/components/chat-dialog/index.vue:腾讯云 Chat UIKit 单聊 + TUICallKit 音视频;通话前后还串联后端通话记录、TRTC 房间绑定、云端混流录制、可选浏览器本地录制、截屏写医生备注。src/components/video-call/index.vue 是另一套旧/独立实现,目前源码中没有被任何页面引用。
6. 处方领域要区分三类对象:
- 处方库模板:tcm.prescriptionLibrary,供医生复用药材组合。
- 已开处方:tcm.prescription,处方笺、患者、医师签名、主辅方、审核与作废。
- 处方业务订单:tcm.prescriptionOrder,收货、费用、双审、支付单、药房和物流履约;它不是支付单 zyt_order。
## 2. 关键源码与路由
### 2.1 页面定位
| 业务 | 关键源文件(绝对路径) | 路由结论 |
|---|---|---|
| 登录 | D:\web\zyt\admin\src\views\account\login.vue | 静态精确路由 /login |
| 接诊台 | D:\web\zyt\admin\src\views\patient\reception\index.vue | 动态菜单组件键应指向 patient/reception/index;实际 URL 取 menu[].paths |
| 我的处方库 | D:\web\zyt\admin\src\views\consumer\prescription\list.vue | 动态菜单组件键应指向 consumer/prescription/list;实际 URL 取 menu[].paths |
| 药品库(不是处方库) | D:\web\zyt\admin\src\views\doctor\medicine.vue | 动态菜单组件键应指向 doctor/medicine |
| 已开处方/处方管理 | D:\web\zyt\admin\src\views\consumer\prescription\index.vue | 动态菜单组件键应指向 consumer/prescription/index |
| 处方业务订单 | D:\web\zyt\admin\src\views\consumer\prescription\order_list.vue | 动态菜单组件键应指向 consumer/prescription/order_list |
| 我的患者 | D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue | 动态菜单组件键应指向 first_visit/my_patients/index |
| 平台用户列表 | D:\web\zyt\admin\src\views\consumer\lists\index.vue | 动态菜单组件键应指向 consumer/lists/index |
| 问诊/挂号列表 | D:\web\zyt\admin\src\views\tcm\appointment\list.vue | 动态菜单组件键应指向 tcm/appointment/list |
| 诊单列表 | D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue | 动态菜单组件键应指向 tcm/diagnosis/index;另有静态 H5 路由 /tcm/diagnosis/h5 |
| 诊单编辑/只读抽屉 | D:\web\zyt\admin\src\views\tcm\diagnosis\edit.vue | 被多个页面异步复用,不一定是独立菜单 |
| 患者只读详情 | D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue | 静态精确路由 /tcm/diagnosis-readonly?id=诊单ID |
| 预约视频问诊 | D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue | 抽屉组件,由诊单列表/我的患者调用 |
| 诊间开方 | D:\web\zyt\admin\src\components\tcm-prescription\index.vue | 复用组件,由问诊列表和诊单编辑调用 |
| 聊天与视频问诊 | D:\web\zyt\admin\src\components\chat-dialog\index.vue | 浮动组件,由接诊台及问诊列表调用 |
| 医助视频旁观 | D:\web\zyt\admin\src\views\tcm\diagnosis\components\AssistantWatchCallDialog.vue | 诊单列表内异步组件 |
### 2.2 动态路由机制
关键文件:
- D:\web\zyt\admin\src\router\routes.ts
- D:\web\zyt\admin\src\router\index.ts
- D:\web\zyt\admin\src\permission.ts
- D:\web\zyt\admin\src\stores\modules\user.ts
流程:
1. 常量路由只注册 /login、/403、/change-password、/bind-work-wechat、/user/setting、/doctor/progress、/tcm/diagnosis/h5 和 /tcm/diagnosis-readonly 等少数页面。
2. 登录成功后 GET /auth.admin/mySelf。
3. user store 保存 data.user、data.permissions,并把 data.menu 交给 filterAsyncRoutes。
4. 每个后端菜单项使用以下字段转为 Vue Router:
- paths:路由路径;
- componentsrc/views 下的组件键;
- name:菜单标题;
- perms:写入 route.meta.perms
- is_show:控制 meta.hidden
- is_cache:控制 keepAlive
- params:默认 query
- selectedactiveMenu
- type:目录或菜单。
5. permission.ts 把转换后的路由动态挂到根布局;第一个可见菜单成为 / 的重定向目标。
因此,本仓库源码能确定组件和静态路由,但不能单独确定接诊台、处方库、已开处方、我的患者、问诊列表的生产 URL 与菜单标题。要得到精确值,必须取得当前环境 /auth.admin/mySelf 的 menu 响应或检查服务端菜单表。
## 3. 登录、认证、权限和状态管理
### 3.1 登录链路
关键文件:
- D:\web\zyt\admin\src\views\account\login.vue
- D:\web\zyt\admin\src\api\user.ts
- D:\web\zyt\admin\src\stores\modules\user.ts
- D:\web\zyt\admin\src\utils\request\index.ts
- D:\web\zyt\admin\src\utils\auth.ts
账号密码:
- POST /login/account
- 请求:account、password、terminal=1。
- 响应被页面使用的字段:token、is_paw、need_bind_work_wechat。
- token 写入本地缓存键 token,后续请求通过请求拦截器放到 HTTP 头 token。
- “记住账号”只缓存 account,不缓存密码,缓存键为 account。
企业微信:
- GET /login/workWechatConfig,使用 enabled、corp_id、agent_id。
- 企业微信内置浏览器走 OAuthscope=snsapi_privateinfo、state=admin_login。
- 普通浏览器动态加载 https://wwcdn.weixin.qq.com/node/wework/wwopen/js/wwLogin-1.2.7.js 显示扫码登录。
- 回调 code 通过 POST /login/workWechatLogin,参数 code、terminal=1。
- 另有 POST /auth.admin/bindWorkWechat、POST /auth.admin/unbindWorkWechat。
守卫:
- is_paw=0 强制跳转 /change-password,并通过 POST /login/changeFirstPassword 修改。
- need_bind_work_wechat=true 强制进入 /bind-work-wechat。
- 没有 token 的非白名单路由跳 /login?redirect=原地址。
- /auth.admin/mySelf 没有任何有效菜单时清认证并跳 /403。
- 响应码约定:1 成功、0 失败、-1 登录失效、10 需要绑定企微、2 打开新页面、-2 未安装。
### 3.2 Pinia 与页面状态
| Store/Hook | 文件 | 职责 |
|---|---|---|
| user | D:\web\zyt\admin\src\stores\modules\user.ts | token、userInfo、routes、perms、isPaw;登录、退出、企微登录、加载个人信息 |
| app | D:\web\zyt\admin\src\stores\modules\app.ts | 网站配置、OSS 图片地址、移动端/侧栏状态、视图刷新 |
| tabs | D:\web\zyt\admin\src\stores\modules\multipleTabs.ts | 多标签与 keep-alive 缓存 |
| setting | D:\web\zyt\admin\src\stores\modules\setting.ts | 本地布局、主题配置 |
| usePaging | D:\web\zyt\admin\src\hooks\usePaging.ts | 页码、page_size、loading、count、lists、extend;支持 silent 静默刷新 |
列表接口统一期待服务端 data 为:
- lists:当前页数组;
- count:总数;
- extend:额外统计、日期、权限范围等扩展数据。
页面内筛选和弹窗状态不进入 Pinia。这一约定适合桌面端复用:认证/权限做全局 store,业务工作台保持页面级 store 或 view-model。
### 3.3 权限判断语义
关键文件:
- D:\web\zyt\admin\src\install\directives\perms.ts
- D:\web\zyt\admin\src\utils\perm.ts
需要特别注意两套语义不同:
- v-perms 数组是“任一权限命中即可显示”(OR)。
- hasPermission 数组是“数组内每个权限都必须存在”(AND)。
- permissions 含星号时视为全部权限。
多数业务调用只传单个权限,因此差异暂时不明显;复用时不要把多权限数组在两处互换。
## 4. 业务页面审计
### 4.1 接诊台
源码:
- D:\web\zyt\admin\src\views\patient\reception\index.vue
- D:\web\zyt\admin\src\api\patient.ts
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\PatientInfoCard.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\PatientCaseCard.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\DailyMatrix.vue
- D:\web\zyt\admin\src\views\patient\reception\components\NoteTimeline.vue
页面行为:
- 默认显示当天 status=1 待接诊;可切到 status=4 已过号。
- 搜索字段 patient_name;分页 page_no/page_size,固定每页 15。
- 每 5 秒静默刷新队列和已选患者详情;页面隐藏时暂停,恢复可见后立即刷新。
- 队列使用无限滚动,并按 id 去重。
- 队列行主要字段:id、patient_id、patient_name、patient_phone、diagnosis_id、doctor_id/name、assistant_id/name、appointment_date/time、gender、age、status/status_desc、has_prescription、remark。
- 详情结构按源码使用为:
- appointment:挂号;
- diagnosis:诊单/病例;
- doctor_notes:医生备注、舌苔和报告。
- 日常记录不依赖 reception 响应完整下发,而由 DailyMatrix 继续按 diagnosis_id 调 trackingWindow/trackingNotes。
操作:
- 通知医助:POST /doctor.appointment/notifyAssistant,参数 id=挂号ID。
- 发起通话:先 POST /tcm.diagnosis/getCallSignature,参数 patient_id、diagnosis_id,再打开 ChatDialog。
- 备注:POST /doctor.appointment/addDoctorNote,参数 diagnosis_id、content,可追加 tongue_images、report_files。
- 编辑病历:复用 tcm/diagnosis/edit.vue。
- 完成接诊:POST /doctor.appointment/complete,参数 id=挂号ID;页面允许 status=1 或 4。
权限:
- doctor.appointment/addDoctorNote
- tcm.diagnosis/edit
- doctor.appointment/complete
源码中的“通知医助”和“发起通话”按钮没有 v-perms;只能依赖页面菜单权限和后端接口鉴权,桌面端若拆成独立入口应补显式能力判断。
### 4.2 我的处方库
源码:
- D:\web\zyt\admin\src\views\consumer\prescription\list.vue
- D:\web\zyt\admin\src\api\tcm.ts
- D:\web\zyt\admin\src\components\medicine-name-select\index.vue
模型与筛选:
- 查询:prescription_name、formula_type(主方/辅方)、is_public(0 仅自己、1 所有人)。
- 列表:id、prescription_name、formula_type、herbs、is_public、disable_edit、creator_id/name、create_time。
- herbs 项:medicine_id(可选)、name、dosage。
- 编辑:id、prescription_name、formula_type、herbs、is_public、disable_edit。
- disable_edit=1 表示导入模板后锁定整张处方的药材,不可增删改,只能再次导入覆盖。
接口:
- GET /tcm.prescriptionLibrary/lists
- POST /tcm.prescriptionLibrary/add
- POST /tcm.prescriptionLibrary/edit
- POST /tcm.prescriptionLibrary/delete,参数 id
- GET /tcm.prescriptionLibrary/detail,参数 id(API 已封装,但当前列表弹窗直接使用行数据)
权限:
- wcf.prescription/add
- wcf.prescription/read
- wcf.prescription/edit
- wcf.prescription/delete
所有权:
- 普通用户只可编辑/删除 creator_id 等于当前 userInfo.id 的模板。
- root=1 或 role_ids 包含 0、3 可管理全部模板。
- 诊间/已开处方导入模板时会额外传 prescribing_creator_id,通常取处方 creator_id,新增时取当前登录用户 id。
### 4.3 药品库(容易和处方库混淆)
源码:D:\web\zyt\admin\src\views\doctor\medicine.vue
接口:
- GET /doctor.medicine/lists
- POST /doctor.medicine/add
- POST /doctor.medicine/edit
- POST /doctor.medicine/delete
- GET /doctor.medicine/detail
模型:
- id、name、supplier、unit、settlement_price、retail_price、stock、image、status、remark。
- 图片上传直接 POST 到 VITE_APP_BASE_URL + /api/upload/image,并携带 token 头。
当前页面的增删改按钮没有 v-perms。它是药材主数据管理,不应直接当作“我的处方库”复刻。
### 4.4 已开处方/处方管理
源码:
- D:\web\zyt\admin\src\views\consumer\prescription\index.vue
- D:\web\zyt\admin\src\components\tcm-prescription\index.vue
- D:\web\zyt\admin\src\api\tcm.ts
列表筛选:
- sn:处方编号模糊查;
- patient_name
- creator_ids:开方医师多选;
- audit_filterall、pending、passed、not_passed、rejected
- source_filterall、manual、system
- start_time、end_time(按创建时间)。
列表核心字段:
- id、sn、prescription_type
- is_system_auto0 手工、1 空白处方/系统代开;
- patient_name、gender、age、phone
- audit_status、audit_remark、business_prescription_audit_rejected、business_prescription_audit_remark
- void_status、void_by_name、void_time
- doctor_name、creator_id、assistant_name、prescription_date、create_time
- has_prescription_order。
处方编辑/详情模型:
- 关联:id、diagnosis_id、creator_id。
- 患者:patient_name、gender、age、visit_no、prescription_date。
- 诊断:tongue、tongue_image、pulse、pulse_condition、clinical_diagnosis。
- 药材:herbs,每项 medicine_id、name、dosage、formula_type(主方/辅方)、locked。
- 剂型/用法:prescription_type、dosage_amount、dosage_unit、dosage_bag_count、need_decoction、bags_per_dose、dose_count、dose_unit、usage_days、times_per_day、usage_instruction、usage_time、usage_way、dietary_taboo、usage_notes。
- 辅方用法 aux_usagedosage_amount、dosage_bag_count、need_decoction、bags_per_dose、times_per_day、usage_days、prescription_name(部分页面保留模板名)。
- 医师:doctor_name、doctor_signaturePNG data URL,保存前必填)。
- 可见性/审核:is_shared、visible_role_ids、audit_status、audit_time、audit_by_name、audit_remark。
状态规则:
- audit_status0 待审核、1 已通过、2 已驳回。
- 驳回处方会同时作废。
- “已通过且未作废”的有效处方不能普通编辑/删除。
- 新增时前端强制 audit_status=0;编辑保存后提示重新进入待审核。
- 诊间开方组件会保存 case_record 病历快照,并在已有 appointment_id 处方时直接进入只读查看。
- 已存在业务订单时,诊间组件禁止作废处方。
主要接口:
- GET /tcm.prescription/lists
- GET /tcm.prescription/detail,参数 id
- POST /tcm.prescription/add
- POST /tcm.prescription/edit
- POST /tcm.prescription/delete,参数 id
- POST /tcm.prescription/patchPatient,参数 id、patient_name、phone、gender
- POST /tcm.prescription/audit,参数 id、action=approve|reject、remark
- POST /tcm.prescription/void,参数 id
- GET /tcm.prescription/listByDiagnosis,参数 diagnosis_id
- GET /tcm.prescription/getByAppointment,参数 appointment_id
权限:
- cf.prescription/add、read、edit、audit、del
- tcm.prescription/patchPatient
- tcm.prescriptionLibrary/lists
- tcm.prescriptionOrder/create、lists、setShipMode
- finance.account_log/lists
- tcm.prescriptionOrder/editRemarkExtra
角色补充:消费者处方页将 root 或 role_ids 0、3 视为可审核角色;仍应以后端和 cf.prescription/audit 为最终判定。
### 4.5 处方业务订单
源码:
- D:\web\zyt\admin\src\views\consumer\prescription\order_list.vue
- D:\web\zyt\admin\src\views\consumer\prescription\components\PrescriptionOrderDetailDrawer.vue
- D:\web\zyt\admin\src\views\consumer\prescription\components\prescription-order-utils.ts
此页面是已开处方的相邻履约域。核心字段:
- id、order_no、prescription_id、diagnosis_id
- recipient_name、recipient_phone、region、shipping_address
- fee_type、amount、internal_cost
- prescription_audit_status、payment_slip_audit_status
- fulfillment_status
- linked_pay_order_count、linked_pay_order_id、linked_pay_paid_total
- medication_days、service_channel、service_package
- express_company、tracking_number、ship_mode
- doctor_name、creator_id/name、assistant_id
- remark_extra、remark_assistant
- 药房提交号和状态。
审核状态统一为 0 待审核、1 已通过、2 已驳回。履约状态:
- 1 待双审通过
- 2 待发货
- 3 已完成
- 4 已取消
- 5 已发货
- 6 已签收
- 7 进行中
- 8 暂不制药
- 9 拒收
- 10 退款
- 11 保留药方
- 12 制药缓发
核心接口:
- GET /tcm.prescriptionOrder/lists、detail、paidPayOrders、logs、logisticsTrace、export
- POST /tcm.prescriptionOrder/create、edit、withdraw、ddcode
- POST /tcm.prescriptionOrder/auditPrescription、auditPayment、revokeRxAudit、revokePayAudit
- POST /tcm.prescriptionOrder/ship、complete、refund、requestCompletion
- POST /tcm.prescriptionOrder/addPayOrder、linkPayOrder
- POST /tcm.prescriptionOrder/patchPrescriptionPatient、patchPrescriptionUsage、updateAmount
- POST /tcm.prescriptionOrder/setShipMode、uploadToPharmacy
- POST /tcm.prescriptionOrder/submitGancaoRecipel、previewGancaoRecipel、confirmGancaoSubmission
- POST /tcm.prescriptionOrder/batchAssignAssistant、addLog
主要权限:
- tcm.prescriptionOrder/detail、edit、export、ddcode、ship、addPayOrder、complete、refund、withdraw
- tcm.prescriptionOrder/auditPrescription、auditPayment
- tcm.prescriptionOrder/setShipMode、uploadToPharmacy、editRemarkExtra
- finance.account_log/lists、prescription.order/finance
前端还存在角色级显示规则:
- role 2:医助;
- role 6:下单角色;
- role 3、8:下单筛选豁免;
- role 0、3、6:财务字段;
- role 0、3:可绕过双审后的创建人编辑锁、可批量改派;
- 业务订单处方审核角色在共享工具中为 0、3、6。
这些数字与服务端配置耦合,不宜在新客户端再次散落硬编码。
### 4.6 我的患者
首选医生端实现:
- D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue
- D:\web\zyt\admin\src\api\first_visit.ts
页面结构:
- “患者列表”“订单管理”“面诊进度”三个工作区。
- 筛选 keyword、status_filter、start_date、end_date。
- status_filterunbooked 未预约、pending_interview 待面诊、completed 已完成、missed 已过号。
- 日期快捷:今天、明天、后天、近 7 天、近 30 天、自定义。
- extend.summary 返回 today/tomorrow/day_after 计数。
- extend.dates 返回对应日期。
- extend.scope.label 直接展示后端判定的数据范围。
列表使用字段:
- diagnosis_id 或 id、source_patient_id
- patient_name、gender_desc、age、phone_masked、has_id_card
- assistant_id/name
- appointment_id、appointment_doctor_id/name、appointment_status、appointment_status_text、appointment_time_text
- revisit_count、confirmed、confirmation_text、diagnosis_date_text。
操作与接口:
- GET /firstvisit.myPatient/lists
- GET /firstvisit.myPatient/assistants
- POST /firstvisit.myPatient/assignid、assistant_id、is_inherit=0|1
- POST /firstvisit.myPatient/fillIdCardid、id_card
- POST /firstvisit.myPatient/createAppointment:预约完整参数
- POST /firstvisit.myPatient/cancelAppointmentid=挂号ID
- 订单工作区另使用 /firstvisit.myPatient/orders、orderDetail、orderEdit 及双审/发货/退款等受限代理接口。
- 面诊进度使用 GET /firstvisit.myPatient/progress。
权限:
- tcm.diagnosis/edit
- tcm.diagnosis/readonlyDetail
- tcm.diagnosis/guahao
- tcm.diagnosis/assign
服务端按当前角色与部门范围裁剪数据;前端不自行拼接 doctor_id 或 department_id 来模拟数据权限。
平台用户列表 D:\web\zyt\admin\src\views\consumer\lists\index.vue 使用 GET /user.user/lists,字段是 avatar、nickname、account、mobile、channel、create_time,只适合账号管理,不适合医生患者列表。
### 4.7 问诊/挂号列表
源码:
- D:\web\zyt\admin\src\views\tcm\appointment\list.vue
- D:\web\zyt\admin\src\api\doctor.ts
默认条件:
- status=1 待接诊;
- start_date=end_date=今天;
- date_preset=today
- 20 秒静默轮询;
- include_status_counts=1 时从 extend.status_count 一次返回各状态角标。
状态:
- 1 待接诊/已预约
- 2 已取消
- 3 已完成
- 4 已过号
筛选:
- patient_name、doctor_name
- status
- start_date、end_date、date_preset
- diagnosis_confirmed
- assistant_dept_id(选父部门含子级)。
行字段:
- id、patient_id、diagnosis_id
- patient_name、patient_phone、gender、age、height、weight
- doctor_id/name、assistant_name
- appointment_date、appointment_time、period
- diagnosis_confirmed
- has_prescription、prescription_is_system_auto、prescription_audit_status、prescription_void_status
- status/status_desc、remark。
操作:
- 编辑患者:复用诊单编辑抽屉。
- 视频二维码:生成小程序码。
- 通话:获取签名并打开 ChatDialog。
- 完成:POST /doctor.appointment/complete,可同时 POST addDoctorNote。
- 开方/查看:复用 TcmPrescription;有效已审核处方显示“查看”。
- 取消:POST /doctor.appointment/cancel。
权限:
- tcm.diagnosis/edit
- tcm.diagnosis/videoQr
- doctor.appointment/prescription
- doctor.appointment/complete
- tcm.diagnosis/kaifang
- doctor.appointment/cancel
- doctor.appointment/addDoctorNote(完成时备注能力)
角色判断:role_id 为 1 医生、2 医助;两者之外才显示医生姓名筛选。此处同时兼容 role_id 单值或数组,但其他页面多使用 role_ids,说明 user 模型尚未完全统一。
### 4.8 诊单列表
源码:
- D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\edit.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue
列表筛选字段:
- keyword、diagnosis_type、syndrome_type、assistant_id
- diagnosis_confirmed、appointment_date、has_appointment
- latest_appointment_start_date/end_date/channel_source
- latest_assign_start_date/end_date
- pending_booking、completed_appointment、pending_assign
- pending_assign_order_month、pending_assign_keyword
- sort_unserved_days。
列表使用字段:
- id、patient_name、gender_desc、age
- assistant_id/assistant、assign_read_at
- appointments 或聚合的 appointment_doctor_name、appointment_time_text、appointment_status
- has_appointment、diagnosis_confirmed
- has_prescription、followup_time_text、followup_doctor_name、followup_rx_voided
- unserved_days、last_blood_record_at
- video_call_hint。
video_call_hint
- statenone、pending_room、live 等;
- label
- start_time、end_time。
操作权限:
- tcm.diagnosis/add、edit、delete、readonlyDetail
- tcm.diagnosis/assign
- tcm.diagnosis/kaifang
- tcm.diagnosis/guahao、guahaoLogList
- tcm.diagnosis/videoQr
- tcm.diagnosis/order
- tcm.diagnosis/watchCall
诊单编辑模型的核心字段:
- 标识:id、patient_id。
- 患者:patient_name、id_card、phone、gender、age、marital_status、height、weight、region。
- 诊断:diagnosis_date、diagnosis_type、syndrome_type、diabetes_type、diabetes_discovery_year、local_hospital_diagnosis、local_hospital_name。
- 指标:systolic_pressure、diastolic_pressure、fasting_blood_sugar。
- 现病史多选:appetite、water_intake、diet_condition、weight_change、body_feeling、sleep_condition、eye_condition、head_feeling、sweat_condition、skin_condition、urine_condition、stool_condition、kidney_condition、fatty_liver_degree。
- 既往史:past_history、trauma_history、surgery_history、allergy_history、family_history、pregnancy_history。
- 医疗内容:symptoms、tongue_coating、pulse、treatment_principle、prescription、doctor_advice、remark、current_medications。
- 归属与来源:assistant_id、status、create_source、show_card、external_userid。
详情响应还使用 patient_basic_locked、can_edit_patient_basic、latest_prescription_order。手机号/身份证是否显示明文由 tcm.diagnosis/phonePlain 控制;已有身份证通常只有明文权限才能修改。
诊单详情 Tab 权限:
- tcm.diagnosis/chufang:处方
- tcm.diagnosis/patientOrders:业务订单
- tcm.diagnosis/huifang:视频回放
- tcm.diagnosis/chat:聊天记录
- tcm.diagnosis/assign 或 detail:指派记录
- doctor.appointment/lists:挂号记录
- tcm.diagnosis/dailyRecord:日常记录
## 5. 视频问诊完整链路
### 5.1 预约
源码:D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue
目前预约类型只有 video。请求字段:
- patient_id
- doctor_id
- appointment_date
- period=all
- appointment_time
- appointment_type=video
- remark
- channel_source
- channel_source_detail
普通入口 POST /doctor.appointment/create;“我的患者”入口 POST /firstvisit.myPatient/createAppointment。可用时段来自 GET /doctor.appointment/availableSlots,字段 doctor_id、appointment_date、period=all,响应使用 slots[].time、slots[].available。页面还读取医生排班并限制不能重复预约当天 status=1/4 的号。
### 5.2 医生发起通话
生产主组件:D:\web\zyt\admin\src\components\chat-dialog\index.vue
1. 页面调用 open({ patientId, patientName, diagnosisId })。
2. POST /tcm.diagnosis/getCallSignature,请求 patient_id、diagnosis_id。
3. 响应实际使用:
- sdkAppId
- userId(医生 IM/TRTC user ID
- userSig
- patientUserId,缺省回退 patient_加患者ID
- assistant_id(群视频邀请)
- isLochostVod(是否启用浏览器本地录制)
4. Chat UIKit 登录并创建与 patientUserId 的 C2C 会话。
5. TUICallKitServer.init 初始化通话能力;只有成功后才显示 AudioCallPicker、VideoCallPicker 和群视频按钮。
6. beforeCalling 时 POST /tcm.diagnosis/startCall
- diagnosis_id
- patient_id
- call_type=2(视频)
7. 呼叫状态进入 calling/connected 后从 TUIStore 或 TUICallEngine 捕获 roomID/strRoomID,再 POST /tcm.diagnosis/bindCallRoom
- diagnosis_id
- room_id(字符串)
8. bindCallRoom 的响应可带 cloud_recording.started、task_id、message;源码注释说明后端在这里触发腾讯云 CreateCloudRecording,混流模式由后端负责。
9. 接通后,若 isLochostVod=true,前端从 TUICallKit 视频元素启动 MediaRecorder/Canvas 本地录制。
10. 通话结束、挂断、IM 自定义挂断消息或用户关闭窗口时先 POST /tcm.diagnosis/endCall,再完成本地视频上传并 POST attachLocalCallRecording。
群视频调用 TUICallKitServer.callsuserIDList=[patientUserId, assistant_id]type=VIDEO_CALL。
### 5.3 截屏、录制与回放
关键文件:
- D:\web\zyt\admin\src\utils\call-local-recorder.ts
- D:\web\zyt\admin\src\utils\call-video-screenshot.ts
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\CallRecordPanel.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\RecordingPlaybackBlock.vue
视频浮窗“截屏”会:
1. 抓取当前 video frame
2. 上传图片;
3. POST /doctor.appointment/addDoctorNote,把路径追加到 tongue_images。
通话记录字段:
- id、call_type1 语音、其他视为视频)
- room_id
- status:1 进行中、2 已结束、3 未接听、4 已取消
- recording_status_text
- recording_urls_list
- start_time_text、end_time_text、duration_text
接口:
- GET /tcm.diagnosis/getCallRecordsdiagnosis_id
- POST /tcm.diagnosis/attachLocalCallRecordingdiagnosis_id、file_url、可选 call_record_id
- POST /tcm.diagnosis/createManualCallRecorddiagnosis_id
- POST /tcm.diagnosis/startCloudRecordingdiagnosis_idAPI 有封装,主组件当前通过 bindCallRoom 的后端联动启动)
### 5.4 医助旁观
入口在诊单列表。只有同时满足:
- 当前 userInfo.id 等于该诊单 assistant_id
- 拥有 tcm.diagnosis/watchCall
- video_call_hint.state=live
才可真正进入。
GET /tcm.diagnosis/watchCall,参数 diagnosis_id,响应使用:
- sdkAppId
- userId
- userSig
- roomId 或 strRoomId
- patientName
旁观组件直接使用 trtc-sdk-v5 进入房间,只调用 startRemoteVideo,不开启本地摄像头或麦克风。pending_room 时显示入口提示但点击会阻止进入,等待房间号同步。
### 5.5 视频二维码
页面通过 POST /tcm.diagnosis/generateMiniProgramQrcode 生成 qrcode_url,常用字段:
- diagnosis_id
- patient_id
- doctor_id(部分入口)
- share_user_id
- mini_program_path=pages/login/login(问诊列表入口)
调用前先 GET 小程序配置并校验 app_id。
## 6. API 总表
所有 URL 会被请求层加上 baseUrl 和 adminapi 前缀;下表写的是 API 封装中的业务路径。
### 6.1 认证
| 方法 | 路径 | 关键请求/响应 |
|---|---|---|
| POST | /login/account | account、password、terminal;返回 token、is_paw、need_bind_work_wechat |
| POST | /login/workWechatLogin | code、terminal;返回同登录结果 |
| GET | /login/workWechatConfig | enabled、corp_id、agent_id |
| GET | /auth.admin/mySelf | 返回 user、permissions、menu |
| POST | /login/logout | 退出 |
| POST | /login/changeFirstPassword | password、password_confirm |
| POST | /auth.admin/bindWorkWechat | code |
### 6.2 挂号与接诊
| 方法 | 路径 | 关键字段 |
|---|---|---|
| GET | /doctor.appointment/lists | status、start_date、end_date、patient_name、doctor_name、diagnosis_confirmed、assistant_dept_id、page_no、page_size |
| GET | /doctor.appointment/reception | id=挂号ID;返回 appointment、diagnosis、doctor_notes |
| GET | /doctor.appointment/detail | id |
| GET | /doctor.appointment/availableSlots | doctor_id、appointment_date、period |
| POST | /doctor.appointment/create | patient_id、doctor_id、appointment_date/time、appointment_type、渠道等 |
| POST | /doctor.appointment/cancel | id |
| POST | /doctor.appointment/complete | id |
| POST | /doctor.appointment/notifyAssistant | id |
| POST | /doctor.appointment/addDoctorNote | diagnosis_id、content、tongue_images、report_files |
| GET | /doctor.appointment/doctorNotes | diagnosis_id |
| POST | /doctor.appointment/deleteDoctorNoteImage | note_id、image_type、image_path |
### 6.3 诊单
| 方法 | 路径 | 关键字段 |
|---|---|---|
| GET | /tcm.diagnosis/lists | 诊单列表全部筛选 + page_no/page_size;返回 lists/count/extend |
| GET | /tcm.diagnosis/detail | id |
| GET | /tcm.diagnosis/readonlyDetail | id;返回 appointment、diagnosis、unserved_days、last_blood_record_at、doctor_notes |
| POST | /tcm.diagnosis/add | 完整诊单模型 |
| POST | /tcm.diagnosis/edit | 完整诊单模型 |
| POST | /tcm.diagnosis/delete | id |
| POST | /tcm.diagnosis/assign | id、assistant_id、可选 is_inherit;批量场景由前端逐条调用 |
| GET | /tcm.diagnosis/getAssistants | 医助选项 |
| GET | /tcm.diagnosis/getDoctors | 医生选项 |
| POST | /tcm.diagnosis/checkPhone | phone 及排除 id |
| POST | /tcm.diagnosis/checkIdCard | id_card 及排除 id |
| POST | /tcm.diagnosis/fillIdCard | id、id_card |
| GET | /tcm.diagnosis/trackingWindow | id、start_date、end_date |
| GET | /tcm.diagnosis/trackingNotes | diagnosis_id |
| POST | /tcm.diagnosis/addTrackingNote | diagnosis_id、tracking_content |
### 6.4 处方与业务订单
处方、处方库、业务订单接口已在 4.2、4.4、4.5 分节完整列出。实现时必须保留三个资源命名空间,不要把 prescription、prescriptionLibrary、prescriptionOrder 合并成一个“处方”接口。
### 6.5 视频
| 方法 | 路径 | 关键字段 |
|---|---|---|
| POST | /tcm.diagnosis/getCallSignature | patient_id、diagnosis_id;返回 sdkAppId、userId、userSig、patientUserId、assistant_id、isLochostVod |
| POST | /tcm.diagnosis/startCall | diagnosis_id、patient_id、call_type |
| POST | /tcm.diagnosis/bindCallRoom | diagnosis_id、room_id |
| POST | /tcm.diagnosis/startCloudRecording | diagnosis_id |
| POST | /tcm.diagnosis/endCall | diagnosis_id |
| GET | /tcm.diagnosis/getCallRecords | diagnosis_id |
| POST | /tcm.diagnosis/attachLocalCallRecording | diagnosis_id、file_url、可选 call_record_id |
| POST | /tcm.diagnosis/createManualCallRecord | diagnosis_id |
| GET | /tcm.diagnosis/watchCall | diagnosis_id |
| POST | /tcm.diagnosis/generateMiniProgramQrcode | diagnosis_id、patient_id、doctor_id、share_user_id 等 |
## 7. 可复用约定
1. 请求协议
- baseURL 来自 VITE_APP_BASE_URL,统一 URL 前缀 adminapi。
- token 放在名为 token 的请求头,不是 Bearer Authorization。
- POST 默认把 params 转为 bodyGET 使用 params。
- 标准成功响应是 code=1,业务数据自动解包为 data。
- GET 网络失败默认最多重试 2 次,POST 不自动重试。
2. 列表协议
- 请求 page_no、page_size。
- 响应 lists、count、extend。
- 定时刷新使用 getLists({ silent: true }),避免表格白屏闪烁。
3. 标识约定
- diagnosis_id 是诊单主键。
- appointment.id 是挂号主键。
- prescription.id 是处方主键。
- prescriptionOrder.id 是处方业务订单主键。
- patientUserId 是腾讯云 IM/TRTC 用户名,通常 patient_加患者标识。
4. 隐私
- 默认手机号 3-4-4 脱敏,身份证保留前 6 后 4。
- tcm.diagnosis/phonePlain 控制诊单编辑中的明文能力。
- 数据范围由后端按角色、部门、归属医助裁剪,前端只做 UI 能力门控。
5. 复用组件
- 患者摘要/病例:PatientInfoCard、PatientCaseCard。
- 日常记录:DailyMatrix。
- 医生备注:NoteTimeline。
- 诊单编辑和只读:tcm/diagnosis/edit.vue 的 open、openViewOnly。
- 处方开立/查看:TcmPrescription 的 open、openById。
- 视频通讯:ChatDialog 的 open。
- 业务订单详情:PrescriptionOrderDetailDrawer。
6. 状态文本
- 不建议在新客户端重复定义状态映射。优先抽取 D:\web\zyt\admin\src\views\consumer\prescription\components\prescription-order-utils.ts 中的审核、履约、支付、供货和物流格式化逻辑为共享领域模块。
## 8. 未知点、歧义与风险
1. 动态菜单缺口:admin 前端仓库没有生产环境 /auth.admin/mySelf 的 menu 数据,因此接诊台、处方库、患者、问诊列表的精确 URL、菜单标题和页面级 route.meta.perms 仍未知。
2. API 类型不足:tcm.ts、doctor.ts 多数参数和返回值是 any;本文列出的响应字段来自实际页面读取,不等于完整服务端 schema。后续实现应抓取真实响应或检查服务端 DTO。
3. patient_id 语义有重载:
- 挂号行中 patient_id 常被当作诊单/患者标识;
- 我的患者 openAppointment 又把 diagnosis_id 或 id 同时写进 id 和 patient_id
- ChatDialog 则把它转换为 patient_前缀的腾讯云用户。
新客户端必须先确认数据库实体关系,不能只按字段名推断。
4. 接诊台发起通话使用 diagnosis_id || row.idrow.id 本身是挂号 ID。若后端要求真正诊单 ID,这个回退可能只在特定历史数据下成立。
5. 视频二维码参数疑点:tcm/appointment/list.vue 的一个入口把 diagnosis_id 赋为 row.doctor_id,而其他入口使用真正诊单 ID;这很可能是历史兼容或缺陷,应向后端核实后再复用。
6. 权限命名不统一:
- 处方库用 wcf.prescription/*
- 已开处方用 cf.prescription/*
- 新增能力又混用 tcm.prescription/* 与 tcm.prescriptionOrder/*。
不能按字符串前缀自动推导资源。
7. 角色配置存在差异:
- 消费者处方审核页面写死 0、3
- 业务订单共享工具写死 0、3、6
- 多处注释都声称与服务端配置一致。
最终角色应由服务端下发 capability,避免继续硬编码。
8. 权限 UI 不是安全边界:部分接诊、药品和通话按钮没有 v-perms;所有写接口必须继续依赖服务端鉴权。
9. 双 SDK 并存:
- 实际 ChatDialog 使用 @tencentcloud/call-uikit-vue
- 未引用的 video-call/index.vue 使用 @trtc/calls-uikit-vue
- 医助旁观直接使用 trtc-sdk-v5。
新项目应明确只保留一套主叫/被叫 UI SDK,并将纯 TRTC 旁观作为独立只拉流能力。
10. src/components/video-call/index.vue 当前没有被任何 Vue/TS 源码引用,不应误认为生产主链。
11. PatientCaseCard 的 caseTypeLabel 无论 consultation_type 都返回“复诊”,属于明显展示逻辑疑点。
12. 录制启动/停止部分依赖 TUICallKit 内部 store、引擎属性和方法包装,升级腾讯云 SDK 时风险较高,必须用真实双端通话、拒接、对端挂断、网络中断和房间号延迟场景回归。
13. 处方业务订单大量前端角色规则与 server/config/project.php 注释耦合;当前审计范围只有 admin 前端,无法验证服务端配置是否已同步。
## 9. 面向新医生端的建议映射
若新项目要复刻医生工作流,建议按领域而不是按现有目录命名:
- /login:复用认证协议和企业微信登录。
- /reception:复用接诊台队列、详情、5 秒静默刷新与 ChatDialog。
- /prescription-library:复用 prescriptionLibrary 模板及所有权规则。
- /prescriptions:复用 tcm.prescription 列表、审核、作废、打印/下载。
- /patients:优先复用 firstvisit.myPatient,而不是 user.user/lists。
- /consultations:复用 doctor.appointment/lists 的今天待接诊视图。
- /diagnoses:复用 tcm.diagnosis/lists 的完整诊单工作台。
- /video-consultation:主叫链路复用 ChatDialog/TUICallKit;医助旁观保持独立 TRTC 只拉流组件。
这些建议 URL 是新端的信息架构建议,不是对 admin 当前动态 URL 的断言。
+461
View File
@@ -0,0 +1,461 @@
# 医生桌面端工程架构与打包方案(Windows / macOS
> 结论先行:采用 **Python 3.12 + PySide6 Qt Widgets** 构建原生业务界面,以分层的 `httpx` API client 连接现有后端;会话、权限、离线队列和本地安全存储统一放在 core 层。视频不是整套应用的实现基础,而是独立的可选集成:只有当现有腾讯 TRTC Web 方案无法由原生 SDK 替代时,才在受限的 `QWebEngineView` 中承载单一视频页面。发布使用 **PyInstaller onedir**Windows 与 macOS 必须在各自原生 CI runner 上分别构建、签名和验收,不能交叉编译。
## 1. 已知上下文、边界与待确认项
本结论只对 `admin/package.json` 和环境配置做了最小只读核对,没有审计管理端实现。
- 管理端以 `VITE_APP_BASE_URL` 注入后端根地址;示例文件故意留空,开发示例注释仅以 `http://127.0.0.1:8080` 举例。现有环境文件还出现了 `https://css.zhenyangtang.com.cn/``https://admin.zhenyangtang.com.cn/` 和 60 秒请求超时,但这些地址可能是网关或前端站点,**不能据此认定为稳定的桌面 API 地址**。
- 管理端依赖包含 Axios、腾讯 TRTC/Call/Chat UI、`hls.js` 和 COS JS SDK。可以据此判断视频、聊天、流媒体和对象存储是潜在集成面,但不能推断接口路径、认证协议、权限码或 RTC 凭证格式。
- 桌面端不得读取或复用 Vite 环境变量,不得硬编码管理端 URL,也不得在客户端持有 COS Secret、TRTC SecretKey 或任何服务端签名密钥。
编码前必须由后端确认以下契约,并固化为 OpenAPI 或最小接口文档:
1. API 的正式 base URL、版本前缀、响应 envelope、错误码、分页和时间格式。
2. 登录协议(账号密码、短信、SSO/OIDC 或 Cookie)、access/refresh 生命周期、登出和吊销语义。
3. `/me` 等当前用户接口返回的医生身份、机构/租户、角色与细粒度权限码。
4. 预约、患者、病历、处方等写操作的幂等键、乐观锁版本号及审计要求。
5. 聊天的拉取/推送协议、断线续传游标;COS 上传应由后端提供短期预签名 URL 或临时凭证。
6. TRTC 房间、`userSig` 等凭证必须由后端短时签发;确认现有 Web 页面能否作为受支持的嵌入入口。
7. 桌面端的 CORS、代理、私有 CA、设备绑定、强制升级和最低版本策略。
在这些问题确认前可以完成壳层、接口抽象和模拟服务器,但不应猜测生产 endpoint。
## 2. 目标平台与技术选择
### 2.1 建议支持矩阵
| 项目 | 首发建议 | 说明 |
| --- | --- | --- |
| Python | CPython 3.12,固定 patch 版本 | 生命周期长,第三方包成熟;每个平台使用相同 minor |
| Windows | Windows 10 22H2 / Windows 11x86-64 | ARM64 可作为后续独立制品,不与 x64 混装 |
| macOS | macOS 13+,先 arm64,再按客户量增加 x86-64 | 当前 PySide6 wheel 的最低系统版本必须在锁版本时再次核对 |
| UI | PySide6 Qt Widgets | 医疗表单、表格、快捷键、打印和可访问性更稳定 |
| 视频 | 可选 PySide6 Addons / QtWebEngineWidgets | 只隔离承载视频页,不用 WebEngine 包住整个应用 |
| 打包 | PyInstaller onedir | 对 QtWebEngine helper、资源、签名和启动性能最稳妥 |
macOS 推荐分别产出 `arm64``x86_64` 制品。`universal2` 只有在 Python、PySide6 和所有二进制依赖均提供 universal2 slice,且真实验证签名/视频后再启用;两个单架构制品更易排障且体积更小。
### 2.2 依赖分档
建立一份代码、两种构建 profile:
- `core``PySide6-Essentials``httpx``pydantic``pydantic-settings``platformdirs``keyring``cryptography`。包含 QtCore/Gui/Widgets/Network/Sql/Svg/PrintSupport,不包含 WebEngine。
- `video`:在 core 上增加与 Essentials **完全相同版本**的 `PySide6-Addons`,从而获得 QtWebEngineWidgets、WebChannel、Multimedia 等模块。
- 开发/测试:`pytest``pytest-qt``respx``coverage``ruff``mypy``pip-audit`
- 构建:锁定 `PyInstaller` 及其 hooks 版本。以 2026-08-10 可验证组合为基线,可先验证 Python 3.12 + PySide6 Essentials/Addons 6.11.1 + PyInstaller 6.21.0;只有在两端打包 smoke test 通过后才更新锁。
不要同时安装 PyQt、PySide2 或系统级 PySide6;必须从干净虚拟环境构建。使用平台专属、带 hash 的锁文件(例如 `requirements-win-x64.lock``requirements-macos-arm64.lock`),而不是在发布任务中直接安装“最新版”。
如果所有医生都需要视频,可只发布 `video` 制品;仍保留 profile 边界,以便定位 WebEngine 问题。若视频是少数场景,可以发布 core 制品并在系统浏览器打开受支持的视频页,避免让每个安装包承担 Chromium 的体积和攻击面。
## 3. 分层架构
依赖方向固定为:`ui -> application -> domain``infrastructure` 在 composition root 中实现 domain/application 定义的 port。View 不允许直接调用 `httpx`、SQLite 或 keyring。
```text
app/
├─ pyproject.toml # 项目元数据、依赖分组、工具配置
├─ requirements/ # 各 OS/架构的发布锁及 hash
├─ src/
│ └─ zyt_doctor/
│ ├─ __main__.py # 极薄入口,只调用 bootstrap.main()
│ ├─ bootstrap.py # QApplication、配置、DI、异常钩子、主窗口
│ ├─ build_info.py # 版本、commit、channel;构建时生成
│ ├─ config/
│ │ ├─ models.py # 强类型配置及校验
│ │ └─ loader.py # defaults -> 受管配置 -> 开发环境变量
│ ├─ domain/
│ │ ├─ identity.py # Principal、Tenant、Permission
│ │ ├─ errors.py # 与 UI/HTTP 无关的错误类型
│ │ └─ ports.py # Repository、Clock、SecretStore 等协议
│ ├─ application/
│ │ ├─ commands.py # 写用例、幂等键和确认规则
│ │ ├─ queries.py # 读用例和缓存策略
│ │ └─ result.py # Result / Page / OperationState
│ ├─ core/
│ │ ├─ api/
│ │ │ ├─ client.py # httpx client、header、超时、重试
│ │ │ ├─ auth.py # token/cookie adapter 与 refresh single-flight
│ │ │ ├─ errors.py # HTTP/业务错误归一化
│ │ │ ├─ models.py # 公共 DTO
│ │ │ └─ generated/ # 若有 OpenAPI,生成代码仅放此处
│ │ ├─ session/
│ │ │ ├─ manager.py # 会话状态机、锁屏、租户切换、登出清理
│ │ │ └─ permissions.py # 权限快照与 guard
│ │ ├─ storage/
│ │ │ ├─ database.py # SQLite、migration、单写线程
│ │ │ ├─ secure_store.py # Windows Credential Manager / macOS Keychain
│ │ │ └─ cache.py # 加密缓存、TTL、容量控制
│ │ ├─ offline/
│ │ │ ├─ connectivity.py # 网络状态提示,不作为唯一真相
│ │ │ ├─ outbox.py # 离线写队列状态机
│ │ │ └─ sync.py # 重放、冲突和人工处理
│ │ ├─ jobs.py # QThreadPool 任务、取消、signal 适配
│ │ ├─ events.py # 进程内 typed event bus
│ │ ├─ logging.py # 脱敏日志和诊断包
│ │ └─ paths.py # QStandardPaths/platformdirs;绝不写 bundle
│ ├─ modules/
│ │ ├─ auth/
│ │ ├─ dashboard/
│ │ ├─ patients/
│ │ ├─ appointments/
│ │ ├─ consultations/
│ │ ├─ medical_records/
│ │ ├─ prescriptions/
│ │ ├─ chat/
│ │ ├─ followups/
│ │ └─ settings/
│ │ # 每个模块内含 domain.py、service.py、viewmodel.py、views.py、permissions.py
│ ├─ integrations/
│ │ ├─ realtime/ # WebSocket/轮询 adapter,不侵入模块
│ │ ├─ object_storage/ # 仅消费后端预签名 URL/临时凭证
│ │ └─ video/
│ │ ├─ port.py # join/leave/mute 等抽象
│ │ ├─ external_browser.py
│ │ └─ webengine.py # 唯一允许 import QtWebEngine 的文件
│ ├─ ui/
│ │ ├─ shell/ # 导航、标题栏、全局离线/会话提示
│ │ ├─ widgets/ # Loading、Empty、Error、PermissionDenied
│ │ ├─ dialogs/
│ │ └─ theme/
│ └─ resources/ # qrc、图标、字体许可、翻译、默认配置
├─ tests/
│ ├─ unit/
│ ├─ contract/
│ ├─ integration/
│ ├─ ui/
│ ├─ packaging/
│ └─ fixtures/ # 全部为合成数据,禁止生产病患数据
├─ packaging/
│ ├─ windows/doctor-core.spec
│ ├─ windows/doctor-video.spec
│ ├─ macos/doctor-core.spec
│ ├─ macos/doctor-video.spec
│ ├─ macos/entitlements.plist
│ └─ hooks/
├─ scripts/ # build、self-check、sign、notarize
└─ docs/ # API 映射、权限矩阵、发布 runbook
```
每个业务模块只公开一个 facade 和路由描述,例如 `ModuleDescriptor(id, title, permissions, view_factory)`。主壳根据权限注册菜单,模块内再对按钮和 command 做 guard;这样既不会形成一个巨型主窗口,也不会把权限判断散落在控件代码中。
## 4. API client、并发和会话
### 4.1 API client
建议使用一个长生命周期 `httpx.Client`,由 composition root 创建并注入 service。所有同步请求放入 `QThreadPool/QRunnable`,结果通过 Qt signal 回到主线程;严禁 UI 线程阻塞网络。窗口关闭或查询条件变化时取消尚未开始的任务,并忽略带旧 generation id 的晚到响应。
Client 的固定行为:
- production base URL 只允许 HTTPSHTTP 仅在 debug profile 且 host 为 localhost 时允许。
- production 允许的 API、视频和上传 host 必须来自签名/受管配置或内置 allowlist,避免篡改本地配置后窃取 token。
- 超时拆分为 connect/read/write/pool,不只设一个总数。可从 connect 10 秒、read 60 秒起步,上传/导出另设长超时。
- 每次请求加入 `Authorization`(若契约采用 bearer)、`X-Request-ID`、客户端版本、平台、时区和租户信息;不得写入日志的 header 列表默认包含 Authorization、Cookie 和所有临时凭证。
- GET/HEAD 和带服务端认可幂等键的写操作,才可对连接错误、超时、429、502、503、504 做指数退避 + jitter;遵守 `Retry-After`。验证错误、普通 4xx 和未知写请求不自动重试。
- 所有关键 mutation 生成并持久化 `Idempotency-Key`,直到收到确定结果;请求超时后的状态为“结果未知”,先按键查询/重放,不能直接再创建一条。
- 业务错误映射为稳定类型:`ValidationError``Unauthenticated``Forbidden``Conflict``RateLimited``Maintenance``TransportError``UnknownServerError`。UI 不解析后端文案。
- 支持 ETag/版本字段做乐观锁。收到 409/412 时进入冲突页,展示服务器版本与本地草稿,不做静默覆盖。
- 下载/上传流式处理并限制文件大小、MIME 和保存目录。COS 只使用后端签发的预签名 URL或短期临时凭证,绝不打包永久密钥。
若后端有 OpenAPI,生成的 models/client 放入 `core/api/generated`,外面再包一层业务 adapter;模块不得直接依赖生成器的数据结构。若无 OpenAPI,先写小而明确的 typed endpoint,不做一个接受任意 path/dict 的“万能客户端”。
实时聊天使用独立 adapter:WebSocket 可运行在一个后台 asyncio loop/thread 中,通过 signal 投递事件;实现心跳、指数重连、服务器 sequence/cursor 补拉、重复消息去重和应用休眠恢复。首版若后端没有可靠续传契约,应采用短轮询而不是假装 WebSocket 永不丢消息。
### 4.2 Session 状态机
`SessionManager` 是唯一会话真相,显式状态为:
```text
SIGNED_OUT -> AUTHENTICATING -> AUTHENTICATED
AUTHENTICATED -> REFRESHING -> AUTHENTICATED
AUTHENTICATED/REFRESHING -> LOCKED | EXPIRED | SIGNED_OUT
```
- access token 只保存在内存;需要“保持登录”时,refresh token 或可续期凭证保存在 OS keychain,不能放在 QSettings、SQLite 明文或日志。
- `keyring` 启动时必须检查实际 backend。没有 Windows Credential Manager/macOS Keychain 等安全 backend 时禁用持久登录,而不是退化到明文文件。
- 多请求同时遇到 401 时只能有一个 refresh 在飞行,其他请求等待同一个 future;refresh 失败统一切到 `EXPIRED`,避免 401 风暴。
- 登出、切换医生或切换租户时:取消网络任务、停止实时连接、退出视频、清内存 token、清空 WebEngine profile、关闭并按用户/租户清理本地缓存密钥。
- 支持工作站空闲自动锁定。解锁方式由后端安全策略决定;锁定界面不得继续显示患者姓名、通知正文或缩略图。
-`QLocalServer/QLocalSocket` 实现单实例,第二次启动只唤醒现有窗口,避免同一用户同时运行两个 outbox。
### 4.3 权限模型
权限码由后端返回并作为服务端授权的镜像,例如 `patient.read``record.write``prescription.sign`;具体字符串必须以真实契约为准。
客户端执行三层防误操作:
1. 路由层:无模块权限时不注册菜单/路由。
2. ViewModel/command 层:按钮显示与执行前都检查 `PermissionGuard.require(...)`
3. API 层:403 统一转为 `Forbidden`,刷新权限快照并提示“权限已变更”。
这些仅改善体验,真正的 RBAC/ABAC、租户隔离和审计必须由后端再次校验。不能因为客户端隐藏了按钮就省略服务端授权。对开方、签名、删除等高风险操作增加 step-up authentication 或明确二次确认,并把 request id/idempotency key 传给服务端审计。
## 5. 本地数据、离线与错误态
### 5.1 数据目录与加密
使用 `QStandardPaths``platformdirs` 获取每用户目录:Windows 通常位于 `%LOCALAPPDATA%`macOS 位于 `~/Library/Application Support`。安装目录和 `.app` bundle 始终只读;业务代码不要访问 `sys._MEIPASS`,资源通过 `importlib.resources`/Qt resource system 读取。
SQLite 使用 WAL、schema migration 和单写入 worker(或每线程独立 connection),不在线程间共享 `sqlite3.Connection`。默认只缓存必要元数据;如果确需缓存患者/病历或离线草稿:
- 使用 `cryptography` 的 AES-GCM 做版本化记录加密,随机 nonce,密钥由 OS keychain 保存;AAD 包含 tenant/user/table/record id,防止记录调包。
- outbox、cache 和密钥按机构 + 用户分区;退出账号做 crypto-erasure(删除密钥)并清理索引。SSD 上不能承诺可靠覆盖删除,因此不能用“反复覆盖文件”作为安全保证。
- 设置缓存 TTL、容量上限和最少字段;搜索索引不放诊断正文等敏感内容。
- QSettings 只保存主题、窗口大小等无敏感偏好。
- 日志不记录患者姓名、手机号、证件号、病历正文、处方内容、token、Cookie 或 URL query;提供用户确认后的脱敏诊断包。
### 5.2 离线策略
不要只依赖系统“在线/离线”事件;真正状态以最近请求结果和轻量 health check 综合判定。主壳常驻显示 `在线 / 网络不稳定 / 离线 / 服务维护`,且标明数据最后更新时间。
写操作按风险分类:
| 类别 | 离线行为 | 恢复后 |
| --- | --- | --- |
| 只读列表/详情 | 展示有时间戳的加密缓存,明显标记“可能已过期” | 后台重新验证并原子替换 |
| 普通草稿、低风险备注 | 可进入 outbox,保存幂等键、base version 和依赖 | 自动重放;冲突转人工处理 |
| 病历最终提交、开方/签方、医嘱、删除等高风险操作 | 只允许保存为本地草稿,禁止假显示“已提交” | 恢复网络后重新拉取服务端版本,由医生确认再提交 |
| 视频/实时聊天 | 显示不可用或重连,不能伪造发送成功 | 按 cursor 补拉并去重 |
Outbox 状态至少包含 `QUEUED -> SENDING -> SUCCEEDED`,以及 `NEEDS_ATTENTION``DEAD_LETTER`。保存 payload schema version、创建人/租户、幂等键、重试次数、next attempt、最后错误和服务端 base version。只自动重放白名单 action;切换用户时绝不重放前一用户队列。
### 5.3 统一错误体验
所有页面复用下列状态组件,而不是把异常 traceback 或后端原文弹给用户:
- 首次加载 skeleton;刷新时保留旧数据并显示非阻塞进度。
- 真空数据(业务上没有记录)与加载失败严格区分。
- 离线且有缓存、离线且无缓存、权限不足、登录过期、字段校验、版本冲突、维护中、未知错误各有独立文案和可行动按钮。
- 未知错误显示 request id、发生时间、“重试/复制诊断编号”,详细堆栈只进入脱敏日志。
- 全局未捕获异常写入 rotating log 并打开安全错误页;不要自动上传包含医疗数据的 crash dump。
## 6. 视频与 QtWebEngine 的可执行边界
### 6.1 首选集成顺序
1. 先确认腾讯或现有供应商是否提供受支持的 Windows/macOS 原生桌面 SDK 以及 Python 可调用层。如果维护成本可接受,原生 adapter 最可控。
2. 若现有成熟能力是 TRTC Web 页面,后端提供一个专用、窄功能、HTTPS 的 `/desktop-call` 类入口(实际路径待定),由 `QWebEngineView` 嵌入。
3. 若 SDK/UA/DRM/屏幕共享在 Qt Chromium 中不受支持,可靠回退是系统默认浏览器,不通过修改 UA 或关闭浏览器安全策略强行兼容。
不要把整套 admin 嵌入桌面壳。JS SDK 的版本和构建产物留在专用 Web 页面侧,Python 只持有 `VideoPort(join, leave, mute, device_changed)` 抽象,这样管理端升级 TRTC SDK 不要求桌面二进制同步发版。
### 6.2 安全桥接
- 桌面从后端申请一次性、短有效期的 call ticketWeb 页面再用 ticket 换房间凭证。禁止把 access token、`userSig` 长期放在 URL、日志或 localStorage。
- 使用专用 `QWebEngineProfile`。优先 off-the-record;若必须持久化设备选择,也只能保存无认证数据。通话结束时清 Cookie、HTTP cache、permissions 和页面内容。
- 导航只允许精确的 HTTPS origin/path allowlist;拦截新窗口、任意下载、`file://`、未知 scheme、跨域跳转和证书错误。证书错误 fail closed。
- 相机/麦克风权限只对当前 allowlisted 通话 origin、活跃通话和明确用户操作放行,结束即撤销。屏幕共享另做显式确认。
- 若使用 Qt WebChannelbridge 只暴露少量 typed method/signal,不暴露文件系统、shell、通用 HTTP client、token getter 或任意 Python 调用。每次调用再次校验当前 page origin 和 session/call id。
- release 不启用 remote debugging,不设置 `QTWEBENGINE_DISABLE_SANDBOX=1`,不使用 `--no-sandbox`。应用也不以管理员/root 身份运行。
### 6.3 兼容性与体积现实
`PySide6` 顶层 wheel 会同时拉入 Essentials 和 Addonscore profile 应直接依赖 `PySide6-Essentials`。WebEngine 位于 Addonswheel 和最终制品都会显著增大,不能把它当成“小插件”。最终体积以两端产物为准,不承诺一个固定数字。
QtWebEngine 使用多进程 Chromium,发布物必须保留:
- `QtWebEngineProcess` helper
- QtWebEngineCore/Widgets 库与所需平台插件;
- `qtwebengine_resources*.pak``icudtl.dat`、V8 snapshot
- `qtwebengine_locales`(至少完整验证 `zh-CN``en-US` 后才可裁剪);
- macOS framework/helper 的 bundle 结构和 entitlements。
H.264/AAC/MP3 等专有 codec 是否可用取决于 QtWebEngine 构建与许可,不能因为 `hls.js` 存在就假设一定能播放。首发验收必须覆盖真实 TRTC/WebRTC、摄像头、麦克风、扬声器切换、屏幕共享(若需求存在)、HLS/录播格式和弱网;若要自行构建启用 proprietary codecs,先完成专利/分发许可评审。
## 7. PyInstaller 构建与发布
### 7.1 为什么固定 onedir
虽然 PyInstaller 支持 onefile,但本项目默认 `onedir`
- onefile 每次启动需解压大体积 Chromium,冷启动慢,易触发杀软且临时目录空间不可控;
- WebEngine 是多进程,helper、framework、资源和 macOS 签名/沙箱都依赖正确目录结构;
- onedir 更容易做增量诊断、签名验证和 installer 管理。
用户最终仍拿到一个 `.exe` 安装程序或 `.dmg/.pkg`,无需手动管理 onedir 目录。
### 7.2 spec 设计原则
维护四个薄 spec,公共配置放 `packaging/common.py`。入口始终为 `src/zyt_doctor/__main__.py``pathex=["src"]`;只收集应用 resources 和必要 metadata。WebEngine profile 因 `integrations/video/webengine.py` 中有显式 import,触发 PyInstaller 官方 PySide6 hook;如通过 feature registry 动态加载,再显式加入这些 hidden imports
```python
VIDEO_HIDDEN_IMPORTS = [
"zyt_doctor.integrations.video.webengine",
"PySide6.QtWebEngineCore",
"PySide6.QtWebEngineWidgets",
"PySide6.QtWebChannel",
]
# core spec 中排除,且 core 构建环境根本不安装 Addons
CORE_EXCLUDES = [
"PySide6.QtWebEngineCore",
"PySide6.QtWebEngineWidgets",
"PySide6.QtWebEngineQuick",
]
```
不要手工把整个 `site-packages/PySide6` 复制进 datas,也不要用 `collect_all("PySide6")`;这会拉入无关 Qt 模块并可能破坏 hook 期望的目录。优先依赖当前锁定 PyInstaller 的 Qt hooks,仅对 self-check 证实遗漏的自有动态模块写自定义 hook。
macOS `BUNDLE` 至少设置稳定的 bundle id、版本、图标,并在 `Info.plist` 中写清:
```python
info_plist = {
"CFBundleIdentifier": "com.zhenyangtang.doctor",
"NSCameraUsageDescription": "用于医生视频问诊",
"NSMicrophoneUsageDescription": "用于医生视频问诊",
"NSHighResolutionCapable": True,
}
```
仅当有实际功能时再加入其他 TCC 权限说明。不得加入放宽 ATS 的全局例外。Windows spec 使用有版本信息的 manifest、`.ico` 和 GUI subsystem,同时保留内部异常日志;开发 smoke build 可临时打开 console。
### 7.3 构建命令骨架
Windows x64 runner
```powershell
py -3.12 -m venv .venv-build
.venv-build\Scripts\python -m pip install --require-hashes -r requirements\video-win-x64.lock
.venv-build\Scripts\python -m PyInstaller --noconfirm --clean packaging\windows\doctor-video.spec
dist\DoctorDesktop\DoctorDesktop.exe --self-check
```
macOS arm64 runner
```bash
python3.12 -m venv .venv-build
.venv-build/bin/python -m pip install --require-hashes -r requirements/video-macos-arm64.lock
.venv-build/bin/python -m PyInstaller --noconfirm --clean packaging/macos/doctor-video.spec
dist/DoctorDesktop.app/Contents/MacOS/DoctorDesktop --self-check
```
PyInstaller 不能从 Windows 生成 macOS `.app`,反之亦然。每次构建从干净环境执行 `--clean`,记录 Python/PySide6/PyInstaller/OS SDK 版本、锁文件 hash、git commit 和产物 SHA-256,保证可追溯。
### 7.4 WebEngine 自检
应用提供 `--self-check`,不访问患者数据,检查:
- build info、只读 resources、可写 data/log/cache 路径;
- keyring backend 是否安全、SQLite migration 是否可运行;
- TLS CA、production 配置和 host allowlist
- video profile 中通过 `QLibraryInfo` 定位 helper/resources/locales,禁止依赖写死的 `_internal` 路径;
- release 环境没有 `QTWEBENGINE_DISABLE_SANDBOX`/`--no-sandbox`
- GUI smoke 模式实际创建 `QWebEngineView`,加载本地无网络测试页,然后退出;真实视频另由端到端测试覆盖。
任何 helper、`.pak`、ICU、snapshot、platform plugin 或 locale 缺失都应让发布流水线失败,不在运行时静默降级。
### 7.5 Windows 发布
1. 使用固定、受控的 Windows x64 runner 构建;不要在装有多套 Qt/Anaconda 的个人机上发正式包。
2. QtWebEngine 运行依赖合适的 MSVC runtime。由安装器包含/检查 Microsoft Visual C++ RedistributableQt 官方要求的版本下限需按锁定 Qt 再核对),并在干净 VM 验证。
3. 用组织的 Authenticode 证书和 RFC 3161 时间戳签名主程序及最终 MSI/EXE 安装器;验证 `signtool verify /pa /all`
4. 安装到 Program Files,用户数据仍进 LocalAppData;普通用户可运行/升级。可用 WiX Toolset/MSIX 或 Inno Setup,选择后固定 UpgradeCode/AppUserModelID 和回滚策略。
5. 在 Windows 10/11 干净 VM 上验证安装、升级、卸载后保留/清除用户数据的明确策略、SmartScreen、企业代理、中文路径和非管理员账户。
### 7.6 macOS 发布
1. 在目标架构的 macOS runner 构建。PyInstaller 修改 Mach-O 后必须重新签名;使用 Developer ID Application 身份,不用 ad-hoc 签名发布。
2. QtWebEngine helper 是嵌套 app/process。必须保留 framework bundle 结构,并确认 helper 使用 Qt 自带的 `QtWebEngineProcess.entitlements` 所需权限签名;主 app 使用项目的 camera/microphone 权限说明和最小 entitlements。签名顺序由内向外,避免用 `codesign --deep` 掩盖错误。
3. 执行 `codesign --verify --deep --strict --verbose=2 DoctorDesktop.app``spctl --assess --type execute`;随后用 `xcrun notarytool submit ... --wait` 公证,staple ticket,再在离线干净 Mac 验证 Gatekeeper。
4. 用 DMG/PKG 或能保留 symlink 的方式分发。PyInstaller 6+ 的 POSIX bundle 广泛使用 symlink,普通 zip 若不保留 symlink 可能膨胀或破坏运行。
5. 在 Intel(若支持)与 Apple Silicon 真机上分别验证 keychain 升级连续性、摄像头/麦克风 TCC、休眠唤醒、Retina、多显示器和 WebEngine helper 签名。
### 7.7 常见打包坑清单
- **Qt binding 混装**:同环境存在 PyQt/PySide2 或系统 Qthook 收到冲突库。解决:干净 venv、只安装一种 binding、锁版本。
- **误用 onefile**:启动慢、helper/沙箱/签名问题更难复现。解决:正式版固定 onedir。
- **动态 import 未分析**:业务模块或 WebEngine 在 registry 中字符串加载。解决:显式 import 或最小 hiddenimports,并用 frozen smoke test 覆盖。
- **资源路径错误**:开发机相对路径可用,安装后 CWD 变化。解决:`importlib.resources`/qrc;用户数据用 QStandardPaths。
- **过度裁剪 Qt**:删除 `.pak`、ICU、snapshot、locale、platform plugin 后只在某些机器崩。解决:先保留 hook 输出,按真实清单与测试有证据地裁剪。
- **macOS 签名次序/entitlements 错**QtWebEngineProcess 启动即退出或 TCC 不弹窗。解决:嵌套 helper 真机测试、由内到外签、notarize/staple。
- **归档破坏 symlink**`.app` 体积暴涨或 framework 无法加载。解决:DMG/ditto 或明确保留 symlink 的归档工具。
- **GPU/远程桌面差异**WebEngine 黑屏。不要默认全局 `--disable-gpu`;收集诊断后提供经验证的软件渲染或外部浏览器 fallback。
- **codec 误判**:开发机能播 H.264,发布 wheel 不能播。解决:把真实媒体矩阵列入 artifact 验收和许可评审。
- **杀软与信誉**:大量 DLL/helper 或未签名 nightly 被拦截。解决:正式证书、时间戳、稳定 installer identity、干净 VM/主流安全软件测试。
- **升级破坏 keychain/数据**bundle id、签名 identity 或 schema 不稳定。解决:这些值从首版固定,migration 支持备份与回滚。
## 8. 安全与隐私基线
- TLS 校验永远开启。企业私有 CA 应通过受管安装进入系统 trust store;不得用 `verify=False`。如需兼容企业代理,可用 `truststore` 接入 OS 证书库并做专项测试。
- 本地配置不能提供任意 production host 重定向;敏感 token 永远不进入 URL query、clipboard、日志、analytics 或 crash report。
- HTML 病历优先用受限 `QTextBrowser`/原生富文本展示并在服务端净化;不要因为“要展示 HTML”就引入 WebEngine。任何外链由用户确认后交给系统浏览器。
- 限制剪贴板和通知中的患者信息;自动锁定后遮蔽窗口内容。截图阻止在跨平台上不可靠,不能作为合规控制。
- 所有高风险业务写操作由服务端保留不可抵赖审计;客户端日志只记录事件名、耗时、状态、request id 和脱敏技术上下文。
- 发布前完成依赖 SBOM、许可证清单和漏洞扫描。PySide6 采用 LGPLv3/GPLv3 或商业许可,QtWebEngine/Chromium/codec 还包含额外 notices;由法务确认采用的 Qt 许可与分发义务,安装包附第三方 notices。
- 自动升级若后续实现,更新 manifest 必须签名,制品必须校验 SHA-256 与平台签名,并支持回滚;首版宁可用已签名安装器提示升级,也不要执行未签名下载内容。
## 9. 测试与发布门槛
### 9.1 自动化测试分层
- **unit**:权限 guard、会话状态机、单飞 refresh、重试白名单、错误映射、缓存 TTL、加解密、outbox 状态和冲突决策。用 fake clock/random/secret store,保证确定性。
- **API contract**:以 OpenAPI schema 或后端 mock 验证字段、错误码、分页、时间和幂等语义;`respx` 模拟超时、断流、401 并发、429/Retry-After、5xx 和结果未知。
- **integration**:临时 SQLite + fake keyring + staging API,覆盖 migration、损坏缓存、磁盘满、退出清理、租户切换和代理/私有 CA。
- **UI**`pytest-qt` 验证路由权限、loading/empty/error/offline、键盘导航、取消和 late response;不要用脆弱的像素级截图替代行为断言。
- **frozen artifact**Windows/macOS 各自安装后运行 `--self-check`,启动主窗口、登录 mock/staging、访问资源、写用户目录、升级 migration、卸载。
- **视频真机**:摄像头/麦克风授权与拒绝、无设备、设备热插拔、回声设备、弱网/断网重连、休眠唤醒、屏幕共享、录播 codec、结束后权限与 Cookie 清理。
重点故障用例包括:十个请求同时 401、提交后响应丢失、服务器版本冲突、系统时钟偏差、刷新时退出、缓存被截断、keychain 被锁、两实例竞争、磁盘只读、API 维护、WebEngine helper 被杀、证书过期。测试数据必须是合成数据。
### 9.2 CI 矩阵与质量门槛
PR 阶段可并行运行 Windows x64 和 macOS arm64 的 lint/type/unit/UI headless 测试。release tag 阶段在原生 runner 生成制品并执行:
1. `ruff``mypy`、unit/contract/integration 测试全绿,覆盖率阈值重点约束 core 状态机而非 UI 行数。
2. 依赖 lock、SBOM、license、`pip-audit` 无未批准的高危项。
3. 两端 frozen self-check 与安装/升级 smoke 通过。
4. 签名、公证、hash、版本资源和 update channel 验证通过。
5. video profile 在目标硬件的人工/自动验收清单签字;core profile 证明不会意外收集 QtWebEngine。
6. staging 完成登录、权限变更、患者查询、一个低风险写操作、一个高风险确认、登出清理和离线恢复闭环。
## 10. 实施顺序与验收里程碑
### M0:契约和风险封板(约 3–5 天)
- 获取 OpenAPI/认证/权限/RTC/对象存储契约;形成 endpoint 与权限矩阵。
- 在 Windows/macOS 原型中用 QtWebEngine 打开专用测试页,验证 TRTC/WebRTC、设备权限和目标 codec。
- 决定首发是 core、video 还是“core + 外部浏览器”。
**退出条件**API base 和 auth 不再是假设;视频路线有真实 PoC,而非只证明网页能打开。
### M1:可发布骨架
- 完成 bootstrap、配置校验、日志/路径、API client、SessionManager、PermissionGuard、shell 和统一状态组件。
- mock server 下完成登录、`/me`、权限菜单、401 single-flight refresh、登出清理。
- 两端 onedir unsigned nightly 可安装并通过 self-check。
### M2:业务纵切
- 先选一个完整纵切(例如预约 -> 患者概要 -> 诊间记录草稿),按 module 结构贯穿 UI、service、API、权限和测试。
- 再并行扩展患者、病历、处方、随访、聊天;高风险操作必须有后端幂等/审计。
### M3:离线与视频
- 实现加密 cache/outbox、冲突页、断网/恢复和数据清理。
- video adapter、受限 profile、一次性 ticket、权限撤销和外部浏览器 fallback 完成。
### M4:生产发布
- 锁依赖和 runner image,完成 Windows Authenticode、macOS Developer ID/notarization、SBOM/许可证。
- 干净 VM/真机、企业代理、非管理员、升级/回滚、视频设备矩阵全部通过。
## 11. 最终架构决策摘要
1. 业务 UI 用原生 Qt WidgetsQtWebEngine 是隔离的视频实现细节,不是应用架构。
2. 后端契约、服务端授权和服务端审计是权威;桌面端只做强类型 adapter、体验 guard 和安全状态管理。
3. access token 仅在内存,长期凭证进 OS keychain;敏感离线数据加密且按用户/租户隔离。
4. 高风险医疗写操作离线时只能保存草稿,恢复后由医生确认;普通 outbox 依赖幂等键和乐观锁。
5. 发布固定 PyInstaller onedir、平台原生构建、签名和 artifact 级测试;不交叉编译,不依赖开发机“能跑”。
6. production URL、SDK secret、对象存储密钥和 RTC 签名密钥都不能硬编码进客户端。
## 参考资料
- [Qt for Python package details](https://doc.qt.io/qtforpython-6.10/package_details.html)Essentials/Addons 拆分和 wheel 内容。
- [Qt for Python 与 PyInstaller](https://doc.qt.io/qtforpython-6.10/deployment/deployment-pyinstaller.html):官方 PyInstaller 基础部署说明。
- [Qt WebEngine 部署](https://doc.qt.io/qt-6/qtwebengine-deploying.html)helper、resources、locales、macOS entitlements 等必需项。
- [Qt WebEngine features](https://doc.qt.io/qt-6/qtwebengine-features.html)WebRTC/媒体能力及专有 codec 许可提醒。
- [PyInstaller macOS multi-arch 与签名](https://pyinstaller.org/en/stable/feature-notes.html):架构 slice 和 codesign 行为。
- [PyInstaller symlink/common pitfalls](https://pyinstaller.org/en/stable/common-issues-and-pitfalls.html)PyInstaller 6+ POSIX bundle 的 symlink 分发要求。
+320
View File
@@ -0,0 +1,320 @@
# 医生工作站最终 parity 审计
审计日期:2026-08-10
审计方式:只读源码复核 + 纯本地测试;未修改业务源码。
结论基准:`D:\web\zyt\admin\src\views` 的实际实现优先于既有 `research/parity_*.md`
## 1. 结论
当前医生桌面整体判定为 **PARTIAL**,不建议在修复 P0 前作为后台同型版本发布。
- **P03 项**
1. 接诊附件没有上传步骤,本机绝对路径被作为附件 URL 发给服务端。
2. 问诊开方在当前 appointment 无处方或查询失败时按 diagnosis 回退,可显示、编辑或作废另一挂号的处方。
3. “我的患者”预约把 `source_patient_id` 优先写入 `patient_id`,而管理端该端点明确要求诊单 ID,可能预约到错误上下文或被后端拒绝。
- **Repository 方法存在性:EXACT**。当前五页及相关对话框实际调用的 canonical 方法均同时存在于 Protocol、Remote、Demo,未发现不存在或拼错的方法名;兼容别名均能解析到有效方法。
- **动态菜单:EXACT(限定五个受支持页面)**。非 Demo 会话使用服务端菜单,遵守显示、禁用、排序、路由及 canonical permission;未把未实现的后台运营页面伪装成本地入口。
- **医生活跃视频 eligibilityEXACT;权限语义:PARTIAL**。状态与三个业务 ID 已对齐,但问诊页错误复用了小程序二维码权限 `tcm.diagnosis/videoQr`
- 既有三份 parity 文档明显早于本轮实现,不能直接当作当前验收结果;本报告已重新逐项分类。
本次执行:
```text
.venv\Scripts\python.exe -m pytest
110 passed in 1.33s
```
全绿不能覆盖本报告的 P0`D:\web\zyt\app\tests\test_reception_parity_ui.py:263-306` 当前把本地绝对路径进入备注 payload 当成正确行为;`D:\web\zyt\app\tests\test_consultations_parity_ui.py:241-264` 当前把 appointment miss 后回退 diagnosis 旧处方当成正确行为。这两组测试需要随 P0 修复反向改写。
## 2. 范围与判定规则
审计范围:
- Python 五页:
- `D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py`
- `D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py`
- `D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py`
- `D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py`
- `D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py`
- 与五页直接相连的 `ui/dialogs/diagnosis.py``ui/dialogs/prescription.py``ui/widgets.py``ui/shell.py``core/models.py``services/*`
- 管理端唯一事实基准:`D:\web\zyt\admin\src\views`,必要时追到其直接使用的 `src/components``src/api`
明确排除:企业微信后台运营、转化/统计后台、医助专属旁观、小程序二维码本身、其他角色专属批量运营能力。医助旁观因此判为 **N/A / intentionally excluded**,不计 MISSING。
判定含义:
- **EXACT**:端点、DTO、可见字段/筛选/动作、权限及状态门槛在医生桌面范围内等价。
- **PARTIAL**:主链存在,但字段、上下文、权限、状态门槛、分页或异步一致性不完整。
- **MISSING**:管理端医生主链存在,而 Python 没有可用实现。
## 3. 页面与横切能力总表
| 范围 | 判定 | 已对齐 | 主要偏差 |
|---|---|---|---|
| 接诊台 | **PARTIAL** | 今日范围;等待/过号状态 1/4;详情、通知医助、备注读取/删除、完成接诊;直呼 ID | 附件真实上传 **MISSING/P0**;固定只取前 50 条;附件无预览/打开 |
| 我的患者 | **PARTIAL** | 患者/订单/面诊进度三工作区;订单筛选、summary/scope、字段、状态动作矩阵与 canonical permissions | 预约上下文 **P0**;预约表单、诊单详情、上下文订单不完整;写操作竞态 |
| 我的问诊 | **PARTIAL** | 列表、详情/编辑/删除端点;字典/医助;开方/作废入口;视频状态与 ID | appointment 处方回退 **P0**;诊单编辑只覆盖小字段子集;部分医生行操作/筛选缺失;视频权限码错误 |
| 我的处方库 | 功能 **EXACT** / 运行时 **PARTIAL** | 筛选、15 条分页、字段、查看/新增/编辑/删除、远程药材、校验、owner/root/role 行条件;`disable_edit` 语义正确 | 通用权限 helper 接受非 canonical 别名;worker 读 Qt 控件;加载中 refresh 被丢弃 |
| 已开处方 | **PARTIAL** | 完整筛选与主要字段;详情、CRUD、患者修正、审核、作废、订单创建/查看、A4 打印、PDF;状态动作矩阵 | 诊单详情授权边界;建单支付单竞态;诊单上下文订单与部分业务字段;重复药材校验 |
| Repository Protocol/Remote/Demo 方法 | **EXACT** | 五页实际 canonical 调用全部存在且签名兼容 | 上传素材方法 **MISSING**Demo 问诊筛选语义不完整 |
| 动态菜单 | **EXACT** | 使用服务端 menu;显示/禁用/排序/路由/权限;只注册受支持页面 | 无发布阻断偏差 |
| 视频端点与 eligibility | **PARTIAL** | ticket/start/bind/end 方法存在;问诊 `has_appointment && status==1`;接诊今日状态域;ID 分离 | 原生直呼错误复用 `videoQr` 权限;服务端仍须最终复核当前状态/归属 |
## 4. P0 发布阻断
### P0-1 接诊附件不是上传,而是泄漏并保存本机路径 — MISSING
管理端合同是严格的两阶段流程:
1. `POST /upload/image``POST /upload/file`multipart 字段为 `file``cid=0`,返回服务器 `uri/url``D:\web\zyt\admin\src\api\file.ts:7-33`
2. 素材选择器只把上传成功的服务器地址交给业务组件:`D:\web\zyt\admin\src\components\material\picker.vue:260-283`
3. 再调用 `POST /doctor.appointment/addDoctorNote`payload 为 `{diagnosis_id, content?, tongue_images?: string[], report_files?: string[]}``D:\web\zyt\admin\src\views\patient\reception\components\NoteTimeline.vue:195-227`
Python 只是用文件选择器保存 `Path(raw_path)`,界面虽写“待上传”,但没有任何上传请求:`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:1291-1346`。保存时本机路径被原样放进 `tongue_images/report_files``D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:1358-1393`Remote repository 直接 JSON 透传:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:709-730``ApiClient` 只有 JSON 请求并固定 `Content-Type: application/json`,没有 multipart`D:\web\zyt\app\src\doctor_workstation\services\api_client.py:108-180,211-220`
影响:服务端会收到 `C:\Users\...\report.pdf` 一类不可共享路径,其他终端无法访问,同时泄漏本机目录。已有 notes 读取与删除端点正确,但不能补救创建时的坏数据:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:732-759`
可执行修复:
1.`ApiClient` 增加独立 multipart 方法,不沿用 JSON `Content-Type`,由 httpx 生成 boundary。
2. 在 repository 增加 `upload_material(path, material_type, cid=0)`;图片走 `/upload/image`,报告走 `/upload/file`
3. 所有素材上传成功后才调用 `addDoctorNote`;最终 DTO 必须拒绝盘符路径、UNC、`file://`
4. 部分失败不提交本地路径,逐文件提示;必要时清理已上传但未关联素材。
5.`D:\web\zyt\app\tests\test_reception_parity_ui.py:263-306` 改成 multipart + 最终 JSON 双阶段测试,并断言最终 JSON 只含服务器地址。
### P0-2 当前 appointment 处方 miss/异常后回退 diagnosis 旧处方 — PARTIAL
管理端从问诊行传 `diagnosis_id=row.id``appointment_id=row.appointment_id``D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue:1685-1694`。处方组件只以 `GET /tcm.prescription/getByAppointment {appointment_id}` 判断当前挂号是否已有处方;空结果进入当前挂号的新建流程,再用 `GET /tcm.diagnosis/detail {id}` 生成病历快照:`D:\web\zyt\admin\src\components\tcm-prescription\index.vue:1829-1907`。保存明确发送 `diagnosis_id``appointment_id``case_record``D:\web\zyt\admin\src\components\tcm-prescription\index.vue:2219-2268`
Python 已有正确的两个 repository 端点:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:994-1016`。但当前 appointment 查询为空,甚至 401/403/网络异常时,都会继续按 diagnosis 查询并选择“最新一张”:`D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:1179-1216`。选中的 fallback 随后可被展示或作废:`D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:1250-1283,1350-1383`。Demo 的 `get_prescription_by_appointment` 也仅按 diagnosis 返回首张,忽略 appointment`D:\web\zyt\app\src\doctor_workstation\services\mock_repository.py:752-760`
同时,新建只从列表行拼少量患者字段:`D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:1300-1347`;编辑器 payload 没有完整 round-trip `appointment_id/case_record``D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:1304-1370`;模型也没有正式保留这两个字段:`D:\web\zyt\app\src\doctor_workstation\core\models.py:580-646,728-771`
影响:同一 diagnosis 的 appointment A 有处方、B 无处方时,B 可误看、误编辑或误作废 A;网络/权限错误也被错误解释为“允许回退”。
可执行修复:
1. `appointment_id > 0` 时只允许 `getByAppointment` 决定当前处方;空结果新建 B,异常 fail-closed 并提示,绝不按 diagnosis 自动回退。
2. diagnosis 级历史只能做独立只读历史列表,不能成为查看/编辑/作废目标选择器。
3. 新建前调用 `get_diagnosis_detail`,把不可变 `case_record``diagnosis_id + appointment_id` 一起提交。
4.`Prescription` 增加并完整序列化 `appointment_id``case_record`Demo 按 appointment 精确匹配。
5. 反向改写 `D:\web\zyt\app\tests\test_consultations_parity_ui.py:241-264`:A 有处方、B 无处方时 B 必须新建 B;查询异常不得回退或作废 A。
### P0-3 “我的患者”预约使用了错误的 `patient_id` 语义 — PARTIAL
管理端在打开预约框时刻意把 `patient_id` 覆盖成 `diagnosis_id || id``D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:513-518`。预约组件把该值设为 `patientInfo.id``D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue:392-402`,并提交到 `POST /firstvisit.myPatient/createAppointment``D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue:701-719`
Python payload 虽计算了 `diagnosis_id`,却让 `patient_id` 优先取 `source_patient_id``D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:266-279`。Remote 不作转换,直接把整个 body 发给同一端点:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:1318-1325`。管理端仅在视频 ticket 中使用 `source_patient_id`,同时保留 diagnosis ID,证明两者不是同一语义:`D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:642-661`
影响:只要列表同时返回 `diagnosis_id``source_patient_id`Python 就与实际端点 DTO 不同,可能绑定错误患者上下文或被后端拒绝。
可执行修复:
1. `firstvisit.myPatient/createAppointment``patient_id` 固定使用当前 `diagnosis_id`,不要用 `source_patient_id`
2. repository 为该端点定义显式 DTO,避免“完整字典透传”隐藏字段语义错误。
3. 增加 `diagnosis_id != source_patient_id` 的合同测试,断言 body 的 `patient_id == diagnosis_id`
4. 视频 ticket 继续使用独立的真实 patient ID,不把本修复扩散到视频 DTO。
## 5. P1 高优先级缺口
### P1-1 诊单详情/编辑只是字段子集,且未落实隐私权限 — PARTIAL
管理端根据 `tcm.diagnosis/phonePlain` 决定明文手机号,并在无权时先对手机号、身份证脱敏:`D:\web\zyt\admin\src\views\tcm\diagnosis\edit.vue:822-856,1263-1274`。Python `DiagnosisDialog` 构造函数不接 permissions`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\diagnosis.py:53-65`,直接渲染电话等字段:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\diagnosis.py:317-352`。编辑仅有 9 个文本字段:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\diagnosis.py:150-174`,保存也只回传这组子集:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\diagnosis.py:446-464`
修复:把 permission set 传入对话框;明文手机严格要求 `tcm.diagnosis/phonePlain`;身份证同样 fail-closed;按管理端 DTO 补齐患者基本信息、生命体征、病史/四诊/诊断字段及电话/身份证唯一性检查;对后端声明不可编辑的基础字段锁定。
### P1-2 患者预约表单缺排班、号源和关键 DTO 字段 — PARTIAL
管理端加载医生列表、未来 7 天排班、可用时间段,并检查当天重复预约:`D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue:423-447,495-625`;提交要求 `appointment_type``channel_source``channel_source_detail``D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue:683-719`。Python 只提供自由输入医生 ID、任意日期/时间/period/remarkpayload 缺上述字段:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:220-279`
修复:用后端医生/排班/可用时间段数据驱动选择器;限制日期和可预约 slot;增加 appointment type、channel source/detail;提交前与服务端均做重复预约校验。与 P0-3 一起建立 exact DTO 测试。
### P1-3 诊单上下文订单缺失,支付/退款字段缩水 — PARTIAL
管理端诊单只读页按 `tcm.diagnosis/patientOrders` 显示订单,并请求 `GET /tcm.prescriptionOrder/lists {context_diagnosis_id, patient_id, scene:'diagnosis_edit'}``D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue:74-88``D:\web\zyt\admin\src\views\tcm\diagnosis\components\PatientOrderList.vue:118-179`。Python `DiagnosisDialog` 只有病历、备注、挂号、指派四个 tab:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\diagnosis.py:53-99,381-386`,虽然 repository 已有订单列表端点:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:1018-1029`
此外,Python 补支付单把 `pay_remark``completion_request=0``pay_create_type=fubei` 写死:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:2048-2076`,而管理端由用户明确选择:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\OrderActionHost.vue:573-587`。Python 强制填写退款金额:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:2108-2131`;管理端允许省略:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\OrderActionHost.vue:610-621`
修复:给 `DiagnosisDialog` 传 permissions,按 canonical `tcm.diagnosis/patientOrders` 增加只读订单 tab并使用 exact context DTO;补支付单暴露三项业务字段;退款增加“不指定金额”。
### P1-4 处方建单可在门槛未加载时提交,并可混入旧诊单支付单 — PARTIAL
Python 把 `deposit_min_amount` 初始化为 0,创建按钮立即可用:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:1793-1827``paidPayOrders` 只在初始化时异步加载,没有 generation/diagnosis ID 回验:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:1928-1979`diagnosis ID 又可编辑。最终 payload 可把新 diagnosis ID 与旧 `pay_order_ids/deposit_min_amount` 组合:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:1989-2054`。管理端在诊单变化时清空并重载支付单:`D:\web\zyt\admin\src\views\consumer\prescription\index.vue:2307-2409`
修复:来自处方的 diagnosis ID 设为只读,或用 `/tcm.diagnosis/searchPatient` 受控选择器;变化时立即清空支付单并禁提交;捕获 `(generation, diagnosis_id)`,仅应用同上下文响应;加载成功后才启用保存;服务端再校验处方、diagnosis、每个支付单的归属和定金门槛。
### P1-5 患者页写操作 latest-wins 会吞掉已执行 mutation 的回调 — PARTIAL
`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:1696-1728` 为所有写操作共用 `_action_generation`,但未串行化或禁用其他动作。动作 B 启动后,动作 A 即使已在服务端成功或失败,其回调也会被丢弃,可能不刷新、不提示,界面与服务端不一致。
修复:按 operation/entity 维护 pending token,或串行化并禁用动作;任何成功 mutation 都必须触发最终一致性 refresh;generation 只能决定消息落点,不能取消写后 reconcile。补“两个不同订单动作乱序完成”的测试。
### P1-6 QRunnable 工作线程读取 Qt 控件 — PARTIAL
`run_async` 的函数实际在工作线程执行:`D:\web\zyt\app\src\doctor_workstation\ui\widgets.py:257-319`。以下 worker lambda 仍调用 `.text()``.currentData()` 或 QWidget 属性:
- 接诊状态:`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:502-525`
- 患者列表:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:825-845`
- 患者订单:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:1114-1129`
- 处方库:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:220-238`
- 模板导入:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:736-755`
修复:所有控件值在 GUI 线程先快照成不可变 query/page DTOworker 只执行 `repository.method(**query)`。这样既满足 Qt 线程约束,也保证 generation 对应的条件不会在执行中变化。
### P1-7 通用 permission helper 会把非 canonical 点号别名当成授权 — PARTIAL
canonical 权限常量采用 slash 形式,例如 `wcf.prescription/add``cf.prescription/edit` 对应代码定义:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:33-52`。但通用 `has_permission` 会生成 slash/dot 互换别名:`D:\web\zyt\app\src\doctor_workstation\ui\widgets.py:135-180`,因此仅持有 `cf.prescription.edit` 也可能通过 `cf.prescription/edit` 门槛。处方库和已开处方使用了该 helper:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:101-161``D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:282-391,809-815`。shell、患者、问诊已采用 exact/wildcard 语义:`D:\web\zyt\app\src\doctor_workstation\ui\shell.py:109-142``D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:110-143`
修复:全项目统一 exact + `*`/`prefix/*` helper;删除点/斜杠互换;增加“只有 alias grant 时按钮必须隐藏且 handler 必须拒绝”的测试。服务端权限仍是最终防线。
### P1-8 原生医生直呼错误复用小程序二维码权限 — PARTIAL
医生直呼状态已经与管理端一致:管理端仅在 `has_appointment && appointment_status===1` 时启用:`D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue:1778-1780`Python 同样分离并复核 appointment/patient/diagnosis ID`D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:142-183,1094-1122,1413-1427`。接诊台的今日状态 1/4 和 ID 也正确:`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:1542-1564``D:\web\zyt\app\src\doctor_workstation\services\repository.py:640-692`
偏差是问诊原生直呼受 `tcm.diagnosis/videoQr` 控制:`D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:625-630,1413-1415`;管理端该权限只保护“小程序视频二维码”:`D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue:390-391`。这还造成接诊台和问诊页对同一原生直呼的授权不一致。
修复:与后端确认独立原生直呼 permission(例如 `tcm.diagnosis/startCall`)并让两入口统一;若没有独立 grant,则不复用 `videoQr`,由 call endpoint 的后端授权兜底。ticket/start/bind/end 的服务端必须再次验证 appointment 当前状态及 diagnosis/patient 归属。医助 `watchCall` 继续排除。
### P1-9 接诊队列固定前 50 条,无继续加载 — PARTIAL
管理端每页 15 条并持续加载,同时维护其他队列计数:`D:\web\zyt\admin\src\views\patient\reception\index.vue:213,296-379`。Python 每次只请求 `page_no=1,page_size=50``D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:502-525`,没有分页或 infinite scroll。忙时第 51 位及以后患者不可达。
修复:按管理端实现 `page_no/page_size=15` 累加加载,并以 total 判定是否继续;切 tab/搜索重置页码和 items;用 generation + pending refresh 保证旧页不污染新筛选。
### P1-10 已开处方可打开诊单详情,但没有诊单权限门槛 — PARTIAL
处方页无条件给详情对话框启用诊单入口:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:633-664`,处方详情对话框据此显示按钮:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:1641-1669`。这条数据入口未检查 `tcm.diagnosis/readonlyDetail` 或产品定义的等价授权。
修复:显式传入 permission set;按钮可见与 handler 双重校验 canonical 诊单只读权限;无权限时不发 `get_diagnosis_detail`。补只有 `cf.prescription/read` 而无诊单权限的拒绝测试。
## 6. Endpoint、DTO 与动作复核
| 业务 | 管理端/服务端合同 | Python | 判定 |
|---|---|---|---|
| 接诊队列 | `GET doctor.appointment/lists``status,start_date,end_date,page_no,page_size,patient_name` | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:640-692`;今日和 1/4 强约束 | DTO **EXACT**;分页 **PARTIAL** |
| 接诊详情/完成 | `detail``doctorNotify``completeAppointment` | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:694-765` | **EXACT** |
| 医生备注 | `doctorNotes/addDoctorNote/deleteDoctorNoteImage`;素材 URL 先上传 | 读/增/删端点存在,但缺 `/upload/image|file` | **MISSING/P0** |
| 我的患者列表 | `GET firstvisit.myPatient/lists`keyword/status/date/page | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:1088-1102` | **EXACT** |
| 患者订单/进度 | `orders``faceToFaceProgress` + scope/summary | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:1104-1128` | **EXACT** |
| 患者订单动作 | detail/edit、两类审核/撤销、支付、物流、完成、退款、撤回、上传药房 | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:1130-1287`;状态矩阵 `D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:1211-1289` | 主链 **EXACT**;支付/退款表单 **PARTIAL** |
| 患者预约 | `POST firstvisit.myPatient/createAppointment``patient_id` 实为当前 diagnosis ID,另含医生/日期/时间/type/channel | Remote `D:\web\zyt\app\src\doctor_workstation\services\repository.py:1318-1325` 透传缩水且错误 DTO | **PARTIAL/P0** |
| 问诊列表/诊单 CRUD | `tcm.diagnosis/lists|detail|add|edit|delete` | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:1339-1418` | endpoint **EXACT**UI 字段 **PARTIAL** |
| appointment 处方上下文 | `getByAppointment`;空则当前 appointment 新建;保存 diagnosis/appointment/case_record | 端点存在,但错误 diagnosis fallback | **PARTIAL/P0** |
| 处方库 | `tcm.prescriptionLibrary/lists|detail|add|edit|delete``doctor.medicine/lists` | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:766-864` | **EXACT** |
| 已开处方 | `tcm.prescription/lists|detail|add|edit|delete|patchPatient|audit|void` | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:866-1016` | endpoint/状态动作 **EXACT**;上下文/权限 **PARTIAL** |
| 处方订单 | lists/detail/create/paidPayOrders | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:1018-1080` | endpoint **EXACT**;建单异步 **PARTIAL** |
| 视频 | `getCallSignature/startCall/bindCallRoom/endCall`IDs 分离 | `D:\web\zyt\app\src\doctor_workstation\services\repository.py:1575-1615` | endpoint/eligibility **EXACT**permission **PARTIAL** |
## 7. 筛选、字段、分页和行状态门槛
### 7.1 接诊台
- **EXACT**:今日 `start_date=end_date`、等待/过号状态 1/4、姓名筛选、选中患者详情与完成前重取详情。Python:`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:488-558,591-750,1474-1529`
- **PARTIAL**:页面固定前 50 条;备注附件只能显示名称/删除,无管理端的图片预览/报告打开。Python:`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:1091-1142`;管理端:`D:\web\zyt\admin\src\views\patient\reception\components\NoteTimeline.vue:52-101`
### 7.2 我的患者
- **EXACT**:患者列表 filters、page size 15、summary/scope;订单 keyword、处方审核、支付审核、履约状态、日期与分页;订单列和 action matrix。Python`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:547-688,825-874,913-1289,1292-1595`。管理端动作基准:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\order-actions.ts:32-124`
- **PARTIAL**:预约和诊单详情/编辑字段;诊单上下文订单;支付/退款输入;写操作回调竞态。
### 7.3 我的问诊
- **EXACT**:核心列表/详情 CRUD endpoints、canonical `tcm.diagnosis/add|edit|delete|readonlyDetail|kaifang`、视频 eligibility 与 ID。
- **PARTIAL**Python filters/columns 位于 `D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:649-660,875-904`;管理端基准在 `D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue:191-341,775-798,878-887`。Python 发出管理端不存在的 `consultation_type`,缺 doctor-relevant 的未接诊天数排序;预约、补身份证等管理端行入口没有在本页呈现。指派/医助专属动作不因本页缺失计发布阻断,因为它们不是医生桌面主链或已在患者页提供。
- **P0**:处方上下文不得按 diagnosis 自动选最近处方。
### 7.4 我的处方库
- **EXACT**:处方名/剂型/公开范围筛选、15 条分页、处方名/剂型/功效/归属/创建人/时间字段、只读/新增/编辑/删除、远程药材选择、剂量校验、owner/root/role(0/3) 行条件。Python`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:106-193,267-376`;管理端:`D:\web\zyt\admin\src\views\consumer\prescription\list.vue:1-111,260-301,333-390`
- **EXACT**`disable_edit` 是导入后药材行的保存锁,不是“处方库模板禁止编辑”的行权限。Python:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:554-585`
- **PARTIAL**canonical permission alias 与异步控件读取。
### 7.5 已开处方
- **EXACT**:SN、患者、审核状态、来源、日期、医生筛选;15 条分页;详情、CRUD、患者修正、审核通过/驳回备注、作废、订单创建/列表、A4 打印、PDF。Python`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:297-847``D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:955-1715`
- **EXACT**:编辑/删除/审核/患者修正/建单的行状态门槛与管理端 action matrix 等价。Python`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:569-577,799-841`;管理端:`D:\web\zyt\admin\src\views\consumer\prescription\index.vue:214-263,3154-3157`
- **PARTIAL**:编辑器没有管理端保存前的重复药材名拒绝。Python校验:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:1397-1417`;管理端:`D:\web\zyt\admin\src\components\tcm-prescription\index.vue:2228-2231`。处方建单及诊单详情权限另见 P1。
## 8. Repository 调用存在性审计
结论:**EXACT;没有发现 UI 调用不存在/错误命名的 canonical repository 方法。**
- 接诊别名最终映射到 `list_appointments/get_reception`notes、tracking、complete 均存在。
- 患者列表、订单、进度、详情、所有订单动作、预约/取消、指派/身份证均在 Protocol/Remote/Demo 存在。
- 问诊列表、字典、医助、诊单 CRUD、appointment/diagnosis 处方查询、开方/作废均存在。
- 处方库 CRUD、药材检索均存在。
- 已开处方 CRUD、患者修正、审核、作废、诊单详情、订单 CRUD/paid orders 均存在。
定义集中于 `D:\web\zyt\app\src\doctor_workstation\services\repository.py:56-425`Remote 实现在 `D:\web\zyt\app\src\doctor_workstation\services\repository.py:640-1615`Demo 实现在 `D:\web\zyt\app\src\doctor_workstation\services\mock_repository.py:97-1515``D:\web\zyt\app\src\doctor_workstation\services\demo_repository.py:1-5` 只是重导出 Demo 类。
兼容调用器 `D:\web\zyt\app\src\doctor_workstation\ui\widgets.py:183-248` 会静默删除未知 kwargs。当前没有因此丢掉必需字段,但这会掩盖未来拼写错误,判 **P2**:把允许删除的冗余键改为显式 adapter/allowlist,测试环境对其他未知键报错。
## 9. 动态菜单与 canonical permissions
### 动态菜单 — EXACT
- 支持页面注册表:`D:\web\zyt\app\src\doctor_workstation\ui\shell.py:41-106`
- 服务端节点 flatten、显示/禁用、排序:`D:\web\zyt\app\src\doctor_workstation\ui\shell.py:145-197`
- component/path 匹配和受支持页面解析:`D:\web\zyt\app\src\doctor_workstation\ui\shell.py:200-249`
- 非 Demo 会话取 menu 并解析:`D:\web\zyt\app\src\doctor_workstation\app.py:413-443``D:\web\zyt\app\src\doctor_workstation\ui\shell.py:264-301,436-460`
仅渲染本地已经实现的五个页面是本轮明确范围,不把其余 admin 路由判 MISSING。不存在“拿静态菜单覆盖后端菜单”的旧问题。
### Permissions — PARTIAL
患者、问诊、shell 使用 exact/wildcard;处方库和已开处方仍经通用 alias helper。所有按钮可见性还必须在 action handler 再检查同一 canonical 权限,不能只靠隐藏按钮。优先修复 P1-7 与 P1-10。
## 10. 异步与竞态审计
已正确做 generation/目标校验的主链包括:接诊队列与详情、患者列表/助手/订单详情、问诊列表/计数/字典选项/处方上下文、诊单对话框加载保存、药材搜索、模板列表、订单列表。
仍需处理:
| 优先级 | 问题 | 证据 | 修复 |
|---|---|---|---|
| P1 | 患者 mutations 共用 latest-wins generation | `D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:1696-1728` | 串行化或 per-operation token;所有成功写入都 reconcile |
| P1 | 处方建单支付单/定金无上下文 generation | `D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:1928-2054` | `(generation,diagnosis_id)` 回验;加载期间禁提交 |
| P1 | worker 直接读 QWidget | `D:\web\zyt\app\src\doctor_workstation\ui\widgets.py:257-319` 及 P1-6 列表 | GUI 线程快照不可变 query |
| P1 | 切换问诊行会 invalidate generation 但可能保留 busy | `D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:1094-1105,1234-1292` | invalidation 同时结束旧 busy,或 active token/cancel;确保新行按钮可恢复 |
| P2 | 处方库/处方列表 loading 时直接丢 refresh | `D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:220-238``D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:499-529` | pending refresh 或并发请求 + generation;快照 filters |
| P2 | 处方诊单详情、订单详情缺目标 ID generation | `D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:653-664``D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:2178-2196` | `_detail_generation` + target ID;加载期间禁重复点击 |
## 11. 低优先级与 Demo 差异
- **P2 / Demo filters**UI 发 `diagnosis_confirmed` 等筛选:`D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:875-904`Demo 只处理少数键且读成 `confirmed``D:\web\zyt\app\src\doctor_workstation\services\mock_repository.py:1243-1268`。Demo 字典除 `server_order` 外也为空:`D:\web\zyt\app\src\doctor_workstation\services\mock_repository.py:851-860`。补齐 UI 暴露的筛选和四组演示字典。
- **P2 / raw detail**:已开处方的订单列表详情是通用字段展示,管理端跳向完整订单路由。若桌面产品要求在本应用闭环,需实现 typed detail;否则应明确“只读摘要”范围。
- **P2 / refresh**:处方库与已开处方在请求过程中改筛选/翻页可能显示旧条件结果,见异步表。
## 12. 既有 parity 文档复核
### `parity_reception_consultations.md`
`D:\web\zyt\app\research\parity_reception_consultations.md:14-16,81,148-154` 中“未限定今日/队列详情竞态/视频状态错误/无附件 UI/无开方”等描述大多已经关闭。当前真实结论是:今日和状态 **EXACT**、详情竞态主要链路 **EXACT**、视频 eligibility **EXACT**、开方入口已存在;附件 UI 已存在但上传协议 **MISSING/P0**
### `parity_patients_permissions.md`
`D:\web\zyt\app\research\parity_patients_permissions.md:13-14,124-127,200-216` 中“订单工作区和动态菜单完全缺失”已关闭。当前三工作区、订单状态矩阵、summary/scope 和动态菜单均存在;仍开放的是预约 DTO/P0、诊单详情隐私、诊单上下文订单和写操作竞态。
### `parity_prescriptions.md`
`D:\web\zyt\app\research\parity_prescriptions.md:255-285,337-343` 把已开处方描述成只读,已过期。当前 `D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:253-393,588-847` 已有 CRUD、审核、患者修正、订单、打印/PDF。处方库旧文档的所有权 fail-open 也已修成缺 ID 默认拒绝:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:267-279`。仍开放的是 P0 处方上下文、P1 order async/权限/重复药材等。
## 13. 建议修复顺序与验收门槛
1. **先修 P0-1 附件上传**:没有 multipart 和服务器 URL 的实现不得开放附件提交。
2. **再修 P0-2 处方上下文**appointment authoritative、异常 fail-closed、完整 `case_record`;删除 diagnosis 自动 fallback。
3. **修 P0-3 患者预约 ID**exact DTO + diagnosis/source patient 分离测试。
4. **随后修权限和订单上下文**P1-1、P1-3、P1-4、P1-7、P1-8、P1-10。
5. **最后收口异步/分页**per-operation mutation、Qt query snapshot、接诊分页、pending refresh。
发布验收至少应新增以下反例:
- 本机盘符/UNC/`file://` 永远不能进入 `addDoctorNote` JSON。
- 同 diagnosis 两个 appointments 时,B 的 miss/异常永远不能展示或作废 A 的处方。
- `diagnosis_id != source_patient_id` 时,预约 body 的 `patient_id` 必须等于 diagnosis ID;视频 body 保持真实 patient ID。
- 只有点号 alias permission 时,slash canonical action 必须拒绝。
- 两个患者订单 mutation 乱序完成后,UI 必须最终与服务端一致。
- 诊单切换前返回的 paid-order 响应不能进入新诊单 payload,门槛未加载时不能提交。
- 超过 50 位的今日接诊队列仍可继续加载。
完成以上 P0 并替换两条错误预期测试后,才可把整体结论从 **PARTIAL** 提升到可发布候选。
+101
View File
@@ -0,0 +1,101 @@
# 医生工作站集成审查
审查时间:2026-08-10。范围为当前工作区中的组合根、登录到主壳链路、Remote/Demo repository、权限门控、退出/视频生命周期及 PyInstaller 入口。审查只读进行;除本报告外未修改项目文件,也未发送网络请求。
## 结论
入口链 `packaging/doctor_workstation.spec -> doctor_workstation/__main__.py -> app.main()``build_repository(..., verify=...)` 的构造签名已经对齐,Remote/Demo 的主要 CRUD 与通话方法也具有兼容签名。当前仍有 4 个高严重度和 6 个中严重度集成问题;其中浏览器视频模式目前不能真正发起通话,退出时也存在通话窗口失管和 GUI 阻塞风险。
## P1(高)
### 1. 浏览器视频模式没有把一次性通话上下文交给伴随页
- `src/doctor_workstation/video/window.py:312-326` 在服务端 `start_call` 后仅执行 `webbrowser.open(location.url)``VideoCallRequest` 中的 diagnosis、目标用户和 UserSig 均未交给浏览器。
- `video_companion/src/main.ts:208-236` 只有显式调用 `window.doctorCall.start(config)` 才会初始化 SDK 并呼叫患者,页面自身不获取票据;`video_companion/src/main.ts:281-289` 只是暴露 API 和发送 ready。
- `README.md:70-77` 又把 browser 声明为正式降级路径,并要求用一次性业务票据传递上下文,和当前实现不一致。
系统浏览器不存在 Qt WebChannel,且静态 URL 连 diagnosis_id 都没有,因此页面会一直停在“等待桌面端发起”,而后端通话记录已经开始。浏览器标签关闭也无法回告 Python,`end_call` 只能等显式退出应用。应使用后端签发、单次消费且短有效期的浏览器 handoff ticketURL 中不能放 UserSig),或受认证的本机 IPC;伴随页确认接收后再调用 `start_call`,并建立可观测的结束回调。
### 2. 通话 start/bind/end 的同步 HTTP 被直接放在 Qt GUI 线程执行
- `src/doctor_workstation/video/window.py:206-290` 直接调用 repository 的 `start_call``bind_call_room``end_call`
- browser 在 `src/doctor_workstation/video/window.py:312-320` 同步 startembedded 在 loadFinished 回调 `src/doctor_workstation/video/window.py:437-456` 同步 start,在 bridge/close 回调 `src/doctor_workstation/video/window.py:485-522` 同步 bind/end。
- Remote 实现最终执行同步 `httpx` POST`src/doctor_workstation/services/repository.py:377-402`),而配置超时可达 120 秒。
- 登出和进程退出又在主线程逐个 `call.close()``src/doctor_workstation/app.py:298-315``src/doctor_workstation/app.py:409-415`)。
弱网时打开、挂断、退出都会冻结整个界面;`end_call` 抛错时 embedded 的 `closeEvent` 甚至到不了 `event.accept()`。应把生命周期写操作放入受控 worker,并使用状态机保证单次执行;退出阶段设置短上限、记录未完成结束动作,同时先关闭媒体/UI,不能让网络请求阻塞 Qt 关闭事件。
### 3. 同一 diagnosis 的重复发起会覆盖通话句柄,导致退出后仍可能保留旧视频窗口
- `src/doctor_workstation/app.py:343-356` 没有 pending/active 去重,用户可在票据请求完成前重复发起。
- `src/doctor_workstation/app.py:390-396` 以 diagnosis_id 为唯一 key 直接覆盖旧句柄;旧窗口的 destroyed 回调还会无条件 `pop` 同一个 key,可能把较新的句柄移除。
- 登出/退出只关闭字典当前仍持有的值(`src/doctor_workstation/app.py:298-305``src/doctor_workstation/app.py:409-412`)。
结果是第一个窗口可能在切回登录页后继续持有摄像头/麦克风;反向关闭旧窗口也会让新窗口失去托管。应对 diagnosis 建立 pending/active 单飞,拒绝或先可靠关闭旧通话;销毁回调必须做对象身份判断后再移除。
### 4. WebEngine 对任意来源自动授予音视频权限,且登出没有隔离/清理 profile
- `src/doctor_workstation/video/window.py:381-390` 使用 `QWebEngineView` 的共享默认 profile,没有为单次通话建立隔离 profile。
- `src/doctor_workstation/video/window.py:405-435` 在授权回调中不校验请求 origin、当前页面 URL 或通话状态,只要 feature 名含 audio/video 就 grant。
- 没有自定义 `acceptNavigationRequest`/origin allowlist;关闭时 `src/doctor_workstation/video/window.py:519-522` 仅挂断,不撤销权限或清理 Cookie、cache、local storage。
初始 URL 虽经过 HTTPS 校验,但页面后续导航不受限制;同一进程内切换账号时 WebEngine 状态也可能复用。应使用每通话或每会话的 off-the-record profile、精确 origin/path allowlist,只在活跃通话且当前主文档来源匹配时授权,并在挂断/登出时撤销权限和清理页面/profile。
## P2(中)
### 5. API code=-1 没有接入应用级会话失效处理
- `src/doctor_workstation/services/api_client.py:277-278` 会抛出 `AuthenticationExpiredError`
- worker 只把异常交给页面自己的通用 on_error(`src/doctor_workstation/ui/widgets.py:262-293`);组合根只有用户主动点击时才执行 `_logout``src/doctor_workstation/app.py:298-315`)。
- 项目中除异常定义/抛出外没有消费 `AuthenticationExpiredError` 的代码。
token 过期后主壳仍展示已加载的患者数据和旧权限快照,各页面只显示错误,用户必须手动退出。应在统一 API/worker 边界发出 session-expired 事件,由 Controller 原子地停止轮询和视频、清 token、销毁 ShellWindow 并回到 LoginWindow。
### 6. 页面和动作权限使用过宽的 OR 兜底,并忽略后端 menu
- `src/doctor_workstation/services/repository.py:111-127` 已解析 `Session.menu`,但 `src/doctor_workstation/ui/shell.py:248-260` 只按硬编码 `NAVIGATION.permissions` 注册页面,menu 从未参与判断;这与管理端“无有效 menu 即 403”的已确认行为(`research/admin_audit.md:103-109`)不一致。
- 接诊页可仅凭 `doctor.appointment/reception` 显示,但页面首先请求 `doctor.appointment/lists``src/doctor_workstation/ui/shell.py:40-47``src/doctor_workstation/ui/pages/reception.py:347-364`)。患者页可仅凭 readonlyDetail 显示,却始终请求 firstvisit lists;问诊页可仅凭 appointment lists 显示,却始终请求 diagnosis lists`src/doctor_workstation/ui/shell.py:62-75`)。
- 通知医助和视频按钮又把基础 lists 当动作权限兜底(`src/doctor_workstation/ui/pages/reception.py:321-332``src/doctor_workstation/ui/pages/consultations.py:88-96`),所以缺少 videoQr/notifyAssistant 的账号仍看到按钮。
服务端仍是最终边界,但当前 UI 会显示必然 403 的页面/动作,也可能绕过管理员对 desktop 页面入口的隐藏意图。页面应同时受后端 menu/capability 和实际列表接口权限约束;动作只接受对应动作码或经过确认的同义码,不能用 lists 兜底。
### 7. 已开处方审核状态筛选在 UI 适配层被改成 Remote 不识别的字段
- UI 传入 status`src/doctor_workstation/ui/widgets.py:205-208` 将其改写为 `audit_status`
- Remote 只在收到 `status` 时转换成后端需要的 `audit_filter=pending|passed|rejected``src/doctor_workstation/services/repository.py:284-303`),因此实际请求会原样携带数值 `audit_status`
- Demo 特意兼容了 `audit_status``src/doctor_workstation/services/mock_repository.py:374-399`),所以演示验收不会暴露生产差异。
结果是生产环境的“待审核/已通过/已驳回”筛选可能无效或被后端拒绝。应只在 repository 内做一次 UI 值到 API DTO 的映射,并给 Remote 增加 request-parameter 合同测试。
### 8. 登录 token 在 Session 校验成功前落盘,失败路径不回滚
- `src/doctor_workstation/services/repository.py:82-90` 在调用 `auth.admin/mySelf` 前就设置并持久化 token。
- mySelf 网络失败、协议错误或 code=10 时 LoginWindow 只显示错误(`src/doctor_workstation/ui/login.py:385-390`),不会调用 repository.logout。
这会在用户从未进入有效 Session 的情况下留下内存和 keyring/JSON token,后续登录还会带着该 token 请求 login/account。应先把 token 暂存在内存,完整构建并校验 Session 后再持久化;任何异常都清理 client/token store。
### 9. “记住账号”未控制 TokenStore 中的账号落盘,且持久 token 没有启动恢复闭环
- `src/doctor_workstation/services/repository.py:87-88` 每次成功账号密码登录都把 account 传给 TokenStore,与复选框无关。
- TokenStore 会把 account 写入 JSON 元数据(`src/doctor_workstation/services/token_store.py:83-108`),而 LoginWindow 的复选框只增删 QSettings`src/doctor_workstation/ui/login.py:374-379`)。
- `ApplicationController.start()` 始终显示登录页(`src/doctor_workstation/app.py:172-176`),未调用已实现的 `restore_session`;普通关闭只 close client,不清 token`src/doctor_workstation/app.py:409-415`)。
因此取消“记住账号”仍会在磁盘留下账号;成功 token 则持续保存但下次启动完全不使用。应让 remember-account 明确控制所有账号元数据,并二选一:启动时安全验证/恢复 token,或不持久化并在关闭时清除。
### 10. 当前冻结产物落后于最新 companion,构建脚本也没有执行入口 smoke test
- 最新 `video_companion/dist/index.html:8` 引用 `index-le5ZH3pL.js`(包含 roomId/bindCallRoom 桥接),现有 `dist/DoctorWorkstation/_internal/video_companion_dist/index.html:8` 仍引用旧的 `index-BrQGJzsD.js`
- 应用已经提供 `--smoke-test``src/doctor_workstation/app.py:436-451`),但 Windows 构建只检查 helper/pak/index 文件存在(`scripts/build_windows.ps1:30-43`),macOS 同样只做静态文件和 codesign 检查(`scripts/build_macos.sh:22-37`),都不启动冻结入口。
所以当前 `dist/DoctorWorkstation` 不包含刚合并的 room binding;未来即使入口 import/bootstrap 失败,构建也可能仍打印成功。交付前应重新执行 PyInstaller,并在隔离用户目录、无真实网络的环境下运行冻结程序 `--smoke-test`,同时校验退出码和日志中无未捕获异常。
## 已核对且未发现签名断点
- `ApiClient(base_url, ..., verify=...)``build_repository(..., verify=...)``app.py` 调用一致。
- Remote/Demo 的 login 均返回 `Session`;列表、接诊、处方库 CRUD、患者/问诊列表及 `get_call_ticket/start_call/bind_call_room/end_call` 的 UI 所需参数基本对齐。
- PyInstaller spec 的入口、`src` pathex、resources 和 `video_companion_dist` 目标路径与 `resources.py``_MEIPASS` 查找规则一致。
- 最新 source companion 已产生 roomId 并调用 `bind_call_room`;本报告未把此前已修复的“问诊页交换 appointment/diagnosis ID”或“未绑定 room”列为当前问题。
## 验证限制
本轮没有重新执行 Python 测试:工作区 `.venv/Scripts/python.exe` 指向当前机器上不存在的 uv Python 基础解释器;为保持只读审查,没有重建虚拟环境。现有 `research/ui_acceptance.md` 记录的最近一次完整测试为 46 passed,但上述多项是未被现有单元测试覆盖的跨层行为。
+229
View File
@@ -0,0 +1,229 @@
# 患者列表、动态菜单与权限一致性审计
审计日期:2026-08-10
管理端唯一事实来源:`D:\web\zyt\admin\src\views\**`
Python 对照范围:`D:\web\zyt\app\src\doctor_workstation\**`
## 1. 结论摘要
| 审计项 | 结论 | 摘要 |
|---|---|---|
| “患者列表”真实业务页面识别 | **EXACT** | 医生工作站语义下的真实页面是 `first_visit/my_patients/index.vue`,不是同样含“患者列表”字样的 `doctor/progress.vue`。 |
| Python 患者列表基础查询 | **PARTIAL** | 使用同一“我的患者”业务语义、相同五种状态和服务端数据范围,但只实现了主列表的子集。 |
| 患者详情与历史关联 | **MISSING** | Python 没有进入诊单编辑/只读详情链,也没有详情请求;右侧内容直接取列表行及其 `raw`。 |
| 患者订单管理、面诊进度 | **MISSING** | 管理端同一路由内的两个完整工作区在 Python 中均不存在。 |
| 五模块主导航 | **PARTIAL** | Python 解析并保存 `mySelf.menu`,登录时只判断菜单非空;Shell 随后忽略菜单树,使用固定五项加扁平权限码。 |
| 按钮权限 | **PARTIAL** | 多数已有写操作有权限门禁,但存在 canonical 权限码偏差、额外别名放行、患者动作大量缺失;处方库所有者判断存在 fail-open。 |
| 医生角色/部门数据范围 | **PARTIAL** | 患者主列表的请求未注入任意医生 ID,保持服务端权威范围;但 Python 丢弃 `extend.scope`、汇总和 ownership 模式,且缺少订单/进度工作区。 |
判定口径:
- **EXACT**:当前证据范围内,业务入口、条件、权限或数据语义一致。
- **PARTIAL**:主链存在,但字段、筛选、动作、菜单约束或范围提示不完整/不一致。
- **MISSING**:管理端已存在的业务能力,在 Python 中未找到入口或调用链。
## 2. 证据边界
1. 本报告没有读取 `D:\web\zyt\admin\src\views` 之外的管理端文件。因此可以确认 Vue 页面实际引用的 API **函数名、参数和交互**,但不能读取 `@/api/**` 的实现来反推真实 HTTP URL。
2. `views/**` 内没有 `auth.admin/mySelf` 调用或该接口返回的真实菜单 JSON。五个主页面的 `paths/component/perms/sort/is_show/is_disable` 精确菜单记录无法从本次允许范围内恢复。
3. 管理端的菜单管理页证明动态菜单记录至少分别保存“路由路径”“组件路径”“权限字符”“是否显示”“菜单状态”:`D:\web\zyt\admin\src\views\permission\menu\edit.vue:41-94,125-150`;其中隐藏菜单仍可访问的语义写在 `D:\web\zyt\admin\src\views\permission\menu\edit.vue:135-137`。因此,不能把单一扁平权限码等同于完整路由记录。
4. 对服务端是否再次执行权限/所有权校验不作推断;本报告的 P0 是**客户端授权门禁一致性**问题。
## 3. “患者列表”真实页面识别
### 3.1 主页面:`first_visit/my_patients/index.vue`
确认依据:
- 页面直接声明“一诊 / 我的患者”,并说明“患者、挂号与诊单信息按当前角色和部门数据范围展示”:`D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:5-10`
- 同一路由内明确包含“患者列表 / 订单管理 / 面诊进度”三个工作区:`D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:15-20`
- 主表由 `myPatientLists` 驱动,并异步装载诊单编辑、预约、订单和进度组件:`D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:330-344,404-409`
结论:Python `PatientsPage` 应对标这一页面及其子工作区,而不应只对标某张通用患者/挂号表。
### 3.2 排除项:`doctor/progress.vue`
该文件虽在表头使用“患者列表”,但它是医生面诊进度看板:
- 页面要求先选择医生,再展示该医生挂号:`D:\web\zyt\admin\src\views\doctor\progress.vue:57-88`
- 组件名为 `doctorProgress`API 为 `doctorLists``appointmentLists``D:\web\zyt\admin\src\views\doctor\progress.vue:176-182`
- 它显式传 `role_id: 1` 拉医生,并用 `doctor_id + progress_board=1` 拉挂号;注释说明不按医生/医助角色收窄:`D:\web\zyt\admin\src\views\doctor\progress.vue:408-448,460`
因此该页面是跨医生进度看板,不是“我的患者”主数据页。把它的数据范围套到患者模块会错误扩大语义范围。
## 4. 管理端患者模块真实合同
### 4.1 主列表、筛选和服务端范围
| 能力 | 管理端证据 |
|---|---|
| 关键词 | 患者姓名、手机号、助理、医生:`D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:23-43` |
| 状态 | 未预约、待面诊、已完成、已过号:`D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:346-393` |
| 挂号日期 | 今日、明日、后天、近 7 天、近 30 天、自定义区间:`D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:46-69,395-401` |
| 汇总 | 今日/明日/后天预约人数来自 `pager.extend.summary/dates``D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:72-88,411-416` |
| 范围提示 | 顶部和表格显示 `pager.extend.scope.label``D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:90-96,417` |
| 列表字段 | 患者性别年龄/脱敏手机、归属助理、预约医生及状态、预约时间、复诊次数、确认信息、诊单日期:`D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:105-148` |
主列表 API 符号为 `myPatientLists`,调用参数为 `keyword/status_filter/start_date/end_date/page``D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:330-336,380-408`
### 4.2 行级动作与权限码
| 动作 | 管理端权限/条件 | 证据 |
|---|---|---|
| 编辑诊单 | `tcm.diagnosis/edit` | `D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:152,418,492-498` |
| 只读查看 | `tcm.diagnosis/readonlyDetail` | `D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:153,419,500-510` |
| 预约/取消挂号 | `tcm.diagnosis/guahao`;取消仅状态 1/4 | `D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:154,179-186,420,681-696` |
| 指派/重新指派医助 | `tcm.diagnosis/assign` | `D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:155-162,421,527-575` |
| 补全身份证 | 复用 `tcm.diagnosis/edit` | `D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:163-170,422,578-628` |
| 诊单二维码 | 复用挂号权限,且要求有效挂号、状态 1、医生 ID | `D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:171-178,630-673` |
只读诊单不是列表内的摘要面板。页面先从动态路由中查找 `meta.perms === 'tcm.diagnosis/readonlyDetail'`,再携带诊单 ID 跳转:`D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:500-510`
### 4.3 订单管理子工作区
- 数据口径明确为“当前患者范围内”的处方业务订单,且“订单创建人不参与数据归属判断”:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\OrderPanel.vue:3-10`
- 支持处方审核、支付单审核、履约状态、关键词与日期筛选:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\OrderPanel.vue:14-79`
- 汇总包含订单数、有效金额、待审核、完成/签收、拒收数和拒收率:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\OrderPanel.vue:82-123,289-297`
- 列表显示订单/患者/处方与诊单/金额/双审/履约/支付单/助理/医生/创建人:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\OrderPanel.vue:125-188`
- 数据由 `myPatientOrderLists` 分页加载:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\OrderPanel.vue:234-244,282-286`
- 订单按钮以 canonical `tcm.prescriptionOrder/*` 权限和业务状态共同判定;完整矩阵在 `D:\web\zyt\admin\src\views\first_visit\my_patients\components\order-actions.ts:32-124`,包括详情、编辑、双审及撤回、快递、补支付、完成、退款、撤回、上传药房。
### 4.4 面诊进度子工作区
- 服务端通过 `extend.schedule_mode` 决定“按本人归属”或“与排班合并”,而不是客户端按角色 ID 猜测:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\ProgressPanel.vue:7-26,206-214`
- “本人患者”语义、近七日安排、医生聚合、候诊队列均在同一工作区:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\ProgressPanel.vue:31-117,120-178`
- API 符号为 `myPatientProgressLists`,数据范围标签来自 `extend.scope.label`,本人患者由 `row.is_self_patient` 标识:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\ProgressPanel.vue:185-203,263,308-309`
- 每 15 秒自动刷新:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\ProgressPanel.vue:318-325`
### 4.5 详情与历史关联链
管理端患者行会进入诊单编辑或只读详情,而后继续关联:
- 只读页真实标题为“患者信息详情”,无权限/不存在时明确空态:`D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue:4-36`
- 基本信息、完整病历、日常记录、医生备注/舌苔/报告:`D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue:38-72`
- 业务订单、视频回放、聊天、医助指派历史、挂号历史分别按权限展示:`D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue:74-146`
- 详情由 `diagnosisReadonlyDetail({id})` 拉取,备注另用 `getDoctorNotes({diagnosis_id})` 补齐:`D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue:151-188,209-234`
- 编辑页还提供处方病历、订单、视频、聊天、指派与挂号记录分页签:`D:\web\zyt\admin\src\views\tcm\diagnosis\edit.vue:655-759`
- 挂号历史不是列表行快照,而是按诊单 ID 单独调用 `appointmentLists`,携带 `diag_scope_relax: 1`、最多 500 条:`D:\web\zyt\admin\src\views\tcm\diagnosis\components\AppointmentRecordPanel.vue:79-89,139-152`
- 指派历史同样按诊单 ID 调用 `tcmDiagnosisAssignLogList``D:\web\zyt\admin\src\views\tcm\diagnosis\components\AssignLogPanel.vue:47-57,102-125`
- 业务订单按 `context_diagnosis_id + patient_id + scene=diagnosis_edit` 独立分页:`D:\web\zyt\admin\src\views\tcm\diagnosis\components\PatientOrderList.vue:118-148,170-179`
## 5. Python 患者模块逐项对照
| 项目 | Python 当前实现 | 判定 | 说明 |
|---|---|---|---|
| 页面身份 | 标题“我的患者”,描述服务端授权范围:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:84-92` | **EXACT** | 对标对象正确。 |
| 状态筛选 | 五种状态一致:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:94-119` | **EXACT** | 状态值与管理端一致。 |
| 关键词 | Python 提示“姓名、手机号或诊单号”:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:99-103` | **PARTIAL** | 管理端还明确支持助理/医生。 |
| 日期与汇总 | 无日期快捷项、区间和三日汇总 | **MISSING** | 管理端证据见 4.1。 |
| 表格 | 患者、性别年龄、电话、进度、医助、最近预约、复诊:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:132-196` | **PARTIAL** | 缺预约医生独立列、确认信息、诊单日期和全部操作。 |
| 列表请求 | `patients(keyword,status,page,page_size)``D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:307-326` | **PARTIAL** | 无 `start_date/end_date`;服务层正确映射 `status -> status_filter`。 |
| 服务端范围 | `list_patients` 使用 `firstvisit.myPatient/lists`,不注入医生 ID`D:\web\zyt\app\src\doctor_workstation\services\repository.py:398-412` | **EXACT** | 保持服务端权威数据范围,没有客户端越权扩大证据。 |
| `extend` | `PageResult` 完整保留 `extend``D:\web\zyt\app\src\doctor_workstation\core\models.py:590-598,618-672`;页面只读 items/total`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:328-335` | **PARTIAL** | scope、summary、dates 已被解析却未消费。 |
| 患者模型 | 覆盖主列表核心字段并保留 `raw``D:\web\zyt\app\src\doctor_workstation\core\models.py:241-318` | **EXACT** | 解析层可承载主表数据。 |
| 详情 | 选择行后直接 `_render_detail(patient)``D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:349-415` | **MISSING** | 没有诊单详情请求,不能等价于管理端只读/编辑页。 |
| 历史预约 | 从列表行 `appointments` 取最多 6 条:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:417-446` | **MISSING** | 管理端按诊单单独请求最多 500 条并显示更完整字段。 |
| 患者行动作 | 无编辑/只读/预约/指派/补身份证/二维码/取消挂号按钮 | **MISSING** | `D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:1-454` 没有动作链。 |
| 订单/进度工作区 | 无 | **MISSING** | Python 只有列表与右侧摘要。 |
补充:`get_value` 会在 dataclass 属性缺失时回退到 `.raw``D:\web\zyt\app\src\doctor_workstation\ui\widgets.py:48-66`。因此右侧摘要“可能”显示列表响应附带的额外字段,但它仍是列表快照,不是独立详情/历史合同。
## 6. 五模块动态路由、API 与按钮权限复核
### 6.1 主路由组件与 Python 导航
| 模块 | 管理端实际组件(views 证据) | Python 固定导航权限 | 判定 |
|---|---|---|---|
| 接诊台 | `D:\web\zyt\admin\src\views\patient\reception\index.vue:1-18` | `doctor.appointment/lists``D:\web\zyt\app\src\doctor_workstation\ui\shell.py:40-47` | **PARTIAL** |
| 处方库 | `D:\web\zyt\admin\src\views\consumer\prescription\list.vue:1-4,234-245` | `tcm.prescriptionLibrary/lists``D:\web\zyt\app\src\doctor_workstation\ui\shell.py:48-54` | **PARTIAL** |
| 已开处方 | `D:\web\zyt\admin\src\views\consumer\prescription\index.vue:1-4,1697-1711` | `tcm.prescription/lists``D:\web\zyt\app\src\doctor_workstation\ui\shell.py:55-61` | **PARTIAL** |
| 患者 | `D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:5-20` | `firstvisit.myPatient/lists``D:\web\zyt\app\src\doctor_workstation\ui\shell.py:62-68` | **PARTIAL** |
| 问诊/诊单 | `D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue:726-738` | `tcm.diagnosis/lists``D:\web\zyt\app\src\doctor_workstation\ui\shell.py:69-75` | **PARTIAL** |
这里的 **PARTIAL** 不是说固定权限码必然错误,而是:在 `views/**` 证据范围内无法恢复五条真实 `mySelf.menu` 记录,Python 又没有使用已经解析到的菜单树来做组件/顺序/显示/停用约束。
### 6.2 `mySelf` 菜单链
1. Python 正确请求 `auth.admin/mySelf`,解析用户、权限与 menu`D:\web\zyt\app\src\doctor_workstation\services\repository.py:164-195` —— **EXACT**
2. `Session` 正确保留 `menu``D:\web\zyt\app\src\doctor_workstation\core\session.py:12-22` —— **EXACT**
3. 正式登录只检查 `session.menu` 非空,然后把权限交给 Shell`D:\web\zyt\app\src\doctor_workstation\app.py:413-443` —— **PARTIAL**
4. Shell 注册页面时遍历固定 `NAVIGATION`,只调用扁平 `has_permission`,没有读取 `session.menu``D:\web\zyt\app\src\doctor_workstation\ui\shell.py:248-280` —— **MISSING(菜单绑定)**
实际影响:菜单中任意一项存在即可通过登录守卫;随后五模块的显示、顺序、名称及停用状态均由本地固定表决定。动态路由中的隐藏/停用/组件路径/排序语义没有落地。
### 6.3 按钮权限与功能矩阵
#### 接诊台 — **PARTIAL**
- 管理端备注、编辑病历、完成接诊分别使用 `doctor.appointment/addDoctorNote``tcm.diagnosis/edit``doctor.appointment/complete``D:\web\zyt\admin\src\views\patient\reception\index.vue:144-171`
- 管理端队列内“通知医助/发起通话”按钮本身没有 `v-perms``D:\web\zyt\admin\src\views\patient\reception\index.vue:75-90`
- Python 备注与完成权限一致,但通知增加了 `doctor.appointment/notifyAssistant`,视频增加了 `tcm.diagnosis/videoQr`;缺“编辑病历”:`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:322-331`
- Python 的通知、备注、完成调用链存在:`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:558-641`;管理端对应 API 调用见 `D:\web\zyt\admin\src\views\patient\reception\index.vue:460-527`
#### 处方库 — **PARTIAL,含 P0 权限门禁问题**
- 管理端 canonical 按钮码是 `wcf.prescription/add|read|edit|delete``D:\web\zyt\admin\src\views\consumer\prescription\list.vue:35-41,83-105`
- 管理端编辑/删除还要求创建人本人,或 root/角色 0、3:`D:\web\zyt\admin\src\views\consumer\prescription\list.vue:278-301`
- Python 新增/编辑/删除同时接受 canonical `wcf.*` 和额外 `tcm.prescriptionLibrary/*``D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:271-279,330-348`
- Python root/角色 0、3 的例外与管理端一致,但当 `user_id``creator_id` 缺失时直接返回可管理:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:460-472`。管理端的 `Number(row.creator_id) === Number(user.id)` 不会对缺失创建人默认放行。
#### 已开处方 — **PARTIAL/MISSING actions**
- 管理端支持业务订单跳转、新增、查看、修正患者、创建订单、编辑、审核、删除,对应权限码见 `D:\web\zyt\admin\src\views\consumer\prescription\index.vue:102-117,214-265`
- 业务订单导航依赖动态路由中的 `meta.perms === 'tcm.prescriptionOrder/lists'`,菜单不存在会拒绝跳转并提示:`D:\web\zyt\admin\src\views\consumer\prescription\index.vue:1862-1871`
- Python 只实现关键词/审核状态的列表和详情请求:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:91-188,284-340`;上述写操作与订单导航均不存在。
#### 患者 — **MISSING actions**
完整动作矩阵见第 4.2 节;Python 患者页没有对应按钮或权限检查。
#### 问诊/诊单 — **PARTIAL/MISSING actions**
- 管理端新增、批量指派、查看、诊单、开方、预约、补身份证、指派/取消指派、视频二维码、确认二维码/取消挂号、挂号日志、订单、删除的权限矩阵见 `D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue:154-175,342-405`
- Python 仅保留列表筛选与 `tcm.diagnosis/videoQr` 视频入口:`D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:101-107,110-236,264-323`
### 6.4 权限匹配语义
- `PermissionSet` 支持精确码、全局 `*``prefix/*``D:\web\zyt\app\src\doctor_workstation\core\permissions.py:24-84`OR 语义见 `D:\web\zyt\app\src\doctor_workstation\core\permissions.py:96-104`
- UI `has_permission` 额外把 `/``.` 互换后尝试匹配:`D:\web\zyt\app\src\doctor_workstation\ui\widgets.py:135-180`
- 管理端视图只声明 canonical 形态,例如 `tcm.diagnosis/edit``wcf.prescription/edit`。由于本次不能读取管理端权限工具实现,不能证明管理端也接受点/斜杠变体;Python 的变体放行应判为 **PARTIAL**,不应当作已证明的兼容要求。
## 7. 医生角色与数据范围
### 7.1 已证明一致的部分
- 患者列表:管理端要求按当前角色/部门展示并把最终范围作为 `extend.scope.label` 返回;Python 调用相同业务域列表,不传任意 `doctor_id`,所以没有客户端扩大数据集的证据。判定 **EXACT(请求边界)**
- 处方库:普通医生只能管理自己创建的模板,root/角色 0、3 才可管理全部。Python 的正常 ID 分支与此一致。判定 **EXACT(正常分支)**
### 7.2 部分或缺失
- Python 不显示 `extend.scope.label`,用户无法验证当前是本人、部门还是其他服务端范围:**PARTIAL**。
- Python 缺失患者订单工作区,因而没有落实“当前患者范围内、订单创建人不参与归属”的口径:**MISSING**。
- Python 缺失面诊进度工作区,因而没有落实服务端 `schedule_mode=ownership``is_self_patient` 和近七日聚合:**MISSING**。
- 除患者模块视图明确写出的范围文字外,接诊、处方、诊单视图没有在允许证据范围内定义服务端角色/部门过滤算法。Python 使用同域列表接口且未见额外越权 `doctor_id` 注入,但无法仅凭 views 宣称五模块的后端数据范围 **EXACT**;应保留 **PARTIAL / 服务端待证**
## 8. P0 / P1 缺口
### P0
1. **处方库所有权校验 fail-open。**
Python 在当前用户 ID 或模板 `creator_id` 任一缺失时允许编辑/删除:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:460-466`;管理端仅允许严格所有者匹配或 root/角色 0、3:`D:\web\zyt\admin\src\views\consumer\prescription\list.vue:286-301`。应改成缺失即拒绝,并只在明确 root/角色例外时放行。若服务端另有强制校验,实际数据写入风险会降低,但客户端授权偏差仍已成立。
### P1
1. **患者模块只有主表子集。** 缺日期筛选、三日汇总、范围标签、确认/诊单日期列和全部行级动作。
2. **患者详情不是管理端真实详情。** Python 不请求诊单只读/编辑详情,病历摘要与最多 6 条历史预约依赖列表行快照,可能为空或陈旧。
3. **患者订单管理与面诊进度两个工作区完全缺失。** 同时丢失订单数据范围、订单操作权限矩阵、ownership 模式和 15 秒候诊刷新。
4. **Shell 忽略 `mySelf.menu`。** 已解析的动态菜单只用于“非空”登录守卫;五模块由固定表和扁平权限决定,未落实组件路径、显示/停用、排序和真实菜单成员关系。
5. **处方库权限码存在额外别名放行。** Python 接受 `tcm.prescriptionLibrary/add|edit|delete`,而视图仅证明 canonical `wcf.prescription/*`;应在拿到真实 `mySelf.permissions/menu` 样本后收敛。
6. **通用权限帮助器接受 `/`/`.` 互换。** 该兼容未被 views-only 证据证明,可能让非 canonical grant 意外点亮按钮。
7. **接诊台按钮矩阵不一致。** Python 比管理端额外门禁通知/视频,同时缺少 `tcm.diagnosis/edit` 的编辑病历动作。
8. **已开处方和问诊页均被压缩为只读子集。** 管理端已有的新增/编辑/审核/删除/订单/预约/指派等受权操作未落地。
## 9. 建议的验收优先级
1. 先修复处方库所有权 fail-open,并用“缺 `creator_id`、缺当前用户 ID、本人、他人、root、角色 0/3”六组合同测试覆盖。
2. 让 Shell 以 `Session.menu` 为主约束,再叠加权限码;不要仅以硬编码五项替代动态菜单。
3. 将患者页拆成与管理端一致的三个工作区,优先补真实只读详情/历史请求和主表行级动作。
4. 消费 `PageResult.extend.scope/summary/dates/schedule_mode`,把服务端权威范围直接呈现给医生。
5. 获取一份真实医生角色 `mySelf` 响应样本后,补做菜单 `paths/component/perms/is_show/is_disable/sort` 的精确合同测试;这一步无法由 `views/**` 单独完成。
+357
View File
@@ -0,0 +1,357 @@
# “我的处方库 / 已开处方”桌面端对齐审计
审计日期:2026-08-10
## 1. 范围、基准与结论口径
本审计只读检查以下两套实现,没有修改业务源码:
- 管理端唯一功能基准:D:/web/zyt/admin/src/views
- Python 桌面端:D:/web/zyt/app/src/doctor_workstation
管理端 API 包装文件、通用组件和分页 hook 仅用于解析“页面已经发起的调用”的 URL、参数与直接子组件行为,不把相邻页面或未被页面调用的 API 当成处方页功能。
结论标签:
- EXACT:用户可见行为、状态语义和请求合同均与基准一致;不要求布局像素一致。
- PARTIAL:仅覆盖基准的子集,或字段/状态/校验语义有差异。
- MISSING:基准存在,而 Python 没有入口或没有可达实现。
- EXACT(负向):基准明确没有该能力,Python 也没有;不能把它列为待补功能。
## 2. 真实页面定位
| 业务名 | 管理端真实 view 组件 | Python 页面 | 结论 |
|---|---|---|---|
| 我的处方库 | D:/web/zyt/admin/src/views/consumer/prescription/list.vue:1-414script name 为 prescriptionLibrary234 | D:/web/zyt/app/src/doctor_workstation/ui/pages/prescription_library.py:251-545;固定导航见 D:/web/zyt/app/src/doctor_workstation/ui/shell.py:48-54 | EXACT(组件映射) |
| 已开处方 / 处方管理 | D:/web/zyt/admin/src/views/consumer/prescription/index.vue:1-4892script name 为 prescriptionList1697 | D:/web/zyt/app/src/doctor_workstation/ui/pages/prescriptions.py:72-436;固定导航见 D:/web/zyt/app/src/doctor_workstation/ui/shell.py:55-61 | EXACT(组件映射),功能不是 EXACT |
管理端采用动态菜单,单凭 src/views 只能确定组件键应分别落到 consumer/prescription/list 与 consumer/prescription/index,不能确定生产环境最终 URL、菜单标题或 route.meta.perms。Python 则固定注册为 prescription_library / prescriptions。审计不伪造管理端生产 URL。
## 3. 直接子组件与边界
### 3.1 MedicineNameSelect
处方库在 D:/web/zyt/admin/src/views/consumer/prescription/list.vue:158-165 使用;已开处方的主方、辅方行分别在 D:/web/zyt/admin/src/views/consumer/prescription/index.vue:695-726、736-767 使用。
真实合同:
- 远程、可过滤选择器,提示支持药材名或拼音首字母:D:/web/zyt/admin/src/components/medicine-name-select/index.vue:3-24。
- 展开或搜索时 GET /doctor.medicine/lists,参数 name、page_no=1、page_size=100、status=1:同文件 69-92URL 包装见 D:/web/zyt/admin/src/api/medicine.ts:6-8。
- 选择后同时回写 name 和 medicine_id;不能把任意自由文本当成一次有效选择:组件 95-110。
Python 处方库的 HerbRow 是两个自由文本 QLineEditmedicine_id 只会保留已有行上的值,新增行没有药材主数据选择:D:/web/zyt/app/src/doctor_workstation/ui/pages/prescription_library.py:76-110。结论:PARTIAL。
### 3.2 DaterangePicker
已开处方在 D:/web/zyt/admin/src/views/consumer/prescription/index.vue:19-23 使用。直接子组件默认 datetimerange,值格式 YYYY-MM-DD HH:mm:ss,并分别回写 start_time/end_timeD:/web/zyt/admin/src/components/daterange-picker/index.vue:1-48。
Python 没有日期范围控件:MISSING。
### 3.3 TcmDiagnosisEditView
已开处方异步加载 D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue,并在 diagnosis_id>0 时提供“查看患者诊单详情”:D:/web/zyt/admin/src/views/consumer/prescription/index.vue:566-574、1692-1693、1728、2910-2921。
直接调用 openViewOnly(id);子页面以同一套界面只读打开、加载 GET /tcm.diagnosis/detail {id},默认病历 tabD:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1263-1264、1310-1322URL 见 D:/web/zyt/admin/src/api/tcm.ts:59-62。
该只读子页仍保留全套 tab 边界:
- 病历字段整体 disableddiagnosis/edit.vue:55-67。
- 医生备注 readonly、日常记录 read-only633-670。
- 处方 tab 受 tcm.diagnosis/chufang 控制且 read-only673-686。
- 业务订单受 tcm.diagnosis/patientOrders 控制:688-702。
- 视频回放、聊天、指派、挂号分别受 tcm.diagnosis/huifang、tcm.diagnosis/chat、tcm.diagnosis/assign 或 detail、doctor.appointment/lists 控制:704-758。
Python 已开处方没有 diagnosis_id 跳转或只读诊单子页:MISSING。
### 3.4 分页
两个管理端页面均通过 usePaging 自动发送 page_no/page_size,默认 page=1、size=15,并消费 count/lists/extendD:/web/zyt/admin/src/hooks/usePaging.ts:13-49。Python 两页均默认 page_size=20,使用 PageResult/Pager
- 处方库:D:/web/zyt/app/src/doctor_workstation/ui/pages/prescription_library.py:263-266、413-442。
- 已开处方:D:/web/zyt/app/src/doctor_workstation/ui/pages/prescriptions.py:84-89、284-316。
参数和总数分页机制 EXACT,默认每页条数 PARTIAL15 vs 20)。
## 4. 我的处方库:管理端真实合同
### 4.1 筛选、字段与状态
- 筛选:prescription_name、formula_type=主方|辅方、is_public=0|1,查询回第一页,重置为初始值:D:/web/zyt/admin/src/views/consumer/prescription/list.vue:5-31、247-252、281-284。
- 列表字段:id、prescription_name、formula_type、herbs.length、全部 herbs 的 name/dosage(g)、is_public、disable_edit、creator_name、create_timelist.vue:44-107。
- 公开范围:0=仅自己可见,1=所有人可见:list.vue:21-26、67-72、195-205。
- disable_edit:0=可修改,1=已禁用;真实语义是“导入到已开处方后锁定整张处方药材”,不是“禁止维护这条模板自身”:list.vue:74-79、208-220。
- 普通用户只管理本人 creator_idroot=1 或 role_ids 含 0、3 可管理任意模板:list.vue:276-301。
### 4.2 查看、创建、编辑、删除、复制
- 新增:独立按钮,表单字段为 prescription_namemaxlength=100)、formula_type、herbs、is_public、disable_editlist.vue:35-41、116-230、259-267。
- 查看:有单独 read 权限按钮,复用列表行数据打开完全禁用的表单;不调用 detaillist.vue:83-87、123-129、333-343。
- 编辑:仅 owner 或管理角色可见;仍可修改 disable_edit=1 模板本身的药材:list.vue:88-106、345-355。
- 删除:确认后 POST {id}list.vue:403-409。
- 复制:页面没有复制/克隆按钮、函数或端点。导入模板属于“已开处方”的复用流程,不是复制模板 CRUD。
- 导出/打印:本页面没有。
### 4.3 药材行和校验
- 药材 DTO{medicine_id?: number, name: string, dosage: number}list.vue:259-266。
- name 必须由 MedicineNameSelect 选取并同步 IDdosage 是 min=0、precision=1、step=0.5 的数字控件:list.vue:156-190。
- 提交要求:名称必填、至少一味药、每行 name 非空、dosage>0list.vue:269-274、357-379。
### 4.4 API DTO
页面调用来源见 list.vue:234-245URL 包装见 D:/web/zyt/admin/src/api/tcm.ts:591-615。
| 方法与端点 | 页面请求参数 / body | 页面使用情况 |
|---|---|---|
| GET /tcm.prescriptionLibrary/lists | page_no、page_size、prescription_name、formula_type、is_public | 列表与分页 |
| POST /tcm.prescriptionLibrary/add | prescription_name、formula_type、herbs、is_public、disable_edit;页面对象还带重置后的 id=0 | 新增 |
| POST /tcm.prescriptionLibrary/edit | id、prescription_name、formula_type、herbs、is_public、disable_edit | 编辑 |
| POST /tcm.prescriptionLibrary/delete | {id} | 删除 |
| GET /tcm.prescriptionLibrary/detail | {id} | API 已封装,但该 view 没有调用 |
权限码为 wcf.prescription/add、read、edit、deletelist.vue:36、85、93、102。
## 5. 我的处方库:Python 逐项对比
| 项目 | Python 证据 | 结论 |
|---|---|---|
| 页面入口 | shell.py:48-54 以 tcm.prescriptionLibrary/lists 注册;页面标题见 prescription_library.py:251-282 | EXACT |
| 筛选 | Python 有关键词、主/辅方、公开范围;repository 把 keyword 改成 prescription_name,把 main/aux 改为主方/辅方:prescription_library.py:284-315、413-429repository.py:279-298、586-588 | PARTIAL:请求合同基本一致,但“名称或药材”的占位文案(291)是假的,实际只查 prescription_name |
| 分页 | page/page_size 经 invoke 转 page_no/page_sizePager 消费总数:prescription_library.py:409-442widgets.py:221-223 | PARTIAL:默认 20,基准 15 |
| 列表字段 | Python 只显示名称、类型、最多 4 味摘要、公开范围、创建人、时间:prescription_library.py:357-384 | PARTIAL:缺 id、药材数量、完整药材、disable_edit;把 create_time 列标题写成“更新时间” |
| 单独查看 | 没有 read 按钮;双击只会走 _edit_selected,且需 edit 按钮可见及 owner/管理员:prescription_library.py:330-350、385-386、479-485 | MISSING |
| 新增 | _new_template -> save -> repository addprescription_library.py:474-501repository.py:309-323 | PARTIAL:端点可用,但药材和 disable_edit 语义不完整 |
| 编辑 | 使用列表行直接打开,与基准一样不请求 detail;保存到 editprescription_library.py:479-501repository.py:325-347 | PARTIAL |
| 删除 | owner/管理员检查、确认、POST {id}prescription_library.py:454-472、507-537repository.py:349-352 | EXACT |
| 公开范围 | 复选框产生 boolrepository 规范为 0/1prescription_library.py:157-159、218-228repository.py:572-575 | EXACT |
| disable_edit | Python 把它解释成“当前模板药材不可编辑”,隐藏增删并禁用输入;而管理端只在导入已开处方后锁药材。Python 也没有切换开关,只能原样回传:prescription_library.py:117-140、167-170、218-224 | PARTIAL(语义错误) |
| 药材选择 | 自由文本 name/dosage;新行无 medicine_id,已有行才保留:prescription_library.py:76-110 | PARTIAL |
| 药材校验 | 只检查至少一行、有名称时 dosage 非空:prescription_library.py:230-248 | PARTIAL:不校验数字和 >0,且允许“10g”字符串,与基准 number DTO 不同 |
| 所有权 | user id 相同、root、role 0/3prescription_library.py:460-472 | EXACT |
| API CRUD | list/detail/add/edit/delete 均存在且 URL、id 合同正确:repository.py:279-352 | EXACT(仓储层) |
| 模型 | PrescriptionTemplate 保留 id/name/formula/herbs/public/disable/creator/time/rawto_api_dict 正规化中文方型和 0/1core/models.py:426-477 | EXACT(字段合同) |
| 权限 | add/edit/delete 接受基准 wcf.*,同时接受 tcm.prescriptionLibrary/* 别名:prescription_library.py:274-279、332-347 | PARTIAL:动作基本受控,但缺 wcf.prescription/read 的可达只读查看;额外别名不是 view 基准 |
| 复制 | 两边都没有 | EXACT(负向) |
| 导出/打印 | 两边都没有 | EXACT(负向) |
## 6. 已开处方:管理端真实合同
### 6.1 筛选与分页 DTO
页面筛选控件见 D:/web/zyt/admin/src/views/consumer/prescription/index.vue:4-99;状态对象见 1874-1887
- sn:处方编号模糊查询。
- patient_name。
- creator_ids:医生 ID 多选;有值才发数组。
- audit_filterall、pending、passed、not_passed、rejectedall 被正规化为空字符串。
- source_filterall、manual、systemall 被正规化为空字符串。
- start_time/end_time:创建时间,格式 YYYY-MM-DD HH:mm:ss。
- 快捷日期:全部、今日、昨日、前天,切换后立即回第一页查询:index.vue:3013-3048。
最终 GET /tcm.prescription/lists 参数是 page_no、page_size 与上述字段;creator_ids 空数组会被省略:index.vue:2990-3007。默认 page_size=15。
### 6.2 列表字段、状态与可达动作
列表与按钮位于 index.vue:102-270
- 列:sn、id、订单一致性警告、prescription_type、is_system_auto1=空白处方,其他=手工)、patient_name/gender/age、综合审核状态和驳回意见、void_status、doctor_name/prescription_date、assistant_name、create_time。
- audit_status0 待审核、1 已通过、2 已驳回:index.vue:3113-3123。
- business_prescription_audit_rejected=1 时,列表主状态覆盖为“已驳回”,并分别展示业务订单与消费者处方的驳回意见:index.vue:3125-3151。
- void_status=1 单独显示“作废”。
- 已有关联业务订单时,药材为空或重名会在编号下给出警告:index.vue:3192-3216。
动作门槛:
- 查看:cf.prescription/read。
- 修正姓名/性别/手机:未作废且 tcm.prescription/patchPatient。
- 创建订单:has_prescription_order=0、未作废且 tcm.prescriptionOrder/create。
- 编辑、删除:只有“非 audit_status=1 且未作废”才出现,分别需 cf.prescription/edit、cf.prescription/del。
- 审核:audit_status=0、未作废且 cf.prescription/audit。
- 业务订单列表:tcm.prescriptionOrder/lists。
精确条件和权限见 index.vue:214-264;“已通过且未作废”判定见 3154-3157。
### 6.3 查看详情、打印与 PDF
查看先用列表行渲染,再 GET /tcm.prescription/detail {id} 覆盖:index.vue:3796-3817。
A4 处方笺显示:
- 来源、作废、消费者审核、业务订单审核及意见:index.vue:284-343。
- 日期/编号;患者姓名、性别、年龄、电话、收件信息、临床诊断:345-390。
- 主方/辅方药材,单剂 dosage 与 dose_count 计算后的总量:392-433。
- 主辅方用法、医嘱、忌口、备注、药房备注、出丸:435-448。
- 医师手写签名、医生、类型、天数/剂数、单剂量:450-481。
- 审核人、审核时间、审核意见:484-496。
打印调用 window.printindex.vue:2741-2751。PDF 用 html2canvas(scale=2) 渲染 A4,再由 jsPDF 分页并下载:index.vue:2753-2813。页面没有“列表导出 Excel”;tcm.ts:384-387 的业务订单导出 API 没有被此 view 导入或调用。
### 6.4 新增/编辑字段与药材规则
表单位于 index.vue:499-1153reactive DTO 在 2815-2862
- 关联/系统:id、diagnosis_id、creator_id、is_system_auto。
- 患者:patient_name、gender、age、visit_no、prescription_date。
- 四诊/诊断:tongue、tongue_image、pulse、pulse_condition、clinical_diagnosis。
- 药材:herbs[{medicine_id?, name, dosage, formula_type=主方|辅方, locked?}]。
- 剂型:prescription_type(浓缩水丸、饮片、颗粒、丸剂、散剂、膏方、汤剂)、dosage_amount、dosage_unit、dosage_bag_count、need_decoction、bags_per_dose、dose_count、dose_unit。
- 主方用法:usage_days、times_per_day、usage_instruction、usage_time、usage_way、dietary_taboo[]、usage_notes。
- 辅方用法 aux_usagedosage_amount、dosage_bag_count、need_decoction、bags_per_dose、times_per_day、usage_days、prescription_name。
- 医师:doctor_name、doctor_signature(手写 canvas 生成 PNG data URL,必填)。
- 隐藏状态:is_shared、visible_role_ids、audit_status/time/by/remark、业务订单审核字段。
主辅方各自使用 MedicineNameSelect 和数字 dosage;导入 disable_edit=1 模板时给行加 locked 并锁定整张处方药材:index.vue:667-813、3489-3515、3712-3732。
新增/编辑校验:
- patient_name、gender、prescription_date、clinical_diagnosis、dose_count、doctor_name、doctor_signature 必填:index.vue:2955-2988。
- 至少一味药,每行 name 非空、dosage>03920-3947。
- 新增提交前强制 audit_status=0add/edit 都把完整 editForm 展开为 body3951-3961。
- 编辑提示保存后重新进入待审核;已作废/已驳回可经编辑恢复,业务订单侧审核会重置:index.vue:514-565。
公开/共享范围的特殊事实:is_shared、visible_role_ids 虽存在于 editForm、详情回填和展开 payload2846-2847、3889-3890、3952),但模板区没有任何 v-model 控件;formatVisibleRoleNames 也未被模板调用(3348-3364)。因此该真实 view 不允许用户设置共享范围。桌面端不应凭字段存在擅自新增 UI。
### 6.5 处方库导入、粘贴导入与“复制”
- 从处方库导入对话框支持处方名称、主/辅方筛选、replace/append、10/15/20/50 分页,展示公开范围和创建人:index.vue:1539-1633。
- 请求 GET /tcm.prescriptionLibrary/lists,参数 page_no、page_size、prescription_name、formula_type、prescribing_creator_idindex.vue:3444-3468。
- prescribing_creator_id 取当前开方医生;语义是“该医生自己的模板 + 公共模板”。
- 粘贴导入解析 name/dosage,只接受 GET /doctor.medicine/lists 中 name 完全相同且唯一的药材;请求 name、page_no=1、page_size=200、status=1index.vue:3538-3709。
- replace 只替换同方型药材,append 追加;重复名仅警告。
该页面没有“复制某张已开处方”动作或端点。处方库导入与文本导入是新增/编辑中的填充方式,不等于复制已开处方。Python 也没有复制:EXACT(负向)。
### 6.6 审核联动
- POST /tcm.prescription/audit body={id, action:'approve'|'reject', remark}D:/web/zyt/admin/src/api/tcm.ts:365-372;调用见 index.vue:3367-3395。
- reject 必填 remark,成功语义为“驳回并作废处方”:index.vue:1669-1689、3373-3385。
- 返回 wecom_notify_ok=false 且有 wecom_notify_hint 时额外警告:3384-3388。
- 页面定义 root/role_ids 0、3 的审核角色辅助函数,但该函数没有被模板使用;真实按钮门槛仍是 v-perms cf.prescription/audit 加行状态:index.vue:1854-1855、247-253、3104-3110。
### 6.7 患者修正与订单联动
患者修正:
- 对话框只改 patient_name、gender、phone,明确不改审核状态,并记录到业务订单日志:index.vue:1155-1195。
- POST /tcm.prescription/patchPatient body={id,patient_name,phone,gender}index.vue:1948-1973API 类型见 tcm.ts:330-338。
订单读取与一致性:
- 编辑处方时 GET /tcm.prescriptionOrder/lists,参数 page_no=1、page_size=5、prescription_id,显示最近订单的 order_no、medication_days、remark_assistantindex.vue:769-811、2879-2907。
- 业务订单审核驳回可以覆盖处方列表状态和处方笺状态,但保留消费者处方本身的 audit_statusindex.vue:165-190、295-343、3125-3151。
从处方创建业务订单:
- 三步表单:诊单患者与收件信息;服务/发货/支付单;费用与备注:index.vue:1197-1537。
- 诊单患者远程搜索意图为 GET /tcm.diagnosis/searchPatient,参数 keyword、page_no=1、page_size=10index.vue:2384-2401API 定义在 D:/web/zyt/admin/src/api/order.ts:88-91。
- GET /tcm.prescriptionOrder/paidPayOrders {diagnosis_id} 返回可关联支付单和 deposit_min_amountindex.vue:2307-2329API 见 tcm.ts:389-398。
- POST /tcm.prescriptionOrder/create 的 body
prescription_id、diagnosis_id、recipient_name、recipient_phone、shipping_address、is_follow_up、prev_staff、service_channel、service_package(逗号拼接)、express_company、tracking_number、ship_mode、fee_type、amount、remark_extra、remark_assistant;可选 shipping_province/city/district、medication_days、internal_cost、pay_order_ids。证据:index.vue:2435-2485URL 见 tcm.ts:374-382。
- ship_mode 选择需 tcm.prescriptionOrder/setShipModeinternal_cost 需 finance.account_log/listsremark_extra 需 tcm.prescriptionOrder/editRemarkExtraindex.vue:1307-1314、1466-1488、2041-2043。
- 服务套餐来自 GET /config/dict {type:'server_order'}index.vue:2180-2187URL 见 D:/web/zyt/admin/src/api/app.ts:13-16。
## 7. 已开处方:Python 逐项对比
D:/web/zyt/app/src/doctor_workstation/ui/pages/prescriptions.py:1 已明确把本页定义为 “Read-only list and detail view for issued prescriptions”。这不是完整管理端主链。
| 项目 | Python 证据 | 结论 |
|---|---|---|
| 页面入口 | shell.py:55-61,以 tcm.prescription/lists 控制 | EXACT |
| 筛选 | 只有一个“编号/患者”关键词和 audit 0/1/2prescriptions.py:101-125、284-303 | PARTIAL |
| 请求映射 | repository 根据 RX/CF/纯数字猜 sn,否则 patient_namestatus 转 pending/passed/rejectedrepository.py:354-387 | PARTIAL:已有控件映射正确;缺 creator_ids、日期、source、not_passed;同一关键词不能同时查编号与患者 |
| 分页 | PageResult/Pager 完整 | PARTIAL:默认 20,基准 15 |
| 列表字段 | sn、patient、audit、source、doctor、prescription_dateprescriptions.py:139-180 | PARTIAL:缺 id、prescription_type、gender/age、业务/消费者驳回意见、void 独立列、assistant、create_time、订单警告 |
| 来源 | is_system_auto -> “系统代开/医生开具”:prescriptions.py:67-70 | PARTIAL:基准语义/文案是“空白处方/手工” |
| 状态 | 先作废,再业务订单驳回/消费者驳回,再通过/待审;raw fallback 可读模型未声明字段:prescriptions.py:46-60widgets.py:48-66 | PARTIAL:主标签逻辑接近,但不能分别显示两类驳回意见、审核人/时间和独立作废信息 |
| 详情请求 | 选中后 GET /tcm.prescription/detail {id}prescriptions.py:326-357repository.py:389-396 | EXACT(端点) |
| 详情字段 | 患者/性别/年龄、医生、日期、类型、剂数、药材/剂量/方型、频次/天数/方式/时间/忌口/说明和一条审核备注:prescriptions.py:360-420 | PARTIAL |
| A4 处方笺 | 无 A4 版式;缺电话/收件、临床诊断、签名、单味总量、药房备注/出丸、审核轨迹等 | MISSING |
| 新增 | 页面无按钮、无表单;RemoteRepository 无 prescription add 方法 | MISSING |
| 编辑 | 页面无按钮、无表单;缺主/辅方药材、剂型用法、签名、重新待审完整流程 | MISSING |
| 删除 | 无按钮;RemoteRepository 无 delete | MISSING |
| 患者修正 | 无 patchPatient UI/仓储方法 | MISSING |
| 审核 | 无 approve/reject UI/仓储方法;无驳回必填、作废联动、企微提示 | MISSING |
| 订单联动 | 无订单列表入口、创建订单、支付单关联、处方一致性警告、最近订单提示 | MISSING |
| 处方库导入 | 无新增/编辑,自然也没有 replace/append、锁药材、prescribing_creator_id | MISSING |
| 文本导入 | 无解析及药材完全同名验证 | MISSING |
| 药材编辑行 | 详情表只读;无 medicine_id 选择与 dosage 校验 | MISSING |
| 打印 | 无 window/Qt 打印等价能力 | MISSING |
| PDF | 无处方笺 PDF 导出 | MISSING |
| 列表 Excel | 基准页也没有 | EXACT(负向) |
| 复制已开处方 | 基准页也没有 | EXACT(负向) |
| 共享范围 UI | Python 不展示 is_shared/visible_role_ids;基准 view 也没有可见控件 | EXACT(只读 UI 的负向行为) |
| 共享字段模型 | Prescription 存 is_shared、visible_role_ids,但没有 mutationcore/models.py:519-521、574-576 | PARTIAL(数据保留) |
| 诊单详情联动 | 没有 diagnosis_id “查看诊单详情”和全 tab 只读子页 | MISSING |
| 动作权限 | 构造器保存 permissions 但页面没有任何 has_permission 动作判断:prescriptions.py:72-99 | MISSING(动作本身也缺) |
### 7.1 Python Prescription DTO 覆盖度
Prescription 模型保留绝大多数列表/详情核心字段:id、diagnosis、sn、患者、四诊、药材、剂型/主方用法、aux_usage、签名、creator/assistant、source/share/audit/void/order/create_time,并保留 rawD:/web/zyt/app/src/doctor_workstation/core/models.py:480-587。
结论:PARTIAL。
原因:
- 模型层比 UI 完整,但 UI 只消费很小子集。
- business_prescription_audit_rejected、business_prescription_audit_remark、收件/药房/出丸等不设显式 dataclass 字段,只能从 raw 兜底;get_value 确实支持 raw fallbackwidgets.py:48-66。
- RemoteRepository 只实现 GET lists/detailrepository.py:354-396;没有 add/edit/delete/patch/audit/order 的请求合同。
## 8. 权限码总表
### 8.1 我的处方库
| 能力 | 管理端真实码 | Python |
|---|---|---|
| 页面/列表 | 动态菜单实际值不能由 view 单独确定;接口是 tcm.prescriptionLibrary/lists | shell 固定 tcm.prescriptionLibrary/lists |
| 新增 | wcf.prescription/add | 接受 wcf.prescription/add 或 tcm.prescriptionLibrary/add |
| 查看 | wcf.prescription/read | MISSING |
| 编辑 | wcf.prescription/edit + owner/root/role 0,3 | 同时接受 wcf 或 tcm 别名 + 同一所有权规则 |
| 删除 | wcf.prescription/delete + owner/root/role 0,3 | 同时接受 wcf 或 tcm 别名 + 同一所有权规则 |
### 8.2 已开处方
管理端真实页面使用:
- cf.prescription/add、read、edit、audit、del。
- tcm.prescription/patchPatient。
- tcm.prescriptionLibrary/lists。
- tcm.prescriptionOrder/create、lists、setShipMode、editRemarkExtra。
- finance.account_log/lists。
- 诊单子页另用 tcm.diagnosis/chufang、patientOrders、huifang、chat、assign/detail 和 doctor.appointment/lists。
证据集中在 index.vue:102-117、214-264、667-680、1307-1314、1466-1488、1692-1728。
Python 只有页面入口 tcm.prescription/listsshell.py:55-61);PrescriptionsPage 没有上述动作,也没有动作权限判断。结论:MISSING。
## 9. 基准 view 自身的边界/风险(不计作 Python 差异)
1. 订单患者搜索在 index.vue:2391 调用 searchPatientsAPI,但 index.vue:1698-1714 的显式 import 没有该符号;真正 export 位于 api/order.ts:88-91。若工程没有为这个本地 API 符号做自动导入,远程患者搜索会失败。仅以 views 不能证明是否存在额外自动导入,故结论是“高风险未确认”,不是把不存在的合同补到 Python。
2. is_shared、visible_role_ids、roleAll、formatVisibleRoleNames 存在,但没有表单绑定;不要把死状态误判成可见“公开范围”功能。
3. userCanAudit() 定义了 root/role 0,3,但没有被模板调用;真实前端门槛是 cf.prescription/audit 和行状态,后端仍是最终边界。
4. prescriptionVoid API 虽在 tcm.ts:360-363 存在,但此 route 没有导入/直接调用;该页的可达作废路径是 audit reject 的服务端联动。
5. prescriptionOrderExport API 存在,但“已开处方” route 不调用;不能据此声称本页有 Excel 导出。
6. 两个基准页面均无“复制”能力。
## 10. P0 / P1 缺口
### P0
1. 已开处方主链整体缺失:新增、编辑、删除及其 RemoteRepository POST 合同均不存在。当前 Python 实现是明确的只读页,无法替代管理端真实路由。
2. 医疗安全关键编辑合同缺失:药材必须来自 MedicineNameSelect、主辅方和 locked 模板规则、dosage>0、剂型/用法、临床诊断、手写医生签名、编辑后重入待审,Python 全部不可达。
3. 审核/作废闭环缺失:approve/reject、驳回意见必填、reject 同时作废、两类审核意见、企微通知提示均不存在。
4. 订单联动缺失:患者修正、订单创建、支付单关联/定金门槛、发货模式权限、订单审核驳回覆盖状态、处方/订单药材一致性提示全部不存在。
5. 权限闭环缺失:已开处方除了列表入口没有任何 cf.prescription/*、patchPatient、prescriptionOrder/* 动作校验;在补动作前必须按基准精确落码,不能以 lists 权限兜底。
### P1
1. 已开处方列表筛选不完整:缺医生多选、创建时间/快捷日期、来源、not_passed;关键词还通过内容猜测 sn 或 patient_name。
2. 已开处方列表/详情展示不完整:缺业务订单警告、两类驳回意见、独立作废/审核轨迹、收件/电话、临床诊断、医生签名、药房备注/出丸及 A4 处方笺。
3. 已开处方缺打印和 A4 PDF 下载。
4. 已开处方缺 diagnosis_id 只读诊单子页及其权限化 tabs。
5. 处方库缺真正的 wcf.prescription/read 查看路径;只有有编辑权且可管理该行的用户能通过编辑对话框看完整内容。
6. 处方库药材行应改为药材远程选择与数字 dosage;当前自由文本允许合同无效值。
7. 处方库 disable_edit 语义错误且无切换开关:它应控制“导入后的已开处方药材锁定”,不应锁死模板自身编辑。
8. 处方库表格缺 id、药材数、完整明细、disable_edit,且时间列标题与数据不一致。
9. 两页默认 page_size 与基准不同(20 vs 15);属于低风险但可见的分页差异。
“复制”不是缺口:两个管理端基准页面均没有复制动作,Python 也没有。
@@ -0,0 +1,267 @@
# 接诊台 / 问诊列表:管理后台到医生桌面的逐项一致性审计
审计日期:2026-08-10
基准:`D:\web\zyt\admin\src\views` 中从现行路由可达的 Vue/TS 实现
对比对象:`D:\web\zyt\app\src\doctor_workstation` 当前 Python 实现
约束:本次只读审计;未修改 `src/`,未发网络请求。
## 1. 结论摘要
当前 Python 不是管理后台两个页面的等价移植,而是“接诊队列 + 简化问诊表 + 原生直呼视频”的医生端子集。端点骨架中,挂号列表、接诊聚合详情、通知、添加文字备注、完成接诊、诊单列表以及 `getCallSignature/startCall/bindCallRoom/endCall` 已接通;但今日队列边界、异步选人一致性、挂号状态判定、病例详情/编辑、开方、日常记录、备注媒体、复杂筛选和操作权限仍有显著差异。
发布阻断级结论:
1. **P0 — 接诊台快速切换患者会出现旧患者详情挂在新患者选择下。** 管理后台每次选人递增请求序号并同时校验序号和 `selectedId``D:\web\zyt\admin\src\views\patient\reception\index.vue:398-418`);Python 在任一详情请求进行中时直接拒绝下一次加载(`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:406-418`),旧响应只按旧 generation 接受(同文件 `:437-447`)。这会把显示、备注目标和当前队列行拆成两个患者上下文。
2. **P0 — Python 接诊队列没有“今天”条件。** 基准固定发送 `start_date=end_date=today``patient/reception/index.vue:296-305`);Python 只发送 `status/keyword/page/page_size``reception.py:344-361`)。因此历史或未来的状态 1/4 记录可能进入“今日接诊台”,并可触发通知、备注、视频和完成。
3. **P0 — 问诊列表的视频门槛读取了错误的状态域。** 基准以 `has_appointment && appointment_status===1` 为唯一可视频条件(`tcm/diagnosis/index.vue:1778-1780`H5 同口径 `index_h5.vue:949-950`)。Python 读取诊单模型的 `status`,并允许 1 或 4`consultations.py:310-317`);模型又优先取根 `status`,而不是根 `appointment_status``core/models.py:362-366,406-414`)。结果是已完成/过号记录可能仍可直呼,合法已预约记录也可能被误禁用。
其余 P1 缺口集中在:完整病例核对与只读详情、诊单编辑、从诊单开方、接诊详情字段/日常记录/备注附件、手机号脱敏、动作权限契约,以及问诊列表的筛选/状态/多挂号语义。详见第 8 节。
## 2. 判定口径与路由范围
状态含义:
- **exact**:端点、关键参数、状态门槛和结果语义均一致;不要求 Qt 与 Web 视觉相同。
- **partial**:已有对应能力,但参数、字段、权限、状态或边界行为不完整。
- **missing**:基准可达的医生核心能力在 Python 中没有入口或 repository 合同。
- **intentionally unsupported**:明确属于 H5 布局、小程序二维码或医助/后台运营角色,当前原生医生桌面选择了不同交互或没有承载;这是审计分类,不代表产品已正式批准删除。
路由证据:
- 管理后台主菜单是服务端动态路由:Vite 收集所有 `views/**/*.vue`,菜单组件名交给 `loadRouteView` 匹配(`D:\web\zyt\admin\src\router\index.ts:9-15,28-69`)。因此接诊台主文件是 `views/patient/reception/index.vue`,问诊列表 PC 主文件是 `views/tcm/diagnosis/index.vue`
- `/tcm/diagnosis/h5` 静态指向 `index_h5.vue``D:\web\zyt\admin\src\router\routes.ts:82-85`)。
- PC 问诊列表的“查看”与双击跳转隐藏路由 `/tcm/diagnosis-readonly?id=...``tcm/diagnosis/index.vue:2165-2172`),静态路由指向 `readonly.vue``router/routes.ts:87-102`)。
- `index.vue.bak` 的后缀不是 `.vue`,不会进入 `import.meta.glob('/src/views/**/*.vue')``add.vue` 没有被现行两个入口导入;两者均未作为基准。PC 虽挂载 `detail.vue`,却没有存活的 `handleDetail` 调用;该弹窗仅由 H5 卡片点击实际触发(`index_h5.vue:121,144,264-268,1013-1015`)。
Python 页面级门控与主入口一致:接诊台使用 `doctor.appointment/lists`,问诊列表使用 `tcm.diagnosis/lists``D:\web\zyt\app\src\doctor_workstation\ui\shell.py:40-47,69-75,248-264`),判定为 **exact**
## 3. 实际可达文件与依赖图
以下只列业务 Vue/TS 和决定行为的共享文件,不展开 Element Plus、基础 popup/upload 等纯框架组件。
### 3.1 接诊台
- `views/patient/reception/index.vue`:队列、轮询、详情、通知、完成、快捷文字备注、通话入口(导入证据 `:191-210`)。
- `views/patient/reception/components/NoteTimeline.vue`:备注文本、舌苔图片、检查报告、单附件删除(`:1-121,125-132`)。
- `views/tcm/diagnosis/components/PatientInfoCard.vue`:脱敏手机号、预约与人员摘要(`:1-41`)。
- `views/tcm/diagnosis/components/PatientCaseCard.vue`:病例全字段和生命体征阈值(`:1-124,128-153`)。
- `views/tcm/diagnosis/components/DailyMatrix.vue``DiagnosisTodoList.vue`:7/30/自定义日期窗、血糖血压/饮食/运动/跟踪备注/待办(`DailyMatrix.vue:317-332,342-470`)。
- `components/chat-dialog/index.vue``ChatMessageItem.vue` 及本地录制/截图/IM 工具:实时聊天与通话、通话落库、房间绑定、结束、可选本地录制;API 导入见 `chat-dialog/index.vue:129-175`
- `views/tcm/diagnosis/edit.vue` 及其下述诊单详情依赖:`DailyMatrix.vue``CaseRecordList.vue``CallRecordPanel.vue``RecordingPlaybackBlock.vue``RecordingVideoPlayer.vue``ImChatRecordPanel.vue``AssignLogPanel.vue``AppointmentRecordPanel.vue``NoteTimeline.vue``TrackingNoteTimeline.vue``PatientOrderList.vue``PrescriptionOrderDetailDrawer.vue``components/tcm-prescription/index.vue`(导入证据 `edit.vue:788-806`)。
### 3.2 问诊列表
- `views/tcm/diagnosis/index.vue`:PC 列表、全部筛选/角标、行操作、20 秒静默轮询(导入及异步组件 `:727-746`)。
- `views/tcm/diagnosis/index_h5.vue`:静态 H5 路由;复用同一 edit/detail/prescription/appointment/watch-call 组件(`:588-630`)。原生 Python 不需要复刻 H5 布局,标为 **intentionally unsupported**;但它证明的业务状态和动作边界仍计入对比。
- `views/tcm/diagnosis/readonly.vue`:PC 隐藏详情路由;复用 PatientInfo/Case、DailyMatrix、备注、通话回放、IM、指派、挂号、业务订单组件(`:151-167`)。
- `views/tcm/diagnosis/detail.vue`H5 卡片的简版诊单详情(`:1-165`)。
- `views/tcm/diagnosis/edit.vue`:新增/编辑/只读抽屉及所有扩展 Tab。
- `components/tcm-prescription/index.vue`:按诊单开方、查已有处方、作废、处方库导入和 PDF。
- `views/tcm/diagnosis/appointment.vue`:预约、排班、号源、渠道和今日重复挂号边界。
- `views/tcm/diagnosis/components/AssistantWatchCallDialog.vue`:医助 TRTC 只拉流旁观(`:23-26,120-219`)。
- 决定字段与边界的 TS`hooks/usePaging.ts``:13-58`)、`utils/perm.ts``:3-17`)、`install/directives/perms.ts``:13-32`)、`utils/diag-display.ts``utils/blood-thresholds.ts``utils/diabetes-discovery-display.ts`
## 4. 接诊台逐项差异
| 项目 | 基准证据 | Python 证据 | 判定 | 差异/影响 |
|---|---|---|---|---|
| 页面权限 | 动态菜单页面权限为 `doctor.appointment/lists` | `shell.py:40-47,248-264` | exact | 页面级一致。 |
| 状态队列 | 仅 status 1“待接诊”和 4“已过号”(`index.vue:33-45,235` | 两个 QTab 映射 1/4`reception.py:152-156,334-336`) | exact | 状态集合一致。 |
| 今日范围 | 每次请求带当天 `start_date/end_date``index.vue:296-305` | 未发送日期(`reception.py:344-361` | missing / P0 | 非今日记录污染工作台并暴露写动作。 |
| 搜索 | `patient_name`,清空也自动搜索(`index.vue:20-32,296-305,431-436`) | UI 只在回车/搜索按钮触发;repository 将 `keyword` 改为 `patient_name``reception.py:159-168,352-360`; `repository.py:231-236` | partial | 请求字段一致;清空不自动刷新。 |
| 分页与数量 | page size 15、另一状态独立 count、无限滚动、去重合并(`index.vue:212-213,296-318,359-379,606-620`) | 固定第一页 50 条,仅显示已加载条数(`reception.py:344-386` | partial | 50 条以上不可见;没有 waiting/passed 总数或加载更多。 |
| 轮询 | 5 秒链式轮询、倒计时、队列/详情/双 count、防重入、页面隐藏暂停/恢复立即刷新(`index.vue:254-259,539-592`) | 8 秒 QTimer,只刷新当前队列;页面 QWidget 隐藏时停(`reception.py:138-140,663-672` | partial | 无倒计时/另一状态 count;应用最小化不等价于 `document.hidden`;周期不同。 |
| 异步选人 | 每次选人递增 seq、清空详情、结果校验 seq + selected id`index.vue:398-418`) | 请求进行中拒绝新选人;旧结果仍应用(`reception.py:406-418,437-447` | missing / P0 | 可显示旧患者详情并对新选择行执行其他动作。 |
| 队列行字段 | id、patient/diagnosis/doctor/assistant、日期时间、性别年龄、status、处方、remark`index.vue:215-233`);实际显示姓名、性别年龄、过号、时间、医助(`:57-90` | 显示同一核心子集(`reception.py:50-95`) | exact | 行上显示字段基本一致。 |
| 聚合详情端点 | GET `/doctor.appointment/reception?id``api/patient.ts:9-12` | `get_reception()` 同端点/参数(`repository.py:242-250`) | exact | 传输合同一致。 |
| 患者/病例字段 | PatientInfo 显示脱敏电话、性别年龄、身高体重地区、预约、医生/客服、状态/开方、remark(`PatientInfoCard.vue:3-25`);PatientCase 显示基本信息、生命体征、糖尿病史、现病史、既往/其他病史、处方意见(`PatientCaseCard.vue:8-124`) | 仅预约时间/电话/医生/医助,加主诉、临床诊断、舌脉、治则摘要和开方提示(`reception.py:241-285,471-516` | partial / P1 | 大量临床字段缺失;无法达到基准的接诊前核对深度。 |
| 手机脱敏 | 两张卡都调用 `maskPhone``PatientInfoCard.vue:8`; `PatientCaseCard.vue:15` | 直接显示 `patient_phone/phone/phone_masked` 中第一个非空值(`reception.py:478-480` | missing / P1 | API 若下发明文即展示;未受 `tcm.diagnosis/phonePlain` 控制。 |
| 医生备注读取 | 聚合详情 `doctor_notes`,编辑/只读还会 GET `/doctor.appointment/doctorNotes` 并保留详情兜底(`readonly.vue:177-217`; `api/patient.ts:29-32` | 仅消费聚合详情中的 `doctor_notes/notes``reception.py:518-548`);Remote 无 `doctorNotes` 方法 | partial | 无独立补拉;备注元数据只显示 creator/time,忽略 `note_date` 语义。 |
| 文字备注写入 | `diagnosis_id + trimmed content`,最多 500 字,空值/无诊单拒绝(`index.vue:175-181,514-536` | 同端点和空值/诊单检查(`reception.py:575-601`; `repository.py:257-272`),但 QTextEdit 无 500 字限制 | partial | 端点 exact;客户端长度边界缺失。 |
| 舌苔/报告 | NoteTimeline 支持最多 99 个图片/文件、新增后刷新、单附件确认删除(`NoteTimeline.vue:3-40,52-101,195-227,260-268`; `api/patient.ts:19-40` | repository 可发送 `tongue_images/report_files`,但 UI 不展示附件、无上传/预览/删除入口(`repository.py:257-272`; `reception.py:524-548` | missing / P1 | 核心检查资料不可见、不可维护。 |
| 日常记录 | 只读 DailyMatrix,按诊单/患者加载(`index.vue:129-139`);7/30/自定义窗、trackingWindow/trackingNotes`DailyMatrix.vue:410-458,550-564,838-875` | 无 UI、模型和 repository 合同 | missing / P1 | 接诊时看不到血糖血压、饮食、运动、跟踪备注及阈值。 |
| 编辑病历 | `tcm.diagnosis/edit` 权限后打开 `edit.vue``index.vue:155-161,474-480` | 无按钮、无 `tcm.diagnosis/detail/edit` repository 方法 | missing / P1 | 接诊工作台不能维护病历。 |
| 通知医助 | POST `/doctor.appointment/notifyAssistant {id}`,防重入、成功后刷新队列与详情(`index.vue:460-471`; `api/patient.ts:14-17`) | 端点/防按钮重入一致,但只 toast、不刷新(`reception.py:558-573`; `repository.py:252-255` | partial | 后端动作 exact;页面不会立即反映服务端副作用。 |
| 完成接诊 | 仅 apt.status 1/4,权限 `doctor.appointment/complete`,不可逆确认,POST `{id: apt.id}` 后清选中并重载(`index.vue:162-171,492-511`; `api/doctor.ts:84-87` | 同权限、确认和端点(`reception.py:326-328,610-641`; `repository.py:274-277` | partial | 正常队列内近似 exact;受“非今日队列”和错患者详情竞态放大风险,UI 本身未再校验状态。 |
| 通话标识 | 缺 patient_id 拒绝;`diagnosis_id = row.diagnosis_id || row.id``index.vue:438-453` | app 同时要求 patient/diagnosis idpayload 优先真实 diagnosis_id`reception.py:643-661`; `app.py:496-537` | exact | ID 未互换。 |
| 通话生命周期 | ChatDialog 调 get signature、start、bind、end`chat-dialog/index.vue:641-695,996-1032,1089-1096` | Remote 同四端点(`repository.py:443-483`),FIFO 生命周期按 start→bind→end`video/lifecycle.py:184-209,211-263,265-292` | exact(核心) | Python 的核心通话落库顺序一致。 |
| 通话扩展 | ChatDialog 还有 IM、截图写备注、云/本地录像上传(`chat-dialog/index.vue:129-175,241-265,1276-1301` | 原生 companion 未提供这些管理后台附属入口 | partial | 核心视频可用;聊天、截图备注和本地录像回填不等价。 |
### 接诊台权限差异
- 基准悬浮备注/编辑/完成分别使用 `doctor.appointment/addDoctorNote``tcm.diagnosis/edit``doctor.appointment/complete``patient/reception/index.vue:146-171`)。Python 已实现备注与完成门控,但编辑缺失(`reception.py:322-331`)。
- 基准队列行“通知医助”和“发起通话”没有 `v-perms``index.vue:75-90`)。Python 额外要求 `doctor.appointment/notifyAssistant``tcm.diagnosis/videoQr``reception.py:322-325`)。后者在基准中是“小程序视频二维码”权限,不是直呼权限;这可能把拥有接诊页但没有二维码权限的医生挡在视频之外,判为 **partial / P1**
- NoteTimeline 的“添加文字备注”检查 addDoctorNote 权限,但两个媒体 picker 和删除图标只受 readonly 控制(`NoteTimeline.vue:3-40,64-99,150-151`)。这是基准自身的权限不对称,不能据此推导 Python 应无条件开放媒体写入。
## 5. 问诊列表逐项差异
### 5.1 列表、筛选与分页
| 项目 | 基准 | Python | 判定 |
|---|---|---|---|
| 列表端点 | GET `/tcm.diagnosis/lists``api/tcm.ts:3-6` | 同端点(`repository.py:414-440` | exact |
| 分页 | `usePaging` 默认 page size 15,发送 `page_no/page_size``usePaging.ts:13-21,25-48` | page size 20、PageResult 容错(`consultations.py:93-95,264-300` | partial |
| 默认范围 | PC/H5 均默认 `appointment_date=today``index.vue:2174-2182`; `index_h5.vue:1427-1437`) | 默认日期同为今天;Remote 在起止相等时转换为 `appointment_date``consultations.py:129-141,248-251,264-282`; `repository.py:420-424` | exact(日期) |
| 默认状态 | 基准默认今天的所有挂号语义,不额外传 appointment status | Python status 下拉默认 1,并转成 `appointment_status=1``consultations.py:121-128,276-279`; `repository.py:429-434` | partial / P1 |
| 关键词 | `keyword`,提示姓名/手机号(`index.vue:72-75,773-775` | 同字段(`consultations.py:116-120,276` | exact |
| 日期快捷项 | 前天、昨天、今天、明天、后天、全部;另有待预约、已完成、待分配及 9 路角标(`index.vue:963-1085`) | 单个起止日期控件和“今天”;无角标 | partial |
| 挂号/确认 | `has_appointment` 0/1`diagnosis_confirmed` 0/1`index.vue:78-97,804-833` | 无这两个筛选 | missing |
| 诊断/证型/医助 | 字典筛选(`index.vue:104-113,1158-1188` | 无 | missing |
| 最近挂号 | 起止日期 + 渠道,且与 appointment_date/未挂号互斥(`index.vue:114-149,1197-1228` | 多日 start/end 被隐式映射为 latest appointment 起止,但无渠道、无互斥提示(`repository.py:420-428` | partial |
| 最近指派 | 起止日期(`index.vue:124-133,1203-1216,1230-1232` | 无 | missing |
| 未服务排序 | `sort_unserved_days=asc/desc``index.vue:297-304,1234-1248` | 无 | missing |
| 待分配宽搜 | 有关键词时只保留 page、pending_assign 和 pending_assign_keyword,主动清空其他筛选(`index.vue:836-861,923-954` | 无 | intentionally unsupported(医助分配) |
| 定时刷新 | PC 20 秒且 document.hidden 时跳过(`index.vue:1098-1105`);H5 15 秒(`index_h5.vue:1418-1437` | 20 秒,页面 QWidget 隐藏时停(`consultations.py:238-240,325-334` | exactPC 周期)/ partial(可见性语义) |
| 路由 query id | `?id=` 会在异步 edit ref 就绪后打开编辑(`index.vue:1343-1357,2184-2188` | 无 deep-link | missing |
Python 的“状态下拉”不是基准筛选:它提供待接诊/取消/完成/过号(`consultations.py:121-127`),Remote 再把 3 映射为 `completed_appointment=1`,其他值映射成 `appointment_status``repository.py:429-434`)。基准 PC 没有该下拉;“已完成”定义为至少有一条 appointment.status=3`index.vue:1007-1020`),取消状态也不在展示映射中。因此这里只能判 **partial**,不能视为同一合同。
### 5.2 行字段与状态映射
基准 PC 行字段:
- 诊单 ID/NEW、患者姓名、性别年龄(`index.vue:191-218`)。
- `appointments[]` 多挂号;回退到 appointment_id/status/doctor/time 主字段;最近渠道(`:219-261,1747-1757`)。
- 确认状态来自 `DiagnosisViewRecord.some(is_confirmed == 1)``:262-267,1313-1317`)。
- 复诊时间/医生/处方已作废、助理、是否开方(`:268-296`)。
- `unserved_days` 与 last blood tooltip,阈值 null 灰、>=7 红、3-6 橙、<=2 绿(`:297-317,1319-1329`)。
- `video_call_hint.state` 映射 none/live/pending_room/label`:318-341,1373-1427`)。
Python 只显示预约时间、患者、状态、联系方式、医助、确认、处方、remark(`consultations.py:183-225`),判 **partial / P1**。具体错误为:
1. `Consultation` 模型没有正式字段 `has_appointment``appointment_status``appointments`、followup、unserved、last blood、video hint;虽保留 raw,但 UI 不读取这些字段(`core/models.py:322-350`)。
2. 模型从根 `status` 优先取值,只有根 status 缺失才使用首个 nested appointment.status`:362-366,406-414`)。基准明确把 appointment status 放在 `appointment_status`/`appointments[].status`,并将诊单本身的 status 用作启用状态。这是 P0 视频门槛错误的根因。
3. Python `diagnosis_confirmed` 属性只来自根 `confirmed/diagnosis_confirmed``:413-423`),不计算 `DiagnosisViewRecord`;存在把已确认显示成待确认的风险。
4. Python 联系方式列直接显示 `patient_phone/phone/phone_masked``consultations.py:195-201`),而基准 PC/H5 列表不展示手机号明文;编辑页仅 `tcm.diagnosis/phonePlain` 可保持明文(`edit.vue:822-856,1263-1277`)。判 **missing / P1(隐私)**
挂号状态基准只有:1=已预约、3=已完成、4=已过号(`index.vue:1732-1745`);取消仅允许 status 1/4,status 3 明确拒绝,且多挂号必须按具体 appointment id 操作(`:1782-1857`)。Python 把 2 显示为“已取消”,并把 4 也设为可直呼(`consultations.py:53-58,310-317`),不符合基准。
### 5.3 详情、编辑与操作
| 能力 | 基准行为与证据 | Python | 判定 |
|---|---|---|---|
| 只读病例详情 | PC 查看/双击需 `tcm.diagnosis/readonlyDetail`,路由 query id;加载 `/tcm.diagnosis/readonlyDetail` + doctorNotes`index.vue:345-354,2165-2172`; `readonly.vue:172-240`; `api/tcm.ts:8-11`) | 表格双击直接发起视频;无详情页/端点(`consultations.py:227-228,319-323` | missing / P1 |
| H5 简版详情 | 卡片头/体打开 `detail.vue`GET `/tcm.diagnosis/detail`,显示基础、病史、舌苔/报告、症状舌脉治则处方医嘱状态(`index_h5.vue:121,144`; `detail.vue:10-122,157-165` | 无 | missing |
| 完整诊单编辑 | `tcm.diagnosis/edit`GET detail 后 POST edit;新增用 add;手机号/身份证唯一性检查(`edit.vue:1263-1375`; `api/tcm.ts:44-61,77-90` | 无 repository 方法或 UI | missing / P1 |
| 编辑字段 | patient/id card/phone/gender/age/marital/height/weight/region/BP/glucose/type/status/source/card/current medicine/local diagnosis、全部现病史/既往史/其他史/symptoms/remark`edit.vue:86-624,916-970` | Consultation 只存列表摘要(`core/models.py:322-350` | missing / P1 |
| 编辑验证 | 姓名、手机、性别、年龄、空腹血糖、诊断类型、当地医院必填;手机号正则;身份证 18 位正则;病史发现最多 50(`edit.vue:1026-1175` | 无 | missing |
| 开方/查看 | `tcm.diagnosis/kaifang`;审核通过且未作废显示“查看”,否则“开方”;传 diagnosis id + appointment id`index.vue:354-363,1671-1694`) | 无问诊行开方入口;只有独立已开处方列表/详情 | missing / P1 |
| 处方边界 | 先按 appointment 查已有;临床诊断、至少一味药、药名/正剂量、无重复、手写签名必填;可作废但有业务订单时禁止(`tcm-prescription/index.vue:1829-1922,2219-2328` | Remote 仅 list/detail issued prescription`repository.py:354-397`),无 add/getByAppointment/void | missing / P1 |
| 预约 | `tcm.diagnosis/guahao`;医生排班/未来 7 天/可用时段;今天过去时段禁用;渠道必填;指定自媒体渠道补充必填;今天已有 status1/4 禁止重复约今天(`appointment.vue:239-420,423-448,535-619,669-724` | 无 | intentionally unsupported(后台/医助调度) |
| 取消挂号 | status1/4 可取消,3 拒绝,多 appointment 精确到子记录(`index.vue:1782-1857`; `api/doctor.ts:59-67` | 无 | intentionally unsupported(后台/医助调度) |
| 指派/取消指派 | `tcm.diagnosis/assign`;单条/批量、继承标志、取消传 assistant_id=0`index.vue:1532-1661` | 无 | intentionally unsupported(医助管理) |
| 视频二维码/确认二维码 | 仅 `has_appointment && appointment_status=1`;需 weapp config;权限 `videoQr`/`guahao``index.vue:390-391,1778-1780,1964-2055` | 用同一 `videoQr` 权限启动原生直呼(`consultations.py:102-106` | intentionally unsupported(二维码)+ partial(权限/状态复用错误) |
| 医助旁观 | 仅当前被指派医助 + `tcm.diagnosis/watchCall`pending_room 显示但不能进,live 才进;TRTC 只拉流(`index.vue:1380-1413`; `AssistantWatchCallDialog.vue:120-219`) | 无旁观角色/入口 | intentionally unsupported(医助角色) |
| 挂号日志 | `tcm.diagnosis/guahaoLogList`,空数组容错,显示 action/operator/summary/time`index.vue:1859-1897` | 无 | intentionally unsupported |
| 补身份证 | 15/18 位前端校验,POST fillIdCard 后自动年龄(`index.vue:1918-1961` | 无 | intentionally unsupported(后台资料维护) |
| 企微记录 | records/contact 并行、20 条分页、文字 note 新增/删除(`index.vue:2066-2160` | 无 | intentionally unsupported(后台企微归档) |
| 创建订单 | order_type、amount、remark 后生成订单二维码(`index.vue:1430-1515` | 无 | intentionally unsupported(后台运营) |
### 5.4 只读详情的权限化子区块
基准 `/tcm/diagnosis-readonly` 不是一个简单摘要,它按权限加载:
- `tcm.diagnosis/dailyRecord`DailyMatrix`readonly.vue:42-54,183`)。
- `doctor.appointment/addDoctorNote`NoteTimeline 的可写版本在 editreadonly route 固定只读(`readonly.vue:56-70`)。
- `tcm.diagnosis/patientOrders`:业务订单(`:74-88`)。
- `tcm.diagnosis/huifang`:通话录制回放(`:90-101`)。
- `tcm.diagnosis/chat`IM 记录(`:103-113`)。
- `tcm.diagnosis/assign``tcm.diagnosis/detail`:指派日志(`:115-131`)。
- `doctor.appointment/lists`:挂号记录(`:133-146`)。
Python 问诊列表没有选中详情容器,因此上述全部为 **missing**;其中病例、日常记录、备注、挂号记录属于医生核对链路,列 P1;订单、指派和后台 IM 可按角色继续列 intentionally unsupported。
## 6. API 覆盖矩阵
此表只包含从上述可达 view/component 实际导入的业务 API;端点定义证据集中在 `D:\web\zyt\admin\src\api\patient.ts:3-40``api\doctor.ts:49-87``api\tcm.ts:3-109,202-310,313-372,591-648`
| API 组 | 管理后台可达用途 | Python 状态 |
|---|---|---|
| `doctor.appointment/lists` | 接诊队列、预约/挂号记录、最近就诊/今日重复检查 | **exact 端点 / partial 参数**;接诊台漏 today |
| `doctor.appointment/reception` | 接诊聚合详情 | **exact** |
| `notifyAssistant` | 通知医助 | **exact 端点 / partial 刷新与权限** |
| `addDoctorNote` | 文字、舌苔、报告、通话截图 | **partial**;Remote 参数齐,UI 只有文字 |
| `doctorNotes`, `deleteDoctorNoteImage` | 备注补拉、附件删除 | **missing** |
| `doctor.appointment/complete` | 完成接诊 | **exact 端点 / partial 上下文边界** |
| `tcm.diagnosis/lists` | 问诊列表、全部角标 | **exact 端点 / partial filters/model** |
| `readonlyDetail`, `detail`, `add`, `edit`, `delete`, `checkPhone`, `checkIdCard`, `fillIdCard` | 详情与资料维护 | **missing** |
| `assign`, `assignLogList`, `getAssistants`, `watchCall` | 医助分配与旁观 | **intentionally unsupported** |
| `trackingWindow`, `trackingNotes`, `addTrackingNote` | 日常记录/备注窗 | **missing** |
| blood/diet/exercise add/edit | 编辑页日常记录 | **missing** |
| diagnosisTodo lists/add/cancel | 日常记录待办 | **missing** |
| `getCallSignature`, `startCall`, `bindCallRoom`, `endCall` | 原生实时视频核心 | **exact** |
| `getCallRecords`, `attachLocalCallRecording`, `createManualCallRecord` | 回放与人工补传 | **missing**(可选扩展) |
| `getImChatMessages`, `triggerImChatSync` | IM 归档 | **missing** |
| `prescription/listByDiagnosis`, `getByAppointment`, `add`, `void` | 病例处方与开方 | **missing / P1** |
| `prescription/detail`, `prescription/lists` | 查看已开处方 | **exact**,但只在独立页面 |
| `prescriptionLibrary/lists` | 开方时导入本人模板 | **exact endpoint**,但问诊开方 UI 缺失 |
| availableSlots/create/roster/lists | 预约 | **intentionally unsupported** |
| generateMiniProgramQrcode/generateOrderQrcode + weapp config | 二维码 | **intentionally unsupported**,由原生直呼替代 |
| WeChat records/contact/add/delete | 企微归档 | **intentionally unsupported** |
| prescriptionOrder lists/detail/logs | 只读业务订单 | **intentionally unsupported** |
Remote 的相关实现块只覆盖 appointment/reception/actions`D:\web\zyt\app\src\doctor_workstation\services\repository.py:226-277`)、diagnosis list 与四个 call endpoint`:414-483`);因此上述“missing”不是 UI 隐藏但 repository 已具备,而是端到端合同确实不存在。
## 7. 权限码对照
### 基准中实际出现
- 页面/读取:`doctor.appointment/lists``tcm.diagnosis/lists``tcm.diagnosis/readonlyDetail``tcm.diagnosis/detail``tcm.diagnosis/dailyRecord``tcm.diagnosis/patientOrders``tcm.diagnosis/huifang``tcm.diagnosis/chat`
- 接诊写动作:`doctor.appointment/addDoctorNote``doctor.appointment/complete``tcm.diagnosis/edit`
- 问诊列表动作:`tcm.diagnosis/add``edit``delete``assign``kaifang``guahao``videoQr``watchCall``guahaoLogList``order`
- 编辑扩展:`tcm.diagnosis/phonePlain``tcm.diagnosis/chufang``tcm.diagnosis/setRevisitSlotStartOffset``tcm.prescriptionOrder/detail`
证据:接诊 `patient/reception/index.vue:146-171`;问诊 PC `tcm/diagnosis/index.vue:318-400`readonly `readonly.vue:42-146`edit `edit.vue:674-748,822-856`PatientOrderList `components/PatientOrderList.vue:20-33,99-114`
### Python 实际门控
- 页面:两个页面码 **exact**`shell.py:40-75`)。
- 接诊:`notifyAssistant``videoQr``complete``addDoctorNote``reception.py:322-331`);缺 edit。
- 问诊:只有 `videoQr``consultations.py:102-106`);其余动作根本没有 UI。
- Python PermissionSet 本身支持 `*`、all/any`core/permissions.py:60-104`),问题在页面选择了哪些码,而不是权限容器能力。
特别注意:Web 的 `v-perms` 是数组 OR`install/directives/perms.ts:13-32`),`hasPermission()` 工具是数组 AND`utils/perm.ts:3-17`);当前页面绝大多数调用只有一个码,唯一显式 OR 的 assign/detail 是两个独立调用(`readonly.vue:117-120`),所以本次差异不受二者实现差别影响。
## 8. P0 / P1 缺口清单(按严重度排序)
### P0
1. **RACE-RECEPTION-01:快速切换患者导致详情与选择错配。** 修复验收应覆盖 A 请求未返回时选 B,A/B 任意顺序返回后 UI、备注 diagnosis_id、通知/完成 appointment_id 和视频 payload 必须全部指向 B;详见第 1、4 节证据。
2. **SCOPE-RECEPTION-02:接诊列表漏 `start_date=end_date=today`。** 必须在 UI 或 repository 的专用 reception query 中固定今日,不能让通用 appointment list 隐式决定。
3. **STATE-CALL-03:问诊视频使用 `status` 而非 `has_appointment + appointment_status`,且错误允许 4。** 模型和 UI 都需改;只修 UI 的字段名仍会被当前 dataclass 的根 status 优先级遮蔽。
### P1
1. **DETAIL-04:问诊列表没有 `readonlyDetail`/详情入口,双击反而直呼。** 医生无法先核对完整病例、日常记录、备注和挂号历史。
2. **EDIT-05:接诊台和问诊列表均无诊单详情/edit 合同。** 基准接诊台明确提供“编辑病历”。
3. **RX-06:没有从诊单开方/查看/作废的工作流。** 独立“已开处方”页面不能替代 `diagnosis_id + appointment_id` 上下文开方。
4. **RECEPTION-DETAIL-07:接诊详情只显示摘要,缺完整 PatientCase、日常记录、备注附件和附件删除。**
5. **PRIVACY-08:接诊和问诊列表可能直接显示明文手机号,未遵守基准的 mask/phonePlain 边界。**
6. **PERM-09:用 `tcm.diagnosis/videoQr` 保护原生直呼、用未在基准按钮上出现的 `doctor.appointment/notifyAssistant` 保护通知,可能让合法接诊医生缺动作;同时缺 `tcm.diagnosis/edit` 入口。** 需要后端权限清单确认专用 call permission,而不是继续借用二维码码。
7. **MODEL-10Consultation 未正式解析 appointment_status、has_appointment、多挂号、DiagnosisViewRecord、unserved/video hint 等;当前确认和状态显示不可靠。**
8. **FILTER-11:问诊筛选缺 has_appointment、confirmed、诊断类型、证型、医助、最新挂号渠道、最新指派、未服务排序及顶部 count;Python 自创 status 下拉又改变默认结果集。**
9. **BOUNDARY-12:文字备注没有 500 字客户端限制;通知成功不刷新;完成动作未在 handler 中二次验证 1/4。**
不列 P0/P1 的 intentionally unsupported 项:H5 响应式布局、小程序/订单二维码、批量指派、医助旁观、企微后台归档、后台订单创建和排班预约。若产品决定医生桌面也承担医助/运营职责,应把这些重新分类为 missing 并另立需求。
## 9. 基准自身的可疑点(不可静默照搬)
以下均是当前 `admin/src/views` 的真实行为,本报告仅记录,不用其他资料修正它:
1. 接诊台“编辑病历”把 `diag.patient_id` 传给 `edit.open('edit', id)``patient/reception/index.vue:474-476`),而 edit 将该 id 直接传给 `/tcm.diagnosis/detail``tcm/diagnosis/edit.vue:1263-1265,1288-1302`);问诊列表传的则是 `row.id``index.vue:1667-1669`)。这是明显 ID 口径冲突。
2. PC/H5 的“视频二维码”请求把 `diagnosis_id` 赋成 `row.appointment_doctor_id``index.vue:1987-1993`; `index_h5.vue:1262-1268`),而“诊单二维码”正确使用 `row.id``index.vue:2037-2042`)。Python 原生直呼当前没有复制此错误。
3. `PatientCaseCard` 的 consultation_type 三元两边都返回“复诊”(`PatientCaseCard.vue:148-151`)。
4. 接诊行通知/通话无权限指令、NoteTimeline 媒体写入未复用 addDoctorNote 门控,见第 4 节;应先确认服务端授权模型,再决定桌面行为。
## 10. 建议的最小合同测试
后续实现至少应加入不发网络的合同测试:
1. Reception request 必含 status、today start/end、patient_name、page_no/page_size。
2. A/B 详情乱序返回不会产生跨患者详情或写动作目标。
3. Consultation row 的 `status=1, appointment_status=3` 不可视频;`status=1, has_appointment=1, appointment_status=1` 可视频;status 4 一律不可从问诊列表直呼。
4. `DiagnosisViewRecord=[{is_confirmed:1}]` 显示已确认;多 appointments 保留并正确挑选 active appointment。
5. 无 phonePlain 权限时列表和详情只出现掩码。
6. readonlyDetail、edit 和 prescription 动作分别由准确权限码控制;缺码时不创建可调用控件。
7. 备注 501 字在客户端拒绝;媒体 note payload 保留 tongue/report 数组;删除附件的三字段合同固定。
+275
View File
@@ -0,0 +1,275 @@
# 腾讯云 TRTC 接入 Python / PySide6 跨平台桌面视频面诊研究
> 调研日期:2026-08-10
> 范围:Windows / macOS 桌面端;Python / PySide6 业务程序;1 对 1 视频面诊,可扩展屏幕共享。
> 来源原则:仅使用腾讯云、Tencent RTC 官方文档与官方 SDK API 文档。文中标为“工程判断/建议”的内容是根据官方能力边界作出的架构推断,并非腾讯云对 Python 或 PySide6 的兼容性承诺。
## 结论摘要
1. **腾讯云没有官方 Python 或 PySide6 TRTC 客户端 SDK。** 用户指定的[产品概述(文档 16788](https://cloud.tencent.com/document/product/647/16788)列出了 Windows、macOS、Web、Electron 等平台,没有 Python/PySide6。官方还提供了 Windows/macOS 的 **C++ Qt** 集成示例,但它不是 Python 绑定,也没有证明 PySide6 / Qt 6 ABI 兼容。
2. **推荐生产架构:PySide6 保留为业务主程序,音视频做成独立 Electron RTC companion(伴随进程/独立面诊窗口),由后端签发进房票据,PySide6 与 companion 通过本机受认证 IPC 交互。** 1 对 1 场景优先使用底层 `trtc-electron-sdk`;若要快速获得完整会议 UI、成员管理和会控,可选官方 TUIRoomKit Electron。官方明确将 TUIRoomKit Electron 用于“医疗问诊”等场景。
3. **最快的业务 MVP 是从 PySide6 打开系统浏览器中的 HTTPS Web SDK 面诊页。** 这是官方浏览器支持路径。把 Web SDK 嵌入 `QWebEngineView` 技术上有机会可行,但 QtWebEngine 不在腾讯的具名支持矩阵中;官方只说理论上支持 Chromium 56+,并要求对 WebView 等未列环境运行能力检测。因此,`QWebEngineView` 只能作为验证分支,不能在未完成双平台全量实测前作为生产承诺。
4. **若“必须在同一 PySide6 窗口内原生渲染”是硬要求,才选择 Native C++ 桥接。** 需要自行为 Windows/macOS 编译 C ABI/CPython 扩展,处理原生渲染句柄、回调线程、Qt 5/Qt 6 兼容、签名与打包;这是可落地但成本和维护风险最高的方案。
5. **生产环境的 SDKSecretKey 只能放在服务端。** 客户端仅获得短期 `UserSig`;需要面诊房间级与媒体权限控制时,开启 Advanced Permission Control 并由服务端签发 `PrivateMapKey`。房间 ID 不能视作访问控制。
## 方案对比
| 方案 | 官方支持边界 | 与 Python/PySide6 的关系 | 摄像头/麦克风/屏幕分享 | 主要风险 | 判断 |
|---|---|---|---|---|---|
| Web SDK + 系统浏览器 | 官方支持 Windows/macOS 主流浏览器;生产要求 HTTPS | PySide6 打开带一次性业务会话的 HTTPS 页面;无 Python SDK 绑定 | 官方均支持;屏幕分享受浏览器/OS 授权和用户选择器约束 | 独立浏览器窗口、业务 UI 一体化较弱 | **最快、风险低的 MVP/兜底** |
| Web SDK + `QWebEngineView` | 官方称理论支持 Chromium 56+,未列环境(含 WebView)需 `TRTC.isSupported()`/能力检测;未明确认证 QtWebEngine | JS 运行在嵌入页,通过 WebChannel/IPC 与 Python 通信(工程自建) | 需验证嵌入引擎的媒体权限、设备切换、屏幕选择与 macOS Screen Recording | 腾讯支持边界、QtWebEngine 版本与权限行为不确定 | **仅作 POC;通过准入测试后才可采用** |
| Electron SDK / TUIRoomKit Electron | 官方支持 Windows/macOSSDK 是 Node 原生模块;官方提供设备权限、打包、屏幕分享和医疗问诊组件指引 | Python 不能直接 `import`;作为独立伴随进程最清晰 | 完整支持,DOM 承载本地/远端画面;可用辅路同时保留摄像头与屏幕 | 包体较大、需维护本机 IPC、两平台分别构建签名 | **推荐生产方案** |
| Native C++ SDK + 自研桥 | 官方全平台 C++ API、Windows/macOS SDK、C++ Qt 示例 | 自行写 C ABI/pybind11/CPython 桥;腾讯不提供 Python/PySide6 绑定 | 能力最完整,包含设备管理、测试、屏幕源枚举、原生渲染与私有加密 API | C++ ABI、Qt 版本、线程、崩溃隔离、双平台构建维护 | **单窗口原生体验的二期方案** |
## 各方案适配判断
### 1. Web SDK
官方事实:
- TRTC Web SDK 是 JavaScript SDK,视频渲染目标是 HTML 元素;通过 `TRTC.create()``enterRoom()``startLocalAudio()``startLocalVideo()``startRemoteVideo()``exitRoom()``destroy()` 完成生命周期,见[Web & H5 快速接入](https://cloud.tencent.com/document/product/647/116544)。
- 官方平台页称理论上支持所有 Chromium 56+ 浏览器;对支持表之外的环境,建议用 `TRTC.isSupported()` 或[能力检测页](https://web.sdk.qcloud.com/trtc/webrtc/demo/detect/index.html)检测。快速 Demo 文档还特别提到 WebView 等环境应先检测,见[Web Demo 准备工作](https://cloud.tencent.com/document/product/647/32398)和[Web API 概览/平台要求](https://intl.cloud.tencent.com/zh/document/product/647/41664)。
- 生产环境推流和屏幕分享要求 HTTPS;HTTP 生产页只能播放,不能上麦/屏幕分享。本地 `localhost` 可用于开发。
- 设备名称和 `deviceId` 在获得摄像头/麦克风授权前可能为空;应先完成授权再展示设备详情。
- 桌面 Web 支持 `startScreenShare()`;用户可能从浏览器系统 UI 停止分享,业务必须监听分享停止事件并恢复 UI 状态,见[Web 屏幕分享](https://intl.cloud.tencent.com/zh/document/product/647/35163)。
对 PySide6 的工程判断:
- 系统 Chrome/Edge/Safari 是具名支持路径,最适合快速验证服务端、UserSig、房间和媒体链路。
- `QWebEngineView` 虽基于 Chromium,但不是腾讯文档具名认证平台。只有在目标 PySide6 随附的 QtWebEngine 上同时通过能力检测和真实设备测试,才能认为本项目可用。
- 若验证嵌入方案,应把下列项目设为硬门槛:摄像头/麦克风首次授权及拒绝后恢复、设备插拔和切换、远端音频自动播放、窗口/整屏分享、从系统分享条停止、macOS Screen Recording 权限、窗口最小化/休眠恢复、打包后仍可用。
- `file://` 虽在官方表中可用,但生产仍建议加载受控 HTTPS 页面,以便版本发布、CSP、证书与安全响应集中管理。
### 2. Electron SDK / TUIRoomKit Electron
官方事实:
- 当前[Electron 快速接入](https://cloud.tencent.com/document/product/647/116549)要求 Electron 工程安装 `trtc-electron-sdk`,实际加载 `trtc_electron_sdk.node` 原生模块;支持 Windows 和 macOS,并给出了两平台不同的原生模块资源路径、摄像头/麦克风/屏幕权限检查及打包配置。
- `startLocalPreview()` 的渲染目标是 `HTMLElement`,不是 PySide `QWidget``startLocalAudio()` 可选 Speech 模式,官方说明其噪声抑制和弱网抗性更强,适合面诊语音。
- 1 对 1 视频面诊应使用 `TRTCAppSceneVideoCall`,而不是直播场景。官方将 VideoCall 定位为 1 对 1 或 300 人以内实时通话。
- [TUIRoomKit Electron 接入](https://cloud.tencent.com/document/product/647/129879)明确列出“医疗问诊”场景,并内置房间管理、音视频控制、屏幕共享、成员管理和布局。
- Electron 屏幕分享支持主路和辅路。辅路可在摄像头继续上行的同时分享屏幕;一个 TRTC 房间目前只能有一路屏幕分享,见[Electron 屏幕分享](https://intl.cloud.tencent.com/zh/document/product/647/47619)。
- 官方 Electron API 还提供设备列表、设备切换、摄像头/麦克风/扬声器测试和网络测速;网络测速应在进房前进行,见[Electron SDK API](https://web.sdk.qcloud.com/trtc/electron/doc/en-us/trtc_electron_sdk/TRTCCloud.html)。
对 PySide6 的工程判断:
- Node 原生模块不能当作 Python 模块直接加载。把它塞进 PySide6 进程会引入 Node/Electron 运行时、DOM 渲染和 ABI 问题,不值得。
- 独立 RTC companion 可将故障、升级和媒体权限与 Python 主进程隔离;PySide6 只负责预约、病历、支付等业务 UI,Electron 只持有当次短期票据并负责通话。
- Windows 与 macOS 应各自在目标系统构建、签名并验证产物;不要把包含 `.node`/`.dll`/framework 的包当作纯 JS 跨平台包。
- IPC 采用本机命名管道/Unix domain socket 或 loopback WebSocket,并以每次启动随机 nonce 双向认证;不要把 `UserSig` 放在命令行参数、URL 查询串或长期日志中。此为工程安全建议,不是腾讯 SDK 的内置保证。
### 3. Native C++ SDK 与 Qt 桥接
官方事实:
- [全平台 C++ API 概览](https://cloud.tencent.com/document/product/647/32268)包含实例/回调、进退房、摄像头和远端渲染、音频、设备管理、屏幕分享、网络质量、连接恢复及私有加密等完整能力。
- 腾讯提供[Qt Windows/macOS 集成文档](https://intl.cloud.tencent.com/zh/document/product/647/39665)Windows 使用 C++ SDK 的 `liteav.lib`/DLLmacOS 引用 `TXLiteAVSDK_TRTC_Mac.framework` 的 C++ 接口。示例是 Qt Widgets/C++macOS 文档写的是 Qt 5.10+。
- macOS 需要在 `Info.plist` 声明摄像头和麦克风权限;官方 Electron 指引另外检查 `screen` 权限。
- C++ API 可枚举/切换设备并运行摄像头、麦克风和扬声器测试;屏幕分享可枚举窗口/屏幕、选择目标、设置辅路参数及包含/排除窗口。
对 PySide6 的工程判断:
- PySide6 使用 Qt 6,而官方 Qt 示例仍以 Qt 5 为基线,不能把“支持 C++ Qt”直接等同于“支持 PySide6”。
- 若采用,推荐制作**很薄的 C ABI 桥**,不要让 Python 直接调用 C++ ABI:桥内持有 `ITRTCCloud`、回调对象和 SDK 生命周期,只向 Python 暴露稳定的 opaque handle、进退房、设备、屏幕分享和事件队列。
- 视频尽量由 SDK 绑定原生子窗口/视图句柄渲染;不要默认把每帧 YUV 回调到 Python,这会显著增加跨语言复制、GIL 和掉帧风险。
- SDK 回调可能不在 Qt UI 线程,桥层必须投递到 PySide6 主线程;退出时顺序应固定为停止屏幕分享/本地采集/远端渲染、`exitRoom`、等待退房回调、移除回调、销毁实例。
- 该路线必须先验证 Qt 版本和原生窗口句柄,且建议在正式承诺前向腾讯云提交工单确认目标 SDK 版本的 Qt 6/PySide 嵌入边界。
## 推荐生产架构
```text
┌──────────────────────┐ HTTPS ┌────────────────────────┐
│ PySide6 业务主程序 │ ───────────────────▶ │ 业务后端 / RTC Ticket │
│ 预约、病历、面诊入口 │ │ 鉴权、预约授权、UserSig │
└──────────┬───────────┘ │ PrivateMapKey、结束房间 │
│ 本机受认证 IPC └────────────┬───────────┘
▼ │ TRTC REST / callback
┌──────────────────────┐ ┌─────────────▼──────────┐
│ Electron RTC companion│ ◀──── RTC 媒体 ────▶ │ 腾讯云 TRTC │
│ 设备、视频、屏幕分享 │ └────────────────────────┘
└──────────────────────┘
```
### 组件职责
**PySide6 主程序**
- 只传预约 ID/面诊动作,不生成或持久化 SDKSecretKey。
- 调用业务后端获取本次短期进房票据,启动/聚焦 RTC companion。
- 展示 companion 回传的 `preflight / joining / connected / reconnecting / ended / error` 状态。
- 业务“面诊已结束”不能只依赖窗口关闭事件,应由客户端结果、TRTC 服务端回调和后端结束动作共同收敛。
**业务后端 / RTC Ticket 服务**
- 验证当前登录用户确实是该预约的医生或患者,并验证允许进房的时间窗和预约状态。
- 服务端派生 `userId``roomId`,生成 `UserSig`;启用高级权限控制时同时生成 `PrivateMapKey`
- 返回最小票据:`sdkAppId``userId`、一种且仅一种 room ID、`userSig``expiresAt`、可选 `privateMapKey`、业务角色与 UI 权限。SDKSecretKey 永不返回。
- 提供“结束面诊”接口;需要强制结束时调用官方 `DismissRoom`/`DismissRoomByStrRoomId`,见[解散房间 API](https://cloud.tencent.com/document/api/647/50089)。
- 接收房间/媒体回调,校验签名并幂等处理。
**Electron RTC companion**
- 使用底层 `trtc-electron-sdk` 完成 1 对 1 VideoCall;若需求接近完整会议产品,则用 TUIRoomKit Electron。
- 不保存长期登录态或云密钥;窗口关闭、崩溃和正常退房均向 PySide6/后端报告。
- 所有摄像头、麦克风和屏幕分享都由用户明确操作开启,并在界面持续显示状态。
## UserSig 与房间权限
### UserSig
- [官方用户鉴权文档](https://cloud.tencent.com/document/product/647/17275)明确指出:客户端计算 UserSig 只适合 Demo。客户端代码,尤其 Web,容易被反编译;泄露 SDKSecretKey 会导致腾讯云资源被盗用。
- 正式环境流程必须是:客户端先向业务服务器请求;服务器按 `SDKAppID + UserID` 生成 UserSig;客户端仅把结果交给 SDK。官方提供 Python HMAC-SHA256 服务端示例,因此 Python 后端生成完全可行。
- 建议把 UserSig 有效期控制为“预约可加入窗口 + 最大面诊时长 + 合理重连缓冲”,并在每次重新进入房间前重新授权。腾讯官方云助手的[Web 进房说明](https://cloud.tencent.com/document/product/1715/104507)指出原始 TRTC 的 UserSig 在进房时校验、进房后到期不影响当前通话;若采用包含 IM 登录的 TUIRoomKit,还要监听 `onUserSigExpired` 并从后端续签。
- SecretKey 放在服务端密钥管理系统/受限环境变量中;禁止进入桌面包、前端 JS、崩溃转储、遥测和调试日志。
### PrivateMapKey(高级权限控制)
- UserSig 证明某 UserID 有权使用该 SDKAppID,**不等于有权进入某个面诊房间**。对视频面诊,建议评估开启[高级权限控制](https://intl.cloud.tencent.com/zh/document/product/647/35157)。
- `PrivateMapKey` 绑定 room ID 与权限位,可分别控制创建房间、进房、收发音频、收发主路视频、收发辅路(屏幕分享)。必须由服务端计算。
- 示例权限策略:
- 医生:创建/进入、收发音频、收发视频、发送/接收辅路。
- 患者:进入、收发音频、收发视频、接收辅路;若不允许患者分享,则不授予“发送辅路”。
- 若不能保证医生先进入,需要给患者也授予创建房间,或由业务规则强制医生先创建。
- 启用高级权限控制后,同一 SDKAppID 下所有用户都必须携带 PrivateMapKey;已有线上应用不能直接无迁移开启。建议新建独立 SDKAppID 做灰度验证。
## 房间与用户生命周期
官方[基本概念](https://cloud.tencent.com/document/product/647/46351)给出的关键规则:
- 不存在的房间在首个用户进入时自动创建。
- 通话模式下,所有用户主动退房后房间立即解散;所有人异常掉线时,服务端约 90 秒后清理并解散。异常等待时间仍计入用量。
- 数字 `roomId` 与字符串 `strRoomId` 是两套不同房间,不能混用;全端和服务端必须统一一种类型。
- 同一 UserID 同时进入同一房间会互踢/干扰。UserID 应由后端稳定映射;若允许同一账号多设备同时加入,应给每个设备/会话分配唯一 UserID。
- 原始 TRTC 的远端用户进出回调用于维护成员列表,不代表对方已有视频;显示远端画面必须监听 `onUserVideoAvailable`,屏幕分享监听 `onUserSubStreamAvailable`
建议客户端状态机:
```text
idle
→ device-preflight
→ ticket-issued
→ joining
→ joined(media-off)
→ media-on / screen-sharing
→ exiting
→ idle
joined ↔ reconnecting
joining/connected → kicked | room-dismissed | fatal-error → cleanup → idle
```
实施要点:
- 创建实例后先注册所有错误、进退房、远端媒体、设备变化和连接状态回调,再调用 `enterRoom`
-`onEnterRoom(result > 0)` 作为真正进房成功,不以 `enterRoom()` 函数返回或窗口已打开代替。
- 网络断开时 SDK 会自动重连;监听 `onConnectionLost``onTryToReconnect``onConnectionRecovery`。官方说明远端通常约 90 秒后才收到异常用户离开,因此业务后端不能把短时断网立即当成面诊结束,见[断线重连说明](https://intl.cloud.tencent.com/document/product/647/36057)。
- 正常结束必须成对调用 `exitRoom`;退出后停止本地媒体、停止远端渲染、移除监听并销毁实例。Web 端明确要求 `exitRoom()` 后不再使用时调用 `destroy()`
- 服务端[房间与媒体回调](https://intl.cloud.tencent.com/zh/document/product/647/39558)可能重试,且特殊网络/重进场景可能产生重复事件;回调处理必须幂等,不能只用“最后一条回调”做财务/医疗业务结论。
## 摄像头、麦克风与屏幕分享
### 通话前检查
- 列出并选择摄像头、麦克风和扬声器;运行摄像头预览、麦克风电平和扬声器测试。
- 网络测速只在进房前运行,避免影响通话质量。
- 默认音频质量使用 Speech/语音模式;先以 640×360 的保守视频档位验证弱网和 CPU,再按质量监控数据决定是否提高到 720p。
- 对权限拒绝、设备被占用、设备拔出、默认设备变化给出可恢复 UI,不应直接结束预约。
### 屏幕分享
- 默认采用**辅路**,让医生摄像头与屏幕并存;远端根据辅路可用事件订阅。
- 一个房间只允许一路屏幕分享,第二人发起前应在业务 UI 层仲裁。
- 分享前必须让用户确认目标窗口/屏幕;分享中持续显示醒目标志;无论从应用按钮还是浏览器/系统指示条停止,都要收到事件并清理本地状态。
- 医疗场景应默认不共享整个桌面,优先窗口分享。Native SDK 可用窗口排除/包含 API,避免将病历主窗口、通知或其他患者信息意外共享。
- macOS 打包必须验证 Camera、Microphone 和 Screen Recording 权限的首次授权、拒绝、系统设置中撤销及升级安装后的行为。
## 生产安全与合规边界
1. **应用隔离**:开发、测试、生产使用不同 SDKAppID;正式环境不要复用 Demo 密钥或固定房间号。
2. **最小化标识**`roomId`/`userId` 使用无语义内部 ID,不包含姓名、手机号、身份证号、诊断或预约描述;映射只保存在业务后端。
3. **后端授权**:每次签票都校验预约参与者、角色、状态和时间窗;不能只靠“知道 roomId”。高安全场景使用 PrivateMapKey。
4. **回调真实性**:生产回调使用 HTTPS;按官方算法对原始请求体做 HMAC-SHA256 校验,并校验 SDKAppID;存储事件 ID/组合键以幂等处理重试。
5. **传输与额外加密**:腾讯[信息安全说明](https://cloud.tencent.com/document/product/647/86362)说明其默认传输有私有传输协议/TLS/WSS 保护。若组织政策要求额外媒体私有加密,Native C++ API 提供 `enablePayloadPrivateEncryption`,见[媒体流私有加密](https://cloud.tencent.com/document/product/647/106173);该能力需要相应套餐,并与云端录制、旁路转推等能力存在冲突,应在架构阶段选定。Electron 官方另有 C++ 动态库形式的[自定义媒体加解密插件](https://web.sdk.qcloud.com/trtc/electron/doc/zh-cn/trtc_electron_sdk/tutorial-%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0%E9%9F%B3%E8%A7%86%E9%A2%91%E7%9A%84%E8%87%AA%E5%AE%9A%E4%B9%89%E5%8A%A0%E8%A7%A3%E5%AF%86.html),实施成本应单独评估。
6. **录制默认关闭**:只有在业务、告知同意、保存期限、访问控制和删除策略均明确后才启用。官方说明云录制文件存入客户指定的云存储;开启私有加密会限制云录制等服务。
7. **日志最小化**:不记录 UserSig、PrivateMapKey、完整 IPC 消息、病历内容或屏幕标题;支持包上传前先脱敏。SDK 日志目录应受操作系统用户权限保护并配置留存期。
8. **程序供应链**Windows 代码签名;macOS Developer ID、Hardened Runtime/必要 entitlement、Notarization;固定已验证 SDK 版本,升级先做双平台回归。
9. **网络准入**:医院/机构网络可能限制 UDP。上线前按[防火墙白名单](https://intl.cloud.tencent.com/zh/document/product/647/35164)在真实网络验证 Native/Electron 或 WebRTC 所需端口与动态域名,不要只在家庭网络测试。
10. **合规不是 SDK 自动获得**:腾讯的信息安全说明明确不是国家/行业标准承诺,强制要求建议通过书面 SLA 确认。医疗隐私、录制同意、数据驻留和留存仍需项目方做法务/安全评审。
## 最小可验证集成(推荐:Electron companion
### 验证目标
先在一个 Windows 10/11 x64 和一个受支持 macOS 真机上实现 doctor ↔ patient 互通;进入预生产前再增加同平台终端,覆盖 Windows ↔ Windows、macOS ↔ macOS。最终证明安全签票、跨平台进房、音视频、设备权限、屏幕辅路、重连、正常退房和打包后运行都成立。
### 实施顺序
1. **TRTC 开发应用**
- 新建独立开发 SDKAppID。
- 先关闭自动录制/旁路转推;高级权限控制在基础链路跑通后于同一开发应用或新应用验证。
2. **Python/任意现有后端的 ticket endpoint**
- `POST /api/appointments/{id}/rtc-ticket`
- 从登录态和预约记录派生 `userId`/room ID;使用腾讯官方 Python HMAC-SHA256 示例在服务端生成 UserSig。
- 响应中不包含 SecretKey;票据只允许用于该预约与短时间窗。
3. **最小 Electron companion**
- 安装官方 SDK,创建 `TRTCCloud` 单例并先注册回调。
- 请求相机/麦克风权限,提供摄像头、麦克风、扬声器测试。
- 使用 `TRTCAppSceneVideoCall` 进房;等待 `onEnterRoom > 0`
- 用户点击后调用 `startLocalPreview()``startLocalAudio(TRTCAudioQualitySpeech)`
- 监听远端主路/辅路可用事件并渲染;实现窗口/整屏辅路分享和停止。
- 退出时完整清理并调用 `destroyTRTCShareInstance()`
4. **PySide6 最小联动**
- “开始面诊”请求 ticket,创建带随机 IPC nonce 的 companionticket 通过受认证 IPC 发送,不放命令行。
- companion 将状态和最终错误码回传;PySide6 只展示状态并提供结束入口。
5. **后端回调**
- 配置 HTTPS 房间/媒体回调,启用自定义 callback key。
- 对原始 body 验签并幂等落库;把客户端与回调状态关联到 appointment/session ID。
6. **高级权限控制验证**
- 服务端生成 PrivateMapKey;验证错误房间、过期/错误票据、患者无屏幕上行权限均被服务端拒绝。
### 最小验收清单
- [ ] Windows ↔ Windows、macOS ↔ macOS、Windows ↔ macOS 三组均能进房。
- [ ] SDKSecretKey 不存在于 Python 包、Electron 包、JS source map、命令行和日志。
- [ ] 正确票据进房成功;错误 UserSig、错误 PrivateMapKey、非预约参与者进房失败。
- [ ] 摄像头、麦克风、扬声器可检测/切换;拒绝授权后 UI 可恢复;设备拔插不崩溃。
- [ ] 医生与患者可看到/听到对方;以首帧和首个音频回调确认,不只凭 UI 按钮状态。
- [ ] 辅路屏幕分享与摄像头并存;系统 UI 停止分享后双方状态同步;第二路分享被正确阻止。
- [ ] 网络断开时进入 reconnecting,恢复后继续通话;应用强杀后服务端约 90 秒收敛,客户端有经过验证的重新取票/进房路径。
- [ ] 重复 UserID 的互踢行为有明确提示;正常退出释放摄像头/麦克风并销毁实例。
- [ ] 回调验签失败被拒绝;腾讯回调重试不会重复结算或重复关闭预约。
- [ ] Windows 签名安装包和 macOS 签名/公证包在干净机器可运行并能申请权限。
- [ ] 医院/目标机构网络按官方白名单完成真实音视频与屏幕分享测试。
### 决策门槛
- 若 Electron companion 的包体/双窗口体验可以接受,按该架构进入生产化。
- 若产品坚持单窗口,可并行做 2 个受限 POC:
1. `QWebEngineView`:仅在上述 WebView 准入测试全部通过后采用;失败即回退 Electron。
2. Native C++ bridge:先只实现 SDK version、实例、回调、一个本地/远端原生渲染视图和完整销毁;确认 Qt 6/PySide6 稳定后再扩展设备与屏幕分享。
- 若组织要求媒体私有加密且同时要求云端录制,必须先解决官方能力冲突,不能在开发末期再补。
## 官方资料索引
- [TRTC 产品概述与平台支持(用户指定文档 16788)](https://cloud.tencent.com/document/product/647/16788)
- [TRTC 基本概念:UserID、房间与生命周期](https://cloud.tencent.com/document/product/647/46351)
- [Web & H5 快速接入](https://cloud.tencent.com/document/product/647/116544)
- [Web DemoWebView 检测、HTTPS 与防火墙要求](https://cloud.tencent.com/document/product/647/32398)
- [Web API 概览与平台支持矩阵](https://intl.cloud.tencent.com/zh/document/product/647/41664)
- [Web 屏幕分享](https://intl.cloud.tencent.com/zh/document/product/647/35163)
- [Electron 快速接入、设备权限与打包](https://cloud.tencent.com/document/product/647/116549)
- [TUIRoomKit Electron:医疗问诊等场景](https://cloud.tencent.com/document/product/647/129879)
- [Electron 屏幕分享](https://intl.cloud.tencent.com/zh/document/product/647/47619)
- [Electron SDK API](https://web.sdk.qcloud.com/trtc/electron/doc/en-us/trtc_electron_sdk/TRTCCloud.html)
- [C++ 全平台 API 概览](https://cloud.tencent.com/document/product/647/32268)
- [C++ Qt Windows/macOS 集成](https://intl.cloud.tencent.com/zh/document/product/647/39665)
- [UserSig 用户鉴权与官方 Python 服务端示例入口](https://cloud.tencent.com/document/product/647/17275)
- [原始 TRTC Web 进房时的 UserSig 校验说明](https://cloud.tencent.com/document/product/1715/104507)
- [PrivateMapKey 高级权限控制](https://intl.cloud.tencent.com/zh/document/product/647/35157)
- [房间与媒体服务端回调、签名和重试](https://intl.cloud.tencent.com/zh/document/product/647/39558)
- [房间解散 REST API](https://cloud.tencent.com/document/api/647/50089)
- [断线与自动重连行为](https://intl.cloud.tencent.com/document/product/647/36057)
- [屏幕分享(macOS/Native](https://cloud.tencent.com/document/product/647/32249)
- [TRTC 信息安全说明](https://cloud.tencent.com/document/product/647/86362)
- [媒体流私有加密](https://cloud.tencent.com/document/product/647/106173)
- [Native/WebRTC 防火墙白名单](https://intl.cloud.tencent.com/zh/document/product/647/35164)
+95
View File
@@ -0,0 +1,95 @@
# 医生工作站 UI 最终离屏回归
- 验收日期:2026-08-10
- 环境:Windows 11、Python 3.12.12、PySide6 6.11.1
- 目标尺寸:1280 × 800
- 最小 Shell 尺寸:1024 × 640
- 最终结论:**PASS**
## 1. 结论摘要
最新原始源码已通过完整离屏回归。验收在全新 Python 进程中执行,没有加载任何方法别名、monkey patch 或兼容垫片,并明确断言 `BusyOverlay` 不存在临时 `set_text` 属性。
`LoginWindow.submit()` 使用空账号和密码触发 Demo 默认凭据,真实执行 `DemoDoctorRepository.login()``get_current_user()`,随后由 `ApplicationController` 创建 `ShellWindow`。登录成功后 loading 正常释放、密码被清空,五个授权页面均能异步加载。
问诊列表到 Controller 再到 `DemoVideoDialog` 的信号链路、性别文本、服务器设置和 1024 × 640 最小 Shell 均通过。上一轮发现的四个问题现已全部关闭。
## 2. 验收方法
1. 使用 `QT_QPA_PLATFORM=offscreen` 创建真实 `QApplication`
2. 创建原始 `LoginWindow`,保持 Demo 模式并将账号密码留空,调用真实 `submit()`
3. 等待 `login_succeeded` 和 Controller 创建 Shell,核对认证 Session、用户、repository、loading 与密码清理状态。
4. 依次进入接诊台、我的处方库、已开处方、我的患者、问诊列表,等待后台 Worker 返回数据和详情。
5. 在问诊列表点击“发起视频问诊”,验证 `ConsultationsPage → ShellWindow.video_requested → ApplicationController._request_video → DemoVideoDialog` 完整链路。
6. 将 Shell 精确调整为 1024 × 640,检查接诊主要按钮的窗口坐标和 splitter 两侧可用宽度。
7. 关闭 Demo 后展开服务器设置,保存 HTTPS 地址与 45 秒超时,验证设置存储和两个配置变更信号。
8. 使用 `QWidget.grab()` 覆盖必要截图并逐张目检。
Qt `offscreen` 平台在本机不提供系统字体列表,因此验收进程临时用 `QFontDatabase.addApplicationFont()` 加载 `C:\Windows\Fonts\msyh.ttc`,使截图反映真实中文排版;未修改产品源码或打包配置。
## 3. 回归结果
| 范围 | 结果 | 验收证据 |
| --- | --- | --- |
| 真实 Demo 登录 | 通过 | 空账号密码成功使用 Demo 默认凭据;发出 `login_succeeded`;Session 已认证;用户为“陈医生(演示)”;loading 释放且密码清空 |
| 服务器设置按钮 | 通过 | Demo 模式下禁用;关闭 Demo 后可展开、保存、收起;地址规范化为 `https://api.example.com/adminapi`,超时为 45 秒 |
| Shell / 权限导航 | 通过 | Controller 创建接诊台、我的处方库、已开处方、我的患者、问诊列表共 5 页 |
| 接诊台 | 通过 | 待接诊 2 条,详情异步加载;性别显示“女”;1280 × 800 与 1024 × 640 均稳定 |
| 我的处方库 | 通过 | Demo 模板 2 条 |
| 已开处方 | 通过 | Demo 处方 2 条;详情显示“赵明远 · 男 · 53岁” |
| 我的患者 | 通过 | Demo 患者 3 条,首条详情正常加载 |
| 问诊列表 | 通过 | 今日待接诊 1 条,视频按钮可用 |
| 问诊 → Controller → DemoVideoDialog | 通过 | payload 为 `appointment_id=101``diagnosis_id=501``patient_id=301`Controller 以 key `501` 创建并回收窗口 |
| Demo 视频窗口 | 通过 | 980 × 660 正常渲染;计时到 `00:01`;麦克风与摄像头均可切换为关闭 |
| 1024 × 640 最小 Shell | 通过 | “完成接诊”边界为 `(883, 243, 84, 38)`,右边界 967、下边界 281,完整位于窗口内;splitter 宽度为 `[322, 430]` |
| 1280 × 800 视觉 | 通过 | 五页、登录页、服务器设置和视频窗无重叠或横向裁切 |
离屏断言摘要:
```text
runtime_adapter = false
login = authenticated / 陈医生(演示) / loading released / password cleared
server_url = https://api.example.com/adminapi
server_timeout = 45
page_rows = reception 2 / library 2 / prescriptions 2 / patients 3 / consultations 1
reception_gender = 女 · 46岁 · 患者编号 301
prescription_gender = 赵明远 · 男 · 53岁
video_ids = appointment 101 / diagnosis 501 / patient 301
video_dialog = key 501 / duration 00:01 / mic off / camera off
compact_shell = 1024 × 640 / complete_button right 967 bottom 281
pytest = 69 passed
```
## 4. 问题关闭情况
| 问题 | 状态 | 本轮证据 |
| --- | --- | --- |
| F-01 问诊列表视频 ID 错置 | 已关闭 | 实际点击后得到 101 / 501 / 301,并由 Controller 创建 `DemoVideoDialog` |
| F-02 紧凑尺寸接诊详情横向裁切 | 已关闭 | Shell 最小尺寸为 1024 × 640;该尺寸下两侧 splitter 有效,“完成接诊”完整可见 |
| F-03 性别显示内部数值 | 已关闭 | 接诊显示“女”,处方详情显示“男” |
| F-04 Demo 登录 loading 调用不存在的方法 | 已关闭 | `LoginWindow` 使用 `BusyOverlay.set_message()`;无垫片真实登录成功进入 Shell |
## 5. 截图索引
1. [登录页 1280 × 800](../artifacts/ui_acceptance/01_login_1280x800.png)
2. [接诊台 1280 × 800](../artifacts/ui_acceptance/02_reception_1280x800.png)
3. [我的处方库 1280 × 800](../artifacts/ui_acceptance/03_prescription_library_1280x800.png)
4. [已开处方 1280 × 800](../artifacts/ui_acceptance/04_prescriptions_1280x800.png)
5. [我的患者 1280 × 800](../artifacts/ui_acceptance/05_patients_1280x800.png)
6. [问诊列表 1280 × 800](../artifacts/ui_acceptance/06_consultations_1280x800.png)
7. [Demo 视频窗口 980 × 660](../artifacts/ui_acceptance/07_demo_video_980x660.png)
8. [最小 Shell 1024 × 640](../artifacts/ui_acceptance/08_shell_compact_1024x640.png)
9. [问诊视频链路成功页 1280 × 800](../artifacts/ui_acceptance/09_consultations_video_success_1280x800.png)
10. [服务器设置 1280 × 800](../artifacts/ui_acceptance/10_login_server_settings_1280x800.png)
旧的 `08_shell_compact_900x600.png``09_consultations_video_error_1280x800.png` 是历史问题证据,不属于本轮最终截图索引。
## 6. 自动化测试记录
```text
uv run --offline pytest
..................................................................... [100%]
69 passed in 0.65s
```
本轮验收没有修改 `src/` 下任何源文件;仅覆盖本报告和 `artifacts/ui_acceptance/` 下的 PNG 截图。
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="真阳医生工作台">
<rect width="64" height="64" rx="16" fill="#123F3A"/>
<path d="M29 14h6a3 3 0 0 1 3 3v10h9a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-9v9a3 3 0 0 1-3 3h-6a3 3 0 0 1-3-3v-9h-9a3 3 0 0 1-3-3v-5a3 3 0 0 1 3-3h9V17a3 3 0 0 1 3-3Z" fill="#F6F4EE"/>
<path d="M42 16c-1 8-5 13-12 15 2-7 6-12 12-15Z" fill="#79B9A4"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -u
project_root="$(cd "$(dirname "$0")" && pwd -P)"
exec /bin/bash "$project_root/scripts/run_macos.sh"
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
project_root="$(cd "$(dirname "$0")/.." && pwd)"
python_bin="${PYINSTALLER_PYTHON:-$project_root/.venv-build/bin/python}"
companion_root="$project_root/video_companion"
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "The macOS bundle must be built on macOS." >&2
exit 2
fi
if [[ ! -x "$python_bin" ]]; then
echo "Build Python was not found: $python_bin" >&2
exit 2
fi
if [[ "${SKIP_FRONTEND_INSTALL:-0}" != "1" ]]; then
npm ci --prefix "$companion_root" --no-audit --no-fund
fi
npm run build --prefix "$companion_root"
"$python_bin" -m PyInstaller \
--noconfirm \
--clean \
"$project_root/packaging/doctor_workstation.spec"
artifact="$project_root/dist/DoctorWorkstation.app"
helper="$(find "$artifact" -type f -name 'QtWebEngineProcess' -print -quit)"
resources="$(find "$artifact" -type f -name 'qtwebengine_resources*.pak' -print -quit)"
companion="$(find "$artifact" -type f -path '*video_companion_dist/index.html' -print -quit)"
executable="$artifact/Contents/MacOS/DoctorWorkstation"
[[ -n "$helper" ]] || { echo "QtWebEngineProcess is missing from the app." >&2; exit 1; }
[[ -n "$resources" ]] || { echo "QtWebEngine resources are missing from the app." >&2; exit 1; }
[[ -n "$companion" ]] || { echo "video_companion_dist is missing from the app." >&2; exit 1; }
[[ -x "$executable" ]] || { echo "Frozen application entry point is missing." >&2; exit 1; }
codesign --verify --deep --strict --verbose=2 "$artifact"
temp_root="${TMPDIR:-/tmp}"
temp_root="${temp_root%/}"
smoke_root="$(mktemp -d "$temp_root/doctor-workstation-smoke.XXXXXX")"
cleanup_smoke() {
case "$smoke_root" in
"$temp_root"/doctor-workstation-smoke.*) rm -rf -- "$smoke_root" ;;
*) echo "Refusing to remove unsafe smoke-test directory: $smoke_root" >&2 ;;
esac
}
trap cleanup_smoke EXIT
mkdir -p \
"$smoke_root/Library/Application Support" \
"$smoke_root/Library/Caches" \
"$smoke_root/xdg/config" \
"$smoke_root/xdg/state" \
"$smoke_root/xdg/cache" \
"$smoke_root/tmp"
set +e
env \
TMPDIR="$smoke_root/tmp" \
XDG_CONFIG_HOME="$smoke_root/xdg/config" \
XDG_STATE_HOME="$smoke_root/xdg/state" \
XDG_CACHE_HOME="$smoke_root/xdg/cache" \
DOCTOR_CONFIG_DIR="$smoke_root/doctor/config" \
DOCTOR_LOG_DIR="$smoke_root/doctor/logs" \
DOCTOR_API_BASE_URL="https://127.0.0.1:9" \
DOCTOR_DEMO_MODE="true" \
DOCTOR_VIDEO_MODE="embedded" \
DOCTOR_VIDEO_WEB_URL="" \
DOCTOR_VERIFY_SSL="true" \
DOCTOR_LOG_LEVEL="INFO" \
DOCTOR_SMOKE_TEST="1" \
HTTP_PROXY="http://127.0.0.1:9" \
HTTPS_PROXY="http://127.0.0.1:9" \
ALL_PROXY="http://127.0.0.1:9" \
NO_PROXY="" \
QT_QPA_PLATFORM="offscreen" \
"$executable" --smoke-test >"$smoke_root/stdout.txt" 2>"$smoke_root/stderr.txt" &
smoke_pid=$!
smoke_running=1
for _ in {1..300}; do
if ! kill -0 "$smoke_pid" 2>/dev/null; then
smoke_running=0
break
fi
sleep 0.1
done
if [[ "$smoke_running" == "1" ]]; then
kill "$smoke_pid" 2>/dev/null
wait "$smoke_pid" 2>/dev/null
smoke_status=124
else
wait "$smoke_pid"
smoke_status=$?
fi
set -e
if [[ "$smoke_status" -ne 0 ]]; then
cat "$smoke_root/stderr.txt" >&2
echo "Frozen application smoke test failed with exit code $smoke_status." >&2
exit 1
fi
diagnostics="$smoke_root/diagnostics.txt"
cat "$smoke_root/stdout.txt" "$smoke_root/stderr.txt" >"$diagnostics"
find "$smoke_root" -type f -name '*.log' -exec cat {} + >>"$diagnostics"
if grep -Eiq 'traceback \(most recent call last\)|unhandled exception|uncaught exception|fatal python error' "$diagnostics"; then
cat "$diagnostics" >&2
echo "Frozen application smoke-test logs contain an unhandled exception." >&2
exit 1
fi
echo "Frozen entry smoke test passed (--smoke-test, isolated demo mode)."
echo "Build complete: $artifact"
+171
View File
@@ -0,0 +1,171 @@
[CmdletBinding()]
param(
[string]$Python = ".venv-build\Scripts\python.exe",
[switch]$SkipFrontendInstall
)
$ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
$Spec = Join-Path $ProjectRoot "packaging\doctor_workstation.spec"
function Invoke-FrozenSmokeTest {
param([Parameter(Mandatory = $true)][string]$Executable)
$TempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
$SmokeRoot = Join-Path $TempRoot ("doctor-workstation-smoke-" + [guid]::NewGuid().ToString("N"))
$SmokeRoot = [System.IO.Path]::GetFullPath($SmokeRoot)
if (-not ($SmokeRoot.StartsWith($TempRoot, [System.StringComparison]::OrdinalIgnoreCase)) -or
-not ([System.IO.Path]::GetFileName($SmokeRoot)).StartsWith("doctor-workstation-smoke-")) {
throw "Refusing to use an unsafe smoke-test directory: $SmokeRoot"
}
$Environment = @{
"APPDATA" = (Join-Path $SmokeRoot "AppData\Roaming")
"LOCALAPPDATA" = (Join-Path $SmokeRoot "AppData\Local")
"XDG_CONFIG_HOME" = (Join-Path $SmokeRoot "xdg\config")
"XDG_STATE_HOME" = (Join-Path $SmokeRoot "xdg\state")
"XDG_CACHE_HOME" = (Join-Path $SmokeRoot "xdg\cache")
"DOCTOR_CONFIG_DIR" = (Join-Path $SmokeRoot "doctor\config")
"DOCTOR_LOG_DIR" = (Join-Path $SmokeRoot "doctor\logs")
"DOCTOR_API_BASE_URL" = "https://127.0.0.1:9"
"DOCTOR_DEMO_MODE" = "true"
"DOCTOR_VIDEO_MODE" = "embedded"
"DOCTOR_VIDEO_WEB_URL" = ""
"DOCTOR_VERIFY_SSL" = "true"
"DOCTOR_LOG_LEVEL" = "INFO"
"DOCTOR_SMOKE_TEST" = "1"
"HTTP_PROXY" = "http://127.0.0.1:9"
"HTTPS_PROXY" = "http://127.0.0.1:9"
"ALL_PROXY" = "http://127.0.0.1:9"
"NO_PROXY" = ""
"QT_QPA_PLATFORM" = "offscreen"
}
$PreviousEnvironment = @{}
$StandardOutput = Join-Path $SmokeRoot "stdout.txt"
$StandardError = Join-Path $SmokeRoot "stderr.txt"
New-Item -ItemType Directory -Path $SmokeRoot -Force | Out-Null
New-Item -ItemType Directory -Path $Environment["APPDATA"] -Force | Out-Null
New-Item -ItemType Directory -Path $Environment["LOCALAPPDATA"] -Force | Out-Null
try {
foreach ($Name in $Environment.Keys) {
$PreviousEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process")
[Environment]::SetEnvironmentVariable($Name, $Environment[$Name], "Process")
}
# Windows PowerShell 5 can leave ExitCode unset on the object returned
# by Start-Process. System.Diagnostics.Process retains the real code
# and lets us drain redirected streams without risking a pipe deadlock.
$ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
$ProcessInfo.FileName = $Executable
$ProcessInfo.Arguments = "--smoke-test"
$ProcessInfo.UseShellExecute = $false
$ProcessInfo.CreateNoWindow = $true
$ProcessInfo.RedirectStandardOutput = $true
$ProcessInfo.RedirectStandardError = $true
$Process = New-Object System.Diagnostics.Process
$Process.StartInfo = $ProcessInfo
if (-not $Process.Start()) {
throw "Unable to start the frozen application smoke test"
}
$OutputTask = $Process.StandardOutput.ReadToEndAsync()
$ErrorTask = $Process.StandardError.ReadToEndAsync()
$TimedOut = -not $Process.WaitForExit(30000)
if ($TimedOut -and -not $Process.HasExited) {
$Process.Kill()
}
$Process.WaitForExit()
$OutputTask.Wait()
$ErrorTask.Wait()
Set-Content -LiteralPath $StandardOutput -Value $OutputTask.Result -Encoding UTF8
Set-Content -LiteralPath $StandardError -Value $ErrorTask.Result -Encoding UTF8
$DiagnosticFiles = @($StandardOutput, $StandardError)
$DiagnosticFiles += Get-ChildItem -LiteralPath $SmokeRoot -Recurse -Filter "*.log" -File |
Select-Object -ExpandProperty FullName
$Diagnostics = ($DiagnosticFiles | Where-Object { Test-Path -LiteralPath $_ } |
ForEach-Object { Get-Content -LiteralPath $_ -Raw -ErrorAction SilentlyContinue }) -join "`n"
if ($TimedOut) {
throw "Frozen application smoke test timed out after 30 seconds`n$Diagnostics"
}
if ($Process.ExitCode -ne 0) {
throw "Frozen application smoke test failed with exit code $($Process.ExitCode)`n$Diagnostics"
}
if ($Diagnostics -match "(?im)traceback \(most recent call last\)|unhandled exception|uncaught exception|fatal python error") {
throw "Frozen application smoke-test logs contain an unhandled exception"
}
Write-Host "Frozen entry smoke test passed (--smoke-test, isolated demo mode)."
}
finally {
foreach ($Name in $Environment.Keys) {
[Environment]::SetEnvironmentVariable($Name, $PreviousEnvironment[$Name], "Process")
}
if (Test-Path -LiteralPath $SmokeRoot) {
Remove-Item -LiteralPath $SmokeRoot -Recurse -Force
}
}
}
if (-not [System.IO.Path]::IsPathRooted($Python)) {
$Python = Join-Path $ProjectRoot $Python
}
if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) {
throw "Build Python was not found: $Python"
}
$Npm = (Get-Command npm.cmd -ErrorAction Stop).Source
Push-Location $ProjectRoot
try {
if (-not $SkipFrontendInstall) {
& $Npm ci --prefix $CompanionRoot --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw "npm ci failed" }
}
& $Npm run build --prefix $CompanionRoot
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
& $Python -m PyInstaller --noconfirm --clean $Spec
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" }
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
$Resources = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "qtwebengine_resources*.pak" -File | Select-Object -First 1
$Companion = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "index.html" -File |
Where-Object { $_.FullName -like "*video_companion_dist*" } |
Select-Object -First 1
if (-not $Helper) { throw "QtWebEngineProcess.exe is missing from the artifact" }
if (-not $Resources) { throw "QtWebEngine Chromium resources are missing from the artifact" }
if (-not $Companion) { throw "video_companion_dist is missing from the artifact" }
$PythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
if ($LASTEXITCODE -ne 0 -or -not $PythonBase) {
throw "Unable to resolve the build Python runtime directory"
}
foreach ($RuntimeDllName in @("libssl-3-x64.dll", "libcrypto-3-x64.dll")) {
$SourceRuntimeDll = Join-Path $PythonBase "DLLs\$RuntimeDllName"
$FrozenRuntimeDll = Join-Path $Artifact "_internal\$RuntimeDllName"
if (-not (Test-Path -LiteralPath $SourceRuntimeDll -PathType Leaf)) {
throw "Build Python runtime dependency is missing: $SourceRuntimeDll"
}
if (-not (Test-Path -LiteralPath $FrozenRuntimeDll -PathType Leaf)) {
throw "Frozen Python runtime dependency is missing: $FrozenRuntimeDll"
}
$SourceHash = (Get-FileHash -LiteralPath $SourceRuntimeDll -Algorithm SHA256).Hash
$FrozenHash = (Get-FileHash -LiteralPath $FrozenRuntimeDll -Algorithm SHA256).Hash
if ($SourceHash -ne $FrozenHash) {
throw "Frozen $RuntimeDllName does not match the build Python runtime"
}
}
$Executable = Join-Path $Artifact "DoctorWorkstation.exe"
if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) {
throw "Frozen application entry point is missing: $Executable"
}
Invoke-FrozenSmokeTest -Executable $Executable
Write-Host "Build complete: $Artifact"
}
finally {
Pop-Location
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd -P)"
project_root="$(cd "$script_dir/.." && pwd -P)"
operational_files=(
"$script_dir/macos_helpers.sh"
"$script_dir/run_macos.sh"
"$script_dir/package_macos.sh"
"$script_dir/build_macos.sh"
"$project_root/一键运行.command"
"$project_root/一键打包.command"
"$project_root/run_macos.command"
"$project_root/package_macos.command"
)
for file in "${operational_files[@]}"; do
[[ -f "$file" ]] || { printf 'Missing macOS entry file: %s\n' "$file" >&2; exit 1; }
/bin/bash -n "$file"
done
for file in \
"$project_root/一键运行.command" \
"$project_root/一键打包.command" \
"$project_root/run_macos.command" \
"$project_root/package_macos.command"; do
[[ -x "$file" ]] || { printf 'Finder entry is not executable: %s\n' "$file" >&2; exit 1; }
done
open_line="$(grep -nF '/usr/bin/open "$artifact"' "$script_dir/run_macos.sh" | head -n 1 | cut -d: -f1)"
source_line="$(grep -nF 'macos_ensure_uv' "$script_dir/run_macos.sh" | head -n 1 | cut -d: -f1)"
[[ -n "$open_line" && -n "$source_line" && "$open_line" -lt "$source_line" ]] || {
echo 'Built .app must be opened before source-environment preparation.' >&2
exit 1
}
grep -Fq 'sync --locked' "$script_dir/run_macos.sh"
grep -Fq 'sync --locked --extra build' "$script_dir/package_macos.sh"
grep -Fq 'ci --prefix "$project_root/video_companion"' "$script_dir/package_macos.sh"
grep -Fq '/bin/bash "$script_dir/build_macos.sh"' "$script_dir/package_macos.sh"
grep -Fq 'SHASUMS256.txt' "$script_dir/package_macos.sh"
grep -Fq '/usr/bin/ditto -c -k --sequesterRsrc --keepParent' "$script_dir/package_macos.sh"
grep -Fq '/usr/bin/shasum -a 256' "$script_dir/package_macos.sh"
grep -Fq 'scripts/run_macos.sh' "$project_root/一键运行.command"
grep -Fq 'scripts/run_macos.sh' "$project_root/run_macos.command"
grep -Fq 'scripts/package_macos.sh' "$project_root/一键打包.command"
grep -Fq 'scripts/package_macos.sh' "$project_root/package_macos.command"
if grep -En '(^|[[:space:]])(export[[:space:]]+)?HOME=' "${operational_files[@]}"; then
echo 'macOS entry scripts must not repurpose HOME.' >&2
exit 1
fi
if grep -Ein 'SDKSecret(Key)?|UserSig|userSig' "${operational_files[@]}"; then
echo 'macOS entry scripts must not contain RTC secrets or credentials.' >&2
exit 1
fi
set +e
trap_output="$(
CI=1 DOCTOR_NONINTERACTIVE=1 /bin/bash -c '
source "$1"
macos_install_exit_trap 1
exit 7
' macos-contract "$script_dir/macos_helpers.sh" 2>&1
)"
trap_status=$?
set -e
[[ "$trap_status" -eq 7 ]] || {
printf 'Non-interactive failure trap changed exit status to %s.\n' "$trap_status" >&2
exit 1
}
grep -Fq '操作失败' <<<"$trap_output"
echo 'macOS entrypoint contracts passed.'
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Shared helpers for Finder-launched macOS entry points. This file is sourced.
MACOS_PAUSE_ON_SUCCESS=0
MACOS_UV_BIN=""
MACOS_TEMP_DIRS=()
macos_register_temp_dir() {
MACOS_TEMP_DIRS[${#MACOS_TEMP_DIRS[@]}]="$1"
}
macos_cleanup_temp_dirs() {
local directory
for directory in "${MACOS_TEMP_DIRS[@]}"; do
[[ -n "$directory" && -d "$directory" ]] || continue
case "$(basename "$directory")" in
doctor-uv.*|doctor-node.*)
rm -rf -- "$directory" || \
printf '临时目录清理失败:%s\n' "$directory" >&2
;;
*) printf '跳过不安全的临时目录清理目标:%s\n' "$directory" >&2 ;;
esac
done
}
macos_should_pause() {
[[ -t 0 && -t 1 && -z "${CI:-}" && "${DOCTOR_NONINTERACTIVE:-0}" != "1" ]]
}
macos_on_exit() {
local status="$1"
trap - EXIT
macos_cleanup_temp_dirs
if [[ "$status" -ne 0 ]]; then
printf '\n操作失败(退出码 %s)。请查看上方信息。\n' "$status" >&2
fi
if macos_should_pause && { [[ "$status" -ne 0 ]] || [[ "$MACOS_PAUSE_ON_SUCCESS" == "1" ]]; }; then
printf '\n按回车键关闭此窗口…'
IFS= read -r _ || true
fi
exit "$status"
}
macos_install_exit_trap() {
MACOS_PAUSE_ON_SUCCESS="${1:-0}"
trap 'macos_on_exit "$?"' EXIT
}
macos_die() {
printf '错误:%s\n' "$1" >&2
exit 1
}
macos_require_darwin() {
if [[ "$(uname -s)" != "Darwin" ]]; then
macos_die "此入口只能在 macOS 上运行。"
fi
}
macos_locate_uv() {
local candidate
candidate="$(command -v uv 2>/dev/null || true)"
if [[ -n "$candidate" && -x "$candidate" ]]; then
MACOS_UV_BIN="$candidate"
return 0
fi
if [[ -n "${HOME:-}" ]]; then
for candidate in "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
if [[ -x "$candidate" ]]; then
MACOS_UV_BIN="$candidate"
return 0
fi
done
fi
for candidate in /opt/homebrew/bin/uv /usr/local/bin/uv; do
if [[ -x "$candidate" ]]; then
MACOS_UV_BIN="$candidate"
return 0
fi
done
return 1
}
macos_ensure_uv() {
local curl_bin installer temp_root
if macos_locate_uv; then
return 0
fi
curl_bin="$(command -v curl 2>/dev/null || true)"
[[ -n "$curl_bin" ]] || macos_die "未找到 uv,也未找到用于安装 uv 的 curl。"
temp_root="${TMPDIR:-/tmp}"
temp_root="${temp_root%/}"
[[ -n "$temp_root" ]] || temp_root="/"
installer="$(mktemp -d "$temp_root/doctor-uv.XXXXXX")"
macos_register_temp_dir "$installer"
printf '未检测到 uv,正在通过官方 HTTPS 安装器安装…\n' >&2
if ! "$curl_bin" --proto '=https' --tlsv1.2 -fsSL \
'https://astral.sh/uv/install.sh' -o "$installer/install.sh"; then
macos_die "uv 安装器下载失败,请检查网络后重试。"
fi
if ! UV_NO_MODIFY_PATH=1 /bin/sh "$installer/install.sh"; then
macos_die "uv 安装失败。"
fi
hash -r
macos_locate_uv || macos_die "uv 已安装,但未在标准位置找到可执行文件。"
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd -P)"
project_root="$(cd "$script_dir/.." && pwd -P)"
# shellcheck source=macos_helpers.sh
source "$script_dir/macos_helpers.sh"
macos_install_exit_trap 1
macos_require_darwin
NODE_BIN=""
NPM_BIN=""
use_node_pair() {
local node_candidate="$1"
local npm_candidate="$2"
local major version
[[ -x "$node_candidate" && -x "$npm_candidate" ]] || return 1
version="$("$node_candidate" --version 2>/dev/null || true)"
major="${version#v}"
major="${major%%.*}"
case "$major" in
''|*[!0-9]*) return 1 ;;
esac
[[ "$major" -ge 20 ]] || return 1
NODE_BIN="$node_candidate"
NPM_BIN="$npm_candidate"
export PATH="$(dirname "$NODE_BIN"):$PATH"
}
locate_node() {
local bin_dir node_candidate npm_candidate
node_candidate="$(command -v node 2>/dev/null || true)"
npm_candidate="$(command -v npm 2>/dev/null || true)"
if use_node_pair "$node_candidate" "$npm_candidate"; then
return 0
fi
for bin_dir in /opt/homebrew/bin /usr/local/bin; do
if use_node_pair "$bin_dir/node" "$bin_dir/npm"; then
return 0
fi
done
if [[ -n "${HOME:-}" ]]; then
for node_candidate in "$HOME"/.nvm/versions/node/*/bin/node; do
[[ -x "$node_candidate" ]] || continue
npm_candidate="$(dirname "$node_candidate")/npm"
if use_node_pair "$node_candidate" "$npm_candidate"; then
return 0
fi
done
fi
return 1
}
install_local_node() {
local architecture archive archive_path cache_root curl_bin download_dir
local expected extracted install_dir node_version shasums_path actual
node_version="${DOCTOR_NODE_VERSION:-22.14.0}"
case "$(uname -m)" in
arm64) architecture="arm64" ;;
x86_64) architecture="x64" ;;
*) macos_die "不支持的 Mac CPU 架构:$(uname -m)" ;;
esac
if [[ -n "${XDG_CACHE_HOME:-}" ]]; then
cache_root="$XDG_CACHE_HOME/DoctorWorkstation/tools"
elif [[ -n "${HOME:-}" ]]; then
cache_root="$HOME/Library/Caches/DoctorWorkstation/tools"
else
macos_die "无法确定 Node 工具缓存目录:HOME 与 XDG_CACHE_HOME 均未设置。"
fi
install_dir="$cache_root/node-v$node_version-darwin-$architecture"
if use_node_pair "$install_dir/bin/node" "$install_dir/bin/npm"; then
return 0
fi
if [[ -e "$install_dir" ]]; then
macos_die "Node 缓存不完整,请删除后重试:$install_dir"
fi
curl_bin="$(command -v curl 2>/dev/null || true)"
[[ -n "$curl_bin" ]] || macos_die "未找到用于下载 Node.js 的 curl。"
mkdir -p "$cache_root"
download_dir="$(mktemp -d "$cache_root/doctor-node.XXXXXX")"
macos_register_temp_dir "$download_dir"
archive="node-v$node_version-darwin-$architecture.tar.gz"
archive_path="$download_dir/$archive"
shasums_path="$download_dir/SHASUMS256.txt"
printf '未检测到 Node.js 20+ 与 npm,正在下载 Node.js %s%s)…\n' \
"$node_version" "$architecture"
if ! "$curl_bin" --proto '=https' --tlsv1.2 -fsSL \
"https://nodejs.org/dist/v$node_version/$archive" -o "$archive_path"; then
macos_die "Node.js 下载失败,请检查网络后重试。"
fi
if ! "$curl_bin" --proto '=https' --tlsv1.2 -fsSL \
"https://nodejs.org/dist/v$node_version/SHASUMS256.txt" -o "$shasums_path"; then
macos_die "Node.js 校验文件下载失败。"
fi
expected="$(/usr/bin/awk -v file="$archive" '$2 == file { print $1; exit }' "$shasums_path")"
actual="$(/usr/bin/shasum -a 256 "$archive_path" | /usr/bin/awk '{print $1}')"
[[ -n "$expected" && "$actual" == "$expected" ]] || macos_die "Node.js 下载包 SHA-256 校验失败。"
/usr/bin/tar -xzf "$archive_path" -C "$download_dir"
extracted="$download_dir/node-v$node_version-darwin-$architecture"
[[ -d "$extracted" ]] || macos_die "Node.js 下载包结构无效。"
if [[ ! -e "$install_dir" ]]; then
/bin/mv "$extracted" "$install_dir"
fi
use_node_pair "$install_dir/bin/node" "$install_dir/bin/npm" || \
macos_die "Node.js 安装完成,但 node/npm 无法执行。"
}
macos_ensure_uv
cd "$project_root"
printf '正在同步 Python 与 PyInstaller 构建依赖…\n'
"$MACOS_UV_BIN" sync --locked --extra build
python_bin="$project_root/.venv/bin/python"
[[ -x "$python_bin" ]] || macos_die "uv 未生成可用的构建 Python$python_bin"
locate_node || install_local_node
printf 'Node.js: %snpm: %s\n' \
"$("$NODE_BIN" --version)" "$("$NPM_BIN" --version)"
printf '正在安装锁定的 companion 依赖…\n'
"$NPM_BIN" ci --prefix "$project_root/video_companion" --no-audit --no-fund
printf '正在构建 DoctorWorkstation.app…\n'
PYINSTALLER_PYTHON="$python_bin" SKIP_FRONTEND_INSTALL=1 \
/bin/bash "$script_dir/build_macos.sh"
artifact="$project_root/dist/DoctorWorkstation.app"
project_version="$(/usr/bin/awk -F '"' '/^version = "/ { print $2; exit }' \
"$project_root/pyproject.toml")"
[[ -n "$project_version" ]] || macos_die "无法从 pyproject.toml 读取版本号。"
case "$(uname -m)" in
arm64) release_arch="arm64" ;;
x86_64) release_arch="x64" ;;
*) macos_die "不支持的 Mac CPU 架构:$(uname -m)" ;;
esac
release_zip="$project_root/dist/DoctorWorkstation-macOS-$release_arch-$project_version.zip"
checksum_file="$release_zip.sha256"
rm -f -- "$release_zip" "$checksum_file"
printf '正在生成可分发 ZIP…\n'
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$artifact" "$release_zip"
release_hash="$(/usr/bin/shasum -a 256 "$release_zip" | /usr/bin/awk '{print $1}')"
printf '%s %s\n' "$release_hash" "$(basename "$release_zip")" >"$checksum_file"
printf '\n打包成功:%s\n' "$artifact"
printf '分发包:%s\n' "$release_zip"
printf 'SHA-256%s\n' "$release_hash"
+198
View File
@@ -0,0 +1,198 @@
[CmdletBinding()]
param(
[switch]$ValidateOnly
)
$ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$BuildScript = Join-Path $PSScriptRoot "build_windows.ps1"
$BuildEnvironment = Join-Path $ProjectRoot ".venv-build"
$BuildPython = Join-Path $BuildEnvironment "Scripts\python.exe"
$FallbackPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
$PackageLock = Join-Path $CompanionRoot "package-lock.json"
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
$Executable = Join-Path $Artifact "DoctorWorkstation.exe"
$DistributionRoot = Join-Path $ProjectRoot "dist"
$ReleaseLauncherTemplate = Join-Path $ProjectRoot "packaging\windows\start_release.bat"
$ReleaseLauncher = Join-Path $DistributionRoot "Start_DoctorWorkstation.bat"
$ProjectMetadata = Join-Path $ProjectRoot "pyproject.toml"
function Test-BuildPython {
param([Parameter(Mandatory = $true)][string]$Candidate)
if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
return $false
}
try {
& $Candidate -c "import sys, httpx, PyInstaller, PySide6; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" 2>$null
return $LASTEXITCODE -eq 0
}
catch {
return $false
}
}
function Find-Application {
param([Parameter(Mandatory = $true)][string]$Name)
$Command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($Command) { return $Command.Source }
return $null
}
try {
foreach ($RequiredFile in @(
$BuildScript,
$PackageLock,
(Join-Path $ProjectRoot "uv.lock"),
$ProjectMetadata,
$ReleaseLauncherTemplate
)) {
if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) {
throw "Required build file is missing: $RequiredFile"
}
}
$Npm = Find-Application -Name "npm.cmd"
$Node = Find-Application -Name "node.exe"
if (-not $Npm -or -not $Node) {
throw "Node.js and npm are required to build the video companion."
}
$NodeVersionText = (& $Node --version).Trim().TrimStart("v")
$NodeVersion = [version]$NodeVersionText
if ($NodeVersion.Major -lt 20) {
throw "Node.js 20 or newer is required; found $NodeVersionText"
}
$Uv = Find-Application -Name "uv"
if ($ValidateOnly) {
if (-not $Uv -and
-not (Test-BuildPython -Candidate $BuildPython) -and
-not (Test-BuildPython -Candidate $FallbackPython)) {
throw "Neither uv nor a usable Python environment with build dependencies was found."
}
Write-Host "Windows package entry validation passed."
exit 0
}
if ($Uv) {
Write-Host "Preparing locked Python build dependencies in $BuildEnvironment ..."
$PreviousProjectEnvironment = [Environment]::GetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
"Process"
)
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
$BuildEnvironment,
"Process"
)
Push-Location $ProjectRoot
try {
& $Uv sync --frozen --extra build
if ($LASTEXITCODE -ne 0) {
throw "uv sync for build dependencies failed with exit code $LASTEXITCODE"
}
}
finally {
Pop-Location
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
$PreviousProjectEnvironment,
"Process"
)
}
}
elseif (Test-BuildPython -Candidate $BuildPython) {
Write-Host "Using existing build environment: $BuildPython"
}
elseif (Test-BuildPython -Candidate $FallbackPython) {
$BuildPython = $FallbackPython
Write-Host "Using existing project environment with build dependencies: $BuildPython"
}
else {
throw "Install uv or prepare .venv-build with the project's build dependencies."
}
if (-not (Test-BuildPython -Candidate $BuildPython)) {
throw "The prepared build Python is unusable: $BuildPython"
}
Write-Host "Installing locked video companion dependencies..."
& $Npm ci --prefix $CompanionRoot --no-audit --no-fund
if ($LASTEXITCODE -ne 0) {
throw "npm ci failed with exit code $LASTEXITCODE"
}
Write-Host "Running the existing Windows release build..."
& $BuildScript -Python $BuildPython -SkipFrontendInstall
if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) {
throw "Build completed without the expected executable: $Executable"
}
$ProjectText = [System.IO.File]::ReadAllText($ProjectMetadata)
$VersionMatch = [regex]::Match(
$ProjectText,
'(?m)^\s*version\s*=\s*"([^"]+)"'
)
if (-not $VersionMatch.Success) {
throw "Unable to read the project version from $ProjectMetadata"
}
$ProjectVersion = $VersionMatch.Groups[1].Value
$ReleaseZip = Join-Path $DistributionRoot (
"DoctorWorkstation-Windows-x64-$ProjectVersion.zip"
)
$ChecksumFile = Join-Path $DistributionRoot "SHA256SUMS.txt"
Copy-Item -LiteralPath $ReleaseLauncherTemplate -Destination $ReleaseLauncher -Force
if (Test-Path -LiteralPath $ReleaseZip) {
Remove-Item -LiteralPath $ReleaseZip -Force
}
Write-Host "Creating the distributable ZIP..."
$SevenZip = Find-Application -Name "7z.exe"
if ($SevenZip) {
Push-Location $DistributionRoot
try {
& $SevenZip a -tzip -mx=5 -mmt=on $ReleaseZip `
".\DoctorWorkstation" ".\Start_DoctorWorkstation.bat"
if ($LASTEXITCODE -ne 0) {
throw "7-Zip failed with exit code $LASTEXITCODE"
}
}
finally {
Pop-Location
}
}
else {
Compress-Archive `
-LiteralPath @($Artifact, $ReleaseLauncher) `
-DestinationPath $ReleaseZip `
-CompressionLevel Optimal
}
if (-not (Test-Path -LiteralPath $ReleaseZip -PathType Leaf)) {
throw "Packaging completed without the expected ZIP: $ReleaseZip"
}
$ReleaseHash = (Get-FileHash -LiteralPath $ReleaseZip -Algorithm SHA256).Hash
$ChecksumLine = "$ReleaseHash $([System.IO.Path]::GetFileName($ReleaseZip))`r`n"
$Utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ChecksumFile, $ChecksumLine, $Utf8WithoutBom)
Write-Host "Windows package complete." -ForegroundColor Green
Write-Host "Artifact: $Artifact" -ForegroundColor Green
Write-Host "Release ZIP: $ReleaseZip" -ForegroundColor Green
Write-Host "SHA-256: $ReleaseHash" -ForegroundColor Green
exit 0
}
catch {
$FailureExitCode = if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) {
$LASTEXITCODE
}
else {
1
}
Write-Host "Windows packaging failed." -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
exit $FailureExitCode
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd -P)"
project_root="$(cd "$script_dir/.." && pwd -P)"
# shellcheck source=macos_helpers.sh
source "$script_dir/macos_helpers.sh"
macos_install_exit_trap 0
macos_require_darwin
artifact="$project_root/dist/DoctorWorkstation.app"
artifact_executable="$artifact/Contents/MacOS/DoctorWorkstation"
if [[ -d "$artifact" && -x "$artifact_executable" ]]; then
printf '正在打开已构建应用:%s\n' "$artifact"
if /usr/bin/open "$artifact"; then
printf '应用已启动。\n'
exit 0
fi
macos_die "DoctorWorkstation.app 存在,但 Finder 无法打开它。"
fi
if [[ -e "$artifact" ]]; then
printf '检测到不完整的应用产物,将改为启动源码:%s\n' "$artifact" >&2
else
printf '尚无已构建应用,正在准备源码运行环境。\n'
fi
macos_ensure_uv
cd "$project_root"
printf '正在同步 Python 运行依赖(首次运行可能需要几分钟)…\n'
"$MACOS_UV_BIN" sync --locked
printf '正在启动医生工作站…\n'
"$MACOS_UV_BIN" run --frozen doctor-workstation
+133
View File
@@ -0,0 +1,133 @@
[CmdletBinding()]
param(
[switch]$ValidateOnly
)
$ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$FrozenExecutable = Join-Path $ProjectRoot "dist\DoctorWorkstation\DoctorWorkstation.exe"
$ProjectPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
$SourceRoot = Join-Path $ProjectRoot "src"
function Test-ProjectPython {
param([Parameter(Mandatory = $true)][string]$Candidate)
if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
return $false
}
try {
& $Candidate -c "import sys, httpx, PySide6; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" 2>$null
return $LASTEXITCODE -eq 0
}
catch {
return $false
}
}
function Find-Uv {
$Command = Get-Command uv -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($Command) { return $Command.Source }
return $null
}
try {
if (Test-Path -LiteralPath $FrozenExecutable -PathType Leaf) {
Write-Host "Using packaged application: $FrozenExecutable"
if ($ValidateOnly) {
Write-Host "Windows run entry validation passed."
exit 0
}
& $FrozenExecutable
$ApplicationExitCode = $LASTEXITCODE
if ($null -eq $ApplicationExitCode) { $ApplicationExitCode = 0 }
if ($ApplicationExitCode -ne 0) {
Write-Host "DoctorWorkstation exited with code $ApplicationExitCode" -ForegroundColor Red
}
exit $ApplicationExitCode
}
$Python = $null
if (Test-ProjectPython -Candidate $ProjectPython) {
$Python = $ProjectPython
Write-Host "Using existing project environment: $ProjectPython"
}
else {
$Uv = Find-Uv
if (-not $Uv) {
throw "No packaged application or usable .venv was found, and uv is not installed."
}
if ($ValidateOnly) {
Write-Host "Source fallback is available through uv: $Uv"
Write-Host "Windows run entry validation passed."
exit 0
}
Write-Host "Preparing the project environment with uv..."
$PreviousProjectEnvironment = [Environment]::GetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
"Process"
)
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
(Join-Path $ProjectRoot ".venv"),
"Process"
)
Push-Location $ProjectRoot
try {
& $Uv sync --frozen
if ($LASTEXITCODE -ne 0) {
throw "uv sync failed with exit code $LASTEXITCODE"
}
}
finally {
Pop-Location
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
$PreviousProjectEnvironment,
"Process"
)
}
if (-not (Test-ProjectPython -Candidate $ProjectPython)) {
throw "uv completed, but the project Python is still unusable: $ProjectPython"
}
$Python = $ProjectPython
}
if ($ValidateOnly) {
Write-Host "Windows run entry validation passed."
exit 0
}
$PreviousPythonPath = [Environment]::GetEnvironmentVariable("PYTHONPATH", "Process")
$env:PYTHONPATH = if ($PreviousPythonPath) {
$SourceRoot + [System.IO.Path]::PathSeparator + $PreviousPythonPath
}
else {
$SourceRoot
}
Push-Location $ProjectRoot
try {
& $Python -m doctor_workstation
$ApplicationExitCode = $LASTEXITCODE
}
finally {
Pop-Location
[Environment]::SetEnvironmentVariable("PYTHONPATH", $PreviousPythonPath, "Process")
}
if ($null -eq $ApplicationExitCode) { $ApplicationExitCode = 0 }
if ($ApplicationExitCode -ne 0) {
Write-Host "DoctorWorkstation exited with code $ApplicationExitCode" -ForegroundColor Red
}
exit $ApplicationExitCode
}
catch {
$FailureExitCode = if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) {
$LASTEXITCODE
}
else {
1
}
Write-Host "Unable to start DoctorWorkstation." -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
exit $FailureExitCode
}
+5
View File
@@ -0,0 +1,5 @@
"""Zhenyang doctor workstation."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+19
View File
@@ -0,0 +1,19 @@
import os
import sys
import traceback
from doctor_workstation.app import main
def _run() -> int:
try:
return main()
except Exception:
if "--smoke-test" not in sys.argv and os.getenv("DOCTOR_SMOKE_TEST") != "1":
raise
traceback.print_exc()
return 1
if __name__ == "__main__":
raise SystemExit(_run())
+716
View File
@@ -0,0 +1,716 @@
"""Application composition root for the doctor workstation."""
from __future__ import annotations
import logging
import os
import sys
import time
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QObject, Qt, QTimer
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtWidgets import (
QApplication,
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from doctor_workstation.config import AppConfig
from doctor_workstation.core import Session
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.logging_setup import configure_logging
from doctor_workstation.resources import resource_path, video_dist_path
from doctor_workstation.services import (
DemoDoctorRepository,
RemoteDoctorRepository,
TokenStore,
build_repository,
)
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
from doctor_workstation.ui.widgets import (
friendly_error,
run_async,
set_authentication_expired_handler,
show_toast,
)
from doctor_workstation.video import BackendMode, launch_video_call
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
LOGGER = logging.getLogger(__name__)
class _UnconfiguredRepository:
"""Login boundary used until an administrator supplies a backend URL."""
def login(
self,
account: str,
password: str,
*,
remember_account: bool = False,
) -> Session:
del account, password, remember_account
raise ValueError("请先展开“服务器设置”,填写管理员提供的 HTTPS 接口地址。")
def get_current_user(self) -> None:
return None
class DemoVideoDialog(QDialog):
"""Non-network video-room preview used only by the explicit demo mode."""
def __init__(self, patient_name: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._seconds = 0
self.setWindowTitle("视频面诊 · 演示模式")
self.setMinimumSize(760, 520)
self.resize(980, 660)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#0B1210;}"
"QLabel{color:#EAF2EE;}"
"QFrame#RemoteStage{background:#14211E;border:1px solid #2C403A;border-radius:18px;}"
"QFrame#LocalStage{background:#20312C;border:1px solid #3C554D;border-radius:14px;}"
"QPushButton{min-width:96px;min-height:42px;border-radius:21px;background:#253A34;"
"color:#F4F8F6;border:1px solid #3C554D;}"
"QPushButton:hover{background:#304A42;}"
"QPushButton#Hangup{background:#B94B44;border-color:#CF625B;}"
)
root = QVBoxLayout(self)
root.setContentsMargins(22, 18, 22, 22)
root.setSpacing(14)
header = QHBoxLayout()
title = QLabel(f"{patient_name or '患者'} 的视频面诊")
title.setStyleSheet("font-size:18px;font-weight:700;")
header.addWidget(title)
header.addStretch(1)
demo = QLabel("● 演示模式 · 未连接腾讯云")
demo.setStyleSheet("color:#91B9AC;font-size:12px;")
header.addWidget(demo)
self.duration_label = QLabel("00:00")
self.duration_label.setStyleSheet("font-weight:700;")
header.addWidget(self.duration_label)
root.addLayout(header)
stage = QFrame()
stage.setObjectName("RemoteStage")
stage_layout = QVBoxLayout(stage)
stage_layout.setContentsMargins(22, 22, 22, 22)
stage_layout.addStretch(1)
avatar = QLabel((patient_name or "")[:1])
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar.setFixedSize(104, 104)
avatar.setStyleSheet(
"background:#DDF1EC;color:#0F6D64;border-radius:52px;font-size:42px;font-weight:700;"
)
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
waiting = QLabel("等待患者接听…")
waiting.setAlignment(Qt.AlignmentFlag.AlignCenter)
waiting.setStyleSheet("font-size:17px;font-weight:600;")
stage_layout.addWidget(waiting)
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint.setStyleSheet("color:#80948D;font-size:12px;")
stage_layout.addWidget(hint)
stage_layout.addStretch(1)
local = QFrame(stage)
local.setObjectName("LocalStage")
local.setGeometry(24, 24, 178, 112)
local_layout = QVBoxLayout(local)
local_label = QLabel("医生画面")
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
local_label.setStyleSheet("color:#A9BDB6;font-weight:600;")
local_layout.addWidget(local_label)
root.addWidget(stage, 1)
controls = QHBoxLayout()
controls.addStretch(1)
self.mic_button = QPushButton("麦克风 开")
self.mic_button.setCheckable(True)
self.mic_button.toggled.connect(
lambda muted: self.mic_button.setText("麦克风 关" if muted else "麦克风 开")
)
controls.addWidget(self.mic_button)
self.camera_button = QPushButton("摄像头 开")
self.camera_button.setCheckable(True)
self.camera_button.toggled.connect(
lambda off: self.camera_button.setText("摄像头 关" if off else "摄像头 开")
)
controls.addWidget(self.camera_button)
hangup = QPushButton("结束面诊")
hangup.setObjectName("Hangup")
hangup.clicked.connect(self.close)
controls.addWidget(hangup)
controls.addStretch(1)
root.addLayout(controls)
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
self._timer.start(1000)
def _tick(self) -> None:
self._seconds += 1
minutes, seconds = divmod(self._seconds, 60)
self.duration_label.setText(f"{minutes:02d}:{seconds:02d}")
class ApplicationController(QObject):
"""Own windows, repositories and the authenticated application lifecycle."""
def __init__(self, application: QApplication, config: AppConfig) -> None:
super().__init__()
self.application = application
self.config = config
self.token_store = TokenStore(config.config_dir / "credentials.json")
self.demo_repository = DemoDoctorRepository()
self.remote_repository: RemoteDoctorRepository | None = None
self.login_window: LoginWindow | None = None
self.shell_window: ShellWindow | None = None
self.current_repository: Any = None
self.current_demo_mode = config.demo_mode
self.video_calls: dict[str, Any] = {}
self.video_pending: dict[str, object] = {}
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
self._restore_generation = 0
self._restore_in_progress = False
self._restore_worker: Any = None
self._shutting_down = False
self._authentication_expiry_in_progress = False
self._rebuild_remote_repository()
set_authentication_expired_handler(self._on_authentication_expired)
application.aboutToQuit.connect(self.shutdown)
def start(self) -> None:
"""Show login immediately, then validate a production token off-thread."""
self._show_login()
self._begin_session_restore()
def _base_repository(self) -> Any:
return self.remote_repository or _UnconfiguredRepository()
def _show_login(self) -> None:
if self.login_window is None:
self.login_window = LoginWindow(
self._base_repository(),
self.config,
self.demo_repository,
)
self.login_window.login_succeeded.connect(self._on_login_succeeded)
self.login_window.config_changed.connect(self._on_config_changed)
self.login_window.demo_mode_changed.connect(self._on_demo_mode_changed)
self._apply_window_icon(self.login_window)
else:
self.login_window.repository = self._base_repository()
self.login_window.config = self.config
if not self.login_window.demo_check.isChecked():
self.login_window.active_repository = self._base_repository()
self.login_window.show()
self.login_window.raise_()
self.login_window.activateWindow()
def _on_config_changed(self, payload: Any) -> None:
previous_connection = (
self.config.api_base_url,
self.config.request_timeout,
self.config.verify_ssl,
)
if isinstance(payload, AppConfig):
updated = payload
elif isinstance(payload, dict):
aliases = {
"base_url": "api_base_url",
"read_timeout": "request_timeout",
}
changes = {aliases.get(key, key): value for key, value in payload.items()}
allowed = {
"api_base_url",
"demo_mode",
"video_mode",
"video_web_url",
"verify_ssl",
"request_timeout",
"log_level",
"remembered_account",
}
updated = self.config.with_updates(
**{key: value for key, value in changes.items() if key in allowed}
)
else:
return
self.config = updated
with suppress(OSError):
self.config.save_preferences()
next_connection = (
self.config.api_base_url,
self.config.request_timeout,
self.config.verify_ssl,
)
if next_connection != previous_connection:
self._cancel_session_restore()
self._rebuild_remote_repository()
if self.login_window is not None:
self.login_window.repository = self._base_repository()
if not self.login_window.demo_check.isChecked():
self.login_window.active_repository = self._base_repository()
if self.login_window is not None:
self.login_window.config = self.config
def _on_demo_mode_changed(self, enabled: bool) -> None:
self.current_demo_mode = enabled
if enabled:
self._cancel_session_restore()
def _rebuild_remote_repository(self) -> None:
old = self.remote_repository
self.remote_repository = None
if self.config.api_base_url:
repository = build_repository(
demo=False,
base_url=self.config.api_base_url,
token_store=self.token_store,
timeout=self.config.request_timeout,
verify=self.config.verify_ssl,
)
if isinstance(repository, RemoteDoctorRepository):
self.remote_repository = repository
if old is not None and old is not self.current_repository:
with suppress(Exception):
old.client.close()
def _begin_session_restore(self) -> None:
"""Validate a scoped persisted token without racing manual login."""
repository = self.remote_repository
if (
self._shutting_down
or self.config.demo_mode
or repository is None
or self.current_repository is not None
):
return
self._restore_generation += 1
generation = self._restore_generation
self._restore_in_progress = True
if self.login_window is not None:
self.login_window.set_session_restore_pending(True)
self._restore_worker = run_async(
repository.restore_session,
on_success=lambda session: self._on_restore_success(
session,
repository,
generation,
),
on_error=lambda error: self._on_restore_error(
error,
repository,
generation,
),
on_finished=lambda: self._on_restore_finished(repository, generation),
)
def _restore_is_current(
self,
repository: RemoteDoctorRepository,
generation: int,
) -> bool:
"""Return whether a restore callback still owns the login surface."""
return (
not self._shutting_down
and self._restore_in_progress
and generation == self._restore_generation
and repository is self.remote_repository
)
def _finish_session_restore(
self,
repository: RemoteDoctorRepository,
generation: int,
) -> bool:
"""Release login controls only for the currently active restore."""
if not self._restore_is_current(repository, generation):
return False
self._restore_in_progress = False
self._restore_worker = None
if self.login_window is not None:
self.login_window.set_session_restore_pending(False)
return True
def _on_restore_success(
self,
session: object,
repository: RemoteDoctorRepository,
generation: int,
) -> None:
"""Enter the shell only for a current, fully validated session."""
if not self._finish_session_restore(repository, generation):
return
if session is None:
return
if not isinstance(session, Session) or not session.authenticated:
with suppress(Exception):
repository.logout()
self._login_guard_error("已保存的登录状态无效,请重新登录。")
return
self._on_login_succeeded(
{
"session": session,
"user": session.user,
"repository": repository,
"demo_mode": False,
"restored_session": True,
}
)
def _on_restore_error(
self,
error: Exception,
repository: RemoteDoctorRepository,
generation: int,
) -> None:
"""Return control to login after a current restore attempt fails."""
if not self._finish_session_restore(repository, generation):
return
if isinstance(error, AuthenticationExpiredError):
message = "登录状态已失效,请重新登录。"
else:
message = f"自动恢复登录失败:{friendly_error(error)}"
self._login_guard_error(message)
def _on_restore_finished(
self,
repository: RemoteDoctorRepository,
generation: int,
) -> None:
"""Release controls when a worker finishes without a result callback."""
self._finish_session_restore(repository, generation)
def _cancel_session_restore(self) -> None:
"""Invalidate late callbacks and re-enable manual login controls."""
self._restore_generation += 1
if not self._restore_in_progress:
return
self._restore_in_progress = False
self._restore_worker = None
if self.login_window is not None:
self.login_window.set_session_restore_pending(False)
def _on_login_succeeded(self, payload: dict[str, Any]) -> None:
self._cancel_session_restore()
session = payload.get("session")
repository = payload.get("repository")
if not isinstance(session, Session) or repository is None:
self._login_guard_error("登录响应不完整,请重试。")
return
if session.password_change_required:
with suppress(Exception):
repository.logout()
self._login_guard_error("该账号需要先修改初始密码,请在管理后台完成后重新登录。")
return
if session.work_wechat_binding_required:
with suppress(Exception):
repository.logout()
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
return
demo_mode = bool(payload.get("demo_mode"))
if not demo_mode and not session.menu:
with suppress(Exception):
repository.logout()
self._login_guard_error("当前账号没有可用医生端菜单,请联系管理员授权。")
return
self.current_repository = repository
self._authentication_expiry_in_progress = False
self.current_demo_mode = demo_mode
self.shell_window = ShellWindow(repository, payload, session.permissions)
self.shell_window.logout_requested.connect(self._logout)
self.shell_window.video_requested.connect(self._request_video)
self._apply_window_icon(self.shell_window)
if self.login_window is not None:
self.login_window.hide()
self.shell_window.show()
self.shell_window.raise_()
self.shell_window.activateWindow()
def _login_guard_error(self, message: str) -> None:
if self.login_window is not None:
self.login_window.error_banner.show_message(message, "warning")
self.login_window.show()
def _on_authentication_expired(self, error: AuthenticationExpiredError) -> bool:
"""Consume an active-shell expiry and atomically return to login."""
if self.shell_window is None or self.current_repository is None:
return False
if self._authentication_expiry_in_progress:
return True
self._authentication_expiry_in_progress = True
LOGGER.info(
"authenticated session expired",
extra={"api_code": error.code, "request_id": error.request_id},
)
self._logout(message="登录状态已失效,请重新登录。")
return True
def _logout(self, *, message: str = "") -> None:
"""Clear authenticated resources and return to the login window."""
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
self._wait_for_video_lifecycle(calls, timeout=1.25)
self.video_calls.clear()
self.video_pending.clear()
for dialog in self.demo_video_dialogs.values():
dialog.close()
self.demo_video_dialogs.clear()
if self.current_repository is not None:
with suppress(Exception):
self.current_repository.logout()
self.current_repository = None
if self.shell_window is not None:
self.shell_window.close()
self.shell_window.deleteLater()
self.shell_window = None
self._show_login()
if message and self.login_window is not None:
self.login_window.error_banner.show_message(message, "warning")
def _request_video(self, payload: dict[str, Any]) -> None:
parent = self.shell_window
if parent is None or self.current_repository is None:
return
patient_id = payload.get("patient_id")
diagnosis_id = payload.get("diagnosis_id")
patient_name = str(payload.get("patient_name") or "患者")
if patient_id in (None, "") or diagnosis_id in (None, ""):
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
return
call_key = str(diagnosis_id)
if (
call_key in self.video_pending
or call_key in self.video_calls
or call_key in self.demo_video_dialogs
):
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
return
if self.current_demo_mode:
dialog = DemoVideoDialog(patient_name, parent)
dialog.finished.connect(
lambda _result, key=call_key, item=dialog: self._forget_demo_dialog(
key,
item,
)
)
self.demo_video_dialogs[call_key] = dialog
dialog.show()
return
show_toast(parent, "正在获取安全通话凭证…", "info")
repository = self.current_repository
marker = object()
self.video_pending[call_key] = marker
def get_ticket() -> Any:
return repository.get_call_ticket(
patient_id=int(patient_id),
diagnosis_id=int(diagnosis_id),
)
run_async(
get_ticket,
on_success=lambda ticket: self._launch_video(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
call_key=call_key,
marker=marker,
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
parent,
error,
),
)
def _video_ticket_error(
self,
call_key: str,
marker: object,
parent: QWidget,
error: Exception,
) -> None:
if self.video_pending.get(call_key) is not marker:
return
self.video_pending.pop(call_key, None)
if self.shell_window is parent:
show_toast(
parent,
f"视频准备失败:{friendly_error(error)}",
"danger",
5200,
)
def _launch_video(
self,
ticket: Any,
*,
diagnosis_id: Any,
patient_id: Any,
repository: Any,
call_key: str,
marker: object,
) -> None:
if self.video_pending.get(call_key) is not marker:
return
self.video_pending.pop(call_key, None)
if (
self.shell_window is None
or self.current_repository is not repository
or call_key in self.video_calls
):
return
try:
mode = BackendMode.parse(self.config.video_mode)
if mode is BackendMode.BROWSER:
raise ValueError("当前后端未提供一次性通话交接票据,浏览器视频模式已安全停用。")
if not WEBENGINE_AVAILABLE:
raise ValueError("当前安装缺少 QtWebEngine,无法打开受信任的视频窗口。")
call = launch_video_call(
ticket,
repository=repository,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
backend_mode=mode,
local_dist=video_dist_path(),
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
)
except Exception as error:
LOGGER.exception("video call could not be launched")
show_toast(
self.shell_window,
f"视频启动失败:{friendly_error(error)}",
"danger",
5600,
)
return
self.video_calls[call_key] = call
qt_window = getattr(call, "qt_window", None)
if qt_window is not None:
qt_window.destroyed.connect(
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
key,
expected,
)
)
def _release_video_call(self, call_key: str, call: Any) -> None:
if self.video_calls.get(call_key) is call:
self.video_calls.pop(call_key, None)
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
if self.demo_video_dialogs.get(call_key) is dialog:
self.demo_video_dialogs.pop(call_key, None)
dialog.deleteLater()
@staticmethod
def _wait_for_video_lifecycle(calls: tuple[Any, ...], *, timeout: float) -> bool:
"""Give queued endCall writes one short shared deadline after media closes."""
deadline = time.monotonic() + max(0.0, timeout)
complete = True
for call in calls:
remaining = deadline - time.monotonic()
if remaining <= 0:
complete = False
break
wait = getattr(call, "wait_for_lifecycle", None)
if callable(wait) and not wait(remaining):
complete = False
if not complete:
LOGGER.warning("video lifecycle cleanup exceeded its bounded deadline")
return complete
@staticmethod
def _apply_window_icon(window: QWidget) -> None:
icon_file = resource_path("icon.svg")
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
def shutdown(self) -> None:
"""Invalidate asynchronous restoration and release owned resources."""
if self._shutting_down:
return
self._shutting_down = True
self._cancel_session_restore()
set_authentication_expired_handler(None)
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
self._wait_for_video_lifecycle(calls, timeout=1.25)
if self.remote_repository is not None:
with suppress(Exception):
self.remote_repository.client.close()
def _create_application(argv: list[str]) -> QApplication:
with suppress(AttributeError):
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
application = QApplication(argv)
application.setApplicationName("臻阳堂医生工作站")
application.setApplicationDisplayName("臻阳堂医生工作站")
application.setOrganizationName("ZhenYangTang")
application.setOrganizationDomain("zhenyangtang.com")
application.setQuitOnLastWindowClosed(True)
icon_file = resource_path("icon.svg")
if icon_file.exists():
application.setWindowIcon(QIcon(str(icon_file)))
apply_theme(application)
return application
def main(argv: list[str] | None = None) -> int:
"""Start the GUI application and return its process exit code."""
config = AppConfig.load()
configure_logging(config.log_dir, config.log_level)
LOGGER.info("doctor workstation starting", extra={"demo_mode": config.demo_mode})
raw_argv = list(sys.argv if argv is None else argv)
smoke_test = "--smoke-test" in raw_argv
application = _create_application(
[argument for argument in raw_argv if argument != "--smoke-test"]
)
controller = ApplicationController(application, config)
controller.start()
if smoke_test or os.getenv("DOCTOR_SMOKE_TEST") == "1":
QTimer.singleShot(1200, application.quit)
return application.exec()
__all__ = ["ApplicationController", "DemoVideoDialog", "main"]
+172
View File
@@ -0,0 +1,172 @@
"""Application configuration and per-user preferences.
Secrets are intentionally excluded: the desktop client only receives a short-lived
TRTC UserSig from the authenticated backend and never stores an SDKSecretKey.
"""
from __future__ import annotations
import json
import os
from contextlib import suppress
from dataclasses import asdict, dataclass, fields, replace
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
try:
from dotenv import load_dotenv
except ImportError: # pragma: no cover - optional during pure unit tests
load_dotenv = None
try:
from platformdirs import user_config_dir, user_log_dir
except ImportError: # pragma: no cover - deterministic fallback
user_config_dir = None
user_log_dir = None
APP_NAME = "ZhenyangDoctor"
APP_AUTHOR = "Zhenyangtang"
def _as_bool(value: str | bool | None, default: bool) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on", "y"}
def _safe_timeout(value: str | int | float | None, default: float = 30.0) -> float:
try:
parsed = float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
return max(3.0, min(parsed, 120.0))
def _config_home() -> Path:
override = os.getenv("DOCTOR_CONFIG_DIR", "").strip()
if override:
return Path(override).expanduser()
if user_config_dir is not None:
return Path(user_config_dir(APP_NAME, APP_AUTHOR))
return Path.home() / f".{APP_NAME.lower()}"
def _log_home() -> Path:
override = os.getenv("DOCTOR_LOG_DIR", "").strip()
if override:
return Path(override).expanduser()
if user_log_dir is not None:
return Path(user_log_dir(APP_NAME, APP_AUTHOR))
return _config_home() / "logs"
def normalize_api_base_url(value: str) -> str:
"""Return a normalized HTTP(S) base URL ending in ``/adminapi``.
Empty values are accepted for demo mode. Credentials, fragments and query
strings are rejected to prevent accidentally persisting tokens in settings.
"""
raw = (value or "").strip().rstrip("/")
if not raw:
return ""
parts = urlsplit(raw)
if parts.scheme not in {"http", "https"} or not parts.netloc:
raise ValueError("服务器地址必须是完整的 http:// 或 https:// 地址")
if parts.username or parts.password or parts.query or parts.fragment:
raise ValueError("服务器地址不能包含账号、密码、查询参数或片段")
path = parts.path.rstrip("/")
if not path.endswith("/adminapi"):
path = f"{path}/adminapi" if path else "/adminapi"
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
@dataclass(frozen=True, slots=True)
class AppConfig:
"""Runtime configuration loaded from environment and user preferences."""
api_base_url: str = ""
demo_mode: bool = True
video_mode: str = "embedded"
video_web_url: str = ""
verify_ssl: bool = True
request_timeout: float = 30.0
log_level: str = "INFO"
remembered_account: str = ""
@property
def config_dir(self) -> Path:
return _config_home()
@property
def log_dir(self) -> Path:
return _log_home()
@property
def preferences_file(self) -> Path:
return self.config_dir / "preferences.json"
@classmethod
def load(cls, env_file: Path | None = None) -> AppConfig:
if load_dotenv is not None:
load_dotenv(dotenv_path=env_file, override=False)
raw_url = os.getenv("DOCTOR_API_BASE_URL", "")
try:
api_url = normalize_api_base_url(raw_url)
except ValueError:
api_url = ""
config = cls(
api_base_url=api_url,
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), True),
video_mode=os.getenv("DOCTOR_VIDEO_MODE", "embedded").strip().lower(),
video_web_url=os.getenv("DOCTOR_VIDEO_WEB_URL", "").strip(),
verify_ssl=_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True),
request_timeout=_safe_timeout(os.getenv("DOCTOR_REQUEST_TIMEOUT")),
log_level=os.getenv("DOCTOR_LOG_LEVEL", "INFO").strip().upper(),
)
return config._merge_preferences()
def _merge_preferences(self) -> AppConfig:
try:
payload = json.loads(self.preferences_file.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return self
allowed = {item.name for item in fields(self)}
clean: dict[str, Any] = {key: value for key, value in payload.items() if key in allowed}
if "api_base_url" in clean:
try:
clean["api_base_url"] = normalize_api_base_url(str(clean["api_base_url"]))
except ValueError:
clean.pop("api_base_url", None)
if "video_mode" in clean and clean["video_mode"] not in {"embedded", "browser"}:
clean.pop("video_mode", None)
if "request_timeout" in clean:
clean["request_timeout"] = _safe_timeout(clean["request_timeout"])
return replace(self, **clean)
def save_preferences(self) -> None:
"""Persist non-secret preferences atomically with user-only intent."""
self.config_dir.mkdir(parents=True, exist_ok=True)
target = self.preferences_file
temporary = target.with_suffix(".tmp")
payload = asdict(self)
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
with suppress(OSError):
os.chmod(temporary, 0o600)
temporary.replace(target)
def with_updates(self, **changes: Any) -> AppConfig:
if "api_base_url" in changes:
changes["api_base_url"] = normalize_api_base_url(str(changes["api_base_url"]))
if "video_mode" in changes and changes["video_mode"] not in {"embedded", "browser"}:
raise ValueError("视频模式只能是 embedded 或 browser")
if "request_timeout" in changes:
changes["request_timeout"] = _safe_timeout(changes["request_timeout"])
return replace(self, **changes)
@@ -0,0 +1,57 @@
"""UI-independent domain primitives for the doctor workstation."""
from .errors import (
ApiBusinessError,
ApiError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationError,
AuthenticationExpiredError,
InstallationRequiredError,
NeedBindWorkWechatError,
OpenNewPageError,
OpenPageRequiredError,
RepositoryNotFoundError,
WorkWechatBindingRequiredError,
)
from .models import (
Appointment,
CallTicket,
Consultation,
PageResult,
Patient,
Prescription,
PrescriptionTemplate,
UserProfile,
)
from .permissions import PermissionSet
from .session import Session
__all__ = [
"ApiBusinessError",
"ApiError",
"ApiHttpError",
"ApiProtocolError",
"ApiTimeoutError",
"ApiTransportError",
"Appointment",
"AuthenticationError",
"AuthenticationExpiredError",
"CallTicket",
"Consultation",
"InstallationRequiredError",
"NeedBindWorkWechatError",
"OpenNewPageError",
"OpenPageRequiredError",
"PageResult",
"Patient",
"PermissionSet",
"Prescription",
"PrescriptionTemplate",
"RepositoryNotFoundError",
"Session",
"UserProfile",
"WorkWechatBindingRequiredError",
]
+93
View File
@@ -0,0 +1,93 @@
"""Structured errors shared by HTTP and repository implementations."""
from __future__ import annotations
from typing import Any
class ApiError(RuntimeError):
"""Base API error carrying machine-readable response context."""
def __init__(
self,
message: str,
*,
code: int | None = None,
data: Any = None,
status_code: int | None = None,
request_id: str | None = None,
) -> None:
"""Initialise a structured API error."""
super().__init__(message)
self.message = message
self.code = code
self.data = data
self.status_code = status_code
self.request_id = request_id
class ApiTransportError(ApiError):
"""A network failure occurred before a valid API response was received."""
class ApiTimeoutError(ApiTransportError):
"""A request exceeded its configured timeout and exhausted safe retries."""
class ApiHttpError(ApiTransportError):
"""The server returned a non-successful HTTP status code."""
class ApiProtocolError(ApiError):
"""The response was not valid JSON or did not contain a valid envelope."""
class ApiBusinessError(ApiError):
"""The API returned envelope code ``0`` for a rejected business action."""
class AuthenticationExpiredError(ApiError):
"""The API returned envelope code ``-1`` and the session must be cleared."""
class WorkWechatBindingRequiredError(ApiError):
"""The API returned code ``10`` and Enterprise WeChat binding is required."""
class OpenPageRequiredError(ApiError):
"""The API returned code ``2`` with an external page that needs user action."""
def __init__(
self,
message: str,
*,
url: str = "",
data: Any = None,
status_code: int | None = None,
request_id: str | None = None,
) -> None:
"""Initialise the redirect signal without opening a browser implicitly."""
super().__init__(
message,
code=2,
data=data,
status_code=status_code,
request_id=request_id,
)
self.url = url
class InstallationRequiredError(ApiError):
"""The legacy API returned code ``-2`` for a missing companion component."""
class RepositoryNotFoundError(KeyError):
"""A requested in-memory or remote domain object could not be found."""
# Readable compatibility aliases for callers that use shorter exception names.
AuthenticationError = AuthenticationExpiredError
NeedBindWorkWechatError = WorkWechatBindingRequiredError
OpenNewPageError = OpenPageRequiredError
+913
View File
@@ -0,0 +1,913 @@
"""Typed domain models used by the doctor workstation service layer.
The production API is not backed by a published schema and has accumulated a
few field aliases over time. The ``from_dict`` factories in this module are
therefore deliberately conservative: known fields are normalised while the
complete source mapping is retained in ``raw`` for forward compatibility.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from math import ceil
from typing import Any, Generic, TypeVar
JSONDict = dict[str, Any]
T = TypeVar("T")
U = TypeVar("U")
def _mapping(value: object) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _text(value: object, default: str = "") -> str:
if value is None:
return default
return str(value)
def _integer(value: object, default: int | None = 0) -> int | None:
if value is None or value == "":
return default
if isinstance(value, bool):
return int(value)
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
try:
return int(float(str(value)))
except (TypeError, ValueError):
return default
def _number(value: object) -> float | None:
if value is None or value == "":
return None
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _boolean(value: object, default: bool = False) -> bool:
if value is None or value == "":
return default
if isinstance(value, str):
return value.strip().lower() not in {"0", "false", "no", "off", "null"}
return bool(value)
def _int_or_text(value: object, default: int | str = 0) -> int | str:
if value is None or value == "":
return default
converted = _integer(value, None)
return converted if converted is not None else str(value)
def _dict_list(value: object) -> list[JSONDict]:
if not isinstance(value, (list, tuple)):
return []
return [dict(item) for item in value if isinstance(item, Mapping)]
def _string_list(value: object) -> list[str]:
"""Return a clean list for APIs that alternate between CSV and arrays."""
values: object = value.split(",") if isinstance(value, str) else value
if not isinstance(values, (list, tuple, set, frozenset)):
return []
return [text for item in values if (text := _text(item).strip())]
def _string_tuple(value: object) -> tuple[str, ...]:
if isinstance(value, str):
values: object = value.split(",")
else:
values = value
if not isinstance(values, (list, tuple, set, frozenset)):
return ()
return tuple(item for item in (_text(entry).strip() for entry in values) if item)
def _role_tuple(value: object) -> tuple[int, ...]:
if isinstance(value, str):
values: object = value.split(",")
elif isinstance(value, (int, float)):
values = [value]
else:
values = value
if not isinstance(values, (list, tuple, set, frozenset)):
return ()
result: list[int] = []
for entry in values:
candidate = entry.get("id") if isinstance(entry, Mapping) else entry
role_id = _integer(candidate, None)
if role_id is not None and role_id not in result:
result.append(role_id)
return tuple(result)
def _formula_type(value: object) -> int | str:
"""Normalise known main/aux formula labels while preserving unknown values."""
text = _text(value).strip()
lowered = text.lower()
if lowered in {"", "1", "main", "primary", "主方"}:
return "main"
if lowered in {"2", "aux", "auxiliary", "secondary", "辅方"}:
return "aux"
converted = _integer(value, None)
return converted if converted is not None else text
@dataclass(slots=True)
class UserProfile:
"""Authenticated doctor profile returned by ``auth.admin/mySelf``."""
id: int = 0
account: str = ""
name: str = ""
avatar: str = ""
phone: str = ""
role_ids: tuple[int, ...] = ()
root: bool = False
department_id: int | None = None
department_name: str = ""
permissions: tuple[str, ...] = ()
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> UserProfile:
"""Build a profile from either a user object or a ``mySelf`` result."""
outer = _mapping(data)
nested_user = outer.get("user")
source = _mapping(nested_user) if isinstance(nested_user, Mapping) else outer
roles = source.get("role_ids", source.get("role_id", source.get("roles")))
permissions = outer.get("permissions", source.get("permissions", ()))
department = _mapping(source.get("department", source.get("dept")))
return cls(
id=_integer(source.get("id", source.get("user_id")), 0) or 0,
account=_text(source.get("account", source.get("username"))),
name=_text(
source.get(
"name",
source.get("real_name", source.get("nickname", source.get("account"))),
)
),
avatar=_text(source.get("avatar")),
phone=_text(source.get("phone", source.get("mobile"))),
role_ids=_role_tuple(roles),
root=_boolean(source.get("root", source.get("is_root"))),
department_id=_integer(
source.get("department_id", source.get("dept_id", department.get("id"))),
None,
),
department_name=_text(
source.get(
"department_name",
source.get("dept_name", department.get("name")),
)
),
permissions=_string_tuple(permissions),
raw=dict(source),
)
@dataclass(slots=True)
class Appointment:
"""A doctor appointment and the patient summary shown in queue views."""
id: int = 0
patient_id: int = 0
diagnosis_id: int | None = None
patient_name: str = ""
patient_phone: str = ""
gender: int | str | None = None
gender_desc: str = ""
age: int | None = None
height: float | None = None
weight: float | None = None
doctor_id: int | None = None
doctor_name: str = ""
assistant_id: int | None = None
assistant_name: str = ""
appointment_date: str = ""
appointment_time: str = ""
period: str = ""
appointment_type: int | str | None = None
appointment_type_text: str = ""
channel: int | str | None = None
channel_text: str = ""
status: int | str = 0
status_desc: str = ""
diagnosis_confirmed: bool = False
has_prescription: bool = False
prescription_audit_status: int | None = None
prescription_void_status: int | None = None
remark: str = ""
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> Appointment:
"""Build an appointment while accepting historical API aliases."""
source = _mapping(data)
return cls(
id=_integer(source.get("id", source.get("appointment_id")), 0) or 0,
patient_id=_integer(source.get("patient_id", source.get("source_patient_id")), 0) or 0,
diagnosis_id=_integer(source.get("diagnosis_id"), None),
patient_name=_text(source.get("patient_name", source.get("name"))),
patient_phone=_text(
source.get("patient_phone", source.get("phone", source.get("phone_masked")))
),
gender=source.get("gender", source.get("gender_desc")),
gender_desc=_text(source.get("gender_desc")),
age=_integer(source.get("age"), None),
height=_number(source.get("height")),
weight=_number(source.get("weight")),
doctor_id=_integer(source.get("doctor_id"), None),
doctor_name=_text(source.get("doctor_name")),
assistant_id=_integer(source.get("assistant_id"), None),
assistant_name=_text(source.get("assistant_name")),
appointment_date=_text(source.get("appointment_date", source.get("date"))),
appointment_time=_text(
source.get(
"appointment_time",
source.get("appointment_time_text", source.get("time")),
)
),
period=_text(source.get("period")),
appointment_type=source.get("appointment_type", source.get("type")),
appointment_type_text=_text(
source.get("appointment_type_text", source.get("type_text"))
),
channel=source.get("channel", source.get("appointment_channel")),
channel_text=_text(source.get("channel_text", source.get("channel_name"))),
status=_int_or_text(source.get("status"), 0),
status_desc=_text(source.get("status_desc", source.get("appointment_status_text"))),
diagnosis_confirmed=_boolean(source.get("diagnosis_confirmed")),
has_prescription=_boolean(source.get("has_prescription")),
prescription_audit_status=_integer(source.get("prescription_audit_status"), None),
prescription_void_status=_integer(source.get("prescription_void_status"), None),
remark=_text(source.get("remark")),
raw=dict(source),
)
@dataclass(slots=True)
class Patient:
"""A patient row from the doctor's scoped first-visit patient list."""
id: int = 0
diagnosis_id: int | None = None
source_patient_id: int | None = None
name: str = ""
gender: int | str | None = None
gender_desc: str = ""
age: int | None = None
phone: str = ""
phone_masked: str = ""
has_id_card: bool = False
assistant_id: int | None = None
assistant_name: str = ""
appointment_id: int | None = None
appointment_doctor_id: int | None = None
appointment_doctor_name: str = ""
appointment_status: int | str | None = None
appointment_status_text: str = ""
appointment_time_text: str = ""
appointment_date: str = ""
appointment_time: str = ""
appointments: list[JSONDict] = field(default_factory=list)
revisit_count: int = 0
confirmed: bool = False
confirmation_text: str = ""
diagnosis_date_text: str = ""
status_filter: str = ""
id_card: str = ""
region: str = ""
is_self_patient: bool = False
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@property
def patient_name(self) -> str:
"""Return the backend-style alias for ``name``."""
return self.name
@property
def patient_phone(self) -> str:
"""Return the best available phone value under the queue-style alias."""
return self.phone_masked or self.phone
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> Patient:
"""Build a patient from a tolerant first-visit list row."""
source = _mapping(data)
diagnosis_id = _integer(source.get("diagnosis_id", source.get("id")), None)
item_id = _integer(source.get("id", diagnosis_id), 0) or 0
return cls(
id=item_id,
diagnosis_id=diagnosis_id,
source_patient_id=_integer(source.get("source_patient_id"), None),
name=_text(source.get("patient_name", source.get("name"))),
gender=source.get("gender", source.get("gender_desc")),
gender_desc=_text(source.get("gender_desc")),
age=_integer(source.get("age"), None),
phone=_text(source.get("phone", source.get("patient_phone"))),
phone_masked=_text(source.get("phone_masked", source.get("phone"))),
has_id_card=_boolean(source.get("has_id_card")),
assistant_id=_integer(source.get("assistant_id"), None),
assistant_name=_text(source.get("assistant_name")),
appointment_id=_integer(source.get("appointment_id"), None),
appointment_doctor_id=_integer(source.get("appointment_doctor_id"), None),
appointment_doctor_name=_text(source.get("appointment_doctor_name")),
appointment_status=(
_int_or_text(source.get("appointment_status"))
if source.get("appointment_status") not in (None, "")
else None
),
appointment_status_text=_text(source.get("appointment_status_text")),
appointment_time_text=_text(source.get("appointment_time_text")),
appointment_date=_text(source.get("appointment_date")),
appointment_time=_text(source.get("appointment_time")),
appointments=_dict_list(source.get("appointments")),
revisit_count=_integer(source.get("revisit_count"), 0) or 0,
confirmed=_boolean(source.get("confirmed", source.get("diagnosis_confirmed"))),
confirmation_text=_text(source.get("confirmation_text")),
diagnosis_date_text=_text(source.get("diagnosis_date_text")),
status_filter=_text(source.get("status_filter", source.get("visit_status"))),
id_card=_text(source.get("id_card")),
region=_text(source.get("region", source.get("region_text"))),
is_self_patient=_boolean(source.get("is_self_patient")),
raw=dict(source),
)
@dataclass(slots=True)
class Consultation:
"""A diagnosis/consultation row used by the consultation workspace."""
id: int = 0
patient_id: int | None = None
appointment_id: int | None = None
patient_name: str = ""
patient_phone: str = ""
phone_masked: str = ""
id_card: str = ""
gender: int | str | None = None
gender_desc: str = ""
age: int | None = None
doctor_id: int | None = None
doctor_name: str = ""
assistant_id: int | None = None
assistant_name: str = ""
diagnosis_date: str = ""
appointment_date: str = ""
appointment_time: str = ""
period: str = ""
has_appointment: bool = False
appointment_status: int | str | None = None
appointment_status_text: str = ""
appointments: list[JSONDict] = field(default_factory=list)
consultation_type: int | str | None = None
clinical_diagnosis: str = ""
chief_complaint: str = ""
present_illness: str = ""
past_history: str = ""
allergy_history: str = ""
personal_history: str = ""
family_history: str = ""
current_medicine: str = ""
local_diagnosis: str = ""
prescription_opinion: str = ""
tongue: str = ""
pulse: str = ""
status: int | str = 0
status_desc: str = ""
confirmed: bool = False
diagnosis_view_records: list[JSONDict] = field(default_factory=list)
has_prescription: bool = False
unserved_days: int | None = None
video_hint: str = ""
source: int | str | None = None
source_text: str = ""
remark: str = ""
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> Consultation:
"""Build a consultation from a diagnosis row or reception detail."""
source = _mapping(data)
nested = _mapping(source.get("diagnosis"))
if nested:
merged: JSONDict = dict(source)
merged.update(nested)
source = merged
appointments = _dict_list(source.get("appointments"))
appointment = _mapping(source.get("appointment", source.get("latest_appointment")))
if not appointment:
appointment = appointments[0] if appointments else {}
view_records = _dict_list(
source.get("DiagnosisViewRecord", source.get("diagnosis_view_records"))
)
confirmed_value: object = source.get("confirmed", source.get("diagnosis_confirmed"))
if confirmed_value in (None, "") and view_records:
confirmed_value = any(_boolean(item.get("is_confirmed")) for item in view_records)
explicit_has_appointment = source.get("has_appointment")
has_appointment = (
_boolean(explicit_has_appointment)
if explicit_has_appointment not in (None, "")
else bool(appointment or appointments or source.get("appointment_id"))
)
appointment_status_value = source.get("appointment_status")
if appointment_status_value in (None, ""):
appointment_status_value = appointment.get("status")
return cls(
id=_integer(source.get("id", source.get("diagnosis_id")), 0) or 0,
patient_id=_integer(source.get("patient_id", source.get("source_patient_id")), None),
appointment_id=_integer(source.get("appointment_id", appointment.get("id")), None),
patient_name=_text(source.get("patient_name", source.get("name"))),
patient_phone=_text(source.get("patient_phone", source.get("phone"))),
phone_masked=_text(source.get("phone_masked")),
id_card=_text(source.get("id_card")),
gender=source.get("gender", source.get("gender_desc")),
gender_desc=_text(source.get("gender_desc")),
age=_integer(source.get("age"), None),
doctor_id=_integer(source.get("doctor_id", appointment.get("doctor_id")), None),
doctor_name=_text(source.get("doctor_name", appointment.get("doctor_name"))),
assistant_id=_integer(source.get("assistant_id"), None),
assistant_name=_text(source.get("assistant_name")),
diagnosis_date=_text(source.get("diagnosis_date", source.get("diagnosis_date_text"))),
appointment_date=_text(
source.get(
"appointment_date",
source.get(
"latest_appointment_date",
appointment.get("appointment_date", appointment.get("date")),
),
)
),
appointment_time=_text(
source.get(
"appointment_time",
source.get(
"appointment_time_text",
source.get(
"latest_appointment_time",
appointment.get("appointment_time", appointment.get("time_text")),
),
),
)
),
period=_text(source.get("period", appointment.get("period"))),
has_appointment=has_appointment,
appointment_status=(
_int_or_text(appointment_status_value)
if appointment_status_value not in (None, "")
else None
),
appointment_status_text=_text(
source.get(
"appointment_status_text",
appointment.get("status_text", appointment.get("status_desc")),
)
),
appointments=appointments,
consultation_type=source.get("consultation_type", source.get("visit_type")),
clinical_diagnosis=_text(source.get("clinical_diagnosis")),
chief_complaint=_text(source.get("chief_complaint", source.get("complaint"))),
present_illness=_text(
source.get("present_illness", source.get("present_illness_history"))
),
past_history=_text(source.get("past_history")),
allergy_history=_text(source.get("allergy_history")),
personal_history=_text(source.get("personal_history")),
family_history=_text(source.get("family_history")),
current_medicine=_text(source.get("current_medicine")),
local_diagnosis=_text(source.get("local_diagnosis")),
prescription_opinion=_text(source.get("prescription_opinion")),
tongue=_text(source.get("tongue")),
pulse=_text(source.get("pulse", source.get("pulse_condition"))),
# Diagnosis enablement and appointment workflow are separate domains.
status=_int_or_text(source.get("status"), 0),
status_desc=_text(source.get("status_desc")),
confirmed=_boolean(confirmed_value),
diagnosis_view_records=view_records,
has_prescription=_boolean(source.get("has_prescription")),
unserved_days=_integer(
source.get("unserved_days", source.get("unserved_day_count")), None
),
video_hint=_text(source.get("video_hint", source.get("call_hint"))),
source=source.get("source", source.get("diagnosis_source")),
source_text=_text(source.get("source_text", source.get("source_name"))),
remark=_text(source.get("remark")),
raw=dict(source),
)
@property
def diagnosis_confirmed(self) -> bool:
"""Return the appointment-list alias for the confirmation flag."""
return self.confirmed
@dataclass(slots=True)
class PrescriptionTemplate:
"""A reusable prescription-library formula owned by a doctor."""
id: int = 0
name: str = ""
formula_type: int | str = 1
herbs: list[JSONDict] = field(default_factory=list)
is_public: bool = False
disable_edit: bool = False
creator_id: int | None = None
creator_name: str = ""
create_time: str = ""
update_time: str = ""
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@property
def prescription_name(self) -> str:
"""Return the API field alias for the template name."""
return self.name
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> PrescriptionTemplate:
"""Build a prescription template from a list or detail response."""
source = _mapping(data)
return cls(
id=_integer(source.get("id"), 0) or 0,
name=_text(source.get("prescription_name", source.get("name"))),
formula_type=_formula_type(source.get("formula_type")),
herbs=_dict_list(source.get("herbs")),
is_public=_boolean(source.get("is_public")),
disable_edit=_boolean(source.get("disable_edit")),
creator_id=_integer(source.get("creator_id"), None),
creator_name=_text(source.get("creator_name")),
create_time=_text(source.get("create_time")),
update_time=_text(source.get("update_time")),
raw=dict(source),
)
def to_api_dict(self, *, include_id: bool = True) -> JSONDict:
"""Serialise this template using the server's field names."""
payload: JSONDict = {
"prescription_name": self.name,
"formula_type": "辅方" if _formula_type(self.formula_type) == "aux" else "主方",
"herbs": [dict(item) for item in self.herbs],
"is_public": int(self.is_public),
"disable_edit": int(self.disable_edit),
}
if include_id and self.id:
payload["id"] = self.id
return payload
@dataclass(slots=True)
class Prescription:
"""An issued prescription, including audit state and dosage information."""
id: int = 0
diagnosis_id: int | None = None
appointment_id: int | None = None
case_record: JSONDict = field(default_factory=dict)
sn: str = ""
patient_name: str = ""
phone: str = ""
phone_masked: str = ""
gender: int | str | None = None
age: int | None = None
visit_no: str = ""
prescription_date: str = ""
prescription_type: int | str | None = None
herbs: list[JSONDict] = field(default_factory=list)
clinical_diagnosis: str = ""
tongue: str = ""
tongue_image: str = ""
pulse: str = ""
pulse_condition: str = ""
dosage_amount: float | None = None
dosage_unit: str = ""
dosage_bag_count: int | None = None
need_decoction: bool = False
bags_per_dose: int | None = None
dose_count: int | None = None
dose_unit: str = ""
usage_days: int | None = None
times_per_day: int | None = None
usage_instruction: str = ""
usage_time: str = ""
usage_way: str = ""
dietary_taboo: list[str] = field(default_factory=list)
usage_notes: str = ""
aux_usage: JSONDict = field(default_factory=dict)
doctor_name: str = ""
doctor_signature: str = ""
creator_id: int | None = None
assistant_name: str = ""
is_system_auto: bool = False
is_shared: bool = False
visible_role_ids: tuple[int, ...] = ()
audit_status: int | None = None
audit_time: str = ""
audit_by_name: str = ""
audit_remark: str = ""
business_prescription_audit_status: int | None = None
business_prescription_audit_rejected: bool = False
business_prescription_audit_remark: str = ""
void_status: int | None = None
void_by_name: str = ""
void_time: str = ""
has_prescription_order: bool = False
prescription_order_id: int | None = None
order_no: str = ""
recipient_name: str = ""
recipient_phone: str = ""
shipping_province: str = ""
shipping_city: str = ""
shipping_district: str = ""
shipping_address: str = ""
pharmacy_remark: str = ""
remark_assistant: str = ""
medication_days: int | None = None
pill_requirement: str = ""
create_time: str = ""
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> Prescription:
"""Build a prescription from a compact list row or a full detail object."""
source = _mapping(data)
return cls(
id=_integer(source.get("id"), 0) or 0,
diagnosis_id=_integer(source.get("diagnosis_id"), None),
appointment_id=_integer(source.get("appointment_id"), None),
case_record=dict(_mapping(source.get("case_record"))),
sn=_text(source.get("sn")),
patient_name=_text(source.get("patient_name")),
phone=_text(source.get("phone", source.get("patient_phone"))),
phone_masked=_text(source.get("phone_masked")),
gender=source.get("gender", source.get("gender_desc")),
age=_integer(source.get("age"), None),
visit_no=_text(source.get("visit_no")),
prescription_date=_text(source.get("prescription_date")),
prescription_type=source.get("prescription_type"),
herbs=_dict_list(source.get("herbs")),
clinical_diagnosis=_text(source.get("clinical_diagnosis")),
tongue=_text(source.get("tongue")),
tongue_image=_text(source.get("tongue_image")),
pulse=_text(source.get("pulse")),
pulse_condition=_text(source.get("pulse_condition")),
dosage_amount=_number(source.get("dosage_amount")),
dosage_unit=_text(source.get("dosage_unit")),
dosage_bag_count=_integer(source.get("dosage_bag_count"), None),
need_decoction=_boolean(source.get("need_decoction")),
bags_per_dose=_integer(source.get("bags_per_dose"), None),
dose_count=_integer(source.get("dose_count"), None),
dose_unit=_text(source.get("dose_unit")),
usage_days=_integer(source.get("usage_days"), None),
times_per_day=_integer(source.get("times_per_day"), None),
usage_instruction=_text(source.get("usage_instruction")),
usage_time=_text(source.get("usage_time")),
usage_way=_text(source.get("usage_way")),
dietary_taboo=_string_list(source.get("dietary_taboo")),
usage_notes=_text(source.get("usage_notes")),
aux_usage=dict(_mapping(source.get("aux_usage"))),
doctor_name=_text(source.get("doctor_name")),
doctor_signature=_text(source.get("doctor_signature")),
creator_id=_integer(source.get("creator_id"), None),
assistant_name=_text(source.get("assistant_name")),
is_system_auto=_boolean(source.get("is_system_auto")),
is_shared=_boolean(source.get("is_shared")),
visible_role_ids=_role_tuple(source.get("visible_role_ids")),
audit_status=_integer(source.get("audit_status"), None),
audit_time=_text(source.get("audit_time")),
audit_by_name=_text(source.get("audit_by_name")),
audit_remark=_text(source.get("audit_remark")),
business_prescription_audit_status=_integer(
source.get("business_prescription_audit_status"), None
),
business_prescription_audit_rejected=_boolean(
source.get("business_prescription_audit_rejected")
),
business_prescription_audit_remark=_text(
source.get("business_prescription_audit_remark")
),
void_status=_integer(source.get("void_status"), None),
void_by_name=_text(source.get("void_by_name")),
void_time=_text(source.get("void_time")),
has_prescription_order=_boolean(source.get("has_prescription_order")),
prescription_order_id=_integer(source.get("prescription_order_id"), None),
order_no=_text(source.get("order_no")),
recipient_name=_text(source.get("recipient_name")),
recipient_phone=_text(source.get("recipient_phone")),
shipping_province=_text(source.get("shipping_province")),
shipping_city=_text(source.get("shipping_city")),
shipping_district=_text(source.get("shipping_district")),
shipping_address=_text(source.get("shipping_address")),
pharmacy_remark=_text(source.get("pharmacy_remark", source.get("pharmacy_note"))),
remark_assistant=_text(source.get("remark_assistant")),
medication_days=_integer(source.get("medication_days"), None),
pill_requirement=_text(source.get("pill_requirement", source.get("make_pill_remark"))),
create_time=_text(source.get("create_time")),
raw=dict(source),
)
def to_api_dict(self, *, include_id: bool = True) -> JSONDict:
"""Serialise the complete editable prescription DTO for add/edit."""
payload: JSONDict = {
"diagnosis_id": self.diagnosis_id,
"appointment_id": self.appointment_id,
"case_record": dict(self.case_record),
"creator_id": self.creator_id,
"is_system_auto": int(self.is_system_auto),
"patient_name": self.patient_name,
"phone": self.phone,
"gender": self.gender,
"age": self.age,
"visit_no": self.visit_no,
"prescription_date": self.prescription_date,
"prescription_type": self.prescription_type,
"tongue": self.tongue,
"tongue_image": self.tongue_image,
"pulse": self.pulse,
"pulse_condition": self.pulse_condition,
"clinical_diagnosis": self.clinical_diagnosis,
"herbs": [dict(item) for item in self.herbs],
"dosage_amount": self.dosage_amount,
"dosage_unit": self.dosage_unit,
"dosage_bag_count": self.dosage_bag_count,
"need_decoction": int(self.need_decoction),
"bags_per_dose": self.bags_per_dose,
"dose_count": self.dose_count,
"dose_unit": self.dose_unit,
"usage_days": self.usage_days,
"times_per_day": self.times_per_day,
"usage_instruction": self.usage_instruction,
"usage_time": self.usage_time,
"usage_way": self.usage_way,
"dietary_taboo": list(self.dietary_taboo),
"usage_notes": self.usage_notes,
"aux_usage": dict(self.aux_usage),
"doctor_name": self.doctor_name,
"doctor_signature": self.doctor_signature,
"is_shared": int(self.is_shared),
"visible_role_ids": list(self.visible_role_ids),
"audit_status": self.audit_status,
}
if include_id and self.id:
payload["id"] = self.id
return {key: value for key, value in payload.items() if value is not None}
@dataclass(slots=True)
class PageResult(Generic[T]):
"""A normalised paginated result with the API's optional extension data."""
items: list[T] = field(default_factory=list)
total: int = 0
page_no: int = 1
page_size: int = 20
extend: JSONDict = field(default_factory=dict)
@property
def lists(self) -> list[T]:
"""Return ``items`` under the legacy API name used by the web client."""
return self.items
@property
def count(self) -> int:
"""Return ``total`` under the legacy API name used by the web client."""
return self.total
@property
def pages(self) -> int:
"""Return the number of pages, or zero when the result is empty."""
return ceil(self.total / self.page_size) if self.total and self.page_size else 0
@classmethod
def from_payload(
cls,
payload: object,
parser: Callable[[Mapping[str, Any]], T],
*,
page_no: int = 1,
page_size: int = 20,
) -> PageResult[T]:
"""Normalise common list/count aliases and parse only mapping rows."""
top_source = _mapping(payload)
if isinstance(payload, (list, tuple)):
rows: object = payload
source: Mapping[str, Any] = {}
else:
source = _mapping(payload)
rows = []
nested = source.get("data")
if not any(key in source for key in ("lists", "items", "rows", "records")):
if isinstance(nested, Mapping):
source = nested
elif isinstance(nested, (list, tuple)):
rows = nested
source = {}
else:
rows = []
if not rows:
rows = next(
(
source[key]
for key in ("lists", "items", "rows", "records", "data")
if isinstance(source.get(key), (list, tuple))
),
[],
)
parsed = [parser(row) for row in rows if isinstance(row, Mapping)]
total = _integer(
source.get("count", source.get("total", source.get("total_count"))),
len(parsed),
)
result_page = _integer(
source.get("page_no", source.get("page", source.get("current_page"))),
page_no,
)
result_size = _integer(
source.get("page_size", source.get("per_page", source.get("limit"))),
page_size,
)
extend: JSONDict = {}
for candidate in (
top_source.get("extend", top_source.get("meta")),
source.get("extend", source.get("meta")),
):
if isinstance(candidate, Mapping):
extend.update(candidate)
return cls(
items=parsed,
total=max(total or 0, 0),
page_no=max(result_page or page_no, 1),
page_size=max(result_size or page_size, 1),
extend=extend,
)
def map(self, transform: Callable[[T], U]) -> PageResult[U]:
"""Return a page with transformed items and unchanged pagination data."""
return PageResult(
items=[transform(item) for item in self.items],
total=self.total,
page_no=self.page_no,
page_size=self.page_size,
extend=dict(self.extend),
)
@dataclass(slots=True)
class CallTicket:
"""Short-lived Tencent IM/TRTC credentials for one consultation flow."""
sdk_app_id: int = 0
user_id: str = ""
user_sig: str = ""
patient_user_id: str = ""
assistant_id: str = ""
diagnosis_id: int | None = None
room_id: str = ""
call_record_id: int | None = None
is_lochost_vod: bool = False
expires_at: int | None = None
raw: JSONDict = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> CallTicket:
"""Build a call ticket from the backend's camelCase or snake_case form."""
source = _mapping(data)
return cls(
sdk_app_id=_integer(source.get("sdkAppId", source.get("sdk_app_id")), 0) or 0,
user_id=_text(source.get("userId", source.get("user_id"))),
user_sig=_text(source.get("userSig", source.get("user_sig"))),
patient_user_id=_text(source.get("patientUserId", source.get("patient_user_id"))),
assistant_id=_text(source.get("assistant_id", source.get("assistantId"))),
diagnosis_id=_integer(source.get("diagnosis_id"), None),
room_id=_text(source.get("room_id", source.get("roomId"))),
call_record_id=_integer(source.get("call_record_id", source.get("callRecordId")), None),
is_lochost_vod=_boolean(source.get("isLochostVod", source.get("is_lochost_vod"))),
expires_at=_integer(source.get("expires_at", source.get("expireTime")), None),
raw=dict(source),
)
@@ -0,0 +1,152 @@
"""Immutable permission helpers matching the admin client's semantics."""
from __future__ import annotations
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
def _normalise(values: Iterable[str] | str | None) -> frozenset[str]:
if values is None:
return frozenset()
candidates = (values,) if isinstance(values, str) else values
return frozenset(value.strip() for value in candidates if value and value.strip())
def _requirements(values: tuple[object, ...]) -> tuple[str, ...]:
if len(values) == 1 and not isinstance(values[0], str):
candidate = values[0]
if isinstance(candidate, Iterable):
values = tuple(candidate)
return tuple(str(value).strip() for value in values if value is not None and str(value).strip())
@dataclass(frozen=True, slots=True, init=False)
class PermissionSet:
"""A permission collection supporting wildcard, AND and OR checks.
``permissions`` accepts the flat permission list returned by ``mySelf``.
``pages`` and ``actions`` are optional categories for callers that keep UI
navigation and action capabilities separate; checks operate on their union.
"""
permissions: frozenset[str]
page_permissions: frozenset[str]
action_permissions: frozenset[str]
def __init__(
self,
permissions: Iterable[str] | str | None = None,
*,
pages: Iterable[str] | str | None = None,
actions: Iterable[str] | str | None = None,
page_permissions: Iterable[str] | str | None = None,
action_permissions: Iterable[str] | str | None = None,
) -> None:
"""Create an immutable, whitespace-normalised permission set."""
object.__setattr__(self, "permissions", _normalise(permissions))
object.__setattr__(
self,
"page_permissions",
_normalise(pages) | _normalise(page_permissions),
)
object.__setattr__(
self,
"action_permissions",
_normalise(actions) | _normalise(action_permissions),
)
@property
def is_superuser(self) -> bool:
"""Return whether the global ``*`` wildcard is present."""
return "*" in self._all_permissions
@property
def _all_permissions(self) -> frozenset[str]:
return self.permissions | self.page_permissions | self.action_permissions
def has(self, permission: str) -> bool:
"""Return whether an exact or wildcard grant covers ``permission``."""
required = permission.strip()
if not required:
return False
grants = self._all_permissions
if "*" in grants or required in grants:
return True
return any(grant.endswith("/*") and required.startswith(grant[:-1]) for grant in grants)
def allows(self, permission: str) -> bool:
"""Alias for :meth:`has`, convenient in UI guard code."""
return self.has(permission)
def has_all(self, *permissions: object) -> bool:
"""Return true when every requested permission is granted (AND)."""
return all(self.has(permission) for permission in _requirements(permissions))
def all(self, *permissions: object) -> bool:
"""Alias for :meth:`has_all`, mirroring the web client's AND helper."""
return self.has_all(*permissions)
def has_any(self, *permissions: object) -> bool:
"""Return true when at least one requested permission is granted (OR)."""
return any(self.has(permission) for permission in _requirements(permissions))
def any(self, *permissions: object) -> bool:
"""Alias for :meth:`has_any`, mirroring ``v-perms`` OR semantics."""
return self.has_any(*permissions)
def can_access_page(self, permission: str) -> bool:
"""Check the permission protecting a page or navigation entry."""
return self.has(permission)
def has_page(self, permission: str) -> bool:
"""Alias for :meth:`can_access_page`."""
return self.can_access_page(permission)
def can_perform_action(self, permission: str) -> bool:
"""Check the permission protecting a button or write action."""
return self.has(permission)
def has_action(self, permission: str) -> bool:
"""Alias for :meth:`can_perform_action`."""
return self.can_perform_action(permission)
def can(self, page_or_permission: str, action: str | None = None) -> bool:
"""Check a direct permission or a ``page/action`` combination."""
if action is None:
return self.has(page_or_permission)
permission = f"{page_or_permission.rstrip('/')}/{action.lstrip('/')}"
return self.has(permission)
def __contains__(self, permission: object) -> bool:
"""Support ``permission in permission_set`` checks."""
return isinstance(permission, str) and self.has(permission)
def __iter__(self) -> Iterator[str]:
"""Iterate over all explicit grants in stable sorted order."""
return iter(sorted(self._all_permissions))
def __len__(self) -> int:
"""Return the number of distinct explicit grants."""
return len(self._all_permissions)
def __bool__(self) -> bool:
"""Return whether at least one grant exists."""
return bool(self._all_permissions)
@@ -0,0 +1,40 @@
"""Authenticated session state independent of the UI framework."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from .models import UserProfile
from .permissions import PermissionSet
@dataclass(slots=True)
class Session:
"""The token, user, permissions and login guards for one signed-in doctor."""
token: str = ""
user: UserProfile = field(default_factory=UserProfile)
permissions: PermissionSet = field(default_factory=PermissionSet)
menu: list[dict[str, Any]] = field(default_factory=list)
is_paw: int = 1
need_bind_work_wechat: bool = False
metadata: dict[str, Any] = field(default_factory=dict, repr=False)
@property
def authenticated(self) -> bool:
"""Return whether this session contains a non-empty access token."""
return bool(self.token.strip())
@property
def password_change_required(self) -> bool:
"""Return whether first-login password replacement is required."""
return self.is_paw == 0
@property
def work_wechat_binding_required(self) -> bool:
"""Return whether the user must bind Enterprise WeChat before use."""
return self.need_bind_work_wechat
@@ -0,0 +1,58 @@
"""Structured application logging with credential redaction."""
from __future__ import annotations
import logging
import re
from logging.handlers import RotatingFileHandler
from pathlib import Path
_SECRET_PATTERNS = (
re.compile(r'(?i)(token|usersig|authorization)(["\'\s:=]+)([^,\s"\']+)'),
re.compile(r'(?i)(password)(["\'\s:=]+)([^,\s"\']+)'),
)
class SecretRedactionFilter(logging.Filter):
"""Remove known credential-shaped values from every rendered log record."""
def filter(self, record: logging.LogRecord) -> bool:
rendered = record.getMessage()
for pattern in _SECRET_PATTERNS:
rendered = pattern.sub(r"\1\2<redacted>", rendered)
record.msg = rendered
record.args = ()
return True
def configure_logging(log_dir: Path, level: str = "INFO") -> Path:
"""Configure console and rotating file logging and return the log path."""
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "doctor-workstation.log"
numeric_level = getattr(logging, level.upper(), logging.INFO)
formatter = logging.Formatter(
fmt="%(asctime)s %(levelname)s %(name)s%(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
redaction = SecretRedactionFilter()
root = logging.getLogger()
root.setLevel(numeric_level)
root.handlers.clear()
file_handler = RotatingFileHandler(
log_file,
maxBytes=2 * 1024 * 1024,
backupCount=4,
encoding="utf-8",
)
file_handler.setFormatter(formatter)
file_handler.addFilter(redaction)
root.addHandler(file_handler)
console = logging.StreamHandler()
console.setFormatter(formatter)
console.addFilter(redaction)
root.addHandler(console)
return log_file
+28
View File
@@ -0,0 +1,28 @@
"""Locate bundled resources in source and PyInstaller builds."""
from __future__ import annotations
import sys
from pathlib import Path
def project_root() -> Path:
frozen_root = getattr(sys, "_MEIPASS", None)
if frozen_root:
return Path(frozen_root)
return Path(__file__).resolve().parents[2]
def resource_path(*parts: str) -> Path:
return project_root().joinpath("resources", *parts)
def video_dist_path() -> Path:
candidates = (
project_root() / "video_companion" / "dist" / "index.html",
project_root() / "video_companion_dist" / "index.html",
)
for candidate in candidates:
if candidate.exists():
return candidate
return resource_path("video", "index.html")
@@ -0,0 +1,27 @@
"""Transport, credential and repository adapters for the doctor workstation."""
from .api_client import ApiClient
from .factory import build_repository
from .mock_repository import DEMO_PERMISSIONS, DemoDoctorRepository
from .repository import (
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
AuditAction,
DoctorRepository,
RemoteDoctorRepository,
)
from .token_store import KeyringLike, TokenStore
__all__ = [
"ApiClient",
"AuditAction",
"DEMO_PERMISSIONS",
"DemoDoctorRepository",
"DoctorRepository",
"KeyringLike",
"PRESCRIPTION_LIBRARY_PERMISSIONS",
"PRESCRIPTION_PERMISSIONS",
"RemoteDoctorRepository",
"TokenStore",
"build_repository",
]
@@ -0,0 +1,348 @@
"""Synchronous HTTP client for the legacy admin API envelope."""
from __future__ import annotations
import time
from collections.abc import Callable, Mapping
from threading import RLock
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import httpx
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
InstallationRequiredError,
OpenPageRequiredError,
WorkWechatBindingRequiredError,
)
class ApiClient:
"""A small, testable client implementing the admin API contract.
The supplied base URL may be either the site origin or an URL already
ending in ``/adminapi``. Timeout retries are deliberately limited to GET
requests so that medical write operations are never submitted twice.
"""
API_VERSION = "1.9.4"
def __init__(
self,
base_url: str,
*,
token: str = "",
timeout: float | httpx.Timeout = 30.0,
max_retries: int = 2,
retry_backoff: float = 0.0,
verify: bool = True,
transport: httpx.BaseTransport | None = None,
client: httpx.Client | None = None,
sleep: Callable[[float], None] = time.sleep,
) -> None:
"""Create a client without performing any network requests."""
if max_retries < 0:
raise ValueError("max_retries must be non-negative")
if retry_backoff < 0:
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")
self.base_url = self.normalise_base_url(base_url)
self.timeout = timeout
self.max_retries = max_retries
self.retry_backoff = retry_backoff
self._sleep = sleep
self._token = token.strip()
self._lock = RLock()
self._owns_client = client is None
self._client = client or httpx.Client(transport=transport, verify=verify)
@staticmethod
def normalise_base_url(base_url: str) -> str:
"""Return an absolute URL ending in exactly one ``/adminapi/``."""
candidate = base_url.strip()
parsed = urlsplit(candidate)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("base_url must be an absolute http(s) URL")
if parsed.query or parsed.fragment:
raise ValueError("base_url must not include query parameters or fragments")
path = parsed.path.rstrip("/")
if path.lower().endswith("/adminapi"):
normalised_path = f"{path}/"
else:
normalised_path = f"{path}/adminapi/" if path else "/adminapi/"
return urlunsplit((parsed.scheme, parsed.netloc, normalised_path, "", ""))
@property
def token(self) -> str:
"""Return the current in-memory access token."""
with self._lock:
return self._token
@token.setter
def token(self, value: str) -> None:
"""Replace the in-memory access token used by later requests."""
with self._lock:
self._token = value.strip()
def set_token(self, token: str) -> None:
"""Set the authentication token; provided for explicit session code."""
self.token = token
def clear_token(self) -> None:
"""Remove the authentication token from memory."""
self.token = ""
def get(
self,
endpoint: str,
params: Mapping[str, Any] | None = None,
*,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Issue a GET request and return the unwrapped envelope data."""
return self.request("GET", endpoint, params=params, headers=headers)
def post(
self,
endpoint: str,
payload: Mapping[str, Any] | None = None,
*,
json: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Issue a non-retried JSON POST and return the unwrapped data."""
if payload is not None and json is not None:
raise ValueError("pass either payload or json, not both")
body = json if json is not None else payload
return self.request("POST", endpoint, json=body or {}, headers=headers)
def post_multipart(
self,
endpoint: str,
*,
files: Mapping[str, Any],
data: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Issue a non-retried multipart POST and return unwrapped data.
``httpx`` owns the multipart boundary. In particular this method does
not inherit the JSON ``Content-Type`` used by ordinary API writes.
File objects remain owned by the caller and are consumed synchronously.
"""
if not files:
raise ValueError("multipart files must not be empty")
return self.request(
"POST",
endpoint,
data=data or {},
files=files,
headers=headers,
)
def request(
self,
method: str,
endpoint: str,
*,
params: Mapping[str, Any] | None = None,
json: Mapping[str, Any] | None = None,
data: Mapping[str, Any] | None = None,
files: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Issue one API request with structured transport/envelope errors."""
verb = method.upper().strip()
if verb not in {"GET", "POST"}:
raise ValueError("ApiClient only supports GET and POST")
if verb == "GET" and (json is not None or data is not None or files is not None):
raise ValueError("GET requests cannot include a body")
if json is not None and (data is not None or files is not None):
raise ValueError("JSON and multipart/form data are mutually exclusive")
if files is not None and verb != "POST":
raise ValueError("multipart uploads require POST")
url = self._endpoint_url(endpoint)
request_headers = self._headers(
headers,
json_content_type=files is None and data is None,
)
attempts = self.max_retries + 1 if verb == "GET" else 1
response: httpx.Response | None = None
for attempt in range(attempts):
try:
response = self._client.request(
verb,
url,
params=dict(params) if params is not None else None,
json=dict(json) if verb == "POST" and json is not None else None,
data=dict(data) if verb == "POST" and data is not None else None,
files=dict(files) if files is not None else None,
headers=request_headers,
timeout=self.timeout,
)
break
except httpx.TimeoutException as exc:
if attempt + 1 < attempts:
delay = self.retry_backoff * (2**attempt)
if delay:
self._sleep(delay)
continue
raise ApiTimeoutError(
f"{verb} {endpoint} timed out after {attempt + 1} attempt(s)",
data={"method": verb, "endpoint": endpoint, "attempts": attempt + 1},
) from exc
except httpx.RequestError as exc:
raise ApiTransportError(
f"{verb} {endpoint} failed: {exc}",
data={"method": verb, "endpoint": endpoint},
) from exc
if response is None: # Defensive; the loop always returns or raises.
raise ApiTransportError(f"{verb} {endpoint} produced no response")
return self._unwrap(response)
def close(self) -> None:
"""Close the internally-created HTTP transport."""
if self._owns_client:
self._client.close()
def __enter__(self) -> ApiClient:
"""Return this client for use as a context manager."""
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
"""Close owned resources when leaving a context manager."""
self.close()
def _endpoint_url(self, endpoint: str) -> str:
value = endpoint.strip()
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc:
raise ValueError("endpoint must be a relative API path")
path = parsed.path.lstrip("/")
if path.lower().startswith("adminapi/"):
path = path[len("adminapi/") :]
if not path:
raise ValueError("endpoint must not be empty")
suffix = f"?{parsed.query}" if parsed.query else ""
return f"{self.base_url}{path}{suffix}"
def _headers(
self,
extra: Mapping[str, str] | None,
*,
json_content_type: bool = True,
) -> dict[str, str]:
result = {
"Accept": "application/json",
"version": self.API_VERSION,
}
if json_content_type:
result["Content-Type"] = "application/json;charset=UTF-8"
token = self.token
if token:
result["token"] = token
if extra:
result.update(extra)
if not json_content_type:
for key in tuple(result):
if key.lower() == "content-type":
result.pop(key)
return result
@staticmethod
def _request_id(response: httpx.Response) -> str | None:
return response.headers.get("x-request-id") or response.headers.get("request-id")
def _unwrap(self, response: httpx.Response) -> Any:
request_id = self._request_id(response)
if not 200 <= response.status_code < 300:
raise ApiHttpError(
f"API returned HTTP {response.status_code}",
status_code=response.status_code,
request_id=request_id,
)
try:
envelope = response.json()
except (ValueError, UnicodeDecodeError) as exc:
raise ApiProtocolError(
"API response is not valid JSON",
status_code=response.status_code,
request_id=request_id,
) from exc
if not isinstance(envelope, Mapping):
raise ApiProtocolError(
"API response envelope must be an object",
data=envelope,
status_code=response.status_code,
request_id=request_id,
)
raw_code = envelope.get("code")
try:
if isinstance(raw_code, bool):
raise ValueError
code = int(raw_code)
except (TypeError, ValueError) as exc:
raise ApiProtocolError(
"API response envelope has no valid code",
data=dict(envelope),
status_code=response.status_code,
request_id=request_id,
) from exc
data = envelope.get("data")
message = str(envelope.get("msg") or envelope.get("message") or "").strip()
if code == 1:
return data
context = {
"code": code,
"data": data,
"status_code": response.status_code,
"request_id": request_id,
}
if code == 0:
if not message and isinstance(data, str):
message = data
raise ApiBusinessError(message or "API rejected the operation", **context)
if code == -1:
raise AuthenticationExpiredError(message or "Login has expired", **context)
if code == 10:
raise WorkWechatBindingRequiredError(
message or "Enterprise WeChat binding is required", **context
)
if code == 2:
target = str(_mapping(data).get("url") or "")
raise OpenPageRequiredError(
message or "The operation must continue in another page",
url=target,
data=data,
status_code=response.status_code,
request_id=request_id,
)
if code == -2:
raise InstallationRequiredError(
message or "A required companion component is not installed", **context
)
raise ApiProtocolError(message or f"Unsupported API envelope code: {code}", **context)
def _mapping(value: object) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
@@ -0,0 +1,5 @@
"""Compatibility module exporting the in-memory demo repository."""
from .mock_repository import DEMO_PERMISSIONS, DemoDoctorRepository
__all__ = ["DEMO_PERMISSIONS", "DemoDoctorRepository"]
@@ -0,0 +1,46 @@
"""Small construction helper used by the application bootstrap layer."""
from __future__ import annotations
import httpx
from .api_client import ApiClient
from .mock_repository import DemoDoctorRepository
from .repository import DoctorRepository, RemoteDoctorRepository
from .token_store import TokenStore
def build_repository(
*,
demo: bool,
base_url: str | None = None,
token_store: TokenStore | None = None,
client: ApiClient | None = None,
token: str = "",
timeout: float | httpx.Timeout = 30.0,
max_retries: int = 2,
verify: bool = True,
) -> DoctorRepository:
"""Build a demo or remote repository without depending on ``AppConfig``.
``client`` is primarily useful for tests or advanced bootstrap code. For
normal remote use, provide ``base_url`` and this factory creates the
correctly configured :class:`ApiClient`.
"""
if demo:
if client is not None:
raise ValueError("client cannot be supplied in demo mode")
return DemoDoctorRepository()
api_client = client
if api_client is None:
if base_url is None or not base_url.strip():
raise ValueError("base_url is required in remote mode")
api_client = ApiClient(
base_url,
token=token,
timeout=timeout,
max_retries=max_retries,
verify=verify,
)
return RemoteDoctorRepository(api_client, token_store)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
"""Compatibility module exporting the production doctor repository."""
from .repository import (
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
AuditAction,
DoctorRepository,
RemoteDoctorRepository,
)
__all__ = [
"AuditAction",
"DoctorRepository",
"PRESCRIPTION_LIBRARY_PERMISSIONS",
"PRESCRIPTION_PERMISSIONS",
"RemoteDoctorRepository",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
"""Token persistence using OS keyring with a restricted-file fallback."""
from __future__ import annotations
import json
import os
import stat
import tempfile
from contextlib import suppress
from pathlib import Path
from typing import Any, Protocol
class KeyringLike(Protocol):
"""The small subset shared by the optional keyring module and its backends."""
def get_password(self, service: str, username: str) -> str | None:
"""Return a stored secret or ``None``."""
def set_password(self, service: str, username: str, password: str) -> None:
"""Persist one secret."""
def delete_password(self, service: str, username: str) -> None:
"""Delete one secret."""
_AUTO_KEYRING = object()
class TokenStore:
"""Store access tokens, but never user passwords.
If a working ``keyring`` backend is importable, the token is stored there
and the JSON file contains at most the remembered account name and the
non-secret API scope. Otherwise the JSON file is atomically written with
owner-only ``0600`` permissions.
"""
def __init__(
self,
path: str | os.PathLike[str] | None = None,
*,
service_name: str = "zyt-doctor-workstation",
token_name: str = "access-token",
keyring_backend: KeyringLike | None | object = _AUTO_KEYRING,
) -> None:
"""Create a token store without writing any files."""
self.path = Path(path) if path is not None else self.default_path()
self.service_name = service_name
self.token_name = token_name
self._keyring = self._load_keyring(keyring_backend)
self._uses_keyring = self._probe_keyring()
@staticmethod
def default_path() -> Path:
"""Return a per-user fallback credential path for the current platform."""
if os.name == "nt":
root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
else:
root = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
return root / "ZYTDoctorWorkstation" / "credentials.json"
@property
def uses_keyring(self) -> bool:
"""Return whether token operations currently use a working keyring."""
return self._uses_keyring
def load_token(self, *, scope: str | None = None) -> str | None:
"""Load a token, optionally requiring an exact persisted API scope.
Supplying ``scope`` is the safe choice for automatic login restoration:
an older unscoped token, or a token issued by another API base URL, is
never returned to the caller.
"""
data = self._read_file()
if scope is not None:
expected_scope = self._normalise_scope(scope)
stored_scope = self._normalise_scope(data.get("scope"))
if not expected_scope or stored_scope != expected_scope:
return None
if self._uses_keyring and self._keyring is not None:
try:
token = self._keyring.get_password(self.service_name, self.token_name)
if token:
return token
except Exception:
self._uses_keyring = False
value = data.get("token")
return str(value) if isinstance(value, str) and value else None
def save_token(
self,
token: str,
*,
account: str | None = None,
scope: str | None = None,
) -> None:
"""Persist a token plus optional account and API-scope metadata.
Passing an empty ``account`` explicitly forgets a previously remembered
account. Passwords are never accepted or persisted.
"""
cleaned = token.strip()
if not cleaned:
self.clear_token()
if account is not None:
self.save_account(account)
return
data = self._read_file()
if account is not None:
account_value = account.strip()
if account_value:
data["account"] = account_value
else:
data.pop("account", None)
if scope is not None:
scope_value = self._normalise_scope(scope)
if scope_value:
data["scope"] = scope_value
else:
data.pop("scope", None)
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.set_password(self.service_name, self.token_name, cleaned)
data.pop("token", None)
self._write_file(data)
return
except Exception:
self._uses_keyring = False
data["token"] = cleaned
self._write_file(data)
def clear_token(self) -> None:
"""Delete the token while retaining an explicitly remembered account."""
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.delete_password(self.service_name, self.token_name)
except Exception:
self._uses_keyring = False
data = self._read_file()
data.pop("token", None)
data.pop("scope", None)
self._write_file(data)
def load_account(self) -> str | None:
"""Load the remembered login account; no password is ever stored."""
value = self._read_file().get("account")
return str(value) if isinstance(value, str) and value else None
def save_account(self, account: str) -> None:
"""Remember only the account name used to pre-fill the login form."""
data = self._read_file()
cleaned = account.strip()
if cleaned:
data["account"] = cleaned
else:
data.pop("account", None)
self._write_file(data)
def clear_account(self) -> None:
"""Forget the remembered account without changing the stored token."""
self.save_account("")
def clear(self) -> None:
"""Delete both token and remembered account information."""
if self._uses_keyring and self._keyring is not None:
try:
self._keyring.delete_password(self.service_name, self.token_name)
except Exception:
self._uses_keyring = False
try:
self.path.unlink(missing_ok=True)
except OSError:
self._write_file({})
def get_token(self) -> str | None:
"""Compatibility alias for :meth:`load_token`."""
return self.load_token()
def set_token(self, token: str) -> None:
"""Compatibility alias for :meth:`save_token`."""
self.save_token(token)
def delete_token(self) -> None:
"""Compatibility alias for :meth:`clear_token`."""
self.clear_token()
@staticmethod
def _load_keyring(candidate: KeyringLike | None | object) -> KeyringLike | None:
if candidate is not _AUTO_KEYRING:
return candidate if candidate is not None else None # type: ignore[return-value]
try:
import keyring # type: ignore[import-not-found]
return keyring
except (ImportError, RuntimeError):
return None
def _probe_keyring(self) -> bool:
if self._keyring is None:
return False
try:
self._keyring.get_password(self.service_name, self.token_name)
return True
except Exception:
return False
@staticmethod
def _normalise_scope(value: object) -> str:
"""Return a stable comparison form for a non-secret API base URL."""
return str(value or "").strip().rstrip("/")
def _read_file(self) -> dict[str, Any]:
try:
content = self.path.read_text(encoding="utf-8")
value = json.loads(content)
except (FileNotFoundError, OSError, UnicodeError, json.JSONDecodeError):
return {}
if not isinstance(value, dict):
return {}
# Explicit allow-list guarantees accidental password-like fields are ignored.
return {key: value[key] for key in ("token", "account", "scope") if key in value}
def _write_file(self, data: dict[str, Any]) -> None:
safe = {key: data[key] for key in ("token", "account", "scope") if data.get(key)}
if not safe:
with suppress(OSError):
self.path.unlink(missing_ok=True)
return
self.path.parent.mkdir(parents=True, exist_ok=True)
temp_fd, temp_name = tempfile.mkstemp(prefix=f".{self.path.name}.", dir=self.path.parent)
temp_path = Path(temp_name)
try:
os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
with os.fdopen(temp_fd, "w", encoding="utf-8", newline="\n") as handle:
temp_fd = -1
json.dump(safe, handle, ensure_ascii=False, separators=(",", ":"))
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, self.path)
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
finally:
if temp_fd >= 0:
os.close(temp_fd)
with suppress(OSError):
temp_path.unlink(missing_ok=True)
@@ -0,0 +1,7 @@
"""Qt Widgets user interface for the doctor workstation."""
from .login import LoginWindow
from .shell import ShellWindow
from .theme import apply_theme
__all__ = ["LoginWindow", "ShellWindow", "apply_theme"]
@@ -0,0 +1,5 @@
"""Reusable doctor-workstation dialogs."""
from .diagnosis import DiagnosisDialog
__all__ = ["DiagnosisDialog"]
@@ -0,0 +1,920 @@
"""Patient diagnosis detail/editor used by the scoped patient workspace."""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from typing import Any
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPlainTextEdit,
QScrollArea,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from ..widgets import (
MessageBanner,
display_text,
first_value,
friendly_error,
gender_text,
get_value,
has_permission,
invoke,
page_items,
page_total,
run_async,
section_title,
)
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
_PATIENT_ORDERS_PERMISSION = "tcm.diagnosis/patientOrders"
_PHONE_PATTERN = re.compile(r"^1[3-9]\d{9}$")
_ID_CARD_PATTERN = re.compile(
r"^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$"
)
_DIAGNOSIS_FIELDS = (
("患者姓名", "patient_name", "患者姓名"),
("手机号", "phone", "11 位手机号"),
("身份证号", "id_card", "18 位身份证号(可选)"),
("性别", "gender", "1=男,0=女"),
("年龄", "age", "患者年龄"),
("诊断日期", "diagnosis_date", "YYYY-MM-DD"),
("诊断类型", "diagnosis_type", "初诊/复诊类型"),
("证型", "syndrome_type", "中医证型"),
("糖尿病类型", "diabetes_type", "糖尿病分型"),
("糖尿病发现年", "diabetes_discovery_year", "发现年份或病程描述"),
("当地医院诊断", "local_hospital_diagnosis", "多项以顿号分隔"),
("当地医院", "local_hospital_name", "当地就诊医院"),
("婚姻状态", "marital_status", "婚姻状态"),
("身高", "height", "cm"),
("体重", "weight", "kg"),
("地区", "region", "所在地区"),
("收缩压", "systolic_pressure", "mmHg"),
("舒张压", "diastolic_pressure", "mmHg"),
("空腹血糖", "fasting_blood_sugar", "mmol/L"),
("主诉", "chief_complaint", "患者此次就诊的主要诉求"),
("现病史", "present_illness", "主要症状、持续时间与变化"),
("症状", "symptoms", "当前主要症状"),
("既往史", "past_history", "既往史;多项可用顿号分隔"),
("外伤史", "trauma_history", "0/1 或具体说明"),
("手术史", "surgery_history", "0/1 或具体说明"),
("过敏史", "allergy_history", "0/1 或具体说明"),
("家族史", "family_history", "0/1 或具体说明"),
("妊娠史", "pregnancy_history", "0/1 或具体说明"),
("食欲", "appetite", "多项以顿号分隔"),
("饮水", "water_intake", "饮水情况"),
("饮食", "diet_condition", "多项以顿号分隔"),
("体重变化", "weight_change", "体重变化"),
("身体感觉", "body_feeling", "多项以顿号分隔"),
("睡眠", "sleep_condition", "多项以顿号分隔"),
("眼部", "eye_condition", "多项以顿号分隔"),
("头部感觉", "head_feeling", "多项以顿号分隔"),
("出汗", "sweat_condition", "多项以顿号分隔"),
("皮肤", "skin_condition", "多项以顿号分隔"),
("小便", "urine_condition", "多项以顿号分隔"),
("大便", "stool_condition", "多项以顿号分隔"),
("肾脏情况", "kidney_condition", "多项以顿号分隔"),
("脂肪肝程度", "fatty_liver_degree", "脂肪肝程度"),
("舌象", "tongue", "舌质、舌苔等观察"),
("舌苔", "tongue_coating", "舌苔记录"),
("脉象", "pulse", "脉象记录"),
("临床诊断", "clinical_diagnosis", "填写临床诊断"),
("治则", "treatment_principle", "治疗原则"),
("处方", "prescription", "处方摘要"),
("处方意见", "prescription_opinion", "辨证与处方意见"),
("医嘱", "doctor_advice", "医生嘱托"),
("当前用药", "current_medications", "患者当前用药"),
("补充备注", "remark", "其他需要记录的信息"),
)
_LIST_FIELDS = {
"local_hospital_diagnosis",
"appetite",
"diet_condition",
"body_feeling",
"sleep_condition",
"eye_condition",
"head_feeling",
"sweat_condition",
"skin_condition",
"urine_condition",
"stool_condition",
"kidney_condition",
}
_INTEGER_FIELDS = {
"gender",
"age",
"marital_status",
"systolic_pressure",
"diastolic_pressure",
"trauma_history",
"surgery_history",
"allergy_history",
"family_history",
"pregnancy_history",
}
_FLOAT_FIELDS = {"height", "weight", "fasting_blood_sugar"}
_PATIENT_BASIC_FIELDS = {"patient_name", "phone", "id_card", "gender", "age"}
def _mask_phone(value: Any) -> str:
text = str(value or "").strip()
return f"{text[:3]}****{text[-4:]}" if len(text) == 11 else ("***" if text else "")
def _mask_id_card(value: Any) -> str:
text = str(value or "").strip()
if len(text) == 18:
return f"{text[:6]}********{text[-4:]}"
if len(text) == 15:
return f"{text[:6]}*****{text[-4:]}"
return "***" if text else ""
def _truthy(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(value)
def _editor_text(value: Any) -> str:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return "".join(str(item) for item in value)
return "" if value is None else str(value)
def _duplicate_found(value: Any) -> bool:
for key in ("exists", "duplicate", "data.exists", "data.duplicate"):
candidate = get_value(value, key, None)
if candidate is not None:
if isinstance(candidate, str):
return candidate.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(candidate)
return False
def _field_value(key: str, text: str, original: Any) -> Any:
value = text.strip()
if isinstance(original, Sequence) and not isinstance(original, (str, bytes, bytearray)):
return [item.strip() for item in re.split(r"[,,、]", value) if item.strip()]
if isinstance(original, bool):
return value.lower() not in {"", "0", "false", "no", "off"}
if isinstance(original, int) or (original in (None, "") and key in _INTEGER_FIELDS):
return _int(value, 0) if value else ""
if isinstance(original, float) or (original in (None, "") and key in _FLOAT_FIELDS):
try:
return float(value) if value else ""
except ValueError:
return value
if key in _LIST_FIELDS:
return [item.strip() for item in re.split(r"[,,、]", value) if item.strip()]
return value
def _invoke_first(repository: Any, names: Sequence[str], **kwargs: Any) -> Any:
"""Call the first repository method present while keeping keyword tolerance."""
for name in names:
if callable(getattr(repository, name, None)):
return invoke(repository, name, **kwargs)
return invoke(repository, names[0], **kwargs)
def _mapping(value: Any) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
class DiagnosisDialog(QDialog):
"""Load a diagnosis and its server-backed histories without blocking Qt."""
saved = Signal()
def __init__(
self,
repository: Any,
parent: QWidget | None = None,
*,
permissions: Any = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.permissions = (
permissions
if permissions is not None
else getattr(parent, "permissions", None)
if parent is not None
else None
)
self._can_phone_plain = has_permission(self.permissions, _PHONE_PERMISSION, default=False)
self._can_patient_orders = has_permission(
self.permissions, _PATIENT_ORDERS_PERMISSION, default=False
)
self._diagnosis_id = 0
self._patient_id = 0
self._editable = False
self._generation = 0
self._save_generation = 0
self._orders_generation = 0
self._orders_page = 1
self._orders_page_size = 10
self._orders_total = 0
self._detail: Any = None
self._field_originals: dict[str, Any] = {}
self.setModal(True)
self.setWindowTitle("患者信息详情")
self.setMinimumSize(680, 520)
self.resize(880, 680)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(12)
heading = QHBoxLayout()
identity = QVBoxLayout()
identity.setSpacing(2)
self.title_label = QLabel("患者信息详情")
self.title_label.setProperty("role", "pageTitle")
identity.addWidget(self.title_label)
self.meta_label = QLabel("正在准备诊单…")
self.meta_label.setProperty("role", "muted")
identity.addWidget(self.meta_label)
heading.addLayout(identity, 1)
self.mode_label = QLabel("只读")
self.mode_label.setObjectName("StatusBadge")
self.mode_label.setProperty("kind", "neutral")
heading.addWidget(self.mode_label, 0, Qt.AlignmentFlag.AlignTop)
root.addLayout(heading)
self.banner = MessageBanner()
root.addWidget(self.banner)
self.tabs = QTabWidget()
self.tabs.setObjectName("DiagnosisTabs")
self.tabs.addTab(self._build_overview_tab(), "患者与病历")
self.tabs.addTab(self._build_notes_tab(), "医生备注")
self.tabs.addTab(self._build_history_tab("appointment"), "挂号记录")
self.tabs.addTab(self._build_history_tab("assign"), "指派记录")
if self._can_patient_orders:
self.tabs.addTab(self._build_orders_tab(), "患者订单")
root.addWidget(self.tabs, 1)
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
self.save_button = self.buttons.addButton(
"保存病历", QDialogButtonBox.ButtonRole.AcceptRole
)
self.save_button.setProperty("variant", "primary")
self.save_button.clicked.connect(self._save)
self.buttons.rejected.connect(self.reject)
root.addWidget(self.buttons)
def _build_overview_tab(self) -> QWidget:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
layout = QVBoxLayout(content)
layout.setContentsMargins(4, 12, 4, 16)
layout.setSpacing(12)
summary = QFrame()
summary.setObjectName("SubtleCard")
summary_layout = QGridLayout(summary)
summary_layout.setContentsMargins(14, 12, 14, 12)
summary_layout.setHorizontalSpacing(18)
summary_layout.setVerticalSpacing(10)
self.summary_fields: dict[str, QLabel] = {}
fields = (
("患者", "patient"),
("手机", "phone"),
("身份证", "id_card"),
("性别 / 年龄", "gender_age"),
("身高 / 体重", "body"),
("预约", "appointment"),
("医生 / 医助", "staff"),
("血压", "pressure"),
("空腹血糖", "blood_sugar"),
)
for index, (caption, key) in enumerate(fields):
row, column = divmod(index, 2)
box = QVBoxLayout()
label = QLabel(caption)
label.setProperty("role", "muted")
value = QLabel("")
value.setWordWrap(True)
value.setStyleSheet("font-weight:600;")
box.addWidget(label)
box.addWidget(value)
summary_layout.addLayout(box, row, column)
self.summary_fields[key] = value
layout.addWidget(summary)
layout.addWidget(section_title("病历内容"))
form_host = QFrame()
form_host.setObjectName("SubtleCard")
form = QFormLayout(form_host)
form.setContentsMargins(14, 12, 14, 12)
form.setHorizontalSpacing(16)
form.setVerticalSpacing(10)
self.edit_fields: dict[str, QPlainTextEdit] = {}
for caption, key, placeholder in _DIAGNOSIS_FIELDS:
edit = QPlainTextEdit()
edit.setPlaceholderText(placeholder)
edit.setMinimumHeight(46)
edit.setMaximumHeight(82)
form.addRow(caption, edit)
self.edit_fields[key] = edit
layout.addWidget(form_host)
layout.addStretch(1)
scroll.setWidget(content)
return scroll
def _build_notes_tab(self) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(4, 12, 4, 12)
self.notes_table = QTableWidget(0, 3)
self.notes_table.setHorizontalHeaderLabels(["时间", "医生", "内容"])
self.notes_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.notes_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.notes_table.verticalHeader().setVisible(False)
self.notes_table.horizontalHeader().setStretchLastSection(True)
self.notes_table.setColumnWidth(0, 150)
self.notes_table.setColumnWidth(1, 110)
layout.addWidget(self.notes_table)
return widget
def _build_history_tab(self, kind: str) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(4, 12, 4, 12)
if kind == "appointment":
table = QTableWidget(0, 7)
table.setHorizontalHeaderLabels(
["状态", "患者", "医生", "医助", "预约日期", "时段", "备注"]
)
widths = (88, 110, 100, 100, 105, 80)
self.appointment_table = table
else:
table = QTableWidget(0, 6)
table.setHorizontalHeaderLabels(
["操作时间", "原医助", "新医助", "继承", "操作人", "账号"]
)
widths = (145, 100, 100, 70, 100)
self.assign_table = table
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
table.verticalHeader().setVisible(False)
table.horizontalHeader().setStretchLastSection(True)
for index, width in enumerate(widths):
table.setColumnWidth(index, width)
layout.addWidget(table)
return widget
def _build_orders_tab(self) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(4, 12, 4, 12)
self.orders_table = QTableWidget(0, 8)
self.orders_table.setHorizontalHeaderLabels(
["订单号", "处方", "患者/收货人", "手机", "金额", "发货", "状态", "创建时间"]
)
self.orders_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.orders_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.orders_table.verticalHeader().setVisible(False)
self.orders_table.horizontalHeader().setStretchLastSection(True)
for index, width in enumerate((130, 70, 120, 110, 90, 90, 100)):
self.orders_table.setColumnWidth(index, width)
layout.addWidget(self.orders_table, 1)
footer = QHBoxLayout()
self.orders_summary = QLabel("共 0 条")
self.orders_summary.setProperty("role", "muted")
footer.addWidget(self.orders_summary)
footer.addStretch(1)
self.orders_previous = QLabel('<a href="prev">上一页</a>')
self.orders_previous.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.orders_previous.linkActivated.connect(lambda _link: self._change_orders_page(-1))
footer.addWidget(self.orders_previous)
self.orders_page_label = QLabel("1 / 1")
footer.addWidget(self.orders_page_label)
self.orders_next = QLabel('<a href="next">下一页</a>')
self.orders_next.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.orders_next.linkActivated.connect(lambda _link: self._change_orders_page(1))
footer.addWidget(self.orders_next)
layout.addLayout(footer)
return widget
def open_for(self, diagnosis_id: int, *, editable: bool = False, seed: Any = None) -> None:
"""Open immediately, then replace the seed with authoritative server data."""
self._diagnosis_id = int(diagnosis_id)
self._patient_id = 0
self._editable = bool(
editable and has_permission(self.permissions, "tcm.diagnosis/edit", default=True)
)
self._generation += 1
self._save_generation += 1
self._orders_generation += 1
self._orders_page = 1
self._orders_total = 0
generation = self._generation
self._detail = seed
self.setWindowTitle("编辑患者病历" if self._editable else "患者信息详情")
self.title_label.setText("编辑患者病历" if self._editable else "患者信息详情")
self.mode_label.setText("可编辑" if self._editable else "只读")
self.mode_label.setProperty("kind", "success" if self._editable else "neutral")
self.mode_label.style().unpolish(self.mode_label)
self.mode_label.style().polish(self.mode_label)
self.save_button.setVisible(self._editable)
for key, field in self.edit_fields.items():
field.setReadOnly(
not self._editable or (key in {"phone", "id_card"} and not self._can_phone_plain)
)
self._clear_tables()
if seed is not None:
self._render(seed, [], [])
self.banner.show_message("正在加载完整诊单与历史记录…", "info")
self.open()
diagnosis_id_snapshot = self._diagnosis_id
editable_snapshot = self._editable
run_async(
lambda: self._load_bundle(diagnosis_id_snapshot, editable_snapshot),
on_success=lambda result: self._apply_bundle(result, generation),
on_error=lambda error: self._load_error(error, generation),
on_finished=lambda: None,
)
def _load_bundle(self, diagnosis_id: int, editable: bool) -> dict[str, Any]:
detail_names = (
("get_diagnosis_detail", "patient_detail", "diagnosis_readonly_detail")
if editable
else ("patient_detail", "diagnosis_readonly_detail", "get_diagnosis_detail")
)
detail = _invoke_first(
self.repository,
detail_names,
diagnosis_id=diagnosis_id,
id=diagnosis_id,
readonly=not editable,
)
appointments: list[Any] = []
assignments: list[Any] = []
history_errors: list[str] = []
try:
appointments = page_items(
_invoke_first(
self.repository,
("appointment_history",),
diagnosis_id=diagnosis_id,
page_no=1,
page_size=500,
)
)
except Exception as error: # history failure must not hide the diagnosis
history_errors.append(f"挂号记录:{friendly_error(error)}")
try:
assignments = page_items(
_invoke_first(
self.repository,
("assign_history",),
diagnosis_id=diagnosis_id,
page_no=1,
page_size=50,
)
)
except Exception as error: # history failure must not hide the diagnosis
history_errors.append(f"指派记录:{friendly_error(error)}")
orders: list[Any] = []
orders_total = 0
diagnosis = get_value(detail, "diagnosis", None) or detail or {}
patient = get_value(detail, "patient", None) or {}
patient_id = _int(
first_value(
diagnosis,
"patient_id",
"source_patient_id",
default=first_value(patient, "patient_id", "id", default=0),
),
0,
)
if self._can_patient_orders:
try:
order_result = self._query_orders(diagnosis_id, patient_id, 1)
orders = page_items(order_result)
orders_total = page_total(order_result, len(orders))
except Exception as error: # order failure must not hide the diagnosis
history_errors.append(f"患者订单:{friendly_error(error)}")
return {
"detail": detail,
"appointments": appointments,
"assignments": assignments,
"patient_id": patient_id,
"orders": orders,
"orders_total": orders_total,
"history_errors": history_errors,
}
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
filters: dict[str, Any] = {
"context_diagnosis_id": diagnosis_id,
"scene": "diagnosis_edit",
}
if patient_id > 0:
filters["patient_id"] = patient_id
return _invoke_first(
self.repository,
("list_prescription_orders",),
page_no=page,
page_size=self._orders_page_size,
**filters,
)
def _apply_bundle(self, result: Any, generation: int) -> None:
if generation != self._generation:
return
detail = get_value(result, "detail", None)
appointments = get_value(result, "appointments", []) or []
assignments = get_value(result, "assignments", []) or []
self._detail = detail
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
self._render(detail, appointments, assignments)
if self._can_patient_orders:
self._orders_total = int(get_value(result, "orders_total", 0) or 0)
self._fill_orders(get_value(result, "orders", []) or [])
self._update_orders_pager()
errors = get_value(result, "history_errors", []) or []
if errors:
self.banner.show_message("".join(str(item) for item in errors), "warning")
else:
self.banner.clear()
def _load_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
def _render(self, detail: Any, appointments: Sequence[Any], assignments: Sequence[Any]) -> None:
diagnosis = get_value(detail, "diagnosis", None) or detail or {}
patient = get_value(detail, "patient", None) or {}
appointment = get_value(detail, "appointment", None) or {}
patient_name = first_value(
diagnosis,
"patient_name",
"name",
default=first_value(patient, "patient_name", "name", default="未命名患者"),
)
gender = gender_text(
first_value(
diagnosis,
"gender_desc",
"gender",
default=first_value(patient, "gender_desc", "gender"),
)
)
age = display_text(first_value(diagnosis, "age", default=first_value(patient, "age")))
self.meta_label.setText(f"诊单 #{self._diagnosis_id} · {patient_name} · {gender} · {age}")
self.summary_fields["patient"].setText(display_text(patient_name))
self.summary_fields["phone"].setText(
display_text(
first_value(
diagnosis,
"phone",
"patient_phone",
default=first_value(
patient,
"phone",
"patient_phone",
default=first_value(appointment, "patient_phone"),
),
)
if self._can_phone_plain
else _mask_phone(
first_value(
diagnosis,
"phone",
"patient_phone",
default=first_value(
patient,
"phone",
"patient_phone",
default=first_value(appointment, "patient_phone"),
),
)
)
)
)
id_card = first_value(
diagnosis,
"id_card",
default=first_value(patient, "id_card", default=""),
)
self.summary_fields["id_card"].setText(
display_text(id_card if self._can_phone_plain else _mask_id_card(id_card))
)
self.summary_fields["gender_age"].setText(f"{gender} / {age}")
self.summary_fields["body"].setText(
f"{display_text(first_value(diagnosis, 'height', default=first_value(patient, 'height')))} cm / "
f"{display_text(first_value(diagnosis, 'weight', default=first_value(patient, 'weight')))} kg"
)
appointment_text = " ".join(
part
for part in (
display_text(first_value(appointment, "appointment_date"), ""),
display_text(
first_value(appointment, "appointment_time", "appointment_time_text"), ""
),
)
if part
)
self.summary_fields["appointment"].setText(appointment_text or "暂无预约")
self.summary_fields["staff"].setText(
f"{display_text(first_value(appointment, 'doctor_name'))} / "
f"{display_text(first_value(appointment, 'assistant_name'))}"
)
self.summary_fields["pressure"].setText(
f"{display_text(first_value(diagnosis, 'systolic_pressure'))} / "
f"{display_text(first_value(diagnosis, 'diastolic_pressure'))} mmHg"
)
self.summary_fields["blood_sugar"].setText(
f"{display_text(first_value(diagnosis, 'fasting_blood_sugar'))} mmol/L"
)
basic_locked = _truthy(
first_value(diagnosis, "patient_basic_locked", default=False)
) or not _truthy(first_value(diagnosis, "can_edit_patient_basic", default=True))
self._field_originals.clear()
for key, field in self.edit_fields.items():
raw = first_value(
diagnosis,
key,
default=first_value(
patient,
key,
default=first_value(
diagnosis,
"patient_phone" if key == "phone" else key,
default="",
),
),
)
self._field_originals[key] = raw
if key == "phone" and not self._can_phone_plain:
rendered = _mask_phone(raw)
elif key == "id_card" and not self._can_phone_plain:
rendered = _mask_id_card(raw)
else:
rendered = _editor_text(raw)
field.setPlainText(rendered)
field.setReadOnly(
not self._editable
or (key in _PATIENT_BASIC_FIELDS and basic_locked)
or (key in {"phone", "id_card"} and not self._can_phone_plain)
)
notes = (
get_value(detail, "doctor_notes", None)
or get_value(diagnosis, "doctor_notes", None)
or []
)
if not isinstance(notes, (list, tuple)):
notes = [notes]
self._fill_notes(notes)
self._fill_appointments(appointments)
self._fill_assignments(assignments)
def _clear_tables(self) -> None:
self.notes_table.setRowCount(0)
self.appointment_table.setRowCount(0)
self.assign_table.setRowCount(0)
if self._can_patient_orders:
self.orders_table.setRowCount(0)
self.orders_summary.setText("共 0 条")
self.orders_page_label.setText("1 / 1")
@staticmethod
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None:
for column, value in enumerate(values):
item = QTableWidgetItem(display_text(value))
item.setToolTip(item.text() if len(item.text()) > 18 else "")
table.setItem(row, column, item)
def _fill_notes(self, rows: Sequence[Any]) -> None:
self.notes_table.setRowCount(len(rows))
for index, row in enumerate(rows):
self._set_row(
self.notes_table,
index,
(
first_value(row, "create_time", "created_at", "time"),
first_value(row, "doctor_name", "creator_name", default="医生"),
first_value(row, "content", "note", "remark"),
),
)
def _fill_appointments(self, rows: Sequence[Any]) -> None:
self.appointment_table.setRowCount(len(rows))
for index, row in enumerate(rows):
self._set_row(
self.appointment_table,
index,
(
first_value(row, "status_desc", "status_text", "status"),
first_value(row, "patient_name"),
first_value(row, "doctor_name"),
first_value(row, "assistant_name"),
first_value(row, "appointment_date"),
first_value(row, "appointment_time", "period"),
first_value(row, "remark"),
),
)
def _fill_assignments(self, rows: Sequence[Any]) -> None:
self.assign_table.setRowCount(len(rows))
for index, row in enumerate(rows):
self._set_row(
self.assign_table,
index,
(
first_value(row, "create_time_text", "create_time"),
first_value(row, "from_assistant_name", default=""),
first_value(row, "to_assistant_name", "assistant_name", default=""),
"" if bool(first_value(row, "is_inherit", default=False)) else "",
first_value(row, "operator_name"),
first_value(row, "operator_account"),
),
)
def _fill_orders(self, rows: Sequence[Any]) -> None:
self.orders_table.setRowCount(len(rows))
for index, row in enumerate(rows):
self._set_row(
self.orders_table,
index,
(
first_value(row, "order_no", "sn", "id"),
first_value(row, "prescription_id"),
first_value(row, "recipient_name", "patient_name"),
first_value(row, "recipient_phone", "phone"),
first_value(row, "amount"),
first_value(row, "ship_mode", "express_company"),
first_value(row, "status_text", "fulfillment_status_text", "status"),
first_value(row, "create_time", "created_at"),
),
)
def _update_orders_pager(self) -> None:
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
self.orders_summary.setText(f"{self._orders_total}")
self.orders_page_label.setText(f"{self._orders_page} / {pages}")
self.orders_previous.setEnabled(self._orders_page > 1)
self.orders_next.setEnabled(self._orders_page < pages)
def _change_orders_page(self, offset: int) -> None:
if not self._can_patient_orders or self._diagnosis_id <= 0:
return
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
target_page = self._orders_page + offset
if target_page < 1 or target_page > pages:
return
self._orders_generation += 1
generation = self._orders_generation
diagnosis_id = self._diagnosis_id
patient_id = self._patient_id
self.banner.show_message("正在加载患者订单…", "info")
run_async(
lambda: self._query_orders(diagnosis_id, patient_id, target_page),
on_success=lambda result: self._apply_orders_page(
result, diagnosis_id, target_page, generation
),
on_error=lambda error: self._orders_error(error, diagnosis_id, generation),
)
def _apply_orders_page(
self,
result: Any,
diagnosis_id: int,
page: int,
generation: int,
) -> None:
if generation != self._orders_generation or diagnosis_id != self._diagnosis_id:
return
rows = page_items(result)
self._orders_page = page
self._orders_total = page_total(result, len(rows))
self._fill_orders(rows)
self._update_orders_pager()
self.banner.clear()
def _orders_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
if generation == self._orders_generation and diagnosis_id == self._diagnosis_id:
self.banner.show_message(friendly_error(error), "danger")
def _save(self) -> None:
if not self._editable or self._diagnosis_id <= 0:
return
diagnosis_id = self._diagnosis_id
changes = {
key: _field_value(key, field.toPlainText(), self._field_originals.get(key))
for key, field in self.edit_fields.items()
if key not in {"phone", "id_card"} or self._can_phone_plain
}
patient_name = str(changes.get("patient_name") or "").strip()
if not patient_name:
self.banner.show_message("请输入患者姓名。", "warning")
self.edit_fields["patient_name"].setFocus()
return
phone = str(changes.get("phone") or "").strip()
if self._can_phone_plain and not _PHONE_PATTERN.fullmatch(phone):
self.banner.show_message("手机号格式不正确。", "warning")
self.edit_fields["phone"].setFocus()
return
id_card = str(changes.get("id_card") or "").strip()
if self._can_phone_plain and id_card and not _ID_CARD_PATTERN.fullmatch(id_card):
self.banner.show_message("身份证号格式不正确。", "warning")
self.edit_fields["id_card"].setFocus()
return
self._save_generation += 1
generation = self._save_generation
self.save_button.setEnabled(False)
self.banner.show_message("正在保存病历…", "info")
run_async(
lambda: self._validate_and_save(diagnosis_id, changes),
on_success=lambda _result: self._save_success(generation),
on_error=lambda error: self._save_error(error, generation),
on_finished=lambda: self._save_finished(generation),
)
def _validate_and_save(self, diagnosis_id: int, changes: Mapping[str, Any]) -> Any:
if self._can_phone_plain:
phone = str(changes.get("phone") or "").strip()
phone_result = _invoke_first(
self.repository,
("check_diagnosis_phone",),
payload={"phone": phone, "id": diagnosis_id},
)
if _duplicate_found(phone_result):
raise ValueError(
str(
first_value(
phone_result, "message", "data.message", default="手机号已存在。"
)
)
)
id_card = str(changes.get("id_card") or "").strip()
if id_card:
id_card_result = _invoke_first(
self.repository,
("check_diagnosis_id_card",),
payload={"id_card": id_card, "id": diagnosis_id},
)
if _duplicate_found(id_card_result):
raise ValueError(
str(
first_value(
id_card_result,
"message",
"data.message",
default="身份证号已存在。",
)
)
)
return _invoke_first(
self.repository,
("update_diagnosis",),
diagnosis=diagnosis_id,
changes=dict(changes),
)
def _save_success(self, generation: int) -> None:
if generation != self._save_generation:
return
self.banner.show_message("病历已保存。", "success")
self.saved.emit()
def _save_error(self, error: Exception, generation: int) -> None:
if generation == self._save_generation:
self.banner.show_message(friendly_error(error), "danger")
def _save_finished(self, generation: int) -> None:
if generation == self._save_generation:
self.save_button.setEnabled(True)
__all__ = ["DiagnosisDialog"]
File diff suppressed because it is too large Load Diff
+440
View File
@@ -0,0 +1,440 @@
"""Account login window for the doctor workstation."""
from __future__ import annotations
from typing import Any
from PySide6.QtCore import QSettings, Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
class LoginWindow(QMainWindow):
"""A responsive login surface with optional demo-repository switching.
``login_succeeded`` emits a dictionary containing ``user``, ``session``,
``repository`` and ``demo_mode``. Keeping the selected repository in the
payload lets the composition root construct the shell without guessing.
``remember_account`` is captured before the worker starts and is forwarded
to the repository so every account metadata store follows the same choice.
"""
login_succeeded = Signal(object)
authenticated = Signal(object)
login_failed = Signal(str)
server_settings_changed = Signal(dict)
config_changed = Signal(object)
demo_mode_changed = Signal(bool)
def __init__(
self,
repository: Any,
config: Any | None = None,
demo_repository: Any | None = None,
settings: QSettings | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.config = config
if demo_repository is None:
demo_repository = getattr(config, "demo_repository", None)
self.demo_repository = demo_repository
self.settings = settings or QSettings("ZhenYangTang", "DoctorWorkstation")
self.active_repository = repository
self.authenticated_user: Any = None
self._loading = False
self.setWindowTitle("臻阳堂 · 医生工作站")
self.setMinimumSize(860, 590)
self.resize(1120, 720)
canvas = QWidget()
canvas.setObjectName("LoginCanvas")
self.setCentralWidget(canvas)
root = QHBoxLayout(canvas)
root.setContentsMargins(26, 26, 26, 26)
root.setSpacing(26)
root.addWidget(self._build_brand_panel(), 5)
root.addWidget(self._build_login_area(), 6)
self._restore_settings()
def _build_brand_panel(self) -> QWidget:
panel = QWidget()
panel.setObjectName("LoginBrandPanel")
panel.setMinimumWidth(310)
panel.setMaximumWidth(470)
layout = QVBoxLayout(panel)
layout.setContentsMargins(38, 38, 38, 38)
layout.setSpacing(18)
brand_row = QHBoxLayout()
mark = QLabel("")
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
mark.setFixedSize(42, 42)
mark.setStyleSheet(
"color:#0F6D64; background:#DDF1EC; border-radius:12px; font-size:20px; font-weight:700;"
)
brand_row.addWidget(mark)
brand_name = QLabel("臻阳堂医疗")
brand_name.setStyleSheet("color:#FCFBF8; font-size:16px; font-weight:700;")
brand_row.addWidget(brand_name)
brand_row.addStretch(1)
layout.addLayout(brand_row)
layout.addStretch(2)
eyebrow = QLabel("DOCTOR WORKSTATION")
eyebrow.setStyleSheet("color:#82B7A9; font-size:11px; font-weight:700; letter-spacing:1px;")
layout.addWidget(eyebrow)
headline = QLabel("把诊间工作,\n留在一个安静的界面里。")
headline.setProperty("role", "display")
headline.setWordWrap(True)
layout.addWidget(headline)
description = QLabel("接诊、问诊、患者与处方信息统一呈现,帮助医生专注于每一次沟通。")
description.setWordWrap(True)
description.setStyleSheet("color:#B8CCC5; font-size:14px; line-height:1.6;")
layout.addWidget(description)
layout.addStretch(3)
privacy = QLabel("本工作站仅供获授权的医疗人员使用\n请勿在公共设备保存账号")
privacy.setWordWrap(True)
privacy.setStyleSheet("color:#82A198; font-size:11px;")
layout.addWidget(privacy)
return panel
def _build_login_area(self) -> QWidget:
area = QWidget()
outer = QVBoxLayout(area)
outer.setContentsMargins(20, 10, 20, 10)
outer.addStretch(1)
self.card = QFrame()
self.card.setObjectName("LoginCard")
self.card.setMaximumWidth(470)
card_layout = QVBoxLayout(self.card)
card_layout.setContentsMargins(40, 36, 40, 36)
card_layout.setSpacing(14)
title = QLabel("欢迎回来")
title.setProperty("role", "pageTitle")
card_layout.addWidget(title)
subtitle = QLabel("使用医生账号登录工作站")
subtitle.setProperty("role", "muted")
card_layout.addWidget(subtitle)
card_layout.addSpacing(6)
self.error_banner = MessageBanner()
card_layout.addWidget(self.error_banner)
account_label = QLabel("账号")
account_label.setStyleSheet("font-weight:600;")
card_layout.addWidget(account_label)
self.account_edit = QLineEdit()
self.account_edit.setPlaceholderText("手机号或工作账号")
self.account_edit.setClearButtonEnabled(True)
self.account_edit.setAccessibleName("登录账号")
card_layout.addWidget(self.account_edit)
password_label = QLabel("密码")
password_label.setStyleSheet("font-weight:600;")
card_layout.addWidget(password_label)
password_row = QHBoxLayout()
password_row.setSpacing(6)
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.password_edit.setPlaceholderText("请输入密码")
self.password_edit.setAccessibleName("登录密码")
self.password_edit.returnPressed.connect(self.submit)
password_row.addWidget(self.password_edit, 1)
self.reveal_button = QToolButton()
self.reveal_button.setText("显示")
self.reveal_button.setCheckable(True)
self.reveal_button.setToolTip("显示或隐藏密码")
self.reveal_button.toggled.connect(self._toggle_password)
password_row.addWidget(self.reveal_button)
card_layout.addLayout(password_row)
choices = QHBoxLayout()
self.remember_check = QCheckBox("记住账号")
self.remember_check.setToolTip("仅保存账号,不保存密码")
choices.addWidget(self.remember_check)
choices.addStretch(1)
self.demo_check = QCheckBox("演示模式")
self.demo_check.setEnabled(self.demo_repository is not None)
if self.demo_repository is None:
self.demo_check.setToolTip("当前未配置演示数据")
self.demo_check.toggled.connect(self._on_demo_toggled)
choices.addWidget(self.demo_check)
card_layout.addLayout(choices)
self.login_button = QPushButton("登录工作站")
self.login_button.setProperty("variant", "primary")
self.login_button.setMinimumHeight(42)
self.login_button.clicked.connect(self.submit)
card_layout.addWidget(self.login_button)
self.server_toggle = QPushButton("服务器设置 +")
self.server_toggle.setProperty("variant", "ghost")
self.server_toggle.setCheckable(True)
self.server_toggle.clicked.connect(self._toggle_server_panel)
card_layout.addWidget(self.server_toggle)
self.server_panel = QFrame()
self.server_panel.setObjectName("SubtleCard")
server_layout = QVBoxLayout(self.server_panel)
server_layout.setContentsMargins(14, 12, 14, 12)
server_layout.setSpacing(8)
server_layout.addWidget(QLabel("服务地址"))
self.server_url_edit = QLineEdit()
self.server_url_edit.setPlaceholderText("由管理员提供,例如 https://api.example.com")
server_layout.addWidget(self.server_url_edit)
timeout_row = QHBoxLayout()
timeout_row.addWidget(QLabel("读取超时"))
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(10, 180)
self.timeout_spin.setSuffix("")
self.timeout_spin.setValue(60)
timeout_row.addWidget(self.timeout_spin)
timeout_row.addStretch(1)
self.save_server_button = QPushButton("保存设置")
self.save_server_button.setProperty("variant", "secondary")
self.save_server_button.clicked.connect(self._save_server_settings)
timeout_row.addWidget(self.save_server_button)
server_layout.addLayout(timeout_row)
server_hint = QLabel("生产环境应使用管理员下发的 HTTPS 地址。")
server_hint.setProperty("role", "muted")
server_hint.setWordWrap(True)
server_layout.addWidget(server_hint)
self.server_panel.setVisible(False)
card_layout.addWidget(self.server_panel)
footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。")
footnote.setProperty("role", "muted")
footnote.setWordWrap(True)
card_layout.addWidget(footnote)
outer.addWidget(self.card, 0, Qt.AlignmentFlag.AlignHCenter)
outer.addStretch(1)
self.busy_overlay = BusyOverlay(self.card, "正在验证账号…")
return area
def _restore_settings(self) -> None:
configured_account = getattr(self.config, "remembered_account", "")
remembered = str(self.settings.value("auth/remembered_account", configured_account) or "")
self.account_edit.setText(remembered)
self.remember_check.setChecked(bool(remembered))
configured_url = getattr(self.config, "base_url", "") or getattr(
self.config, "api_base_url", ""
)
self.server_url_edit.setText(
str(self.settings.value("server/base_url", configured_url) or "")
)
try:
configured_timeout = getattr(self.config, "request_timeout", 60)
timeout = int(
self.settings.value("server/read_timeout", configured_timeout) or configured_timeout
)
except (TypeError, ValueError):
timeout = 60
self.timeout_spin.setValue(max(10, min(180, timeout)))
if self.demo_repository is not None and bool(getattr(self.config, "demo_mode", False)):
self.demo_check.setChecked(True)
if remembered:
self.password_edit.setFocus()
else:
self.account_edit.setFocus()
def _toggle_password(self, visible: bool) -> None:
self.password_edit.setEchoMode(
QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password
)
self.reveal_button.setText("隐藏" if visible else "显示")
def _on_demo_toggled(self, enabled: bool) -> None:
self.active_repository = self.demo_repository if enabled else self.repository
self.server_toggle.setEnabled(not enabled and not self._loading)
self.demo_mode_changed.emit(enabled)
self._emit_config_update(demo_mode=enabled)
self.error_banner.clear()
if enabled:
self.account_edit.setPlaceholderText("可留空,使用演示医生")
self.password_edit.setPlaceholderText("可留空")
else:
self.account_edit.setPlaceholderText("手机号或工作账号")
self.password_edit.setPlaceholderText("请输入密码")
def _toggle_server_panel(self, expanded: bool) -> None:
self.server_panel.setVisible(expanded)
self.server_toggle.setText("服务器设置 -" if expanded else "服务器设置 +")
def _save_server_settings(self) -> None:
base_url = self.server_url_edit.text().strip().rstrip("/")
if base_url and not base_url.startswith(
("https://", "http://localhost", "http://127.0.0.1")
):
self.error_banner.show_message(
"服务地址需使用 HTTPS;本机调试可使用 localhost。", "warning"
)
return
values = {
"base_url": base_url,
"read_timeout": self.timeout_spin.value(),
"api_base_url": base_url,
"request_timeout": self.timeout_spin.value(),
}
self.settings.setValue("server/base_url", base_url)
self.settings.setValue("server/read_timeout", self.timeout_spin.value())
self.settings.sync()
self.server_settings_changed.emit(values)
self._emit_config_update(
api_base_url=base_url,
request_timeout=self.timeout_spin.value(),
)
self.error_banner.show_message("服务器设置已保存,将在连接时生效。", "success")
def _emit_config_update(self, **changes: Any) -> None:
updater = getattr(self.config, "with_updates", None)
if callable(updater):
try:
self.config = updater(**changes)
except (TypeError, ValueError):
self.config_changed.emit(changes)
else:
self.config_changed.emit(self.config)
return
self.config_changed.emit(changes)
def submit(self) -> None:
if self._loading:
return
demo_mode = self.demo_check.isChecked()
remember_account = self.remember_check.isChecked()
account = self.account_edit.text().strip()
password = self.password_edit.text()
if demo_mode:
account = account or str(getattr(self.active_repository, "DEMO_ACCOUNT", "doctor"))
password = password or str(
getattr(self.active_repository, "DEMO_PASSWORD", "doctor123")
)
if not account:
self.error_banner.show_message("请输入登录账号。", "warning")
self.account_edit.setFocus()
return
if not password:
self.error_banner.show_message("请输入密码。", "warning")
self.password_edit.setFocus()
return
repository = self.active_repository
if repository is None:
self.error_banner.show_message("演示服务尚未配置。", "warning")
return
self.error_banner.clear()
self._set_loading(True)
def authenticate() -> dict[str, Any]:
session = invoke(
repository,
"login",
account=account,
password=password,
remember_account=remember_account,
)
user = invoke(repository, "get_current_user")
return {
"session": session,
"user": user,
"repository": repository,
"demo_mode": demo_mode,
"remember_account": remember_account,
}
run_async(
authenticate,
on_success=lambda payload: self._on_login_success(
payload,
account,
remember_account,
),
on_error=self._on_login_error,
on_finished=lambda: self._set_loading(False),
)
def _set_loading(
self,
loading: bool,
*,
button_text: str = "正在登录…",
overlay_text: str = "正在验证账号…",
) -> None:
self._loading = loading
self.login_button.setEnabled(not loading)
self.account_edit.setEnabled(not loading)
self.password_edit.setEnabled(not loading)
self.remember_check.setEnabled(not loading)
self.reveal_button.setEnabled(not loading)
self.demo_check.setEnabled(not loading and self.demo_repository is not None)
self.server_toggle.setEnabled(not loading and not self.demo_check.isChecked())
self.server_panel.setEnabled(not loading)
self.server_url_edit.setEnabled(not loading)
self.timeout_spin.setEnabled(not loading)
self.save_server_button.setEnabled(not loading)
self.login_button.setText(button_text if loading else "登录工作站")
self.busy_overlay.set_message(overlay_text)
self.busy_overlay.setVisible(loading)
if loading:
self.busy_overlay.raise_()
def set_session_restore_pending(self, pending: bool) -> None:
"""Block manual login controls while persisted-token validation runs."""
if pending:
self.error_banner.clear()
self._set_loading(
pending,
button_text="正在恢复登录…",
overlay_text="正在验证已保存的登录状态…",
)
def _on_login_success(
self,
payload: dict[str, Any],
account: str,
remember_account: bool | None = None,
) -> None:
if remember_account is None:
remember_account = self.remember_check.isChecked()
if remember_account:
self.settings.setValue("auth/remembered_account", account)
else:
self.settings.remove("auth/remembered_account")
self.settings.sync()
self._emit_config_update(remembered_account=account if remember_account else "")
self.password_edit.clear()
self.authenticated_user = payload.get("user")
self.login_succeeded.emit(payload)
self.authenticated.emit(payload.get("session"))
def _on_login_error(self, error: Exception) -> None:
message = friendly_error(error)
self.error_banner.show_message(message, "danger")
self.login_failed.emit(message)
self.password_edit.selectAll()
self.password_edit.setFocus()
__all__ = ["LoginWindow"]
@@ -0,0 +1,15 @@
"""Business pages shown inside :class:`doctor_workstation.ui.shell.ShellWindow`."""
from .consultations import ConsultationsPage
from .patients import PatientsPage
from .prescription_library import PrescriptionLibraryPage
from .prescriptions import PrescriptionsPage
from .reception import ReceptionPage
__all__ = [
"ConsultationsPage",
"PatientsPage",
"PrescriptionLibraryPage",
"PrescriptionsPage",
"ReceptionPage",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,420 @@
"""Reusable prescription-template library matching the admin workflow."""
from __future__ import annotations
from typing import Any
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from ..dialogs.prescription import PrescriptionTemplateDialog
from ..widgets import (
EmptyState,
MessageBanner,
PageHeader,
Pager,
SortableTable,
TableColumn,
display_text,
first_value,
friendly_error,
get_value,
has_permission,
invoke,
page_items,
page_total,
run_async,
show_toast,
)
def _formula_text(value: Any, _row: Any = None) -> str:
text = str(value or "").strip().lower()
return "辅方" if text in {"2", "aux", "auxiliary", "secondary", "辅方"} else "主方"
def _visibility_text(value: Any, _row: Any = None) -> str:
return "所有人可见" if value in (True, 1, "1", "public", "all") else "仅自己可见"
def _disable_edit_text(value: Any, _row: Any = None) -> str:
return "已禁用" if value in (True, 1, "1") else "可修改"
def _herb_count(_value: Any, row: Any) -> str:
herbs = get_value(row, "herbs", None) or []
return f"{len(herbs)}" if isinstance(herbs, (list, tuple)) else "0味"
def _herbs_detail(_value: Any, row: Any) -> str:
herbs = get_value(row, "herbs", None) or []
if not isinstance(herbs, (list, tuple)):
return display_text(herbs)
pieces = []
for herb in herbs:
name = first_value(herb, "name", "medicine_name", default="药材")
dosage = first_value(herb, "dosage", "amount", default="")
pieces.append(f"{name} {dosage}g".strip())
return "".join(pieces) if pieces else "暂无药材"
class PrescriptionLibraryPage(QWidget):
"""Filter, inspect, and manage reusable prescriptions."""
def __init__(
self,
repository: Any,
permissions: Any = None,
current_user: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.permissions = permissions
self.current_user = current_user
self._generation = 0
self._loading = False
self._refresh_pending = False
self._page = 1
self._page_size = 15
root = QVBoxLayout(self)
root.setContentsMargins(24, 20, 24, 24)
root.setSpacing(15)
header = PageHeader(
"我的处方库",
"管理可复用药材组合;公开模板可被其他医生导入,禁用修改仅作用于导入后的处方。",
)
self.new_button = QPushButton(" 新增处方")
self.new_button.setProperty("variant", "primary")
self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add"))
self.new_button.clicked.connect(self._new_template)
header.add_action(self.new_button)
root.addWidget(header)
filters = QFrame()
filters.setObjectName("FilterBar")
grid = QGridLayout(filters)
grid.setContentsMargins(16, 13, 16, 13)
grid.setHorizontalSpacing(10)
self.name_filter = QLineEdit()
self.name_filter.setPlaceholderText("处方名称")
self.name_filter.setClearButtonEnabled(True)
self.name_filter.returnPressed.connect(self._search)
grid.addWidget(self.name_filter, 0, 0, 1, 2)
self.formula_filter = QComboBox()
self.formula_filter.addItem("全部类型", "")
self.formula_filter.addItem("主方", "主方")
self.formula_filter.addItem("辅方", "辅方")
grid.addWidget(self.formula_filter, 0, 2)
self.visibility_filter = QComboBox()
self.visibility_filter.addItem("全部公开范围", "")
self.visibility_filter.addItem("仅自己可见", 0)
self.visibility_filter.addItem("所有人可见", 1)
grid.addWidget(self.visibility_filter, 0, 3)
query = QPushButton("查询")
query.setProperty("variant", "secondary")
query.clicked.connect(self._search)
grid.addWidget(query, 0, 4)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.clicked.connect(self._reset_filters)
grid.addWidget(reset, 0, 5)
grid.setColumnStretch(0, 1)
root.addWidget(filters)
self.banner = MessageBanner()
root.addWidget(self.banner)
card = QFrame()
card.setObjectName("Card")
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(16, 14, 16, 14)
card_layout.setSpacing(10)
toolbar = QHBoxLayout()
title = QLabel("处方模板")
title.setProperty("role", "sectionTitle")
toolbar.addWidget(title)
toolbar.addStretch(1)
self.view_button = QPushButton("查看")
self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read"))
self.view_button.setEnabled(False)
self.view_button.clicked.connect(self._view_selected)
toolbar.addWidget(self.view_button)
self.edit_button = QPushButton("编辑")
self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit"))
self.edit_button.setEnabled(False)
self.edit_button.clicked.connect(self._edit_selected)
toolbar.addWidget(self.edit_button)
self.delete_button = QPushButton("删除")
self.delete_button.setProperty("variant", "danger")
self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete"))
self.delete_button.setEnabled(False)
self.delete_button.clicked.connect(self._delete_selected)
toolbar.addWidget(self.delete_button)
refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost")
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
card_layout.addLayout(toolbar)
self.stack = QStackedWidget()
table_host = QWidget()
table_layout = QVBoxLayout(table_host)
table_layout.setContentsMargins(0, 0, 0, 0)
self.table = SortableTable(
[
TableColumn("id", "ID", 60),
TableColumn("prescription_name", "处方名称", 180),
TableColumn("formula_type", "处方类型", 90, _formula_text),
TableColumn("herbs", "药材数量", 90, _herb_count),
TableColumn("herbs", "药材明细", 300, _herbs_detail),
TableColumn("is_public", "是否公开", 110, _visibility_text),
TableColumn("disable_edit", "禁用修改", 95, _disable_edit_text),
TableColumn("creator_name", "创建人", 100),
TableColumn("create_time", "创建时间", 150),
]
)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size)
self.pager.page_changed.connect(self._change_page)
table_layout.addWidget(self.pager)
self.stack.addWidget(table_host)
empty = EmptyState(
"还没有处方模板",
"可以新增常用药材组合,之后开方时快速导入。",
"新增处方",
)
empty.action_button.setVisible(self.new_button.isVisible())
empty.action_requested.connect(self._new_template)
self.stack.addWidget(empty)
card_layout.addWidget(self.stack, 1)
root.addWidget(card, 1)
def _search(self) -> None:
self._page = 1
self.refresh()
def _reset_filters(self) -> None:
self.name_filter.clear()
self.formula_filter.setCurrentIndex(0)
self.visibility_filter.setCurrentIndex(0)
self._search()
def _change_page(self, page: int) -> None:
self._page = page
self.refresh()
def refresh(self) -> None:
if self._loading:
self._refresh_pending = True
return
self._loading = True
self._refresh_pending = False
self._generation += 1
generation = self._generation
query = {
"prescription_name": self.name_filter.text().strip(),
"formula_type": self.formula_filter.currentData(),
"is_public": self.visibility_filter.currentData(),
"page": self._page,
"page_size": self._page_size,
}
requested_page = self._page
self.banner.show_message("正在加载处方库…", "info")
run_async(
lambda: invoke(
self.repository,
"prescription_library",
**query,
),
on_success=lambda result: self._apply_result(result, generation, requested_page),
on_error=lambda error: self._load_error(error, generation),
on_finished=lambda: self._load_finished(generation),
)
def _apply_result(self, result: Any, generation: int, requested_page: int) -> None:
if generation != self._generation:
return
rows = page_items(result)
self.table.set_rows(rows)
self.pager.update_state(requested_page, page_total(result, len(rows)))
self.stack.setCurrentIndex(0 if rows else 1)
self.banner.clear()
self._selection_changed()
def _load_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
def _load_finished(self, generation: int) -> None:
if generation == self._generation:
self._loading = False
if self._refresh_pending:
self._refresh_pending = False
self.refresh()
def _selection_changed(self) -> None:
row = self.table.current_data()
self.view_button.setEnabled(row is not None)
manageable = self._can_manage_row(row)
self.edit_button.setEnabled(row is not None and manageable)
self.delete_button.setEnabled(row is not None and manageable)
def _can_manage_row(self, row: Any) -> bool:
if row is None:
return False
user_id = first_value(self.current_user, "id", "user_id", default=None)
creator_id = first_value(row, "creator_id", "doctor_id", default=None)
if user_id is not None and creator_id is not None and str(user_id) == str(creator_id):
return True
if _truthy(first_value(self.current_user, "root", "is_root", default=False)):
return True
role_ids = first_value(self.current_user, "role_ids", "role_id", default=[]) or []
if not isinstance(role_ids, (list, tuple, set)):
role_ids = [role_ids]
return any(str(role) in {"0", "3"} for role in role_ids)
def _new_template(self) -> None:
if not has_permission(self.permissions, "wcf.prescription/add"):
return
dialog = PrescriptionTemplateDialog(
self.repository,
mode="add",
parent=self,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_template(dialog.payload(), None)
def _view_selected(self) -> None:
row = self.table.current_data()
if row is None or not has_permission(self.permissions, "wcf.prescription/read"):
return
PrescriptionTemplateDialog(
self.repository,
row,
mode="view",
parent=self,
).exec()
def _edit_selected(self) -> None:
row = self.table.current_data()
if (
row is None
or not has_permission(self.permissions, "wcf.prescription/edit")
or not self._can_manage_row(row)
):
return
dialog = PrescriptionTemplateDialog(
self.repository,
row,
mode="edit",
parent=self,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_template(
dialog.payload(),
first_value(row, "id", "template_id", default=None),
)
def _save_template(self, payload: dict[str, Any], template_id: Any) -> None:
permission = "wcf.prescription/add" if template_id is None else "wcf.prescription/edit"
if not has_permission(self.permissions, permission):
return
self._set_actions_enabled(False)
if template_id is None:
def operation() -> Any:
return invoke(
self.repository,
"create_prescription_template",
template=payload,
)
else:
def operation() -> Any:
return invoke(
self.repository,
"update_prescription_template",
template=template_id,
changes=payload,
)
run_async(
operation,
on_success=lambda _result: self._mutation_success("处方模板已保存。"),
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4300),
on_finished=lambda: self._set_actions_enabled(True),
)
def _delete_selected(self) -> None:
row = self.table.current_data()
template_id = first_value(row, "id", "template_id", default=None)
if (
row is None
or template_id is None
or not has_permission(self.permissions, "wcf.prescription/delete")
or not self._can_manage_row(row)
):
return
name = display_text(first_value(row, "prescription_name", "name", default="该模板"))
answer = QMessageBox.warning(
self,
"删除处方模板",
f"确定删除“{name}”吗?此操作无法撤销。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
)
if answer != QMessageBox.StandardButton.Yes:
return
self._set_actions_enabled(False)
run_async(
lambda: invoke(
self.repository,
"delete_prescription_template",
template_id=template_id,
),
on_success=lambda _result: self._mutation_success("处方模板已删除。"),
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4300),
on_finished=lambda: self._set_actions_enabled(True),
)
def _mutation_success(self, message: str) -> None:
show_toast(self, message, "success")
self.refresh()
def _set_actions_enabled(self, enabled: bool) -> None:
self.new_button.setEnabled(enabled)
if not enabled:
self.view_button.setEnabled(False)
self.edit_button.setEnabled(False)
self.delete_button.setEnabled(False)
else:
self._selection_changed()
def showEvent(self, event: Any) -> None:
super().showEvent(event)
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
def _truthy(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(value)
__all__ = ["PrescriptionLibraryPage", "PrescriptionTemplateDialog"]
@@ -0,0 +1,933 @@
"""Issued-prescription management matching the admin route contract."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from typing import Any
from PySide6.QtCore import QDateTime, Qt
from PySide6.QtWidgets import (
QComboBox,
QDateTimeEdit,
QDialog,
QDialogButtonBox,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from ..dialogs.prescription import (
AuditPrescriptionDialog,
DiagnosisDetailDialog,
PatchPatientDialog,
PrescriptionDetailDialog,
PrescriptionEditorDialog,
PrescriptionOrderDialog,
PrescriptionOrderListDialog,
)
from ..widgets import (
EmptyState,
MessageBanner,
PageHeader,
Pager,
SortableTable,
TableColumn,
display_text,
first_value,
friendly_error,
get_value,
has_permission,
invoke,
page_items,
page_total,
run_async,
show_toast,
)
def _int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _truthy(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(value)
def prescription_status(row: Any) -> tuple[str, str]:
"""Return the admin-equivalent combined status label and visual kind."""
if _int(first_value(row, "void_status", "is_void"), 0) == 1:
return "已作废", "danger"
if _truthy(first_value(row, "business_prescription_audit_rejected", default=False)):
return "已驳回", "danger"
status = _int(first_value(row, "audit_status", "status", default=0), 0)
if status == 2:
return "已驳回", "danger"
if status == 1:
return "已通过", "success"
return "待审核", "warning"
def is_approved_active(row: Any) -> bool:
return (
_int(first_value(row, "audit_status", "status"), 0) == 1
and _int(first_value(row, "void_status", "is_void"), 0) != 1
)
def can_patch_patient(row: Any) -> bool:
return row is not None and _int(first_value(row, "void_status", "is_void"), 0) != 1
def can_create_order(row: Any) -> bool:
return can_patch_patient(row) and not _truthy(
first_value(row, "has_prescription_order", default=False)
)
def can_audit(row: Any) -> bool:
return (
row is not None
and _int(first_value(row, "audit_status", "status"), 0) == 0
and _int(first_value(row, "void_status", "is_void"), 0) != 1
)
def can_edit_or_delete(row: Any) -> bool:
return row is not None and not is_approved_active(row)
def _formula(value: Any) -> str:
text = str(value or "").strip().lower()
return "辅方" if text in {"2", "aux", "auxiliary", "辅方"} else "主方"
def _order_warnings(row: Any) -> list[str]:
if not _truthy(first_value(row, "has_prescription_order", default=False)):
return []
herbs = get_value(row, "herbs", None) or []
if not isinstance(herbs, (list, tuple)) or not herbs:
return ["请开方,当前处方药材为空白"]
seen: set[str] = set()
duplicate: list[str] = []
for herb in herbs:
name = str(first_value(herb, "name", "medicine_name", default="")).strip()
key = "".join(name.split()).lower()
if key and key in seen and name not in duplicate:
duplicate.append(name)
seen.add(key)
return [f"已有关联业务订单,存在重复药材:{''.join(duplicate)}"] if duplicate else []
def _sn_cell(_value: Any, row: Any) -> str:
sn = first_value(row, "sn", "prescription_no", "id", default="")
prescription_id = first_value(row, "id", "prescription_id", default="")
warnings = _order_warnings(row)
suffix = "\n" + "\n".join(warnings) if warnings else ""
return f"{sn}\nID: {prescription_id}{suffix}"
def _patient_cell(_value: Any, row: Any) -> str:
gender = first_value(row, "gender", default=None)
gender_text = "" if gender in (1, "1") else "" if gender in (0, "0") else "未知"
age = first_value(row, "age", default="")
return f"{first_value(row, 'patient_name', default='')}\n{gender_text} · {age}"
def _source_cell(_value: Any, row: Any) -> str:
return (
"空白处方"
if _truthy(first_value(row, "is_system_auto", "source", default=False))
else "手工"
)
def _audit_cell(_value: Any, row: Any) -> str:
label = prescription_status(row)[0]
reasons = []
if _truthy(first_value(row, "business_prescription_audit_rejected", default=False)):
reasons.append(
"业务订单审核:"
+ display_text(first_value(row, "business_prescription_audit_remark", default=""))
)
if _int(first_value(row, "audit_status"), 0) == 2:
reasons.append(
"消费者处方审核:" + display_text(first_value(row, "audit_remark", default=""))
)
return label + ("\n" + "\n".join(reasons) if reasons else "")
def _void_cell(_value: Any, row: Any) -> str:
return "作废" if _int(first_value(row, "void_status", "is_void"), 0) == 1 else ""
def _doctor_cell(_value: Any, row: Any) -> str:
return (
f"{first_value(row, 'doctor_name', 'creator_name', default='')}\n"
f"{first_value(row, 'prescription_date', default='')}"
)
class DoctorMultiSelect(QWidget):
"""Compact checkable doctor selector fed by list rows/extend data."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._options: dict[int, str] = {}
self._selected: set[int] = set()
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.button = QPushButton("全部医生")
self.button.clicked.connect(self._choose)
layout.addWidget(self.button)
def values(self) -> list[int]:
return sorted(self._selected)
def clear(self) -> None:
self._selected.clear()
self._update_text()
def update_options(self, rows: list[Any]) -> None:
for row in rows:
doctor_id = _int(first_value(row, "id", "creator_id", "doctor_id"), 0)
name = str(first_value(row, "name", "doctor_name", "creator_name", default="")).strip()
if doctor_id and name:
self._options[doctor_id] = name
self._update_text()
def _choose(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("选择医生")
dialog.resize(360, 430)
layout = QVBoxLayout(dialog)
listing = QListWidget()
for doctor_id, name in sorted(self._options.items(), key=lambda item: item[1]):
item = QListWidgetItem(name)
item.setData(Qt.ItemDataRole.UserRole, doctor_id)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked if doctor_id in self._selected else Qt.CheckState.Unchecked
)
listing.addItem(item)
layout.addWidget(listing)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
self._selected = {
_int(listing.item(index).data(Qt.ItemDataRole.UserRole))
for index in range(listing.count())
if listing.item(index).checkState() == Qt.CheckState.Checked
}
self._selected.discard(0)
self._update_text()
def _update_text(self) -> None:
if not self._selected:
self.button.setText("全部医生")
return
names = [self._options.get(value, str(value)) for value in sorted(self._selected)]
self.button.setText("".join(names[:2]) + (f"{len(names)}" if len(names) > 2 else ""))
class PrescriptionsPage(QWidget):
"""Complete issued-prescription route: list, workflow actions, print, and orders."""
def __init__(
self,
repository: Any,
permissions: Any = None,
current_user: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.permissions = permissions
self.current_user = current_user
self._page = 1
self._page_size = 15
self._generation = 0
self._loading = False
self._refresh_pending = False
self._detail_generation = 0
self._detail_target = 0
self._diagnosis_detail_generation = 0
self._diagnosis_detail_target = 0
self._mutation_pending = False
root = QVBoxLayout(self)
root.setContentsMargins(24, 20, 24, 24)
root.setSpacing(14)
header = PageHeader(
"已开处方",
"管理处方审核、患者修正与履约订单;已通过且未作废的处方只允许查看。",
)
self.orders_button = QPushButton("业务订单")
self.orders_button.setVisible(has_permission(permissions, "tcm.prescriptionOrder/lists"))
self.orders_button.clicked.connect(lambda: self._open_orders())
header.add_action(self.orders_button)
self.add_button = QPushButton(" 新增处方")
self.add_button.setProperty("variant", "primary")
self.add_button.setVisible(has_permission(permissions, "cf.prescription/add"))
self.add_button.clicked.connect(self._add_prescription)
header.add_action(self.add_button)
root.addWidget(header)
root.addWidget(self._build_filters())
self.banner = MessageBanner()
root.addWidget(self.banner)
root.addWidget(self._build_table_card(), 1)
self._load_doctor_options()
def _build_filters(self) -> QWidget:
frame = QFrame()
frame.setObjectName("FilterBar")
grid = QGridLayout(frame)
grid.setContentsMargins(14, 12, 14, 12)
grid.setHorizontalSpacing(9)
grid.setVerticalSpacing(8)
self.quick_date = QComboBox()
self.quick_date.addItem("全部时间", "all")
self.quick_date.addItem("今日", "today")
self.quick_date.addItem("昨日", "yesterday")
self.quick_date.addItem("前天", "before_yesterday")
self.quick_date.addItem("自定义", "custom")
self.quick_date.currentIndexChanged.connect(self._quick_date_changed)
grid.addWidget(self.quick_date, 0, 0)
self.start_time = QDateTimeEdit(QDateTime.currentDateTime().addDays(-7))
self.start_time.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
self.start_time.setCalendarPopup(True)
self.start_time.setEnabled(False)
grid.addWidget(self.start_time, 0, 1)
self.end_time = QDateTimeEdit(QDateTime.currentDateTime())
self.end_time.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
self.end_time.setCalendarPopup(True)
self.end_time.setEnabled(False)
grid.addWidget(self.end_time, 0, 2)
self.audit_filter = QComboBox()
self.audit_filter.addItem("待审核", "pending")
self.audit_filter.addItem("全部审核状态", "all")
self.audit_filter.addItem("已通过", "passed")
self.audit_filter.addItem("未通过", "not_passed")
self.audit_filter.addItem("已驳回", "rejected")
self.audit_filter.setCurrentIndex(1)
grid.addWidget(self.audit_filter, 0, 3)
self.source_filter = QComboBox()
self.source_filter.addItem("全部来源", "all")
self.source_filter.addItem("手工", "manual")
self.source_filter.addItem("空白处方", "system")
grid.addWidget(self.source_filter, 0, 4)
self.sn_filter = QLineEdit()
self.sn_filter.setPlaceholderText("处方编号")
self.sn_filter.setClearButtonEnabled(True)
self.sn_filter.returnPressed.connect(self._search)
grid.addWidget(self.sn_filter, 1, 0)
self.patient_filter = QLineEdit()
self.patient_filter.setPlaceholderText("患者姓名")
self.patient_filter.setClearButtonEnabled(True)
self.patient_filter.returnPressed.connect(self._search)
grid.addWidget(self.patient_filter, 1, 1)
self.doctor_filter = DoctorMultiSelect()
current_id = _int(first_value(self.current_user, "id", "user_id"), 0)
current_name = str(first_value(self.current_user, "name", "real_name", default="")).strip()
if current_id and current_name:
self.doctor_filter.update_options([{"id": current_id, "name": current_name}])
grid.addWidget(self.doctor_filter, 1, 2)
query = QPushButton("查询")
query.setProperty("variant", "secondary")
query.clicked.connect(self._search)
grid.addWidget(query, 1, 3)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.clicked.connect(self._reset_filters)
grid.addWidget(reset, 1, 4)
grid.setColumnStretch(1, 1)
grid.setColumnStretch(2, 1)
return frame
def _build_table_card(self) -> QWidget:
card = QFrame()
card.setObjectName("Card")
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 13, 14, 13)
layout.setSpacing(9)
toolbar = QHBoxLayout()
title = QLabel("处方列表")
title.setProperty("role", "sectionTitle")
toolbar.addWidget(title)
toolbar.addStretch(1)
self.view_button = self._action_button("查看", "cf.prescription/read", self._view_selected)
toolbar.addWidget(self.view_button)
self.patch_button = self._action_button(
"修正患者", "tcm.prescription/patchPatient", self._patch_selected
)
toolbar.addWidget(self.patch_button)
self.create_order_button = self._action_button(
"创建订单", "tcm.prescriptionOrder/create", self._create_order
)
toolbar.addWidget(self.create_order_button)
self.edit_button = self._action_button("编辑", "cf.prescription/edit", self._edit_selected)
toolbar.addWidget(self.edit_button)
self.audit_button = self._action_button(
"审核", "cf.prescription/audit", self._audit_selected
)
toolbar.addWidget(self.audit_button)
self.delete_button = self._action_button(
"删除", "cf.prescription/del", self._delete_selected, danger=True
)
toolbar.addWidget(self.delete_button)
refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost")
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
layout.addLayout(toolbar)
self.stack = QStackedWidget()
table_host = QWidget()
table_layout = QVBoxLayout(table_host)
table_layout.setContentsMargins(0, 0, 0, 0)
self.table = SortableTable(
[
TableColumn("sn", "处方编号", 190, _sn_cell),
TableColumn("prescription_type", "处方类型", 95),
TableColumn("is_system_auto", "来源", 90, _source_cell),
TableColumn("patient_name", "患者信息", 120, _patient_cell),
TableColumn("audit_status", "审核状态", 220, _audit_cell),
TableColumn("void_status", "作废", 70, _void_cell),
TableColumn("doctor_name", "医生信息", 130, _doctor_cell),
TableColumn("assistant_name", "医助", 90),
TableColumn("create_time", "创建时间", 145),
]
)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size)
self.pager.page_changed.connect(self._change_page)
table_layout.addWidget(self.pager)
self.stack.addWidget(table_host)
self.stack.addWidget(
EmptyState("没有找到处方", "请调整创建时间、审核状态或患者筛选后重试。")
)
layout.addWidget(self.stack, 1)
return card
def _action_button(
self,
text: str,
permission: str,
handler: Callable[[], None],
*,
danger: bool = False,
) -> QPushButton:
button = QPushButton(text)
if danger:
button.setProperty("variant", "danger")
button.setVisible(has_permission(self.permissions, permission))
button.setEnabled(False)
button.clicked.connect(handler)
return button
def _load_doctor_options(self) -> None:
"""Populate the creator multi-select from the repository's doctor catalog."""
method = getattr(self.repository, "list_diagnosis_doctors", None)
if not callable(method):
return
run_async(
method,
on_success=lambda result: self.doctor_filter.update_options(page_items(result)),
)
def _quick_date_changed(self) -> None:
custom = self.quick_date.currentData() == "custom"
self.start_time.setEnabled(custom)
self.end_time.setEnabled(custom)
self._search()
def _date_filters(self) -> tuple[str, str]:
value = self.quick_date.currentData()
if value == "all":
return "", ""
if value == "custom":
return (
self.start_time.dateTime().toString("yyyy-MM-dd HH:mm:ss"),
self.end_time.dateTime().toString("yyyy-MM-dd HH:mm:ss"),
)
offset = {"today": 0, "yesterday": 1, "before_yesterday": 2}.get(value, 0)
target = datetime.now().date() - timedelta(days=offset)
return (
f"{target.isoformat()} 00:00:00",
f"{target.isoformat()} 23:59:59",
)
def _search(self) -> None:
self._page = 1
self.refresh()
def _reset_filters(self) -> None:
self.quick_date.blockSignals(True)
self.quick_date.setCurrentIndex(0)
self.quick_date.blockSignals(False)
self.start_time.setEnabled(False)
self.end_time.setEnabled(False)
self.audit_filter.setCurrentIndex(1)
self.source_filter.setCurrentIndex(0)
self.sn_filter.clear()
self.patient_filter.clear()
self.doctor_filter.clear()
self._search()
def _change_page(self, page: int) -> None:
self._page = page
self.refresh()
def refresh(self) -> None:
if self._loading:
self._refresh_pending = True
return
self._loading = True
self._refresh_pending = False
self._generation += 1
generation = self._generation
requested_page = self._page
page_size = self._page_size
start_time, end_time = self._date_filters()
filters: dict[str, Any] = {
"sn": self.sn_filter.text().strip(),
"patient_name": self.patient_filter.text().strip(),
"audit_filter": ""
if self.audit_filter.currentData() == "all"
else self.audit_filter.currentData(),
"source_filter": ""
if self.source_filter.currentData() == "all"
else self.source_filter.currentData(),
"start_time": start_time,
"end_time": end_time,
}
creator_ids = self.doctor_filter.values()
if creator_ids:
filters["creator_ids"] = creator_ids
self.banner.show_message("正在加载处方列表…", "info")
run_async(
lambda: invoke(
self.repository,
"prescriptions",
page=requested_page,
page_size=page_size,
**filters,
),
on_success=lambda result: self._apply_result(result, generation, requested_page),
on_error=lambda error: self._load_error(error, generation),
on_finished=lambda: self._load_finished(generation),
)
def _apply_result(self, result: Any, generation: int, requested_page: int) -> None:
if generation != self._generation:
return
rows = page_items(result)
self.table.set_rows(rows)
self.pager.update_state(requested_page, page_total(result, len(rows)))
self.stack.setCurrentIndex(0 if rows else 1)
doctor_rows = []
for row in rows:
doctor_rows.append(
{
"id": first_value(row, "creator_id", "doctor_id"),
"name": first_value(row, "doctor_name", "creator_name"),
}
)
extend_doctors = get_value(result, "extend.doctors", None) or get_value(
result, "doctors", None
)
if isinstance(extend_doctors, (list, tuple)):
doctor_rows.extend(extend_doctors)
self.doctor_filter.update_options(doctor_rows)
self.banner.clear()
if rows and self.table.currentRow() < 0:
self.table.selectRow(0)
self._selection_changed()
def _load_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
def _load_finished(self, generation: int) -> None:
if generation == self._generation:
self._loading = False
if self._refresh_pending:
self._refresh_pending = False
self.refresh()
def _selection_changed(self) -> None:
row = self.table.current_data()
active = not self._mutation_pending
self.view_button.setEnabled(active and row is not None)
self.patch_button.setEnabled(active and can_patch_patient(row))
self.create_order_button.setEnabled(active and can_create_order(row))
self.edit_button.setEnabled(active and can_edit_or_delete(row))
self.audit_button.setEnabled(active and can_audit(row))
self.delete_button.setEnabled(active and can_edit_or_delete(row))
def _set_mutation_pending(self, pending: bool) -> None:
self._mutation_pending = pending
self.add_button.setEnabled(not pending)
self.orders_button.setEnabled(not pending)
self._selection_changed()
def _selected(self) -> Any:
return self.table.current_data()
def _get_prescription(self, prescription_id: int) -> Any:
method = self.repository.get_prescription
return method(prescription_id)
def _load_detail(
self,
row: Any,
callback: Callable[[Any], None],
*,
message: str = "正在加载处方详情…",
) -> None:
prescription_id = _int(first_value(row, "id", "prescription_id"), 0)
if not prescription_id:
self.banner.show_message("处方 ID 无效。", "warning")
return
self._detail_generation += 1
generation = self._detail_generation
self._detail_target = prescription_id
self.banner.show_message(message, "info")
run_async(
lambda: self._get_prescription(prescription_id),
on_success=lambda detail: self._detail_success(
detail, prescription_id, generation, callback
),
on_error=lambda error: self._detail_error(error, prescription_id, generation),
)
def _detail_success(
self,
detail: Any,
prescription_id: int,
generation: int,
callback: Callable[[Any], None],
) -> None:
if generation != self._detail_generation or prescription_id != self._detail_target:
return
self.banner.clear()
callback(detail)
def _detail_error(self, error: Exception, prescription_id: int, generation: int) -> None:
if generation == self._detail_generation and prescription_id == self._detail_target:
self.banner.show_message(friendly_error(error), "danger")
def _view_selected(self) -> None:
row = self._selected()
if row is None or not has_permission(self.permissions, "cf.prescription/read"):
return
self._load_detail(row, self._show_detail)
def _show_detail(self, detail: Any) -> None:
if not has_permission(self.permissions, "cf.prescription/read"):
return
dialog = PrescriptionDetailDialog(
detail,
can_open_diagnosis=has_permission(self.permissions, "tcm.diagnosis/readonlyDetail"),
can_open_orders=has_permission(self.permissions, "tcm.prescriptionOrder/lists"),
parent=self,
)
dialog.diagnosis_requested.connect(self._open_diagnosis)
dialog.orders_requested.connect(lambda prescription_id: self._open_orders(prescription_id))
dialog.exec()
def _open_diagnosis(self, diagnosis_id: int) -> None:
if not has_permission(self.permissions, "tcm.diagnosis/readonlyDetail"):
self.banner.show_message("无权查看诊单详情。", "danger")
return
diagnosis_id = _int(diagnosis_id, 0)
if diagnosis_id <= 0:
self.banner.show_message("诊单 ID 无效。", "warning")
return
method = getattr(self.repository, "get_diagnosis_detail", None)
readonly_keyword = True
if not callable(method):
method = getattr(self.repository, "diagnosis_readonly_detail", None)
readonly_keyword = False
if not callable(method):
self.banner.show_message("当前 repository 不支持诊单详情。", "danger")
return
self._diagnosis_detail_generation += 1
generation = self._diagnosis_detail_generation
self._diagnosis_detail_target = diagnosis_id
self.banner.show_message("正在加载诊单详情…", "info")
run_async(
lambda: (
method(diagnosis_id, readonly=True) if readonly_keyword else method(diagnosis_id)
),
on_success=lambda detail: self._diagnosis_detail_success(
detail, diagnosis_id, generation
),
on_error=lambda error: self._diagnosis_detail_error(error, diagnosis_id, generation),
)
def _diagnosis_detail_success(self, detail: Any, diagnosis_id: int, generation: int) -> None:
if (
generation != self._diagnosis_detail_generation
or diagnosis_id != self._diagnosis_detail_target
):
return
self.banner.clear()
DiagnosisDetailDialog(detail, self).exec()
def _diagnosis_detail_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
if (
generation == self._diagnosis_detail_generation
and diagnosis_id == self._diagnosis_detail_target
):
self.banner.show_message(friendly_error(error), "danger")
def _add_prescription(self) -> None:
if not has_permission(self.permissions, "cf.prescription/add"):
return
dialog = PrescriptionEditorDialog(
self.repository,
mode="add",
current_user=self.current_user,
parent=self,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_prescription(dialog.payload(), None)
def _edit_selected(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "cf.prescription/edit")
or not can_edit_or_delete(row)
):
return
self._load_detail(row, self._open_editor, message="正在准备编辑处方…")
def _open_editor(self, detail: Any) -> None:
dialog = PrescriptionEditorDialog(
self.repository,
detail,
mode="edit",
current_user=self.current_user,
parent=self,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self._save_prescription(
dialog.payload(),
_int(first_value(detail, "id", "prescription_id"), 0),
)
def _save_prescription(
self,
payload: dict[str, Any],
prescription_id: int | None,
) -> None:
permission = "cf.prescription/edit" if prescription_id else "cf.prescription/add"
if not has_permission(self.permissions, permission):
return
self._set_mutation_pending(True)
if prescription_id:
def operation() -> Any:
return self.repository.update_prescription(
prescription_id,
changes=payload,
)
message = "处方已保存并重新进入待审核。"
else:
def operation() -> Any:
return self.repository.create_prescription(payload)
message = "处方已新增并提交审核。"
run_async(
operation,
on_success=lambda _result: self._mutation_success(message),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _delete_selected(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "cf.prescription/del")
or not can_edit_or_delete(row)
):
return
answer = QMessageBox.warning(
self,
"删除处方",
"确定删除该处方吗?此操作无法撤销。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
)
if answer != QMessageBox.StandardButton.Yes:
return
prescription_id = _int(first_value(row, "id", "prescription_id"), 0)
self._set_mutation_pending(True)
run_async(
lambda: self.repository.delete_prescription(prescription_id),
on_success=lambda _result: self._mutation_success("处方已删除。"),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _patch_selected(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "tcm.prescription/patchPatient")
or not can_patch_patient(row)
):
return
dialog = PatchPatientDialog(row, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
payload = dialog.payload()
self._set_mutation_pending(True)
run_async(
lambda: self.repository.patch_prescription_patient(
payload["id"],
patient_name=payload["patient_name"],
phone=payload["phone"],
gender=payload["gender"],
),
on_success=lambda _result: self._mutation_success("患者信息已修正。"),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _audit_selected(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "cf.prescription/audit")
or not can_audit(row)
):
return
dialog = AuditPrescriptionDialog(row, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
payload = dialog.payload()
self._set_mutation_pending(True)
run_async(
lambda: self.repository.audit_prescription(
payload["id"],
action=payload["action"],
remark=payload["remark"],
),
on_success=lambda result: self._audit_success(result, payload["action"]),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _audit_success(self, result: Any, action: str) -> None:
message = "处方已驳回并作废。" if action == "reject" else "处方审核已通过。"
self._mutation_success(message)
if (
isinstance(result, Mapping)
and result.get("wecom_notify_ok") is False
and result.get("wecom_notify_hint")
):
show_toast(self, str(result["wecom_notify_hint"]), "warning", 5000)
def _create_order(self) -> None:
row = self._selected()
if (
row is None
or not has_permission(self.permissions, "tcm.prescriptionOrder/create")
or not can_create_order(row)
):
return
self._load_detail(row, self._open_order_editor, message="正在准备业务订单…")
def _open_order_editor(self, detail: Any) -> None:
if not has_permission(self.permissions, "tcm.prescriptionOrder/create"):
return
dialog = PrescriptionOrderDialog(
self.repository,
detail,
can_select_ship_mode=has_permission(
self.permissions, "tcm.prescriptionOrder/setShipMode"
),
can_view_internal_cost=has_permission(self.permissions, "finance.account_log/lists"),
can_edit_pharmacy_remark=has_permission(
self.permissions, "tcm.prescriptionOrder/editRemarkExtra"
),
parent=self,
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
payload = dialog.payload()
self._set_mutation_pending(True)
run_async(
lambda: self.repository.create_prescription_order(payload),
on_success=lambda result: self._order_created(result),
on_error=self._mutation_error,
on_finished=lambda: self._set_mutation_pending(False),
)
def _order_created(self, result: Any) -> None:
order_no = first_value(result, "order_no", "data.order_no", default="")
message = f"业务订单已创建:{order_no}" if order_no else "业务订单已创建。"
self._mutation_success(message)
def _open_orders(self, prescription_id: int | None = None) -> None:
if not has_permission(self.permissions, "tcm.prescriptionOrder/lists"):
return
PrescriptionOrderListDialog(
self.repository,
prescription_id=prescription_id,
parent=self,
).exec()
def _mutation_success(self, message: str) -> None:
self.banner.clear()
show_toast(self, message, "success", 3600)
self.refresh()
def _mutation_error(self, error: Exception) -> None:
message = friendly_error(error)
self.banner.show_message(message, "danger")
show_toast(self, message, "danger", 5000)
def showEvent(self, event: Any) -> None:
super().showEvent(event)
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
__all__ = [
"PrescriptionsPage",
"can_audit",
"can_create_order",
"can_edit_or_delete",
"can_patch_patient",
"is_approved_active",
"prescription_status",
]
File diff suppressed because it is too large Load Diff
+521
View File
@@ -0,0 +1,521 @@
"""Authenticated application shell with permission-aware navigation."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from .pages import (
ConsultationsPage,
PatientsPage,
PrescriptionLibraryPage,
PrescriptionsPage,
ReceptionPage,
)
from .widgets import EmptyState, StatusBadge, display_text, first_value, get_value
@dataclass(frozen=True)
class NavigationItem:
key: str
title: str
glyph: str
page_type: type[QWidget]
permissions: tuple[str, ...]
NAVIGATION = (
NavigationItem(
"reception",
"接诊台",
"",
ReceptionPage,
("doctor.appointment/lists",),
),
NavigationItem(
"prescription_library",
"我的处方库",
"",
PrescriptionLibraryPage,
("tcm.prescriptionLibrary/lists",),
),
NavigationItem(
"prescriptions",
"已开处方",
"",
PrescriptionsPage,
("tcm.prescription/lists",),
),
NavigationItem(
"patients",
"我的患者",
"",
PatientsPage,
("firstvisit.myPatient/lists",),
),
NavigationItem(
"consultations",
"问诊列表",
"",
ConsultationsPage,
("tcm.diagnosis/lists",),
),
)
_NAVIGATION_BY_PERMISSION = {item.permissions[0]: item for item in NAVIGATION}
_MENU_ROUTE_IDENTIFIERS = {
"reception": {
"reception",
"patient/reception",
"patient/reception/index",
},
"prescription_library": {
"prescription-library",
"prescription_library",
"consumer/prescription/list",
},
"prescriptions": {
"prescriptions",
"consumer/prescription/index",
},
"patients": {
"patients",
"first_visit/my_patients",
"first_visit/my_patients/index",
},
"consultations": {
"consultations",
"tcm/diagnosis",
"tcm/diagnosis/index",
},
}
def _canonical_allowed(permissions: Any, code: str) -> bool:
"""Apply the core exact/wildcard semantics without slash-dot aliases."""
if permissions is None:
return True
for method_name in ("allows", "has", "can_access_page", "has_page", "can"):
method = getattr(permissions, method_name, None)
if callable(method):
try:
return bool(method(code))
except (TypeError, ValueError):
continue
raw = permissions
for attr in ("codes", "permissions", "values"):
candidate = getattr(permissions, attr, None)
if candidate is not None and not callable(candidate):
raw = candidate
break
if isinstance(raw, Mapping):
nested = first_value(raw, "codes", "permissions", "values", default=None)
if nested is not None:
raw = nested
if isinstance(raw, Mapping):
available = {str(key) for key, enabled in raw.items() if enabled}
elif isinstance(raw, str):
available = {raw}
else:
try:
available = {str(value) for value in raw}
except TypeError:
return False
if "*" in available or code in available:
return True
return any(grant.endswith("/*") and code.startswith(grant[:-1]) for grant in available)
def _menu_rows(value: Any) -> list[Mapping[str, Any]]:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
return []
return [row for row in value if isinstance(row, Mapping)]
def _menu_visible(row: Mapping[str, Any]) -> bool:
value = get_value(row, "is_show", 1)
if isinstance(value, str):
return value.strip().lower() not in {"0", "false", "hidden", "no"}
return value != 0
def _menu_enabled(row: Mapping[str, Any]) -> bool:
value = get_value(row, "is_disable", 0)
if isinstance(value, str):
return value.strip().lower() not in {"1", "true", "disabled", "yes"}
return value != 1
def _menu_sort(row: Mapping[str, Any]) -> float:
try:
return float(first_value(row, "sort", "sort_order", "order", default=0))
except (TypeError, ValueError):
return 0.0
def _visible_menu_nodes(value: Any) -> list[Mapping[str, Any]]:
"""Flatten visible, enabled nodes; larger admin sort values come first."""
nodes = _menu_rows(value)
ordered = sorted(enumerate(nodes), key=lambda pair: (-_menu_sort(pair[1]), pair[0]))
result: list[Mapping[str, Any]] = []
for _index, node in ordered:
if not _menu_visible(node) or not _menu_enabled(node):
continue
result.append(node)
children = first_value(node, "children", "child", "childs", default=[])
result.extend(_visible_menu_nodes(children))
return result
def _normalise_route(value: Any) -> str:
route = str(value or "").strip().replace("\\", "/").lower()
route = route.split("?", 1)[0].split("#", 1)[0].strip("/")
if route.endswith(".vue"):
route = route[:-4]
return route
def _menu_permissions(row: Mapping[str, Any]) -> tuple[str, ...]:
value = first_value(row, "perms", "permission", "meta.perms", default="")
if isinstance(value, str):
return (value.strip(),) if value.strip() else ()
if isinstance(value, Sequence):
return tuple(str(item).strip() for item in value if str(item).strip())
return ()
def _match_navigation(row: Mapping[str, Any]) -> NavigationItem | None:
for permission in _menu_permissions(row):
item = _NAVIGATION_BY_PERMISSION.get(permission)
if item is not None:
return item
identifiers = {
_normalise_route(first_value(row, "paths", "path")),
_normalise_route(get_value(row, "component", "")),
}
identifiers.discard("")
for item in NAVIGATION:
if identifiers & _MENU_ROUTE_IDENTIFIERS[item.key]:
return item
return None
def _resolve_navigation(
menu: Any,
permissions: Any,
*,
demo_mode: bool,
) -> list[tuple[NavigationItem, str]]:
"""Resolve only locally supported pages from the authoritative menu tree."""
rows = _menu_rows(menu)
if rows:
resolved: list[tuple[NavigationItem, str]] = []
seen: set[str] = set()
for row in _visible_menu_nodes(rows):
item = _match_navigation(row)
if item is None or item.key in seen:
continue
if not _canonical_allowed(permissions, item.permissions[0]):
continue
title_value = first_value(row, "name", "title", "meta.title", default=item.title)
title = str(title_value).strip() or item.title
resolved.append((item, title))
seen.add(item.key)
return resolved
if demo_mode:
return [
(item, item.title)
for item in NAVIGATION
if _canonical_allowed(permissions, item.permissions[0])
]
return []
class ShellWindow(QMainWindow):
"""Main workstation window.
The expected construction signature is ``ShellWindow(repository, session,
permissions=None)``. ``session`` may be a Session dataclass, the payload
emitted by :class:`LoginWindow`, or a plain mapping.
"""
logout_requested = Signal()
video_requested = Signal(dict)
page_changed = Signal(str)
def __init__(
self,
repository: Any,
session: Any,
permissions: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.login_payload = session
self.session = get_value(session, "session", None) or session
self.current_user = (
get_value(session, "user", None)
or get_value(self.session, "user", None)
or get_value(session, "current_user", None)
or session
)
if permissions is not None:
self.permissions = permissions
else:
session_permissions = get_value(self.session, "permissions", None)
self.permissions = (
session_permissions
if session_permissions is not None
else get_value(self.current_user, "permissions", None)
)
session_menu = get_value(self.session, "menu", None)
self.menu = session_menu if session_menu is not None else get_value(session, "menu", [])
self.demo_mode = bool(
get_value(session, "demo_mode", False)
or get_value(self.session, "metadata.demo", False)
or get_value(self.session, "metadata.demo_mode", False)
)
self.navigation = _resolve_navigation(
self.menu,
self.permissions,
demo_mode=self.demo_mode,
)
self.pages: dict[str, QWidget] = {}
self.nav_buttons: dict[str, QPushButton] = {}
self.page_titles: dict[int, str] = {}
self.setWindowTitle("臻阳堂 · 医生工作站")
self.setMinimumSize(1024, 640)
self.resize(1280, 800)
canvas = QWidget()
canvas.setObjectName("AppCanvas")
self.setCentralWidget(canvas)
root = QHBoxLayout(canvas)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
root.addWidget(self._build_sidebar())
workspace = QWidget()
workspace_layout = QVBoxLayout(workspace)
workspace_layout.setContentsMargins(0, 0, 0, 0)
workspace_layout.setSpacing(0)
workspace_layout.addWidget(self._build_topbar())
self.stack = QStackedWidget()
workspace_layout.addWidget(self.stack, 1)
root.addWidget(workspace, 1)
self._register_pages()
def _build_sidebar(self) -> QWidget:
sidebar = QWidget()
sidebar.setObjectName("Sidebar")
sidebar.setFixedWidth(216)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(18, 22, 18, 18)
layout.setSpacing(8)
brand = QHBoxLayout()
mark = QLabel("")
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
mark.setFixedSize(38, 38)
mark.setStyleSheet(
"color:#0F6D64; background:#DDF1EC; border-radius:11px; font-size:18px; font-weight:700;"
)
brand.addWidget(mark)
brand_text = QVBoxLayout()
brand_text.setSpacing(0)
name = QLabel("医生工作站")
name.setStyleSheet("color:#FFFFFF; font-size:15px; font-weight:700;")
brand_text.addWidget(name)
institution = QLabel("臻阳堂医疗")
institution.setStyleSheet("color:#82A198; font-size:10px;")
brand_text.addWidget(institution)
brand.addLayout(brand_text)
brand.addStretch(1)
layout.addLayout(brand)
layout.addSpacing(28)
navigation_label = QLabel("工作区")
navigation_label.setStyleSheet("color:#78998F; font-size:10px; font-weight:700;")
layout.addWidget(navigation_label)
self.nav_layout = QVBoxLayout()
self.nav_layout.setSpacing(6)
layout.addLayout(self.nav_layout)
layout.addStretch(1)
safety = QFrame()
safety.setStyleSheet("background:#20483D; border-radius:12px;")
safety_layout = QVBoxLayout(safety)
safety_layout.setContentsMargins(12, 11, 12, 11)
safety_layout.setSpacing(4)
safety_title = QLabel("● 安全连接")
safety_title.setStyleSheet("color:#B9DDD3; font-size:11px; font-weight:700;")
safety_layout.addWidget(safety_title)
safety_text = QLabel("医疗数据按账号权限展示")
safety_text.setWordWrap(True)
safety_text.setStyleSheet("color:#91ACA3; font-size:10px;")
safety_layout.addWidget(safety_text)
layout.addWidget(safety)
version = QLabel("Doctor Workstation")
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
version.setStyleSheet("color:#617F76; font-size:9px;")
layout.addWidget(version)
return sidebar
def _build_topbar(self) -> QWidget:
topbar = QFrame()
topbar.setObjectName("TopBar")
topbar.setFixedHeight(68)
layout = QHBoxLayout(topbar)
layout.setContentsMargins(24, 0, 22, 0)
layout.setSpacing(11)
self.context_label = QLabel("工作台")
self.context_label.setStyleSheet("color:#315147; font-size:14px; font-weight:600;")
layout.addWidget(self.context_label)
layout.addStretch(1)
self.connection_badge = StatusBadge("服务正常", "success")
layout.addWidget(self.connection_badge)
display_name = display_text(
first_value(
self.current_user, "name", "display_name", "nickname", "account", default="医生"
)
)
avatar = QLabel(display_name[:1] if display_name else "")
avatar.setObjectName("UserAvatar")
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(avatar)
identity = QVBoxLayout()
identity.setSpacing(0)
user_name = QLabel(display_name)
user_name.setStyleSheet("font-weight:700; color:#17382F;")
identity.addWidget(user_name)
role = QLabel(self._role_text())
role.setProperty("role", "muted")
role.setStyleSheet("font-size:10px;")
identity.addWidget(role)
layout.addLayout(identity)
logout = QPushButton("退出")
logout.setProperty("variant", "ghost")
logout.setToolTip("退出当前账号")
logout.clicked.connect(lambda: self.logout_requested.emit())
layout.addWidget(logout)
return topbar
def _role_text(self) -> str:
department = first_value(
self.current_user, "department_name", "department.name", default=""
)
role_ids = first_value(self.current_user, "role_ids", "role_id", default=[]) or []
if not isinstance(role_ids, (list, tuple, set, frozenset)):
role_ids = [role_ids]
role_values = {str(value) for value in role_ids}
role = "医生" if "1" in role_values else "医助" if "2" in role_values else "医疗人员"
return f"{department} · {role}" if department else role
def _register_pages(self) -> None:
self.nav_group = QButtonGroup(self)
self.nav_group.setExclusive(True)
first_button: QPushButton | None = None
for item, title in self.navigation:
page = item.page_type(
self.repository,
permissions=self.permissions,
current_user=self.current_user,
)
if hasattr(page, "video_requested"):
page.video_requested.connect(lambda payload: self.video_requested.emit(payload))
index = self.stack.addWidget(page)
self.pages[item.key] = page
self.page_titles[index] = title
button = QPushButton(f"{item.glyph} {title}")
button.setProperty("variant", "nav")
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(
lambda _checked=False, page_index=index, key=item.key: self._navigate(
page_index, key
)
)
self.nav_group.addButton(button, index)
self.nav_layout.addWidget(button)
self.nav_buttons[item.key] = button
if first_button is None:
first_button = button
if first_button is None:
denied = EmptyState(
"暂无可用工作区",
"当前账号没有医生工作站页面权限,请联系管理员调整授权。",
)
index = self.stack.addWidget(denied)
self.page_titles[index] = "权限受限"
self.stack.setCurrentIndex(index)
self.context_label.setText("权限受限")
return
first_button.setChecked(True)
first_index = self.nav_group.id(first_button)
self._navigate(
first_index,
next(key for key, button in self.nav_buttons.items() if button is first_button),
)
def _navigate(self, index: int, key: str) -> None:
if index < 0 or index >= self.stack.count():
return
self.stack.setCurrentIndex(index)
self.context_label.setText(self.page_titles.get(index, "工作台"))
button = self.nav_buttons.get(key)
if button is not None:
button.setChecked(True)
page = self.stack.widget(index)
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
self.page_changed.emit(key)
def navigate(self, key: str) -> bool:
"""Navigate to a visible page by stable key; return whether it exists."""
button = self.nav_buttons.get(key)
page = self.pages.get(key)
if button is None or page is None:
return False
self._navigate(self.stack.indexOf(page), key)
return True
def refresh_current_page(self) -> None:
page = self.stack.currentWidget()
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
def set_connection_state(self, online: bool, message: str = "") -> None:
self.connection_badge.set_status(
message or ("服务正常" if online else "连接中断"),
"success" if online else "danger",
)
__all__ = ["NAVIGATION", "NavigationItem", "ShellWindow"]
+298
View File
@@ -0,0 +1,298 @@
"""Application-wide visual theme.
The UI deliberately uses a restrained, clinical palette: warm whites for long
working sessions, ink green navigation, and teal for actionable state. The
theme is pure QSS so it remains dependable in frozen Windows and macOS builds.
"""
from __future__ import annotations
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
COLORS = {
"canvas": "#F4F3EF",
"surface": "#FCFBF8",
"surface_alt": "#F0F2EF",
"ink": "#17382F",
"ink_soft": "#315147",
"teal": "#168579",
"teal_dark": "#0F6D64",
"teal_pale": "#DDF1EC",
"text": "#18211E",
"muted": "#66736D",
"line": "#D9DEDA",
"danger": "#B5473F",
"danger_pale": "#F8E8E5",
"warning": "#9A6A19",
"warning_pale": "#F8F0DA",
"success": "#287659",
"success_pale": "#E1F1E9",
"info": "#35698B",
"info_pale": "#E5EFF5",
}
GLOBAL_QSS = r"""
QWidget {
color: #18211E;
background-color: transparent;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px;
}
QMainWindow, QDialog, QWidget#AppCanvas, QWidget#LoginCanvas {
background-color: #F4F3EF;
}
QLabel[role="muted"] { color: #66736D; }
QLabel[role="eyebrow"] {
color: #168579;
font-size: 11px;
font-weight: 700;
}
QLabel[role="pageTitle"] {
color: #17382F;
font-size: 25px;
font-weight: 700;
}
QLabel[role="sectionTitle"] {
color: #17382F;
font-size: 16px;
font-weight: 700;
}
QLabel[role="display"] {
color: #FCFBF8;
font-size: 30px;
font-weight: 700;
}
QLabel[role="metric"] {
color: #17382F;
font-size: 22px;
font-weight: 700;
}
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel {
background-color: #FCFBF8;
border: 1px solid #D9DEDA;
border-radius: 16px;
}
QFrame#SubtleCard {
background-color: #F0F2EF;
border: 1px solid #E1E5E1;
border-radius: 12px;
}
QFrame#Divider { background-color: #D9DEDA; min-height: 1px; max-height: 1px; }
QPushButton {
min-height: 36px;
padding: 0 15px;
border: 1px solid #CBD3CE;
border-radius: 9px;
background-color: #FCFBF8;
color: #24443B;
font-weight: 600;
}
QPushButton:hover { background-color: #F0F2EF; border-color: #AEBBB4; }
QPushButton:pressed { background-color: #E6EAE6; }
QPushButton:disabled { color: #9AA39E; background-color: #EFF1EF; border-color: #E1E5E1; }
QPushButton[variant="primary"] {
color: #FFFFFF;
background-color: #168579;
border-color: #168579;
}
QPushButton[variant="primary"]:hover { background-color: #0F6D64; border-color: #0F6D64; }
QPushButton[variant="secondary"] {
color: #0F6D64;
background-color: #DDF1EC;
border-color: #B7DDD4;
}
QPushButton[variant="secondary"]:hover { background-color: #CCE8E1; }
QPushButton[variant="danger"] {
color: #A43C35;
background-color: #F8E8E5;
border-color: #EBC6C1;
}
QPushButton[variant="danger"]:hover { background-color: #F1D7D3; }
QPushButton[variant="ghost"] { border-color: transparent; background-color: transparent; }
QPushButton[variant="ghost"]:hover { background-color: #E8ECE9; }
QPushButton[variant="nav"] {
min-height: 44px;
padding: 0 15px;
border: 0;
border-radius: 10px;
background-color: transparent;
color: #C8D7D1;
text-align: left;
font-weight: 600;
}
QPushButton[variant="nav"]:hover { background-color: #244B40; color: #FFFFFF; }
QPushButton[variant="nav"]:checked { background-color: #DDF1EC; color: #0D5E56; }
QToolButton {
min-width: 32px;
min-height: 32px;
border: 0;
border-radius: 8px;
color: #315147;
}
QToolButton:hover { background-color: #E8ECE9; }
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QDateEdit, QSpinBox, QDoubleSpinBox {
min-height: 36px;
padding: 0 11px;
border: 1px solid #CBD3CE;
border-radius: 9px;
background-color: #FFFFFF;
selection-background-color: #B7DDD4;
selection-color: #17382F;
}
QTextEdit, QPlainTextEdit { padding: 9px 11px; }
QLineEdit:hover, QTextEdit:hover, QPlainTextEdit:hover, QComboBox:hover, QDateEdit:hover,
QSpinBox:hover, QDoubleSpinBox:hover { border-color: #9DAEA5; }
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QDateEdit:focus,
QSpinBox:focus, QDoubleSpinBox:focus { border: 2px solid #168579; }
QLineEdit:disabled, QTextEdit:disabled, QComboBox:disabled { background-color: #EFF1EF; color: #8A958F; }
QComboBox::drop-down, QDateEdit::drop-down { border: 0; width: 25px; }
QComboBox QAbstractItemView {
background-color: #FFFFFF;
border: 1px solid #CBD3CE;
border-radius: 8px;
padding: 4px;
selection-background-color: #DDF1EC;
selection-color: #17382F;
}
QCheckBox, QRadioButton { spacing: 8px; }
QCheckBox::indicator, QRadioButton::indicator { width: 17px; height: 17px; }
QCheckBox::indicator:unchecked {
background-color: #FFFFFF;
border: 1px solid #AEBBB4;
border-radius: 4px;
}
QCheckBox::indicator:checked {
background-color: #168579;
border: 1px solid #168579;
border-radius: 4px;
}
QTableWidget, QTableView {
background-color: #FCFBF8;
alternate-background-color: #F6F7F4;
border: 0;
border-radius: 12px;
gridline-color: #E5E8E5;
selection-background-color: #DDF1EC;
selection-color: #17382F;
outline: 0;
}
QTableWidget::item, QTableView::item { padding: 9px 8px; border-bottom: 1px solid #E6E9E6; }
QHeaderView::section {
background-color: #EEF1EE;
color: #53635C;
border: 0;
border-bottom: 1px solid #D9DEDA;
padding: 10px 8px;
font-size: 12px;
font-weight: 700;
}
QTableCornerButton::section { background-color: #EEF1EE; border: 0; }
QListWidget {
background-color: transparent;
border: 0;
outline: 0;
}
QListWidget::item { border: 0; margin: 2px 0; }
QListWidget::item:selected { background-color: #DDF1EC; color: #17382F; border-radius: 11px; }
QListWidget::item:hover { background-color: #F0F2EF; border-radius: 11px; }
QTabBar::tab {
min-height: 36px;
padding: 0 16px;
margin-right: 4px;
color: #66736D;
background-color: transparent;
border: 0;
border-radius: 9px;
font-weight: 600;
}
QTabBar::tab:hover { background-color: #F0F2EF; }
QTabBar::tab:selected { background-color: #DDF1EC; color: #0F6D64; }
QScrollArea { border: 0; background-color: transparent; }
QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
QScrollBar::handle:vertical { background: #C5CEC8; min-height: 30px; border-radius: 4px; }
QScrollBar::handle:vertical:hover { background: #9FAEA6; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
QScrollBar::handle:horizontal { background: #C5CEC8; min-width: 30px; border-radius: 4px; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QProgressBar { min-height: 6px; max-height: 6px; border: 0; border-radius: 3px; background: #E1E5E1; }
QProgressBar::chunk { border-radius: 3px; background-color: #168579; }
QLabel#StatusBadge {
padding: 4px 9px;
border-radius: 9px;
font-size: 11px;
font-weight: 700;
}
QLabel#StatusBadge[kind="neutral"] { color: #53635C; background-color: #E8ECE9; }
QLabel#StatusBadge[kind="success"] { color: #216348; background-color: #E1F1E9; }
QLabel#StatusBadge[kind="warning"] { color: #805714; background-color: #F8F0DA; }
QLabel#StatusBadge[kind="danger"] { color: #A43C35; background-color: #F8E8E5; }
QLabel#StatusBadge[kind="info"] { color: #2F5F7E; background-color: #E5EFF5; }
QLabel#StatusBadge[kind="accent"] { color: #0F6D64; background-color: #DDF1EC; }
QFrame#MessageBanner { border-radius: 10px; }
QFrame#MessageBanner[kind="info"] { background-color: #E5EFF5; border: 1px solid #C6DCE8; }
QFrame#MessageBanner[kind="success"] { background-color: #E1F1E9; border: 1px solid #C2E1D1; }
QFrame#MessageBanner[kind="warning"] { background-color: #F8F0DA; border: 1px solid #EADAAE; }
QFrame#MessageBanner[kind="danger"] { background-color: #F8E8E5; border: 1px solid #EBC6C1; }
QLabel#Toast {
color: #FFFFFF;
background-color: #17382F;
border: 1px solid #315147;
border-radius: 11px;
padding: 11px 16px;
font-weight: 600;
}
QLabel#Toast[kind="danger"] { background-color: #8F3832; border-color: #A94740; }
QLabel#Toast[kind="success"] { background-color: #216348; border-color: #2B7759; }
QWidget#Sidebar { background-color: #17382F; }
QFrame#TopBar { background-color: #FCFBF8; border-bottom: 1px solid #D9DEDA; }
QLabel#UserAvatar {
min-width: 36px; max-width: 36px; min-height: 36px; max-height: 36px;
color: #0F6D64; background-color: #DDF1EC; border-radius: 18px;
font-size: 15px; font-weight: 700;
}
QWidget#LoginBrandPanel { background-color: #17382F; border-radius: 22px; }
QFrame#LoginCard { background-color: #FCFBF8; border: 1px solid #D9DEDA; border-radius: 20px; }
QFrame#BusyOverlay { background-color: rgba(244, 243, 239, 220); border-radius: 16px; }
QSplitter::handle { background-color: transparent; width: 8px; height: 8px; }
QSplitter::handle:hover { background-color: #DDE3DF; }
QToolTip { color: #FFFFFF; background-color: #17382F; border: 0; padding: 6px; }
"""
def apply_theme(app: QApplication) -> None:
"""Apply the global palette and stylesheet to ``app``."""
app.setStyle("Fusion")
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(COLORS["canvas"]))
palette.setColor(QPalette.ColorRole.WindowText, QColor(COLORS["text"]))
palette.setColor(QPalette.ColorRole.Base, QColor("#FFFFFF"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(COLORS["surface_alt"]))
palette.setColor(QPalette.ColorRole.Text, QColor(COLORS["text"]))
palette.setColor(QPalette.ColorRole.Button, QColor(COLORS["surface"]))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(COLORS["text"]))
palette.setColor(QPalette.ColorRole.Highlight, QColor(COLORS["teal_pale"]))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(COLORS["ink"]))
palette.setColor(QPalette.ColorRole.PlaceholderText, QColor("#8A958F"))
app.setPalette(palette)
app.setStyleSheet(GLOBAL_QSS)
__all__ = ["COLORS", "GLOBAL_QSS", "apply_theme"]
+687
View File
@@ -0,0 +1,687 @@
"""Shared widgets and safe asynchronous helpers for the UI layer."""
from __future__ import annotations
import inspect
import traceback
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
from PySide6.QtGui import QResizeEvent
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QLabel,
QProgressBar,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from doctor_workstation.core.errors import AuthenticationExpiredError
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
def set_authentication_expired_handler(
handler: AuthenticationExpiredHandler | None,
) -> None:
"""Install the application-level expired-session callback.
The callback returns ``True`` when it consumed the error. Returning
``False`` preserves the originating operation's local error handling, as is
required when authentication itself fails on the login screen.
"""
global _AUTHENTICATION_EXPIRED_HANDLER
_AUTHENTICATION_EXPIRED_HANDLER = handler
def get_value(value: Any, key: str, default: Any = None) -> Any:
"""Read a dotted key from mappings, dataclasses, or ordinary objects."""
current = value
for part in key.split("."):
if current is None:
return default
if isinstance(current, Mapping):
current = current.get(part, default)
else:
marker = object()
candidate = getattr(current, part, marker)
if candidate is marker:
raw = getattr(current, "raw", None)
candidate = raw.get(part, marker) if isinstance(raw, Mapping) else marker
current = default if candidate is marker else candidate
if current is default:
return default
return current
def first_value(value: Any, *keys: str, default: Any = None) -> Any:
"""Return the first present, non-empty value from ``keys``."""
for key in keys:
candidate = get_value(value, key, None)
if candidate is not None and candidate != "":
return candidate
return default
def display_text(value: Any, default: str = "") -> str:
if value is None or value == "":
return default
if isinstance(value, bool):
return "" if value else ""
if isinstance(value, (datetime, date)):
return value.strftime("%Y-%m-%d %H:%M" if isinstance(value, datetime) else "%Y-%m-%d")
return str(value)
def gender_text(value: Any, default: str = "") -> str:
"""Format the legacy gender codes without exposing numeric API values."""
normalized = str(value).strip().lower() if value not in (None, "") else ""
labels = {
"0": "未知",
"1": "",
"2": "",
"m": "",
"male": "",
"f": "",
"female": "",
"unknown": "未知",
}
return labels.get(normalized, display_text(value, default))
def page_items(result: Any) -> list[Any]:
"""Extract rows from common PageResult/dict response shapes."""
if result is None:
return []
if isinstance(result, (list, tuple)):
return list(result)
for key in ("items", "lists", "results", "rows", "data"):
items = get_value(result, key, None)
if isinstance(items, (list, tuple)):
return list(items)
if key == "data" and items is not None and items is not result:
nested = page_items(items)
if nested:
return nested
return []
def page_total(result: Any, fallback: int = 0) -> int:
for key in ("total", "count", "total_count", "data.total", "data.count"):
value = get_value(result, key, None)
if value is not None:
try:
return int(value)
except (TypeError, ValueError):
pass
return fallback
def has_permission(permissions: Any, codes: str | Sequence[str], default: bool = True) -> bool:
"""Check canonical permissions with exact and resource-wildcard semantics.
A sequence uses OR semantics, matching the admin client's route guards.
Permission names are opaque: ``resource/action`` never aliases
``resource.action``.
"""
if permissions is None:
return default
requested = tuple(
code.strip()
for code in ((codes,) if isinstance(codes, str) else tuple(codes))
if code and code.strip()
)
if not requested:
return True
for method_name in ("allows", "has", "can", "contains", "has_permission"):
method = getattr(permissions, method_name, None)
if callable(method):
for code in requested:
try:
if bool(method(code)):
return True
except (TypeError, ValueError):
continue
raw = permissions
for attr in ("codes", "permissions", "values"):
candidate = getattr(permissions, attr, None)
if candidate is not None and not callable(candidate):
raw = candidate
break
if isinstance(raw, Mapping):
available = {str(key).strip() for key, enabled in raw.items() if enabled}
elif isinstance(raw, str):
available = {raw}
else:
try:
available = {str(item).strip() for item in raw}
except TypeError:
return default
return any(
"*" in available
or code in available
or any(grant.endswith("/*") and code.startswith(grant[:-1]) for grant in available)
for code in requested
)
def invoke(repository: Any, method_name: str, /, **kwargs: Any) -> Any:
"""Invoke a repository method with keyword filtering for contract tolerance."""
method = getattr(repository, method_name, None)
if method is None and method_name == "save_prescription_template":
template = kwargs.get("template") or {}
template_id = kwargs.get("template_id", kwargs.get("id"))
if template_id is None:
creator = (
getattr(repository, "create_prescription_template", None)
or repository.add_prescription_template
)
return creator(template=template)
updater = (
getattr(repository, "update_prescription_template", None)
or repository.edit_prescription_template
)
return updater(template_id, changes=template)
aliases = {
"reception_queue": "list_appointments",
"reception_detail": "get_reception",
"prescription_library": "list_prescription_templates",
"prescriptions": "list_prescriptions",
"prescription_detail": "get_prescription",
"patients": "list_patients",
"consultations": "list_consultations",
}
resolved_name = method_name
if method is None:
resolved_name = aliases.get(method_name, method_name)
method = getattr(repository, resolved_name, None)
if method is None and method_name == "reception_detail":
resolved_name = "reception"
method = getattr(repository, resolved_name)
if method is None:
raise AttributeError(f"repository has no method {method_name!r}")
call_kwargs = dict(kwargs)
if resolved_name.startswith("list_") and "page" in call_kwargs and "page_no" not in call_kwargs:
call_kwargs["page_no"] = call_kwargs.pop("page")
if (
resolved_name == "get_prescription"
and "id" in call_kwargs
and "prescription_id" not in call_kwargs
):
call_kwargs["prescription_id"] = call_kwargs.pop("id")
try:
signature = inspect.signature(method)
except (TypeError, ValueError):
return method(**call_kwargs)
parameters = signature.parameters
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
return method(**call_kwargs)
accepted = {
name: value
for name, value in call_kwargs.items()
if name in parameters
and parameters[name].kind
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
}
if len(parameters) == 1 and not accepted:
only = next(iter(parameters.values()))
if only.name in {"payload", "data", "query", "filters", "params"}:
return method(call_kwargs)
return method(**accepted)
class WorkerSignals(QObject):
result = Signal(object)
error = Signal(object, str)
finished = Signal()
class Worker(QRunnable):
"""A small QRunnable that marshals results back through Qt signals."""
def __init__(self, function: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
super().__init__()
self.function = function
self.args = args
self.kwargs = kwargs
self.signals = WorkerSignals()
@Slot()
def run(self) -> None:
try:
result = self.function(*self.args, **self.kwargs)
except Exception as exc: # UI boundary: report domain and transport errors alike.
self.signals.error.emit(exc, traceback.format_exc())
else:
self.signals.result.emit(result)
finally:
self.signals.finished.emit()
_RUNNING_WORKERS: set[Worker] = set()
def _dispatch_async_error(
error: Exception,
local_handler: Callable[[Exception], None] | None,
) -> None:
"""Route session expiry globally before falling back to a page handler."""
handled = False
if isinstance(error, AuthenticationExpiredError):
handler = _AUTHENTICATION_EXPIRED_HANDLER
if handler is not None:
try:
handled = bool(handler(error))
except Exception:
traceback.print_exc()
if not handled and local_handler is not None:
local_handler(error)
def run_async(
function: Callable[..., Any],
*args: Any,
on_success: Callable[[Any], None] | None = None,
on_error: Callable[[Exception], None] | None = None,
on_finished: Callable[[], None] | None = None,
pool: QThreadPool | None = None,
**kwargs: Any,
) -> Worker:
"""Run ``function`` off the GUI thread and return its Worker handle."""
worker = Worker(function, *args, **kwargs)
_RUNNING_WORKERS.add(worker)
if on_success is not None:
worker.signals.result.connect(on_success)
worker.signals.error.connect(lambda exc, _tb: _dispatch_async_error(exc, on_error))
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)
return worker
def friendly_error(error: Any) -> str:
text = str(error).strip()
return text or "操作未完成,请稍后重试。"
class PageHeader(QWidget):
"""Consistent title, subtitle, and action area for business pages."""
def __init__(
self,
title: str,
subtitle: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(16)
text_layout = QVBoxLayout()
text_layout.setSpacing(3)
self.title_label = QLabel(title)
self.title_label.setProperty("role", "pageTitle")
text_layout.addWidget(self.title_label)
self.subtitle_label = QLabel(subtitle)
self.subtitle_label.setProperty("role", "muted")
self.subtitle_label.setWordWrap(True)
self.subtitle_label.setVisible(bool(subtitle))
text_layout.addWidget(self.subtitle_label)
layout.addLayout(text_layout, 1)
self.actions = QHBoxLayout()
self.actions.setSpacing(8)
layout.addLayout(self.actions)
def add_action(self, widget: QWidget) -> QWidget:
self.actions.addWidget(widget)
return widget
def set_subtitle(self, text: str) -> None:
self.subtitle_label.setText(text)
self.subtitle_label.setVisible(bool(text))
class StatusBadge(QLabel):
def __init__(
self, text: str = "", kind: str = "neutral", parent: QWidget | None = None
) -> None:
super().__init__(text, parent)
self.setObjectName("StatusBadge")
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self.set_kind(kind)
def set_kind(self, kind: str) -> None:
self.setProperty("kind", kind)
self.style().unpolish(self)
self.style().polish(self)
def set_status(self, text: str, kind: str = "neutral") -> None:
self.setText(text)
self.set_kind(kind)
class EmptyState(QWidget):
action_requested = Signal()
def __init__(
self,
title: str = "暂无数据",
description: str = "调整筛选条件后再试试。",
action_text: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 44, 24, 44)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(8)
glyph = QLabel("")
glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
glyph.setStyleSheet("font-size: 30px; color: #9DAEA5;")
layout.addWidget(glyph)
title_label = QLabel(title)
title_label.setProperty("role", "sectionTitle")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
description_label = QLabel(description)
description_label.setProperty("role", "muted")
description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
description_label.setWordWrap(True)
layout.addWidget(description_label)
self.action_button = QPushButton(action_text)
self.action_button.setProperty("variant", "secondary")
self.action_button.setVisible(bool(action_text))
self.action_button.clicked.connect(self.action_requested)
layout.addWidget(self.action_button, 0, Qt.AlignmentFlag.AlignCenter)
class MessageBanner(QFrame):
def __init__(self, text: str = "", kind: str = "info", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("MessageBanner")
self.setProperty("kind", kind)
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 9, 12, 9)
layout.setSpacing(9)
self.icon = QLabel("i")
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.icon.setFixedSize(20, 20)
self.icon.setStyleSheet("font-weight: 700;")
self.label = QLabel(text)
self.label.setWordWrap(True)
layout.addWidget(self.icon)
layout.addWidget(self.label, 1)
self.setVisible(bool(text))
def show_message(self, text: str, kind: str = "info") -> None:
glyphs = {"info": "i", "success": "", "warning": "!", "danger": "!"}
self.label.setText(text)
self.icon.setText(glyphs.get(kind, "i"))
self.setProperty("kind", kind)
self.style().unpolish(self)
self.style().polish(self)
self.setVisible(bool(text))
def clear(self) -> None:
self.setVisible(False)
self.label.clear()
class Toast(QLabel):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.setObjectName("Toast")
self.setWordWrap(True)
self.setMaximumWidth(420)
self.hide()
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.hide)
def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None:
self.setText(text)
self.setProperty("kind", kind)
self.style().unpolish(self)
self.style().polish(self)
self.adjustSize()
parent = self.parentWidget()
if parent is not None:
self.move(max(16, parent.width() - self.width() - 24), 20)
self.raise_()
self.show()
self._timer.start(duration)
def show_toast(parent: QWidget, text: str, kind: str = "info", duration: int = 2800) -> None:
window = parent.window()
toast = getattr(window, "_doctor_workstation_toast", None)
if not isinstance(toast, Toast):
toast = Toast(window)
window._doctor_workstation_toast = toast
toast.show_message(text, kind, duration)
class BusyOverlay(QFrame):
"""Non-blocking visual guard for a card or page while a worker is active."""
def __init__(self, parent: QWidget, text: str = "正在加载…") -> None:
super().__init__(parent)
self.setObjectName("BusyOverlay")
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(10)
self.label = QLabel(text)
self.label.setProperty("role", "muted")
progress = QProgressBar()
progress.setRange(0, 0)
progress.setFixedWidth(140)
layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter)
layout.addWidget(progress, 0, Qt.AlignmentFlag.AlignCenter)
self.hide()
def set_message(self, text: str) -> None:
self.label.setText(text)
def showEvent(self, event: Any) -> None:
self.setGeometry(self.parentWidget().rect())
self.raise_()
super().showEvent(event)
class OverlayHost(QWidget):
"""Widget base that automatically sizes a BusyOverlay child."""
def resizeEvent(self, event: QResizeEvent) -> None:
overlay = getattr(self, "busy_overlay", None)
if isinstance(overlay, BusyOverlay):
overlay.setGeometry(self.rect())
super().resizeEvent(event)
@dataclass(frozen=True)
class TableColumn:
key: str
title: str
width: int = 0
formatter: Callable[[Any, Any], str] | None = None
alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
class SortableTable(QTableWidget):
"""A QTableWidget that safely retains the source object after sorting."""
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.columns = list(columns)
self.setColumnCount(len(self.columns))
self.setHorizontalHeaderLabels([column.title for column in self.columns])
self.setAlternatingRowColors(True)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setSortingEnabled(True)
self.verticalHeader().setVisible(False)
self.horizontalHeader().setStretchLastSection(True)
for index, column in enumerate(self.columns):
if column.width:
self.setColumnWidth(index, column.width)
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "appointment_id", default=None)
self.setSortingEnabled(False)
self.clearContents()
materialized = list(rows)
self.setRowCount(len(materialized))
row_to_select = -1
for row_index, row in enumerate(materialized):
row_id = first_value(row, "id", "appointment_id", default=None)
if selected_id is not None and row_id == selected_id:
row_to_select = row_index
for column_index, column in enumerate(self.columns):
raw = get_value(row, column.key, None)
text = column.formatter(raw, row) if column.formatter else display_text(raw)
item = QTableWidgetItem(text)
item.setTextAlignment(column.alignment)
item.setData(Qt.ItemDataRole.UserRole, row)
item.setToolTip(text if len(text) > 18 else "")
self.setItem(row_index, column_index, item)
self.setSortingEnabled(True)
if row_to_select >= 0:
self.selectRow(row_to_select)
def current_data(self) -> Any:
row = self.currentRow()
if row < 0:
return None
item = self.item(row, 0)
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
class Pager(QWidget):
page_changed = Signal(int)
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 4, 0, 0)
layout.addStretch(1)
self.summary = QLabel("共 0 条")
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
self.previous = QPushButton("上一页")
self.previous.setProperty("variant", "ghost")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.page_label = QLabel("1 / 1")
self.page_label.setMinimumWidth(58)
self.page_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.page_label)
self.next = QPushButton("下一页")
self.next.setProperty("variant", "ghost")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.page = max(1, page)
self.total = max(0, total)
self.summary.setText(f"{self.total}")
self.page_label.setText(f"{self.page} / {self.page_count}")
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < self.page_count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
def card_layout(card: QFrame, margins: int = 18, spacing: int = 12) -> QVBoxLayout:
layout = QVBoxLayout(card)
layout.setContentsMargins(margins, margins, margins, margins)
layout.setSpacing(spacing)
return layout
def section_title(text: str, trailing: QWidget | None = None) -> QWidget:
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
title = QLabel(text)
title.setProperty("role", "sectionTitle")
layout.addWidget(title)
layout.addStretch(1)
if trailing is not None:
layout.addWidget(trailing)
return container
def clear_layout(layout: QVBoxLayout | QHBoxLayout) -> None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
child_layout = item.layout()
if widget is not None:
widget.deleteLater()
elif child_layout is not None:
clear_layout(child_layout) # type: ignore[arg-type]
__all__ = [
"BusyOverlay",
"EmptyState",
"MessageBanner",
"OverlayHost",
"PageHeader",
"Pager",
"SortableTable",
"StatusBadge",
"TableColumn",
"Toast",
"Worker",
"card_layout",
"clear_layout",
"display_text",
"first_value",
"friendly_error",
"get_value",
"has_permission",
"invoke",
"page_items",
"page_total",
"run_async",
"section_title",
"set_authentication_expired_handler",
"show_toast",
]
@@ -0,0 +1,23 @@
"""Optional video-call integration for the doctor workstation."""
from .launcher import (
BackendMode,
VideoCallLauncher,
VideoCallRequest,
VideoTicketError,
launch_video_call,
normalize_backend_ticket,
require_supported_backend,
)
from .lifecycle import OrderedCallLifecycle
__all__ = [
"BackendMode",
"OrderedCallLifecycle",
"VideoCallLauncher",
"VideoCallRequest",
"VideoTicketError",
"launch_video_call",
"normalize_backend_ticket",
"require_supported_backend",
]
@@ -0,0 +1,405 @@
"""Pure-Python contract and launcher for the optional video companion.
The backend is the only authority that may issue ``userSig``. This module
normalizes that short-lived ticket and deliberately keeps Qt imports out of the
contract layer so it remains importable in core-only installations and tests.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any
class VideoTicketError(ValueError):
"""Raised when a backend video ticket is incomplete or unsafe."""
class BackendMode(StrEnum):
"""Supported rendering backends for a video call."""
EMBEDDED = "embedded"
BROWSER = "browser"
@classmethod
def parse(cls, value: BackendMode | str) -> BackendMode:
if isinstance(value, cls):
return value
try:
return cls(str(value).strip().lower())
except ValueError as exc:
raise VideoTicketError("backend mode must be 'embedded' or 'browser'") from exc
def require_supported_backend(value: BackendMode | str) -> BackendMode:
"""Reject browser launch until a server-issued one-time handoff exists."""
mode = BackendMode.parse(value)
if mode is BackendMode.BROWSER:
raise VideoTicketError(
"browser video mode is disabled until a server-issued one-time handoff is available"
)
return mode
Identifier = int | str
def _identifier(value: Any, field_name: str) -> Identifier:
if isinstance(value, bool) or value is None:
raise VideoTicketError(f"{field_name} must be a non-empty identifier")
if isinstance(value, int):
if value <= 0:
raise VideoTicketError(f"{field_name} must be a positive identifier")
return value
if isinstance(value, str):
cleaned = value.strip()
if not cleaned:
raise VideoTicketError(f"{field_name} must be a non-empty identifier")
return cleaned
raise VideoTicketError(f"{field_name} must be a string or integer")
def _non_empty_string(value: Any, field_name: str) -> str:
if not isinstance(value, str) or not value.strip():
raise VideoTicketError(f"{field_name} must be a non-empty string")
return value.strip()
def _sdk_app_id(value: Any, field_name: str = "SDKAppID") -> int:
if isinstance(value, bool):
raise VideoTicketError(f"{field_name} must be a positive integer")
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise VideoTicketError(f"{field_name} must be a positive integer") from exc
if parsed <= 0 or str(value).strip() != str(parsed):
raise VideoTicketError(f"{field_name} must be a positive integer")
return parsed
def _normalized_key(key: Any) -> str:
return "".join(character for character in str(key).lower() if character.isalnum())
_FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"}
def _reject_server_secrets(payload: Mapping[str, Any]) -> None:
for key in payload:
if _normalized_key(key) in _FORBIDDEN_SECRET_KEYS:
raise VideoTicketError("backend ticket contains forbidden server-side secret material")
def _contains_ticket_fields(payload: Mapping[str, Any]) -> bool:
keys = {_normalized_key(key) for key in payload}
return bool(keys & {"sdkappid", "userid", "usersig", "targetuserid", "patientuserid"})
def _ticket_payload(ticket: Mapping[str, Any]) -> Mapping[str, Any]:
_reject_server_secrets(ticket)
if _contains_ticket_fields(ticket):
return ticket
for envelope_key in ("data", "result", "ticket"):
nested = ticket.get(envelope_key)
if isinstance(nested, Mapping):
_reject_server_secrets(nested)
if _contains_ticket_fields(nested):
return nested
return ticket
def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
"""Adapt the repository's CallTicket model without importing core models."""
if isinstance(ticket, Mapping):
return ticket
raw = getattr(ticket, "raw", None)
if isinstance(raw, Mapping):
_reject_server_secrets(raw)
attribute_aliases = {
"sdkAppId": "sdk_app_id",
"userId": "user_id",
"userSig": "user_sig",
"patientUserId": "patient_user_id",
"diagnosisId": "diagnosis_id",
"patientId": "patient_id",
}
adapted = {
json_name: getattr(ticket, attribute_name)
for json_name, attribute_name in attribute_aliases.items()
if hasattr(ticket, attribute_name)
}
if adapted:
return adapted
raise VideoTicketError("backend ticket must be a mapping or call-ticket object")
def _read_aliases(
payload: Mapping[str, Any],
aliases: tuple[str, ...],
field_name: str,
converter: Callable[[Any, str], Any],
*,
required: bool = True,
) -> Any:
converted: list[Any] = []
for alias in aliases:
if alias in payload and payload[alias] is not None:
converted.append(converter(payload[alias], field_name))
if not converted:
if required:
raise VideoTicketError(f"backend ticket is missing {field_name}")
return None
if any(value != converted[0] and str(value) != str(converted[0]) for value in converted[1:]):
raise VideoTicketError(f"backend ticket has conflicting {field_name} aliases")
return converted[0]
def _merge_identifier(
payload_value: Identifier | None,
explicit_value: Any,
field_name: str,
) -> Identifier:
if explicit_value is None:
if payload_value is None:
raise VideoTicketError(f"backend ticket is missing {field_name}")
return payload_value
normalized = _identifier(explicit_value, field_name)
if (
payload_value is not None
and payload_value != normalized
and str(payload_value) != str(normalized)
):
raise VideoTicketError(f"backend ticket conflicts with requested {field_name}")
return normalized
@dataclass(frozen=True, slots=True)
class VideoCallRequest:
"""Validated data required to start one doctor-to-patient video call.
``user_sig`` is excluded from ``repr``. Use :meth:`safe_log_context` for
structured logs; never serialize the dataclass itself into diagnostics.
"""
sdk_app_id: int
user_id: str
user_sig: str = field(repr=False)
target_user_id: str
diagnosis_id: Identifier
patient_id: Identifier | None = None
backend_mode: BackendMode = BackendMode.EMBEDDED
def __post_init__(self) -> None:
object.__setattr__(self, "sdk_app_id", _sdk_app_id(self.sdk_app_id))
object.__setattr__(self, "user_id", _non_empty_string(self.user_id, "userID"))
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
object.__setattr__(
self,
"target_user_id",
_non_empty_string(self.target_user_id, "targetUserId"),
)
object.__setattr__(
self,
"diagnosis_id",
_identifier(self.diagnosis_id, "diagnosisId"),
)
if self.patient_id is not None:
object.__setattr__(
self,
"patient_id",
_identifier(self.patient_id, "patientId"),
)
object.__setattr__(self, "backend_mode", BackendMode.parse(self.backend_mode))
@classmethod
def from_backend_ticket(
cls,
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
) -> VideoCallRequest:
return normalize_backend_ticket(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
backend_mode=backend_mode,
)
def to_web_config(self) -> dict[str, Any]:
"""Return the canonical JavaScript bridge payload.
The returned mapping contains the short-lived credential and therefore
must only be passed in memory to the trusted companion page.
"""
return {
"SDKAppID": self.sdk_app_id,
"userID": self.user_id,
"userSig": self.user_sig,
"targetUserId": self.target_user_id,
"diagnosisId": self.diagnosis_id,
}
def safe_log_context(self) -> dict[str, Any]:
"""Return non-secret call metadata suitable for structured logging."""
return {
"diagnosis_id": self.diagnosis_id,
"patient_id": self.patient_id,
"backend_mode": self.backend_mode.value,
}
def normalize_backend_ticket(
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
) -> VideoCallRequest:
"""Normalize backend camel-case aliases into a validated call request."""
payload = _ticket_payload(_ticket_mapping(ticket))
payload_diagnosis = _read_aliases(
payload,
("diagnosisId", "diagnosis_id"),
"diagnosisId",
_identifier,
required=False,
)
payload_patient = _read_aliases(
payload,
("patientId", "patient_id"),
"patientId",
_identifier,
required=False,
)
normalized_diagnosis = _merge_identifier(
payload_diagnosis,
diagnosis_id,
"diagnosisId",
)
if patient_id is not None:
normalized_patient = _merge_identifier(payload_patient, patient_id, "patientId")
else:
normalized_patient = payload_patient
return VideoCallRequest(
sdk_app_id=_read_aliases(
payload,
("SDKAppID", "sdkAppId", "sdkAppID"),
"SDKAppID",
_sdk_app_id,
),
user_id=_read_aliases(
payload,
("userID", "userId"),
"userID",
_non_empty_string,
),
user_sig=_read_aliases(
payload,
("userSig", "user_sig"),
"userSig",
_non_empty_string,
),
target_user_id=_read_aliases(
payload,
("targetUserId", "patientUserId"),
"targetUserId",
_non_empty_string,
),
diagnosis_id=normalized_diagnosis,
patient_id=normalized_patient,
backend_mode=BackendMode.parse(backend_mode),
)
@dataclass(slots=True)
class VideoCallLauncher:
"""Small composition root that defers the optional Qt import until launch."""
repository: Any
backend_mode: BackendMode | str = BackendMode.EMBEDDED
local_dist: str | Path | None = None
remote_url: str | None = None
logger: Any = None
browser_opener: Callable[[str], bool] | None = None
def prepare(
self,
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
) -> VideoCallRequest:
require_supported_backend(self.backend_mode)
return normalize_backend_ticket(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
backend_mode=self.backend_mode,
)
def launch(
self,
ticket: Any,
*,
diagnosis_id: Any = None,
patient_id: Any = None,
) -> Any:
request = self.prepare(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
)
from .window import open_video_call
return open_video_call(
request,
repository=self.repository,
local_dist=self.local_dist,
remote_url=self.remote_url,
logger=self.logger,
browser_opener=self.browser_opener,
)
def launch_video_call(
ticket: Any,
*,
repository: Any,
diagnosis_id: Any = None,
patient_id: Any = None,
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: Any = None,
browser_opener: Callable[[str], bool] | None = None,
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
return VideoCallLauncher(
repository=repository,
backend_mode=backend_mode,
local_dist=local_dist,
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
).launch(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
)
@@ -0,0 +1,307 @@
"""Ordered, non-blocking backend lifecycle writes for one video call.
The repository uses synchronous HTTP. A dedicated daemon worker keeps
``start_call -> bind_call_room -> end_call`` ordered without ever blocking the
Qt GUI thread. Only non-secret call metadata is logged.
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import queue
import threading
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from dataclasses import dataclass
from typing import Any, TypeVar
from .launcher import VideoCallRequest
ResultT = TypeVar("ResultT")
def _settled_future(value: ResultT) -> Future[ResultT]:
future: Future[ResultT] = Future()
future.set_result(value)
return future
def _resolve_result(result: Any) -> Any:
if inspect.isawaitable(result):
return asyncio.run(result)
return result
def _call_repository_method(method: Callable[..., Any], payload: Mapping[str, Any]) -> Any:
"""Invoke common repository signatures with a non-secret payload only."""
try:
signature = inspect.signature(method)
except (TypeError, ValueError):
return _resolve_result(method(**payload))
parameters = list(signature.parameters.values())
if any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters):
return _resolve_result(method(**payload))
keyword_names = {
parameter.name
for parameter in parameters
if parameter.kind
in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
}
accepted = {key: value for key, value in payload.items() if key in keyword_names}
required = [
parameter
for parameter in parameters
if parameter.default is inspect.Parameter.empty
and parameter.kind
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
if len(parameters) == 1 and not accepted:
result = method(dict(payload))
elif any(parameter.kind is inspect.Parameter.POSITIONAL_ONLY for parameter in required):
missing = [parameter.name for parameter in required if parameter.name not in payload]
if missing:
raise TypeError("repository method requires unsupported positional parameters")
ordered = [payload[parameter.name] for parameter in required]
result = method(*ordered, **accepted)
else:
result = method(**accepted)
return _resolve_result(result)
@dataclass(slots=True)
class _WorkItem:
operation: str
callback: Callable[[], Any]
future: Future[Any]
class _OrderedDaemonWorker:
"""A minimal FIFO executor with timeout-aware idle observation."""
def __init__(self, logger: logging.Logger, log_context: Mapping[str, Any]) -> None:
self._logger = logger
self._log_context = dict(log_context)
self._queue: queue.Queue[_WorkItem | None] = queue.Queue()
self._lock = threading.Lock()
self._idle = threading.Event()
self._idle.set()
self._pending = 0
self._stopping = False
self._thread = threading.Thread(
target=self._run,
name="video-call-lifecycle",
daemon=True,
)
self._thread.start()
@property
def is_daemon(self) -> bool:
return self._thread.daemon
def submit(self, operation: str, callback: Callable[[], ResultT]) -> Future[ResultT]:
with self._lock:
if self._stopping:
raise RuntimeError("video lifecycle worker is stopping")
future: Future[ResultT] = Future()
self._pending += 1
self._idle.clear()
self._queue.put(_WorkItem(operation, callback, future))
return future
def stop_when_idle(self) -> None:
with self._lock:
if self._stopping:
return
self._stopping = True
self._queue.put(None)
def wait(self, timeout: float = 0.25) -> bool:
bounded = max(0.0, min(float(timeout), 5.0))
return self._idle.wait(bounded)
def _run(self) -> None:
while True:
item = self._queue.get()
if item is None:
self._queue.task_done()
return
try:
if item.future.set_running_or_notify_cancel():
try:
item.future.set_result(item.callback())
except BaseException as error:
item.future.set_exception(error)
self._logger.error(
"video lifecycle operation failed",
extra={
"video_call": self._log_context,
"operation": item.operation,
"error_type": type(error).__name__,
},
)
finally:
with self._lock:
self._pending -= 1
if self._pending == 0:
self._idle.set()
self._queue.task_done()
class OrderedCallLifecycle:
"""Idempotent lifecycle state machine backed by one FIFO daemon worker."""
def __init__(
self,
request: VideoCallRequest,
repository: Any,
logger: logging.Logger,
) -> None:
if repository is None:
raise ValueError("a video call repository is required")
self.request = request
self.repository = repository
self.logger = logger
self.started = False
self.ended = False
self.bound_room_id: str | None = None
self._claimed_room_id: str | None = None
self._start_future: Future[bool] | None = None
self._bind_future: Future[bool] | None = None
self._end_future: Future[bool] | None = None
self._lock = threading.RLock()
self._worker = _OrderedDaemonWorker(logger, request.safe_log_context())
@property
def worker_is_daemon(self) -> bool:
return self._worker.is_daemon
def start(self) -> Future[bool]:
with self._lock:
if self._start_future is not None:
return self._start_future
method = getattr(self.repository, "start_call", None)
if not callable(method):
raise ValueError("video repository does not implement start_call")
payload: dict[str, Any] = {
"diagnosis_id": self.request.diagnosis_id,
"call_type": 2,
}
if self.request.patient_id is not None:
payload["patient_id"] = self.request.patient_id
def operation() -> bool:
_call_repository_method(method, payload)
with self._lock:
self.started = True
self.logger.info(
"video call record started",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._start_future = self._worker.submit("start", operation)
return self._start_future
def bind_room(self, room_id: Any) -> Future[bool]:
cleaned = str(room_id or "").strip()
if not cleaned or cleaned == "0":
return _settled_future(False)
with self._lock:
if self._end_future is not None:
return _settled_future(False)
if self.bound_room_id:
if self.bound_room_id != cleaned:
self.logger.warning(
"ignoring a changed TRTC room identifier",
extra={"video_call": self.request.safe_log_context()},
)
return _settled_future(self.bound_room_id == cleaned)
if self._claimed_room_id:
if self._claimed_room_id != cleaned:
self.logger.warning(
"ignoring a changed TRTC room identifier",
extra={"video_call": self.request.safe_log_context()},
)
return _settled_future(False)
return self._bind_future or _settled_future(False)
if self._start_future is None:
self.start()
self._claimed_room_id = cleaned
method = getattr(self.repository, "bind_call_room", None)
def operation() -> bool:
with self._lock:
started = self.started
if not started:
return False
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(
method,
{"diagnosis_id": self.request.diagnosis_id, "room_id": cleaned},
)
with self._lock:
self.bound_room_id = cleaned
self.logger.info(
"TRTC room bound to video call record",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
def end(self, reason: str) -> Future[bool]:
with self._lock:
if self._end_future is not None:
return self._end_future
method = getattr(self.repository, "end_call", None)
def operation() -> bool:
with self._lock:
started = self.started
if not started:
with self._lock:
self.ended = True
return False
if not callable(method):
raise ValueError("video repository does not implement end_call")
_call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id},
)
with self._lock:
self.ended = True
self.logger.info(
"video call record ended",
extra={
"video_call": {
**self.request.safe_log_context(),
"reason": str(reason)[:80],
}
},
)
return True
self._end_future = self._worker.submit("end", operation)
self._end_future.add_done_callback(lambda _future: self._worker.stop_when_idle())
return self._end_future
def wait(self, timeout: float = 0.25) -> bool:
"""Wait for queued writes for at most five seconds; never waits indefinitely."""
return self._worker.wait(timeout)
__all__ = ["OrderedCallLifecycle"]
@@ -0,0 +1,109 @@
"""Pure-Python trust policy for the embedded video companion document."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import unquote, urlsplit
from urllib.request import url2pathname
class TrustedDocumentError(ValueError):
"""Raised when a companion location cannot form a safe allowlist."""
def _normalized_host(host: str | None) -> str:
if not host:
return ""
try:
return host.encode("idna").decode("ascii").lower()
except UnicodeError:
return host.lower()
def _normalized_file_path(value: str) -> str | None:
parsed = urlsplit(value)
if parsed.scheme.lower() != "file" or parsed.netloc not in {"", "localhost"}:
return None
path = Path(url2pathname(unquote(parsed.path))).resolve()
rendered = str(path)
return rendered.casefold() if os.name == "nt" else rendered
@dataclass(frozen=True, slots=True)
class TrustedDocumentPolicy:
"""Exact main-document allowlist plus origin matching for media grants."""
scheme: str
host: str
port: int | None
path: str
query: str
local_path: str | None = None
@classmethod
def from_url(cls, url: str, *, is_local: bool) -> TrustedDocumentPolicy:
parsed = urlsplit(url)
scheme = parsed.scheme.lower()
if is_local:
local_path = _normalized_file_path(url)
if local_path is None:
raise TrustedDocumentError("local video companion must be a file URL")
return cls("file", "", None, parsed.path, parsed.query, local_path)
if scheme != "https" or not parsed.hostname:
raise TrustedDocumentError("remote embedded video companion must use HTTPS")
if parsed.username or parsed.password:
raise TrustedDocumentError("remote embedded video companion must not use credentials")
try:
port = parsed.port or 443
except ValueError as error:
raise TrustedDocumentError(
"remote embedded video companion has an invalid port"
) from error
return cls(
"https",
_normalized_host(parsed.hostname),
port,
parsed.path or "/",
parsed.query,
)
def allows_main_document(self, candidate: str) -> bool:
"""Allow only the configured file or HTTPS document, including its query."""
parsed = urlsplit(candidate)
if self.scheme == "file":
return (
parsed.query == self.query and _normalized_file_path(candidate) == self.local_path
)
try:
port = parsed.port or (443 if parsed.scheme.lower() == "https" else None)
except ValueError:
return False
return (
parsed.scheme.lower() == self.scheme
and _normalized_host(parsed.hostname) == self.host
and port == self.port
and (parsed.path or "/") == self.path
and parsed.query == self.query
)
def allows_origin(self, candidate: str) -> bool:
"""Match only the origin that supplied the trusted main document."""
parsed = urlsplit(candidate)
if self.scheme == "file":
return parsed.scheme.lower() == "file" and parsed.netloc in {"", "localhost"}
try:
port = parsed.port or (443 if parsed.scheme.lower() == "https" else None)
except ValueError:
return False
return (
parsed.scheme.lower() == self.scheme
and _normalized_host(parsed.hostname) == self.host
and port == self.port
)
__all__ = ["TrustedDocumentError", "TrustedDocumentPolicy"]
+585
View File
@@ -0,0 +1,585 @@
"""Hardened QtWebEngine host for the video companion.
Browser launch is intentionally disabled until the backend provides a
single-use handoff ticket. PySide6 remains optional at import time, while an
actual call requires an isolated QtWebEngine profile and an active QApplication.
"""
from __future__ import annotations
import json
import logging
import sys
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
from .launcher import (
VideoCallRequest,
VideoTicketError,
require_supported_backend,
)
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.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import (
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
)
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]
QWebChannel = QWebEnginePage = QWebEngineProfile = None # type: ignore[assignment]
QWebEngineSettings = QWebEngineView = None # type: ignore[assignment]
QApplication = QMainWindow = None # type: ignore[assignment]
_WEBENGINE_IMPORT_ERROR: Exception | None = _qt_import_error
else: # pragma: no cover - requires a GUI runtime.
_WEBENGINE_IMPORT_ERROR = None
WEBENGINE_AVAILABLE = _WEBENGINE_IMPORT_ERROR is None
_LOGGER = logging.getLogger(__name__)
_SENSITIVE_QUERY_KEYS = {"usersig", "sdksecret", "sdksecretkey", "secretkey"}
class VideoWindowError(RuntimeError):
"""Raised when the trusted embedded companion cannot be opened."""
@dataclass(frozen=True, slots=True)
class CompanionLocation:
url: str
is_local: bool
def _validate_remote_url(value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise VideoWindowError("remote video companion URL must use HTTPS")
if parsed.username or parsed.password:
raise VideoWindowError("remote video companion URL must not contain credentials")
url_parameter_keys = {
"".join(character for character in key.lower() if character.isalnum())
for key, _ in (*parse_qsl(parsed.query), *parse_qsl(parsed.fragment))
}
if url_parameter_keys & _SENSITIVE_QUERY_KEYS:
raise VideoWindowError("remote video companion URL must not contain RTC credentials")
return value
def _candidate_index(local_dist: str | Path) -> Path:
candidate = Path(local_dist).expanduser().resolve()
return candidate if candidate.name.lower() == "index.html" else candidate / "index.html"
def _default_local_indexes() -> tuple[Path, ...]:
candidates: list[Path] = []
bundle_root = getattr(sys, "_MEIPASS", None)
if bundle_root:
candidates.append(Path(bundle_root) / "video_companion_dist" / "index.html")
project_root = Path(__file__).resolve().parents[3]
candidates.append(project_root / "video_companion" / "dist" / "index.html")
return tuple(candidates)
def resolve_companion_location(
*,
local_dist: str | Path | None = None,
remote_url: str | None = None,
) -> CompanionLocation:
"""Resolve the trusted document used inside QtWebEngine."""
indexes = (
(_candidate_index(local_dist),) if local_dist is not None else _default_local_indexes()
)
for index in indexes:
if index.is_file():
return CompanionLocation(index.as_uri(), is_local=True)
if remote_url:
return CompanionLocation(_validate_remote_url(remote_url), is_local=False)
raise VideoWindowError(
"video companion is unavailable: build video_companion/dist or configure an HTTPS URL"
)
def webengine_unavailable_reason() -> str | None:
"""Return a non-sensitive diagnostic reason without importing Qt again."""
if _WEBENGINE_IMPORT_ERROR is None:
return None
return f"{type(_WEBENGINE_IMPORT_ERROR).__name__}: QtWebEngine is not installed"
if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration test.
class _RestrictedWebEnginePage(QWebEnginePage): # type: ignore[misc, valid-type]
def __init__(
self,
profile: Any,
policy: TrustedDocumentPolicy,
logger: logging.Logger,
parent: Any,
) -> None:
super().__init__(profile, parent)
self._policy = policy
self._logger = logger
self._shutting_down = False
def begin_shutdown(self) -> None:
self._shutting_down = True
def acceptNavigationRequest(
self,
url: Any,
navigation_type: Any,
is_main_frame: bool,
) -> bool:
del navigation_type
if not is_main_frame:
return True
rendered = url.toString()
if self._shutting_down and rendered == "about:blank":
return True
if self._policy.allows_main_document(rendered):
return True
self._logger.warning(
"blocked video companion main-document navigation",
extra={
"target_scheme": url.scheme(),
"target_host": url.host(),
},
)
return False
def createWindow(self, window_type: Any) -> Any:
del window_type
self._logger.warning("blocked video companion popup window")
return None
class _QtVideoBridge(QObject): # type: ignore[misc, valid-type]
def __init__(self, callback: Callable[[Mapping[str, Any]], None]) -> None:
super().__init__()
self._callback = callback
@Slot(str) # type: ignore[misc]
def notify(self, payload: str) -> None:
if not isinstance(payload, str) or len(payload) > 16_384:
return
try:
message = json.loads(payload)
except (TypeError, ValueError):
return
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
self._callback(message)
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]
def __init__(
self,
request: VideoCallRequest,
location: CompanionLocation,
lifecycle: OrderedCallLifecycle,
*,
logger: logging.Logger,
) -> None:
super().__init__()
self.request = request
self.location = location
self.lifecycle = lifecycle
self.logger = logger
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
is_local=location.is_local,
)
except TrustedDocumentError as error:
raise VideoWindowError(str(error)) from error
self._injected = False
self._media_active = False
self._closing = False
self._companion_ended = False
self._released = False
self._close_reason = "window-closed"
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self.setWindowTitle("视频面诊")
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.resize(1120, 760)
self.setMinimumSize(760, 520)
self._profile = QWebEngineProfile(self)
if not self._profile.isOffTheRecord():
raise VideoWindowError("video WebEngine profile must be off-the-record")
profile_policy = QWebEngineProfile.PersistentCookiesPolicy
cache_type = QWebEngineProfile.HttpCacheType
self._profile.setPersistentCookiesPolicy(profile_policy.NoPersistentCookies)
self._profile.setHttpCacheType(cache_type.MemoryHttpCache)
self._profile.downloadRequested.connect(self._deny_download)
self.web_view = QWebEngineView(self)
self._page = _RestrictedWebEnginePage(
self._profile,
self._policy,
self.logger,
self.web_view,
)
self.web_view.setPage(self._page)
self.setCentralWidget(self.web_view)
self._configure_settings(self._page.settings())
self._bridge = _QtVideoBridge(self._handle_bridge_message)
self._channel = QWebChannel(self._page)
self._channel.registerObject("qtVideoBridge", self._bridge)
self._page.setWebChannel(self._channel)
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
def _configure_settings(self, settings: Any) -> None:
attributes = getattr(QWebEngineSettings, "WebAttribute", QWebEngineSettings)
values = (
("LocalContentCanAccessRemoteUrls", self.location.is_local),
("PlaybackRequiresUserGesture", False),
("JavascriptCanOpenWindows", False),
("AllowRunningInsecureContent", False),
)
for name, enabled in values:
attribute = getattr(attributes, name, None)
if attribute is not None:
settings.setAttribute(attribute, enabled)
def _connect_permissions(self) -> None:
if hasattr(self._page, "featurePermissionRequested"):
self._page.featurePermissionRequested.connect(self._grant_legacy_media_permission)
if hasattr(self._page, "permissionRequested"):
self._page.permissionRequested.connect(self._grant_media_permission)
def _permission_context_is_trusted(self, origin: Any) -> bool:
if self._closing or self._released or not self._media_active:
return False
if not self._policy.allows_main_document(self._page.url().toString()):
return False
return self._policy.allows_origin(origin.toString())
def _grant_legacy_media_permission(self, origin: Any, feature: Any) -> None:
features = QWebEnginePage.Feature
allowed = {
features.MediaAudioCapture,
features.MediaVideoCapture,
features.MediaAudioVideoCapture,
}
policies = QWebEnginePage.PermissionPolicy
trusted = self._permission_context_is_trusted(origin) and feature in allowed
policy = (
policies.PermissionGrantedByUser if trusted else policies.PermissionDeniedByUser
)
self._page.setFeaturePermission(origin, feature, policy)
if trusted:
self._legacy_grants.append((origin, feature))
def _grant_media_permission(self, permission: Any) -> None:
permission_type = permission.permissionType()
allowed_names = {
"MediaAudioCapture",
"MediaVideoCapture",
"MediaAudioVideoCapture",
}
trusted = (
permission.isValid()
and permission_type.name in allowed_names
and self._permission_context_is_trusted(permission.origin())
)
if trusted:
permission.grant()
self._permission_grants.append(permission)
else:
permission.deny()
def _deny_download(self, download: Any) -> None:
download.cancel()
def _on_load_finished(self, succeeded: bool) -> None:
if self._closing:
return
if not succeeded:
self.logger.error(
"embedded video companion failed to load",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "page-load-failed"
self.close()
return
try:
start_future = self.lifecycle.start()
except Exception:
self.logger.error(
"video call record could not be queued",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "record-start-queue-failed"
self.close()
return
start_future.add_done_callback(self._notify_start_completed)
def _notify_start_completed(self, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
except Exception:
succeeded = False
with suppress(RuntimeError):
self._start_completed.emit(succeeded)
def _on_lifecycle_started(self, succeeded: bool) -> None:
if self._closing:
return
if not succeeded:
self._close_reason = "record-start-failed"
self.close()
return
self._media_active = True
config_json = json.dumps(
self.request.to_web_config(),
ensure_ascii=True,
separators=(",", ":"),
)
script = f"""
(() => {{
if (!window.doctorCall || typeof window.doctorCall.start !== 'function') {{
return false;
}}
void window.doctorCall.start({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _after_injection(self, result: Any) -> None:
self._injected = result is not False
if not self._injected:
self._close_reason = "bridge-api-missing"
self.close()
def _handle_bridge_message(self, message: Mapping[str, Any]) -> None:
if self._closing:
return
event = str(message.get("event", ""))
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
if event == "room":
return
if event == "status":
status = str(message.get("status", "unknown"))[:80]
self.status_changed.emit(status)
if status == "idle":
self._close_from_companion("remote-idle")
elif event == "hangup":
status = str(message.get("status", "ended"))[:80]
self.call_ended.emit(status)
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
self.call_error.emit(message_text)
self._close_from_companion("companion-error")
def _close_from_companion(self, reason: str) -> None:
self._companion_ended = True
self._close_reason = reason
self.close()
def hangup(self) -> None:
self._close_reason = "desktop-hangup"
self.close()
def _begin_shutdown(self) -> None:
if self._closing:
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.doctorCall?.hangup?.().catch(() => undefined)"
)
self.lifecycle.end(self._close_reason)
self._release_webengine()
def _release_webengine(self) -> None:
if self._released:
return
self._released = True
policies = QWebEnginePage.PermissionPolicy
for origin, feature in self._legacy_grants:
with suppress(RuntimeError):
self._page.setFeaturePermission(
origin,
feature,
policies.PermissionDeniedByUser,
)
self._legacy_grants.clear()
for permission in self._permission_grants:
try:
if permission.isValid():
permission.reset()
except RuntimeError:
pass
self._permission_grants.clear()
try:
self._channel.deregisterObject(self._bridge)
self._page.setWebChannel(None)
except RuntimeError:
pass
try:
self._profile.cookieStore().deleteAllCookies()
self._profile.clearHttpCache()
self._profile.clearAllVisitedLinks()
except RuntimeError:
pass
try:
self._page.begin_shutdown()
self._page.setUrl(QUrl("about:blank"))
except RuntimeError:
pass
view = self.takeCentralWidget()
if view is not None:
view.close()
view.deleteLater()
self._page.deleteLater()
self._profile.deleteLater()
def closeEvent(self, event: Any) -> None:
self._begin_shutdown()
event.accept()
else:
_EmbeddedVideoWindow = None # type: ignore[assignment, misc]
class VideoCallWindow:
"""Facade for the only currently supported backend: embedded QtWebEngine."""
def __init__(
self,
request: VideoCallRequest,
*,
repository: Any,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
self.backend_mode = require_supported_backend(request.backend_mode)
except VideoTicketError as error:
raise VideoWindowError(str(error)) from error
if not WEBENGINE_AVAILABLE:
raise VideoWindowError(
"embedded video is unavailable and automatic browser fallback is disabled"
)
if QApplication is None or QApplication.instance() is None:
raise VideoWindowError("embedded video requires an active QApplication")
self.request = request
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
local_dist=local_dist,
remote_url=remote_url,
)
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
self._session: Any = None
@property
def qt_window(self) -> Any:
return self._session
def open(self) -> VideoCallWindow:
try:
self._session = _EmbeddedVideoWindow(
self.request,
self.location,
self.lifecycle,
logger=self.logger,
)
except Exception:
self.lifecycle.end("window-open-failed")
raise
self._session.show()
self._session.raise_()
self._session.activateWindow()
return self
show = open
def hangup(self) -> None:
if self._session is not None:
self._session.hangup()
else:
self.lifecycle.end("unopened-session")
def close(self) -> None:
if self._session is not None:
self._session.close()
else:
self.lifecycle.end("unopened-session")
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
"""Wait briefly for ordered backend writes; timeout is capped at five seconds."""
return self.lifecycle.wait(timeout)
wait = wait_for_lifecycle
def open_video_call(
request: VideoCallRequest,
*,
repository: Any,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
if not isinstance(request, VideoCallRequest):
raise VideoTicketError("request must be a VideoCallRequest")
return VideoCallWindow(
request,
repository=repository,
local_dist=local_dist,
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
).open()
__all__ = [
"CompanionLocation",
"TrustedDocumentPolicy",
"VideoCallWindow",
"VideoWindowError",
"WEBENGINE_AVAILABLE",
"open_video_call",
"resolve_companion_location",
"webengine_unavailable_reason",
]
+272
View File
@@ -0,0 +1,272 @@
"""Contract tests for the UI-independent API client and token store."""
from __future__ import annotations
import json
from pathlib import Path
import httpx
import pytest
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiError,
ApiProtocolError,
ApiTimeoutError,
AuthenticationExpiredError,
OpenPageRequiredError,
WorkWechatBindingRequiredError,
)
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.token_store import TokenStore
def test_get_normalises_adminapi_and_sends_contract_headers() -> None:
"""The site base and already-prefixed base resolve to the same API URL."""
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"code": 1, "data": {"ok": True}})
with ApiClient(
"https://example.test/root/",
token="secret-token",
transport=httpx.MockTransport(handler),
) as client:
assert client.get("/doctor.appointment/lists", {"page_no": 2}) == {"ok": True}
request = requests[0]
assert str(request.url) == (
"https://example.test/root/adminapi/doctor.appointment/lists?page_no=2"
)
assert request.headers["token"] == "secret-token"
assert request.headers["version"] == "1.9.4"
assert ApiClient.normalise_base_url("https://example.test/adminapi") == (
"https://example.test/adminapi/"
)
def test_post_uses_json_and_never_retries_timeout() -> None:
"""Writes use JSON and a timeout never causes an automatic duplicate POST."""
attempts = 0
bodies: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
bodies.append(json.loads(request.content))
raise httpx.ReadTimeout("slow write", request=request)
client = ApiClient(
"https://example.test",
max_retries=5,
transport=httpx.MockTransport(handler),
)
with pytest.raises(ApiTimeoutError) as caught:
client.post("doctor.appointment/complete", {"id": 42})
client.close()
assert attempts == 1
assert bodies == [{"id": 42}]
assert caught.value.data["attempts"] == 1
def test_multipart_post_lets_httpx_set_boundary_and_sends_form_fields(
tmp_path: Path,
) -> None:
"""Uploads use real multipart encoding without the JSON content type."""
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
json={"code": 1, "data": {"uri": "/uploads/demo.jpg"}},
)
source = tmp_path / "demo.jpg"
source.write_bytes(b"jpeg-demo-bytes")
with (
ApiClient(
"https://example.test",
transport=httpx.MockTransport(handler),
) as client,
source.open("rb") as stream,
):
result = client.post_multipart(
"upload/image",
files={"file": (source.name, stream, "image/jpeg")},
data={"cid": "0"},
)
assert result == {"uri": "/uploads/demo.jpg"}
request = requests[0]
content_type = request.headers["content-type"]
assert content_type.startswith("multipart/form-data; boundary=")
assert "application/json" not in content_type
assert b'name="file"; filename="demo.jpg"' in request.content
assert b'name="cid"' in request.content
assert b"jpeg-demo-bytes" in request.content
def test_get_retries_only_timeouts_then_returns_data() -> None:
"""A GET may recover from a bounded number of timeout failures."""
attempts = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts < 3:
raise httpx.ReadTimeout("temporary", request=request)
return httpx.Response(200, json={"code": "1", "data": ["ready"]})
with ApiClient(
"https://example.test/adminapi/",
max_retries=2,
transport=httpx.MockTransport(handler),
) as client:
assert client.get("health") == ["ready"]
assert attempts == 3
@pytest.mark.parametrize(
("code", "exception_type"),
[
(0, ApiBusinessError),
(-1, AuthenticationExpiredError),
(10, WorkWechatBindingRequiredError),
],
)
def test_envelope_error_codes_are_structured(code: int, exception_type: type[Exception]) -> None:
"""Known control-flow codes become typed exceptions with response data."""
transport = httpx.MockTransport(
lambda request: httpx.Response(
200,
headers={"x-request-id": "req-123"},
json={"code": code, "msg": "action needed", "data": {"reason": "demo"}},
)
)
with (
ApiClient("https://example.test", transport=transport) as client,
pytest.raises(exception_type) as caught,
):
client.get("auth.admin/mySelf")
error = caught.value
assert isinstance(error, ApiError)
assert error.code == code
assert error.data == {"reason": "demo"}
assert error.request_id == "req-123"
def test_open_page_signal_does_not_open_a_browser() -> None:
"""Code 2 is surfaced to the UI as data, not executed by the service layer."""
transport = httpx.MockTransport(
lambda request: httpx.Response(
200,
json={"code": 2, "data": {"url": "https://example.test/continue"}},
)
)
with (
ApiClient("https://example.test", transport=transport) as client,
pytest.raises(OpenPageRequiredError) as caught,
):
client.get("continue")
assert caught.value.url == "https://example.test/continue"
def test_invalid_envelope_raises_protocol_error() -> None:
"""Successful HTTP is not mistaken for API success without a valid envelope."""
transport = httpx.MockTransport(
lambda request: httpx.Response(200, json={"data": "missing code"})
)
with (
ApiClient("https://example.test", transport=transport) as client,
pytest.raises(ApiProtocolError),
):
client.get("broken")
def test_token_store_file_fallback_never_persists_password(tmp_path: Path) -> None:
"""The fallback contains only an access token and an optional account name."""
path = tmp_path / "credentials.json"
store = TokenStore(path, keyring_backend=None)
store.save_token("token-value", account="doctor")
assert store.load_token() == "token-value"
assert store.load_account() == "doctor"
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload == {"token": "token-value", "account": "doctor"}
assert "password" not in path.read_text(encoding="utf-8").lower()
store.clear_token()
assert store.load_token() is None
assert store.load_account() == "doctor"
class _MemoryKeyring:
"""Minimal deterministic keyring double."""
def __init__(self) -> None:
self.values: dict[tuple[str, str], str] = {}
def get_password(self, service: str, username: str) -> str | None:
"""Return an in-memory secret."""
return self.values.get((service, username))
def set_password(self, service: str, username: str, password: str) -> None:
"""Store an in-memory secret."""
self.values[(service, username)] = password
def delete_password(self, service: str, username: str) -> None:
"""Delete an in-memory secret."""
self.values.pop((service, username), None)
def test_token_store_prefers_available_keyring(tmp_path: Path) -> None:
"""A working keyring keeps the token out of the fallback JSON file."""
backend = _MemoryKeyring()
path = tmp_path / "credentials.json"
store = TokenStore(path, keyring_backend=backend)
store.save_token("keyring-token", account="doctor")
assert store.uses_keyring
assert store.load_token() == "keyring-token"
assert json.loads(path.read_text(encoding="utf-8")) == {"account": "doctor"}
def test_token_store_scopes_automatic_restore_and_forgets_account(tmp_path: Path) -> None:
"""Automatic restore never returns a token issued for another API base."""
path = tmp_path / "credentials.json"
store = TokenStore(path, keyring_backend=None)
api_scope = "https://example.test/adminapi/"
store.save_token(
"scoped-token",
account="doctor",
scope=api_scope,
)
assert store.load_token(scope="https://example.test/adminapi") == "scoped-token"
assert store.load_token(scope="https://other.test/adminapi/") is None
assert store.load_token() == "scoped-token"
assert json.loads(path.read_text(encoding="utf-8")) == {
"token": "scoped-token",
"account": "doctor",
"scope": "https://example.test/adminapi",
}
store.save_token("next-token", account="", scope=api_scope)
assert store.load_account() is None
assert "account" not in json.loads(path.read_text(encoding="utf-8"))
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from pathlib import Path
import pytest
from doctor_workstation.config import AppConfig, normalize_api_base_url
@pytest.mark.parametrize(
("raw", "expected"),
[
("https://api.example.com", "https://api.example.com/adminapi"),
("https://api.example.com/", "https://api.example.com/adminapi"),
("https://api.example.com/adminapi", "https://api.example.com/adminapi"),
("http://127.0.0.1:8080/gateway", "http://127.0.0.1:8080/gateway/adminapi"),
("", ""),
],
)
def test_normalize_api_base_url(raw: str, expected: str) -> None:
assert normalize_api_base_url(raw) == expected
@pytest.mark.parametrize(
"raw",
["api.example.com", "ftp://api.example.com", "https://u:p@example.com", "https://x.test?a=1"],
)
def test_normalize_api_base_url_rejects_unsafe_values(raw: str) -> None:
with pytest.raises(ValueError):
normalize_api_base_url(raw)
def test_config_update_validates_video_mode() -> None:
with pytest.raises(ValueError):
AppConfig().with_updates(video_mode="unknown")
def test_runtime_directories_can_be_isolated_without_replacing_user_home(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_dir = tmp_path / "config"
log_dir = tmp_path / "logs"
monkeypatch.setenv("DOCTOR_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("DOCTOR_LOG_DIR", str(log_dir))
config = AppConfig()
assert config.config_dir == config_dir
assert config.log_dir == log_dir
+405
View File
@@ -0,0 +1,405 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtWidgets import QApplication, QDialog
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import (
ConsultationsPage,
_video_payload,
appointment_rows,
is_diagnosis_confirmed,
is_video_available,
prescription_action_label,
)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(consultations_module, "run_async", run_immediately)
monkeypatch.setattr(diagnosis_module, "run_async", run_immediately)
def _row(**changes: Any) -> dict[str, Any]:
row = {
"id": 501,
"diagnosis_id": 501,
"patient_id": 301,
"patient_name": "林晓岚",
"gender": 2,
"age": 36,
"status": 4,
"has_appointment": 1,
"appointment_id": 101,
"appointment_status": 1,
"appointments": [
{
"id": 101,
"status": 1,
"doctor_name": "陈医生",
"appointment_date": "2026-08-10",
"time_text": "09:00",
}
],
"DiagnosisViewRecord": [{"is_confirmed": 1}],
"has_prescription": 0,
}
row.update(changes)
return row
def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
assert is_video_available(_row(status=4, appointment_status=1))
assert not is_video_available(_row(status=1, appointment_status=4))
assert not is_video_available(_row(status=1, appointment_status=1, has_appointment=0))
payload = _video_payload(_row(id=777, diagnosis_id=777, appointment_id=222))
assert payload["appointment_id"] == 222
assert payload["diagnosis_id"] == 777
assert payload["patient_id"] == 301
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
row = _row(
appointment_id=None,
appointment_status=None,
DiagnosisViewRecord=[{"is_confirmed": 0}, {"is_confirmed": "1"}],
)
assert appointment_rows(row)[0]["id"] == 101
assert is_diagnosis_confirmed(row)
assert (
prescription_action_label({"prescription_audit_status": 1, "prescription_void_status": 0})
== "查看处方"
)
assert (
prescription_action_label({"prescription_audit_status": 1, "prescription_void_status": 1})
== "开方"
)
def test_default_query_matches_admin_today_and_page_size_contract(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_consultations(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
page.refresh(silent=True)
assert len(calls) == 1
query = calls[0]
assert query["page_no"] == 1
assert query["page_size"] == 15
assert query["appointment_date"] == QDate.currentDate().toString("yyyy-MM-dd")
assert "status" not in query
assert query["has_appointment"] == ""
assert query["diagnosis_confirmed"] == ""
assert {
"diagnosis_type",
"syndrome_type",
"assistant_id",
"latest_appointment_start_date",
"latest_appointment_end_date",
"latest_appointment_channel_source",
"latest_assign_start_date",
"latest_assign_end_date",
"sort_unserved_days",
}.issubset(query)
assert "consultation_type" not in query
page.close()
application.processEvents()
def test_double_click_opens_readonly_and_never_emits_video(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = ConsultationsPage(
SimpleNamespace(),
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail", "tcm.diagnosis/videoQr"]),
)
page.table.set_rows([_row()])
page.table.selectRow(0)
opened: list[tuple[int, bool]] = []
videos: list[dict[str, Any]] = []
monkeypatch.setattr(
page._diagnosis_dialog,
"open_for",
lambda diagnosis_id, *, editable=False, seed=None: opened.append((diagnosis_id, editable)),
)
page.video_requested.connect(videos.append)
page.table.itemDoubleClicked.emit(page.table.item(0, 0))
application.processEvents()
assert opened == [(501, False)]
assert videos == []
page.close()
def test_action_visibility_requires_exact_canonical_permissions(
application: QApplication,
) -> None:
aliases = PermissionSet(
[
"tcm.diagnosis.readonlyDetail",
"tcm.diagnosis.edit",
"tcm.diagnosis.add",
"tcm.diagnosis.delete",
]
)
page = ConsultationsPage(SimpleNamespace(), permissions=aliases)
assert not page.view_button.isVisible()
assert not page.edit_button.isVisible()
assert not page.add_button.isVisible()
assert not page.delete_button.isVisible()
page.close()
exact = PermissionSet(
[
"tcm.diagnosis/readonlyDetail",
"tcm.diagnosis/edit",
"tcm.diagnosis/add",
"tcm.diagnosis/delete",
]
)
page = ConsultationsPage(SimpleNamespace(), permissions=exact)
assert not page.view_button.isHidden()
assert not page.edit_button.isHidden()
assert not page.add_button.isHidden()
assert not page.delete_button.isHidden()
page.close()
application.processEvents()
def test_refresh_generation_ignores_late_results(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(consultations_module, "run_async", queue_async)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.refresh(silent=True)
page.refresh(silent=True)
callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1})
callbacks[0]["on_success"]({"lists": [_row(id=901, diagnosis_id=901)], "count": 1})
application.processEvents()
assert page.table.rowCount() == 1
assert page.table.item(0, 0).text().startswith("902")
page.close()
def test_current_appointment_is_the_only_prescription_authority(
application: QApplication,
) -> None:
calls: list[tuple[str, int]] = []
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> None:
calls.append(("appointment", appointment_id))
return None
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise AssertionError(f"diagnosis fallback is forbidden: {diagnosis_id}")
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
assert page._load_context_prescription(_row(appointment_id=202)) is None
assert calls == [("appointment", 202)]
page.close()
application.processEvents()
def test_empty_appointment_wrapper_is_treated_as_a_new_prescription(
application: QApplication,
) -> None:
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 202
return {"data": {}}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
assert page._load_context_prescription(_row(appointment_id=202)) is None
page.close()
application.processEvents()
def test_prescription_query_error_is_fail_closed_without_diagnosis_fallback(
application: QApplication,
) -> None:
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> None:
raise RuntimeError(f"appointment {appointment_id} unavailable")
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise AssertionError(f"diagnosis fallback is forbidden: {diagnosis_id}")
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
with pytest.raises(RuntimeError, match="appointment 202 unavailable"):
page._load_context_prescription(_row(appointment_id=202))
page.close()
application.processEvents()
def test_new_prescription_uses_authoritative_case_snapshot_and_exact_ids(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, int]] = []
created: list[dict[str, Any]] = []
dialog_seeds: list[dict[str, Any]] = []
case_record = {
"diagnosis": {"id": 501, "patient_name": "林晓岚", "chief_complaint": "咳嗽"},
"patient": {"id": 301, "gender": 2, "age": 36},
}
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> None:
calls.append(("appointment", appointment_id))
return None
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise AssertionError(f"diagnosis fallback is forbidden: {diagnosis_id}")
def get_diagnosis_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]:
calls.append(("diagnosis_detail", diagnosis_id))
return case_record
def create_prescription(self, prescription: Any) -> dict[str, Any]:
created.append(dict(prescription))
return {"id": 901}
def list_consultations(self, **_kwargs: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
class AcceptedEditor:
def __init__(
self,
_repository: Any,
seed: dict[str, Any],
**_kwargs: Any,
) -> None:
dialog_seeds.append(seed)
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"formula_name": "止咳方", "medicines": []}
monkeypatch.setattr(consultations_module, "PrescriptionEditorDialog", AcceptedEditor)
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
page._begin_prescription_load(_row(appointment_id=202), mode="open")
assert calls[:2] == [("appointment", 202), ("diagnosis_detail", 501)]
assert len(created) == 1
assert created[0]["diagnosis_id"] == 501
assert created[0]["appointment_id"] == 202
assert created[0]["case_record"] == case_record
assert created[0]["case_record"] is not case_record
assert dialog_seeds[0]["case_record"] == case_record
assert dialog_seeds[0]["case_record"] is not case_record
page.close()
application.processEvents()
def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(consultations_module, "run_async", queue_async)
page.table.set_rows(
[
_row(id=501, diagnosis_id=501, appointment_id=101),
_row(id=502, diagnosis_id=502, appointment_id=202),
]
)
page.table.selectRow(0)
page._begin_prescription_load(page.table.current_data(), mode="open")
assert page._prescription_busy
assert not page.prescription_button.isEnabled()
page.table.selectRow(1)
application.processEvents()
assert not page._prescription_busy
assert page.prescription_button.isEnabled()
callbacks[0]["on_success"]({"id": 88, "appointment_id": 101})
callbacks[0]["on_finished"]()
assert page.table.current_data()["appointment_id"] == 202
assert not page._prescription_busy
page.close()
application.processEvents()
def test_native_call_does_not_reuse_video_qr_permission(
application: QApplication,
) -> None:
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row()])
page.table.selectRow(0)
application.processEvents()
assert not page.video_button.isHidden()
assert page.video_button.isEnabled()
page._request_video()
assert emitted == [_video_payload(_row())]
page.close()
application.processEvents()
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
import pytest
from doctor_workstation import __main__ as entrypoint
def test_smoke_entrypoint_returns_nonzero_instead_of_opening_crash_dialog(
monkeypatch: Any,
capsys: Any,
) -> None:
def fail_startup() -> int:
raise RuntimeError("startup failed")
monkeypatch.setattr(entrypoint, "main", fail_startup)
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
assert entrypoint._run() == 1
assert "RuntimeError: startup failed" in capsys.readouterr().err
def test_normal_entrypoint_preserves_startup_exception(monkeypatch: Any) -> None:
def fail_startup() -> int:
raise RuntimeError("startup failed")
monkeypatch.setattr(entrypoint, "main", fail_startup)
monkeypatch.delenv("DOCTOR_SMOKE_TEST", raising=False)
monkeypatch.setattr(entrypoint.sys, "argv", ["doctor-workstation"])
with pytest.raises(RuntimeError, match="startup failed"):
entrypoint._run()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import logging
from doctor_workstation.logging_setup import SecretRedactionFilter
def test_redaction_filter_hides_credentials() -> None:
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg="token: abc123 userSig='secret-value' password=hunter2",
args=(),
exc_info=None,
)
assert SecretRedactionFilter().filter(record)
rendered = record.getMessage()
assert "abc123" not in rendered
assert "secret-value" not in rendered
assert "hunter2" not in rendered
assert rendered.count("<redacted>") == 3
+494
View File
@@ -0,0 +1,494 @@
"""Behaviour tests for mutable demo data and tolerant model parsing."""
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Any
import pytest
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiProtocolError,
AuthenticationExpiredError,
RepositoryNotFoundError,
)
from doctor_workstation.core.models import Appointment, PageResult
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.services.token_store import TokenStore
@pytest.fixture
def repository() -> DemoDoctorRepository:
"""Return a fresh deterministic repository for each test."""
return DemoDoctorRepository(today=date(2026, 8, 10))
def test_demo_login_has_all_doctor_permissions(
repository: DemoDoctorRepository,
) -> None:
"""Documented credentials produce a typed, globally authorised session."""
with pytest.raises(ApiBusinessError):
repository.login("doctor", "wrong")
session = repository.login("doctor", "doctor123")
assert session.authenticated
assert session.user.name == "陈医生(演示)"
assert session.permissions.is_superuser
assert session.permissions.can("doctor.appointment", "complete")
assert session.permissions.can("tcm.prescriptionLibrary", "delete")
def test_complete_appointment_mutates_all_related_views(
repository: DemoDoctorRepository,
) -> None:
"""Completing reception is observable in queue, patient and diagnosis lists."""
repository.complete_appointment(101)
completed = repository.list_appointments(status=3).items
assert [item.id for item in completed] == [101]
patient = repository.list_patients(keyword="林晓岚").items[0]
assert patient.appointment_status == 3
assert patient.status_filter == "completed"
consultation = repository.list_consultations(patient_name="林晓岚").items[0]
assert consultation.status == 3
assert repository.get_reception(101)["appointment"]["status"] == 3
def test_add_note_persists_in_reception_detail(
repository: DemoDoctorRepository,
) -> None:
"""Text and media added to a diagnosis remain available on later reads."""
before = len(repository.get_reception(101)["doctor_notes"])
created = repository.add_doctor_note(
501,
"午后睡意减轻",
tongue_images=["demo://tongue.png"],
report_files=["demo://report.pdf"],
)
after = repository.get_reception(101)["doctor_notes"]
assert len(after) == before + 1
assert after[-1] == created
assert after[-1]["tongue_images"] == ["demo://tongue.png"]
def test_demo_upload_material_returns_safe_uri_and_note_rejects_local_path(
repository: DemoDoctorRepository,
tmp_path: Path,
) -> None:
"""Demo mode exercises the same upload-before-note contract as production."""
source = tmp_path / "tongue.jpg"
source.write_bytes(b"demo-image")
uri = repository.upload_material(source, "image")
created = repository.add_doctor_note(501, tongue_images=[uri])
assert uri.startswith("/demo/uploads/image/")
assert str(tmp_path) not in uri
assert created["tongue_images"] == [uri]
with pytest.raises(ValueError, match="server uri/url"):
repository.add_doctor_note(501, tongue_images=[str(source)])
def test_prescription_template_crud_is_real_and_isolated(
repository: DemoDoctorRepository,
) -> None:
"""Create, update and delete change subsequent list/detail reads."""
original_count = repository.list_prescription_templates().total
created = repository.create_prescription_template(
name="益气演示方",
formula_type=1,
herbs=[{"name": "黄芪", "dosage": "20g"}],
is_public=False,
)
assert repository.list_prescription_templates().total == original_count + 1
updated = repository.update_prescription_template(
created.id,
{"name": "益气健脾演示方", "is_public": True},
)
assert updated.name == "益气健脾演示方"
assert updated.is_public
assert repository.get_prescription_template(created.id).name == updated.name
repository.delete_prescription_template(created.id)
assert repository.list_prescription_templates().total == original_count
with pytest.raises(RepositoryNotFoundError):
repository.get_prescription_template(created.id)
def test_demo_pagination_and_returned_copies(repository: DemoDoctorRepository) -> None:
"""Pagination metadata is stable and callers cannot mutate repository state."""
page = repository.list_appointments(page_no=1, page_size=1)
assert page.total == 3
assert page.pages == 3
page.items[0].patient_name = "外部改写"
assert repository.list_appointments(page_no=1, page_size=1).items[0].patient_name != (
"外部改写"
)
def test_demo_prescription_lookup_is_appointment_authoritative(
repository: DemoDoctorRepository,
) -> None:
"""Demo getByAppointment never substitutes another diagnosis-level record."""
first = repository.get_prescription_by_appointment(101)
second = repository.get_prescription_by_appointment(102)
missing = repository.get_prescription_by_appointment(103)
assert first is not None and first.id == 802 and first.appointment_id == 101
assert second is not None and second.id == 801 and second.appointment_id == 102
assert missing is None
assert first.case_record["appointment_id"] == 101
def test_demo_consultation_filters_and_dictionaries_cover_exposed_ui(
repository: DemoDoctorRepository,
) -> None:
"""Every visible consultation filter has deterministic offline semantics."""
for dictionary_type in (
"diagnosis_type",
"consultation_type",
"syndrome_type",
"appointment_channel_source",
"channels",
):
assert repository.get_dictionary(dictionary_type)
assert {row.id for row in repository.list_consultations(diagnosis_confirmed="1").items} == {
501,
503,
}
assert {row.id for row in repository.list_consultations(diagnosis_type="integrated").items} == {
502,
504,
}
assert [row.id for row in repository.list_consultations(syndrome_type="phlegm_damp").items] == [
502,
504,
]
assert [
row.id
for row in repository.list_consultations(latest_appointment_channel_source="clinic").items
] == [502]
assert [row.id for row in repository.list_consultations(pending_booking="1").items] == [504]
assert [row.id for row in repository.list_consultations(pending_assign="1").items] == [504]
assert [row.id for row in repository.list_consultations(completed_appointment="1").items] == [
501
]
sorted_rows = repository.list_consultations(sort_unserved_days="desc").items
assert [row.unserved_days for row in sorted_rows] == [14, 8, 1, 0]
def test_demo_roster_and_slots_fail_closed_to_known_doctor(
repository: DemoDoctorRepository,
) -> None:
"""Offline booking uses the same roster/slot boundary as production."""
rosters = repository.list_appointment_rosters(
doctor_id=1001,
start_date="2026-08-10",
end_date="2026-08-16",
)
slots = repository.get_available_appointment_slots(
doctor_id=1001,
appointment_date="2026-08-10",
)
assert rosters.total == 7
assert rosters.items[0]["date"] == "2026-08-10"
assert any(row["time"] == "09:00" and not row["available"] for row in slots["slots"])
assert (
repository.get_available_appointment_slots(
doctor_id=9999,
appointment_date="2026-08-10",
)["slots"]
== []
)
def test_demo_call_lifecycle_mutates_record(repository: DemoDoctorRepository) -> None:
"""Start, room binding and end operations share one mutable call record."""
ticket = repository.get_call_ticket(301, 501)
assert ticket.patient_user_id == "patient_301"
started = repository.start_call(501, 301)
assert started["status"] == "ringing"
bound = repository.bind_call_room(501, "room-501")
assert bound["room_id"] == "room-501"
ended = repository.end_call(501)
assert ended["status"] == "ended"
assert ended["room_id"] == "room-501"
def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None:
"""List parsing handles nullable fields, aliases and non-object rows safely."""
page = PageResult.from_payload(
{
"rows": [
{
"appointment_id": "9",
"patient_name": "测试患者",
"status": "waiting",
},
None,
],
"total": "12",
"current_page": "2",
"per_page": "5",
"meta": {"scope": "demo"},
},
Appointment.from_dict,
)
assert len(page.items) == 1
assert page.items[0].id == 9
assert page.items[0].status == "waiting"
assert page.total == 12
assert page.page_no == 2
assert page.extend == {"scope": "demo"}
class _StubApiClient:
"""No-network API client double that records repository endpoint use."""
def __init__(self) -> None:
self.base_url = "https://example.test/adminapi/"
self.token = ""
self.get_calls: list[tuple[str, dict[str, Any]]] = []
self.post_calls: list[tuple[str, dict[str, Any]]] = []
def set_token(self, token: str) -> None:
"""Retain the synthetic login token."""
self.token = token
def clear_token(self) -> None:
"""Clear the synthetic login token."""
self.token = ""
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
"""Return a shape appropriate for the requested read endpoint."""
self.get_calls.append((endpoint, dict(params or {})))
if endpoint == "auth.admin/mySelf":
return {
"user": {"id": 1, "name": "远程医生", "role_ids": [1]},
"permissions": ["doctor.appointment/lists"],
"menu": [],
}
if endpoint.endswith("/detail"):
if endpoint.startswith("tcm.prescriptionLibrary"):
return {"id": 7, "prescription_name": "远程模板", "herbs": []}
if endpoint.startswith("tcm.prescription"):
return {"id": 8, "sn": "RX8", "patient_name": "远程患者"}
if endpoint == "doctor.appointment/reception":
return {"appointment": {"id": params["id"]}, "doctor_notes": []}
return {"lists": [], "count": 0, "extend": {}}
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
"""Return synthetic mutation data and record the exact JSON payload."""
body = dict(payload or {})
self.post_calls.append((endpoint, body))
if endpoint == "login/account":
return {"token": "remote-token", "is_paw": 1}
if endpoint == "tcm.prescriptionLibrary/add":
return {"id": 9}
if endpoint == "tcm.diagnosis/getCallSignature":
return {
"sdkAppId": 123,
"userId": "doctor_1",
"userSig": "short-lived",
"patientUserId": "patient_2",
}
return {"ok": True}
def test_remote_repository_uses_all_confirmed_endpoints_without_network() -> None:
"""Every required remote operation maps to its audited admin endpoint."""
client = _StubApiClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
session = repository.login(" doctor ", "secret")
assert session.token == "remote-token"
assert repository.get_current_user().name == "远程医生"
assert [path for path, _ in client.get_calls].count("auth.admin/mySelf") == 1
repository.list_appointments(keyword="", page_no=2, page_size=15)
repository.get_reception(5)
repository.notify_assistant(5)
repository.add_doctor_note(6, "记录")
repository.complete_appointment(5)
repository.list_prescription_templates(keyword="", formula_type="aux")
repository.get_prescription_template(7)
repository.create_prescription_template(
name="新方", formula_type="main", herbs=[{"name": "茯苓", "dosage": "10g"}]
)
repository.update_prescription_template(7, {"name": "改方"})
repository.delete_prescription_template(7)
repository.list_prescriptions(keyword="RX8", status=1)
repository.get_prescription(8)
repository.list_patients(status="completed")
repository.list_consultations(keyword="远程")
ticket = repository.get_call_ticket(2, 6)
repository.start_call(6, 2)
repository.end_call(6)
repository.bind_call_room(6, "room-6")
assert ticket.user_sig == "short-lived"
get_endpoints = {path for path, _ in client.get_calls}
assert {
"doctor.appointment/lists",
"doctor.appointment/reception",
"tcm.prescriptionLibrary/lists",
"tcm.prescriptionLibrary/detail",
"tcm.prescription/lists",
"tcm.prescription/detail",
"firstvisit.myPatient/lists",
"tcm.diagnosis/lists",
} <= get_endpoints
post_endpoints = {path for path, _ in client.post_calls}
assert {
"login/account",
"doctor.appointment/notifyAssistant",
"doctor.appointment/addDoctorNote",
"doctor.appointment/complete",
"tcm.prescriptionLibrary/add",
"tcm.prescriptionLibrary/edit",
"tcm.prescriptionLibrary/delete",
"tcm.diagnosis/getCallSignature",
"tcm.diagnosis/startCall",
"tcm.diagnosis/endCall",
"tcm.diagnosis/bindCallRoom",
} <= post_endpoints
template_list_call = next(
params
for endpoint, params in client.get_calls
if endpoint == "tcm.prescriptionLibrary/lists"
)
assert template_list_call["formula_type"] == "辅方"
prescription_list_call = next(
params for endpoint, params in client.get_calls if endpoint == "tcm.prescription/lists"
)
assert prescription_list_call == {
"sn": "RX8",
"audit_filter": "passed",
"page_no": 1,
"page_size": 20,
}
patient_call = next(
params for endpoint, params in client.get_calls if endpoint == "firstvisit.myPatient/lists"
)
assert patient_call["status_filter"] == "completed"
class _FailingProfileClient(_StubApiClient):
"""Client double whose post-login session validation always fails."""
def __init__(self, error: Exception) -> None:
super().__init__()
self.error = error
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
"""Raise the configured error only for the authoritative profile call."""
if endpoint == "auth.admin/mySelf":
self.get_calls.append((endpoint, dict(params or {})))
raise self.error
return super().get(endpoint, params)
def test_remote_login_persists_only_after_session_validation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A failed ``mySelf`` call rolls back memory and never saves its token."""
client = _FailingProfileClient(ApiProtocolError("bad profile"))
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
store.save_token("older-token", account="older", scope=client.base_url)
saved: list[tuple[str, dict[str, Any]]] = []
original_save = store.save_token
def record_save(token: str, **metadata: Any) -> None:
saved.append((token, metadata))
original_save(token, **metadata)
monkeypatch.setattr(store, "save_token", record_save)
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
with pytest.raises(ApiProtocolError, match="bad profile"):
repository.login(
"doctor",
"secret",
remember_account=True,
)
assert saved == []
assert client.token == ""
assert store.load_token() is None
def test_remote_login_applies_remember_account_to_token_store(tmp_path: Path) -> None:
"""The checkbox choice controls account metadata while retaining the token."""
client = _StubApiClient()
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
repository.login("doctor", "secret", remember_account=True)
assert store.load_account() == "doctor"
assert store.load_token(scope=client.base_url) == "remote-token"
repository.logout()
repository.login("doctor", "secret", remember_account=False)
assert store.load_account() is None
assert store.load_token(scope=client.base_url) == "remote-token"
def test_expired_persisted_token_is_removed_during_restore(tmp_path: Path) -> None:
"""An invalid startup token cannot trigger the same failed restore next run."""
client = _FailingProfileClient(AuthenticationExpiredError("expired", code=-1))
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
store.save_token("expired-token", account="doctor", scope=client.base_url)
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
with pytest.raises(AuthenticationExpiredError):
repository.restore_session()
assert client.token == ""
assert store.load_token() is None
assert store.load_account() == "doctor"
def test_restore_never_sends_token_to_a_different_api_scope(tmp_path: Path) -> None:
"""Changing the configured server invalidates automatic token reuse."""
client = _StubApiClient()
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
store.save_token(
"other-server-token",
account="doctor",
scope="https://other.test/adminapi/",
)
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
assert repository.restore_session() is None
assert client.token == ""
assert client.get_calls == []
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def read(relative_path: str) -> str:
return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8")
def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
for name in (
"Run_DoctorWorkstation.bat",
"Build_DoctorWorkstation.bat",
"一键运行_医生工作站.bat",
"一键打包_医生工作站.bat",
):
assert (PROJECT_ROOT / name).is_file()
run_script = read("scripts/run_windows.ps1")
package_script = read("scripts/package_windows.ps1")
release_launcher = read("packaging/windows/start_release.bat")
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
assert "& $Uv sync --frozen" in run_script
assert "& $Uv sync --frozen --extra build" in package_script
assert "& $Npm ci --prefix" in package_script
assert "build_windows.ps1" in package_script
assert "DoctorWorkstation-Windows-x64-$ProjectVersion.zip" in package_script
assert "Get-FileHash" in package_script
assert "Start_DoctorWorkstation.bat" in package_script
assert "DoctorWorkstation\\DoctorWorkstation.exe" in release_launcher
assert "explorer.exe" in read("Build_DoctorWorkstation.bat")
def test_macos_one_click_entrypoints_and_release_pipeline() -> None:
for name in (
"run_macos.command",
"package_macos.command",
"一键运行.command",
"一键打包.command",
):
assert (PROJECT_ROOT / name).is_file()
run_script = read("scripts/run_macos.sh")
package_script = read("scripts/package_macos.sh")
assert run_script.index('/usr/bin/open "$artifact"') < run_script.index("macos_ensure_uv")
assert "sync --locked" in run_script
assert "sync --locked --extra build" in package_script
assert 'ci --prefix "$project_root/video_companion"' in package_script
assert "/usr/bin/ditto -c -k --sequesterRsrc --keepParent" in package_script
assert "/usr/bin/shasum -a 256" in package_script
assert "DoctorWorkstation-macOS-$release_arch-$project_version.zip" in package_script
+556
View File
@@ -0,0 +1,556 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtWidgets import QApplication, QInputDialog
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
from doctor_workstation.ui.pages import patients as patients_module
from doctor_workstation.ui.pages.patients import (
PatientListWorkspace,
PatientOrdersWorkspace,
PatientProgressWorkspace,
PatientsPage,
_AppointmentDialog,
_PaymentDialog,
_RefundDialog,
)
from doctor_workstation.ui.shell import NAVIGATION, ShellWindow, _resolve_navigation
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(patients_module, "run_async", run_immediately)
monkeypatch.setattr(diagnosis_module, "run_async", run_immediately)
def test_shell_resolves_dynamic_menu_order_visibility_and_canonical_permissions() -> None:
permissions = PermissionSet(
[
"firstvisit.myPatient/lists",
"tcm.diagnosis/lists",
"tcm.prescription/lists",
"doctor.appointment/lists",
]
)
menu = [
{
"name": "隐藏接诊",
"perms": "doctor.appointment/lists",
"sort": 999,
"is_show": 0,
},
{
"name": "诊疗中心",
"sort": 20,
"children": [
{
"name": "患者工作区",
"component": "first_visit/my_patients/index",
"sort": 80,
},
{
"name": "问诊工作区",
"perms": "tcm.diagnosis/lists",
"sort": 60,
},
],
},
{
"name": "停用处方",
"perms": "tcm.prescription/lists",
"sort": 30,
"is_disable": 1,
},
]
resolved = _resolve_navigation(menu, permissions, demo_mode=False)
assert [(item.key, title) for item, title in resolved] == [
("patients", "患者工作区"),
("consultations", "问诊工作区"),
]
assert _resolve_navigation([], permissions, demo_mode=False) == []
assert [item.key for item, _title in _resolve_navigation([], permissions, demo_mode=True)] == [
"reception",
"prescriptions",
"patients",
"consultations",
]
assert (
_resolve_navigation(
[{"perms": "firstvisit.myPatient/lists"}],
PermissionSet(["firstvisit.myPatient.lists"]),
demo_mode=False,
)
== []
)
def test_patient_page_runs_all_three_demo_workspaces_and_progress_timer(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
page.resize(808, 560)
page.show()
application.processEvents()
assert [page.tabs.tabText(index) for index in range(page.tabs.count())] == [
"患者列表",
"订单管理",
"面诊进度",
]
assert page.patient_workspace.table.rowCount() > 0
assert page.patient_workspace.scope_label.text() != ""
page.tabs.setCurrentIndex(1)
application.processEvents()
assert page.order_workspace.table.rowCount() > 0
assert page.order_workspace.metrics["orders"].text() == "1"
assert page.order_workspace.metrics["amount"].text() == "¥368.00"
page.tabs.setCurrentIndex(2)
application.processEvents()
assert page.progress_workspace.timer.isActive()
assert page.progress_workspace.schedule_table.rowCount() == 7
page.tabs.setCurrentIndex(0)
assert not page.progress_workspace.timer.isActive()
page.close()
application.processEvents()
def test_patient_refresh_generation_ignores_late_results(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(patients_module, "run_async", queue_async)
workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"]))
workspace.refresh()
workspace.refresh()
newer = {
"lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}],
"count": 1,
}
stale = {
"lists": [{"id": 1, "diagnosis_id": 1, "patient_name": "旧结果"}],
"count": 1,
}
callbacks[1]["on_success"](newer)
callbacks[0]["on_success"](stale)
application.processEvents()
assert workspace.table.rowCount() == 1
assert workspace.table.item(0, 0).text().startswith("新结果")
workspace.close()
def test_workspace_queries_use_frozen_page_no_contract(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[tuple[str, dict[str, Any]]] = []
class Repository:
def list_patients(self, **kwargs: Any) -> dict[str, Any]:
calls.append(("patients", kwargs))
return {"lists": [], "count": 0}
def patient_orders(self, **kwargs: Any) -> dict[str, Any]:
calls.append(("orders", kwargs))
return {"lists": [], "count": 0}
def patient_progress(self, **kwargs: Any) -> dict[str, Any]:
calls.append(("progress", kwargs))
return {"lists": [], "count": 0}
repository = Repository()
patient = PatientListWorkspace(repository, PermissionSet(["*"]))
orders = PatientOrdersWorkspace(repository, PermissionSet(["*"]))
progress = PatientProgressWorkspace(repository)
patient.refresh()
orders.refresh()
progress.refresh()
assert [name for name, _kwargs in calls] == ["patients", "orders", "progress"]
for _name, kwargs in calls:
assert kwargs["page_no"] == 1
assert kwargs["page_size"] == 15
assert "page" not in kwargs
patient.close()
orders.close()
progress.close()
application.processEvents()
def test_workspace_workers_use_gui_thread_query_snapshots(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
queued: list[tuple[Any, tuple[Any, ...], dict[str, Any]]] = []
patient_calls: list[dict[str, Any]] = []
order_calls: list[dict[str, Any]] = []
def queue_async(function: Any, *args: Any, **options: Any) -> object:
queued.append((function, args, options))
return object()
class Repository:
def list_patients(self, **kwargs: Any) -> dict[str, Any]:
patient_calls.append(kwargs)
return {"lists": [], "count": 0}
def patient_orders(self, **kwargs: Any) -> dict[str, Any]:
order_calls.append(kwargs)
return {"lists": [], "count": 0}
repository = Repository()
patient = PatientListWorkspace(repository, PermissionSet(["*"]))
orders = PatientOrdersWorkspace(repository, PermissionSet(["*"]))
monkeypatch.setattr(patients_module, "run_async", queue_async)
patient.keyword_edit.setText("captured patient")
patient.refresh()
patient.keyword_edit.setText("changed patient")
function, args, _options = queued[0]
function(*args)
orders.keyword_edit.setText("captured order")
orders.refresh()
orders.keyword_edit.setText("changed order")
function, args, _options = queued[1]
function(*args)
assert patient_calls[0]["keyword"] == "captured patient"
assert order_calls[0]["keyword"] == "captured order"
patient.close()
orders.close()
application.processEvents()
def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
application: QApplication,
immediate_async: None,
) -> None:
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
appointment_queries: list[dict[str, Any]] = []
roster_queries: list[dict[str, Any]] = []
slot_queries: list[dict[str, Any]] = []
class Repository:
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 77, "name": "陈医生", "department_name": "中医科"}]
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
assert dictionary_type == "channels"
return [{"id": 1, "name": "线上复诊", "value": "online", "status": 1, "sort": 10}]
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
appointment_queries.append(kwargs)
return {"lists": [], "count": 0}
def list_appointment_rosters(self, **kwargs: Any) -> dict[str, Any]:
roster_queries.append(kwargs)
return {"lists": [{"date": tomorrow}], "count": 1}
def get_available_appointment_slots(self, **kwargs: Any) -> dict[str, Any]:
slot_queries.append(kwargs)
return {"slots": [{"time": "09:30-10:00", "available": True, "quota": 2}]}
row = {
"id": 501,
"diagnosis_id": 501,
"patient_id": 999,
"source_patient_id": 999,
"patient_name": "林晓岚",
"doctor_id": 77,
}
dialog = _AppointmentDialog(row, repository=Repository())
application.processEvents()
dialog.channel_source.setCurrentIndex(dialog.channel_source.findData("online"))
dialog.slot_combo.setCurrentIndex(dialog.slot_combo.findData("09:30-10:00"))
dialog.remark.setPlainText("复诊预约")
application.processEvents()
payload = dialog.payload()
assert appointment_queries[0]["patient_id"] == 501
assert roster_queries[0]["doctor_id"] == 77
assert slot_queries[0] == {
"doctor_id": 77,
"appointment_date": tomorrow,
"period": "all",
}
assert dialog.ok_button.isEnabled()
assert payload == {
"diagnosis_id": 501,
"patient_id": 501,
"doctor_id": 77,
"appointment_date": tomorrow,
"appointment_time": "09:30-10:00",
"period": "all",
"appointment_type": "video",
"remark": "复诊预约",
"channel_source": "online",
"channel_source_detail": "",
}
assert payload["patient_id"] != row["source_patient_id"]
dialog.close()
application.processEvents()
def test_payment_and_refund_forms_expose_full_contract(
application: QApplication,
) -> None:
payment = _PaymentDialog({"amount": 368, "linked_pay_paid_total": 100})
payment.pay_create_type.setCurrentIndex(payment.pay_create_type.findData("express_cod"))
payment.pay_amount.setValue(268)
payment.pay_remark.setPlainText("货到代收")
payment.completion_request.setChecked(True)
assert payment.payload() == {
"order_type": 3,
"pay_amount": 268.0,
"pay_remark": "货到代收",
"completion_request": 1,
"pay_create_type": "express_cod",
}
refund = _RefundDialog({"amount": 368})
refund.reason.setPlainText("患者取消")
assert refund.payload() == {"reason": "患者取消", "refund_amount": None}
refund.specify_amount.setChecked(True)
refund.refund_amount.setValue(88.5)
assert refund.payload() == {"reason": "患者取消", "refund_amount": 88.5}
payment.close()
refund.close()
application.processEvents()
def test_out_of_order_mutation_successes_each_trigger_reconciliation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = PatientsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
callbacks: list[dict[str, Any]] = []
reconciliations: list[str] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(patients_module, "run_async", queue_async)
monkeypatch.setattr(page, "_after_mutation", lambda: reconciliations.append("refresh"))
page._run_action("first", lambda: None, success="first done")
page._run_action("second", lambda: None, success="second done")
callbacks[1]["on_success"](None)
callbacks[0]["on_success"](None)
callbacks[1]["on_finished"]()
callbacks[0]["on_finished"]()
assert reconciliations == ["refresh", "refresh"]
assert page._pending_action_tokens == set()
page.close()
application.processEvents()
def test_diagnosis_dialog_loads_histories_and_saves_canonical_fields(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
diagnosis_id = repository.list_patients().items[0].diagnosis_id
before = repository.get_diagnosis_detail(diagnosis_id)
dialog = DiagnosisDialog(repository)
dialog.open_for(diagnosis_id, editable=True)
application.processEvents()
assert (
dialog.edit_fields["chief_complaint"].toPlainText()
== before["diagnosis"]["chief_complaint"]
)
assert dialog.appointment_table.rowCount() >= 1
dialog.edit_fields["chief_complaint"].setPlainText("离屏回归主诉")
dialog._save()
assert (
repository.get_diagnosis_detail(diagnosis_id)["diagnosis"]["chief_complaint"]
== "离屏回归主诉"
)
dialog.close()
application.processEvents()
def test_order_action_matrix_requires_exact_permissions_and_states(
application: QApplication,
) -> None:
codes = {
"tcm.prescriptionOrder/detail",
"tcm.prescriptionOrder/edit",
"tcm.prescriptionOrder/auditPrescription",
"tcm.prescriptionOrder/auditPayment",
"tcm.prescriptionOrder/ddcode",
"tcm.prescriptionOrder/ship",
"tcm.prescriptionOrder/addPayOrder",
"tcm.prescriptionOrder/complete",
"tcm.prescriptionOrder/refund",
"tcm.prescriptionOrder/withdraw",
"tcm.prescriptionOrder/uploadToPharmacy",
}
workspace = PatientOrdersWorkspace(SimpleNamespace(), PermissionSet(codes))
pending = {
"id": 1,
"fulfillment_status": 1,
"prescription_audit_status": 0,
"payment_slip_audit_status": 0,
"amount": 100,
"linked_pay_paid_total": 0,
}
shipped = {
**pending,
"fulfillment_status": 5,
"prescription_audit_status": 1,
"payment_slip_audit_status": 1,
"linked_pay_paid_total": 80,
}
assert [key for key, _label, _danger in workspace._available_actions(pending)] == [
"edit",
"audit_prescription",
"ddcode",
"withdraw",
]
assert [key for key, _label, _danger in workspace._available_actions(shipped)] == [
"revoke_pay_audit",
"ddcode",
"add_pay_order",
"complete",
"refund",
"upload_pharmacy",
]
remote_locked = {**pending, "gancao_reciperl_order_no": "GC-REMOTE-1"}
assert [key for key, _label, _danger in workspace._available_actions(remote_locked)] == [
"audit_prescription",
"ddcode",
]
alias_only = PatientOrdersWorkspace(
SimpleNamespace(), PermissionSet(["tcm.prescriptionOrder.edit"])
)
assert alias_only._available_actions(pending) == []
workspace.close()
alias_only.close()
application.processEvents()
def test_order_audit_action_uses_repository_contract_values(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
order_id = repository.patient_orders().items[0]["id"]
repository.revoke_patient_order_payment_audit(order_id)
repository.revoke_patient_order_prescription_audit(order_id)
row = repository.get_patient_order(order_id)
monkeypatch.setattr(
QInputDialog,
"getItem",
staticmethod(lambda *_args, **_kwargs: ("通过", True)),
)
monkeypatch.setattr(
QInputDialog,
"getText",
staticmethod(lambda *_args, **_kwargs: ("离屏审核", True)),
)
page._handle_order_action("audit_prescription", row)
assert repository.get_patient_order(row["id"])["prescription_audit_status"] == 1
page.close()
application.processEvents()
def test_shell_uses_demo_session_menu_and_fits_minimum_window(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
patient_menu = next(
row for row in session.menu if row.get("perms") == "firstvisit.myPatient/lists"
)
patient_menu["name"] = "患者中心"
patient_menu["sort"] = 99
session.menu = [patient_menu]
shell = ShellWindow(
repository,
{"session": session, "demo_mode": True},
permissions=session.permissions,
)
shell.resize(1024, 640)
shell.show()
application.processEvents()
assert list(shell.pages) == ["patients"]
assert shell.nav_buttons["patients"].text().endswith("患者中心")
assert shell.minimumWidth() == 1024
assert shell.minimumHeight() == 640
assert shell.size().width() == 1024
assert shell.size().height() == 640
assert {item.key for item in NAVIGATION} == {
"reception",
"prescription_library",
"prescriptions",
"patients",
"consultations",
}
shell.close()
application.processEvents()
+52
View File
@@ -0,0 +1,52 @@
"""Tests for permission semantics reused by page and action guards."""
from doctor_workstation.core.permissions import PermissionSet
def test_global_wildcard_grants_every_page_and_action() -> None:
"""The server's global wildcard remains authoritative."""
permissions = PermissionSet(["*"])
assert permissions.is_superuser
assert permissions.can_access_page("doctor.appointment/lists")
assert permissions.can_perform_action("doctor.appointment/complete")
assert permissions.can("doctor.appointment", "complete")
def test_all_and_any_accept_sequences_or_positional_values() -> None:
"""AND and OR helpers preserve the two web-client permission semantics."""
permissions = PermissionSet(["doctor.appointment/lists", "doctor.appointment/complete"])
assert permissions.all("doctor.appointment/lists", "doctor.appointment/complete")
assert permissions.has_all(["doctor.appointment/lists", "doctor.appointment/complete"])
assert not permissions.all("doctor.appointment/lists", "doctor.appointment/cancel")
assert permissions.any(["doctor.appointment/cancel", "doctor.appointment/complete"])
assert not permissions.has_any([])
assert permissions.has_all([])
def test_page_action_categories_and_resource_wildcards() -> None:
"""Explicit page/action categories share checks without overloading UI code."""
permissions = PermissionSet(
pages=["patients/view"],
actions=["tcm.prescriptionLibrary/*"],
)
assert permissions.can_access_page("patients/view")
assert permissions.can("tcm.prescriptionLibrary", "add")
assert permissions.can_perform_action("tcm.prescriptionLibrary/delete")
assert not permissions.can_perform_action("tcm.prescription/delete")
assert "patients/view" in permissions
def test_permission_set_normalises_and_deduplicates_values() -> None:
"""Whitespace and duplicates do not create surprising guard results."""
permissions = PermissionSet([" alpha/read ", "alpha/read", ""])
assert list(permissions) == ["alpha/read"]
assert len(permissions) == 1
assert bool(permissions)
+386
View File
@@ -0,0 +1,386 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QDialog
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui import widgets
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs import prescription as dialog_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
from doctor_workstation.ui.dialogs.prescription import (
PrescriptionEditorDialog,
PrescriptionOrderDialog,
PrescriptionTemplateDialog,
)
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescription_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _immediate_async(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**_kwargs: Any,
) -> object:
try:
result = function(*args)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
class _DiagnosisRepository:
def __init__(self) -> None:
self.order_queries: list[dict[str, Any]] = []
self.phone_checks: list[dict[str, Any]] = []
self.id_card_checks: list[dict[str, Any]] = []
self.updates: list[tuple[int, dict[str, Any]]] = []
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
del readonly
return {
"diagnosis": {
"id": diagnosis_id,
"patient_id": 321,
"patient_name": "林晓岚",
"phone": "13812345678",
"id_card": "510107199001011234",
"gender": 0,
"age": 36,
"height": 162.5,
"weight": 52.0,
"fasting_blood_sugar": "6.2",
"chief_complaint": "乏力",
"symptoms": "口干",
"appetite": ["一般", "少食"],
"clinical_diagnosis": "气阴两虚",
"can_edit_patient_basic": True,
},
"patient": {"id": 321},
"appointment": {},
}
def appointment_history(self, **_kwargs: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
def assign_history(self, **_kwargs: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
def list_prescription_orders(self, **kwargs: Any) -> dict[str, Any]:
self.order_queries.append(kwargs)
return {
"lists": [
{
"id": 8,
"order_no": "ORDER-8",
"prescription_id": 5,
"patient_name": "林晓岚",
"amount": 128,
}
],
"count": 1,
}
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
self.phone_checks.append(payload)
return {"exists": False}
def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]:
self.id_card_checks.append(payload)
return {"duplicate": False}
def update_diagnosis(
self, diagnosis: int, changes: dict[str, Any] | None = None
) -> dict[str, Any]:
self.updates.append((diagnosis, dict(changes or {})))
return {"id": diagnosis, **dict(changes or {})}
def test_canonical_permission_helper_rejects_dot_aliases_and_accepts_wildcards() -> None:
assert not widgets.has_permission(
PermissionSet(["cf.prescription.edit"]), "cf.prescription/edit"
)
assert widgets.has_permission(PermissionSet(["cf.prescription/*"]), "cf.prescription/edit")
assert widgets.has_permission(PermissionSet(["*"]), "cf.prescription/edit")
assert not widgets.has_permission({}, "cf.prescription/edit")
def test_diagnosis_masks_sensitive_fields_and_loads_exact_context_orders(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
repository = _DiagnosisRepository()
dialog = DiagnosisDialog(
repository,
permissions=PermissionSet(["tcm.diagnosis/patientOrders"]),
)
dialog.open_for(77, editable=False)
assert dialog.summary_fields["phone"].text() == "138****5678"
assert dialog.summary_fields["id_card"].text() == "510107********1234"
assert dialog.edit_fields["phone"].toPlainText() == "138****5678"
assert dialog.edit_fields["id_card"].toPlainText() == "510107********1234"
assert dialog.edit_fields["phone"].isReadOnly()
assert dialog.edit_fields["id_card"].isReadOnly()
assert dialog.orders_table.rowCount() == 1
assert repository.order_queries == [
{
"page_no": 1,
"page_size": 10,
"context_diagnosis_id": 77,
"patient_id": 321,
"scene": "diagnosis_edit",
}
]
dialog.close()
application.processEvents()
def test_diagnosis_edit_checks_unique_identity_and_saves_expanded_dto(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
repository = _DiagnosisRepository()
dialog = DiagnosisDialog(
repository,
permissions=PermissionSet(["tcm.diagnosis/edit", "tcm.diagnosis/phonePlain"]),
)
dialog.open_for(77, editable=True)
dialog.edit_fields["symptoms"].setPlainText("口干、多饮")
dialog.edit_fields["appetite"].setPlainText("一般、少食")
dialog._save()
assert repository.phone_checks == [{"phone": "13812345678", "id": 77}]
assert repository.id_card_checks == [{"id_card": "510107199001011234", "id": 77}]
diagnosis_id, changes = repository.updates[-1]
assert diagnosis_id == 77
assert changes["phone"] == "13812345678"
assert changes["id_card"] == "510107199001011234"
assert changes["symptoms"] == "口干、多饮"
assert changes["appetite"] == ["一般", "少食"]
dialog.close()
application.processEvents()
def test_duplicate_herbs_are_rejected_for_templates_and_issued_prescriptions(
application: QApplication,
) -> None:
herbs = [
{"medicine_id": 11, "name": "黄芪", "dosage": 10},
{"medicine_id": 11, "name": "黄芪", "dosage": 15},
]
repository = SimpleNamespace()
template = PrescriptionTemplateDialog(
repository,
{"id": 1, "prescription_name": "重复方", "herbs": herbs},
mode="edit",
)
template.accept()
assert template.result() == QDialog.DialogCode.Rejected
assert "药材不可重复:黄芪" in template.validation.label.text()
editor = PrescriptionEditorDialog(
repository,
mode="add",
current_user=SimpleNamespace(id=9, name="周医生"),
)
editor.patient_name.setText("林晓岚")
editor.clinical_diagnosis.setPlainText("气虚")
editor.signature._has_strokes = True
editor.herbs.set_rows(herbs, locked=False)
editor.accept()
assert editor.result() == QDialog.DialogCode.Rejected
assert "药材不可重复:黄芪" in editor.validation.label.text()
template.close()
editor.close()
application.processEvents()
def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(dialog_module, "run_async", queue_async)
monkeypatch.setattr(dialog_module.QTimer, "singleShot", staticmethod(lambda *_args: None))
dialog = PrescriptionOrderDialog(
SimpleNamespace(list_paid_prescription_orders=lambda diagnosis_id: {}),
{"id": 12, "diagnosis_id": 6, "patient_name": "林晓岚"},
)
dialog._load_paid_orders()
assert not dialog.save_button.isEnabled()
dialog.diagnosis_id.setValue(7)
assert len(callbacks) == 2
callbacks[0]["on_success"](
{"lists": [{"id": 66, "order_no": "OLD"}], "deposit_min_amount": 100}
)
assert dialog.paid_orders.count() == 0
assert not dialog.save_button.isEnabled()
callbacks[1]["on_success"]({"lists": [{"id": 77, "order_no": "NEW"}], "deposit_min_amount": 50})
assert dialog.paid_orders.item(0).data(Qt.ItemDataRole.UserRole) == 77
assert dialog.save_button.isEnabled()
assert dialog._paid_orders_diagnosis_id == 7
dialog.close()
application.processEvents()
def _finish_queued(callback: dict[str, Any], result: Any) -> None:
callback["on_success"](result)
if callback.get("on_finished"):
callback["on_finished"]()
def test_prescription_lists_snapshot_queries_and_replay_pending_refresh(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
library_callbacks: list[tuple[Any, dict[str, Any]]] = []
library_calls: list[dict[str, Any]] = []
def queue_library(function: Any, **options: Any) -> object:
library_callbacks.append((function, options))
return object()
class LibraryRepository:
def list_prescription_templates(self, **kwargs: Any) -> dict[str, Any]:
library_calls.append(kwargs)
return {"lists": [], "count": 0}
monkeypatch.setattr(library_module, "run_async", queue_library)
library = PrescriptionLibraryPage(
LibraryRepository(), PermissionSet(["wcf.prescription/*"]), SimpleNamespace(id=1)
)
library.refresh()
library.name_filter.setText("新条件")
library.refresh()
assert len(library_callbacks) == 1
first_function, first_options = library_callbacks[0]
first_result = first_function()
_finish_queued(first_options, first_result)
assert len(library_callbacks) == 2
second_function, second_options = library_callbacks[1]
second_result = second_function()
_finish_queued(second_options, second_result)
assert [call["prescription_name"] for call in library_calls] == ["", "新条件"]
issued_callbacks: list[tuple[Any, dict[str, Any]]] = []
issued_calls: list[dict[str, Any]] = []
def queue_issued(function: Any, **options: Any) -> object:
issued_callbacks.append((function, options))
return object()
class IssuedRepository:
def list_prescriptions(self, **kwargs: Any) -> dict[str, Any]:
issued_calls.append(kwargs)
return {"lists": [], "count": 0}
monkeypatch.setattr(prescription_module, "run_async", queue_issued)
issued = PrescriptionsPage(
IssuedRepository(), PermissionSet(["cf.prescription/*"]), SimpleNamespace(id=1)
)
issued.refresh()
issued.patient_filter.setText("新患者")
issued.refresh()
assert len(issued_callbacks) == 1
first_function, first_options = issued_callbacks[0]
first_result = first_function()
_finish_queued(first_options, first_result)
assert len(issued_callbacks) == 2
second_function, second_options = issued_callbacks[1]
second_result = second_function()
_finish_queued(second_options, second_result)
assert [call["patient_name"] for call in issued_calls] == ["", "新患者"]
library.close()
issued.close()
application.processEvents()
def test_diagnosis_detail_requires_permission_and_ignores_stale_target(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository_calls: list[int] = []
class Repository:
def get_diagnosis_detail(
self, diagnosis_id: int, *, readonly: bool = False
) -> dict[str, Any]:
repository_calls.append(diagnosis_id)
return {"id": diagnosis_id, "readonly": readonly}
denied = PrescriptionsPage(Repository(), PermissionSet([]), SimpleNamespace(id=1))
denied._open_diagnosis(1)
assert repository_calls == []
callbacks: list[tuple[Any, dict[str, Any]]] = []
def queue_async(function: Any, **options: Any) -> object:
callbacks.append((function, options))
return object()
shown: list[int] = []
class FakeDiagnosisDetailDialog:
def __init__(self, detail: dict[str, Any], _parent: Any) -> None:
shown.append(detail["id"])
def exec(self) -> int:
return 0
monkeypatch.setattr(prescription_module, "run_async", queue_async)
monkeypatch.setattr(prescription_module, "DiagnosisDetailDialog", FakeDiagnosisDetailDialog)
allowed = PrescriptionsPage(
Repository(),
PermissionSet(["tcm.diagnosis/readonlyDetail"]),
SimpleNamespace(id=1),
)
allowed._open_diagnosis(11)
allowed._open_diagnosis(12)
assert len(callbacks) == 2
old_function, old_options = callbacks[0]
old_options["on_success"](old_function())
assert shown == []
new_function, new_options = callbacks[1]
new_options["on_success"](new_function())
assert shown == [12]
assert repository_calls == [11, 12]
denied.close()
allowed.close()
application.processEvents()
+424
View File
@@ -0,0 +1,424 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.dialogs import prescription as dialog_module
from doctor_workstation.ui.dialogs.prescription import (
AuditPrescriptionDialog,
PrescriptionDetailDialog,
PrescriptionEditorDialog,
PrescriptionOrderDialog,
PrescriptionTemplateDialog,
RemoteMedicineComboBox,
parse_pasted_herbs,
render_prescription_html,
)
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescription_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import (
PrescriptionsPage,
can_audit,
can_create_order,
can_edit_or_delete,
can_patch_patient,
prescription_status,
)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(dialog_module, "run_async", run_immediately)
monkeypatch.setattr(library_module, "run_async", run_immediately)
monkeypatch.setattr(prescription_module, "run_async", run_immediately)
def test_admin_status_and_action_guards_are_exact() -> None:
pending = {
"id": 1,
"audit_status": 0,
"void_status": 0,
"has_prescription_order": 0,
}
approved = {**pending, "audit_status": 1}
rejected = {
**approved,
"business_prescription_audit_rejected": 1,
"business_prescription_audit_remark": "剂量需调整",
}
voided = {**approved, "void_status": 1}
assert prescription_status(pending) == ("待审核", "warning")
assert prescription_status(approved) == ("已通过", "success")
assert prescription_status(rejected) == ("已驳回", "danger")
assert prescription_status(voided) == ("已作废", "danger")
assert can_patch_patient(pending)
assert can_create_order(pending)
assert can_audit(pending)
assert can_edit_or_delete(pending)
assert not can_audit(approved)
assert not can_edit_or_delete(approved)
assert can_edit_or_delete(voided)
assert not can_patch_patient(voided)
def test_paste_parser_matches_common_admin_recipe_forms() -> None:
parsed = parse_pasted_herbs("Rp: 黄芪15 党参12、茯苓10g\n柴胡、白术各6克\n饭后温服")
assert parsed == [
{"name": "黄芪", "dosage": 15.0},
{"name": "党参", "dosage": 12.0},
{"name": "茯苓", "dosage": 10.0},
{"name": "柴胡", "dosage": 6.0},
{"name": "白术", "dosage": 6.0},
]
def test_remote_medicine_selector_rejects_new_free_text(
application: QApplication,
) -> None:
repository = SimpleNamespace(list_medicines=lambda **_kwargs: {"lists": [], "count": 0})
selector = RemoteMedicineComboBox(repository, name="历史药名")
assert selector.has_valid_selection
selector._queue_search("随意输入")
selector._timer.stop()
selector.setEditText("随意输入")
assert not selector.has_valid_selection
selector.set_value(11, "黄芪")
assert selector.has_valid_selection
selector.close()
application.processEvents()
def test_template_disable_edit_controls_import_not_template_maintenance(
application: QApplication,
immediate_async: None,
) -> None:
repository = SimpleNamespace(
list_medicines=lambda **_kwargs: {
"lists": [{"id": 11, "name": "黄芪"}],
"count": 1,
}
)
template = {
"id": 8,
"prescription_name": "益气方",
"formula_type": "主方",
"is_public": 1,
"disable_edit": 1,
"herbs": [{"medicine_id": 11, "name": "黄芪", "dosage": 15}],
}
dialog = PrescriptionTemplateDialog(repository, template, mode="edit")
assert dialog.disable_edit_check.isChecked()
assert dialog.herbs.rows[0].medicine.isEnabled()
assert dialog.herbs.rows[0].dosage.isEnabled()
assert dialog.payload() == {
"id": 8,
"prescription_name": "益气方",
"formula_type": "主方",
"is_public": 1,
"disable_edit": 1,
"herbs": [{"medicine_id": 11, "name": "黄芪", "dosage": 15.0}],
}
dialog.close()
application.processEvents()
def test_library_page_uses_canonical_permissions_and_full_columns(
application: QApplication,
immediate_async: None,
) -> None:
class Repository:
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
return {
"lists": [
{
"id": 3,
"prescription_name": "安神方",
"formula_type": "辅方",
"herbs": [{"name": "酸枣仁", "dosage": 12}],
"is_public": 0,
"disable_edit": 1,
"creator_id": 9,
"creator_name": "张医生",
"create_time": "2026-08-10 12:00:00",
}
],
"count": 1,
}
permissions = {
"wcf.prescription/add",
"wcf.prescription/read",
"wcf.prescription/edit",
"wcf.prescription/delete",
}
page = PrescriptionLibraryPage(
Repository(),
permissions,
SimpleNamespace(id=9, root=0, role_ids=[]),
)
page.refresh()
page.table.selectRow(0)
page._selection_changed()
assert page.table.columnCount() == 9
assert not page.view_button.isHidden() and page.view_button.isEnabled()
assert not page.edit_button.isHidden() and page.edit_button.isEnabled()
assert not page.delete_button.isHidden() and page.delete_button.isEnabled()
assert page.pager.page_size == 15
page.close()
application.processEvents()
def test_issued_page_sends_exact_filter_dto_and_row_guards(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
pending = {
"id": 21,
"sn": "CF-21",
"patient_name": "林晓岚",
"gender": 0,
"age": 33,
"audit_status": 0,
"void_status": 0,
"has_prescription_order": 0,
"creator_id": 7,
"doctor_name": "周医生",
"prescription_date": "2026-08-10",
"create_time": "2026-08-10 12:00:00",
"herbs": [{"name": "黄芪", "dosage": 15}],
}
class Repository:
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 9, "name": "孙医生"}]
def list_prescriptions(self, **filters: Any) -> dict[str, Any]:
calls.append(filters)
return {"lists": [pending], "count": 1}
permissions = {
"cf.prescription/add",
"cf.prescription/read",
"cf.prescription/edit",
"cf.prescription/audit",
"cf.prescription/del",
"tcm.prescription/patchPatient",
"tcm.prescriptionOrder/create",
"tcm.prescriptionOrder/lists",
}
page = PrescriptionsPage(
Repository(),
permissions,
SimpleNamespace(id=7, name="周医生"),
)
page.refresh()
page.table.selectRow(0)
page._selection_changed()
assert calls == [
{
"page_no": 1,
"page_size": 15,
"sn": "",
"patient_name": "",
"audit_filter": "",
"source_filter": "",
"start_time": "",
"end_time": "",
}
]
page.quick_date.setCurrentIndex(1)
assert len(calls) == 2
assert calls[-1]["start_time"].endswith("00:00:00")
assert calls[-1]["end_time"].endswith("23:59:59")
assert page.audit_button.isEnabled()
assert page.doctor_filter._options[9] == "孙医生"
assert page.patch_button.isEnabled()
assert page.create_order_button.isEnabled()
assert page.edit_button.isEnabled()
assert page.delete_button.isEnabled()
page.table.set_rows([{**pending, "audit_status": 1}])
page.table.selectRow(0)
page._selection_changed()
assert not page.audit_button.isEnabled()
assert not page.edit_button.isEnabled()
assert not page.delete_button.isEnabled()
assert page.patch_button.isEnabled()
assert page.create_order_button.isEnabled()
page.close()
application.processEvents()
def test_editor_builds_complete_add_payload(
application: QApplication,
immediate_async: None,
) -> None:
repository = SimpleNamespace(
list_medicines=lambda **_kwargs: {
"lists": [{"id": 31, "name": "黄芪"}],
"count": 1,
}
)
user = SimpleNamespace(id=7, name="周医生")
editor = PrescriptionEditorDialog(repository, mode="add", current_user=user)
editor.patient_name.setText("林晓岚")
editor.clinical_diagnosis.setPlainText("脾气虚")
editor.herbs.rows[0].medicine.set_value(31, "黄芪")
editor.herbs.rows[0].dosage.setValue(15)
editor.signature._has_strokes = True
payload = editor.payload()
assert payload["creator_id"] == 7
assert payload["audit_status"] == 0
assert payload["patient_name"] == "林晓岚"
assert payload["clinical_diagnosis"] == "脾气虚"
assert payload["herbs"] == [
{
"medicine_id": 31,
"name": "黄芪",
"dosage": 15.0,
"formula_type": "主方",
}
]
assert payload["doctor_name"] == "周医生"
assert payload["doctor_signature"].startswith("data:image/png;base64,")
assert isinstance(payload["aux_usage"], dict)
editor.close()
application.processEvents()
def test_audit_reject_requires_remark(
application: QApplication,
) -> None:
dialog = AuditPrescriptionDialog({"id": 4})
dialog._choose("reject")
assert dialog.action == ""
assert not dialog.banner.isHidden()
dialog.remark.setPlainText("剂量需调整")
dialog._choose("reject")
assert dialog.action == "reject"
assert dialog.payload() == {
"id": 4,
"action": "reject",
"remark": "剂量需调整",
}
dialog.close()
application.processEvents()
def test_order_payload_and_a4_print_document(
application: QApplication,
immediate_async: None,
) -> None:
repository = SimpleNamespace(
list_paid_prescription_orders=lambda diagnosis_id: {
"lists": [{"id": 88, "order_no": "PAY-88", "amount": 100}],
"deposit_min_amount": 100,
}
)
prescription = {
"id": 12,
"diagnosis_id": 6,
"sn": "CF-12",
"patient_name": "林晓岚",
"phone": "13800000000",
"gender": 0,
"age": 33,
"clinical_diagnosis": "脾气虚",
"doctor_name": "周医生",
"prescription_date": "2026-08-10",
"audit_status": 1,
"dose_count": 7,
"dose_unit": "",
"usage_days": 7,
"times_per_day": 2,
"herbs": [
{"name": "黄芪", "dosage": 15, "formula_type": "主方"},
{"name": "酸枣仁", "dosage": 12, "formula_type": "辅方"},
],
}
order = PrescriptionOrderDialog(repository, prescription)
order._load_paid_orders()
order.shipping_province.setText("四川省")
order.shipping_city.setText("成都市")
order.shipping_district.setText("双流区")
order.shipping_address.setText("黄龙大道 280 号")
order.amount.setValue(100)
order.paid_orders.item(0).setCheckState(Qt.CheckState.Checked)
payload = order.payload()
assert payload["prescription_id"] == 12
assert payload["diagnosis_id"] == 6
assert payload["pay_order_ids"] == [88]
assert payload["amount"] == 100
assert payload["ship_mode"] == "gancao"
rendered = render_prescription_html(prescription)
assert "林晓岚" in rendered
assert "黄芪" in rendered
assert "酸枣仁" in rendered
viewer = PrescriptionDetailDialog(prescription)
assert "中医处方笺" in viewer.document.toHtml()
viewer.close()
order.close()
application.processEvents()
def test_demo_repository_pages_render_offscreen(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
user = SimpleNamespace(id=1, name="演示医生", root=1, role_ids=[0])
library = PrescriptionLibraryPage(repository, None, user)
issued = PrescriptionsPage(repository, None, user)
library.refresh()
issued.refresh()
assert library.table.rowCount() > 0
assert issued.table.rowCount() > 0
library.close()
issued.close()
application.processEvents()
+546
View File
@@ -0,0 +1,546 @@
from __future__ import annotations
import json
import os
from datetime import date
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import NOTE_LIMIT, ReceptionPage
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(reception_module, "run_async", run_immediately)
def _detail(
appointment_id: int,
*,
name: str,
status: int = 1,
phone: str = "13800138000",
) -> dict[str, Any]:
patient_id = appointment_id + 100
diagnosis_id = appointment_id + 200
return {
"appointment": {
"id": appointment_id,
"patient_id": patient_id,
"patient_name": name,
"status": status,
"appointment_date": date.today().isoformat(),
"appointment_time": "09:30",
"doctor_name": "张医生",
"assistant_name": "李医助",
"appointment_type_text": "复诊",
"channel_text": "线上",
"remark": "准时到诊",
},
"patient": {
"id": patient_id,
"phone": phone,
"gender": 2,
"age": 42,
"height": 165,
"weight": 55,
"region_text": "浙江省杭州市",
},
"diagnosis": {
"id": diagnosis_id,
"patient_id": patient_id,
"patient_name": name,
"phone": phone,
"chief_complaint": "反复口渴",
"present_illness": "持续两周",
"clinical_diagnosis": "消渴",
},
}
def test_queue_uses_admin_same_day_contract(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ReceptionPage(Repository(), PermissionSet([]))
page.search_edit.setText(" 王小明 ")
page.refresh()
assert calls == [
{
"status": 1,
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 1,
"page_size": 15,
"patient_name": "王小明",
}
]
page.queue_tabs.setCurrentIndex(1)
assert calls[-1]["status"] == 4
assert calls[-1]["start_date"] == calls[-1]["end_date"]
page.close()
application.processEvents()
def test_fast_patient_switch_rejects_late_detail(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(object(), PermissionSet(["*"]))
first = {"id": 11, "patient_id": 111, "diagnosis_id": 211, "patient_name": "甲患者"}
second = {"id": 22, "patient_id": 122, "diagnosis_id": 222, "patient_name": "乙患者"}
page._select_record(first)
page.note_edit.setPlainText("甲患者的未保存草稿")
page._pending_report_files = [r"C:\records\first.pdf"]
page._select_record(second)
assert len(callbacks) == 2
assert page.patient_name_label.text() == "乙患者"
assert page.note_edit.toPlainText() == ""
assert page._pending_report_files == []
callbacks[0]["on_success"]({"detail": _detail(11, name="甲患者")})
callbacks[0]["on_finished"]()
assert page._selected_appointment_id == 22
assert page.patient_name_label.text() == "乙患者"
assert page._selected_detail is None
callbacks[1]["on_success"]({"detail": _detail(22, name="乙患者")})
callbacks[1]["on_finished"]()
assert page._selected_detail == _detail(22, name="乙患者")
assert page.patient_name_label.text() == "乙患者"
assert "反复口渴" in page.diagnosis_text.text()
page.close()
application.processEvents()
def test_queue_load_more_accumulates_to_total_boundary(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
all_rows = [
{
"id": index,
"patient_id": 1000 + index,
"diagnosis_id": 2000 + index,
"patient_name": f"患者{index:02d}",
"status": 1,
}
for index in range(1, 23)
]
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {
"lists": all_rows[start : start + kwargs["page_size"]],
"count": len(all_rows),
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
row = all_rows[appointment_id - 1]
return {
"appointment": row,
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
assert page.queue_list.count() == 15
assert page.load_more_button.isVisibleTo(page)
page._load_more()
assert [call["page_no"] for call in calls] == [1, 2]
assert all(call["page_size"] == 15 for call in calls)
assert page.queue_list.count() == 22
assert page.queue_summary.text() == "已加载 22 / 共 22 位患者"
assert page.load_more_button.isHidden()
page.close()
application.processEvents()
def test_search_and_tab_changes_reset_accumulated_pages(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
if kwargs["status"] == 4:
rows = [{"id": 401, "patient_name": "过号患者", "status": 4}]
elif kwargs["patient_name"]:
rows = [{"id": 201, "patient_name": "搜索患者", "status": 1}]
else:
rows = [
{"id": index, "patient_name": f"患者{index}", "status": 1}
for index in range(1, 19)
]
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {"lists": rows[start : start + kwargs["page_size"]], "count": len(rows)}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": appointment_id + 1000,
"status": 1,
},
"diagnosis": {
"id": appointment_id + 2000,
"patient_id": appointment_id + 1000,
},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
page._load_more()
assert page.queue_list.count() == 18
page.search_edit.setText("搜索")
page.refresh()
assert page.queue_list.count() == 1
assert page._queue_page == 1
assert calls[-1]["patient_name"] == "搜索"
page.queue_tabs.setCurrentIndex(1)
assert page.queue_list.count() == 1
assert page._queue_page == 1
assert calls[-1]["status"] == 4
page.close()
application.processEvents()
def test_queue_worker_uses_frozen_widget_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
jobs: list[Any] = []
calls: list[dict[str, Any]] = []
def queue_async(function: Any, **_options: Any) -> object:
jobs.append(function)
return object()
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(Repository(), PermissionSet([]))
page.search_edit.setText("甲患者")
page.refresh()
page.search_edit.blockSignals(True)
page.search_edit.setText("乙患者")
page.search_edit.blockSignals(False)
page.queue_tabs.blockSignals(True)
page.queue_tabs.setCurrentIndex(1)
page.queue_tabs.blockSignals(False)
jobs[0]()
assert calls == [
{
"status": 1,
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 1,
"page_size": 15,
"patient_name": "甲患者",
}
]
page.close()
application.processEvents()
def test_phone_permission_and_ungated_notify_video_actions(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(31, name="脱敏患者")
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 31
return detail
masked_page = ReceptionPage(Repository(), PermissionSet([]))
masked_page._select_record(detail["appointment"])
assert masked_page.patient_labels["phone"].text() == "138****8000"
assert not masked_page.notify_button.isHidden()
assert not masked_page.video_button.isHidden()
plain_page = ReceptionPage(Repository(), PermissionSet(["tcm.diagnosis/phonePlain"]))
plain_page._select_record(detail["appointment"])
assert plain_page.patient_labels["phone"].text() == "13800138000"
assert plain_page.appointment_labels["doctor"].text() == "张医生"
assert plain_page.appointment_labels["assistant"].text() == "李医助"
assert plain_page.patient_labels["region"].text() == "浙江省杭州市"
masked_page.close()
plain_page.close()
application.processEvents()
def test_video_payload_keeps_three_identifiers_distinct(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(41, name="视频患者")
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 41
return detail
page = ReceptionPage(Repository(), PermissionSet([]))
page._select_record(detail["appointment"])
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page._request_video()
assert emitted == [
{
"source": "reception",
"appointment_id": 41,
"patient_id": 141,
"diagnosis_id": 241,
"patient_name": "视频患者",
"record": detail["appointment"],
}
]
page.close()
application.processEvents()
def test_completion_revalidates_server_status_before_write(
application: QApplication,
) -> None:
completed: list[int] = []
class Repository:
status = 3
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": 151,
"status": self.status,
}
}
def complete_appointment(self, appointment_id: int) -> dict[str, bool]:
completed.append(appointment_id)
return {"ok": True}
repository = Repository()
page = ReceptionPage(repository, PermissionSet(["doctor.appointment/complete"]))
with pytest.raises(ValueError, match="状态已变化"):
page._complete_after_revalidation(51)
assert completed == []
repository.status = 4
assert page._complete_after_revalidation(51) == {"ok": True}
assert completed == [51]
page.close()
application.processEvents()
def test_note_limit_and_attachment_payload_contract(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
jobs: list[tuple[Any, dict[str, Any]]] = []
received: list[dict[str, Any]] = []
uploads: list[dict[str, Any]] = []
def queue_async(function: Any, **options: Any) -> object:
jobs.append((function, options))
return object()
class Repository:
def upload_material(self, **kwargs: Any) -> str:
uploads.append(kwargs)
suffix = "tongue.jpg" if kwargs["material_type"] == "image" else "report.pdf"
return f"/uploads/{kwargs['material_type']}/{suffix}"
def add_doctor_note(self, diagnosis_id: int, content: str, **kwargs: Any) -> None:
received.append({"diagnosis_id": diagnosis_id, "content": content, **kwargs})
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(Repository(), PermissionSet(["doctor.appointment/addDoctorNote"]))
detail = _detail(61, name="备注患者")
page._selected_record = detail["appointment"]
page._selected_appointment_id = 61
page._selected_detail = detail
page._detail_generation = 7
page.note_edit.setPlainText("" * (NOTE_LIMIT + 20))
page._pending_tongue_images = [r"C:\records\tongue.jpg"]
page._pending_report_files = [r"C:\records\report.pdf"]
assert len(page.note_edit.toPlainText()) == NOTE_LIMIT
assert page.note_counter.text() == f"{NOTE_LIMIT} / {NOTE_LIMIT}"
page._save_note()
assert len(jobs) == 1
jobs[0][0]()
assert uploads == [
{"path": r"C:\records\tongue.jpg", "material_type": "image", "cid": 0},
{"path": r"C:\records\report.pdf", "material_type": "file", "cid": 0},
]
assert received == [
{
"diagnosis_id": 261,
"content": "" * NOTE_LIMIT,
"tongue_images": ["/uploads/image/tongue.jpg"],
"report_files": ["/uploads/file/report.pdf"],
}
]
jobs[0][1]["on_finished"]()
assert not page._note_busy
page.close()
application.processEvents()
def test_remote_note_uses_multipart_then_server_urls_only(
application: QApplication,
tmp_path: Path,
) -> None:
requests: list[httpx.Request] = []
tongue = tmp_path / "tongue.jpg"
report = tmp_path / "report.pdf"
tongue.write_bytes(b"tongue-image")
report.write_bytes(b"report-file")
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.url.path.endswith("/upload/image"):
return httpx.Response(
200,
json={"code": 1, "data": {"uri": "/materials/tongue.jpg"}},
)
if request.url.path.endswith("/upload/file"):
return httpx.Response(
200,
json={"code": 1, "data": {"url": "https://cdn.test/report.pdf"}},
)
return httpx.Response(200, json={"code": 1, "data": {"id": 9}})
with ApiClient(
"https://example.test",
transport=httpx.MockTransport(handler),
) as client:
repository = RemoteDoctorRepository(client)
page = ReceptionPage(repository, PermissionSet([]))
result = page._upload_and_add_note(
501,
"两阶段备注",
[str(tongue)],
[str(report)],
)
assert result == {"id": 9}
assert [request.url.path.rsplit("/", 2)[-2:] for request in requests] == [
["upload", "image"],
["upload", "file"],
["doctor.appointment", "addDoctorNote"],
]
for upload_request in requests[:2]:
assert upload_request.headers["content-type"].startswith("multipart/form-data; boundary=")
assert b'name="cid"' in upload_request.content
assert b"\r\n0\r\n" in upload_request.content
note_payload = json.loads(requests[-1].content)
assert note_payload == {
"diagnosis_id": 501,
"content": "两阶段备注",
"tongue_images": ["/materials/tongue.jpg"],
"report_files": ["https://cdn.test/report.pdf"],
}
assert str(tmp_path) not in requests[-1].content.decode("utf-8")
page.close()
application.processEvents()
def test_partial_upload_failure_never_submits_note(
application: QApplication,
) -> None:
submitted: list[dict[str, Any]] = []
class Repository:
def upload_material(self, path: str, material_type: str, cid: int = 0) -> str:
del material_type, cid
if path.endswith("bad.pdf"):
raise OSError("磁盘读取失败")
return "/materials/good.jpg"
def add_doctor_note(self, **kwargs: Any) -> None:
submitted.append(kwargs)
page = ReceptionPage(Repository(), PermissionSet([]))
with pytest.raises(RuntimeError, match="bad.pdf.*上传失败"):
page._upload_and_add_note(
501,
"不会提交",
[r"C:\records\good.jpg"],
[r"C:\records\bad.pdf"],
)
assert submitted == []
page.close()
application.processEvents()
+358
View File
@@ -0,0 +1,358 @@
"""No-network contracts for the audited five doctor workspaces."""
from __future__ import annotations
from datetime import date
from typing import Any
import pytest
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import (
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
RemoteDoctorRepository,
)
class RecordingClient:
"""Small API client double retaining exact endpoint and DTO calls."""
def __init__(self) -> None:
self.base_url = "https://example.test/adminapi/"
self.token = "token"
self.get_calls: list[tuple[str, dict[str, Any]]] = []
self.post_calls: list[tuple[str, dict[str, Any]]] = []
def set_token(self, token: str) -> None:
"""Set the current synthetic token."""
self.token = token
def clear_token(self) -> None:
"""Clear the current synthetic token."""
self.token = ""
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
"""Record a GET and return a shape appropriate for its contract."""
self.get_calls.append((endpoint, dict(params or {})))
if endpoint == "auth.admin/mySelf":
return {
"user": {"id": 1, "name": "Doctor", "role_ids": [1]},
"permissions": ["tcm.diagnosis/lists"],
"menu": [
{
"name": "问诊列表",
"component": "tcm/diagnosis/index",
"future": {"badge": 3},
"children": [{"name": "只读", "perms": "tcm.diagnosis/readonlyDetail"}],
"unsafe": lambda: None,
7: "non-string key",
}
],
}
if endpoint in {
"tcm.diagnosis/detail",
"tcm.diagnosis/readonlyDetail",
"firstvisit.myPatient/orderDetail",
"tcm.prescriptionOrder/detail",
}:
return {"id": int((params or {}).get("id", 0)), "patient_name": "测试患者"}
if endpoint == "tcm.prescription/getByAppointment":
return {}
if endpoint == "doctor.appointment/availableSlots":
return {"slots": [{"time": "09:00", "available": True}]}
if endpoint == "tcm.prescriptionOrder/paidPayOrders":
return {"lists": [{"id": 9}], "deposit_min_amount": 50}
return {"lists": [], "count": 0, "extend": {"scope": {"label": "server"}}}
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
"""Record a POST and return a stable synthetic mutation result."""
body = dict(payload or {})
self.post_calls.append((endpoint, body))
if endpoint in {"tcm.prescription/add", "tcm.prescriptionOrder/create"}:
return {"id": 88}
return {"ok": True}
def test_page_result_preserves_outer_and_nested_extend() -> None:
"""Server scope metadata must survive nested data pagination envelopes."""
page = PageResult.from_payload(
{
"extend": {"scope": {"label": "doctor"}},
"data": {
"lists": [{"id": 1}],
"count": 4,
"extend": {"schedule_mode": "roster"},
},
},
Appointment.from_dict,
)
assert page.total == 4
assert page.extend == {
"scope": {"label": "doctor"},
"schedule_mode": "roster",
}
def test_consultation_keeps_diagnosis_and_appointment_status_separate() -> None:
"""Video eligibility fields cannot be overwritten by diagnosis enablement."""
row = Consultation.from_dict(
{
"id": 3,
"status": 1,
"status_desc": "启用",
"has_appointment": 1,
"appointment_status": 4,
"appointment_status_text": "已过号",
"appointments": [{"id": 8, "status": 4}],
"DiagnosisViewRecord": [{"is_confirmed": 1}],
}
)
assert row.status == 1
assert row.appointment_status == 4
assert row.has_appointment
assert row.confirmed
assert row.appointments == [{"id": 8, "status": 4}]
def test_prescription_round_trips_appointment_and_case_record() -> None:
"""Appointment authority and immutable case snapshot survive model DTOs."""
prescription = Prescription.from_dict(
{
"id": 8,
"diagnosis_id": 5,
"appointment_id": 17,
"case_record": {
"diagnosis_id": 5,
"appointment_id": 17,
"clinical_diagnosis": "气阴两虚证",
},
}
)
assert prescription.appointment_id == 17
assert prescription.case_record["clinical_diagnosis"] == "气阴两虚证"
payload = prescription.to_api_dict()
assert payload["appointment_id"] == 17
assert payload["case_record"] == {
"diagnosis_id": 5,
"appointment_id": 17,
"clinical_diagnosis": "气阴两虚证",
}
def test_remote_reception_is_forcibly_scoped_to_today() -> None:
"""Status 1/4 queue reads always carry the audited same-day date range."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.list_appointments(status=1, keyword="", page_no=2, page_size=15)
endpoint, params = client.get_calls[-1]
assert endpoint == "doctor.appointment/lists"
assert params == {
"status": 1,
"patient_name": "",
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 2,
"page_size": 15,
}
def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
"""Prescription, patient and diagnosis methods remain thin endpoint adapters."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.create_prescription(
patient_name="测试患者",
diagnosis_id=5,
herbs=[{"medicine_id": 1, "name": "黄芪", "dosage": 10}],
)
repository.patch_prescription_patient(8, patient_name="修正患者", phone="13800000000", gender=2)
repository.audit_prescription(8, action="reject", remark="剂量不符")
repository.create_prescription_order(
prescription_id=8,
diagnosis_id=5,
recipient_name="修正患者",
recipient_phone="13800000000",
)
repository.list_paid_prescription_orders(5, prescription_order_id=3)
repository.list_medicines(name="黄芪")
repository.patient_orders(page_no=1, page_size=15, fulfillment_status=2)
repository.patient_progress(page_no=1, page_size=15, status=1)
repository.patient_detail(5)
repository.appointment_history(5)
repository.assign_history(5)
repository.assign_patient(5, 20, is_inherit=1)
repository.fill_patient_id_card(5, "410000199001010000")
repository.book_patient_appointment({"diagnosis_id": 5, "appointment_date": "2026-08-10"})
repository.cancel_patient_appointment(7)
repository.update_diagnosis(5, {"clinical_diagnosis": "气虚证"})
repository.list_appointment_rosters(
doctor_id=1,
start_date="2026-08-10",
end_date="2026-08-16",
)
slots = repository.get_available_appointment_slots(
doctor_id=1,
appointment_date="2026-08-10",
)
assert (
"tcm.prescription/patchPatient",
{
"id": 8,
"patient_name": "修正患者",
"phone": "13800000000",
"gender": 2,
},
) in client.post_calls
assert (
"tcm.prescription/audit",
{
"id": 8,
"action": "reject",
"remark": "剂量不符",
},
) in client.post_calls
assert (
"firstvisit.myPatient/assign",
{
"id": 5,
"assistant_id": 20,
"is_inherit": 1,
},
) in client.post_calls
assert ("tcm.diagnosis/edit", {"id": 5, "clinical_diagnosis": "气虚证"}) in client.post_calls
assert slots == {"slots": [{"time": "09:00", "available": True}]}
get_endpoints = {endpoint for endpoint, _ in client.get_calls}
assert {
"tcm.prescriptionOrder/paidPayOrders",
"doctor.medicine/lists",
"firstvisit.myPatient/orders",
"firstvisit.myPatient/progress",
"tcm.diagnosis/readonlyDetail",
"doctor.appointment/lists",
"tcm.diagnosis/assignLogList",
"doctor.roster/lists",
"doctor.appointment/availableSlots",
} <= get_endpoints
@pytest.mark.parametrize(
"unsafe_reference",
[r"C:\records\tongue.jpg", r"\\server\share\report.pdf", "file:///tmp/a.jpg"],
)
def test_remote_note_rejects_local_material_references(unsafe_reference: str) -> None:
"""No drive, UNC or file URI can reach addDoctorNote JSON."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
with pytest.raises(ValueError, match="server uri/url"):
repository.add_doctor_note(5, tongue_images=[unsafe_reference])
assert not any(
endpoint == "doctor.appointment/addDoctorNote" for endpoint, _payload in client.post_calls
)
def test_remote_dynamic_menu_preserves_json_metadata_but_drops_runtime_objects() -> None:
"""Future menu fields pass through safely without evaluating arbitrary values."""
repository = RemoteDoctorRepository(RecordingClient()) # type: ignore[arg-type]
menu = repository.get_session().menu
assert menu[0]["component"] == "tcm/diagnosis/index"
assert menu[0]["future"] == {"badge": 3}
assert menu[0]["children"][0]["perms"] == "tcm.diagnosis/readonlyDetail"
assert "unsafe" not in menu[0]
assert 7 not in menu[0]
def test_canonical_prescription_permissions_match_routed_views() -> None:
"""Service exports one canonical spelling for each routed action."""
assert PRESCRIPTION_LIBRARY_PERMISSIONS == {
"create": "wcf.prescription/add",
"read": "wcf.prescription/read",
"update": "wcf.prescription/edit",
"delete": "wcf.prescription/delete",
}
assert PRESCRIPTION_PERMISSIONS["delete"] == "cf.prescription/del"
assert PRESCRIPTION_PERMISSIONS["patch_patient"] == "tcm.prescription/patchPatient"
def test_demo_mutates_prescriptions_orders_and_patient_workspaces() -> None:
"""Offline mode supports the full workflow rather than static placeholders."""
repository = DemoDoctorRepository(today=date(2026, 8, 10))
created = repository.create_prescription(
diagnosis_id=501,
patient_name="林晓岚",
phone="13800131203",
gender=2,
herbs=[{"medicine_id": 17, "name": "黄芪", "dosage": 20}],
doctor_name="陈医生(演示)",
doctor_signature="data:image/png;base64,demo",
)
updated = repository.update_prescription(created.id, {"clinical_diagnosis": "气虚证"})
repository.patch_prescription_patient(
created.id, patient_name="林晓岚(修正)", phone="13800131203", gender=2
)
audit = repository.audit_prescription(created.id, action="approve")
order = repository.create_prescription_order(
prescription_id=created.id,
diagnosis_id=501,
recipient_name="林晓岚(修正)",
recipient_phone="13800131203",
amount=268,
)
assert updated.clinical_diagnosis == "气虚证"
assert audit["audit_status"] == 1
assert repository.get_prescription(created.id).has_prescription_order
assert repository.get_prescription_order(order["id"])["prescription_id"] == created.id
assert repository.patient_orders().extend["summary"]["order_count"] == 2
assigned = repository.assign_patient(501, 2002)
repository.fill_patient_id_card(501, "410000199001010000")
appointment = repository.book_patient_appointment(
diagnosis_id=501,
appointment_date="2026-08-11",
appointment_time="15:00-15:30",
)
repository.update_diagnosis(501, {"chief_complaint": "乏力"})
assert assigned["assistant_name"] == "许医助"
assert repository.assign_history(501).total == 2
assert repository.patient_detail(501)["diagnosis"]["chief_complaint"] == "乏力"
assert repository.appointment_history(501).total == 2
repository.cancel_patient_appointment(appointment["id"])
assert repository.appointment_history(501).items[-1].status == 2
assert repository.list_medicines(name="黄芪").items[0]["name"] == "黄芪"
def test_reject_actions_require_a_remark() -> None:
"""Audit rejection mirrors the admin dialog's mandatory reason boundary."""
repository = DemoDoctorRepository(today=date(2026, 8, 10))
with pytest.raises(ValueError, match="remark"):
repository.audit_prescription(802, action="reject")
with pytest.raises(ValueError, match="remark"):
repository.audit_patient_order_payment(901, "reject")
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QApplication
from doctor_workstation import app as app_module
from doctor_workstation.app import ApplicationController
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import login as login_module
from doctor_workstation.ui import widgets as widget_module
from doctor_workstation.ui.login import LoginWindow
from doctor_workstation.ui.pages.consultations import _video_payload
from doctor_workstation.ui.shell import NAVIGATION
from doctor_workstation.ui.widgets import (
gender_text,
invoke,
set_authentication_expired_handler,
)
def test_consultation_video_payload_keeps_appointment_and_diagnosis_ids_distinct() -> None:
record = SimpleNamespace(
id=501,
appointment_id=101,
patient_id=301,
patient_name="林晓岚",
)
payload = _video_payload(record)
assert payload["appointment_id"] == 101
assert payload["diagnosis_id"] == 501
assert payload["patient_id"] == 301
def test_gender_text_maps_legacy_codes_and_preserves_labels() -> None:
assert gender_text(1) == ""
assert gender_text("2") == ""
assert gender_text(0) == "未知"
assert gender_text("未知标签") == "未知标签"
def test_navigation_requires_each_pages_actual_list_capability() -> None:
assert {item.key: item.permissions for item in NAVIGATION} == {
"reception": ("doctor.appointment/lists",),
"prescription_library": ("tcm.prescriptionLibrary/lists",),
"prescriptions": ("tcm.prescription/lists",),
"patients": ("firstvisit.myPatient/lists",),
"consultations": ("tcm.diagnosis/lists",),
}
def test_video_release_does_not_remove_a_newer_call() -> None:
older = object()
newer = object()
controller = SimpleNamespace(video_calls={"501": newer})
ApplicationController._release_video_call(controller, "501", older)
assert controller.video_calls == {"501": newer}
ApplicationController._release_video_call(controller, "501", newer)
assert controller.video_calls == {}
def test_invoke_leaves_prescription_filters_for_repository_mapping() -> None:
"""The UI adapter only normalises pagination, not API-specific DTO fields."""
class Repository:
def list_prescriptions(self, **filters: Any) -> dict[str, Any]:
return filters
assert invoke(
Repository(),
"prescriptions",
keyword="CF-2026-8",
status=2,
page=3,
page_size=15,
) == {
"keyword": "CF-2026-8",
"status": 2,
"page_no": 3,
"page_size": 15,
}
def test_async_error_dispatch_consumes_active_session_expiry_globally() -> None:
"""A consumed authentication expiry does not also reach a stale page."""
global_errors: list[Exception] = []
local_errors: list[Exception] = []
error = AuthenticationExpiredError("expired", code=-1)
try:
set_authentication_expired_handler(lambda caught: global_errors.append(caught) is None)
widget_module._dispatch_async_error(error, local_errors.append)
finally:
set_authentication_expired_handler(None)
assert global_errors == [error]
assert local_errors == []
def test_controller_session_expiry_returns_to_login_once() -> None:
"""The composition root owns the single transition out of an active shell."""
messages: list[str] = []
controller = SimpleNamespace(
shell_window=object(),
current_repository=object(),
_authentication_expiry_in_progress=False,
_logout=lambda *, message="": messages.append(message),
)
error = AuthenticationExpiredError("expired", code=-1)
assert ApplicationController._on_authentication_expired(controller, error)
assert ApplicationController._on_authentication_expired(controller, error)
assert messages == ["登录状态已失效,请重新登录。"]
def test_persisted_session_restore_blocks_manual_submit_before_worker_start(
monkeypatch: Any,
) -> None:
"""Login controls are locked before the asynchronous restore is dispatched."""
pending_states: list[bool] = []
worker_calls: list[tuple[Any, dict[str, Any]]] = []
worker = object()
repository = SimpleNamespace(restore_session=lambda: None)
login_window = SimpleNamespace(set_session_restore_pending=pending_states.append)
controller = SimpleNamespace(
remote_repository=repository,
_shutting_down=False,
config=SimpleNamespace(demo_mode=False),
current_repository=None,
_restore_generation=0,
_restore_in_progress=False,
_restore_worker=None,
login_window=login_window,
_on_restore_success=lambda *args: None,
_on_restore_error=lambda *args: None,
_on_restore_finished=lambda *args: None,
)
def fake_run_async(function: Any, **callbacks: Any) -> object:
assert pending_states == [True]
worker_calls.append((function, callbacks))
return worker
monkeypatch.setattr(app_module, "run_async", fake_run_async)
ApplicationController._begin_session_restore(controller)
assert controller._restore_in_progress
assert controller._restore_worker is worker
assert worker_calls[0][0] == repository.restore_session
def test_login_restore_pending_uses_existing_submit_guard() -> None:
"""A manual submit is a no-op for the whole persisted-token validation window."""
class Banner:
def clear(self) -> None:
pass
class LoginDouble:
def __init__(self) -> None:
self._loading = False
self.error_banner = Banner()
self.loading_options: dict[str, Any] = {}
def _set_loading(self, loading: bool, **options: Any) -> None:
self._loading = loading
self.loading_options = options
login = LoginDouble()
LoginWindow.set_session_restore_pending(login, True) # type: ignore[arg-type]
LoginWindow.submit(login) # type: ignore[arg-type]
assert login._loading
assert login.loading_options["button_text"] == "正在恢复登录…"
def test_real_demo_login_reaches_success_without_widget_adapter(
monkeypatch: Any,
tmp_path: Any,
) -> None:
"""Exercise the actual login widgets and demo repository as one contract."""
application = QApplication.instance() or QApplication([])
repository = DemoDoctorRepository()
settings = QSettings(str(tmp_path / "login.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="https://127.0.0.1:9",
request_timeout=30,
demo_mode=True,
remembered_account="",
)
payloads: list[dict[str, Any]] = []
def run_immediately(function: Any, **callbacks: Any) -> object:
try:
callbacks["on_success"](function())
except Exception as error: # pragma: no cover - assertion output is more useful
callbacks["on_error"](error)
finally:
callbacks["on_finished"]()
return object()
monkeypatch.setattr(login_module, "run_async", run_immediately)
window = LoginWindow(
object(),
config=config,
demo_repository=repository,
settings=settings,
)
window.login_succeeded.connect(payloads.append)
window.submit()
assert len(payloads) == 1
assert payloads[0]["repository"] is repository
assert payloads[0]["demo_mode"] is True
assert window.busy_overlay.label.text() == "正在验证账号…"
assert not window._loading
window.close()
application.processEvents()
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
import logging
import sys
import threading
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
from doctor_workstation.video.launcher import ( # noqa: E402
BackendMode,
VideoCallLauncher,
VideoCallRequest,
VideoTicketError,
normalize_backend_ticket,
)
from doctor_workstation.video.lifecycle import OrderedCallLifecycle # noqa: E402
from doctor_workstation.video.security import ( # noqa: E402
TrustedDocumentError,
TrustedDocumentPolicy,
)
def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
request = normalize_backend_ticket(
{
"sdkAppId": "1400123456",
"userId": " doctor_42 ",
"userSig": "short-lived-ticket",
"patientUserId": " patient_8 ",
"diagnosisId": 123,
"patientId": 8,
}
)
assert 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,
)
assert request.to_web_config() == {
"SDKAppID": 1400123456,
"userID": "doctor_42",
"userSig": "short-lived-ticket",
"targetUserId": "patient_8",
"diagnosisId": 123,
}
def test_accepts_uppercase_aliases_and_nested_backend_envelope() -> None:
request = VideoCallRequest.from_backend_ticket(
{
"data": {
"SDKAppID": 1400123456,
"userID": "doctor_42",
"userSig": "ticket-value",
"targetUserId": "patient_8",
}
},
diagnosis_id="diagnosis-123",
patient_id=8,
backend_mode="embedded",
)
assert request.diagnosis_id == "diagnosis-123"
assert request.backend_mode is BackendMode.EMBEDDED
def test_accepts_repository_call_ticket_object_without_importing_core_models() -> None:
ticket = SimpleNamespace(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="ticket-value",
patient_user_id="patient_8",
diagnosis_id=123,
raw={"sdkAppId": 1400123456},
)
request = normalize_backend_ticket(ticket, patient_id=8)
assert request.patient_id == 8
assert request.to_web_config()["targetUserId"] == "patient_8"
def test_secret_is_excluded_from_repr_and_safe_log_context() -> None:
request = normalize_backend_ticket(
{
"sdkAppId": 1400123456,
"userId": "doctor_42",
"userSig": "never-write-this-value",
"patientUserId": "patient_8",
},
diagnosis_id=123,
)
assert "never-write-this-value" not in repr(request)
assert "never-write-this-value" not in str(request.safe_log_context())
assert "user_sig" not in request.safe_log_context()
@pytest.mark.parametrize("forbidden_key", ["SDKSecretKey", "sdk_secret_key", "secretKey"])
def test_rejects_server_side_secret_material(forbidden_key: str) -> None:
with pytest.raises(VideoTicketError, match="forbidden server-side secret"):
normalize_backend_ticket(
{
"sdkAppId": 1400123456,
"userId": "doctor_42",
"userSig": "ticket-value",
"patientUserId": "patient_8",
"diagnosisId": 123,
forbidden_key: "must-never-reach-a-client",
}
)
@pytest.mark.parametrize(
("field", "value"),
[
("sdkAppId", 0),
("userId", ""),
("userSig", ""),
("patientUserId", " "),
("diagnosisId", None),
],
)
def test_rejects_incomplete_or_invalid_ticket(field: str, value: object) -> None:
ticket: dict[str, object] = {
"sdkAppId": 1400123456,
"userId": "doctor_42",
"userSig": "ticket-value",
"patientUserId": "patient_8",
"diagnosisId": 123,
}
ticket[field] = value
with pytest.raises(VideoTicketError):
normalize_backend_ticket(ticket)
def test_rejects_conflicting_aliases_and_modes() -> None:
with pytest.raises(VideoTicketError, match="conflicting SDKAppID aliases"):
normalize_backend_ticket(
{
"SDKAppID": 1400123456,
"sdkAppId": 1400654321,
"userID": "doctor_42",
"userSig": "ticket-value",
"targetUserId": "patient_8",
"diagnosisId": 123,
}
)
with pytest.raises(VideoTicketError, match="backend mode"):
BackendMode.parse("native")
def test_launcher_rejects_browser_before_importing_window_or_writing_repository() -> None:
class Repository:
def start_call(self, **payload: object) -> None:
raise AssertionError(f"unexpected repository write: {payload}")
launcher = VideoCallLauncher(repository=Repository(), backend_mode="browser")
with pytest.raises(VideoTicketError, match="one-time handoff"):
launcher.prepare(
{
"sdkAppId": 1400123456,
"userId": "doctor_42",
"userSig": "ticket-value",
"patientUserId": "patient_8",
"diagnosisId": 123,
}
)
def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
events: list[tuple[object, ...]] = []
start_entered = threading.Event()
release_start = threading.Event()
class Repository:
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int) -> None:
start_entered.set()
assert release_start.wait(2)
events.append(("start", diagnosis_id, patient_id, call_type))
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
events.append(("bind", diagnosis_id, room_id))
def end_call(self, diagnosis_id: int) -> None:
events.append(("end", diagnosis_id))
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
started_at = time.monotonic()
start_future = lifecycle.start()
bind_future = lifecycle.bind_room("456789")
duplicate_bind = lifecycle.bind_room("456789")
changed_bind = lifecycle.bind_room("another-room")
end_future = lifecycle.end("test")
elapsed = time.monotonic() - started_at
assert start_entered.wait(1)
assert elapsed < 0.2
assert lifecycle.worker_is_daemon is True
assert lifecycle.wait(0.01) is False
assert duplicate_bind is bind_future
assert changed_bind.result(timeout=0) is False
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 events == [
("start", 123, 8, 2),
("bind", 123, "456789"),
("end", 123),
]
def test_failed_start_prevents_bind_and_end_writes() -> None:
events: list[str] = []
class Repository:
def start_call(self, diagnosis_id: int, *, call_type: int) -> None:
del diagnosis_id, call_type
events.append("start")
raise RuntimeError("backend unavailable")
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
del diagnosis_id, room_id
events.append("bind")
def end_call(self, diagnosis_id: int) -> None:
del diagnosis_id
events.append("end")
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
start_future = lifecycle.start()
bind_future = lifecycle.bind_room("456789")
end_future = lifecycle.end("test")
with pytest.raises(RuntimeError, match="backend unavailable"):
start_future.result(timeout=1)
assert bind_future.result(timeout=1) is False
assert end_future.result(timeout=1) is False
assert lifecycle.wait(1) is True
assert events == ["start"]
def test_https_document_policy_is_exact_and_origin_scoped() -> None:
policy = TrustedDocumentPolicy.from_url(
"https://RTC.Example.com/doctor-call/index.html?tenant=a#boot",
is_local=False,
)
assert policy.allows_main_document(
"https://rtc.example.com:443/doctor-call/index.html?tenant=a#ready"
)
assert not policy.allows_main_document(
"https://rtc.example.com/doctor-call/index.html?tenant=b"
)
assert not policy.allows_main_document("https://rtc.example.com/other/index.html?tenant=a")
assert policy.allows_origin("https://rtc.example.com")
assert not policy.allows_origin("https://sub.rtc.example.com")
assert not policy.allows_origin("http://rtc.example.com")
with pytest.raises(TrustedDocumentError, match="HTTPS"):
TrustedDocumentPolicy.from_url("http://rtc.example.com/doctor-call", is_local=False)
def test_local_document_policy_rejects_sibling_files(tmp_path: Path) -> None:
index = tmp_path / "dist" / "index.html"
index.parent.mkdir()
index.touch()
sibling = index.with_name("other.html")
sibling.touch()
policy = TrustedDocumentPolicy.from_url(index.as_uri(), is_local=True)
assert policy.allows_main_document(index.as_uri())
assert not policy.allows_main_document(sibling.as_uri())
assert policy.allows_origin("file:///")
Generated
+790
View File
@@ -0,0 +1,790 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "altgraph"
version = "0.17.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
]
[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "backports-tarfile"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.15.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" },
{ url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" },
{ url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" },
{ url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" },
{ url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" },
{ url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" },
{ url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" },
{ url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" },
{ url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" },
{ url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" },
{ url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" },
{ url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" },
{ url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" },
{ url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" },
{ url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" },
{ url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" },
{ url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" },
{ url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" },
{ url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" },
{ url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" },
{ url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" },
{ url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" },
{ url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" },
{ url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" },
{ url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" },
{ url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" },
{ url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" },
{ url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" },
{ url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" },
{ url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" },
{ url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" },
{ url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" },
{ url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" },
{ url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" },
{ url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" },
{ url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" },
{ url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" },
{ url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" },
{ url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" },
{ url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" },
{ url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" },
{ url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" },
{ url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" },
{ url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" },
{ url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" },
{ url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" },
{ url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" },
{ url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" },
{ url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" },
{ url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" },
{ url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" },
{ url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" },
{ url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" },
{ url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" },
{ url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" },
{ url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" },
{ url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" },
{ url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" },
{ url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" },
{ url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" },
{ url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" },
{ url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" },
{ url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" },
{ url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" },
{ url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" },
{ url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" },
{ url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" },
{ url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" },
{ url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" },
{ url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" },
{ url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" },
{ url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" },
{ url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" },
{ url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" },
{ url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" },
{ url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" },
{ url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" },
{ url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" },
{ url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" },
{ url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" },
{ url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" },
{ url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" },
{ url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" },
{ url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" },
{ url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" },
{ url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "cryptography"
version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
{ url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
{ url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
{ url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "importlib-metadata"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-tarfile", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "macholib"
version = "1.16.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
]
[[package]]
name = "more-itertools"
version = "11.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pefile"
version = "2024.8.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
]
[[package]]
name = "platformdirs"
version = "4.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/0a/062135c9a98dac804265073cc3afdbec5ae1aa37980bb354f461bafe81b4/platformdirs-4.11.1.tar.gz", hash = "sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27", size = 32396, upload-time = "2026-08-07T23:06:48.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl", hash = "sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386", size = 23261, upload-time = "2026-08-07T23:06:47.219Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyinstaller"
version = "6.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
{ name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "pyinstaller-hooks-contrib" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/03/669d06735cf57d7e2e5dfc3c2e1643554b34c559eff21449c58172ca0335/pyinstaller-6.22.0.tar.gz", hash = "sha256:8b0166fff4583b374bbe7fa044bf03ddc33cab0f792a665807d96048e63f060e", size = 4072247, upload-time = "2026-08-08T13:34:52.418Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/c7/abe1ca65ab55e615961df1b978b997129a8488ab9dc0931dc7664bdc4c99/pyinstaller-6.22.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:d7c7c5b149f7f9b37585b37e2376f774038b6a5633ba34db0de2698f75391e13", size = 1058495, upload-time = "2026-08-08T13:33:46.963Z" },
{ url = "https://files.pythonhosted.org/packages/17/d0/b7874153bd034fcfe0f47abe1140b49d436e262a3ad18cd52ba86be6358c/pyinstaller-6.22.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f92f78a9c463b884d57f90c7459c0cb263ccd26a499356bbfac7a5f41fd5b93f", size = 752753, upload-time = "2026-08-08T13:33:51.247Z" },
{ url = "https://files.pythonhosted.org/packages/b9/45/810aee0ad8c137667f2685783cde14c8517357ba08e1c54599ba77547578/pyinstaller-6.22.0-py3-none-manylinux2014_i686.whl", hash = "sha256:f6c750c8ae999cc5520ddbce4263ac3ece96db04ba359ef20acacd81bccc201b", size = 765543, upload-time = "2026-08-08T13:33:55.44Z" },
{ url = "https://files.pythonhosted.org/packages/96/36/24af3e59ca090ae1c0ba9daaeaa10125e99b767df5e3ab06384b516ff1e7/pyinstaller-6.22.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:0a20c37761851100f422ec261f513f46d8e57100ea22886b9ff7a6c346104bd8", size = 764130, upload-time = "2026-08-08T13:33:59.623Z" },
{ url = "https://files.pythonhosted.org/packages/45/17/92cb544e29ad867bcf5a06efd63dcf93af7c662f2121333b99409615b50b/pyinstaller-6.22.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:e6859e80b9c7dccb8a1687c5ed4644a722b370c2aa13e8a9ca5ebf5f9bb842ce", size = 759221, upload-time = "2026-08-08T13:34:03.687Z" },
{ url = "https://files.pythonhosted.org/packages/ea/aa/3733a6f590075c8a6678072480ee22f7b0237f7952b2c1521d5aafa15514/pyinstaller-6.22.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b07ac9fd1d661ae4d0af6752c3941e4e5576a16cfe7e2d6504a32d57e17d0acc", size = 759087, upload-time = "2026-08-08T13:34:08.012Z" },
{ url = "https://files.pythonhosted.org/packages/f8/b0/300a00798920d5e8e52f71fb0ceb2955a71c24814eaf6c086aeb95944049/pyinstaller-6.22.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:7885de3c830356b0990b27fe164f2b008fb4a18560bac884cf33985f61469f44", size = 758597, upload-time = "2026-08-08T13:34:12.164Z" },
{ url = "https://files.pythonhosted.org/packages/3c/ba/3c5f8cbe3686f8080eadadee10a0da4c7af3eaff72998165d5f5174f5a40/pyinstaller-6.22.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:81dc305c16510c1f9ead6d115b66f381e3d3453ecb96653099c51aa3050420d1", size = 758149, upload-time = "2026-08-08T13:34:16.381Z" },
{ url = "https://files.pythonhosted.org/packages/b6/66/f7de05fa65b141cbd83bf61f56730b3f5249820a4de026e11feadffca3c8/pyinstaller-6.22.0-py3-none-win32.whl", hash = "sha256:6f527c1345d5010682587aebc2f0c1cc34a9b1f200424969c7bfda723cddc074", size = 1341311, upload-time = "2026-08-08T13:34:23.386Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a7/0ea60e966a0da82961ecba80177627fae28fdda5127993281db479ee46fc/pyinstaller-6.22.0-py3-none-win_amd64.whl", hash = "sha256:6e5f3656de100954bf5db25536c43e097e46d482843a96d03a0852bf266e4853", size = 1403046, upload-time = "2026-08-08T13:34:30.352Z" },
{ url = "https://files.pythonhosted.org/packages/47/9a/71fbfdeffc60107c8256436ea62eb39dd35266164f721091242924363747/pyinstaller-6.22.0-py3-none-win_arm64.whl", hash = "sha256:b1233da63926be213cdfbfbb314735bc7762136ed772b1082e1b0a2d7ed896b5", size = 1350592, upload-time = "2026-08-08T13:34:36.532Z" },
]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2026.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" },
]
[[package]]
name = "pyside6"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-addons" },
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" },
{ url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" },
{ url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" },
{ url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" },
]
[[package]]
name = "pyside6-addons"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" },
{ url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" },
]
[[package]]
name = "pyside6-essentials"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" },
{ url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" },
{ url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest-cov"
version = "6.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "ruff"
version = "0.16.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" },
{ url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" },
{ url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" },
{ url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" },
{ url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" },
{ url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" },
{ url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" },
{ url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" },
{ url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" },
{ url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" },
{ url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" },
]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "setuptools"
version = "84.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
]
[[package]]
name = "shiboken6"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" },
{ url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" },
{ url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" },
{ url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" },
{ url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "zhenyang-doctor-workstation"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "keyring" },
{ name = "platformdirs" },
{ name = "pyside6" },
{ name = "python-dotenv" },
]
[package.optional-dependencies]
build = [
{ name = "pyinstaller" },
]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27.2,<1" },
{ name = "keyring", specifier = ">=25.5,<26" },
{ name = "platformdirs", specifier = ">=4.3,<5" },
{ name = "pyinstaller", marker = "extra == 'build'", specifier = ">=6.11,<7" },
{ name = "pyside6", specifier = ">=6.8.2,<7" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<9" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6,<7" },
{ name = "python-dotenv", specifier = ">=1.0.1,<2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9,<1" },
]
provides-extras = ["dev", "build"]
[[package]]
name = "zipp"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" },
]
+35
View File
@@ -0,0 +1,35 @@
# Doctor Video Companion
这是桌面端专用的最小 Vue 3 / Vite 视频页面。它与 admin 的独立视频组件保持同一条 SDK 主链,直接使用固定版本 `@trtc/calls-uikit-vue@4.4.6`
## 构建
```bash
cd video_companion
npm ci
npm run build
```
构建产物位于 `video_companion/dist/`。Vite 使用相对资源路径,因此产物既能由 HTTPS 托管,也能由 QtWebEngine 从本地安装目录加载。
桌面端当前只在隔离的 QtWebEngine 中加载此页面。页面不会自行向后端领取通话票据,因此在后端提供服务端签发的一次性 handoff 前,系统浏览器模式与自动浏览器降级均被禁用。
## 宿主契约
页面加载后会暴露:
```ts
window.doctorCall.start({
SDKAppID: 1400000000, // 也接受 sdkAppId
userID: 'doctor_42', // 也接受 userId
userSig: '<后端短时票据>',
targetUserId: 'patient_8', // 也接受 patientUserId
diagnosisId: 123,
})
await window.doctorCall.hangup()
```
`userSig` 必须由业务后端签发。此页面不会生成 UserSig,也不接受或使用 SDKSecretKey。
状态、错误与挂断消息优先调用 `window.qtVideoBridge.notify(JSON.stringify(message))`。没有 Qt bridge 时,页面使用 `postMessage` 通知父窗口或 opener;都不可用时只输出不含票据的控制台状态。

Some files were not shown because too many files have changed in this diff Show More