更新
@@ -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=
|
||||
@@ -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
|
||||
@@ -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%
|
||||
@@ -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)。
|
||||
@@ -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%
|
||||
@@ -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 建议 Authenticode;macOS 需要 Developer ID、Hardened Runtime、摄像头/麦克风用途说明与公证。
|
||||
|
||||
发现凭据泄露、越权、患者数据写入日志或视频房间被未授权加入时,应立即停用相关凭据、保留审计证据并按组织的安全响应流程处置。
|
||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -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())
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
project_root="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
exec /bin/bash "$project_root/scripts/package_macos.sh"
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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])])
|
||||
]
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:路由路径;
|
||||
- component:src/views 下的组件键;
|
||||
- name:菜单标题;
|
||||
- perms:写入 route.meta.perms;
|
||||
- is_show:控制 meta.hidden;
|
||||
- is_cache:控制 keepAlive;
|
||||
- params:默认 query;
|
||||
- selected:activeMenu;
|
||||
- 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。
|
||||
- 企业微信内置浏览器走 OAuth,scope=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_filter:all、pending、passed、not_passed、rejected;
|
||||
- source_filter:all、manual、system;
|
||||
- start_time、end_time(按创建时间)。
|
||||
|
||||
列表核心字段:
|
||||
|
||||
- id、sn、prescription_type;
|
||||
- is_system_auto:0 手工、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_usage:dosage_amount、dosage_bag_count、need_decoction、bags_per_dose、times_per_day、usage_days、prescription_name(部分页面保留模板名)。
|
||||
- 医师:doctor_name、doctor_signature(PNG data URL,保存前必填)。
|
||||
- 可见性/审核:is_shared、visible_role_ids、audit_status、audit_time、audit_by_name、audit_remark。
|
||||
|
||||
状态规则:
|
||||
|
||||
- audit_status:0 待审核、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_filter:unbooked 未预约、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/assign:id、assistant_id、is_inherit=0|1
|
||||
- POST /firstvisit.myPatient/fillIdCard:id、id_card
|
||||
- POST /firstvisit.myPatient/createAppointment:预约完整参数
|
||||
- POST /firstvisit.myPatient/cancelAppointment:id=挂号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:
|
||||
|
||||
- state:none、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.calls,userIDList=[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_type(1 语音、其他视为视频)
|
||||
- room_id
|
||||
- status:1 进行中、2 已结束、3 未接听、4 已取消
|
||||
- recording_status_text
|
||||
- recording_urls_list
|
||||
- start_time_text、end_time_text、duration_text
|
||||
|
||||
接口:
|
||||
|
||||
- GET /tcm.diagnosis/getCallRecords,diagnosis_id
|
||||
- POST /tcm.diagnosis/attachLocalCallRecording,diagnosis_id、file_url、可选 call_record_id
|
||||
- POST /tcm.diagnosis/createManualCallRecord,diagnosis_id
|
||||
- POST /tcm.diagnosis/startCloudRecording,diagnosis_id(API 有封装,主组件当前通过 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 转为 body;GET 使用 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.id;row.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 的断言。
|
||||
@@ -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 11,x86-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 只允许 HTTPS;HTTP 仅在 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 ticket;Web 页面再用 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 WebChannel,bridge 只暴露少量 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 和 Addons;core profile 应直接依赖 `PySide6-Essentials`。WebEngine 位于 Addons,wheel 和最终制品都会显著增大,不能把它当成“小插件”。最终体积以两端产物为准,不承诺一个固定数字。
|
||||
|
||||
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++ Redistributable(Qt 官方要求的版本下限需按锁定 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 或系统 Qt,hook 收到冲突库。解决:干净 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 Widgets;QtWebEngine 是隔离的视频实现细节,不是应用架构。
|
||||
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 分发要求。
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
# 医生工作站最终 parity 审计
|
||||
|
||||
审计日期:2026-08-10
|
||||
审计方式:只读源码复核 + 纯本地测试;未修改业务源码。
|
||||
结论基准:`D:\web\zyt\admin\src\views` 的实际实现优先于既有 `research/parity_*.md`。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
当前医生桌面整体判定为 **PARTIAL**,不建议在修复 P0 前作为后台同型版本发布。
|
||||
|
||||
- **P0:3 项**
|
||||
1. 接诊附件没有上传步骤,本机绝对路径被作为附件 URL 发给服务端。
|
||||
2. 问诊开方在当前 appointment 无处方或查询失败时按 diagnosis 回退,可显示、编辑或作废另一挂号的处方。
|
||||
3. “我的患者”预约把 `source_patient_id` 优先写入 `patient_id`,而管理端该端点明确要求诊单 ID,可能预约到错误上下文或被后端拒绝。
|
||||
- **Repository 方法存在性:EXACT**。当前五页及相关对话框实际调用的 canonical 方法均同时存在于 Protocol、Remote、Demo,未发现不存在或拼错的方法名;兼容别名均能解析到有效方法。
|
||||
- **动态菜单:EXACT(限定五个受支持页面)**。非 Demo 会话使用服务端菜单,遵守显示、禁用、排序、路由及 canonical permission;未把未实现的后台运营页面伪装成本地入口。
|
||||
- **医生活跃视频 eligibility:EXACT;权限语义: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/remark,payload 缺上述字段:`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 DTO,worker 只执行 `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** 提升到可发布候选。
|
||||
@@ -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 ticket(URL 中不能放 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` 同步 start;embedded 在 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,但上述多项是未被现有单元测试覆盖的跨层行为。
|
||||
@@ -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/**` 单独完成。
|
||||
@@ -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-414;script name 为 prescriptionLibrary(234) | 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-4892;script name 为 prescriptionList(1697) | 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-92;URL 包装见 D:/web/zyt/admin/src/api/medicine.ts:6-8。
|
||||
- 选择后同时回写 name 和 medicine_id;不能把任意自由文本当成一次有效选择:组件 95-110。
|
||||
|
||||
Python 处方库的 HerbRow 是两个自由文本 QLineEdit,medicine_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_time:D:/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},默认病历 tab:D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1263-1264、1310-1322;URL 见 D:/web/zyt/admin/src/api/tcm.ts:59-62。
|
||||
|
||||
该只读子页仍保留全套 tab 边界:
|
||||
|
||||
- 病历字段整体 disabled:diagnosis/edit.vue:55-67。
|
||||
- 医生备注 readonly、日常记录 read-only:633-670。
|
||||
- 处方 tab 受 tcm.diagnosis/chufang 控制且 read-only:673-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/extend:D:/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,默认每页条数 PARTIAL(15 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_time:list.vue:44-107。
|
||||
- 公开范围:0=仅自己可见,1=所有人可见:list.vue:21-26、67-72、195-205。
|
||||
- disable_edit:0=可修改,1=已禁用;真实语义是“导入到已开处方后锁定整张处方药材”,不是“禁止维护这条模板自身”:list.vue:74-79、208-220。
|
||||
- 普通用户只管理本人 creator_id;root=1 或 role_ids 含 0、3 可管理任意模板:list.vue:276-301。
|
||||
|
||||
### 4.2 查看、创建、编辑、删除、复制
|
||||
|
||||
- 新增:独立按钮,表单字段为 prescription_name(maxlength=100)、formula_type、herbs、is_public、disable_edit:list.vue:35-41、116-230、259-267。
|
||||
- 查看:有单独 read 权限按钮,复用列表行数据打开完全禁用的表单;不调用 detail:list.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 选取并同步 ID;dosage 是 min=0、precision=1、step=0.5 的数字控件:list.vue:156-190。
|
||||
- 提交要求:名称必填、至少一味药、每行 name 非空、dosage>0:list.vue:269-274、357-379。
|
||||
|
||||
### 4.4 API DTO
|
||||
|
||||
页面调用来源见 list.vue:234-245;URL 包装见 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、delete:list.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-429;repository.py:279-298、586-588 | PARTIAL:请求合同基本一致,但“名称或药材”的占位文案(291)是假的,实际只查 prescription_name |
|
||||
| 分页 | page/page_size 经 invoke 转 page_no/page_size;Pager 消费总数:prescription_library.py:409-442,widgets.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 add:prescription_library.py:474-501;repository.py:309-323 | PARTIAL:端点可用,但药材和 disable_edit 语义不完整 |
|
||||
| 编辑 | 使用列表行直接打开,与基准一样不请求 detail;保存到 edit:prescription_library.py:479-501;repository.py:325-347 | PARTIAL |
|
||||
| 删除 | owner/管理员检查、确认、POST {id}:prescription_library.py:454-472、507-537;repository.py:349-352 | EXACT |
|
||||
| 公开范围 | 复选框产生 bool,repository 规范为 0/1:prescription_library.py:157-159、218-228;repository.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/3:prescription_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/raw,to_api_dict 正规化中文方型和 0/1:core/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_filter:all、pending、passed、not_passed、rejected;all 被正规化为空字符串。
|
||||
- source_filter:all、manual、system;all 被正规化为空字符串。
|
||||
- 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_auto(1=空白处方,其他=手工)、patient_name/gender/age、综合审核状态和驳回意见、void_status、doctor_name/prescription_date、assistant_name、create_time。
|
||||
- audit_status:0 待审核、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.print:index.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-1153,reactive 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_usage:dosage_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>0:3920-3947。
|
||||
- 新增提交前强制 audit_status=0;add/edit 都把完整 editForm 展开为 body:3951-3961。
|
||||
- 编辑提示保存后重新进入待审核;已作废/已驳回可经编辑恢复,业务订单侧审核会重置:index.vue:514-565。
|
||||
|
||||
公开/共享范围的特殊事实:is_shared、visible_role_ids 虽存在于 editForm、详情回填和展开 payload(2846-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_id:index.vue:3444-3468。
|
||||
- prescribing_creator_id 取当前开方医生;语义是“该医生自己的模板 + 公共模板”。
|
||||
- 粘贴导入解析 name/dosage,只接受 GET /doctor.medicine/lists 中 name 完全相同且唯一的药材;请求 name、page_no=1、page_size=200、status=1:index.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-1973;API 类型见 tcm.ts:330-338。
|
||||
|
||||
订单读取与一致性:
|
||||
|
||||
- 编辑处方时 GET /tcm.prescriptionOrder/lists,参数 page_no=1、page_size=5、prescription_id,显示最近订单的 order_no、medication_days、remark_assistant:index.vue:769-811、2879-2907。
|
||||
- 业务订单审核驳回可以覆盖处方列表状态和处方笺状态,但保留消费者处方本身的 audit_status:index.vue:165-190、295-343、3125-3151。
|
||||
|
||||
从处方创建业务订单:
|
||||
|
||||
- 三步表单:诊单患者与收件信息;服务/发货/支付单;费用与备注:index.vue:1197-1537。
|
||||
- 诊单患者远程搜索意图为 GET /tcm.diagnosis/searchPatient,参数 keyword、page_no=1、page_size=10:index.vue:2384-2401;API 定义在 D:/web/zyt/admin/src/api/order.ts:88-91。
|
||||
- GET /tcm.prescriptionOrder/paidPayOrders {diagnosis_id} 返回可关联支付单和 deposit_min_amount:index.vue:2307-2329;API 见 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-2485;URL 见 tcm.ts:374-382。
|
||||
- ship_mode 选择需 tcm.prescriptionOrder/setShipMode;internal_cost 需 finance.account_log/lists;remark_extra 需 tcm.prescriptionOrder/editRemarkExtra:index.vue:1307-1314、1466-1488、2041-2043。
|
||||
- 服务套餐来自 GET /config/dict {type:'server_order'}:index.vue:2180-2187;URL 见 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/2:prescriptions.py:101-125、284-303 | PARTIAL |
|
||||
| 请求映射 | repository 根据 RX/CF/纯数字猜 sn,否则 patient_name;status 转 pending/passed/rejected:repository.py:354-387 | PARTIAL:已有控件映射正确;缺 creator_ids、日期、source、not_passed;同一关键词不能同时查编号与患者 |
|
||||
| 分页 | PageResult/Pager 完整 | PARTIAL:默认 20,基准 15 |
|
||||
| 列表字段 | sn、patient、audit、source、doctor、prescription_date:prescriptions.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-60,widgets.py:48-66 | PARTIAL:主标签逻辑接近,但不能分别显示两类驳回意见、审核人/时间和独立作废信息 |
|
||||
| 详情请求 | 选中后 GET /tcm.prescription/detail {id}:prescriptions.py:326-357;repository.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,但没有 mutation:core/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,并保留 raw:D:/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 fallback:widgets.py:48-66。
|
||||
- RemoteRepository 只实现 GET lists/detail:repository.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/lists(shell.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 id;payload 优先真实 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`) | exact(PC 周期)/ 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 的可写版本在 edit;readonly 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-10:Consultation 未正式解析 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 数组;删除附件的三字段合同固定。
|
||||
|
||||
@@ -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/macOS;SDK 是 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`/DLL;macOS 引用 `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 的 companion;ticket 通过受认证 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 Demo:WebView 检测、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)
|
||||
@@ -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 截图。
|
||||
@@ -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 |
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
project_root="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
exec /bin/bash "$project_root/scripts/run_macos.sh"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.'
|
||||
@@ -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 已安装,但未在标准位置找到可执行文件。"
|
||||
}
|
||||
@@ -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: %s;npm: %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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Zhenyang doctor workstation."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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:///")
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -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;都不可用时只输出不含票据的控制台状态。
|
||||