diff --git a/app/.env.example b/app/.env.example index 1459c3147..38781d4b9 100644 --- a/app/.env.example +++ b/app/.env.example @@ -1,3 +1,7 @@ +# 此文件是源码开发/企业部署模板,不会被复制进发布 ZIP/.app。 +# 生产配置应由受控启动器、设备管理或进程环境注入;禁止把密码、token、 +# UserSig、TRTC SecretKey 或其他长期凭据写进本文件或发布包。 + # 后端根地址。程序会自动追加 /adminapi;也可直接填写以 /adminapi 结尾的地址。 DOCTOR_API_BASE_URL=https://api.example.com diff --git a/app/.gitignore b/app/.gitignore index 24b350c7c..a8da8f342 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -6,6 +6,8 @@ __pycache__/ *.py[cod] .pytest_cache/ +.pytest-tmp-*/ +artifacts/pytest_*/ .ruff_cache/ .coverage htmlcov/ diff --git a/app/README.md b/app/README.md index 163273d02..13e09e105 100644 --- a/app/README.md +++ b/app/README.md @@ -9,16 +9,16 @@ Windows 直接在项目根目录双击: - `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。 -- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,构建并冒烟验证,最后生成 `dist/DoctorWorkstation-Windows-x64-<版本>.zip` 和 SHA-256 文件。 +- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `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 文件。 +- `一键打包.command`:构建、QtWebEngine/QtMultimedia 文件门禁、签名检查和两项冻结冒烟验证后,生成 `.app`、可分发 ZIP 及 SHA-256 文件。 -Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境和锁定依赖。首次打包需要联网下载依赖,之后会复用本机缓存。如果 macOS 传输过程丢失了执行权限,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`;若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。 +Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境和锁定依赖。首次打包需要联网下载依赖,之后会复用本机缓存。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。 ## 已实现范围 @@ -52,6 +52,8 @@ uv run doctor-workstation `.env.example` 默认启用演示模式。演示账号:`doctor`,密码:`doctor123`。 +`.env.example` 仅作为源码开发/企业部署模板,不会复制进发布 ZIP 或 `.app`。生产环境请通过受控启动器、设备管理或进程环境注入配置;不要把密码、token、UserSig、TRTC SecretKey 等凭据放进 `.env` 或发布包。 + ## 连接现有后端 将 `.env` 调整为: @@ -100,12 +102,16 @@ uv run ruff check src tests .\scripts\build_windows.ps1 ``` +`build_windows.ps1` 只生成并验证 onedir;正式一键发布请运行 `Build_DoctorWorkstation.bat`,它在所有冻结文件/媒体门禁通过后再生成版本 ZIP 与 SHA-256。 + macOS 必须在 macOS 机器上构建、签名和公证: ```bash ./scripts/build_macos.sh ``` +`build_macos.sh` 只生成并验证 `.app`;正式一键发布请双击 `一键打包.command`(或 `package_macos.command`),通过相同门禁后再归档并生成 SHA-256。 + Windows 与 macOS 的 Qt/媒体权限和签名产物不能交叉编译。首次生产发布前,应按 [research/tencent_rtc.md](research/tencent_rtc.md) 的准入清单完成摄像头、麦克风、设备插拔、休眠恢复和弱网实测。 ## 工程结构 diff --git a/app/artifacts/diagnosis_visual/appointment_drawer_1024x640.png b/app/artifacts/diagnosis_visual/appointment_drawer_1024x640.png new file mode 100644 index 000000000..abe454369 Binary files /dev/null and b/app/artifacts/diagnosis_visual/appointment_drawer_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/appointment_drawer_1440x900.png b/app/artifacts/diagnosis_visual/appointment_drawer_1440x900.png new file mode 100644 index 000000000..fa2cc01cd Binary files /dev/null and b/app/artifacts/diagnosis_visual/appointment_drawer_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/appointment_drawer_empty_doctors_1440x900.png b/app/artifacts/diagnosis_visual/appointment_drawer_empty_doctors_1440x900.png new file mode 100644 index 000000000..733f08d01 Binary files /dev/null and b/app/artifacts/diagnosis_visual/appointment_drawer_empty_doctors_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/appointment_drawer_empty_roster_1440x900.png b/app/artifacts/diagnosis_visual/appointment_drawer_empty_roster_1440x900.png new file mode 100644 index 000000000..5d6e80f60 Binary files /dev/null and b/app/artifacts/diagnosis_visual/appointment_drawer_empty_roster_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/appointment_drawer_error_1440x900.png b/app/artifacts/diagnosis_visual/appointment_drawer_error_1440x900.png new file mode 100644 index 000000000..3c228a8a6 Binary files /dev/null and b/app/artifacts/diagnosis_visual/appointment_drawer_error_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/appointment_drawer_keyboard_focus_1440x900.png b/app/artifacts/diagnosis_visual/appointment_drawer_keyboard_focus_1440x900.png new file mode 100644 index 000000000..b36ecee48 Binary files /dev/null and b/app/artifacts/diagnosis_visual/appointment_drawer_keyboard_focus_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/appointment_drawer_loading_1440x900.png b/app/artifacts/diagnosis_visual/appointment_drawer_loading_1440x900.png new file mode 100644 index 000000000..a7d9aabbe Binary files /dev/null and b/app/artifacts/diagnosis_visual/appointment_drawer_loading_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/appointment_drawer_refreshing_1440x900.png b/app/artifacts/diagnosis_visual/appointment_drawer_refreshing_1440x900.png new file mode 100644 index 000000000..53ead9e85 Binary files /dev/null and b/app/artifacts/diagnosis_visual/appointment_drawer_refreshing_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_1024x640.png new file mode 100644 index 000000000..f5a034a46 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_1440x900.png b/app/artifacts/diagnosis_visual/diagnosis_1440x900.png new file mode 100644 index 000000000..c360de26c Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_advanced_filters_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_advanced_filters_1280x800.png new file mode 100644 index 000000000..bcd89fe44 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_advanced_filters_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_double_appointment_cancel_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_double_appointment_cancel_1280x800.png new file mode 100644 index 000000000..2985b6278 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_double_appointment_cancel_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_edit_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_edit_1024x640.png new file mode 100644 index 000000000..4ddf77c73 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_edit_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_edit_1440x900.png b/app/artifacts/diagnosis_visual/diagnosis_edit_1440x900.png new file mode 100644 index 000000000..c5faae135 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_edit_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_empty_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_empty_1280x800.png new file mode 100644 index 000000000..638355efb Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_empty_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_error_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_error_1280x800.png new file mode 100644 index 000000000..049b432a7 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_error_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_focus_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_focus_1280x800.png new file mode 100644 index 000000000..5b4746f6c Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_focus_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_full_menu_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_full_menu_1280x800.png new file mode 100644 index 000000000..cb3fa82a8 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_full_menu_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_horizontal_scroll_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_horizontal_scroll_1024x640.png new file mode 100644 index 000000000..5aa0d1154 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_horizontal_scroll_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_hover_warning_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_hover_warning_1280x800.png new file mode 100644 index 000000000..20edf9994 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_hover_warning_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_loading_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_loading_1280x800.png new file mode 100644 index 000000000..dc57bdb8c Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_loading_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_order_detail_drawer_1440x900.png b/app/artifacts/diagnosis_visual/diagnosis_order_detail_drawer_1440x900.png new file mode 100644 index 000000000..7b90b58f3 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_order_detail_drawer_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_order_qrcode_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_order_qrcode_1280x800.png new file mode 100644 index 000000000..283d816a9 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_order_qrcode_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_pending_assign_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_pending_assign_1280x800.png new file mode 100644 index 000000000..8150572d6 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_pending_assign_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_permissions_cropped_1280x800.png b/app/artifacts/diagnosis_visual/diagnosis_permissions_cropped_1280x800.png new file mode 100644 index 000000000..9c250f680 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_permissions_cropped_1280x800.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_readonly_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_readonly_1024x640.png new file mode 100644 index 000000000..7e0155f83 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_readonly_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_readonly_1440x900.png b/app/artifacts/diagnosis_visual/diagnosis_readonly_1440x900.png new file mode 100644 index 000000000..095e3037b Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_readonly_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_shell_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_shell_1024x640.png new file mode 100644 index 000000000..3c7e02fec Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_shell_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_shell_1440x900.png b/app/artifacts/diagnosis_visual/diagnosis_shell_1440x900.png new file mode 100644 index 000000000..38f9515e0 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_shell_1440x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_chat_archive_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_chat_archive_1024x640.png new file mode 100644 index 000000000..cfbef2739 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_chat_archive_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_daily_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_daily_1024x640.png new file mode 100644 index 000000000..88ef32848 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_daily_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_daily_blood_edit_650x620.png b/app/artifacts/diagnosis_visual/diagnosis_state_daily_blood_edit_650x620.png new file mode 100644 index 000000000..de0e11551 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_daily_blood_edit_650x620.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_daily_lower_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_daily_lower_1024x640.png new file mode 100644 index 000000000..f3a058980 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_daily_lower_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_empty_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_empty_1024x640.png new file mode 100644 index 000000000..6aa5c3b69 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_empty_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_error_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_error_1024x640.png new file mode 100644 index 000000000..1869a55f3 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_error_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_focus_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_focus_1024x640.png new file mode 100644 index 000000000..35771b643 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_focus_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_loading_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_loading_1024x640.png new file mode 100644 index 000000000..b4a9f00f3 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_loading_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_notes_actions_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_notes_actions_1024x640.png new file mode 100644 index 000000000..c8247ed0f Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_notes_actions_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_order_detail_640x540.png b/app/artifacts/diagnosis_visual/diagnosis_state_order_detail_640x540.png new file mode 100644 index 000000000..576aacd25 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_order_detail_640x540.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_order_detail_drawer_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_order_detail_drawer_1024x640.png new file mode 100644 index 000000000..576aacd25 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_order_detail_drawer_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_order_offset_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_order_offset_1024x640.png new file mode 100644 index 000000000..452a38b8a Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_order_offset_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_permission_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_permission_1024x640.png new file mode 100644 index 000000000..e32cd09c1 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_permission_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_1024x768.png b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_1024x768.png new file mode 100644 index 000000000..0e48d52bd Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_1024x768.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_1200x900.png b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_1200x900.png new file mode 100644 index 000000000..cf8aa3aa0 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_1200x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_920x780.png b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_920x780.png new file mode 100644 index 000000000..7c8a5b30f Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_920x780.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_herbs_1200x900.png b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_herbs_1200x900.png new file mode 100644 index 000000000..ba63f21b4 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_herbs_1200x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_signature_1200x900.png b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_signature_1200x900.png new file mode 100644 index 000000000..31a94fa8b Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_signature_1200x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_usage_1200x900.png b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_usage_1200x900.png new file mode 100644 index 000000000..f335da325 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_prescription_editor_usage_1200x900.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_save_failure_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_save_failure_1024x640.png new file mode 100644 index 000000000..11a28a47d Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_save_failure_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_save_loading_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_save_loading_1024x640.png new file mode 100644 index 000000000..261b3d722 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_save_loading_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_save_success_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_save_success_1024x640.png new file mode 100644 index 000000000..f86be457f Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_save_success_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_video_player_820x560.png b/app/artifacts/diagnosis_visual/diagnosis_state_video_player_820x560.png new file mode 100644 index 000000000..bded098ff Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_video_player_820x560.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_video_replay_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_video_replay_1024x640.png new file mode 100644 index 000000000..d631c92fb Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_video_replay_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_state_video_upload_action_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_state_video_upload_action_1024x640.png new file mode 100644 index 000000000..564a108cd Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_state_video_upload_action_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_video_inline_player_1200x560.png b/app/artifacts/diagnosis_visual/diagnosis_video_inline_player_1200x560.png new file mode 100644 index 000000000..ab83d9a7d Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_video_inline_player_1200x560.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_viewonly_1024x640.png b/app/artifacts/diagnosis_visual/diagnosis_viewonly_1024x640.png new file mode 100644 index 000000000..584c43156 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_viewonly_1024x640.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_viewonly_1440x900.png b/app/artifacts/diagnosis_visual/diagnosis_viewonly_1440x900.png new file mode 100644 index 000000000..7664d1774 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_viewonly_1440x900.png differ diff --git a/app/packaging/README.md b/app/packaging/README.md index 1cc28f316..d763c0db3 100644 --- a/app/packaging/README.md +++ b/app/packaging/README.md @@ -2,7 +2,9 @@ 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. +`PySide6.QtMultimedia` and `PySide6.QtMultimediaWidgets` are also explicit hidden imports. Their maintained PyInstaller hooks collect the Python extension modules, `Qt6Multimedia`/`Qt6MultimediaWidgets` DLLs, dylibs or frameworks, `plugins/multimedia`, and the platform media backends. The build scripts assert those frozen files before accepting an artifact. + +Both build scripts launch the frozen executable twice. `--media-smoke-test` is handled by a PyInstaller runtime hook before the normal application entry point: it imports both multimedia modules, constructs `QMediaPlayer`, `QAudioOutput`, and `QVideoWidget`, checks that a decoder backend exposes formats, runs one offscreen event-loop turn, and returns non-zero on any failure. The existing `--smoke-test` then validates the packaged application bootstrap. Each process uses a temporary user/config directory, loopback-only proxy settings, and a 30-second deadline; a non-zero exit or an unhandled exception in its logs fails the build. Run the build on the target operating system. PyInstaller cannot cross-build Windows and macOS artifacts. @@ -14,6 +16,8 @@ Run the build on the target operating system. PyInstaller cannot cross-build Win The default interpreter is `.venv-build\Scripts\python.exe`; override it with `-Python C:\path\to\python.exe`. +For the one-click release ZIP and SHA-256 manifest, run `Build_DoctorWorkstation.bat`. It prepares locked dependencies, invokes the build/file/smoke gates, and archives only after all gates pass. + ## macOS ```bash @@ -22,4 +26,8 @@ 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`. +For the one-click release ZIP and SHA-256 file, use `package_macos.command` (or `一键打包.command`). All root `.command` files and operational `scripts/*.sh` files must be tracked with mode `100755`; `scripts/check_macos_entrypoints.sh` verifies both filesystem executability and Git index mode before packaging. + 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. + +`.env.example` is a source/deployment template and is intentionally not included in the release archives. Production endpoints and non-secret policy values should be injected into the process environment by the managed launcher/MDM. Never place passwords, tokens, UserSig, TRTC SecretKey, or other long-lived credentials in a release archive. diff --git a/app/packaging/doctor_workstation.spec b/app/packaging/doctor_workstation.spec index 6be1f4a12..242cea7f4 100644 --- a/app/packaging/doctor_workstation.spec +++ b/app/packaging/doctor_workstation.spec @@ -4,6 +4,10 @@ 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. +The explicit QtMultimedia imports likewise activate PyInstaller's official Qt +dependency scan, which collects the QtMultimedia/QtMultimediaWidgets extension +modules, Qt6Multimedia shared libraries/frameworks, multimedia plugins, and +their platform media backends. """ import os @@ -18,11 +22,14 @@ 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" +MEDIA_SMOKE_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py" 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") +if not MEDIA_SMOKE_HOOK.is_file(): + raise SystemExit(f"Frozen multimedia smoke hook is missing: {MEDIA_SMOKE_HOOK}") # Some Windows developer tools add an unrelated OpenSSL installation to PATH. # PyInstaller's dependency scanner would then pair Python's ``_ssl.pyd`` with @@ -48,6 +55,15 @@ qt_webengine_hiddenimports = [ "PySide6.QtPrintSupport", ] +qt_multimedia_hiddenimports = [ + # These are intentionally explicit instead of relying on imports hidden by + # the application's optional media fallback. PyInstaller's official + # hooks collect Qt6Multimedia*.dll/.dylib/framework, plugins/multimedia, + # and the platform FFmpeg/native backend dependencies. + "PySide6.QtMultimedia", + "PySide6.QtMultimediaWidgets", +] + analysis = Analysis( [str(ENTRY_POINT)], pathex=[str(SOURCE_ROOT)], @@ -56,10 +72,10 @@ analysis = Analysis( (str(VIDEO_DIST), "video_companion_dist"), (str(RESOURCES), "resources"), ], - hiddenimports=qt_webengine_hiddenimports, + hiddenimports=qt_webengine_hiddenimports + qt_multimedia_hiddenimports, hookspath=[], hooksconfig={}, - runtime_hooks=[], + runtime_hooks=[str(MEDIA_SMOKE_HOOK)], excludes=[], noarchive=False, optimize=0, diff --git a/app/packaging/runtime_media_smoke.py b/app/packaging/runtime_media_smoke.py new file mode 100644 index 000000000..345e52d34 --- /dev/null +++ b/app/packaging/runtime_media_smoke.py @@ -0,0 +1,85 @@ +"""Frozen-process gate for the Qt multimedia runtime. + +PyInstaller executes this file as a runtime hook. Normal application starts +are untouched; ``--media-smoke-test`` exits before the application entry point +after proving that the frozen Qt multimedia modules and a decoder backend can +be loaded in an offscreen Qt event loop. +""" + +from __future__ import annotations + +import sys + +MEDIA_SMOKE_ARGUMENT = "--media-smoke-test" + + +def _emit(message: str, *, error: bool = False) -> None: + # Windowed PyInstaller executables set stdout/stderr to None on Windows. + # The exit status is the gate contract; diagnostics are best-effort. + stream = sys.stderr if error else sys.stdout + if stream is not None: + print(message, file=stream, flush=True) + + +def _run_media_smoke_gate() -> None: + # Keep these imports inside the gate. Missing frozen extension modules or + # linked Qt multimedia libraries must make this process fail, while normal + # launches retain the application's existing fallback behaviour. + from PySide6.QtCore import QTimer + from PySide6.QtMultimedia import QAudioOutput, QMediaFormat, QMediaPlayer + from PySide6.QtMultimediaWidgets import QVideoWidget + from PySide6.QtWidgets import QApplication + + application = QApplication.instance() + owns_application = application is None + if application is None: + application = QApplication(["DoctorWorkstationMediaSmoke"]) + + player = QMediaPlayer() + audio_output = QAudioOutput() + video_widget = QVideoWidget() + player.setAudioOutput(audio_output) + player.setVideoOutput(video_widget) + + if not player.isAvailable(): + raise RuntimeError("Qt reports that no multimedia backend is available") + + decode_formats = QMediaFormat().supportedFileFormats(QMediaFormat.ConversionMode.Decode) + if not decode_formats: + raise RuntimeError("Qt multimedia loaded without a supported decoder format") + + video_widget.resize(16, 16) + video_widget.show() + QTimer.singleShot(0, application.quit) + event_status = application.exec() + video_widget.close() + player.setVideoOutput(None) + player.setAudioOutput(None) + if event_status != 0: + raise RuntimeError(f"Qt multimedia offscreen event loop exited with {event_status}") + + # QApplication cannot be recreated safely in-process. The runtime hook is + # a one-shot frozen gate, but retaining this distinction makes direct test + # imports predictable. + if owns_application: + application.processEvents() + + +def _dispatch() -> int | None: + if MEDIA_SMOKE_ARGUMENT not in sys.argv[1:]: + return None + try: + _run_media_smoke_gate() + except Exception as exc: + _emit( + f"Frozen Qt multimedia smoke gate failed: {type(exc).__name__}: {exc}", + error=True, + ) + return 70 + _emit("Frozen Qt multimedia smoke gate passed.") + return 0 + + +_media_smoke_status = _dispatch() +if _media_smoke_status is not None: + raise SystemExit(_media_smoke_status) diff --git a/app/research/diagnosis_detail_visual_spec.md b/app/research/diagnosis_detail_visual_spec.md new file mode 100644 index 000000000..78a4b827a --- /dev/null +++ b/app/research/diagnosis_detail_visual_spec.md @@ -0,0 +1,565 @@ +# 诊单详情 / 编辑 / 预约视觉规格(后台源码基准) + +## 1. 范围与判读原则 + +本规格只以 `D:/web/zyt/admin` 当前源码为事实源,覆盖: + +- 三个主视图:`src/views/tcm/diagnosis/edit.vue`、`readonly.vue`、`appointment.vue`。 +- 三个主视图直接渲染的诊单组件:患者摘要、病例、日常记录、医生备注、处方/病例记录、业务订单、视频、聊天、医助指派历史、挂号历史、待办。 +- 备注视图直接引用的 `src/views/patient/reception/components/NoteTimeline.vue`。 +- 直接生效的全局样式入口、Element Plus 变量和 Tailwind 尺寸映射。 + +颜色、尺寸若由当前文件明确声明,标为“显式”;若控件仅沿用 Element Plus,则标为“继承”。项目使用 Element Plus `^2.9.4` 与 Tailwind CSS `^3.4.17`(`package.json:27,64`)。全局样式入口为 `main.ts:1-3`,依次加载 `element.scss`、`dark.css`、`var.css`、`tailwind.css`、`public.scss`(`src/styles/index.scss:1-5`)。因此不能把浏览器/Element 默认值误写成某个业务组件的局部规格。 + +> 关键结论:系统实际上有两种“只读详情”。`readonly.vue` 是独立、卡片化、纵向详情页;`edit.vue` 在 `viewOnly` 模式下则仍是右侧抽屉和 Tab 结构,只禁用编辑控件。两者不能用同一个线框替代。 + +--- + +## 2. 全局视觉基础 + +### 2.1 字体与字号 + +字体栈由 Tailwind 配置明确为 `PingFang SC, Arial, Hiragino Sans GB, Microsoft YaHei, sans-serif`(`tailwind.config.js:79-80`),并赋给 `--el-font-family`(`src/styles/var.css:1-3`)。基础字号如下(`src/styles/var.css:11-18`): + +| 语义 | 实际字号 | +|---|---:| +| extra large | 18 px | +| large | 16 px | +| medium | 15 px | +| base | 14 px | +| small | 13 px | +| extra small | 12 px | + +业务层进一步压缩:编辑抽屉根节点和表单文字为 12 px(`edit.vue:1476-1504,1532-1556`);只读病例键值为 12.5 px(`components/PatientCaseCard.vue:307-341`);聊天气泡为 14 px(`components/ImChatRecordPanel.vue:260-286`)。 + +### 2.2 全局颜色与表面 + +以下均为项目显式变量(`src/styles/var.css:20-40`): + +| 用途 | 色值 | +|---|---| +| 页面底 | `#f6f6f6` | +| 普通/浮层底 | `#ffffff` | +| 主文字 | `#333333` | +| 常规文字 | `#666666` | +| 次要文字 | `#999999` | +| placeholder | `#a8abb2` | +| disabled | `#c0c4cc` | +| border / light / lighter | `#dcdfe6` / `#e4e7ed` / `#ebeef5` | +| extra-light border | `#f2f2f2` | +| fill / light / lighter | `#f0f2f5` / `#f8f8f8` / `#fafafa` | +| extra-light fill | `#fafcff` | + +Element 语义主色未在这些诊单文件中改写,局部引用 `var(--el-color-primary)` 或显式 `#409eff`。继承的 Element Plus 2.9.4 常规控件高度为 32 px(small 24 px、large 40 px);但编辑抽屉底部按钮被局部覆盖为最小 40 px,移动端最小 44 px(`edit.vue:1732-1740,1905-1913`)。 + +### 2.3 间距基线和全局行为 + +Tailwind 间距映射是 4 px 基线:`1=4`、`2=8`、`2.5=10`、`3=12`、`3.5=14`、`4=16`、`5=20`、`6=24`(`tailwind.config.js:103-125`)。业务文件里的 8/10/12/14/16/18/20/24 px 与该节奏一致。 + +直接影响这些页面的全局规则: + +- Dialog 居中,最大宽 `calc(100vw - 30px)`,圆角 5 px(`src/styles/element.scss:11-27`)。 +- 全局 Drawer 主内边距变量为 16 px,Header 内边距 `13px 16px`、下边框 1 px lighter;预约 Drawer 没有重写这些值,编辑 Drawer 则只额外重写背景与边框(`src/styles/element.scss:46-56`)。 +- 全局表格字号 14 px、表头主文字色、表头字重 400、表头底 `#f8f8f8`(`src/styles/element.scss:58-68`; `src/styles/var.css:10`)。因此编辑根节点的 12 px 不应错误地传成所有表格单元字号。 +- Input、Select、Textarea 聚焦时出现 2 px primary-light 外环,radio/checkbox active 也有 2 px 外环;校验错误时切换 danger-light 外环(`src/styles/element.scss:140-175`)。 +- Tabs 底线全局压至 1 px(`src/styles/element.scss:131-133`)。 +- 消息与通知层级为 9999(`src/styles/element.scss:1-9`)。 +- 小于等于 768 px 时分页隐藏页码跳转与 page-size 选择器(`src/styles/element.scss:177-183`)。 +- Tailwind 会导入 base/components/utilities(`src/styles/tailwind.css:1-3`),但 Element 按钮背景、focus、hover 被重新还原为 Element 变量,避免被 Tailwind reset 覆盖(`src/styles/element.scss:186-200`)。 +- `body` 为 14 px 主文字、`overflow:hidden`、最小宽 375 px;页面/Drawer 自己承担滚动(`src/styles/public.scss:1-3`)。全局 `.form-tips` 为 12 px secondary、行高 24 px、上间距 4 px(`src/styles/public.scss:4-6`)。 + +暗色主题另有一套直接全局变量:页面/表面为 `#0a0a0a / #1d2124 / #1d1e1f`,主/常规/次要文字为 `#e5eaf3 / #cfd3dc / #a3a6ad`,边框从 `#636466` 到 `#2b2b2c`,遮罩为 `rgba(0,0,0,.8)`(`src/styles/dark.css:1-31`)。不过三个诊单主视图及卡片大量显式写入白底、浅蓝渐变和 slate 色,不能完整随暗色变量切换;本规格后续均按源码默认浅色态记录,暗色不能视为已完整适配。 + +--- + +## 3. 独立只读详情页 `readonly.vue` + +### 3.1 页面骨架 + +源码结构是单列滚动页,不使用 Tabs(`readonly.vue:1-149`): + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ ← 返回 诊单只读详情 张三 男 · 42岁 [未服务 3天] │ +├─────────────────────────────────────────────────────────────────────┤ +│ [患者信息摘要 PatientInfoCard] │ +├─────────────────────────────────────────────────────────────────────┤ +│ [患者病例 PatientCaseCard] │ +├─────────────────────────────────────────────────────────────────────┤ +│ [日常记录 DailyMatrix] 按权限可见 │ +├─────────────────────────────────────────────────────────────────────┤ +│ [医生备注/舌象/报告 NoteTimeline] │ +├─────────────────────────────────────────────────────────────────────┤ +│ [业务订单 PatientOrderList] 按权限可见 │ +├─────────────────────────────────────────────────────────────────────┤ +│ [视频录制回放 CallRecordPanel] 按权限可见 │ +├─────────────────────────────────────────────────────────────────────┤ +│ [聊天 ImChatRecordPanel] 按权限可见 │ +├─────────────────────────────────────────────────────────────────────┤ +│ [指派医助记录 AssignLogPanel] 按权限可见 │ +├─────────────────────────────────────────────────────────────────────┤ +│ [挂号记录 AppointmentRecordPanel] 按权限可见 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +渲染顺序严格来自 `readonly.vue:39-146`;异常时在 hero 下方显示错误空态(`readonly.vue:31-36`)。页面根容器垂直间距 16 px、内边距 16 px(`readonly.vue:269-273`)。 + +### 3.2 顶部 Hero + +Hero 是可换行的左右布局:`display:flex; justify-content:space-between; flex-wrap:wrap; gap:12px`,内边距 `12px 16px`,圆角 12 px,边框 `#dde7ff`,背景为 `#f5f8ff → #eef3ff` 的浅蓝渐变(`readonly.vue:275-285`)。 + +- 页面标题:16 px / 700(`readonly.vue:286-303`)。 +- 患者姓名:18 px / 700;性别年龄 13 px(`readonly.vue:305-320`)。 +- 未服务状态胶囊:12 px / 600,内边距 `4px 10px`,999 px 全圆角(`readonly.vue:323-330`)。 +- 状态色:正常绿 `#16a34a` / 边框 `#bbf7d0`;提醒橙 `#ea580c` / `#fed7aa`;严重红 `#dc2626` / `#fecaca`(`readonly.vue:332-355`)。从未记录时使用 Element placeholder 灰。 + +### 3.3 卡片规格 + +通用详情卡为白底、1 px `#e6ebf2` 边框、14 px 圆角、18 px 内边距、14 px 内部纵向间距和轻阴影(`readonly.vue:364-373`)。卡片标题 15 px / 700,标题前为 `3 × 16 px` 蓝色标记(`readonly.vue:375-396`)。DailyMatrix 外层额外 16 px 内边距以对齐矩阵(`readonly.vue:398-405`)。错误空态卡保持 14 px 圆角并有 40 px 纵向内边距(`readonly.vue:357-362`)。 + +患者摘要内部是浅蓝 Hero 卡:圆角 12 px,内边距 `16px 18px`,行距 6 px;患者名 22 px / 700,其余信息 13 px、行高 1.7(`components/PatientInfoCard.vue:78-123`)。字段顺序是: + +1. 姓名。 +2. 脱敏手机 · 性别 · 年龄。 +3. 身高 / 体重 · 地区。 +4. 预约日期时间 · 时段。 +5. 医生 / 医助。 +6. 预约状态 · 是否已开方。 +7. 备注(有值才显示)。 + +顺序由 `components/PatientInfoCard.vue:6-25` 确定。 + +### 3.4 病例字段顺序与网格 + +病例卡以分组标题 + 网格键值展示;组间 `gap:10px`,除首组外顶部 12 px 内边距并有 dashed 分隔线(`components/PatientCaseCard.vue:258-269`)。分组标题 13 px / 600、蓝色,前有 4 px 圆点(`components/PatientCaseCard.vue:271-287`)。网格横向间距 16 px、纵向 8 px,并提供 1/2/3/4 等分列(`components/PatientCaseCard.vue:289-305`)。键值字号 12.5 px、行高 1.55,label 最小宽 58 px;label 为 `#6b7280`,value 为 `#1f2937` / 500,空值为 `#c0c4cc`(`components/PatientCaseCard.vue:307-341`)。 + +只读病例的真实字段顺序(`components/PatientCaseCard.vue:8-124`): + +| 分组 | 列数 | 顺序 | +|---|---:|---| +| 基本信息 | 4 | 诊单ID、姓名、身份证、手机、性别、年龄、婚姻、地区 | +| 生命体征 | 4 | 身高、体重、高压、低压、诊断类型、空腹血糖、在用药物(多行) | +| 主诉 | 4 | 当地诊断日期、糖尿病病史、当地医院、当地医院诊断结果 | +| 现病史 | 3 | 口腔、饮水、体重变化、脂肪肝、饮食(跨2列)、肢体、睡眠、眼、头、出汗、皮肤、小便、大便、腰肾、其他补充(跨3列、多行) | +| 既往史 | 1 | 既往史(整行) | +| 其他病史 | 混合 | 外伤、手术、过敏、家族、妊娠 | +| 补充与意见 | 1 | 病史补充(有值才显示)、处方意见 | + +超标指标采用 `#dc2626`、700 字重并带上箭头(`components/PatientCaseCard.vue:343-358`)。处方意见为 13 px、`12px 14px` 内边距、8 px 圆角、左侧 3 px 蓝条(`components/PatientCaseCard.vue:360-368`)。 + +### 3.5 响应式事实 + +`readonly.vue` 自身没有 media query;Hero 仅通过 `flex-wrap` 避免左右信息互相挤压。`PatientCaseCard.vue` 也没有窄屏降列规则,所以 4 列病例网格不会自动变成 1 列。实现 PySide 时不能把“可换行 Hero”误判为整页已经完整响应式。 + +--- + +## 4. 编辑 / 抽屉只读 `edit.vue` + +### 4.1 框架与模式 + +右侧 Drawer 宽度固定为视口的 60%,方向 RTL;只读时 z-index 4000,编辑/新增时 1500(`edit.vue:2-10`)。抽屉有三种标识:只读 `info`、新建 `success`、编辑 `warning`,标题下显示“患者名 · 诊单ID”(`edit.vue:11-54,859-863`)。 + +```text +桌面(右侧 60% Drawer) +┌───────────────────────────────────────────────────────────────┐ +│ 编辑诊单 [编辑] 患者名 · #123 │ Header +├───────────────────────────────────────────────────────────────┤ +│ 病历 │ 医生备注 │ 日常记录 │ 处方 │ 业务订单 │ 视频 │ ... → │ Tabs +├───────────────────────────────────────────────────────────────┤ +│ │ +│ [锁定提示 / 隐私提示,按条件] │ +│ 诊单ID [_____________________________] │ +│ 姓名 [____________] 身份证 [____________] │ +│ │ +│ ──────────────── 生命体征 ──────────────── │ +│ 婚姻 [____] 身高 [____] 体重 [____] │ Scroll body +│ ... │ +│ ──────────────── 现病史 ───────────────── │ +│ [可换行 radio / checkbox 组] │ +│ │ +├───────────────────────────────────────────────────────────────┤ +│ [取消/关闭] [保存] │ Footer slot +└───────────────────────────────────────────────────────────────┘ +``` + +`viewOnly` 模式没有切换到独立详情卡,而是给病历 `fieldset` 禁用输入,并隐藏保存;锁定患者信息时显示 warning alert(`edit.vue:57-84,766-781`)。 + +### 4.2 Header、Tabs、Footer + +- Header 背景 `#fff → #f8fafc`,底边 1 px `#e2e8f0`(`edit.vue:1776-1785`)。标题 18 px / 600 / `#0f172a`,行高 1.3;副标题 13 px(`edit.vue:1670-1717`)。 +- Body 背景 `#f8fafc`(`edit.vue:1787-1790`)。 +- Tabs 横向滚动,滚动条 4 px;nav 使用 `max-content` 且最小宽 100%,tab 水平内边距 14 px,常态 `#64748b` / 500,激活态为 Element primary / 600,激活条 3 px(`edit.vue:1798-1858`)。 +- Footer 最终生效样式为白底、上边框 1 px `#e2e8f0`,内容右对齐、gap 12 px、内边距 `14px 20px 18px`(`edit.vue:1719-1730,1860-1864`)。按钮最小高 40 px、水平内边距 20 px、600 字重、圆角 10 px(`edit.vue:1732-1740`)。 + +这里没有 CSS `position: sticky`。底部操作之所以视觉上固定,是因为它使用 Element Drawer 的独立 footer slot,位于可滚动 body 之外(模板 `edit.vue:766-781`;结构样式 `edit.vue:1792-1796`)。PySide 应以固定底栏实现,而不是在滚动内容末尾放按钮。 + +### 4.3 Tab 顺序、权限与内容 + +Tab 的准确顺序和门槛(`edit.vue:55-759`): + +| 序号 | Tab | 条件 | 内容组件 | +|---:|---|---|---| +| 1 | 病历 | 始终 | 内联表单 | +| 2 | 医生备注 | 始终 | `NoteTimeline` | +| — | 跟踪备注 | **已注释,不渲染** | 原拟 `TrackingNoteTimeline`(`edit.vue:644-654`) | +| 3 | 日常记录 | `tcm.diagnosis/dailyRecord` | `DailyMatrix` | +| 4 | 处方 | `tcm.diagnosis/chufang` | `CaseRecordList` | +| 5 | 业务订单 | `tcm.diagnosis/patientOrders` | `PatientOrderList` | +| 6 | 视频录制回放 | `tcm.diagnosis/huifang` | `CallRecordPanel` | +| 7 | 聊天 | `tcm.diagnosis/chat` | `ImChatRecordPanel` | +| 8 | 指派医助记录 | `tcm.diagnosis/assign` 或 `tcm.diagnosis/detail` | `AssignLogPanel` | +| 9 | 挂号记录 | `doctor.appointment/lists` | `AppointmentRecordPanel` | + +权限变化后若当前 Tab 失权,代码会主动回到 `basic`(`edit.vue:883-913`)。除病历外的大部分 Tab 为 lazy 内容,首次选中才加载。 + +### 4.4 病历表单布局、标签和控件 + +桌面表单 label width 为 160 px(`edit.vue:61-63`)。根字号、label、输入/textarea/radio/checkbox/button 均显式为 12 px;label 为 500 / `#606266`(`edit.vue:1476-1504,1532-1556`)。表单项底部间距 18 px(`edit.vue:1528-1530`)。输入、选择、日期、数字框圆角 10 px(`edit.vue:1645-1667`);普通输入高度沿用 Element 32 px。 + +分组标题的上/下外边距为 28/22 px;中间文字 14 px / 600 / `#0f172a`、`7px 16px` 内边距、999 px 胶囊圆角,两侧 1 px `#e2e8f0` 横线(`edit.vue:1506-1525`)。单选紧凑组换行、gap 12 px;复选组 gap `12px 16px`;普通 radio/checkbox 右、下间距分别 16/8 px(`edit.vue:1558-1604`)。帮助文本 12 px、`#909399`、行高 1.5、顶部 4 px(`edit.vue:1606-1611`)。 + +### 4.5 编辑表单的真实字段顺序 + +以下只列实际渲染字段,不把 `formData` 中未渲染的属性当作 UI(模板 `edit.vue:57-632`): + +| 区段 | 栅格 | 字段顺序与控件 | +|---|---|---| +| 顶部 | 24 | 诊单ID(只读) | +| 基本身份 | 12 + 12 | 姓名;身份证 | +| 联系信息 | 12 + 12 | 手机;性别 | +| 年龄 | 12 | 年龄 | +| **生命体征** | 8 + 8 + 8 | 婚姻;身高;体重 | +| | 8 + 8 + 8 | 地区;高压;低压 | +| | 8 | 空腹血糖 | +| | 12 | 诊断类型 | +| | 12 + 12 | 状态;渠道 | +| | 12 | 统计端就诊卡 | +| | 12 | 在用药物,textarea,3 行,maxlength 2000 | +| **主诉** | 12 + 12 | 当地诊断日期;糖尿病病史 | +| | 24 | 当地医院诊断结果(check buttons) | +| | 24 | 当地医院名称 | +| **现病史** | 24 | 口腔感觉(check buttons) | +| | 24 | 每日饮水量(radio buttons) | +| | 24 | 近月体重变化(radio buttons) | +| | 24 | 脂肪肝(radio buttons) | +| | 24 | 饮食(check buttons) | +| | 24 × 9 | 肢体、睡眠、眼睛、头部、出汗、皮肤、小便、大便、腰肾 | +| | 24 | 其他补充,textarea,3 行 | +| **既往史** | 24 | 既往史(check buttons) | +| **其他病史** | 8 + 8 + 8 | 外伤;手术;过敏 | +| | 12 + 12 | 家族;妊娠 | +| **诊断信息** | 24 | 病史补充,textarea,2 行 | + +具体模板行:顶部/身份 `edit.vue:86-194`;生命体征 `edit.vue:199-368`;主诉 `edit.vue:369-412`;现病史 `edit.vue:414-549`;既往史 `edit.vue:552-564`;其他病史 `edit.vue:567-613`;诊断信息 `edit.vue:616-629`。 + +当前模板没有显示 `syndrome_type`、`diabetes_type`、舌象、脉象、治则、医嘱等字段,即便脚本模型中存在,也不应擅自加入视觉复刻。 + +### 4.6 移动端 + +小于等于 768 px 时(`edit.vue:1876-2004`): + +- Drawer 变为 100vw;header `12px 14px`,body `10px 12px`。 +- Footer 改为纵向、按钮全宽,gap 10 px,内边距 `12px 14px + safe-area`,按钮最小 44 px。 +- 标题 17 px;Tab 高/行高 44 px、字号 13 px、水平内边距 12 px。 +- label 改到控件上方:100% 宽、左对齐、下间距 6 px;表单项底部 14 px。 +- 所有 `el-col` 强制 100%,数字框 100%;radio gap `8px 16px`,checkbox `8px 10px`。 +- 分组标题外边距 18/14 px,文字 13 px、水平内边距 10 px。 + +```text +移动端(<=768) +┌──────────────────────┐ +│ 标题 [状态] × │ +│ 患者 · 诊单ID │ +├──────────────────────┤ +│ ← 可横向滚动 Tabs → │ +├──────────────────────┤ +│ 姓名 │ +│ [__________________] │ +│ 身份证 │ +│ [__________________] │ +│ ... 全部单列 ... │ +├──────────────────────┤ +│ [取消/关闭 全宽] │ +│ [保存 全宽,44px+] │ +└──────────────────────┘ +``` + +--- + +## 5. 预约抽屉 `appointment.vue` + +### 5.1 骨架与字段顺序 + +预约同样是 RTL 右侧 Drawer,宽 60%,z-index 2000,且点击遮罩不能关闭(`appointment.vue:2-9`)。表单 label width 为 100 px(`appointment.vue:19`)。 + +```text +┌───────────────────────────────────────────────────────────────┐ +│ 预约面诊 × │ +├───────────────────────────────────────────────────────────────┤ +│ [今日重复预约警告 / 其他日期提示,条件显示] │ +│ 上次面诊 2026-08-01 10:30 │ +│ 预约方式 (●) 按时间预约 │ +│ 预约类型 (●) 视频面诊 │ +│ 预约患者 (●) 当前患者 │ +│ 渠道来源 [请选择 ▼] │ +│ 自媒体详情 [请选择 ▼] ← 仅自媒体渠道 │ +│ 预约医生 ( ) 医生A ( ) 医生B ... │ +│ 预约时间 [08月10日 周一] [08月11日 周二] ... │ +│ ┌───────────────────────────────────────────┐ │ +│ │ 可预约时段 [刷新] │ │ +│ │ [09:00 可约] [09:30 已满] [10:00 可约] ...│ │ +│ └───────────────────────────────────────────┘ │ +│ 备注 [_________________________________________] │ +├───────────────────────────────────────────────────────────────┤ +│ [取消] [确认预约] │ +└───────────────────────────────────────────────────────────────┘ +``` + +字段顺序严格为(`appointment.vue:21-181`): + +1. 上次面诊(文本)。 +2. 预约方式(当前只呈现按时间预约)。 +3. 预约类型(视频)。 +4. 预约患者(当前患者)。 +5. 渠道来源(必填 select)。 +6. 自媒体详情(渠道命中自媒体白名单时必填)。 +7. 预约医生(radio 列表)。 +8. 预约时间:先日期按钮,后可预约时段矩阵。 +9. 备注(textarea,2 行)。 +10. Footer:取消、确认预约。 + +### 5.2 尺寸、颜色与交互状态 + +- 渠道控件宽 100%、最大宽 360 px(`appointment.vue:741-750`)。 +- 医生/单选组可换行,gap 12 px(`appointment.vue:752-765,911-921`)。 +- 日期按钮 flex-wrap、gap 10 px、最小宽 130 px、高 40 px、字号 14 px、圆角 8 px;hover 上移 2 px并加阴影(`appointment.vue:771-789`)。 +- 时段容器底 `#f8f9fa`、圆角 8 px、内边距 16 px;标题 15 px / 600 / `#303133`(`appointment.vue:791-808`)。 +- 时段网格 `auto-fill minmax(110px,1fr)`、gap 10 px、最大高 450 px并滚动,滚动条宽 6 px(`appointment.vue:810-830`)。 +- 时段卡白底,2 px `#e4e7ed` 边框、8 px 圆角、最小高 70 px、水平内边距 8 px;时间 15 px / 600 / `#303133`,状态 12 px / `#909399`(`appointment.vue:831-862`)。 +- 可约 hover:`#409eff` 边框、`#ecf5ff` 底、上移并加阴影(`appointment.vue:864-873`)。 +- 不可约:`#f5f7fa` 底、opacity 0.6、文字 `#c0c4cc`(`appointment.vue:875-894`)。 +- 选中:`#409eff → #66b1ff` 渐变、白字和阴影(`appointment.vue:896-909`)。 +- Footer 右对齐、gap 12 px、上下内边距 12 px(`appointment.vue:923-928`)。 + +状态逻辑是视觉的一部分:未选医生时显示“请先选择医生”,无排班显示空态;日期只来自当前及未来有效排班(`appointment.vue:299-326`);当天过去时段禁用(`appointment.vue:328-357`);当天已有预约显示 warning,其他日期重复信息为 info(`appointment.vue:359-375`)。提交按钮只有医生、日期、时间、渠道以及条件性渠道详情齐全,且不存在当天冲突时才可用(`appointment.vue:377-386`)。 + +### 5.3 响应式事实 + +`appointment.vue` 没有移动端 media query。内部医生、日期和 slot 网格会换行,但 Drawer 在窄屏仍保持 60% 视口宽。这是后台源码的真实缺口;PySide 若要求窗口缩小时可用,应保留桌面 60% 的视觉比例,同时设置合理最小内容宽并在不足时切到全宽/单列,而不声称这是 Vue 原实现已有行为。 + +--- + +## 6. 备注、日常记录、处方/订单与历史关联区 + +### 6.1 医生备注 / 舌象 / 报告 + +编辑 Drawer 和独立只读页均使用 `NoteTimeline`(`edit.vue:633-643`; `readonly.vue:56-72`)。编辑态顶部按权限显示“新增备注、选择舌象、选择报告”;只读态隐藏这些操作(`src/views/patient/reception/components/NoteTimeline.vue:1-123`)。 + +视觉规格(`NoteTimeline.vue:272-436`): + +- 顶部动作行 gap 12 px、底部分隔线。 +- 时间轴左内边距 22 px,轴线 2 px `#ebeef5`;节点 10 px、`#409eff`,外圈 `#ecf5ff`。 +- 日期 13 px / 600 / `#303133`,等宽数字;正文 12.5 px / `#606266` / 1.6 行高。 +- 舌象缩略图 `64 × 64 px`、圆角 6 px、带边框;删除为红色动作。 +- 报告文件 chip 为 `6px 10px`、圆角 6 px、12 px primary 字,最大宽 180 px。 +- 上传格 `90 × 90 px`、1 px dashed `#dcdfe6`、圆角 6 px、12 px;hover 转 primary。 +- 新增备注 Dialog 宽 480 px,textarea 4 行、maxlength 500。 + +`TrackingNoteTimeline` 当前并未进入可见 Tab:`edit.vue:644-654` 将整段注释。不能在复刻中把它当作现存“跟踪备注”页签。 + +### 6.2 日常记录、趋势与待办 + +DailyMatrix 顶部提供最近 7 天 / 30 天 / 自定义日期和日期范围;右侧有本人记录图例、编辑动作与血糖血压/饮食/运动/备注/刷新按钮,readonly 仅保留刷新(`components/DailyMatrix.vue:1-129`)。矩阵的指标顺序为:空腹、餐后2小时、其他血糖、血压、西药、胰岛素、早餐、午餐、晚餐、运动、跟踪备注(`components/DailyMatrix.vue:410-485`)。固定指标列 120 px,日期列最小 100 px。 + +- 根内边距 16 px;toolbar 换行、gap 12 px、下间距 12 px(`DailyMatrix.vue:1090-1105`)。 +- 趋势卡上间距 16 px、内边距 `16px 18px`、圆角 10 px,图表高 280 px(`DailyMatrix.vue:1124-1158`)。 +- 超标红 `#dc2626` / 700;本人记录用紫色浅底,徽章 `#6d28d9 / #ede9fe / #ddd6fe`(`DailyMatrix.vue:1182-1225`)。 +- <=768 px 时根内边距 12 px,图表内边距 12 px、高 240 px(`DailyMatrix.vue:1240-1260`)。 + +待办区域位于 DailyMatrix 下方,标题 14 px / 600。表格字段依次为提醒时间、内容、状态、创建人、推送时间、错误信息、操作;toolbar 可筛全部/待推送/已推送/失败/已取消(`components/DiagnosisTodoList.vue:1-112`)。新增 Dialog 宽 520 px、label 100 px,字段顺序为提醒时间、内容(4 行/500 字)、提醒人;toolbar 换行、gap 8 px、下间距 12 px,分页右对齐(`DiagnosisTodoList.vue:291-319`)。 + +### 6.3 处方 / 病例历史 + +`CaseRecordList` 顶部编辑态显示小号 primary“开方”,只读时隐藏。表格字段顺序:就诊日期(120)、就诊编号(120)、诊断(min 160)、处方摘要(min 180)、医生(90)、状态(140)、操作(120 固定右侧);根内边距 20 px(`components/CaseRecordList.vue:1-39,103-106`)。 + +点击“开方”进入直接依赖的全局 `@/components/tcm-prescription/index.vue`,不是在诊单 Tab 内原地编辑:它另开 1200 px Drawer、禁止点遮罩关闭,表单 label 100 px(`src/components/tcm-prescription/index.vue:1-10`)。编辑态顺序为: + +1. 患者信息:姓名 / 性别 / 年龄三等分,电话 / 门诊号两等分。 +2. 诊断信息:面象 / 舌象两等分,临床诊断整行 2 行 textarea;舌象详情和脉象详情在模板中被注释(`index.vue:11-76`)。 +3. 中药处方 RP:药材总数与锁定提示;添加主方、添加辅方、处方库导入、粘贴导入;处方价格;主方卡片网格;辅方卡片网格(`index.vue:78-216`)。 +4. 用法信息:处方类型、主方用法、条件性辅方用法、剂数、剂量单位、用法、服用时间、服用方式、忌口、其他说明、医师、是否共享、医师签名(`index.vue:217-531`)。 + +信息分区间距 20 px、内边距 16 px、底 `#f5f7fa`、圆角 6 px;标题 15 px / 600 / `#303133`(`index.vue:2500-2516`)。RP toolbar 可换行,gap `12px 20px`、内边距 `14px 16px`、圆角 10 px、lighter 边框;标题 16 px、说明/锁定文字 12 px。药材编辑区桌面固定 4 列、gap 12 px,卡片白底、10 px 内边距、6 px 圆角;仅在 <=960 px 时让操作按钮行占满,**没有**把 4 列药材网格降列(`index.vue:2522-2690`)。签名区最大宽 600 px、1 px `#dcdfe6`、圆角 4 px(`index.vue:2694-2711`)。 + +保存后同一 Drawer 切换为处方预览:Tab 为“药房联 / 处方联”,上方工具条与作废/审核驳回状态,主体是 A4 `210 × 297 mm` 白纸,`8mm 10mm` 内边距、13 px 字号、28 px / 700 标题(`index.vue:536-754,2714-2782`)。患者信息是 4 列表格,药材是 2 列,底部医师/类型/天数/剂量为网格(`index.vue:2807-2966,2990-3035`)。未保存编辑态才提供 Drawer footer(`index.vue:899-905`)。 + +### 6.4 业务订单 + +订单 Tab/卡片顶部先显示“全局就诊序号偏移”工具条:label、tooltip、0–20 数字框(宽 120 px)、保存按钮(`components/PatientOrderList.vue:7-42`)。工具条换行、`12px 14px` 内边距、1 px lighter 边框、8 px 圆角、light fill(`PatientOrderList.vue:252-276`)。 + +订单表为 bordered + striped,字段顺序和列宽来自 `PatientOrderList.vue:44-106`: + +1. 订单号,min 200。 +2. 全局就诊序号,96。 +3. 数量统计,96,Tag。 +4. 金额,120,右对齐红字。 +5. 医生,110。 +6. 医助,110。 +7. 履约状态,110,Tag。 +8. 创建时间,min 170。 +9. 详情,100,固定右侧。 + +默认 page size 为 10,底部带分页,点击详情打开只读订单详情(`PatientOrderList.vue:108-148`)。 + +订单详情复用 `PrescriptionOrderDetailDrawer` 的 readonly 受限版,而不是订单页内展开(`PatientOrderList.vue:113-124`)。该共享 Drawer 宽 80%,Header `16px 24px`,标题 18 px / 600,订单号与药房单号用圆角 Tag;加载态为 12 行 skeleton,body 水平内边距 24 px并独立纵向滚动(`src/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue:13-50,1752-1757`)。内容顺序为: + +1. 金额概览:总金额、已付总额、退款金额、需代收、已付笔数;响应式 2 / 3 / 5 列(`PrescriptionOrderDetailDrawer.vue:154-197`)。 +2. 两栏主区:左侧处方详情,右侧未关联收款与关联收款;收款表顺序为 ID、单号、类型、金额、状态、方式(readonly 隐藏)、创建人、创建时间(`PrescriptionOrderDetailDrawer.vue:200-545`)。 +3. 履约与收货信息:3 列 descriptions,label 宽 120 px(`PrescriptionOrderDetailDrawer.vue:551-680,1782-1785`)。 +4. 物流轨迹与操作日志(有数据/开启附加加载时)(`PrescriptionOrderDetailDrawer.vue:683-824`)。 + +readonly 明确隐藏顶部告警、流程步骤、挂号关联、内部成本、药材明细、收款方式和外部物流更新等完整版内容,但金额概览、主辅方用量/服法和基础详情仍共享(`PrescriptionOrderDetailDrawer.vue:2-11`)。面板统一 8 px 圆角,Header `14px 16px`、Body 16 px(`PrescriptionOrderDetailDrawer.vue:1759-1780`)。 + +### 6.5 视频、聊天与历史表 + +**视频录制回放。** CallRecordPanel 编辑态有上传 toolbar;字段为播放(min 320)、开始时间 170、结束时间 170、通话类型 100、房间 180、时长 110、状态 90、录制 100、上传 180(仅编辑)(`components/CallRecordPanel.vue:1-74`)。空态内边距 `28px 12px`,标题 14 px、说明 12 px / 1.6(`CallRecordPanel.vue:188-214`)。内嵌视频最大高 180 px、圆角 4 px、黑底;播放覆盖层为 `rgba(0,0,0,.62)`、白色 12 px(`components/RecordingVideoPlayer.vue:322-359`)。 + +**聊天。** 顶部 info alert + 同步/刷新 toolbar,消息区最大高 `min(60vh,520px)`、gap 16 px。单条最大宽 88%;患者消息靠左、灰底,医生消息靠右、primary 浅底;meta 12 px,气泡 14 px、1.5 行高、`10px 12px` 内边距、8 px 圆角;图片最大 `240 × 200 px`(`components/ImChatRecordPanel.vue:1-70,200-293`)。 + +**医助指派历史。** 字段依次为时间 175、原医助 min 120、新医助 min 120、继承 72 Tag、快照订单创建人 min 130、快照创建时间 190、操作人 110、账号 120、IP 130(`components/AssignLogPanel.vue:1-43`)。 + +**挂号历史。** 字段依次为 ID 72、状态 100 Tag、患者 min 150(姓名+手机堆叠)、医生 110、医助 110、预约时间 min 130(日期+时段堆叠)、预约类型 100、渠道 110、确认 92、处方 80、备注 min 100、创建时间 165(`components/AppointmentRecordPanel.vue:1-75`)。堆叠单元垂直 gap 2 px、行高 1.35(`AppointmentRecordPanel.vue:172-178`)。 + +### 6.6 `components/` 全目录的可达性边界 + +三主视图的实际 import 清单见 `edit.vue:787-805` 与 `readonly.vue:151-167`。为避免把目录中保留组件误认成当前详情功能,本次也核了其余文件: + +| 组件 | 三主视图当前可达性 | 结论 | +|---|---|---| +| `PatientInfoCard`、`PatientCaseCard` | readonly 直接使用 | 当前只读摘要/病例 | +| `DailyMatrix`、`CaseRecordList`、`PatientOrderList`、`CallRecordPanel`、`ImChatRecordPanel`、`AssignLogPanel`、`AppointmentRecordPanel` | edit/readonly 直接使用(依页面和权限而异) | 当前主链 | +| `RecordingPlaybackBlock` → `RecordingVideoPlayer` | CallRecordPanel 间接使用 | 当前视频主链(`CallRecordPanel.vue:32,78`; `RecordingPlaybackBlock.vue:5,35`) | +| `DiagnosisTodoList` | DailyMatrix 间接使用 | 当前日常记录主链 | +| `TrackingNoteTimeline` | edit 有 import,但模板区整段注释 | 当前不可见(`edit.vue:644-654,803`) | +| `BloodRecordList` | 未被三主视图 import | 旧/独立颗粒列表;其能力已由 DailyMatrix 内置 600 px 血糖血压 Dialog 承接(`BloodRecordList.vue:1-170`; `DailyMatrix.vue:131-185`) | +| `DietRecordList` | 未被三主视图 import | 旧/独立列表;当前 DailyMatrix 内置 800 px 饮食 Dialog(`DietRecordList.vue:1-112`; `DailyMatrix.vue:187-227`) | +| `ExerciseRecordList` | 未被三主视图 import | 旧/独立列表;当前 DailyMatrix 内置 600 px 运动 Dialog(`ExerciseRecordList.vue:1-86`; `DailyMatrix.vue:229-260`) | +| `TrackingMatrix` | 未被三主视图 import | 非当前矩阵;当前入口明确 import `DailyMatrix`(`edit.vue:796`; `readonly.vue:161`) | +| `AssistantWatchCallDialog` | 未被三主视图 import | 旁路观看 Dialog,不属于诊单详情/编辑/预约;自身是 auto-fill、最小 280 px 的观看网格,gap 12 px(`AssistantWatchCallDialog.vue:1-19,242-282`) | + +因此 PySide 对齐应以“当前可达主链”为实现范围,不能因为同目录存在旧颗粒列表而重复增加血糖、饮食、运动 Tab,也不能把观看 Dialog 或 TrackingMatrix 插入诊单详情。 + +--- + +## 7. 加载、空态、错误和权限状态 + +| 场景 | 后台表现 | 源码 | +|---|---|---| +| 独立详情加载 | 页面级 loading | `readonly.vue:1-2` | +| 独立详情失败 | Hero 下方错误空态卡 | `readonly.vue:31-36` | +| 无未服务记录 | Hero 灰色“从未记录”状态 | `readonly.vue:323-355` | +| 抽屉只读 | 状态 badge + disabled fieldset + 无保存 | `edit.vue:11-54,57-84,766-781` | +| 患者隐私锁定 | warning Alert,身份字段不可改 | `edit.vue:71-84` | +| Tab 失权 | 隐藏 Tab;当前失权时回 basic | `edit.vue:655-759,883-913` | +| 预约未选医生 | 时间区提示先选医生 | `appointment.vue:100-171` | +| 无排班/无时段 | 时间区空态 | `appointment.vue:100-171` | +| 当天已有预约 | warning 且禁止确认 | `appointment.vue:10-18,359-386` | +| 其他日期已有预约 | info 提示,不等同当天阻断 | `appointment.vue:359-375` | +| 表格无数据 | 各组件 Element empty/自定义空态 | `PatientOrderList.vue:1-6`; `CallRecordPanel.vue:55-74` | + +操作权限应控制“是否渲染/是否可编辑”,而不是只把无权按钮做成 disabled;这正是 Tab 通过 `v-if` 实现的模式。 + +--- + +## 8. PySide6 组件映射 + +### 8.1 结构映射 + +| Vue / Element 结构 | PySide6 建议 | 必须保留的视觉行为 | +|---|---|---| +| `el-drawer` 右侧抽屉 | 模态 `QDialog` + 右侧面板,或主窗口上的遮罩 `QFrame` + 固定宽面板 | 桌面约 60% 宽;edit 在窄屏切全宽;Header/Body/Footer 三段分离 | +| Drawer body | `QScrollArea` + body widget | 只让 body 滚动,footer 不随滚动 | +| Drawer footer slot | 独立 `QFrame` + `QHBoxLayout` | 上边框、白底、固定在底部;不要放进 ScrollArea | +| `el-tabs` | `QTabWidget` / 自定义 `QTabBar + QStackedWidget` | 可横向滚动、3 px active indicator、按 canonical permission 移除页签 | +| `el-form` + `el-row/el-col` | `QFormLayout` 套 `QGridLayout` | 桌面 160 px 编辑标签 / 100 px 预约标签;窄屏改上标签单列 | +| input/select/date/input-number | `QLineEdit`、`QComboBox`、`QDateEdit`、`QSpinBox/QDoubleSpinBox` | 常规高 32 px、圆角 10 px;footer 按钮 40/44 px | +| radio/checkbox button groups | `QButtonGroup` + 可换行 FlowLayout | 12 px 字号,组间 12–16 px;小窗必须换行 | +| textarea | `QTextEdit/QPlainTextEdit` | 2/3/4 行按源码用途设置 minimumHeight,而非无限增高 | +| alert / empty | `QFrame + QLabel` / 统一 EmptyState | warning/info/error 色语义与文案位置一致 | +| card | `QFrame` | 白底、1 px `#e6ebf2`、14 px radius、18 px padding | +| table | `QTableView + QAbstractTableModel` | stripe、固定/最小列宽、状态 `QStyledItemDelegate` tag、右侧动作列 | +| timeline | `QListView` 或纵向 QWidget 列表 + paintEvent 轴线 | 22 px 左缩进、2 px 轴、10 px node,图片缩略图 64 px | +| appointment slot grid | `QScrollArea + FlowLayout` / `QGridLayout` 动态列 | cell min 110 × 70;选中/禁用/可用状态显式 | +| loading | overlay `QFrame` + spinner/文字 | 遮住当前内容但保留布局尺寸,异步返回后解除 | + +### 8.2 推荐常量(来自源码,不是重新设计) + +```text +FONT_FAMILY = "PingFang SC, Arial, Hiragino Sans GB, Microsoft YaHei" +FONT_BASE = 14 +FONT_FORM = 12 +CONTROL_HEIGHT = 32 +FOOTER_BUTTON_HEIGHT = 40 # <=768 对应布局时 44 +CARD_RADIUS = 14 +CARD_PADDING = 18 +CONTROL_RADIUS = 10 +PANEL_GAP = 16 +BORDER = #e6ebf2 +BODY_BG = #f8fafc +PAGE_BG = #f6f6f6 +TEXT_PRIMARY = #333333 +TEXT_REGULAR = #666666 +TEXT_MUTED = #999999 +PRIMARY = #409eff # 继承 Element 语义主色 +READONLY_ACCENT = #2563eb +READONLY_ACCENT_STRONG = #1d4ed8 +``` + +### 8.3 PySide 字段装配顺序 + +为避免实现时按数据模型字母序排字段,应按以下 UI 顺序装配: + +```text +编辑病历: +诊单ID +→ 姓名 / 身份证 +→ 手机 / 性别 +→ 年龄 +→ [生命体征] 婚姻 / 身高 / 体重 +→ 地区 / 高压 / 低压 +→ 空腹血糖 +→ 诊断类型 +→ 状态 / 渠道 +→ 统计端就诊卡 +→ 在用药物 +→ [主诉] 当地诊断日期 / 糖尿病病史 +→ 当地医院诊断结果 +→ 当地医院名称 +→ [现病史] 口腔 → 饮水 → 体重变化 → 脂肪肝 → 饮食 +→ 肢体 → 睡眠 → 眼睛 → 头部 → 出汗 → 皮肤 → 小便 → 大便 → 腰肾 +→ 其他补充 +→ [既往史] 既往史 +→ [其他病史] 外伤 / 手术 / 过敏 → 家族 / 妊娠 +→ [诊断信息] 病史补充 + +预约: +上次面诊 → 预约方式 → 预约类型 → 预约患者 → 渠道来源 +→ [条件] 自媒体详情 → 预约医生 → 日期 → 时段 → 备注 +``` + +--- + +## 9. 验收清单 + +1. 独立 readonly 是纵向卡片流,不出现 Tabs;抽屉 viewOnly 才是 Tabs + disabled 表单。 +2. 编辑 Drawer 桌面 60%,<=768 等效状态下全宽;预约 Drawer 源码没有该响应式覆盖,需明确属于适配增强。 +3. 编辑桌面 label 160 px,预约 label 100 px;编辑移动端 label 在控件上方。 +4. 编辑的 body 独立滚动,footer 始终可见;实现中没有把按钮放到滚动区末尾。 +5. Tab 顺序与 canonical permission 一致;“跟踪备注”不渲染。 +6. 编辑病历字段严格按 §4.5;不把脚本中未渲染字段自行加入。 +7. 只读病例按 4/3 列网格及 §3.4 顺序;小窗若做降列,需视为桌面端增强而非源码既有行为。 +8. 预约状态至少覆盖:未选医生、无排班、可约、不可约、选中、当天冲突、其他日期提示、loading。 +9. 订单、备注、视频、聊天、指派历史、挂号历史的列顺序/动作显隐与 §6 一致。 +10. 所有表格提供 loading、empty、error;权限失去时移除入口并将当前页回退到可见页。 + +以上规格记录的是当前后台源码的可见事实;PySide 可为桌面小窗增加降列和全宽适配,但不得改变字段顺序、权限门槛、状态语义或主操作位置。 diff --git a/app/research/diagnosis_exact_final_report.md b/app/research/diagnosis_exact_final_report.md new file mode 100644 index 000000000..a4d47f612 --- /dev/null +++ b/app/research/diagnosis_exact_final_report.md @@ -0,0 +1,31 @@ +# Diagnosis 详情二次终验报告 + +日期:2026-08-10 + +## CLOSED + +- 统计端就诊卡:使用原生自绘二态 `DiagnosisSwitch`,保存值严格为 `0/1`。 +- 日常记录:提供 `+ 血糖 / + 饮食 / + 运动 / + 备注`,以及待办新增、仅待执行且 `can_cancel` 的精确取消;Remote/Demo/Protocol 均接真实方法,包含权限、DTO 校验和 generation 失效保护。 +- 诊疗笔记:接入新增/当日追加、舌象与报告上传、单附件删除;所有动作均调用现有真实 endpoint。 +- 处方与订单:处方按钮进入完整 `PrescriptionEditorDialog` 并调用 `tcm.prescription/add`;诊次偏移写入 `setRevisitSlotStartOffset`;订单详情调用真实详情方法。 +- 视频回放:列表、视频上传、手工通话记录创建、附件绑定均接真实 endpoint;播放仅允许合法 `HTTP(S)` 外链。 +- IM:固定 `only_archived=1`,支持异步同步与重新加载,并显示文本、图片、文件消息。 +- 视觉:tab 使用 4px 横向滚动条并隐藏原生左右箭头框;footer 有向上阴影;保存按钮自绘 loading spinner、成功勾、失败叉;readonly 摘要禁止拆字;表格状态改为 pill/tag。 +- Shell owner:每次 `open_for` / `showEvent` 从 `parentWidget().window()` 重新绑定,按 Shell 宽度 60% 计算抽屉,move/resize 立即同步,关闭后全部请求 generation 失效。 +- 列表补充合同:QR、挂号日志、通用订单与 exact `doctor.appointment/cancel` 均有 Protocol/Remote/Demo 方法。 + +## OPEN(服务端合同不存在,fail-closed) + +- 诊疗笔记“任意改写既有正文”与“删除整条笔记”没有后端 endpoint,因此桌面端没有伪造动作。现有合同只允许“当天追加”和“删除单个附件”: + - `server/app/adminapi/controller/doctor/AppointmentController.php:168-192` 仅公开 `addDoctorNote`、`doctorNotes`、`deleteDoctorNoteImage`。 + - `server/app/adminapi/logic/doctor/DoctorNoteLogic.php:14-67` 的 `addOrAppend` 按 `diagnosis_id + 当天` 追加正文/附件,不是任意正文覆盖。 + - `server/app/adminapi/logic/doctor/DoctorNoteLogic.php:114-141` 仅实现单个附件删除。 + - `admin/src/api/patient.ts:20-40` 同样只有上述三个客户端 API。 + +## 验证 + +- 定向详情与合同:`28 passed`。 +- 相关 repository/reception/video:`57 passed`。 +- 全量:`226 passed`。 +- `ruff check .`:exit 0(仅工作区不可访问缓存目录的扫描 warning)。 +- 视觉产物:`artifacts/diagnosis_visual/` 中 6 张模式图与 14 张详情状态图,共 20 张本轮详情 PNG。 diff --git a/app/research/diagnosis_final_visual_gate.md b/app/research/diagnosis_final_visual_gate.md new file mode 100644 index 000000000..2992a2205 --- /dev/null +++ b/app/research/diagnosis_final_visual_gate.md @@ -0,0 +1,155 @@ +# Diagnosis 最终视觉发布门禁 + +审计日期:2026-08-11 +审计性质:第四次独立、只读、最终视觉发布门禁;除更新本报告外,未修改业务源码、测试或 PNG。 +基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/` 及其主链直接依赖组件。 +当前截图:`D:/web/zyt/app/artifacts/diagnosis_visual/`。 +Trellis:`D:/web/zyt/app/.trellis` 不存在(`Test-Path` 为 `False`),因此无可补读的 Trellis workflow/spec。 +审计口径:只有与基准达到同型才记 PASS;Qt 与浏览器字体栅格化造成的轻微像素差不算结构问题。 + +## 最终结论 + +**PASS。** + +- 共逐张按原始像素尺寸检查 **57 张 PNG**。 +- 逐图判定:**57 PASS / 0 PARTIAL / 0 不可读 / 0 硬裁切到窗口外**。 +- 严重度:**P0 = 0,P1 = 0,P2 = 0**。 +- 旧审计中的 Notes 真实缩略图、Chat 图片缩略图、Daily 局部工具栏、1200 px 连续处方 Drawer、订单 80% 详情 Drawer、订单 offset、Shell 与预约均已闭合或保持通过。 +- 上轮唯一 P1——视频表内播放器被压成 16 px 黑条——已关闭。`diagnosis_video_inline_player_1200x560.png` 与 `diagnosis_state_video_replay_1024x640.png` 均显示完整加载遮罩、播放按钮、进度条、时间、外部打开、独立窗口和备用地址;播放器本体运行时高度为 158 px,未超过基准 180 px 上限。 + +尺寸分布(按 PNG 实际像素而非文件名):`1024x640` 24 张、`1024x768` 1 张、`1200x560` 1 张、`1200x900` 4 张、`1280x800` 11 张、`1440x900` 13 张、`650x620` 1 张、`820x560` 1 张、`920x780` 1 张。 + +第四次增量完整性:目录仍为 57 张;相对上一门禁仅 3 张视频 PNG 更新(`diagnosis_video_inline_player_1200x560.png`、`diagnosis_state_video_replay_1024x640.png`、`diagnosis_state_video_upload_action_1024x640.png`)。本轮已对这 3 张全部按原始尺寸复核,并原尺寸抽查 Notes、Chat、Daily、处方四段连续态、订单 80% Drawer/offset、Shell、预约及 1024/1440 主列表;其余非视频 PNG 时间戳未变化,沿用上一轮全量逐图结论。 + +## 此前 OPEN 逐项复核 + +| 旧 OPEN | 终态 | 视觉与结构证据 | +|---|---|---| +| Notes 舌象真实缩略图 | **CLOSED** | `diagnosis_state_notes_actions_1024x640.png` 已显示实际 64x64 舌象位图,并有右上删除入口;当前 `_RemoteImageButton` 以 cover 方式加载到 `QSize(64, 64)`:`src/doctor_workstation/ui/diagnosis_drawer.py:2364-2404`。基准为真实 `el-image` 64x64 cover:`D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:52-69`。 | +| Chat 图片消息缩略图 | **CLOSED** | `diagnosis_state_chat_archive_1024x640.png` 的患者图片消息直接显示实际图像缩略图,不再是“查看图片”文字链接;当前最大 `240x200`:`diagnosis_drawer.py:2553-2573`。基准:`ImChatRecordPanel.vue:39-50`。 | +| Daily 待办局部工具栏 | **CLOSED** | `diagnosis_state_daily_lower_1024x640.png` 同时显示 `+ 新增待办`、全部/待执行/已发送/失败/已取消及局部“刷新”,无遮挡;当前:`diagnosis_drawer.py:1725-1761`,基准:`DiagnosisTodoList.vue:3-20`。顶部动作行也继续在 `diagnosis_state_daily_1024x640.png` 完整换行。 | +| 处方 1200 px 连续 Drawer | **CLOSED** | `diagnosis_state_prescription_editor_1200x900.png`、`..._herbs_1200x900.png`、`..._usage_1200x900.png`、`..._signature_1200x900.png` 连续证明患者、诊断、RP 药材、用法、签名处于同一纵向滚动流,固定 footer 稳定;1024/920 窄宽度采用可用宽度而非截断。当前 `DRAWER_WIDTH = 1200` 且五段连续加入同一 `QScrollArea`:`src/doctor_workstation/ui/dialogs/prescription.py:1467,1491-1556,1902-1914`。基准:`D:/web/zyt/admin/src/components/tcm-prescription/index.vue:2-216,217-531`。旧 4-tab modal 结构已消失。 | +| 订单 80% 详情 Drawer | **CLOSED** | `diagnosis_order_detail_drawer_1440x900.png` 的 panel 从 x=288 开始、宽 1152(80%);`diagnosis_state_order_detail_640x540.png` 与 `diagnosis_state_order_detail_drawer_1024x640.png` 的实际像素均为 1024x640,panel 从 x=205 开始、宽 819(约 80%)。金额概览、处方详情、关联收款在首屏可见;当前连续构建收款、履约与收货、物流轨迹、操作日志:`src/doctor_workstation/ui/dialogs/diagnosis.py:669-710,3623-4065`。基准共享 readonly Drawer:`PrescriptionOrderDetailDrawer.vue:13-20,154-220,433-545,550-824`。旧 `640x540` 仅是遗留文件名,不是当前截图尺寸。 | +| 订单 offset 文案与解释层级 | **CLOSED** | `diagnosis_state_order_offset_1024x640.png` 已为“复诊统计起始偏移”+ 问号帮助 +“第 1 笔实单计为三诊”+“保存”,与基准同型;当前:`diagnosis.py:1384-1416,3001-3058`,基准:`PatientOrderList.vue:7-41`。 | +| 视频多地址与逐条上传 | **CLOSED(能力/动作)** | `diagnosis_state_video_upload_action_1024x640.png` 的行级“追加回放”可见;当前每行绑定精确 `call_record_id`:`diagnosis.py:3304-3342`。备用地址结构存在于 `diagnosis_media.py:358-442`。 | +| 视频表内播放器 | **CLOSED** | 两张终态图均显示完整可操作的表内播放器与备用地址;第四次运行时复核在 1200x560、1024x640 宿主中均测得 row 243 px、cell 242 px、player 158 px,controls 全部可见。详见下节。 | +| Shell | **CLOSED / 无回归** | `diagnosis_shell_1024x640.png` 与 `diagnosis_shell_1440x900.png` 均保持 183 px sidebar、50 px topbar、40 px tabs,操作区及固定列无错位。 | +| 预约 Drawer | **CLOSED / 无回归** | 8 张预约 PNG 的 60% Drawer、固定 footer、时段网格、empty/error/loading/refreshing/focus 状态均完整。 | + +## P0 / P1 / P2 + +### P0 + +**0 项。** 未发现窗口级硬裁切、核心信息完全不可见、Drawer 比例失效、footer 滚走、二维码不可辨识、Shell 壳层错位或预约主流程不可操作的发布阻断。 + +### P1 + +**0 项。** 上轮唯一 P1“视频表内播放器被裁成 16 px 黑条”已关闭。 + +#### 视频表内播放器关闭证据 + +精确截图: + +- `D:/web/zyt/app/artifacts/diagnosis_visual/diagnosis_video_inline_player_1200x560.png` +- `D:/web/zyt/app/artifacts/diagnosis_visual/diagnosis_state_video_replay_1024x640.png` + +两张图在原始尺寸中均呈现完整播放器:黑色视频 surface 内有“预览待加载 / 点击播放开始加载”,下方可见“播放”、进度条、`00:00 / 00:00`、“外部打开”和“独立窗口”;播放器下方继续显示“备用地址”及 `COS HLS 1`、`链接 2`,第二条无回放记录也保持独立普通行。1200 与 1024 宿主均无裁切、重叠或 controls 遗失。 + +第四次运行时几何复核(使用与最终截图相同的 `RenderRepository` 与两行 fixture,未写 PNG)在 1200x560 和 1024x640 两种宿主下得到一致结果: + +- table row 0:243 px;可视 cell rect:242 px;`RecordingPlaybackCell`:242 px。 +- `InlineRecordingPlayer`:158 px,满足实现的 158–180 px 约束,也满足基准 `max-height: 180px`。 +- 播放按钮、slider、时间标签及整个 player 均 `visible=True`。 +- 备用地址使外层 cell 高于 180 px 是正确行为;180 px 上限只约束播放器本体,不约束播放器 + 备用地址的整行总高。 + +当前源码的闭环点: + +- `src/doctor_workstation/ui/diagnosis_media.py:179-278` 构建 158–180 px 内嵌 player、视频 surface 与完整控制栏。 +- `diagnosis_media.py:359-457` 将 player、备用地址组成定高 cell,并由 `required_table_row_height()` 返回真实所需高度。 +- `src/doctor_workstation/ui/dialogs/diagnosis.py:3304-3367` 在 Qt 的 `resizeRowsToContents()` 后,以 cell 实际 hint 恢复 row 高度并写入 item size hint,避免另一列固定高按钮再次压扁整行。 + +基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/RecordingPlaybackBlock.vue:1-30` 在“录制回放”单元格内直接放置 `RecordingVideoPlayer`;`RecordingVideoPlayer.vue:2-13,320-329` 显示 controls、16:9、最大高 180 px 的完整播放器。当前终态在结构、信息层级和可操作性上已同型,因此关闭 P1。 + +### P2 + +**0 项。** 上一轮两项 P2 已闭合:Daily 待办已具精确 `+ 新增待办` 与局部刷新;订单 offset 已具精确标题、帮助入口、动态解释与“保存”文案。 + +## 57 张 PNG 逐张判定 + +### 预约 Drawer(8/8 PASS) + +| PNG | 判定 | 原始尺寸下目检结论 | +|---|---|---| +| `appointment_drawer_1024x640.png` | PASS | 60% Drawer、body 滚动、时段状态与固定 footer 完整。 | +| `appointment_drawer_1440x900.png` | PASS | 医生、日期、时段网格、备注与 footer 对齐。 | +| `appointment_drawer_empty_doctors_1440x900.png` | PASS | warning、空医生状态与禁用确认完整。 | +| `appointment_drawer_empty_roster_1440x900.png` | PASS | 无排班 warning、医生选择与空态完整。 | +| `appointment_drawer_error_1440x900.png` | PASS | 号源错误 banner、刷新入口、空态与禁用确认完整。 | +| `appointment_drawer_keyboard_focus_1440x900.png` | PASS | 日期按钮 focus ring 清晰,不挤压布局。 | +| `appointment_drawer_loading_1440x900.png` | PASS | 初始 loading overlay、spinner、文案与 footer 完整。 | +| `appointment_drawer_refreshing_1440x900.png` | PASS | 刷新 overlay、spinner、文案与 footer 完整。 | + +### 诊单列表、壳层与主 Drawer(23/23 PASS) + +| PNG | 判定 | 原始尺寸下目检结论 | +|---|---|---| +| `diagnosis_1024x640.png` | PASS | 窄屏筛选换行、横向滚动、右 460 px 固定区与行语义完整。 | +| `diagnosis_1440x900.png` | PASS | 宽屏列密度、复合挂号格、业务色与固定操作区成立。 | +| `diagnosis_advanced_filters_1280x800.png` | PASS | 高级筛选全量展开,无控件裁切。 | +| `diagnosis_double_appointment_cancel_1280x800.png` | PASS | 同诊单双挂号各有取消入口。 | +| `diagnosis_edit_1024x640.png` | PASS | 60% Drawer、tab、独立滚动与固定 footer 成立。 | +| `diagnosis_edit_1440x900.png` | PASS | 864 px Drawer 与宽屏字段比例稳定。 | +| `diagnosis_empty_1280x800.png` | PASS | 表头、空态、横向滚动与 0 条分页保留。 | +| `diagnosis_error_1280x800.png` | PASS | 错误态清晰,无旧数据穿透。 | +| `diagnosis_focus_1280x800.png` | PASS | 搜索 focus ring 清晰且不挤压布局。 | +| `diagnosis_full_menu_1280x800.png` | PASS | 8 项菜单、图标、danger 删除与“二维码”均完整。 | +| `diagnosis_horizontal_scroll_1024x640.png` | PASS | 主表横移时右 460 px 固定区不动。 | +| `diagnosis_hover_warning_1280x800.png` | PASS | warning 行 hover 与左侧强调条连续。 | +| `diagnosis_loading_1280x800.png` | PASS | 表内 spinner 与淡化内容完整。 | +| `diagnosis_order_detail_drawer_1440x900.png` | PASS | 80% Drawer、金额概览、处方详情、收款记录与固定 footer 成立。 | +| `diagnosis_order_qrcode_1280x800.png` | PASS | QR 可辨、订单号、状态、只读 URL 与动作完整。 | +| `diagnosis_pending_assign_1280x800.png` | PASS | 待分配医助、月份与搜索处于同一流式区域。 | +| `diagnosis_permissions_cropped_1280x800.png` | PASS | 权限收口后只保留查看,无敏感动作残留。 | +| `diagnosis_readonly_1024x640.png` | PASS | 独立只读页 hero、病例卡与窄屏字段无拆字。 | +| `diagnosis_readonly_1440x900.png` | PASS | 4 列病例密度、异常指标与长页滚动成立。 | +| `diagnosis_shell_1024x640.png` | PASS | 183/50/40 壳层、tabs、固定列及分页成立。 | +| `diagnosis_shell_1440x900.png` | PASS | 宽屏壳层比例及顶栏图标/中文稳定。 | +| `diagnosis_viewonly_1024x640.png` | PASS | 60% 只读 Drawer、警告、disabled 表单与仅关闭 footer 成立。 | +| `diagnosis_viewonly_1440x900.png` | PASS | 宽屏只读栅格、全 tabs 与固定 footer 成立。 | + +### 详情状态与子流程(26/26 PASS) + +| PNG | 判定 | 原始尺寸下目检结论 | +|---|---|---| +| `diagnosis_state_chat_archive_1024x640.png` | PASS | info、归档同步、左右气泡与真实图片缩略图成立。 | +| `diagnosis_state_daily_1024x640.png` | PASS | Daily 动作完整换行,无裁切。 | +| `diagnosis_state_daily_blood_edit_650x620.png` | PASS | 血糖/血压字段、单位、必填说明与固定动作完整。 | +| `diagnosis_state_daily_lower_1024x640.png` | PASS | `+ 新增待办`、filters、局部刷新、状态与横向滚动完整。 | +| `diagnosis_state_empty_1024x640.png` | PASS | 处方空态与 primary“开方”成立。 | +| `diagnosis_state_error_1024x640.png` | PASS | danger banner、重试、空表单与禁用保存完整。 | +| `diagnosis_state_focus_1024x640.png` | PASS | 姓名 focus ring、close 与固定 footer 完整。 | +| `diagnosis_state_loading_1024x640.png` | PASS | loading overlay、spinner 与文案完整。 | +| `diagnosis_state_notes_actions_1024x640.png` | PASS | 新增/上传动作、实际舌象缩略图、报告 chip 与单附件删除完整。 | +| `diagnosis_state_order_detail_640x540.png` | PASS | 实际为 1024x640 的 80% Drawer;金额与处方首屏、滚动与 footer 成立。 | +| `diagnosis_state_order_detail_drawer_1024x640.png` | PASS | 80% Drawer 窄屏终态与上一张一致且无结构裁切。 | +| `diagnosis_state_order_offset_1024x640.png` | PASS | 精确标题、帮助、动态解释、“保存”、列表与分页成立。 | +| `diagnosis_state_permission_1024x640.png` | PASS | 获权 tabs、敏感字段掩码与仅关闭 footer 成立。 | +| `diagnosis_state_prescription_editor_1024x768.png` | PASS | 窄屏连续 Drawer 首段、RP 工具栏、滚动与固定 footer 成立。 | +| `diagnosis_state_prescription_editor_1200x900.png` | PASS | 1200 px 连续 Drawer 患者/诊断/RP 起始结构成立。 | +| `diagnosis_state_prescription_editor_920x780.png` | PASS | 窄宿主按可用宽度适配,旧 tabbed modal 已消失。 | +| `diagnosis_state_prescription_editor_herbs_1200x900.png` | PASS | 主方/辅方网格、剂量与删除入口处于同一滚动流。 | +| `diagnosis_state_prescription_editor_signature_1200x900.png` | PASS | 辅方用法、医生签名板及固定 footer 成立。 | +| `diagnosis_state_prescription_editor_usage_1200x900.png` | PASS | 主/辅方用法层级、说明字段与连续滚动成立。 | +| `diagnosis_state_save_failure_1024x640.png` | PASS | 红色失败态与文案完整。 | +| `diagnosis_state_save_loading_1024x640.png` | PASS | 按钮内 spinner 与“正在保存”完整。 | +| `diagnosis_state_save_success_1024x640.png` | PASS | 绿色成功态与文案完整。 | +| `diagnosis_state_video_player_820x560.png` | PASS | 独立播放器作为兼容兜底本身完整;不替代表内播放器要求。 | +| `diagnosis_state_video_replay_1024x640.png` | PASS | 158 px 表内 player、加载态、完整 controls 与备用地址均可见,第二条普通行未受高行影响。 | +| `diagnosis_state_video_upload_action_1024x640.png` | PASS | 横移后 243 px 高视频行仍保持状态、录制与“追加回放”垂直居中,第二条普通行未被错误拉高。 | +| `diagnosis_video_inline_player_1200x560.png` | PASS | 宽屏完整显示 158 px 表内 player、controls、备用地址及相邻列,无裁切。 | + +## 发布判定 + +- 旧审计的 Notes、Chat、Daily、处方、订单、视频、Shell、预约问题已经全部闭合或保持通过。 +- 两张重新录制的视频原始尺寸 PNG 与运行时实际控件几何相互印证,旧唯一 P1 已关闭。 +- 当前最终门禁:**PASS(57 PASS / 0 PARTIAL,共检查 57 张 PNG;P0=0,P1=0,P2=0)。** diff --git a/app/research/diagnosis_index_visual_spec.md b/app/research/diagnosis_index_visual_spec.md new file mode 100644 index 000000000..15934687e --- /dev/null +++ b/app/research/diagnosis_index_visual_spec.md @@ -0,0 +1,373 @@ +# 管理端“诊单列表”视觉规格(源码基准) + +> 审计日期:2026-08-10 +> 唯一业务页面基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue` +> 方法:只读检查 Vue 模板、同文件 scoped SCSS、直接挂载组件、全局样式、主题生成逻辑和当前锁定的 Element Plus 源码;未启动浏览器、未改业务源码。 + +## 1. 结论摘要 + +1. 该页面**没有页面内标题、说明文案或独立标题栏**。路由内容从一张无边框、无阴影的筛选卡直接开始;“新增诊单”等主操作位于第二张列表卡顶部,而不是页面标题旁。证据:`index.vue:2-154`。 +2. 页面是“浅灰页面底 + 两张白卡”的高密度后台列表。外层路由容器提供左右 `8px`、上下 `16px` 的留白;筛选卡和列表卡之间为 `12px`。证据:`D:/web/zyt/admin/src/layout/default/components/main.vue:2-10`、`index.vue:154`、`D:/web/zyt/admin/tailwind.config.js:103-113`。 +3. 顶部不是常规 tabs,而是可换行的圆角文字 chip:6 个日期入口,再接“待预约 / 已完成 / 待分配医助”,计数是文字后面的普通半透明数字,不是独立 badge。当天默认选中。证据:`index.vue:6-70,964-976,2174-2182,2222-2317`。 +4. 表格声明列的最小总宽约为 **1403px**;右侧固定“视频旁观” `120px` 与“操作” `340px`,合计 `460px`。窄于该宽度时由 Element Table 横向滚动,右侧两列保持 fixed。证据:`index.vue:191-342`。 +5. 表格使用斑马纹;普通偶数行是 `#FAFAFA`,普通悬停是 `#F8F8F8`。未确认、未挂号行还声明了左侧语义色和横向渐变,但静态渐变写在 `tr`,斑马纹及 fixed cell 的 `td` 背景可能遮住它;两类行的 hover 渐变直接写在 `td`,表现更稳定。证据:`index.vue:2723-2735`、Element Plus `table.scss:560-588,665-668`。 +6. 空态不是 `el-empty` 插画,而是表格默认的 **60px 高“暂无数据”文本区**;加载态是覆盖表格的半透明蒙层和 `42px` 主色环。证据:Element Plus `table.scss:52-70`、`loading.scss:19-73`、中文 locale `zh-cn.mjs:137`。 + +## 2. 源码证据层级 + +| 层级 | 文件 | 本规格使用内容 | +|---|---|---| +| 页面模板与局部样式 | `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:1-2766` | DOM 次序、列宽、状态类、局部尺寸和颜色 | +| 路由内容容器 | `D:/web/zyt/admin/src/layout/default/components/main.vue:1-12` | 页面底色、外层滚动、`px-2 py-4` | +| 共享组件 | `D:/web/zyt/admin/src/components/pagination/index.vue:1-50`;`D:/web/zyt/admin/src/components/daterange-picker/index.vue:1-49` | 分页布局、页容量、日期范围控件行为 | +| 项目全局样式 | `D:/web/zyt/admin/src/styles/index.scss:1-5`、`var.css:1-49`、`element.scss:58-68,106-115,140-151,177-201`、`public.scss:1-3` | 全局字体、颜色、表格、输入焦点、移动分页 | +| 项目主题 | `D:/web/zyt/admin/src/config/setting.ts:1-15`、`src/utils/theme.ts:3-75`、`src/App.vue:25-34` | 默认主色、明暗主题色派生、运行时覆盖 | +| Tailwind token | `D:/web/zyt/admin/tailwind.config.js:5-155` | 字体族、颜色映射、间距值 | +| UI 框架 | `D:/web/zyt/admin/pnpm-lock.yaml:47-49,2412-2415`;`node_modules/element-plus/theme-chalk/src/**` | 当前锁定 Element Plus 2.13.7 的默认组件尺寸 | + +`package.json:27` 写的是兼容范围 `^2.9.4`,当前 lock 与已安装源码均为 **2.13.7**;本规格的框架默认尺寸以锁文件和当前安装源码为准。 + +## 3. 页面骨架与 ASCII wireframe + +### 3.1 宽屏主态 + +```text +主内容滚动区,#F6F6F6;左右 8,上下 16 +┌────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 筛选卡 #FFF / r4 / 无边框无阴影;内边距 12V × 16H │ +│ [昨天挂号 n] [前天挂号 n] [当天挂号 n] [明天挂号 n] [后天挂号 n] [全部 n] │ +│ [待预约 n] [已完成 n] [待分配医助 n] [患者姓名 / 手机号 160] [查询 32h] │ +│ ──────────────────────────────────────────────────────────────────────────────────────── │ +│ 挂号:[全部] [已挂号] [未挂号] │ 确认:[全部] [已确认] [未确认] 更多筛选⌄ │ +│ · · · 展开时:诊断类型 / 证型 / 医助 / 最近挂号日期 / 最近指派日期 / 渠道 / 重置 · · · │ +└────────────────────────────────────────────────────────────────────────────────────────────┘ + 12px +┌────────────────────────────────────────────────────────────────────────────────────────────┐ +│ [新增诊单] [批量指派医助] [批量取消指派] 已选 n 条(按需显示) │ +├────┬──────┬──────┬───────────┬──────────────┬──────┬────────┬──────┬──────┬──────────┬──────┬────────────────────┤ +│ □ │ ID │ 患者 │ 性别/年龄 │ 挂号 │ 确认 │ 复诊 │ 助理 │ 开方 │未服务天数│旁观* │ 操作* │ +├────┼──────┼──────┼───────────┼──────────────┼──────┼────────┼──────┼──────┼──────────┼──────┼────────────────────┤ +│ □ │123 NEW│张三 │ 男 · 42岁 │[待问诊] 医生 │未确认│日期/医生│医助 │已开方│ 8 │进入 │查看 诊单 开方 … 更多│ +│ │ │ │ │时间 / 渠道 │ │ │ │ │ │ │ │ +├────┴──────┴──────┴───────────┴──────────────┴──────┴────────┴──────┴──────┴──────────┴──────┴────────────────────┤ +│ 共 n 条 [15条/页] [<] 1 2 … [>] 前往 [ ] 页 │ +└────────────────────────────────────────────────────────────────────────────────────────────┘ + * 右侧固定列,共 460px +``` + +### 3.2 结构说明 + +- 外层 `main` 是整页垂直滚动容器;诊单表没有本页固定高度或纵向虚拟滚动,15 条数据会把页面自然撑高。证据:`layout/default/components/main.vue:2-11`、`index.vue:179-190`。 +- 两张卡都使用 `shadow="never"` 与 `!border-none`;保留 Element Card 的白底、裁切和 `4px` 圆角。框架默认:Element Plus `card.scss:9-17`、`common/var.scss:1226-1235`。 +- 页面根节点只设 `min-height:100%`、页面底色和水平居中;不设自身宽度、最大宽度或 padding。证据:`index.vue:2200-2206`。 +- 顶部全局 header、面包屑和多页签属于应用壳,不在 `index.vue` 中;它们是否出现受全局 setting 控制,不能当作本页标题复刻。 + +## 4. 可复刻的基础 token + +### 4.1 字体与基础尺寸 + +| 项 | 源码确定值 | 证据 | +|---|---:|---| +| 字体族 | `PingFang SC, Arial, Hiragino Sans GB, Microsoft YaHei, sans-serif` | `tailwind.config.js:79-81`、`var.css:2` | +| body / Element base | `14px` | `public.scss:1-3`、`var.css:16` | +| small / extra-small | `13px / 12px` | `var.css:17-18` | +| 默认控件高度 | `32px` | Element Plus `common/var.scss:233-241` | +| small 控件高度 | `24px` | 同上 | +| 默认圆角 | `4px` | Element Plus `base.css:1` | +| 卡片圆角 | `4px` | Element Plus `common/var.scss:1227-1234` | + +字体栅格的真实像素高度仍受 Windows 缩放、浏览器 DPR 与首个可用字体影响;页面 chip 没有固定 `height`/`line-height`,只能精确复刻 padding,不能仅凭源码承诺最终外框高度。 + +### 4.2 亮色主题 + +项目在 `App.vue` mount 时用 setting 重写 Element 语义色。默认主色不是 Element 原生蓝,而是 `#4A5DFF`。主题派生算法见 `src/utils/theme.ts:3-18,29-37,55-74`。 + +| token | 默认运行值 | 使用处 | +|---|---|---| +| `--el-color-primary` | `#4A5DFF` | active chip、主按钮、链接、loading | +| primary light-3 / 5 / 7 / 8 / 9 | `#808EFF / #A5AEFF / #C9CEFF / #DBDFFF / #EDEFFF` | hover、focus、轻底色 | +| primary dark-2 | `#3B4ACC` | active 状态;页面另有硬编码近似值 `#3A4ACC` | +| success | `#67C23A` | 已完成 chip、已确认、成功按钮 | +| warning | `#E6A23C` | 待分配、未确认、警示行 | +| danger | `#F56C6C` | NEW、删除/取消 | +| info | `#909399` | 待预约、完成预约、次要态 | +| page / card | `#F6F6F6 / #FFFFFF` | 页面底 / 卡片与普通行 | +| primary / regular / secondary text | `#333333 / #666666 / #999999` | 标题信息 / 常规 / 辅助 | +| placeholder / disabled | `#A8ABB2 / #C0C4CC` | 空值、禁用 | +| border / light / lighter | `#DCDFE6 / #E4E7ED / #EBEEF5` | 控件、卡线、行分隔 | +| fill / light / lighter | `#F0F2F5 / #F8F8F8 / #FAFAFA` | hover、表头、斑马纹 | +| loading mask | `rgba(255,255,255,.5)` | 表格加载蒙层 | + +证据:`src/config/setting.ts:9-14`、`src/styles/var.css:9-43`。主色的派生结果使用项目同一 `css-color-function` 算法计算;不是页面硬编码。warning/info 的页面渐变使用 `--el-color-*-rgb`,其当前框架默认分别为 `230,162,60` 和 `144,147,153`(Element Plus `base.css:1`)。 + +### 4.3 暗色与运行时主题边界 + +- `.dark` 下页面底为 `#0A0A0A`、普通背景 `#1D2124`、overlay `#1D1E1F`,主/常规/次要文字为 `#E5EAF3 / #CFD3DC / #A3A6AD`,lighter border 为 `#363637`。证据:`src/styles/dark.css:1-31`。 +- `App.vue` 根据 `useDark()` 调用主题生成器,主色仍可保持 `#4A5DFF`,但 light-* 在暗色下改用 shade 算法。证据:`src/App.vue:29-34`、`src/utils/theme.ts:12-18,55-77`。 +- setting 会从本地缓存覆盖默认颜色,因此**某个真实账号的主色/语义色可能不同**。证据:`src/stores/modules/setting.ts:12-50,65-77`。上表是无用户自定义时的确定基线。 +- 页面内硬编码的 `#4A5DFF`、`#3A4ACC` 与 `rgba(74,93,255,...)` 不会随自定义主题变化,因而自定义主题下会出现两套蓝色。证据:`index.vue:2522-2529,2556-2559,2596-2599`。 + +## 5. 顶部筛选卡 + +### 5.1 卡与第一行 + +筛选卡 body padding 为 `12px 16px`。第一行 `.filter-main` 是单行 flex:垂直居中、两端对齐、组间 `16px`、下边距 `8px`;**自身没有 `flex-wrap`**。左侧 `.date-chips` 才可换行,chip 间距 `6px`。证据:`index.vue:3-76,2208-2225`。 + +| 元素 | 几何/排版 | 默认/交互颜色 | 证据 | +|---|---|---|---| +| 普通日期 chip | padding `6px 12px`;14px/500;r6;transition `.2s` | 常规 `#666` + `#F8F8F8`;hover `#F0F2F5`;active 白字 + 主色 | `index.vue:2227-2250` | +| chip 计数 | 与标题相隔 `4px`;normal style;opacity `.8` | 跟随父 chip | `index.vue:23-24,2237-2241` | +| 待预约 | 普通 chip 外再有左 margin `8px` | info / info-light-9;active info 实色 | `index.vue:26-32,2253-2265` | +| 已完成 | 左 margin `8px` | success / success-light-9;active success 实色 | `index.vue:33-39,2268-2280` | +| 待分配医助 | 权限控制;外容器可换行、gap `8px`、左 margin `8px` | warning / warning-light-9;active warning 实色 | `index.vue:40-70,2283-2308` | +| 待分配月份 | default 月选择器;宽 `128px` | Element 默认输入态 | `index.vue:51-60,2310-2312` | +| 待分配宽搜索 | default 输入;宽 `220px`,最大 `42vw` | Element 默认输入态 | `index.vue:61-69,2314-2317` | +| 主搜索 | default 输入宽 `160px` | placeholder“患者姓名 / 手机号” | `index.vue:72-75,2320-2326` | +| 查询 | primary default button;高 `32px`,14px,r4 | 白字/主色底 | `index.vue:74`、Element Plus `button.scss:22-43,76-82` | + +日期 chip 的实际顺序是:**昨天、前天、当天、明天、后天、全部**,不是按自然日期升序;随后才是 3 个业务 chip。证据:`index.vue:964-976`。页面挂载时直接选中当天并请求列表;各 chip 计数延后到 idle(不支持时延后约 `120ms`)获取,所以首帧可能只有标签、稍后才出现数字。证据:`index.vue:1046-1095,2174-2182`。 + +### 5.2 第二行快捷筛选 + +- 第二行上方有 `1px solid #EBEEF5`;自身 `padding-top:8px`、`margin-top:8px`、水平 gap `6px`、垂直居中。证据:`index.vue:78-102,2329-2335`。 +- “挂号:”“确认:”为 `13px #999`,label 后额外 `2px`。每组都是“全部 / 已… / 未…”。证据:`index.vue:79-97,2337-2341`。 +- 小 chip 最终 padding 是 `.chip-sm` 的 `4px 10px`,13px、r4、透明底;hover/active 用主色文字与 primary-light-9,active 权重 500。证据:`index.vue:2343-2366`。 +- 两组之间为 `1×14px` 的 lighter 分隔线,左右 margin `4px`。证据:`index.vue:2368-2373`。 +- “更多筛选”是 primary/link/small 按钮,左 margin `8px`;展开时 ArrowDown 旋转 `180°`,文字变“收起”。源码没有为该旋转指定独立 transition。证据:`index.vue:98-101,2375-2380`。 + +### 5.3 展开筛选区 + +展开区使用 `v-show`,所以是即时显隐而非折叠动画。它为可换行 flex,gap `10px`,上 margin/padding 各 `8px`,顶部是 `1px dashed #EBEEF5`。证据:`index.vue:104-151,2384-2391`。 + +控件从左到右: + +1. 诊断类型 select(small,`24px` 高); +2. 证型 select(small); +3. 医助 select(small,可搜索); +4. 最近挂号日期范围(共享 `DaterangePicker`,`260px` 宽、最大 100%,未传 size,故 default `32px` 高); +5. 最近指派日期范围(同上); +6. 最近挂号渠道(small,可搜索,宽 `170px`); +7. 重置(small button,`24px` 高)。 + +证据:`index.vue:104-150,2393-2405`;共享日期组件只是透传一个 `el-date-picker`,见 `components/daterange-picker/index.vue:1-11,15-31`。因此展开区客观上混有 `24px` 与 `32px` 两种控件高度。前三个 select 没有本页固定宽度;Element Select 自身是 `width:100%` 的可收缩 flex item,最终每行宽度依赖可用空间与浏览器 flex 计算,源码不能给出一个稳定像素值。 + +## 6. 列表卡操作栏 + +- 列表卡距筛选卡 `12px`,body padding 清零。证据:`index.vue:154,2408-2412`、Tailwind spacing `tailwind.config.js:103-113`。 +- 操作栏为左右两端布局,padding `10px 16px`,底边 `1px #EBEEF5`;未固定高度。default button 高 `32px`,故无换行时栏高至少约 `52px`。证据:`index.vue:155-177,2414-2429`。 +- 左侧依权限显示:primary“新增诊单”、success“批量指派医助”、warning/plain“批量取消指派”。后两者按选中状态禁用。右侧仅在选中数量大于 0 时显示 `13px #999` 的“已选 n 条”。证据:`index.vue:157-176`。 +- `.list-actions` 声明 `gap:8px`,但 Element 默认还有 `.el-button + .el-button { margin-left:12px }`,此处没有清零。因此相邻按钮的实际起点间空白通常是 **20px(8+12)**,而不是设计者表面写下的 8px。证据:`index.vue:2421-2424`、Element Plus `button.scss:72-74`。 + +## 7. 表格规格 + +### 7.1 外框与表头 + +- 表格外围 padding `10px 16px 0`;表格宽度 100%,默认 fixed layout、无竖边框;行之间由 `1px #EBEEF5` 底边分隔。证据:`index.vue:178-190,2432-2434`、Element Plus `table.scss:11-20,188-195,217-235`。 +- 表头底 `#F8F8F8`、13px/600、文字 `#666`。默认 cell 水平 padding `12px`,行高 `23px`,纵向 padding `8px`,所以单行表头的理论最小高度约 **39px**(23+16,不含亚像素/边线差异)。证据:`index.vue:2710-2716`、Element Plus `table.scss:156-195`、`common/var.scss:1014-1031`。 +- 表头“未服务天数”有双态排序箭头,顺序只允许 descending → ascending;激活箭头使用主色。证据:`index.vue:297-304`、Element Plus `table.scss:548-552`。 + +### 7.2 列宽与内容 + +| 次序 | 列 | 声明宽度 | 对齐/固定 | 主要视觉内容 | +|---:|---|---:|---|---| +| 1 | 选择 | `48px` | center | header 全选 + 行 checkbox | +| 2 | ID | `min 70px` | left | ID 为 14px/600;未读指派时附 small/dark/danger `NEW` | +| 3 | 患者 | `min 60px` | left | 患者名 14px/600;头像模板已注释,不显示 | +| 4 | 性别 / 年龄 | `100px` | left | `男 · 42岁`,继承表格 14px | +| 5 | 挂号 | `min 175px` | left | 预约状态 badge、医生、时间、可取消链接、最近渠道;无则“未挂号” | +| 6 | 确认 | `88px` | center | 13px 已确认/未确认文字 | +| 7 | 复诊 | `min 120px` | left | 时间、医生、可选“处方已作废” tag;无则“无” | +| 8 | 助理 | `100px` | left | 13px 次要文字,溢出 tooltip | +| 9 | 开方 | `72px` | center | 13px 已开方/未开方 | +| 10 | 未服务天数 | `110px` | center | 13px/600 tabular number,按天数绿/橙/红;可有健康打卡 tooltip | +| 11 | 视频旁观 | `120px` | center / right fixed | small link 或 12px 次要状态 | +| 12 | 操作 | `340px` | left / right fixed | 5 个 small/link 主动作 + click dropdown | + +证据:`index.vue:191-406`。声明宽度/最小宽度合计约 `1403px`;`min-width` 列在更宽窗口可继续分配余量。右固定区域为 `120+340=460px`,Element 会在滚动边缘加 fixed-column 阴影;阴影值来自框架 `common/var.scss:981-1000`。 + +### 7.3 行高与密度 + +- 本页再次把 body cell 设为 `padding:8px 0`,cell 内层保留 Element 的水平 `12px` 与 `23px` line-height。纯单行内容的最小行高约 **39px**。证据:`index.vue:2720-2722`、Element Plus `table.scss:188-195`。 +- 挂号 cell 有 `min-height:36px`、内 padding `4px 8px`、r6,所以含该列的常规数据行通常至少约 **52px**(cell 36 + table 上下 16);实际由内容较高者决定。证据:`index.vue:2485-2489`。 +- 同一诊单多条预约时,每一后续预约额外带 `8px` top margin、`8px` top padding 和 `1px dashed #EBEEF5`,行高会显著增加,不是固定行高。证据:`index.vue:227-247,2490-2494`。 +- 操作区允许换行;链接数量受权限与记录状态影响,所以操作列也可能增高整行。证据:`index.vue:344-403,2683-2688`。 + +### 7.4 斑马、hover 与业务高亮 + +| 状态 | 静态 | hover | 判定优先级 | +|---|---|---|---| +| 普通奇数行 | 白 `#FFF` | `#F8F8F8` | 无业务类 | +| 普通偶数行 | `#FAFAFA` | `#F8F8F8` | `stripe` | +| 未确认 | 预期左侧 `3px #E6A23C`;从左到右 warning RGB `.06 → transparent` | warning RGB `.10 → transparent`,直接覆盖 `td` | 第一优先 | +| 已确认但未挂号 | 预期左侧 `3px #909399`;info RGB `.04 → transparent` | info RGB `.08 → transparent`,直接覆盖 `td` | 第二优先 | + +判定函数先检查“是否确认”,只有已确认才检查“是否挂号”,所以一行不会同时得到两个业务类。证据:`index.vue:1313-1337`。页面声明见 `index.vue:2723-2735`;斑马和 hover 框架规则见 Element Plus `table.scss:560-588,665-668`。 + +实现时必须区分“源码意图”和“浏览器可见结果”: + +- 静态业务渐变在 `tr`,而偶数斑马行和 fixed cell 的背景落在 `td`;不透明 `td` 会遮住下面的 `tr` 渐变。 +- 业务 hover 选择器直接命中 `td` 且带 `!important`,因此 hover 更可靠。 +- `border-left` 施加在 `tr`,在 `border-collapse:separate` 的 table 中具体像素绘制还受浏览器表格格式化上下文影响。 + +PySide 复刻应按“意图”直接给每个可见 cell/row delegate 绘背景和 3px 左条,避免复现 CSS 遮挡缺陷;若目标是截图逐像素一致,则需先用指定 Chromium、固定数据测一张运行态截图。 + +### 7.5 单元格内部视觉 + +#### 患者与 ID + +- `.patient-name` 为 14px/600、`#333`、line-height `1.3`。ID 与患者列都复用该样式。证据:`index.vue:194-212,2461-2470`。 +- 32×32、r8、主色渐变的头像 CSS 存在,但患者模板已注释,不参与实际页面。证据:`index.vue:207,2447-2459`。 +- `NEW` 是 small (`20px` 高)、danger、dark tag:白字、danger 实色底;字体 12px、r4。证据:`index.vue:198`、Element Plus `tag.scss:66-87,142-165`、`common/var.scss:1110-1138`。 + +#### 挂号块 + +- 每个预约 badge:11px/600、padding `2px 6px`、r4、下 margin `2px`;医生 14px/600 `#333`;时间 12px、上 margin `2px`。证据:`index.vue:2496-2519`。 +- 活跃预约 badge 为 `#3A4ACC` / `rgba(74,93,255,.15)`,时间硬编码 `#4A5DFF`;整块为 135° 蓝色渐变 `.12 → .06`,边 `rgba(74,93,255,.3)`。证据:`index.vue:2522-2531,2556-2559`。 +- 已完成预约用 info-dark-2 / info-light-7;爽约用 warning-dark-2 / warning-light-7。整块也分别换成 info 或 warning 透明渐变。证据:`index.vue:2533-2569`。 +- 最近渠道为上 margin `6px`、12px、line-height `1.35`、`#999`;未挂号为 13px `#A8ABB2`。证据:`index.vue:2571-2581`。 + +#### 语义文字与 tag + +- 已确认:13px/500 success;未确认:13px/600 warning。证据:`index.vue:2584-2594`。 +- 已开方:13px/500、硬编码 `#4A5DFF`;未开方:13px `#A8ABB2`。证据:`index.vue:2596-2605`。 +- 复诊时间 13px/500 `#333`,医生 12px `#999`,纵向 gap `4px`、line-height `1.35`;“处方已作废”是 small/plain/info tag。证据:`index.vue:268-285,2651-2676`。 +- 未服务天数:最小 `28px`、13px/600、等宽数字;空值灰 `#A8ABB2`/400,`<=2` 绿 `#16A34A`,`3-6` 橙 `#EA580C`,`>=7` 红 `#DC2626`。证据:`index.vue:305-316,1319-1329,2607-2632`。 +- 视频旁观为 12px、line-height `1.4`,左右 padding `2px`;不可进入时是 `#999`。证据:`index.vue:318-341,2690-2704`。 + +## 8. 按钮、dropdown、tooltip + +### 8.1 操作列链接 + +直接动作依次为:查看(primary)、诊单(primary)、开方/查看处方(primary)、预约(success)、补全身份证(warning),最后是 info“更多”。权限会移除部分按钮。证据:`index.vue:345-403`。 + +- 均为 `size="small"`、`link`:12px,透明边/底,padding `2px`,高度 auto;hover 使用各语义色的 light-3。证据:Element Plus `button.scss:223-248`、`element.scss:113-127`。 +- `.action-cell` 设 flex-wrap + `gap:4px`;但相邻 `.el-button + .el-button` 仍有 `12px` 左 margin,因此连续按钮间通常是 **16px(4+12)**。dropdown 外壳与前一按钮之间只有 flex gap `4px`。证据:`index.vue:2683-2688`、Element Plus `button.scss:72-74`。 +- 340px 操作列不足时允许链接换行;没有强制单行或省略号。 + +### 8.2 “更多”菜单 + +- click 触发、`bottom-end` 对齐。菜单项按权限/状态可包含:指派、取消指派、视频二维码、二维码、取消挂号、挂号日志、创建订单、删除。证据:`index.vue:379-403`。 +- Element 默认 dropdown item 为 14px、line-height `22px`、padding `5px 16px`,故普通项约 `32px` 高;菜单上下净 padding 约 `5px`,r4、overlay 白底,popper 有 light border 与 shadow。hover 为 fill-light + primary。证据:Element Plus `dropdown.scss:7-24,42-70,137-176`。 +- 删除项有 divided 顶线,文字由 `.text-danger` 强制为 danger。证据:`index.vue:400,2706-2708`。 +- tooltip 使用 Element 默认 popper:12px、line-height `20px`、padding `5px 11px`;实际位置由视口碰撞计算,不能从源码固定。 + +## 9. 分页、空态与加载态 + +### 9.1 分页 + +- 列表默认 `page=1, size=15`;共享组件页容量选项为 `[15,20,30,40]`。证据:`src/hooks/usePaging.ts:14-34`、`components/pagination/index.vue:18-28`。 +- 布局固定为 `total, sizes, prev, pager, next, jumper`,最多显示 5 个 pager,单页也不隐藏。证据:`components/pagination/index.vue:3-14,24-28`。 +- 容器右对齐,padding `10px 16px 16px`。默认分页按钮 `32×32px`(最小宽),14px,r2,项组间 gap token `16px`;active 仅主色文字 + bold,并未启用 background 模式。证据:`index.vue:408-410,2436-2440`、Element Plus `common/var.scss:1034-1057`、`pagination.scss:5-67`。 +- page-size select 框架宽 `128px`,jumper 输入 `56px`。证据:Element Plus `pagination.scss:73-75,118-150`。 +- 视口 `<=768px` 时,全局 CSS 隐藏 size selector 与 jumper,仍保留 total、prev、pager、next。证据:`src/styles/element.scss:177-184`。 + +### 9.2 空态 + +页面没有提供 table `#empty` slot,也没有在主列表使用 `el-empty`。因此空列表为: + +- sticky empty block,min-height `60px`; +- 居中纯文本“暂无数据”; +- 文字 `#999`,line-height `60px`,宽 50%; +- 表头、底线和分页仍存在。 + +证据:`index.vue:179-190`、Element Plus `table.scss:52-70`、`src/App.vue:5,25-28`、Element 中文 locale `zh-cn.mjs:137`。 + +### 9.3 加载 + +- `v-loading="pager.loading"` 直接挂表格;普通请求开始时 loading=true,完成后 false。证据:`index.vue:179-184`、`src/hooks/usePaging.ts:35-72`。 +- 蒙层绝对覆盖 table,z-index 2000,亮色为 `rgba(255,255,255,.5)`;中央环 `42px`,主色描边 `2px`,2 秒旋转、1.5 秒 dash 动画。证据:`src/styles/var.css:41-43`、Element Plus `loading.scss:19-73,85-103`、`common/var.scss:1340-1349`。 +- 页面每 20 秒静默刷新列表和计数;标签页不可见时跳过,静默刷新不打开 loading,因此不会周期性白闪。证据:`index.vue:1098-1105,2182-2195`、`src/hooks/usePaging.ts:36-42,64-71`。 +- 首次 `pager.loading` 初值是 false,但 mount 后立即调用 `getLists()`,正常网络下会很快进入蒙层;能否肉眼看到取决于响应时长。 + +## 10. 响应式与滚动行为 + +1. `index.vue` 的 scoped 样式**没有 media query**。页面级响应式主要来自 flex-wrap、宽度上限、Element Table 横向滚动及全局分页规则。 +2. body 被全局设为 `min-width:375px`、`overflow:hidden`;应用主内容通过 `el-scrollbar` 承担页面滚动。证据:`src/styles/public.scss:1-3`、`layout/default/components/main.vue:2-11`。 +3. 第一筛选行本身不换行。左侧日期 chip 可换行,但右侧 160px 搜索 + 32px 查询按钮仍与左侧同一 flex 行;在极窄内容宽度下可能压缩、增高左侧或溢出,源码没有把搜索行堆到下一行的断点。 +4. 待分配容器可换行,宽搜索最多 `42vw`;展开筛选区可换行,两个日期范围各为 `260px` 且 `max-width:100%`。证据:`index.vue:2298-2317,2384-2405`。 +5. 表格 min 总宽约 1403px。Element Table 内部提供横向 scrollbar;右侧 460px fixed 区保持可见,导致很窄时可见的左侧业务列空间进一步减少。 +6. 外层应用在窄屏会折叠侧栏,但那是壳层行为,不改变本页筛选/表格 DOM。证据:`src/App.vue:36-53`。 +7. dark mode 有完整全局变量,但页面的硬编码蓝/绿/橙/红不会自适应;其亮度对比应另做暗色运行态验收。 + +## 11. 直接挂载的覆盖层(不占主页面静态布局) + +主列表下方常驻挂载多个异步组件或 dialog/drawer;关闭时不占主页面空间,操作后覆盖在页面之上: + +| 入口 | 覆盖层 | 关键尺寸 | 证据 | +|---|---|---:|---| +| 新增/诊单 | `edit.vue` 诊单 drawer | `60%` | `index.vue:414`、`edit.vue:1-10` | +| 详情组件 | `detail.vue` popup | `800px` | `index.vue:415`、`detail.vue:1-10` | +| 开方/处方 | `tcm-prescription` drawer | `1200px` | `index.vue:416`、`components/tcm-prescription/index.vue:1-8` | +| 预约 | `appointment.vue` drawer | `60%` | `index.vue:417`、`appointment.vue:1-10` | +| 视频旁观 | AssistantWatchCall dialog | `760px`;视频 grid min `220px` | `index.vue:418-422`、`AssistantWatchCallDialog.vue:1-19,242-284` | +| 挂号日志 | drawer | `460px` | `index.vue:424-447` | +| 小程序二维码 | dialog | `400px`;二维码 `256×256` | `index.vue:449-481` | +| 指派医助 | dialog | `500px` | `index.vue:483-531` | +| 创建订单 | dialog | `600px` | `index.vue:533-587` | +| 补全身份证 | dialog | `400px` | `index.vue:589-615` | +| 企微记录 | drawer | `600px` | `index.vue:617-722` | + +这些覆盖层说明“操作按钮点击后”的页面层级,但其完整内部视觉不应混入诊单 index 主页面的静态复刻范围。 + +## 12. PySide 映射建议 + +### 12.1 页面与筛选 + +| Vue/CSS 意图 | PySide 建议 | 复刻要点 | +|---|---|---| +| 外层 `el-scrollbar` + 两张 card | `QScrollArea` → 单一 `QWidget/QVBoxLayout`;卡用 `QFrame` | viewport `#F6F6F6`;外 margin `16V/8H`;卡 r4、白底、无 border/shadow;卡间 12 | +| date chips | 自定义 `FlowLayout` + checkable `QToolButton` | padding 6×12、r6、gap6;计数拼接为同一 label,保持源码顺序 | +| 业务色 chip | 同一 button class 加 dynamic property | `pending_booking/info`、`completed/success`、`pending/warning`;active 白字实底 | +| 第一行搜索 | `QHBoxLayout` 右侧固定 `QLineEdit(160)` + `QPushButton` | 高 32、gap8;若追求源码一致,不自行添加窄屏 stack | +| 第二行筛选 | `QHBoxLayout`/FlowLayout + `QToolButton` | top line、上下 8;13px;active primary-light-9 | +| 更多筛选 | 可显隐 `QWidget` + FlowLayout | 无动画;top dashed border;gap10;small 控件 24,日期范围 32 | + +Qt 没有内建通用 FlowLayout,建议采用 Qt 示例式 `FlowLayout`,并把 chip 的 `sizeHint()` 包含字体宽度、左右 12/10px padding;不要用固定总宽,否则计数从一位变三位时会截断。 + +### 12.2 表格 + +- 使用 `QTableView + QAbstractTableModel`,不要堆 15 行 QWidget。列的 initial/min widths 按本规格表设置;表头高建议从 `39px` 起,常规 row 从 `52px` 起,并由 delegate `sizeHint()` 为多预约/操作换行增高。 +- 选择列用 check-state delegate;ID/患者、确认、复诊、开方、未服务天数分别用专用 delegate 绘文字和 tag,避免嵌套控件带来的滚动性能问题。 +- 挂号列用一个复合 delegate:r6 的状态背景、11px badge、14px/600 医生、12px 时间、多预约虚线分隔。可点击“取消挂号”若必须嵌入,可在命中区域处理 mouse event,而不是每格常驻 QPushButton。 +- 两个右固定列可用**同步滚动的双 `QTableView`**:主表隐藏最后两列,右表只显示 120/340 两列,共宽 460;共享 model、selection model、vertical scrollbar 与 row height。单一 QTableView 无 Element 的 sticky fixed-column 等价物。 +- 行背景应在 delegate 对每个 cell 绘制:普通白/`#FAFAFA`,hover `#F8F8F8`;业务行覆盖渐变并在第一可见 cell 绘 3px 语义条。不要把渐变只画在 viewport 底层,否则会重现 HTML `td` 遮挡问题。 +- 操作列优先使用 delegate + 命中区域或常驻 `QWidget` 行容器;若用按钮,链接样式为 12px、2px padding、无背景/边框,并预留源码实际的较大按钮间距。 +- 横向滚动时保持右表固定;表头与 body 必须共用列宽。多预约行要同步两张表的 row height。 + +### 12.3 dropdown、分页、空态、加载 + +- “更多”使用 `QMenu`,item 高约 32、左右 padding16、14px;按权限/状态动态构建,删除项前 `addSeparator()` 且 danger 文本。 +- 分页建议自定义 `PaginationBar`:total、15/20/30/40 combo、prev、最多 5 页、next、jump;高度32,容器右对齐并保留 `10T/16H/16B`。窗口内容宽度 <=768 时隐藏 combo 与 jump。 +- 空态在 `QTableView.viewport()` 中央放一个透明 `QLabel("暂无数据")`,有效高度 60;不要用大插画。仍显示 header 和分页。 +- 加载态用覆盖 table rect 的半透明 widget:亮色 `rgba(255,255,255,.5)`,中央约 42px 主色 spinner。新请求显示;20 秒静默刷新不显示。 +- 用 generation/request id 丢弃过期异步响应,并只在当前请求结束时关闭 overlay;这不是 Vue 当前 `latestOnly:false` 的视觉保证,但能避免 PySide 快速筛选时旧响应造成闪回。 + +### 12.4 主题与字体 + +- 定义集中 token,而不是把 `#4A5DFF` 散落在 QSS;但为了忠实,挂号块与“已开方”目前应保留硬编码蓝,或在设计确认后统一为 theme primary。 +- Windows 上首选“Microsoft YaHei UI”还是源码的“Microsoft YaHei”会改变字宽;逐像素复刻应显式用 `Microsoft YaHei`,14px CSS 大致映射为 Qt pixelSize 14,而不是 pointSize 14。 +- Qt 的 devicePixelRatio 与 CSS px 不同。截图验收需固定 Windows 缩放(建议 100%)、字体、窗口内容宽度和主题。 + +## 13. 能从源码确定 / 不能从源码确定 + +### 可以确定 + +- DOM 顺序、无页面内标题、两张卡、每个筛选和操作入口; +- 页面局部 padding/gap/宽度、列声明宽度、固定列、颜色、字体大小/字重; +- 日期 chip 的顺序与当天默认态; +- 表格 stripe、业务类判定优先级、语义文本与预约块状态; +- 共享分页的页容量、布局、空态文案、loading 结构; +- 默认亮/暗 token 及运行时主题派生方式; +- 权限或数据条件控制哪些按钮/tag 出现。 + +### 仅运行态可最终确定 + +1. **最终换行和行高**:取决于 viewport、数据长度、按钮权限、预约条数、字体回退、DPR;chip 和多数 row 都没有固定 height。 +2. **实际主题色**:setting 存在本地缓存,账号可改主色及语义色;dark 状态也来自浏览器存储/系统偏好。 +3. **业务行静态渐变是否完整可见**:受 `tr` 背景、striped/fixed `td` 背景与 Chromium table paint 顺序影响;源码只能确定规则叠加关系。 +4. **fixed-column 阴影和 scrollbar 尺寸**:由 Chromium/Element 的滚动状态、平台 scrollbar 策略决定。 +5. **tooltip/dropdown 的最终坐标**:popper 会做视口碰撞与翻转。 +6. **加载态停留时间和计数出现时间**:由 API 延迟、idle callback 调度决定;静默轮询通常无蒙层。 +7. **应用壳标题/面包屑/多页签**:由路由菜单、setting 和权限生成,不是 `index.vue` 的页面内视觉。 + +若后续要求“像素级截图一致”,应在固定 `Element Plus 2.13.7 + Chromium 版本 + 100% Windows 缩放 + 默认亮色主题 + 固定权限/样本数据` 下补一轮运行态测量;本文件已经给出源码能够保证的全部静态约束与明确边界。 diff --git a/app/research/diagnosis_post_flow_audit.md b/app/research/diagnosis_post_flow_audit.md new file mode 100644 index 000000000..c65e9b1e6 --- /dev/null +++ b/app/research/diagnosis_post_flow_audit.md @@ -0,0 +1,153 @@ +# 诊单视觉重构最终业务回归审计(三次终验) + +- 终验日期:2026-08-10 +- 范围:生产视频/IM、医生备注与日常记录 mutation、处方/订单、多挂号精确取消、诊单 Shell owner、列表 QR/日志/订单;并复核上一版已关闭的权限、DTO、隐私与 generation 项。 +- 基准:当前最新 Python 源码与测试,以及 `D:/web/zyt/admin`、`D:/web/zyt/server` 的实际 admin 行为和服务端控制器/Logic。 +- 约束:只读业务终验;未安装依赖、未打包、未访问线上接口。除更新本报告外,未修改业务源码或测试。 + +## 最终结论 + +第三次终验后的剩余项为: + +- **P0:0,明确无 P0。** +- **P1:1 项 OPEN。** 诊单列表“创建订单”已调用真实 `order.order/create`,但没有消费返回的 `order_no` 并调用现成的 `generate_order_qrcode`,admin 的付款二维码闭环仍中断。 +- **P2:2 项 OPEN。** 一是日常矩阵不能编辑已有血糖/饮食/运动记录;二是视频回放只显示每条记录的第一个 URL,且没有 admin 的“向具体通话记录追加回放”入口。 +- **服务端不可实现限制:不计入 P0/P1/P2。** 医生备注与跟踪备注的服务端契约只支持按当天追加;服务端没有任意旧正文覆盖或整条删除 endpoint,桌面端无法在不扩展后端的前提下实现这些动作。 +- 其余本轮复核项均 **CLOSED**:生产视频/IM 取数与同步、备注追加/附件、日常新增与待办、处方动作、订单详情/偏移、精确挂号取消、列表视频/确认 QR 与日志、真实 Shell 60%/move-resize/close generation。 +- 定向离线回归:**128/128 passed**。 + +| 复核项 | 三次终验状态 | 最终等级 | 摘要 | +|---|---|---:|---| +| 生产视频/IM `only_archived`、sync、上传、回放 | **OPEN(主体 CLOSED)** | **P2** | 生产接口、同步、工具栏上传和首个回放可用;多 URL 与指定 call-record 追加入口缺失 | +| 医生备注 mutation | **CLOSED** | — | 追加正文、上传舌象/报告、删除单附件均走真实合同;正文覆盖/整条删除属后端无 endpoint 限制 | +| 日常记录 mutation | **OPEN(新增 CLOSED)** | **P2** | 新增血糖/饮食/运动/跟踪备注及待办新增/取消可用;已有三类记录不能编辑 | +| 处方/详情订单动作 | **CLOSED** | — | 开方、处方查看、业务订单列表/详情与诊次偏移均真实落库/取数 | +| 多挂号精确取消 | **CLOSED** | — | 每个挂号卡携带精确 ID,最终调用 `doctor.appointment/cancel` | +| 诊单 owner、60%、move-resize、generation | **CLOSED** | — | 每次打开重绑真实 Shell,宽度为 Shell 60%,移动/缩放/关闭同步且所有异步代次失效 | +| 列表 QR / 日志 / 创建订单 | **OPEN(QR/日志/创建主体 CLOSED)** | **P1** | 视频/确认 QR、日志、创建订单均真实调用;创建成功后的支付 QR 未接入 UI | + +上一版已关闭的 `phonePlain`、canonical `diagnosis_type`、`viewOnly` endpoint 分流、detail 失败禁存、身份证 15/18 位校验、权限 Tab 裁剪与隐私脱敏,本轮定向回归均未发现反弹。 + +## 逐项证据 + +### 1. 生产视频/IM:`[OPEN / P2,主体 CLOSED]` + +已关闭部分: + +- `RemoteDoctorRepository` 已实现生产 `getCallRecords`、手工 call record、附件关联及“上传 → 创建记录 → 关联”顺序:`src/doctor_workstation/services/repository.py:1875-1943`。这与 admin `CallRecordPanel` 的真实流程一致:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue:109-180`。 +- IM 首次读取明确发 `only_archived=1`,同步走 `triggerImChatSync`:`src/doctor_workstation/services/repository.py:1945-1965`;dialog 的 lazy 查询也固定 `only_archived=True`:`src/doctor_workstation/ui/dialogs/diagnosis.py:2016-2038`。admin 对照为 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/ImChatRecordPanel.vue:148-185`。 +- edit 态视频工具栏调用真实上传,成功后按 media generation 重新加载;聊天同步成功后延时重新读取归档:`src/doctor_workstation/ui/dialogs/diagnosis.py:2541-2611`。聊天图片/文件只能通过 HTTP(S) 安全打开:`:2652-2658`。 +- production/server 合同存在:`D:/web/zyt/server/app/adminapi/controller/tcm/DiagnosisController.php:397-438`、`:505-538`;归档快路径在 `D:/web/zyt/server/app/adminapi/logic/tcm/DiagnosisLogic.php:1015-1045`。 + +仍 OPEN 的 P2: + +- 服务端返回 `recording_urls_list` 数组:`D:/web/zyt/server/app/adminapi/logic/tcm/DiagnosisLogic.php:1810-1833`;admin 把完整数组交给播放器,并在每个具体 call row 提供上传入口:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue:30-72`。 +- Python `_fill_video` 只取 `raw_urls[0]`,其余回放不可见,且视频表没有指定 `call_record_id` 的上传操作列:`src/doctor_workstation/ui/dialogs/diagnosis.py:1126-1135`、`:2844-2881`。虽然 repository 已支持 `call_record_id`(`src/doctor_workstation/services/repository.py:1894-1913`),UI 没有把它接出来。 + +该问题不造成错误写入,且工具栏上传与一个回放可用,故按 **P2** 而非 P1 计。 + +### 2. 医生备注:`[CLOSED;后端限制已明确]` + +- NotesTimeline 已暴露新增、上传、删除单附件与打开附件信号:`src/doctor_workstation/ui/diagnosis_drawer.py:1804-1810`、`:1820-1851`、`:1887-1961`;dialog 绑定真实方法:`src/doctor_workstation/ui/dialogs/diagnosis.py:989-997`、`:2376-2487`。 +- production DTO 分别走 `doctor.appointment/addDoctorNote`、`doctor.appointment/doctorNotes`、`doctor.appointment/deleteDoctorNoteImage`:`src/doctor_workstation/services/repository.py:935-985`。权限分别按新增与删除附件 endpoint 裁剪:`src/doctor_workstation/ui/dialogs/diagnosis.py:478-483`、`:1233-1241`。 +- admin 自身也只有追加、上传、删除单附件动作:`D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:195-268`;API 定义仅三条:`D:/web/zyt/admin/src/api/patient.ts:19-40`。 + +服务端不可实现边界: + +- controller 仅暴露 `addDoctorNote`、`doctorNotes`、`deleteDoctorNoteImage`,随后类即结束:`D:/web/zyt/server/app/adminapi/controller/doctor/AppointmentController.php:168-198`。 +- `DoctorNoteLogic::addOrAppend` 对当天正文使用换行追加,不支持任意旧正文覆盖:`D:/web/zyt/server/app/adminapi/logic/doctor/DoctorNoteLogic.php:12-76`;唯一删除逻辑只删除附件数组中的单项:`:111-136`。 + +因此“任意旧笔记正文覆盖/整条删除”不是桌面端漏接已有合同,而是服务端没有 endpoint;本报告将其列为**后端限制,不计客户端 P0/P1/P2**。 + +### 3. 日常记录:`[OPEN / P2,新增 CLOSED]` + +已关闭部分: + +- edit 态已提供血糖、饮食、运动、跟踪备注新增按钮和待办新增/取消;只读态/无权限态隐藏:`src/doctor_workstation/ui/diagnosis_drawer.py:1363-1371`、`:1431-1450`、`:1481-1511`、`:1760-1791`。 +- dialog 将 signal 接到真实 add/todo repository,并为每次 mutation 使用 diagnosis-id + generation 校验后强制刷新:`src/doctor_workstation/ui/dialogs/diagnosis.py:1089-1101`、`:2175-2358`。 +- production 合同为 `tcm.bloodRecord/add`、`tcm.dietRecord/add`、`tcm.exerciseRecord/add`、`tcm.diagnosis/addTrackingNote`、todo add/cancel:`src/doctor_workstation/services/repository.py:1745-1765`、`:1778-1805`、`:1847-1873`。 + +仍 OPEN 的 P2: + +- admin 点击已有单元格时会把记录(含 `id`)放入表单,保存时分别调用 blood/diet/exercise edit:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue:921-1013`。 +- Python matrix 明确 `NoEditTriggers`、`NoSelection`,只有 `daily_add_requested`,没有已有记录 edit signal:`src/doctor_workstation/ui/diagnosis_drawer.py:1366-1371`、`:1455-1463`。dialog 也只选择 `add_*`:`src/doctor_workstation/ui/dialogs/diagnosis.py:2260-2284`。 +- 这不是后端限制:production repository 已实现三类 edit:`src/doctor_workstation/services/repository.py:1767-1776`、`:1787-1796`、`:1807-1818`。 + +跟踪备注例外:server 设计就是“仅给今天追加”。controller 仅有 add/list:`D:/web/zyt/server/app/adminapi/controller/tcm/DiagnosisController.php:180-214`;`TrackingNoteLogic` 只有 `addOrAppend/getByDiagnosis`,类在第 95 行结束:`D:/web/zyt/server/app/adminapi/logic/tcm/TrackingNoteLogic.php:8-95`。其任意旧正文覆盖/整条删除同样属于后端无 endpoint 限制,不计客户端问题。 + +### 4. 处方与详情订单动作:`[CLOSED]` + +- “开具处方”打开真实 `PrescriptionEditorDialog`,accepted 后调用 `create_prescription`,并携带 diagnosis/appointment/case snapshot:`src/doctor_workstation/ui/dialogs/diagnosis.py:2489-2539`。 +- 历史处方的“查看”打开真实 `PrescriptionDetailDialog`:`:2791-2842`。 +- 业务订单 lazy 列表、详情权限/真实 repository 门禁、异步 order generation 及详情 dialog 均已接通:`:1998-2000`、`:2069-2074`、`:2883-2991`。 +- 全局诊次偏移调用真实 `setRevisitSlotStartOffset`,并有独立 generation:`:2613-2650`。 + +未发现处方或详情订单的假动作/越权入口,本项 **CLOSED**。 + +### 5. 多挂号精确取消:`[CLOSED]` + +- 每个可取消挂号卡按精确 primary key 生成按钮,signal 携带原 record 与该 appointment-id:`src/doctor_workstation/ui/diagnosis_index_widgets.py:150-163`、`:1121-1148`。歧义的行级“取消挂号”仍只在唯一挂号时出现:`:1403-1410`。 +- handler 选择当前诊单后再次按 ID 和状态校验,确认前后都防 stale,最终仅调用 exact capability:`src/doctor_workstation/ui/pages/consultations.py:1868-1921`。 +- production 方法明确走 `doctor.appointment/cancel`,不再误用患者工作区 endpoint:`src/doctor_workstation/services/repository.py:1545-1555`。 +- 新测试覆盖两个挂号、超 32 位 appointment-id、错误诊单、状态刷新、权限/能力缺失与 canonical endpoint:`tests/test_diagnosis_index_visual.py:340-379`、`tests/test_consultations_parity_ui.py:610-774`、`tests/test_diagnosis_detail_contract.py:90-105`。 + +本项 **CLOSED**。 + +### 6. 真实 Shell owner / 60% / move-resize / generation:`[CLOSED]` + +- `_owner` 不再在 page 构造期缓存;每次 `open_for/showEvent` 都从当前 `parent.window()` 重绑真实 Shell:`src/doctor_workstation/ui/dialogs/diagnosis.py:526-527`、`:1314-1349`、`:1398-1408`。 +- overlay 与 owner 同位置/尺寸,宽屏 drawer 固定为 owner 宽度 60%;监听 owner 的 Resize/Move/Show/Close:`:1283-1299`、`:1351-1371`。 +- `reject/done` 及 owner Close 会使 detail/save/orders/order-detail/daily/notes/media/offset/所有 lazy-tab generation 全部失效:`:1373-1383`、`:1681-1700`。 + +真实 `ShellWindow -> QStackedWidget -> ConsultationsPage -> cached DiagnosisDialog` offscreen 探针结果: + +```text +OPEN owner_shell=True shell=(1200,760) dialog=(1200,760) panel=720 expected=720 pos=(80,60) +MOVED shell=(1024,650) dialog=(1024,650) panel=614 expected=614 pos=(190,140) +CLOSE visible=False generation=True media=True +``` + +本项 **CLOSED**。现有 `tests/test_diagnosis_drawer_visual.py:837-861` 仍只用直接顶层 owner;本次额外真实 Shell 探针补足了业务终验,但建议后续把该探针固化成回归测试。 + +### 7. 列表 QR / 日志 / 订单:`[OPEN / P1,主体 CLOSED]` + +已关闭部分: + +- 页面只在 canonical 权限与真实 repository 方法同时存在时展示视频 QR、确认 QR、日志、订单入口:`src/doctor_workstation/ui/pages/consultations.py:704-715`、`:1005-1030`。 +- QR DTO 校验 patient/doctor/share-user/diagnosis,日志使用 diagnosis-id,所有菜单请求均受 selection generation 保护:`:1972-2091`。 +- production repository 真实调用 `generateMiniProgramQrcode`、`guahaoLogList`、`order.order/create`:`src/doctor_workstation/services/repository.py:1967-2060`。测试验证 exact DTO、权限裁剪与 stale callback:`tests/test_diagnosis_detail_contract.py:85-127`、`tests/test_consultations_parity_ui.py:777-958`。 + +仍 OPEN 的 P1: + +- admin 创建订单成功后读取 `order_no`,立即调用 `generateOrderQrcode` 并展示付款 QR:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:1474-1509`。 +- Python `_create_diagnosis_order` 把真实 create 放进通用 `_run_mutation`;该 helper 丢弃 `_result`,只 toast + refresh,因此永远不会生成付款 QR:`src/doctor_workstation/ui/pages/consultations.py:2093-2128`、`:2460-2479`。 +- repository 明明已有 `generate_order_qrcode`:`src/doctor_workstation/services/repository.py:2062-2076`,但 UI 源码没有任何调用。现有测试也只分别测试“UI 创建订单”和“repository 可生成 QR”,未覆盖两者串联:`tests/test_consultations_parity_ui.py:777-872`、`tests/test_diagnosis_detail_contract.py:88-89`。 + +订单已实际创建却不展示付款入口,用户容易停在半完成状态或重复建单;这是主流程闭环缺失,按 **P1 OPEN** 计。 + +## 离线验证记录 + +使用项目现有 `.venv`,设置 `QT_QPA_PLATFORM=offscreen`、`PYTHONDONTWRITEBYTECODE=1`,禁用 pytest cache,并把 basetemp 放在系统临时目录;未安装任何依赖: + +```text +pytest -q -p no:cacheprovider \ + tests/test_diagnosis_detail_contract.py \ + tests/test_diagnosis_index_visual.py \ + tests/test_diagnosis_drawer_visual.py \ + tests/test_appointment_drawer_visual.py \ + tests/test_consultations_parity_ui.py \ + tests/test_prescription_security_ui.py \ + tests/test_patients_ui.py \ + tests/test_repository_parity.py \ + tests/test_permissions.py + +128 passed +``` + +测试退出码为 0;`--collect-only -q` 分文件计数合计 128。另执行了上述真实 Shell owner 探针,退出码为 0。 + +## 发布判断 + +当前不存在错误 endpoint、歧义挂号取消、权限泄漏、隐私明文回流或关闭后旧异步回调落地等 P0 风险。生产视频/IM、备注追加、日常新增、处方、订单详情及列表主要动作均已从“假动作/未接入”进展为真实 repository 行为。 + +但若发布标准是“完整保留 admin 的订单支付闭环与所有已存在的数据编辑/回放能力”,当前仍不满足:**剩余 P0 0 / P1 1 / P2 2**。其中任意旧医生/跟踪笔记正文覆盖或整条删除必须先扩展 server endpoint,不能作为桌面端单独修复项。 diff --git a/app/research/diagnosis_post_visual_audit.md b/app/research/diagnosis_post_visual_audit.md new file mode 100644 index 000000000..2be1c672c --- /dev/null +++ b/app/research/diagnosis_post_visual_audit.md @@ -0,0 +1,113 @@ +# 诊疗工作台第三次、最终只读视觉同型审计 + +审计日期:2026-08-10 +审计性质:最终只读终验;除更新本报告外,未修改任何业务代码、测试、renderer 或 PNG。 +事实源:当前 Python 源码、D:/web/zyt/admin/src/views/tcm/diagnosis/ 下 Vue 基准、三份既有视觉规格,以及 artifacts/diagnosis_visual/ 当前 43 张 PNG。 +判定方法:43 张 PNG 均按原始尺寸逐张目检;源码只用于解释可见结果与确认真实能力。测试通过数不作为任何视觉结论的依据,本轮也未以测试替代目检。 + +## 最终结论 + +**PARTIAL,不是 PASS。** + +- P0:**0 OPEN**。语义表单、1024 栅格、右固定列、双尺寸 60% 抽屉、固定 footer、核心 loading/empty/error/focus/permission 状态均未回退。 +- 上一版 12 项 P1:**8 项 CLOSED / EXACT,4 项仍 OPEN / PARTIAL**。 +- 43 张当前 PNG:**36 张 EXACT(其中两张 edit 仅指入镜区域)、7 张 PARTIAL、0 张乱码或不可读**。 +- 列表、详情、预约与 Shell 的所有 loading 图均为真实圆弧 spinner;没有字符型 spinner、方框箭头、中文乱码或缺字。 +- 当前不能判 PASS 的原因是:1024 Daily 动作行裁切及按钮语义态被压平;完整菜单仍缺 Vue 图标、danger 删除色和精确文案;备注附件视觉仍未命中预期样式;处方/订单/视频/聊天仍有可见简化或缺少关键截图证据。 +- “既有笔记整条正文覆盖 / 删除整条笔记”另列为服务端合同限制,不计作伪造桌面能力,也不用于掩盖上述视觉 P1。 + +## 上一版 12 项 OPEN 逐项终验 + +| # | 上一版问题 | 状态 | 同型分类 | 最新精确证据与目检结论 | +|---:|---|---|---|---| +| 1 | 统计端就诊卡仍非 switch | **CLOSED** | **EXACT(组件)** | 当前已使用自绘二态 DiagnosisSwitch,44×24 轨道、圆形滑块、0/1 文本合同:src/doctor_workstation/ui/diagnosis_drawer.py:643-675;表单 show_card 分支:src/doctor_workstation/ui/dialogs/diagnosis.py:924-925;Vue 基准:D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:341-348。现有 edit PNG 没有滚到该字段,因此本项是源码级确定结论,不引用测试,也不声称有单独的 switch 截图。 | +| 2 | Daily 编辑动作不完整 | **OPEN** | **PARTIAL** | + 血糖 / + 饮食 / + 运动 / + 备注、刷新、新增待办和精确可取消动作均已接入:src/doctor_workstation/ui/diagnosis_drawer.py:1431-1450、:1486-1512、:1764-1784;真实处理:src/doctor_workstation/ui/dialogs/diagnosis.py:2175-2356。可是 diagnosis_state_daily_1024x640.png 中最右“刷新”被抽屉右缘裁掉;原因是单个 QHBoxLayout 不换行(diagnosis_drawer.py:1398-1451),而 Vue 明确 flex-wrap(D:/web/zyt/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue:1094-1109)。同时“最近7天”的 checked 态、+血糖 与新增待办的 primary 态均被 scoped 基础按钮规则压平;Vue 的主动作在 DailyMatrix.vue:25-35、DiagnosisTodoList.vue:3-19。 | +| 3 | “更多”菜单动作被关闭 | **OPEN** | **PARTIAL** | 能力缺口已补齐:权限/真实方法策略在 src/doctor_workstation/ui/pages/consultations.py:1002-1031,handler 在 :1672-1692;diagnosis_full_menu_1280x800.png 已出现指派、取消指派、视频二维码、确认二维码、取消挂号、挂号日志、创建订单、删除。仍不同型:Python 只创建纯文字 QAction(src/doctor_workstation/ui/diagnosis_index_widgets.py:1381-1431),Vue 每项有图标,删除为 divided + danger 红,并使用“二维码”而不是“确认二维码”(D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:379-400)。QMenu 白底、边框、hover 与分隔线样式已生效(diagnosis_index_widgets.py:1371-1380),但未覆盖这些剩余语义。 | +| 4 | Tab 溢出仍用左右箭头 | **CLOSED** | **EXACT** | 原生左右工具按钮宽度归零,额外 4 px 横向滚动条可见并驱动选中 Tab:src/doctor_workstation/ui/diagnosis_drawer.py:194-242、:749-792;Vue 基准:D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1798-1828。所有 1024 详情状态图均无盒状左右箭头,蓝色细滚动条连续可见。 | +| 5 | footer 缺上投影 | **CLOSED** | **EXACT** | footer 使用 blur 18、offset 0/-4、半透明深色 QGraphicsDropShadowEffect:src/doctor_workstation/ui/dialogs/diagnosis.py:784-803;Vue 为 0 -4px 24px / 7%:D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1792-1796。edit/viewOnly/daily/save 状态图均可见 footer 上缘阴影,且 footer 始终在滚动体外。 | +| 6 | 保存中无按钮 spinner / 结果态 | **CLOSED** | **EXACT** | SaveStateButton 以 QTimer + QPainter 画 loading 圆弧、成功勾和失败叉:src/doctor_workstation/ui/diagnosis_drawer.py:678-746;只在 editable/basic 显示:src/doctor_workstation/ui/dialogs/diagnosis.py:1652-1661;真实保存状态切换::3044-3163。diagnosis_state_save_loading/success/failure_1024x640.png 分别完整显示“正在保存 / 保存成功 / 保存失败”,无缺字。 | +| 7 | readonly 摘要拆“客服” | **CLOSED** | **EXACT** | 摘要值禁止换行并使用 MinimumExpanding:src/doctor_workstation/ui/dialogs/diagnosis.py:681-712;“客服:…”赋值::1767-1770。diagnosis_readonly_1024x640.png 与 diagnosis_readonly_1440x900.png 均完整显示“客服:赵医助”,不再拆字。 | +| 8 | Notes 缺新增、上传、删除动作 | **OPEN** | **PARTIAL** | 动作和真实端点已存在:NotesTimeline 信号/工具栏/单附件删除在 src/doctor_workstation/ui/diagnosis_drawer.py:1804-1962;处理在 src/doctor_workstation/ui/dialogs/diagnosis.py:2376-2478;远端合同在 src/doctor_workstation/services/repository.py:935-985。diagnosis_state_notes_actions_1024x640.png 已显示新增/追加、上传舌象、上传报告、打开与单附件删除。但视觉仍不同型:QSS 写的是 QLabel#DiagnosisTongueThumb 与 QLabel#DiagnosisAttachmentChip(diagnosis_drawer.py:486-500),运行时却创建 QPushButton(:1897-1900、:1932-1937),所以截图中的“舌象/查看”和报告附件退化为普通白按钮,而非 Vue 的真实缩略图、文件 chip 与叠加 CircleClose(D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:42-100)。 | +| 9 | 处方、订单、视频、聊天仍为简化实现 | **OPEN** | **PARTIAL / MISSING** | 处方已接完整 PrescriptionEditorDialog 和真实 create_prescription(src/doctor_workstation/ui/dialogs/diagnosis.py:2489-2539),订单偏移及详情入口已接真实方法(:2613-2650、:2883-2941),视频上传/回放和聊天归档同步也已接真实方法(:2541-2611、:2844-2881)。但视觉仍未闭合:处方按钮在 diagnosis_state_empty_1024x640.png 中为普通白色“开具处方”,而 Vue 是 primary 小号“开方”(D:/web/zyt/admin/src/views/tcm/diagnosis/components/CaseRecordList.vue:1-5);当前证据集没有处方编辑器或订单详情抽屉 PNG。diagnosis_state_video_replay_1024x640.png 只有外链“播放回放”,代码通过 QDesktopServices 打开 HTTP(S)(diagnosis.py:2652-2658、:2874-2881),而 Vue 是表格内 RecordingPlaybackBlock(D:/web/zyt/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue:17-34)。diagnosis_state_chat_archive_1024x640.png 有气泡、文件和同步/重载,但省略 Vue 的完整 info alert 和详细归档说明(D:/web/zyt/admin/src/views/tcm/diagnosis/components/ImChatRecordPanel.vue:1-20)。订单偏移可见部分本身为 EXACT。 | +| 10 | 表格状态仍整格着色而非 pill | **CLOSED** | **EXACT** | set_status_tag 以 QLabel pill 放入 cell widget,不再染整格:src/doctor_workstation/ui/diagnosis_drawer.py:2366-2384;所有状态列经 semanticize 转换:src/doctor_workstation/ui/dialogs/diagnosis.py:1900-1921、:2752-2754、:2822-2825、:2870-2874、:2898-2902。diagnosis_state_daily_lower_1024x640.png 的“待执行”蓝色 pill 是直接视觉证据。 | +| 11 | Shell 仍为 216/68 且无 40 px tabs | **CLOSED** | **EXACT** | 侧栏 183:src/doctor_workstation/ui/shell.py:537-540;topbar 50::599-603;multiple-tabs 40::683-687。diagnosis_shell_1024x640.png 与 diagnosis_shell_1440x900.png 均可直接量出 183/50/40 三段,比例一致。 | +| 12 | Shell 颜色偏离且箭头缺字 | **CLOSED** | **EXACT(本项范围)** | 侧栏为 #1D2124:src/doctor_workstation/ui/shell.py:541-569;方向、刷新、全屏、下拉、关闭图形均由 QPainter 自绘,不依赖字体 glyph::257-395;topbar/tab QMenu 样式::599-628、:683-723。两张最新 Shell PNG 中“更多筛选”、行“更多”、用户菜单和 tabs 菜单箭头均正常,没有方框或乱码。 | + +## 最后增加的三个专项检查 + +| 专项 | 状态 | 结论 | +|---|---|---| +| scoped panel 按钮 | **OPEN / PARTIAL** | src/doctor_workstation/ui/diagnosis_drawer.py:112-136 的 QDialog#DiagnosisDialogRoot QPushButton 规则确实把详情按钮从全局暖灰绿主题隔离出来;但它的 ID 选择器优先级高于全局 QPushButton[variant="primary/ghost"](src/doctor_workstation/ui/theme.py:87-118),而局部 QSS 没有补 primary、ghost、dangerGhost、diagnosisRange:checked 规则。结果在 daily/处方/视频/待办 PNG 中所有动作都被压成同一白色按钮,选中态也不清楚。这是实图结果,不是测试推断。 | +| QMenu | **OPEN / PARTIAL** | diagnosis_full_menu_1280x800.png 证明局部白底、细边框、32 px 左右行高、分隔线和菜单宽度已生效;但与 Vue 相比仍缺每项图标、danger 删除色,且“确认二维码”文案不精确。证据:src/doctor_workstation/ui/diagnosis_index_widgets.py:1371-1431 对照 D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:379-400、:2706-2708。 | +| 紧凑 close | **CLOSED / EXACT** | Header close 被限定 32×32、透明常态、蓝色 hover/focus:src/doctor_workstation/ui/diagnosis_drawer.py:137-154;创建位置:src/doctor_workstation/ui/dialogs/diagnosis.py:754-782。diagnosis_state_focus_1024x640.png 显示紧凑透明常态,默认 edit/viewOnly 图显示蓝色 focus/hover 外观,没有被全局 40 px footer 按钮规则放大。 | + +## 43 张 PNG 原尺寸目检矩阵 + +下表的 EXACT 只针对该张 PNG 实际入镜内容;不以不可见的滚动下方内容作证明。 + +| PNG | 分类 | 原尺寸目检结论 | +|---|---|---| +| diagnosis_1024x640.png | **EXACT** | 窄屏筛选换行、表格内部横向溢出、右固定列和阴影稳定,无硬裁切。 | +| diagnosis_1440x900.png | **EXACT** | 宽屏列密度、复合挂号卡、斑马/业务语义色和固定操作区成立。 | +| diagnosis_advanced_filters_1280x800.png | **EXACT** | 高级筛选即时展开,日期范围、控件顺序和重置入口完整。 | +| diagnosis_double_appointment_cancel_1280x800.png | **EXACT** | 同一诊单两条预约均有各自取消入口,虚线层级和固定列正常。 | +| diagnosis_empty_1280x800.png | **EXACT** | 表头、内部空态、横向滚动和 0 条分页均保留。 | +| diagnosis_error_1280x800.png | **EXACT** | 错误文本居中可读,无旧数据穿透。 | +| diagnosis_focus_1280x800.png | **EXACT** | 搜索框蓝色 focus ring 清楚且不挤压布局。 | +| diagnosis_full_menu_1280x800.png | **PARTIAL** | 8 项动作与分隔线齐全;缺 Vue 图标、danger 删除色,二维码文案不同。 | +| diagnosis_horizontal_scroll_1024x640.png | **EXACT** | 主表横移、右 460 px 固定区不动,左缘阴影连续。 | +| diagnosis_hover_warning_1280x800.png | **EXACT** | warning 行 hover 渐变、3 px 左条和固定区同步。 | +| diagnosis_loading_1280x800.png | **EXACT** | 表内真实圆弧 spinner,内容淡化,无字符缺字。 | +| diagnosis_pending_assign_1280x800.png | **EXACT** | 待分配 chip、月份与宽搜索保持同一流式区。 | +| diagnosis_permissions_cropped_1280x800.png | **EXACT** | 权限裁掉 toolbar 与敏感动作,仅保留查看。 | +| diagnosis_edit_1024x640.png | **EXACT(可见区)** | 60% 抽屉、2/3 列语义表单、细 tab 条、固定 footer 无裁切;switch 未入镜。 | +| diagnosis_edit_1440x900.png | **EXACT(可见区)** | 宽屏密度、字段比例和 footer 稳定;switch 未入镜。 | +| diagnosis_readonly_1024x640.png | **EXACT** | 独立只读页、hero、病例卡完整,“客服”不拆字。 | +| diagnosis_readonly_1440x900.png | **EXACT** | 4 列病例密度和异常指标层级完整,无不必要换行。 | +| diagnosis_viewonly_1024x640.png | **EXACT** | 60% 只读抽屉、权限提示、禁用表单与仅关闭 footer 成立。 | +| diagnosis_viewonly_1440x900.png | **EXACT** | 宽屏只读栅格、全 Tab 条和固定 footer 成立。 | +| diagnosis_state_daily_1024x640.png | **PARTIAL** | DailyMatrix、异常/自录内容和新增动作出现;最右“刷新”被裁,range/primary 语义态被压平。 | +| diagnosis_state_daily_lower_1024x640.png | **PARTIAL** | 趋势、待办筛选、表格与 pill 出现;“新增待办”不是 Vue primary,toolbar 语义层级不足。 | +| diagnosis_state_empty_1024x640.png | **PARTIAL** | 处方空态和仅取消 footer 正确;“开具处方”按钮文案/primary 样式不同,且未覆盖真实处方编辑器。 | +| diagnosis_state_error_1024x640.png | **EXACT** | danger banner、重试、空表单、禁用保存完整。 | +| diagnosis_state_focus_1024x640.png | **EXACT** | 姓名 focus ring、紧凑 close 常态和固定 footer 清晰。 | +| diagnosis_state_loading_1024x640.png | **EXACT** | 圆弧 spinner 与“正在加载诊单...”完整,无旧 seed 或缺字。 | +| diagnosis_state_permission_1024x640.png | **EXACT** | 只保留病历/医生备注,手机号与身份证掩码,footer 仅关闭。 | +| diagnosis_state_notes_actions_1024x640.png | **PARTIAL** | 增加/上传/打开/单附件删除均出现;舌象与报告未呈现为 Vue 缩略图/chip。 | +| diagnosis_state_order_offset_1024x640.png | **EXACT(可见区)** | 偏移数值框、保存、订单表、分页与横向滚动完整;订单详情未入镜。 | +| diagnosis_state_save_failure_1024x640.png | **EXACT** | 红色叉与“保存失败”完整。 | +| diagnosis_state_save_loading_1024x640.png | **EXACT** | 白色圆弧 spinner 与“正在保存”完整。 | +| diagnosis_state_save_success_1024x640.png | **EXACT** | 绿色勾与“保存成功”完整。 | +| diagnosis_state_video_replay_1024x640.png | **PARTIAL** | 回放动作可见且文案完整;仍是外链按钮,不是 Vue 表内播放器。 | +| diagnosis_state_chat_archive_1024x640.png | **PARTIAL** | 左右气泡、图片/文件入口、同步/重载可见;完整 info alert 与原文案缺失。 | +| diagnosis_shell_1024x640.png | **EXACT(本项范围)** | 183/50/40,深色侧栏、分页、三角箭头和中文均无缺字。 | +| diagnosis_shell_1440x900.png | **EXACT(本项范围)** | 同一 Shell 比例在宽屏保持,固定列阴影和 horizontal scroll 清楚。 | +| appointment_drawer_1024x640.png | **EXACT** | 60% 右抽屉、日期/时段、body 滚动与固定 footer 无裁切。 | +| appointment_drawer_1440x900.png | **EXACT** | 医生 radio、四个 130×40 日期按钮、时段网格、备注与 footer 对齐。 | +| appointment_drawer_empty_doctors_1440x900.png | **EXACT** | warning、80 px 空态插图、“暂无可预约医生”和禁用确认完整。 | +| appointment_drawer_empty_roster_1440x900.png | **EXACT** | 无排班 warning、空态插图和“该医生暂无排班”可读。 | +| appointment_drawer_error_1440x900.png | **EXACT** | “号源加载失败:号源服务暂时不可用”、刷新入口与空态并存。 | +| appointment_drawer_keyboard_focus_1440x900.png | **EXACT** | 130×40 日期按钮的蓝色键盘 focus 边界清楚。 | +| appointment_drawer_loading_1440x900.png | **EXACT** | “正在加载医生、渠道与挂号状态...”与真实圆弧 spinner 完整,无缺字。 | +| appointment_drawer_refreshing_1440x900.png | **EXACT** | “正在刷新可用号源...”与 overlay spinner 完整,确认禁用。 | + +## 服务端合同限制:不归入视觉 P1 + +当前服务端只提供“当日追加 / 查询 / 删除单个附件”,没有“任意覆盖既有正文”或“删除整条笔记”端点: + +- D:/web/zyt/server/app/adminapi/controller/doctor/AppointmentController.php:168-196 只公开 addDoctorNote、doctorNotes、deleteDoctorNoteImage。 +- D:/web/zyt/server/app/adminapi/logic/doctor/DoctorNoteLogic.php:14-69 按 diagnosis_id + 当天 find-or-create,并把新正文追加为新行,不是任意覆盖。 +- D:/web/zyt/server/app/adminapi/logic/doctor/DoctorNoteLogic.php:114-136 只删除 tongue_images / report_files 中的单个路径。 +- D:/web/zyt/admin/src/api/patient.ts:19-40 同样只有上述三个客户端 API。 +- Python 远端仓库与该合同一致:src/doctor_workstation/services/repository.py:935-985。 + +因此桌面端没有伪造“覆盖旧正文 / 删除整条笔记”按钮是正确的 fail-closed 行为。该限制不能被当作视觉 PASS 的理由,也不应混入上面 4 个仍可修的 UI P1。 + +## 最终判定 + +- **P0:0 OPEN。** +- **P1:4 OPEN / PARTIAL**:Daily 动作行与 scoped 按钮语义;完整菜单图标/删除色/文案;Notes 附件视觉;处方/订单/视频/聊天视觉与证据覆盖。 +- **MISSING 视觉证据**:当前 diagnosis_visual 没有滚动到 switch 的截图,也没有完整处方编辑器和订单详情抽屉截图;源码能力存在,但不能用测试代替这些像素证据。 +- **乱码 / 缺字:0。** 43 张图中的中文、圆弧 loading、箭头、保存勾叉均完整。 +- **最终:PARTIAL。** 只有消除上述 4 组可见差异并补齐处方编辑器、订单详情与 switch 的当前截图后,才可给视觉同型 PASS。 + diff --git a/app/research/diagnosis_python_visual_gap.md b/app/research/diagnosis_python_visual_gap.md new file mode 100644 index 000000000..82e313ceb --- /dev/null +++ b/app/research/diagnosis_python_visual_gap.md @@ -0,0 +1,547 @@ +# Python 医生工作站诊单视觉 / 交互差异审计 + +> 审计日期:2026-08-10 +> 结论:**OPEN — 当前实现不具备与管理端诊单主链做截图级验收的条件。** 主要差异不是局部颜色或间距,而是索引页层级、表格固定列、只读/编辑形态和预约选择器的组件架构不同。 +> 约束:本轮只读检查业务源码;只新增本审计文档,未修改 Python、Vue、接口、payload、权限、状态门槛或业务流程。 +> 环境说明:项目提示的 `D:/web/zyt/app/.trellis/` 当前不存在,因此没有可补读的 Trellis 工作流或包级规格。 + +## 1. 范围与事实源 + +当前 Python 审计范围: + +- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py` +- `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py` +- `D:/web/zyt/app/src/doctor_workstation/ui/shell.py` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py` +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py` 中 `_AppointmentDialog` 及其入口 + +管理端唯一视觉事实源: + +- `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` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue` +- 上述页面直接渲染的 diagnosis components、`NoteTimeline.vue` 以及直接生效的全局样式 + +研究基线: + +- `D:/web/zyt/app/research/diagnosis_index_visual_spec.md:1-373` +- `D:/web/zyt/app/research/diagnosis_detail_visual_spec.md:1-565` + +明确排除:其他角色页面、小程序、企微运营、二维码、视频观看弹窗、未被三主视图引用的旧 Blood/Diet/Exercise/Tracking 组件,以及任何业务接口改造。本报告提到权限时,只描述入口或 Tab 的可见状态,不重新设计权限规则。 + +## 2. 总体判定 + +| 领域 | 当前状态 | 阻断截图复刻的根因 | +|---|---|---| +| 全局主题与密度 | OPEN | Qt 是暖灰绿、13 px、36 px 控件;管理端诊单基线是冷灰白、14 px、32 px 控件,索引主色为 `#4A5DFF` | +| 诊单索引 | OPEN | 多出页面标题;筛选永久四行;单表无右固定列;行操作、复合单元格、分页、空态和 loading 架构不同 | +| 诊单只读 / 编辑 | OPEN | 独立 readonly 与 drawer viewOnly 被合并成同一个居中 `QDialog`;Tabs、字段布局和内容主链大量不等价 | +| 预约 | OPEN | 60% 右 Drawer 被固定 540×560 Dialog 取代;医生、日期、时段网格全部降级为 ComboBox | +| 1024×640 | OPEN | 880×680 诊单 Dialog 请求高度超过外窗;索引页无页面级滚动且高级筛选常驻,表格可见高度过低 | +| 1440×900 | OPEN | 索引仍缺固定 460 px 右区;详情/预约保持固定宽度,无法随视口保持 60% 比例 | + +必须先完成四项结构改造,再做颜色和像素微调: + +1. 诊单索引改回“无页面标题 + 两张平卡 + 折叠筛选”。 +2. 诊单表使用专用 model/delegate,并实现右侧 `120 + 340 = 460 px` 固定区域。 +3. 将独立 readonly 页面与 edit/viewOnly Drawer 拆成两个视觉形态。 +4. 将预约恢复为右侧 Drawer,并恢复医生 radio、日期按钮、时段卡片网格及完整状态。 + +## 3. 基础 token 与壳层差异 + +### G-01 [阻断] 字体、颜色、控件与圆角是另一套设计系统 + +管理端诊单索引基线为 `PingFang SC, Arial, Hiragino Sans GB, Microsoft YaHei`、基础 14 px、页面 `#F6F6F6`、卡片 `#FFFFFF`、主/常规/次要文字 `#333/#666/#999`、默认控件 32 px、small 24 px、索引主色 `#4A5DFF`、卡片 r4。证据: + +- `D:/web/zyt/admin/src/config/setting.ts:1-14` +- `D:/web/zyt/admin/src/styles/var.css:1-43` +- `D:/web/zyt/app/research/diagnosis_index_visual_spec.md:65-101` + +当前 Qt 为 Microsoft YaHei UI 优先、基础 13 px、画布/表面 `#F4F3EF/#FCFBF8`、墨绿/青绿 `#17382F/#168579`、按钮和输入最小 36 px/r9、卡片 r16。状态色也全部偏成低饱和绿褐色。证据: + +- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:13-33` +- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:36-84` +- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:87-175` +- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:233-250` + +可见后果:同一页面的字宽、首屏密度、按钮高度、卡片轮廓、语义色和表格行数均不同;仅替换主色不能修复。 + +建议边界:在 `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:13-275` 增加以 `#DiagnosisIndex`、`#DiagnosisDrawer`、`#DiagnosisReadonly`、`#AppointmentDrawer` 为根的局部 token/QSS。除非另立全工作站换肤任务,不应直接改写共享 `Card`、`QPushButton`、`QTableWidget` 的全局规则。 + +### G-02 [高] 壳层占宽与页面滚动模型不同 + +当前 Shell 最小 1024×640,侧栏固定 216 px、顶栏固定 68 px,workspace 的 `QStackedWidget` 本身没有页面滚动容器: + +- `D:/web/zyt/app/src/doctor_workstation/ui/shell.py:306-325` +- `D:/web/zyt/app/src/doctor_workstation/ui/shell.py:329-423` + +管理端默认侧栏宽 183 px,navbar 50 px,默认还可显示 40 px multiple tabs;路由内容由 `el-scrollbar` 承担整页纵向滚动,页面外留白为 8 px 水平 / 16 px 垂直: + +- `D:/web/zyt/admin/src/config/setting.ts:1-6` +- `D:/web/zyt/admin/src/styles/var.css:4-10` +- `D:/web/zyt/admin/src/layout/default/components/header/multiple-tabs.vue:65` +- `D:/web/zyt/admin/src/layout/default/components/main.vue:2-11` + +当前诊单页又额外使用 24 px 水平边距,因此两档窗口下的静态内容宽约为: + +| 外窗 | 当前 workspace | 当前页面内容宽 | 管理端默认路由内容宽(约) | 差值 | +|---|---:|---:|---:|---:| +| 1024×640 | 808 | 760 | 824 | -64 px | +| 1440×900 | 1224 | 1176 | 1240 | -64 px | + +建议边界:索引页自行增加页面级滚动及 8/16 外留白;不要为了修诊单页直接改变全工作站 216/68 壳。若最终验收要求连管理端壳一起复刻,应另行冻结侧栏、navbar、multiple tabs 的 setting 后再改 `ShellWindow`。 + +### G-03 [高] hover、checked、focus、read-only 状态不完整 + +- 日期和业务入口都是 checkable ghost `QPushButton`,但 QSS 没有 `QPushButton[variant="ghost"]:checked`;active 态没有确定的可见反馈。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:87-118`、`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:495-528,776-799`;基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2227-2308`。 +- Qt 输入 focus 直接把 1 px 边改为 2 px 青绿实线,存在内部尺寸跳动;管理端是 primary-light 的 2 px 外环。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:140-154`;基准:`D:/web/zyt/admin/src/styles/element.scss:140-175`。 +- 按钮、Tab、radio、checkbox 没有统一的键盘 focus ring;表格还显式 `outline:0`。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:87-138,164-186,208-220`。 +- read-only `QPlainTextEdit` 没有独立 QSS,仍呈白底可编辑外观;只定义了部分 `:disabled`。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:140-155`、`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:685-689`。 +- 表格没有显式 item hover 规则;管理端普通 hover 是 `#F8F8F8`,业务行还有语义渐变。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:177-197`;基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2723-2735`。 + +建议边界:为诊单专用 chip、输入、Tab、表格 delegate 增加状态规则;保留键盘可达性,不必复刻 HTML `span` 不可聚焦的缺陷。focus 不应通过改变边框占位宽度实现。 + +### G-04 [中] 共享组件的视觉契约与诊单基线不同 + +- `PageHeader` 固定生成标题/副标题层级:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:331-365`。 +- `EmptyState` 是 30 px 圆圈 + 16 px 标题 + 说明 + 44 px 垂直留白:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:388-420`。 +- `BusyOverlay` 是文字 + 140 px 横向 progress,非 42 px 环形 spinner:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:489-514`、`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:230-231,271`。 +- `SortableTable` 是单选、全列本地排序、纯文本 item、末列 stretch:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:535-575`。 +- `Pager` 只有 total、上一页、页码分数、下一页:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:585-627`。 + +建议边界:新增诊单专用组件,不要破坏性修改这些被患者、订单、接诊页共享的类。 + +## 4. 诊单索引页差异 + +### I-01 [阻断] 页面层级、留白和卡片形态错误 + +管理端页面内没有标题或说明;路由内容直接从无边框、无阴影、r4 的筛选白卡开始,第二张列表卡相距 12 px。新增诊单在列表卡工具栏: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2-177` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2200-2211,2408-2434` + +当前页面先放 25 px `PageHeader("问诊列表", …)`,并把新增按钮放在标题右侧;外边距 24/20/24、间距 14。两张卡是有边框的暖白 r16,表格自身再加 r12,形成“卡中卡”: + +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:464-587` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:590-674` +- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:54-84,177-197` + +建议边界:只在 `ConsultationsPage.__init__` 移除页面内 `PageHeader`,将 add 入口移入列表工具栏;保留 Shell topbar。诊单两卡使用局部 objectName/QSS。 + +### I-02 [阻断] 日期 / 业务 chip 顺序、文案、换行和语义态不同 + +管理端顺序严格是“昨天挂号、前天挂号、当天挂号、明天挂号、后天挂号、全部”,随后是待预约/已完成/待分配医助;左侧 chips 可换行,计数是同行半透明文字。三种业务 chip 分别用 info/success/warning: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:6-70` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:964-970` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2222-2317` + +当前顺序是“前天、昨天、今天、明天、后天、全部”,缺“挂号”文案;九个按钮放在不可换行的 `QHBoxLayout`,全部是通用 ghost,无业务色和明确 checked 态: + +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:483-530` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:776-799` + +在 1024 档,按钮加入两三位计数后可能在约 760 px 内容宽内被挤压或裁字。 + +建议边界:增加诊单局部 `FlowLayout` 和带 dynamic property 的 chip;沿用现有 `_choose_*` 与 `_update_quick_buttons()`,只换视图装配和文案。 + +### I-03 [阻断] 筛选的两层结构被永久四行网格取代 + +管理端:第一行右侧是固定 160 px 搜索 + 32 px primary 查询;第二行是“挂号/确认”小 chips 和“更多筛选”;高级区默认隐藏,点击后即时显隐,使用虚线上边界和可换行布局: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:72-151` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2320-2405` + +当前:关键词、挂号、确认和所有高级条件永久铺成四行等宽 `QGridLayout`;查询/重置在最后一行。挂号/确认变成 ComboBox;最近日期范围被拆成四个独立 `QDateEdit`;还常驻一个管理端通过表头表达的“未服务排序”下拉: + +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:532-586` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:683-721` + +当前还缺待分配模式选中后显示的月份 128 px 和宽搜索 220 px / max 42vw;管理端证据为 `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:40-70,2298-2317`。 + +建议边界:重建筛选视图,但保留 `_search()`、`_filters()`、日期校验和现有请求字段。是否补待分配月份/宽搜索涉及现有请求参数,超出本视觉报告授权;在业务层确认前先预留容器,不自行发明接口字段。 + +### I-04 [阻断] 列表工具栏与选择模型不等价 + +管理端列表栏无“诊单记录”标题;左侧是新增、批量指派、批量取消指派,右侧按需显示“已选 n 条”。表格首列为 48 px checkbox 多选: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:154-191` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2414-2429` + +当前工具栏是标题 + 总数摘要 + 查看/诊单/开方/作废/视频/删除/刷新等全局按钮;表格无 checkbox,使用整行单选,加载后自动选第一行并出现青绿选择底: + +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:590-640` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:938-946` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:1085-1112` +- `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:541-549` + +建议边界:视图层改为 checkbox check-state;不要用整行 selection 代替多选。批量动作若当前业务方法不存在,本轮只应建立视觉容器和禁用态,不得伪造 mutation。 + +### I-05 [阻断] 表格列、固定区、排序和行操作架构不同 + +管理端列顺序与宽度为:选择 48、ID min70、患者 min60、性别年龄 100、挂号 min175、确认 88、复诊 min120、助理 100、开方 72、未服务 110、视频旁观 120 fixed、操作 340 fixed。声明最小总宽约 1403 px,右固定区共 460 px: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:191-406` +- `D:/web/zyt/app/research/diagnosis_index_visual_spec.md:169-186` + +当前列约 1392 px,但无选择/操作列,额外有医生 90、作废 74;挂号 300、未服务 180、视频 90。所有列随单一 `QTableWidget` 一起滚动,末列还会 stretch: + +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:646-663` +- `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:535-552` + +管理端只允许“未服务天数”走 descending → ascending 的自定义排序;当前全部表头都可做客户端排序,并另设排序 ComboBox: + +- 基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:297-304` +- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:543-549`、`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:560-570` + +管理端每行右侧有查看、诊单、开方/查看处方、预约、补全身份证和“更多”菜单;当前把少量动作集中在列表栏,行内没有动作命中区: + +- 基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:342-403` +- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:605-639` + +建议边界:新增 `DiagnosisTableModel`、专用 delegates 和同步的双 `QTableView`;右表只显示 120/340 两列,共享 model、selection、垂直滚动与 row height。不要把固定列能力硬塞进通用 `SortableTable`。 + +### I-06 [阻断] 复合单元格、语义色、hover 和密度全部降级为纯文本 + +当前 `SortableTable.set_rows()` 只创建 `QTableWidgetItem(text)`;格式化函数也只拼接字符串: + +- `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:554-575` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:233-322` + +由此缺失: + +- ID/患者 14 px/600;`NEW` 20 px danger tag。 +- 挂号块的 11 px badge、14 px/600 医生、12 px 时间、状态渐变、多预约虚线、取消挂号链接和最近渠道。 +- 已确认/未确认、已开方/未开方、处方作废 tag。 +- 未服务 null/`<=2`/`3–6`/`>=7` 的灰/绿/橙/红,以及健康打卡 tooltip。 +- 普通白/#FAFAFA 斑马、#F8F8F8 hover、未确认/未挂号的 3 px 左条和渐变。 +- 行级视频入口和 340 px 动作链接区。 + +管理端证据: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:193-406` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2442-2735` + +当前表格头是 12 px/700、`#EEF1EE`,alternate 为暖灰 `#F6F7F4`;基准表头为 13 px/600、`#F8F8F8`,普通预约行约 52 px。当前只调用 `resizeRowsToContents()`,无 39/52 px 基线: + +- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:177-197`、`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:938-940` +- 基准:`D:/web/zyt/app/research/diagnosis_index_visual_spec.md:163-212` + +建议边界:复合绘制与 `sizeHint()` 均放在诊单 delegates;不要给每格常驻 QWidget。行样本必须覆盖多预约和动作换行,且双表 row height 同步。 + +### I-07 [高] 分页、空态、loading、error 的布局契约不同 + +分页基线为 total、15/20/30/40 size、prev、最多 5 个数字页、next、jumper,按钮 32 px;当前只有 total、上一页、`1/N`、下一页,按钮最小 36 px: + +- 基准:`D:/web/zyt/app/research/diagnosis_index_visual_spec.md:254-262` +- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:585-627` + +空态基线保留表头和分页,仅在 table body 显示约 60 px 高的“暂无数据”。当前切换整个 `content_stack` 到大型 `EmptyState`,表头与分页同时消失: + +- 基准:`D:/web/zyt/app/research/diagnosis_index_visual_spec.md:264-273` +- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:642-673`、`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:388-420` + +loading 基线只覆盖表格,亮色 `rgba(255,255,255,.5)` + 42 px 主色环;20 秒静默刷新不闪。当前普通加载在两卡之间插入 MessageBanner,并禁用刷新,没有 table overlay;共享 `BusyOverlay` 的形态也不匹配: + +- 基准:`D:/web/zyt/app/research/diagnosis_index_visual_spec.md:275-280` +- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:901-956`、`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:489-514` + +错误当前只显示全宽 Banner,原表/旧数据仍在;管理端主表没有独立 error 插画。建议保留错误消息但不得让 Banner 插入造成卡片整体跳位。 + +建议边界:新增诊单专用 PaginationBar、table viewport empty label 和 table-only overlay;保持现有 generation、silent refresh 与错误传递逻辑不变。 + +### I-08 [高] 页面纵向滚动与少量数据高度不同 + +管理端表格没有本页固定高度;15 行自然撑高并由路由 `el-scrollbar` 滚整页。当前列表卡以 stretch=1 填满余高,表格内部滚动;一行数据时会留下大块空白,15 行时筛选和工具栏固定在窗口而不是随页面滚动: + +- 基准:`D:/web/zyt/admin/src/layout/default/components/main.vue:2-11`、`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:179-190` +- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:642-674` + +建议边界:诊单页使用单一 `QScrollArea` 承载两卡,表格按 header + 实际 rows + pagination 给自然高度;不要改其他页面的内部表滚动策略。 + +## 5. 诊单只读 / 编辑差异 + +### D-01 [阻断] 两种只读形态被合并为一个居中 Dialog + +管理端有两个不可互换的只读形态: + +1. `readonly.vue`:独立单列页面,无 Tabs,Hero 后按顺序纵向展示摘要、病例、日常记录、备注、订单、视频、聊天、指派、挂号。 +2. `edit.vue` 的 `viewOnly`:仍是 60% 右 Drawer + Tabs + disabled 表单,只隐藏保存。 + +证据: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/readonly.vue:1-149` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1-84,766-781` +- `D:/web/zyt/app/research/diagnosis_detail_visual_spec.md:75-152,156-194` + +当前所有查看/编辑都调用同一个 `DiagnosisDialog.open_for()`;只通过 `setReadOnly` 和保存按钮显隐切模式: + +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:245-288` +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:426-464` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:1113-1129` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:2142-2151` + +建议边界:保留 `open_for()`、saved signal、现有 load bundle 和数据快照;仅拆视图为 `DiagnosisReadonlyView` 与 `DiagnosisDrawerView`,由入口决定呈现形态。不得通过给现有 QDialog 换皮来声称两种基线都已覆盖。 + +### D-02 [阻断] Drawer 几何与 Header/Body/Footer 三段壳缺失 + +编辑/viewOnly 基线为 RTL 右 Drawer、宽视口 60%、全高;header 为白到 `#F8FAFC` 渐变和底线,body `#F8FAFC`,footer 独立白底、上边线,按钮右对齐、40 px/r10: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:3-10` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1670-1745` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1776-1864` + +当前是居中系统 `QDialog`,请求 880×680、最小 680×520,统一暖灰画布,四周 20/18;标题 25 px。按钮栏虽在 Tabs 外,能保持不随首 Tab 滚动,但没有 footer frame、边线和确定按钮顺序: + +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:245-288` +- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:44-46,54-58,87-104` + +当前 mode 文案/颜色也不同:后台只读 info、编辑 warning、新建 success;当前只读 neutral、可编辑 success,且标题是“患者信息详情/编辑患者病历”:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:256-266,441-447`。 + +建议边界:在主窗之上实现遮罩 + 右贴边 panel,按外窗宽度计算 60%;只让 body 滚动,header/footer 固定。保留 QDialog 作为临时承载会继续出现系统标题栏和居中几何差异。 + +### D-03 [阻断] Tabs 样式、顺序、权限可见性和内容主链不等价 + +管理端顺序:病历、医生备注、日常记录、处方、业务订单、视频录制回放、聊天、指派、挂号;“跟踪备注”已注释,不渲染。Tabs 为可横滚文本导航,4 px scrollbar,active 3 px 底条: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:55-759` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1798-1858` + +当前只有患者与病历、医生备注、挂号记录、指派记录、可选患者订单;挂号先于指派,日常记录/待办、处方、视频、聊天均缺失。除订单外,Tab 没按对应权限移除: + +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:271-279` +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:479-525` + +当前全局 Tab 是青绿填充 pill、r9、36 px,无 3 px active indicator:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:208-220`。 + +建议边界:用诊单局部 `QTabBar + QStackedWidget` 或可横滚 TabBar;按既有 canonical permissions 移除入口,当前 Tab 失权时回病历。不要加入已注释 TrackingNote,也不要重复增加旧 Blood/Diet/Exercise Tabs。 + +### D-04 [阻断] 编辑表单被统一成单列大文本框,字段顺序也偏离真实模板 + +当前 `_DIAGNOSIS_FIELDS` 将全部字段装成单列 `QFormLayout + QPlainTextEdit`,每项 46–82 px;没有 160 px label、栅格、分组标题或语义控件: + +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:49-102` +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:331-346` + +管理端是 12 px 表单、160 px label、32 px input/select/date/number、radio/check-button、24/12/8 栅格、18 px 项间距,以及横线夹胶囊的分组标题: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:57-632` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1476-1667` + +当前与后台真实模板的具体偏差: + +- 缺少/不等价:诊单ID表单行、状态、渠道、统计端就诊卡、当地诊断日期、糖尿病病史、口腔感觉、其他补充、病史补充。 +- “当前用药”被放在末尾;后台“在用药物”位于生命体征区。 +- 当前显示后台模板未渲染的证型、糖尿病类型、自由文本主诉/现病史/症状、食欲、舌象、舌苔、脉象、临床诊断、治则、处方、处方意见、医嘱。 +- 类别项都变成自由文本,失去 radio/check/select 的密度和状态。 + +真实后台字段次序见 `D:/web/zyt/app/research/diagnosis_detail_visual_spec.md:221-255`。 + +患者基本信息锁定时,当前只设 read-only,没有后台 warning Alert: + +- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:659-689` +- 基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:71-84` + +建议边界:只重建 `DiagnosisDialog` 的表单装配和 scoped QSS,保留字段值转换、校验、保存入口及隐私遮罩。严格按后台实际渲染字段,不把脚本模型中但模板未显示的字段继续呈现。 + +### D-05 [阻断] 独立 readonly 的 Hero、卡片流和病例网格完全缺失 + +readonly 基线:16 px 页面边距/卡间距;可换行浅蓝 Hero 带返回、患者名、性别年龄、未服务绿橙红 badge;白卡 r14、18 px padding、标题前 3×16 蓝条;患者摘要按确定顺序,病例按 4/3 列分组键值展示: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/readonly.vue:1-149` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/readonly.vue:257-405` +- `D:/web/zyt/app/research/diagnosis_detail_visual_spec.md:75-152` + +当前没有返回、Hero、未服务 badge 或纵向卡片流。摘要是两列九项,额外含身份证/血压/血糖,却缺地区、预约状态、是否开方和备注;病例仍是单列可编辑形态: + +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:290-349` +- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:574-700` + +建议边界:单独实现只读卡片 view;不要把 disabled 编辑表单当作独立 readonly。1440 档保留后台 4/3 列;1024 档若主动降列,应在验收中标注为桌面可用性增强,而不是声称 Vue 原实现已有响应式。 + +### D-06 [高] 备注、订单、历史表和关联内容被降级或缺失 + +- 医生备注:后台是时间轴、64×64 缩略图、文件 chip、编辑动作;当前仅三列“时间/医生/内容”。基准 `D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:1-436`;当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:351-364`。 +- 日常记录/待办:后台有日期筛选、矩阵、趋势、toolbar 和待办;当前整个 Tab 缺失。基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue:1-1260`、`D:/web/zyt/admin/src/views/tcm/diagnosis/components/DiagnosisTodoList.vue:1-319`。 +- 处方历史:后台七列并有编辑态开方入口;当前缺失。基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/CaseRecordList.vue:1-106`。 +- 业务订单:后台有就诊序号工具栏、九列、Tag/红金额、固定详情;当前八列为订单号/处方/收货人/手机/金额/发货/状态/创建,无工具栏和详情动作。当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:393-424,764-787`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/PatientOrderList.vue:7-148,252-276`。 +- 指派历史:后台九列;当前六列且继承只是纯文本。当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:378-390,748-762`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/AssignLogPanel.vue:1-43`。 +- 挂号历史:后台十二列和多个状态 Tag;当前七列。当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:370-390,731-746`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/AppointmentRecordPanel.vue:1-75`。 +- 视频与聊天:当前完全缺失。基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue:1-214`、`D:/web/zyt/admin/src/views/tcm/diagnosis/components/ImChatRecordPanel.vue:1-293`。 + +当前所有表格最终都只创建纯文本 `QTableWidgetItem`:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:711-716`。 + +建议边界:按当前主视图可达组件增加局部 model/delegate;不把不可达旧组件或观看弹窗混入。缺失内容若尚无当前 repository 数据来源,应明确保持 OPEN,不能用静态假数据冒充视觉完成。 + +### D-07 [高] loading、empty、error、保存与隐私锁定状态不足 + +- 打开详情时只显示 MessageBanner,种子内容继续可见;没有 readonly 页面级 overlay/skeleton:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:452-464`。 +- 加载失败只改 Banner,后台独立 readonly 应在 Hero 下显示错误 Empty:当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:570-573`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/readonly.vue:31-36`。 +- 清空表格只是 `setRowCount(0)`,没有“暂无…”状态:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:702-709`。 +- 订单翻页仍用全局 Banner,未遮住对应 Tab 内容:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:789-827`。 +- 保存只禁用按钮并显示 Banner,没有按钮 loading 动画或字段级 danger:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:829-917`。 +- 隐私/业务锁定只有只读控件,无 warning Alert,且 read-only 控件视觉仍像可编辑白框:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:659-689`、`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:140-155`。 + +建议边界:为每个内容区域提供局部 overlay/empty/error;保留 seed-first、generation 和异常处理。错误样式不能改变业务成功/失败判定。 + +## 6. 预约差异 + +### A-01 [阻断] 预约 Drawer 被固定居中 Dialog 取代 + +基线为 RTL 右 Drawer、外窗 60% 宽、全高、遮罩点击不能关闭、独立 footer;表单 label 100 px: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:2-19,185-198` + +当前是居中 540×560 模态 QDialog、20/18 内边距、系统标题栏、默认 QFormLayout label 宽;内容顶部还额外放 25 px 患者名和“患者号仅用于视频”说明: + +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:219-299` + +建议边界:保留 `_AppointmentDialog` 的异步查询、状态字段、`payload()` 和 `accept()`;只把承载层替换为右贴边 panel,并将 Header/Body/Footer 分离。不要改 `PatientsPage._book_appointment()` 的业务调用:`D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:2208-2224`。 + +### A-02 [阻断] 字段顺序和控件类型不等价 + +后台严格顺序是:上次就诊、预约方式、预约类型、当前患者、渠道、条件性自媒体补充、医生 radio、日期按钮、时段矩阵、备注: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:20-181` + +当前缺上次就诊、预约方式和当前患者;预约类型是 ComboBox,医生/日期/号源也全是 ComboBox: + +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:247-285` + +渠道基线宽 100%、最大 360 px;当前随 QFormLayout 拉伸,没有最大宽。`channel_source_detail` 创建后默认可见,初始化信号又被 `_setting` 拦截,因此首帧/默认渠道可能错误显示“渠道补充”;只有后续用户 change 才显隐: + +- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:260-268,345-402,587-594` +- 基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:62-75,741-750` + +建议边界:按真实顺序重建表单控件;沿用当前字段与校验,不改变 payload key。条件控件在初始渲染时就必须正确隐藏。 + +### A-03 [阻断] 日期和时段的所有可见状态都被下拉框吞掉 + +后台医生为可换行 radio;日期按钮 min 130×40、r8、14 px,hover 上移 2 px并有阴影;时段容器为 `#F8F9FA`、16 px padding,网格 cell min 110×70,显式展示可约、不可约、hover、selected 和刷新 loading: + +- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:77-171` +- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:752-921` + +当前只将医生、日期、可用号源放进 ComboBox;`_apply_slots()` 直接过滤不可约、quota<=0 和当天过去时段,因此不可约卡、状态标签、slot hover、selected 渐变和刷新入口均无法呈现: + +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:269-280` +- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:534-574` + +建议边界:以服务端原始 slot 列表驱动局部 Flow/Grid;业务上不可提交的时段保留为 disabled card。当前的未来日期限制、过期判定和 submit gate 保持不变。 + +### A-04 [高] loading、空态、重复预约、hover/focus 状态不足 + +- 后台未选医生/无排班在时间区显示 Empty;当前只是 ComboBox placeholder + Banner:基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:100-171`;当前 `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:410-493`。 +- 后台 `v-loading` 覆盖预约 body;当前所有阶段复用表单下方 MessageBanner,没有区域 overlay:基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:10`;当前 `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:286-299,439-448,521-532`。 +- 当天冲突基线在顶部持续显示 warning,切其他日期显示 info;当前只计算今日 blocking 并通过提交禁用/accept 文案表达,没有持续的 warning/info 双态:当前 `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:319-402,600-643`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:10-18,359-386`。 +- 后台 available hover、disabled opacity、selected 蓝渐变和 slot refresh loading 当前均不存在。 +- 当前 generic focus 仍是青绿 2 px 实边,radio/date/slot 的 focus-visible 不存在。 + +建议边界:增加预约 body overlay、时间区 empty、顶部冲突 alert、按钮/卡片 hover/pressed/focus;保留现有 `_today_blocking` 与 submit complete 判定。 + +### A-05 [高] 预约响应式和滚动安全不足 + +基线预约 Drawer 自身没有 <=768 media query,但内部医生、日期、slot 可换行,body 是 Drawer 可滚区域。当前固定 540×560 且无 `QScrollArea`;恢复完整 slot 网格后若继续用当前容器,1024×640 必然裁掉内容或 footer。 + +建议边界:桌面保持 60% 比例;当可用内容宽不足时再做全宽/单列增强,并明确这是 PySide 可用性增强。body 独立滚动,footer 永远留在可视区。 + +## 7. 两档窗口的确定性差异 + +### 1024×640 + +| 视图 | 管理端基线 | 当前源码请求 | 必须验收的风险 | +|---|---|---|---| +| 索引 | 页面内容可整体纵向滚;表宽约1403,右460固定 | 内容约760;高级筛选四行常驻;单表约1392且无固定列 | chip 计数裁字、表格高度仅剩少数行、动作不可见、空/loading移位 | +| edit/viewOnly | 右贴边约614 px、全高、固定 footer | 居中880×680,最小680×520 | 请求高度已超过640;系统窗口可能裁剪/强制缩放,footer不可作为稳定截图基线 | +| appointment | 右贴边约614 px、全高 | 居中540×560 | 宽度偏小且大量留白;恢复slot后无滚动会裁内容 | +| standalone readonly | 页面流、16px边距、无Tabs | 880px模态Tabs | 模式、层级和宽度均不可比较 | + +### 1440×900 + +| 视图 | 管理端基线 | 当前 | 必须验收的风险 | +|---|---|---|---| +| 索引 | 内容宽约1240;仍可能横滚,右460固定 | 内容约1176;横滚约248px且无固定列 | 视频/操作随滚动消失;少量数据时列表卡被拉出大空白 | +| edit/viewOnly | 右贴边约864 px | 居中880×680 | 宽度偶然接近,但方向、全高、三段壳仍错误 | +| appointment | 右贴边约864 px | 居中540×560 | 明显过窄,无法呈现日期/slot网格密度 | +| standalone readonly | 宽页面流,病例4/3列 | 固定880px单列表单 | 信息密度和卡片层级完全不同 | + +注:Element Drawer 的 60% 按外窗 viewport 计算,因此上表为约 614/864 px;不按 Python 扣除侧栏后的 workspace 计算。 + +## 8. 建议改动边界(按文件 / 类) + +| 目标 | 允许的视觉改造 | 必须保持不动 | +|---|---|---| +| `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:13-275` | 新增诊单局部 token、scoped QSS、checked/focus/read-only/overlay/delegate 状态 | 不全局换肤其他页面;不改变业务状态含义 | +| `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:331-627` | 新增 FlowLayout、DiagnosisPaginationBar、局部 loading/empty、专用 model/delegate/固定表辅助 | 不破坏现有 PageHeader、EmptyState、SortableTable、Pager 的调用方 | +| `D:/web/zyt/app/src/doctor_workstation/ui/shell.py:306-423` | 仅当全壳明确纳入验收时调整;可提供 drawer host/overlay host | 不以改壳宽度掩盖诊单页布局问题 | +| `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:464-674` | 重建索引层级、筛选、工具栏、表格绑定、页面滚动 | 保留 repository 调用、过滤语义、权限判断、generation、mutation、视频门槛 | +| `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:901-956` | 将普通 loading/error 映射到 table-only overlay/状态层 | 20秒 silent refresh 继续不闪;不改请求时序 | +| `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:208-424` | 拆 readonly/drawer 视图,重建 header/tabs/form/cards/tables | 保留 `open_for` 契约、saved signal、隐私可见性和已有数据加载 | +| `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:426-917` | 把加载/错误/锁定/保存映射到局部视觉状态 | 不改 endpoint、payload、字段校验、generation、防过期响应 | +| `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:219-646` | 把预约改成 Drawer、radio/date/slot grid、overlay/empty/alert | 保留排班来源、日期/过期过滤、冲突门槛、submit gate、`payload()` | +| `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:2208-2224` | 仅替换打开的视觉容器 | 不改预约保存调用与 mutation reconcile | + +推荐实施顺序: + +1. 建 scoped token 与 drawer/table 基础组件。 +2. 重建 index 层级、筛选、固定列和 states。 +3. 拆 readonly 与 edit/viewOnly,先完成壳和病历主 Tab。 +4. 补当前可达的 notes/orders/histories;数据源不存在的主链保持显式 OPEN。 +5. 重建 appointment 卡片式选择器。 +6. 最后做两档截图的字号、间距和颜色微调。 + +## 9. 截图验收前置条件 + +所有截图固定:Windows 缩放 100%、默认亮色、同一 Qt 字体回退、同一权限集合、相同样本数据、无本地主题覆盖。运行态字体/DPR、tooltip/dropdown 坐标和 scrollbar 像素应记录环境,不应以静态源码推断绝对像素。 + +索引样本至少包含:`NEW`、双预约、已完成预约、爽约、未确认、已确认未挂号、处方作废、unserved=`null/2/5/8`、可进入/不可进入视频。 + +详情样本至少包含:患者基本信息可编辑/锁定、全部权限/裁减权限、备注含图片和文件、订单有/无数据、挂号/指派有/无数据、加载失败。 + +预约样本至少包含:未选医生、无排班、可约、不可约、当天过去时段、选中、今日冲突、其他日期 info、渠道需要/不需要补充、loading/error。 + +## 10. 截图验收矩阵 + +| ID | 窗口 | 状态 / 操作 | 必须同时看见 | 关键判定 | +|---|---|---|---|---| +| IDX-01 | 1024×640 | 默认15条、筛选收起、横滚最左 | 无页面内标题;两张平卡;两行筛选;表头、分页 | chips 不裁字;搜索完整;默认当天 active;至少能正常纵向滚整页 | +| IDX-02 | 1024×640 | 横滚到最右 | 左表发生横滚;视频120+操作340仍贴右 | 固定区不移动;双表 header/body/row height 无错位 | +| IDX-03 | 1024×640 | 更多筛选展开 | 虚线分隔、高级控件可换行、表格仍可达 | 显隐无动画;24/32px混合高度按源码;页面整体可滚 | +| IDX-04 | 1024×640 | 四类 chip active + 键盘 focus | 当天/待预约/已完成/待分配各自语义色 | 计数为同行文字;focus清晰且无几何跳动 | +| IDX-05 | 1024×640 | 普通、未确认、未挂号 hover | 斑马、#F8F8F8 hover、3px语义条/渐变 | fixed区背景连续;hover不被 selection 色覆盖 | +| IDX-06 | 1024×640 | 慢请求 loading | 原表尺寸保持、table-only 半透明层、42px spinner | 筛选/卡位置不跳;无全宽 banner 插入 | +| IDX-07 | 1024×640 | 空结果 | 表头、60px“暂无数据”、分页 | 无大圆圈插画;pager不消失 | +| IDX-08 | 1440×900 | 默认15条 | 完整两卡、自然表高、完整分页 | 少量数据不拉出大空白;15条由整页滚动承载 | +| IDX-09 | 1440×900 | 多预约 + 操作换行 | 预约 badge/渐变/虚线、340px操作链接 | 行高由内容增长;左右固定表同步 | +| IDX-10 | 两档 | tooltip / 更多菜单 | 助理溢出、未服务、视频 tooltip;bottom-end菜单 | tooltip只在语义命中区;删除前分隔且danger | +| DET-01 | 1024×640 | edit 基本 Tab | 右侧约614px全高 Drawer、header、横滚Tabs、固定footer | body单独滚;footer不裁;label160px;无系统标题栏 | +| DET-02 | 1024×640 | viewOnly + 患者锁定 | info badge、disabled表单、warning Alert、关闭按钮 | 无保存;read-only外观不似可编辑;隐私字段无泄漏 | +| DET-03 | 1440×900 | edit 全权限 | 右侧约864px、Tab正确顺序、24/12/8栅格 | 无TrackingNote;active底条3px;表单字段顺序与§4.5一致 | +| DET-04 | 两档 | 独立 readonly | 无Tabs/模态;返回+Hero+摘要+病例+权限卡片 | 1024可用;1440病例保持4/3列;未服务三色正确 | +| DET-05 | 两档 | 详情 loading/error/empty | overlay;Hero下错误Empty;各表局部空态 | 不残留错误seed;error不导致footer/tab位移 | +| DET-06 | 1440×900 | 备注/订单/指派/挂号 | 时间轴图片/文件;订单工具栏/详情;完整列/Tag | 表头顺序、状态色、固定动作列与管理端一致 | +| APT-01 | 1024×640 | 初始 loading / 未选医生 | 右侧约614px Drawer、body overlay、时间区Empty、固定footer | label100px;footer可见;渠道补充默认正确隐藏 | +| APT-02 | 1024×640 | 无排班 | 医生radio、时间区“暂无排班”Empty | 不用ComboBox placeholder冒充空态 | +| APT-03 | 1024×640 | 可约/不可约/hover/selected | 日期130×40;slot最小110×70;刷新 | 不可约仍显示但禁用;hover抬升;selected蓝渐变 | +| APT-04 | 两档 | 今日冲突 / 其他日期重复 | 顶部warning切info、确认disabled/available | 状态随所选日期切换;不只在点击提交后提示 | +| APT-05 | 1440×900 | 完整预约表单 | 右侧约864px、医生和日期自然换行、slot多列 | 不保持540px窄窗;body滚动,footer固定 | +| A11Y-01 | 两档 | 仅键盘遍历 | chip/input/select/radio/checkbox/table/button均有focus | 顺序可预测;focus对比足够;无控件尺寸抖动 | + +## 11. 发布门槛 + +以下任一项仍存在即不能标记视觉对齐完成: + +- 索引页仍有第二个页面标题,或高级筛选默认常驻。 +- 表格仍是单个纯文本 `QTableWidget`,右侧 460 px 不固定。 +- 空态仍替换掉表头/分页,或普通加载仍插入全宽 Banner。 +- 只读与 viewOnly 仍复用同一个居中 Tabs Dialog。 +- 编辑/预约仍不是 60% 右贴边、全高、固定 footer 的 Drawer。 +- 病历仍由一串同形态 `QPlainTextEdit` 组成。 +- 医生、日期和时段仍以 ComboBox 代替 radio/button/grid。 +- 1024×640 下任何 footer、搜索、chip 或表格固定区被裁剪。 +- 1440×900 下预约仍固定 540 px,或详情仍只有固定 880 px 居中模态形态。 +- hover/focus/loading/empty/error/disabled/selected 任一规定状态没有独立截图证据。 + +本报告只定义视觉和交互呈现的修复边界。所有 repository、endpoint、payload、权限 code、状态门槛和 mutation reconcile 均应原样保留,并在实现后另走现有业务回归测试。 diff --git a/app/research/diagnosis_release_contract_audit.md b/app/research/diagnosis_release_contract_audit.md new file mode 100644 index 000000000..e07a20b37 --- /dev/null +++ b/app/research/diagnosis_release_contract_audit.md @@ -0,0 +1,164 @@ +# 诊单发布后端合同独立终验 + +- 终验日期:2026-08-11 +- 审计对象:`D:/web/zyt/app` 当前工作区终态 +- 对照基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/`、其直接引用的 API,以及 `D:/web/zyt/server` 当前服务端控制器/Logic +- 审计性质:只读业务与后端合同终验;未访问网络、未安装依赖、未调用线上接口;除本报告和 pytest 临时产物外,未修改业务源码或测试 + +## 发布结论 + +**PASS(限本报告题定范围)。** + +| 等级 | OPEN 数 | 结论 | +|---|---:|---| +| P0 | **0** | 未发现错误对象写入、越权入口、错误 endpoint、旧异步回调落地或主数据破坏风险 | +| P1 | **0** | 题定的所有主流程均已形成真实后端闭环 | +| P2 | **0** | 前两轮发现的 Daily 既有记录不可编辑、视频只显示首条 URL/不能逐通话追加等合同缺口均已关闭 | + +**AVAILABLE 后端功能是否全部对齐:是。** 在“admin 诊单页面及其直接组件/API 中、本报告明确列出的可用后端功能”这一范围内,桌面端已经全部对齐:列表查询与分页、权限和菜单、多挂号精确取消、订单创建与付款二维码、三种详情模式、Daily 新增/编辑、真实 Notes 追加与附件、处方/业务订单、视频、归档 IM,以及 Shell owner/异步代次保护均为真实调用,无伪造成功动作。 + +这里的 AVAILABLE 不扩张为对 `admin` 全仓所有未枚举页面和无关路由的声明。本报告也不把后端从未提供的功能算成桌面端缺口:**任意旧笔记正文覆盖、删除整条医生笔记,以及旧跟踪备注改写/整条删除,在当前 server 快照中没有 endpoint,属于后端合同限制,不计入 P0/P1/P2。** + +## 合同终验矩阵 + +| 题定能力 | 状态 | admin / 后端合同 | 桌面端终态 | +|---|---|---|---| +| 诊单列表筛选、分页、权限、菜单 | **CLOSED** | `tcm.diagnosis/lists`;canonical 权限;服务端动态菜单 | 筛选字段、待分配宽搜、分页总数/末页回退、统计计数请求、菜单裁剪均对齐 | +| 多挂号精确取消 | **CLOSED** | `POST doctor.appointment/cancel {id}` | 每张挂号卡携带精确 appointment id,确认前后重验同一诊单和可取消状态 | +| 创建订单后生成付款二维码 | **CLOSED** | `order.order/create` → `order_no` → `tcm.diagnosis/generateOrderQrcode` | 已消费真实 `order_no`,即时请求 QR;缺 order_no / qrcode_url 时 fail-closed,可重试且有 selection/generation 防串单 | +| edit / viewOnly / readonly | **CLOSED** | edit、viewOnly 用 `detail`;独立 readonly 用 `readonlyDetail`;保存用 `edit` | mode dispatcher 与 admin 一致,viewOnly 不误打 readonly endpoint | +| Daily 新增与既有血糖/饮食/运动编辑 | **CLOSED** | 三类 `add` / `edit` endpoint | 已有单元格解析 record id 并走 update;新增走 add;权限、DTO、owner/generation 校验完整 | +| Notes 真实新增与附件 | **CLOSED(AVAILABLE)** | `addDoctorNote`、`doctorNotes`、`deleteDoctorNoteImage` | 正文真实追加;舌象/报告先上传得到服务端 URI 再新增;单附件真实删除 | +| 处方与业务订单 | **CLOSED** | `tcm.prescription/add`、`listByDiagnosis`、`tcm.prescriptionOrder/lists` / `detail` 等 | 真实开方、历史处方查看、诊单上下文订单列表/详情与诊次偏移均接通 | +| 视频全量回放与逐通话上传 | **CLOSED** | `getCallRecords`、`createManualCallRecord`、`attachLocalCallRecording` | 完整消费 `recording_urls_list`;工具栏上传可建手工记录;行内上传传精确 `call_record_id` | +| IM 归档读取与同步 | **CLOSED** | `only_archived=1`、`triggerImChatSync` | 首次及重载均固定归档读取;真实触发同步并按 media generation 刷新 | +| owner resize / generation | **CLOSED** | UI 生命周期合同 | 每次打开重绑真实 Shell;overlay 同步 Move/Resize/Show/Close;宽屏 60%、窄屏全宽;关闭使全部异步代次失效 | + +## 逐项证据 + +### 1. 诊单列表:筛选、分页、权限与菜单 + +- admin 的筛选模型及待分配宽搜在 `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:773-917`;待分配有关键词时只保留分页、`pending_assign=1` 和 `pending_assign_keyword`,没有关键词时带月份。 +- 桌面端 `src/doctor_workstation/ui/pages/consultations.py:1566-1602` 发送 canonical 字段:`keyword`、`has_appointment`、`diagnosis_confirmed`、`diagnosis_type`、`syndrome_type`、`assistant_id`、最新挂号日期/渠道、最新分配日期、`appointment_date`、`pending_booking`、`completed_appointment`、`pending_assign`、`sort_unserved_days`。待分配宽搜规则与 admin 一致。 +- `consultations.py:1604-1653` 把 `page/page_size` 传入 repository,消费真实 `total`,超末页时回退并重查;每页仅接受 15/20/30/40。`:1672-1741` 的日期/待挂号/已完成/待分配计数沿用同一筛选语义。 +- production repository 在 `src/doctor_workstation/services/repository.py:1562-1589` 调用 `GET tcm.diagnosis/lists`,不丢弃当前 canonical 查询字段。 +- `src/doctor_workstation/ui/shell.py:46-254` 将 `tcm.diagnosis/lists` 作为诊单导航权限,识别 `tcm/diagnosis` 与 `tcm/diagnosis/index`;生产菜单以服务端 visible/enabled/sort/children 为事实源,同时还必须通过精确 slash 权限。空生产菜单不会静默回退出诊单入口,demo 才允许本地回退。 +- 页面动作同样使用 exact slash 权限并同时检查 repository capability;没有把 dot 形式或相似字符串当成授权。 + +结论:查询 DTO、统计 DTO、分页和动态菜单/动作门禁均与 admin 的实际合同对齐。 + +### 2. 多挂号精确 `doctor.appointment/cancel` + +- admin 从 `D:/web/zyt/admin/src/api/doctor.ts:60` 调用 `/doctor.appointment/cancel`,诊单列表在 `index.vue:1820`、`:1847` 传具体挂号 `id`。 +- 桌面端按嵌套挂号数组生成多个操作;歧义的行级“取消挂号”仅在唯一可取消挂号时成立。`consultations.py:2040-2107` 会: + 1. 按传入的精确 appointment id 查找状态可取消的挂号; + 2. 绑定当前 diagnosis; + 3. 用户确认后再次从当前模型按同一 id 重验; + 4. 最终只把该 id 传给 diagnosis 专用取消方法。 +- `repository.py:1550-1555` 明确调用 `POST doctor.appointment/cancel {"id": appointment_id}`,没有误走患者工作区的 `firstvisit.myPatient/cancelAppointment`。 + +结论:同一诊单有多条挂号时不会取消错行,超 32 位 Python 整数也不会因 UI 转换而截断;本项 CLOSED。 + +### 3. 创建订单 → `order_no` → `generateOrderQrcode` + +- admin 的完整链在 `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:1478-1499`:`orderCreate` 成功后读取 `res.order_no`,随后请求 `generateOrderQrcode({order_no})`。 +- 桌面端 `consultations.py:2279-2329` 只有在 `tcm.diagnosis/order` 权限以及 create/QR 两个 capability 都存在时开放入口。 +- `:2331-2479` 已完整串联结果:读取顶层或 `data.order_no`,创建 QR 对话框并立即请求真实 QR;缺少 `order_no` 不会伪造二维码,缺少 `qrcode_url` 显示失败;retry、当前 diagnosis/patient、权限与独立 order generation 均受保护。 +- `repository.py:2035-2076` 精确调用 `order.order/create`,返回 mapping;再调用 `tcm.diagnosis/generateOrderQrcode {"order_no": ...}`,并强制要求非空 `qrcode_url`。 + +结论:此前“订单已创建但付款 QR 闭环中断”的 P1 已关闭。 + +### 4. edit、viewOnly、readonly endpoint 分流 + +- admin edit 与 viewOnly 共用 `tcmDiagnosisDetail`:`D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1263-1322`;保存调用 `tcmDiagnosisEdit`(`:1367`)。独立 readonly 页调用 `diagnosisReadonlyDetail`:`readonly.vue:155`、`:231`。 +- API 定义分别是 `GET /tcm.diagnosis/detail`、`POST /tcm.diagnosis/edit`、`GET /tcm.diagnosis/readonlyDetail`:`D:/web/zyt/admin/src/api/tcm.ts:9-11`、`:50-52`、`:60-62`。 +- 桌面端 mode resolver 在 `src/doctor_workstation/ui/dialogs/diagnosis.py:1427-1544`:edit 与 viewOnly 均调用普通 detail;仅独立 readonly 调用 readonlyDetail。`repository.py:1591-1626` 实现同一分流和 edit 保存。 + +结论:viewOnly 不再误用权限感知 readonly endpoint;三种模式 CLOSED。 + +### 5. Daily:新增及既有血糖/饮食/运动编辑 + +- admin `DailyMatrix.vue:586-648` 按日期聚合记录并保留可编辑 id,`:921-1013` 点击已有单元格时载入记录,提交时按 id 在 add/edit 之间分流。 +- admin API 的现存合同为: + - `tcm.bloodRecord/add` / `edit`:`D:/web/zyt/admin/src/api/tcm.ts:114-120`; + - `tcm.dietRecord/add` / `edit`:`:146-152`; + - `tcm.exerciseRecord/add` / `edit`:`:173-179`。 +- 桌面端 Daily matrix 已按同样规则合并同日血糖字段,并给已有血糖、饮食、运动单元格保留 record id;`diagnosis.py:2234-2338` 分别打开新增/编辑器,并按 id 调用 add 或 update。 +- production repository 在 `repository.py:1758-1818` 精确实现上述六个 endpoint;编辑器保留饮食/运动既有图片,mutation 完成只在 diagnosis/patient 和 daily generation 仍匹配时刷新。 + +结论:此前“只能新增、不能编辑已有三类记录”的 P2 已关闭。跟踪备注仍按后端设计只允许当天追加,见后端限制章节。 + +### 6. Notes:真实新增、上传与单附件删除 + +- admin `D:/web/zyt/admin/src/api/patient.ts:20-40` 只有三条医生笔记 API:`addDoctorNote`、`doctorNotes`、`deleteDoctorNoteImage`。 +- 桌面端 `diagnosis.py:2424-2511` 的正文新增真实调用 `add_doctor_note`;舌象/报告先调用 multipart `upload_material`,取得服务端 URI 后再把 URI 交给新增接口;单附件删除携带 note id、附件类别和服务器路径。 +- production repository 在 `repository.py:906-985` 实现上传以及 `doctor.appointment/addDoctorNote`、`doctor.appointment/doctorNotes`、`doctor.appointment/deleteDoctorNoteImage`,并拒绝把本地路径当成已上传附件提交。 +- 所有按钮同时受 exact permission 和 capability 裁剪,viewOnly/readonly 不出现可写入口。 + +结论:当前后端实际 AVAILABLE 的 Notes 能力全部对齐;没有用本地假数据或“只 toast 不落库”模拟新增/附件操作。 + +### 7. 处方与诊单上下文订单 + +- 详情页处方 Tab 会查询 `tcm.prescription/listByDiagnosis`,编辑态“开方”打开真实处方编辑器并调用 `tcm.prescription/add`;历史处方查看使用该真实列表返回的处方数据打开详情对话框。 +- 订单 Tab 使用 `tcm.prescriptionOrder/lists` 并带诊单上下文,详情调用 `tcm.prescriptionOrder/detail`;诊次统计偏移调用真实 `setRevisitSlotStartOffset`。 +- production endpoint 证据:`repository.py:1131-1149`、`:1220-1265`、`:1633-1645`;UI 调度与 capability 门禁在 `diagnosis.py:2048-2058`、`:2534-2689`、`:2791-3186`。 + +结论:题定的处方创建/查看及业务订单列表/详情/偏移均为真实后端行为,未发现伪动作或无权限仍可提交。 + +### 8. 视频:全量 `recording_urls_list` 与逐 `call_record_id` 上传 + +- admin `CallRecordPanel.vue:32` 把完整 `row.recording_urls_list` 传给播放组件;`:150-177` 支持“上传后新建手工通话记录再绑定”,也支持向指定现有记录追加。 +- 桌面端 `diagnosis.py:2899-2967` 会归一化并去重 direct URL 与完整 `recording_urls_list`,逐一提供播放入口,不再只取数组第一个元素。每个有正 id 的行提供“追加回放”,闭包携带该行精确 `call_record_id`。 +- `diagnosis.py:2587-2613` 将可选精确 id 交给 repository。`repository.py:1875-1943` 的顺序与 admin 一致:先上传;无 id 时创建 manual call record;最后调用 `attachLocalCallRecording`。已有 id 时不创建新记录,直接绑定该 id。 + +结论:此前“只展示首条 URL、不能逐通话记录追加”的两个 P2 缺口均已关闭。 + +### 9. IM:`only_archived` 与 sync + +- admin `ImChatRecordPanel.vue:148-185` 固定 `only_archived: 1` 并调用 `triggerImChatSync`。 +- 桌面端 lazy query 在 `diagnosis.py:2084-2097` 固定 `only_archived=True`,同步动作在 `:2615-2627` 调用真实 repository,并在当前 media generation 仍有效时重载。 +- `repository.py:1945-1965` 精确发送 `GET tcm.diagnosis/getImChatMessages {diagnosis_id, only_archived: 1}` 与 `POST tcm.diagnosis/triggerImChatSync`。 + +结论:未混入 live merge 默认值,归档读取和同步闭环 CLOSED。 + +### 10. Shell owner、resize 与 generation + +- `diagnosis.py:1325-1413` 每次打开/显示都从当前 `parentWidget().window()` 重绑真实 owner;overlay 采用 owner 的全局原点与完整尺寸,drawer 在宽屏取 owner 60%,窄屏取全宽,并响应 owner Move/Resize/Show/Close。 +- `diagnosis.py:1731-1760` 在 reject/done/owner close 时统一递增 detail、save、orders、order detail、daily、notes、media、offset 及所有 lazy-tab generation,并关闭相关子对话框/播放器。 + +结论:移动或缩放真实 Shell 后几何会立即同步;关闭后旧请求结果不能回写到下一次打开的诊单。 + +## 后端合同限制:旧笔记不能任意覆盖或整条删除 + +本项进行了 admin API、controller、Logic 和 `D:/web/zyt/server` 全仓关键词交叉检索,当前 server 快照的结论明确: + +1. `D:/web/zyt/server/app/adminapi/controller/doctor/AppointmentController.php:168-198` 仅公开 `addDoctorNote`、`doctorNotes`、`deleteDoctorNoteImage`,随后该 controller 结束;不存在医生笔记正文 edit/update 或整条 delete action。 +2. `D:/web/zyt/server/app/adminapi/logic/doctor/DoctorNoteLogic.php:14-76` 的 `addOrAppend` 按 `diagnosis_id + 当天` find-or-create,并把新正文追加到当日内容,不允许任意覆盖历史正文。 +3. 同一 Logic 的唯一删除逻辑 `deleteImage`(`:114-136`)只从 `tongue_images` 或 `report_files` 数组中删除一个附件,不删除笔记行。 +4. admin API `D:/web/zyt/admin/src/api/patient.ts:20-40` 也只有上述三条接口,没有被桌面端漏接的隐藏 edit/delete API。 +5. 跟踪备注同样只有 `addTrackingNote` 与 `trackingNotes` controller action(`DiagnosisController.php:183-214`);`TrackingNoteLogic.php:23-95` 只有按当天追加和按诊单读取,没有历史正文覆盖或整条删除。 + +因此: + +- “任意覆盖既有医生/跟踪笔记正文”与“删除整条笔记”是**后端合同限制**,不是客户端 P0/P1/P2。 +- 当前桌面端不展示无法兑现的按钮是正确的 fail-closed 行为。 +- 若产品必须新增这些能力,应先在 server 增加独立 authenticated endpoint、权限码、归属校验、审计记录、并发/版本策略和删除语义;在此之前不应由客户端通过追加接口模拟覆盖或删除。 + +## 离线验证 + +在当前 `.venv` 下使用 `QT_QPA_PLATFORM=offscreen`、`PYTHONDONTWRITEBYTECODE=1`,禁用 pytest cache,并把 basetemp 放到 `artifacts/pytest_release_contract_audit/`;未使用网络: + +```text +python -m pytest -q -p no:cacheprovider --basetemp artifacts/pytest_release_contract_audit tests/test_diagnosis_detail_contract.py tests/test_consultations_parity_ui.py tests/test_diagnosis_drawer_visual.py tests/test_diagnosis_index_visual.py tests/test_shell_contract.py tests/test_repository_parity.py tests/test_permissions.py + +118 passed +``` + +退出码为 0。该结果覆盖 endpoint/DTO、权限裁剪、多挂号精确取消、订单 QR 串联、三种详情模式、Daily edit、Notes、视频多 URL/逐 call id、IM 和 owner/generation 的定向回归。测试通过是回归信号;最终 AVAILABLE 判定同时依赖上面的 admin/API/server 静态合同核对,不以 mock 测试单独代替生产合同证据。 + +## 发布门槛判断 + +- **业务/后端合同门槛:通过。** +- **OPEN:P0 0 / P1 0 / P2 0。** +- **AVAILABLE:题定范围内全部对齐。** +- **唯一保留项:旧医生/跟踪笔记正文覆盖与整条删除需先扩展后端合同;这是明确的 server limitation,不阻断“当前 AVAILABLE 功能”发布。** diff --git a/app/research/diagnosis_release_visual_audit.md b/app/research/diagnosis_release_visual_audit.md new file mode 100644 index 000000000..65b9a27ac --- /dev/null +++ b/app/research/diagnosis_release_visual_audit.md @@ -0,0 +1,173 @@ +# Diagnosis 发布视觉终验 + +审计日期:2026-08-11 +审计性质:独立只读终验;除新增本报告外,未修改业务源码、测试、renderer 或 PNG。 +基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/` 及其当前主链直接依赖组件。 +当前实现:PySide6 + scoped QSS + QPainter,主要位于 `src/doctor_workstation/ui/`。 +Trellis:`D:/web/zyt/app/.trellis` 当前不存在(`Test-Path` 为 `False`),因此无可补读的 Trellis workflow/spec。 + +## 最终结论 + +**PARTIAL,不是 PASS。** + +- 检查了 `artifacts/diagnosis_visual/` 当前全部 **49 张 PNG**,均以原始尺寸逐张目检;尺寸分布为:`1024x640` 22 张、`1280x800` 11 张、`1440x900` 12 张、`640x540` / `650x620` / `820x560` / `920x780` 各 1 张。 +- PNG 判定:**40 PASS / 9 PARTIAL / 0 不可读 / 0 硬裁切**。 +- **P0:0 OPEN。** 诊单列表、右固定 460 px 区、付款二维码、edit/viewOnly 的 60% Drawer、独立 readonly、预约 Drawer、固定 footer、loading/error/empty/focus/permission 状态及 Shell 183/50/40 均未发现发布阻断。 +- 上一轮明确 OPEN 中,Daily 动作行裁切与按钮语义、完整更多菜单的图标/danger/“二维码”文案已经闭合;视频的多回放、逐条追加与应用内播放器能力也已经存在。 +- 仍不能给 PASS 的原因是 4 组 P1 同型差异:Notes 不渲染真实缩略图;处方编辑器仍是另一种 tabbed modal;订单详情仍是精简 640x540 表单而非共享 80% Drawer;视频仍从表格跳到独立播放器且聊天图片仍降级为文字链接。另有 2 组 P2 文案/局部工具栏差异。 + +严重度口径:P0 = 核心流程不可见/不可操作或窗口级裁切;P1 = 本次明确要求的主链组件结构、信息层级或媒体形态未与基准同型;P2 = 功能成立但文案、局部按钮或说明层级未精确对齐。 + +## 此前 OPEN 复核 + +| 项目 | 当前状态 | 精确证据 | +|---|---|---| +| Daily 裁切 | **CLOSED** | `diagnosis_state_daily_1024x640.png` 中“刷新”已换到下一行且完整可见;当前用 `FlowLayout`:`src/doctor_workstation/ui/diagnosis_drawer.py:1607-1609`,基准允许换行:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue:1094-1109`。 | +| Daily 按钮语义 | **CLOSED(主问题)** | `+血糖` 为 primary,`+饮食/+运动/+备注` 为 secondary,范围 checked 态可见,`新增待办` 为 primary;实现:`diagnosis_drawer.py:1613-1659,1691-1702`;基准:`DailyMatrix.vue:25-35`、`DiagnosisTodoList.vue:3-19`。仍有下述 P2 文案/局部刷新差异。 | +| 完整更多菜单 | **CLOSED** | `diagnosis_full_menu_1280x800.png` 显示 8 项、每项图标、删除分隔线与 danger 红、精确“二维码”。QPainter 图标与 danger 绘制:`src/doctor_workstation/ui/diagnosis_index_widgets.py:74-175`;动作与文案:`:1478-1556`;基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:379-400`。 | +| Notes 动作与附件 | **PARTIAL** | 新增/追加、舌象/报告上传、打开与单附件删除均已出现;但舌象始终是 64x64 的“舌象/查看”文字按钮(`diagnosis_drawer.py:2153-2183`),没有像基准那样用真实 URL 渲染 `el-image` 缩略图(`D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:52-69,370-379`)。 | +| 处方入口 | **CLOSED** | `diagnosis_state_empty_1024x640.png` 中按钮已经是 primary “开方”;实现:`src/doctor_workstation/ui/dialogs/diagnosis.py:1012-1031`;基准:`CaseRecordList.vue:1-38`。编辑器结构仍有 P1,见下节。 | +| 订单列表/偏移/详情 | **PARTIAL** | 列表、金额色、状态 tag、分页、详情入口均存在(`diagnosis.py:1056-1099,2980-3010`),并补了详情 PNG;但详情结构仍非基准共享 Drawer,偏移文案也未精确。 | +| 视频多回放 | **CLOSED(能力)/ PARTIAL(同型)** | `diagnosis_state_video_replay_1024x640.png` 同一记录显示主回放 + 2 个备用回放;实现去重并逐条建链接:`diagnosis.py:2899-2952`。 | +| 视频逐条上传 | **CLOSED** | `diagnosis_state_video_upload_action_1024x640.png` 显示行级“追加回放”;绑定精确 `call_record_id`:`diagnosis.py:2953-2967`,上传调用:`:2587-2613`。 | +| 视频播放器 | **CLOSED(能力)/ PARTIAL(同型)** | `diagnosis_state_video_player_820x560.png` 显示应用内播放面、播放/进度/时间/安全外开;实现:`src/doctor_workstation/ui/diagnosis_media.py:38-172`。但基准播放器内嵌在表格单元格且最大高 180 px:`RecordingPlaybackBlock.vue:1-30`、`RecordingVideoPlayer.vue:1-38,320-329`。 | +| Chat info/归档 | **CLOSED(主体)/ PARTIAL(图片)** | `diagnosis_state_chat_archive_1024x640.png` 已有完整 info、only_archived、同步/重载与左右气泡;实现:`diagnosis_drawer.py:2235-2360`。图片消息仍只创建“查看图片”链接(`:2329-2348`),基准直接渲染图片缩略图(`ImChatRecordPanel.vue:39-50,272-276`)。 | +| Shell | **CLOSED** | 两张 Shell PNG 均为 183/50/40;当前源码:`src/doctor_workstation/ui/shell.py:540,602,686`;基准:`D:/web/zyt/admin/src/config/setting.ts:5`、`D:/web/zyt/admin/src/styles/var.css:8`、`D:/web/zyt/admin/src/layout/default/components/header/multiple-tabs.vue:65`。 | + +## P0 / P1 / P2 + +### P0 + +**0 项。** 49 张图未发现核心信息被窗口边界硬裁、60% Drawer 尺寸失效、footer 随正文滚走、中文/图标缺字、二维码不可辨识或 1024/1440 壳层错位。 + +### P1-1 Notes 舌象不是实际缩略图 + +- 实图:`diagnosis_state_notes_actions_1024x640.png` 中舌象为蓝色文字卡“舌象 / 查看”,不是图像;报告文件 chip 和两个删除入口可见。 +- 当前源码明确只创建 `QPushButton("舌象\n查看")`,没有加载 URL 到 pixmap:`src/doctor_workstation/ui/diagnosis_drawer.py:2153-2183`。 +- 基准用 `el-image :src="getImageUrl(url)"`、64x64 cover 缩略图与叠加 CircleClose:`D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:52-69,366-379`。 +- 建议边界:保留当前安全打开与单附件删除合同,只把可安全下载/解码的图片异步渲染为 64x64 缩略图;失败时再回退为当前文字卡。 + +### P1-2 处方编辑器不是基准的 1200 px 连续 Drawer + +- 实图:`diagnosis_state_prescription_editor_920x780.png` 是 920x780 modal,按“患者与诊断 / 药材配方 / 剂型与用法 / 医师签名”拆为 4 个 tabs。 +- 当前结构:`src/doctor_workstation/ui/dialogs/prescription.py:1017-1037`;诊单入口调用该 dialog:`src/doctor_workstation/ui/dialogs/diagnosis.py:2533-2585`。 +- 基准入口直接打开 `tcm-prescription`,其根是 `size="1200px"` Drawer,并在同一滚动流中依次呈现患者信息、诊断信息、RP 药材工具栏/网格、用法和签名:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:416,744`;`D:/web/zyt/admin/src/components/tcm-prescription/index.vue:2-216,217-531`。 +- 当前 DTO/真实提交能力存在,本项是视觉结构 P1,不是功能缺失。 + +### P1-3 订单详情仍是精简 modal + +- 实图:`diagnosis_state_order_detail_640x540.png` 只有 hero、状态 tag 和 10 行键值信息。 +- 当前源码固定 `640x540`,用单个 `QFormLayout` 罗列患者、金额、医护、地址、物流、备注:`src/doctor_workstation/ui/dialogs/diagnosis.py:3060-3135`。 +- 基准 `PatientOrderList` 打开共享 readonly `PrescriptionOrderDetailDrawer`:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/PatientOrderList.vue:96-124`;共享组件宽 `80%`,readonly 仍保留金额概览、处方详情、未关联/关联收款、履约收货、物流轨迹与操作日志:`D:/web/zyt/admin/src/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue:13-16,154-197,200-545,551-824`。 +- 因结构和信息密度均明显不同,新增截图只能证明“有详情”,不能证明“同型”。 + +### P1-4 视频与聊天媒体形态仍非基准同型 + +- 视频:多地址、逐条追加和 QtMultimedia 播放能力均真实存在,但表格只显示文字链接,点击后打开 820x560 独立播放器(`diagnosis.py:2934-2978`;`diagnosis_media.py:38-116`)。基准在“录制回放”单元格内直接嵌入最大高 180 px 的播放器,并把其他地址放在其下:`CallRecordPanel.vue:17-34`、`RecordingPlaybackBlock.vue:1-30`、`RecordingVideoPlayer.vue:320-329`。 +- 聊天:文字/文件气泡和 info alert 已同型;图片仍为“查看图片”文字链接(`diagnosis_drawer.py:2329-2348`),基准为最大 `240x200` 的可预览图片(`ImChatRecordPanel.vue:39-50,272-276`)。 +- 两者均可安全使用,但尚未达到这轮明确要求的媒体视觉同型。 + +### P2-1 Daily 待办局部工具栏文案仍未精确 + +- `diagnosis_state_daily_lower_1024x640.png` 的 primary 按钮为“新增待办”,基准为“+ 新增待办”;当前待办卡内也没有基准同一工具栏上的局部“刷新”(全 Daily 顶部已有刷新)。 +- 当前:`diagnosis_drawer.py:1691-1702`;基准:`DiagnosisTodoList.vue:3-19`。 +- 这不再造成裁切或语义压平,故降为 P2。 + +### P2-2 订单偏移栏文案与解释层级未精确 + +- `diagnosis_state_order_offset_1024x640.png` 显示“全局就诊序号偏移 / 0 / 个序号 / 保存偏移”。 +- 基准是“复诊统计起始偏移”+ tooltip +“第 1 笔实单计为…”+“保存”:`PatientOrderList.vue:7-41`。 +- 当前:`src/doctor_workstation/ui/dialogs/diagnosis.py:1056-1083`。数值范围与保存入口成立,本项为文案/说明层级 P2。 + +## 尺寸与壳层精确证据 + +| 核对项 | 1024x640 | 1440x900 | 源码/基准 | +|---|---|---|---| +| edit/viewOnly Drawer 60% | panel 从 x=410 开始,宽 614;x=409 仍为 overlay | panel 从 x=576 开始,宽 864 | `diagnosis.py:1336-1340`;基准 `edit.vue:3-9` | +| appointment Drawer 60% | panel 从 x=410 开始,宽 614 | panel 从 x=576 开始,宽 864 | `appointment_drawer.py:1716-1719`;基准 `appointment.vue:2-6` | +| Shell sidebar | x=0..182 为侧栏,x=183 进入内容;抽样 x182=`#25292D`、x183=`#F6F6F6` | 同一 183 px 边界 | `shell.py:540-543`;基准 `setting.ts:5` | +| Shell topbar/tabs | x=200 处 y49 为 topbar 底边,y50 进入 tabs;y89 为 tabs 底边,y90 进入内容,即 50 + 40 | 同一分段 | `shell.py:602,686`;基准 `var.css:8`、`multiple-tabs.vue:65` | + +## 49 张 PNG 逐张判定 + +下列 PASS 只证明该张图实际入镜内容;不会拿截图外的滚动内容代替证据。 + +### 诊单列表与二维码(14) + +| PNG | 判定 | 目检结论 | +|---|---|---| +| `diagnosis_1024x640.png` | PASS | 窄屏筛选换行、内部横向溢出、右固定区与行语义完整,无硬裁切。 | +| `diagnosis_1440x900.png` | PASS | 宽屏列密度、挂号复合单元格、斑马/业务色与固定操作区成立。 | +| `diagnosis_advanced_filters_1280x800.png` | PASS | 高级筛选完整展开,日期范围、控件顺序与重置可见。 | +| `diagnosis_double_appointment_cancel_1280x800.png` | PASS | 同诊单两条挂号各有取消入口,虚线层级正常。 | +| `diagnosis_empty_1280x800.png` | PASS | 表头、内部空态、横向滚动与 0 条分页均保留。 | +| `diagnosis_error_1280x800.png` | PASS | 错误态居中可读,无旧数据穿透。 | +| `diagnosis_focus_1280x800.png` | PASS | 搜索框 focus ring 清楚且不挤压布局。 | +| `diagnosis_full_menu_1280x800.png` | PASS | 8 项、8 个图标、删除分隔/danger 与精确“二维码”全部可见。 | +| `diagnosis_horizontal_scroll_1024x640.png` | PASS | 主表横移时右 460 px 固定区不动,阴影连续。 | +| `diagnosis_hover_warning_1280x800.png` | PASS | warning 行 hover 渐变、3 px 左条和固定区同步。 | +| `diagnosis_loading_1280x800.png` | PASS | 表内真实圆弧 spinner,内容淡化,无字符 spinner。 | +| `diagnosis_pending_assign_1280x800.png` | PASS | 待分配 chip、月份和宽搜索在同一流式区。 | +| `diagnosis_permissions_cropped_1280x800.png` | PASS | 权限裁掉批量工具栏和敏感动作,仅留查看。 | +| `diagnosis_order_qrcode_1280x800.png` | PASS | 订单号、可辨识 QR、成功态、只读 URL、打开/重试/关闭均完整。 | + +### edit / viewOnly / readonly(6) + +| PNG | 判定 | 目检结论 | +|---|---|---| +| `diagnosis_edit_1024x640.png` | PASS | 60% Drawer、2/3 列语义表单、横向 tabs、独立滚动体与固定 footer 成立。 | +| `diagnosis_edit_1440x900.png` | PASS | 864 px Drawer、宽屏字段比例和 footer 稳定。 | +| `diagnosis_viewonly_1024x640.png` | PASS | 60% 只读 Drawer、待配药警告、disabled 表单与仅关闭 footer 成立。 | +| `diagnosis_viewonly_1440x900.png` | PASS | 宽屏只读栅格、全 tabs 与固定 footer 成立。 | +| `diagnosis_readonly_1024x640.png` | PASS | 独立只读页、hero、病例卡和“客服:赵医助”不拆字。 | +| `diagnosis_readonly_1440x900.png` | PASS | 4 列病例密度、异常指标层级及长页面滚动成立。 | + +### 详情状态与子流程(19) + +| PNG | 判定 | 目检结论 | +|---|---|---| +| `diagnosis_state_loading_1024x640.png` | PASS | 圆弧 spinner 与“正在加载诊单...”完整,无旧 seed 穿透。 | +| `diagnosis_state_error_1024x640.png` | PASS | danger banner、重试、空表单和禁用保存完整。 | +| `diagnosis_state_empty_1024x640.png` | PASS | 处方空态与 primary “开方”已对齐;编辑器另见 PARTIAL。 | +| `diagnosis_state_permission_1024x640.png` | PASS | 仅保留获权 tabs,手机号/身份证掩码,footer 仅关闭。 | +| `diagnosis_state_daily_1024x640.png` | PASS | Daily 动作全部可见;刷新换行而未裁切,range/primary/secondary 层级清楚。 | +| `diagnosis_state_daily_lower_1024x640.png` | PARTIAL(P2) | 趋势、待办 filters、状态 pill 与 primary 新增成立;缺“+”及待办局部刷新。 | +| `diagnosis_state_daily_blood_edit_650x620.png` | PASS | 血糖/血压编辑字段、单位、必填说明、备注和固定动作完整。 | +| `diagnosis_state_focus_1024x640.png` | PASS | 姓名 focus ring、紧凑 close 和固定 footer 清楚。 | +| `diagnosis_state_save_loading_1024x640.png` | PASS | 按钮内圆弧 spinner 与“正在保存”完整。 | +| `diagnosis_state_save_success_1024x640.png` | PASS | 绿色勾与“保存成功”完整。 | +| `diagnosis_state_save_failure_1024x640.png` | PASS | 红色叉与“保存失败”完整。 | +| `diagnosis_state_notes_actions_1024x640.png` | PARTIAL(P1) | 动作、chip 和单附件删除存在;舌象仍为文字按钮而非真实缩略图。 | +| `diagnosis_state_prescription_editor_920x780.png` | PARTIAL(P1) | 完整可用的 4-tab 编辑器已入镜;但不是基准 1200 px 连续 Drawer。 | +| `diagnosis_state_order_offset_1024x640.png` | PARTIAL(P2) | 数值、保存、订单表与分页成立;偏移文案/tooltip/解释层级未精确。 | +| `diagnosis_state_order_detail_640x540.png` | PARTIAL(P1) | 详情 modal 可用;缺基准 80% Drawer 的金额/支付/履约/物流/日志结构。 | +| `diagnosis_state_video_replay_1024x640.png` | PARTIAL(P1) | 主回放 + 2 个备用回放成立;仍是链接列表,不是表内播放器。 | +| `diagnosis_state_video_upload_action_1024x640.png` | PARTIAL(P1) | 指定行“追加回放”成立;媒体单元格结构仍未同型。 | +| `diagnosis_state_video_player_820x560.png` | PARTIAL(P1) | 应用内播放器和安全外开成立;形态是独立 dialog,不是 180 px 表内播放器。 | +| `diagnosis_state_chat_archive_1024x640.png` | PARTIAL(P1) | 完整 info、归档、同步/重载及左右气泡成立;图片消息退化为文字链接。 | + +### 预约 Drawer(8) + +| PNG | 判定 | 目检结论 | +|---|---|---| +| `appointment_drawer_1024x640.png` | PASS | 60% Drawer、body 滚动、时段状态和固定 footer 无硬裁切。 | +| `appointment_drawer_1440x900.png` | PASS | 医生 radio、四个日期按钮、时段网格、备注和 footer 对齐。 | +| `appointment_drawer_empty_doctors_1440x900.png` | PASS | warning、空态插图、暂无医生与禁用确认完整。 | +| `appointment_drawer_empty_roster_1440x900.png` | PASS | 无排班 warning、空态和“该医生暂无排班”可读。 | +| `appointment_drawer_error_1440x900.png` | PASS | 号源错误 banner、刷新入口和空态并存。 | +| `appointment_drawer_keyboard_focus_1440x900.png` | PASS | 日期按钮蓝色键盘 focus 边界清楚。 | +| `appointment_drawer_loading_1440x900.png` | PASS | 初始加载 overlay、圆弧 spinner、文案与禁用确认完整。 | +| `appointment_drawer_refreshing_1440x900.png` | PASS | 刷新 overlay、圆弧 spinner、文案与禁用确认完整。 | + +### Shell(2) + +| PNG | 判定 | 目检结论 | +|---|---|---| +| `diagnosis_shell_1024x640.png` | PASS | 183/50/40、深色侧栏、tabs、固定列与分页均成立;无 glyph 方框。 | +| `diagnosis_shell_1440x900.png` | PASS | 同一壳层比例在宽屏保持,箭头/刷新/全屏/关闭图形和中文完整。 | + +## 发布判定 + +- **可以确认无 P0,且此前 Daily、更多菜单、Shell 的明确 OPEN 已关闭。** +- **不能给视觉同型 PASS。** 需至少关闭 P1-1 至 P1-4,并补录对应终态 PNG;P2 可在同一轮顺手精确。 +- 本轮最终:**PARTIAL(40 PASS / 9 PARTIAL,共检查 49 张 PNG)**。 diff --git a/app/research/release_packaging_audit.md b/app/research/release_packaging_audit.md new file mode 100644 index 000000000..15d4d8ae2 --- /dev/null +++ b/app/research/release_packaging_audit.md @@ -0,0 +1,329 @@ +# DoctorWorkstation 发布打包就绪审计 + +审计日期:2026-08-11(Asia/Shanghai) +审计主机:Windows 10/11 工作区 `D:\web\zyt\app` +审计边界:只检查入口、脚本、PyInstaller spec、冻结依赖、伴随页、文档和既有产物;未执行完整 PyInstaller,未改业务/打包源码。 + +## 结论 + +**当前状态:不可直接发布既有 `dist`,也不应立即从当前 Git 状态制作正式发布包。** + +构建流水线的主体是完整的:Windows/macOS 均有根目录中英文入口,能从锁文件准备 Python/Node 依赖,能构建 TRTC Web companion,PyInstaller 使用 onedir/.app,构建后会检查 QtWebEngine 与 companion,并在隔离目录中运行冻结入口 `--smoke-test`。Windows 既有 ZIP 的命名和 SHA-256 也正确。 + +但发布前有三个 P0:macOS 入口在 Git 中没有可执行位;新增 QtMultimedia 业务文件仍未纳入版本控制且工作树存在大量未提交发布输入;当前 Windows 成品早于新增媒体代码,缺少 QtMultimedia Python 模块、Widgets 库及媒体插件。另有 P1:当前冻结烟测会在媒体能力缺失时仍通过,生产级签名/公证也尚未进入一键流水线。 + +**macOS 特别说明:PyInstaller 不能从 Windows 交叉产出或验收 `.app`。本审计只能检查 macOS 脚本和合同;最终 `.app`、原生 Qt/媒体插件、签名、摄像头/麦克风权限及 TRTC 实通必须在原生 macOS(对应 arm64 或 x86_64)上构建和验证。** + +严重度定义: + +- P0:在任何正式构建/分发前必须解决。 +- P1:可做内部技术构建,但生产发布前必须解决或由发布负责人书面接受风险。 +- P2:不阻断本次内部构建,建议纳入发布工程改进。 + +## 构建前阻断 + +### P0-1:macOS Finder/命令行入口在 Git 索引中全部为 `100644` + +`git ls-files --stage -- '*.command' 'scripts/*.sh'` 显示以下文件均为 `100644`,不是 `100755`: + +- `run_macos.command` +- `package_macos.command` +- `一键运行.command` +- `一键打包.command` +- `scripts/build_macos.sh` +- `scripts/package_macos.sh` +- `scripts/run_macos.sh` +- `scripts/macos_helpers.sh` +- `scripts/check_macos_entrypoints.sh` + +影响:新的 macOS clone 中,Finder 双击 `.command` 和 README 中的 `./scripts/build_macos.sh` 不能满足“一键运行/一键打包”;`scripts/check_macos_entrypoints.sh` 自己也要求这些入口 `-x`。Windows/NTFS 下 Git Bash 把文件视为可执行,因此当前合同检查通过并不能覆盖 Git 模式错误。 + +发布前处理: + +```powershell +git update-index --chmod=+x run_macos.command package_macos.command ` + "一键运行.command" "一键打包.command" ` + scripts/build_macos.sh scripts/package_macos.sh scripts/run_macos.sh ` + scripts/macos_helpers.sh scripts/check_macos_entrypoints.sh +git ls-files --stage -- '*.command' 'scripts/*.sh' +``` + +第二条命令的每一行都必须以 `100755` 开头,并将模式变化提交到发布 commit。 + +### P0-2:当前发布输入不在可复现的 Git 状态 + +审计时工作树含多处已修改和大量未跟踪代码;与本次媒体发布直接相关的 `src/doctor_workstation/ui/diagnosis_media.py` 及对应测试仍是未跟踪文件。若从当前目录构建,PyInstaller 会把这些本地文件打入包;若从当前 HEAD/干净 CI clone 构建,则不会包含它们。两者结果不一致。 + +发布前必须由负责人审阅 `git status --short`,只纳入确认属于本次版本的源码、测试、脚本和文档,并从确定的 commit/tag 构建。不要用 `git add -A` 掩盖当前大量测试产物和临时目录。 + +### P0-3:既有 Windows ZIP/onedir 早于新增 QtMultimedia 代码,禁止分发 + +证据: + +- `dist/DoctorWorkstation/DoctorWorkstation.exe`:2026-08-10 17:07。 +- `dist/DoctorWorkstation-Windows-x64-0.1.0.zip`:2026-08-10 17:08。 +- `src/doctor_workstation/ui/diagnosis_media.py`:2026-08-11 01:05。 +- 既有成品只有 `Qt6Multimedia.dll`/QML 辅助文件,缺少: + - `PySide6/QtMultimedia.pyd` + - `PySide6/QtMultimediaWidgets.pyd` + - `PySide6/Qt6MultimediaWidgets.dll` + - `PySide6/plugins/multimedia/ffmpegmediaplugin.dll` + - `PySide6/plugins/multimedia/windowsmediaplugin.dll` + +既有 ZIP 也缺少同一组文件。它的 SHA-256 文件本身有效,但只证明旧 ZIP 未损坏,不证明它对应当前源码。 + +此外,根目录“一键运行”优先启动既有 `dist`,所以当前直接双击会运行旧代码,不会验证当前工作树。正式验收前必须完成干净重建,并以新 ZIP 的时间戳、文件清单和哈希替换旧证据。 + +## P1:生产发布前必须闭环 + +### P1-1:QtMultimedia 依赖可用,但冻结收集没有独立门禁 + +通过项: + +- `pyproject.toml` 依赖 `PySide6>=6.8.2,<7`;`uv.lock` 当前锁定 PySide6/PySide6 Addons/Essentials 6.11.1,Addons 提供 QtMultimedia。 +- 当前 `.venv` 和 `.venv-build` 均有 `QtMultimedia.pyd`、`QtMultimediaWidgets.pyd`、`Qt6MultimediaWidgets.dll` 以及 Windows `plugins/multimedia` 下的 FFmpeg/Windows Media 插件。 +- 当前 PyInstaller 6.22 的官方 `hook-PySide6.QtMultimedia.py` 会追加 `PySide6.QtMultimediaWidgets`,Qt 模块映射会收集 Qt 6 的 `multimedia` 插件。当前源码中的静态 import 理论上会触发该 hook。 + +缺口: + +- `packaging/doctor_workstation.spec` 的显式 hidden imports 只有 QtWebEngine 相关模块,没有显式列出 `PySide6.QtMultimedia`/`PySide6.QtMultimediaWidgets`。 +- Windows/macOS 构建脚本只检查 QtWebEngine helper、Chromium `.pak` 和 `video_companion_dist/index.html`,不检查 QtMultimedia Python 扩展、Widgets 库或媒体后端插件。 +- `diagnosis_media.py` 捕获 `ImportError` 并降级到外部打开,因此媒体模块缺失不会让应用启动失败。 +- 实测旧冻结包缺少上述模块/插件,但隔离执行 `DoctorWorkstation.exe --smoke-test` 仍返回 0。这证明现有烟测不是 QtMultimedia 发布门禁。 + +发布前至少应做到以下二者之一,建议二者都做: + +1. 在 spec 中显式纳入 `PySide6.QtMultimedia` 和 `PySide6.QtMultimediaWidgets`,继续使用 PyInstaller 官方 hooks 收集平台插件。 +2. 在两个构建脚本中增加平台化的成品断言;Windows 检查两个 `.pyd`、`Qt6MultimediaWidgets.dll` 和 `plugins/multimedia`,macOS 检查两个 Python 扩展、Qt multimedia framework/dylib 和 `plugins/multimedia`。 + +最终验收还要用一个获准分发的 MP4(以及产品真实需要时的 HLS)实际播放,不能只看文件存在。 + +### P1-2:`--smoke-test` 只证明冻结启动,不证明 WebRTC/媒体可用 + +当前烟测的隔离、30 秒超时、非零退出码和未捕获异常日志检查设计良好;Windows/macOS 都不会访问真实后端。它会启动 composition root,并在约 1.2 秒后退出。 + +它不执行以下能力: + +- 创建 `QMediaPlayer` 并确认媒体 backend 被发现; +- 播放音视频并检查解码/渲染; +- 打开 `QWebEngineView` 的 companion 主文档; +- 执行 `TRTC.isSupported()`、请求摄像头/麦克风或加入真实测试房间; +- 验证设备插拔、休眠恢复、弱网、屏幕共享或录播 codec。 + +因此冻结烟测应保留为启动门禁,同时新增“冻结媒体插件合同”和原生机器上的手工/自动媒体准入。README 已提示首发要做真实 TRTC/设备验收,这项不能被 `--smoke-test` 替代。 + +### P1-3:生产分发签名链未纳入一键打包 + +Windows:当前流水线生成 ZIP 与 SHA-256,但没有 Authenticode 签名步骤。SHA-256 提供完整性,不提供发布者身份;对外分发前应在压缩前签名并验证最终可执行文件。 + +macOS:spec 接受可选的 `MACOS_CODESIGN_IDENTITY`,构建脚本只运行 `codesign --verify --deep --strict`。未提供 Developer ID 时,PyInstaller 的临时/ad-hoc 签名也可能通过这一完整性检查;脚本不执行 notarization/stapling,也没有验证嵌套 `QtWebEngineProcess.app` 的实际 entitlements/运行能力。当前 README 仅把签名、公证作为人工说明。 + +若本次只是内部 QA 包,应明确标注“未签名/未公证内部构建”。若是生产或院外分发,应先把 Windows 签名,以及 macOS Developer ID → nested code 检查 → notarization → stapling → 重新生成最终 ZIP/哈希,纳入发布流水线。注意:staple 会改变 `.app`,所以不能继续分发公证前生成的 ZIP。 + +### P1-4:macOS 成品尚无原生验证证据 + +Windows 上的 `bash -n` 和合同脚本只能证明 shell 文本结构。以下证据必须在 macOS 构建机补齐: + +- `.app` 原生架构与最低系统版本; +- `QtWebEngineProcess.app`、Qt framework、QtMultimedia 插件实际存在并签名有效; +- `NSCameraUsageDescription`/`NSMicrophoneUsageDescription` 能触发正确权限流程; +- companion 的本地 `file:`/qrc 资源、WebChannel 和 TRTC 能在打包态工作; +- arm64/x64 输出名与实际二进制架构一致; +- 最终 ZIP 的 Gatekeeper/notary 结果。 + +这不是 Windows 端可以规避的检查;必须安排一台原生 macOS runner/构建机。 + +## P2:建议改进 + +1. 仓库没有 `.gitattributes`,而本机 `core.autocrlf=true`。当前 macOS shell 文件实际是无 BOM 的 LF,`bash -n` 通过,但建议用 `.gitattributes` 固定 `*.sh`/`*.command` 为 LF,并固定批处理/PowerShell 的预期行尾。 +2. Windows 使用 `uv sync --frozen`,macOS 使用 `uv sync --locked`。当前 `uv.lock` 包含所需依赖,但 `--frozen` 不承担 lock 新鲜度检查;发布前可增加 `uv lock --check`,或统一为能拒绝 pyproject/lock 漂移的策略。 +3. `packaging/windows/version_info.txt` 的 `0.1.0.0` 与 `pyproject.toml` 的 `0.1.0` 当前一致,但它是手工同步;脚本没有发布前一致性检查。升级版本时可能出现 ZIP 名与 EXE 版本资源不一致。 +4. README 顶部的一键入口描述正确,但“测试与打包”段落调用 `build_*`,只产生 onedir/.app,不产生版本化 ZIP/哈希。建议明确区分“构建”与“发布打包”,并把 `package_*`/根入口列为正式发布命令。 +5. `.env.example` 对 demo/生产、SSL、TRTC secret 禁止项说明清楚,未包含长期凭据;但发布 ZIP 不包含 README 或 `.env.example`,README 也没有明确冻结包从哪里查找 `.env`。若企业部署依赖环境文件,应说明冻结态放置/注入方式;若只允许登录页保存非敏感配置,也应明确说明无需随包放 `.env`。 +6. Windows 用固定 `dist/SHA256SUMS.txt`,macOS 用相邻的 `.sha256`;两者都可校验,但发布自动化和用户说明可以统一。`dist` 还可能保留旧版本 ZIP,根打包入口成功后打开整个目录,建议在发布清单中明确唯一应交付的文件。 +7. `Build_DoctorWorkstation.bat -ValidateOnly` 会打印 `Package ready in: ...`,即使它只做了入口预检;不影响退出码,但容易被误认作已生成新包。 +8. spec 没有设置 Windows EXE/macOS bundle 的原生应用图标;窗口内 SVG 正常,但系统文件/Finder 图标仍是默认值。若品牌发布有要求,应在最终签名前补齐 `.ico`/`.icns`。 +9. AGENTS.md 指向的 `.trellis/workflow.md` 与 `.trellis/spec/` 在本工作区不存在;本次无法执行项目内 Trellis 规范审查。这不影响二进制启动,但属于发布治理缺口。 + +## 已通过的检查 + +| 检查项 | 结果 | 备注 | +|---|---:|---| +| Windows 根英文入口 `Build_DoctorWorkstation.bat -ValidateOnly` | PASS | Node/uv/必需文件预检通过 | +| Windows 根英文入口 `Run_DoctorWorkstation.bat -ValidateOnly` | PASS | 当前命中既有冻结 EXE;不代表源码新鲜 | +| Windows 两个中文别名 `-ValidateOnly` | PASS | 正确转发参数和退出码 | +| PowerShell parser | PASS | `build_windows.ps1`、`package_windows.ps1`、`run_windows.ps1` 均无解析错误 | +| macOS `bash -n` | PASS | helpers、run/package/build/check 与四个 `.command` 均通过 | +| `scripts/check_macos_entrypoints.sh` | PASS(有限) | Git Bash 下合同通过;不能覆盖 Git `100644` 问题 | +| 入口/冻结异常合同 pytest | PASS | `tests/test_one_click_entrypoints.py` + `tests/test_entrypoint.py`,4 passed | +| 当前 source QtMultimedia import | PASS | `QMediaPlayer`、`QAudioOutput`、`QVideoWidget` 均可导入;两个 Windows multimedia plugin 可见 | +| TRTC companion 工作树 | PASS | `video_companion` 无 Git 变更;dist 新于源码 | +| companion 相对资源 | PASS | `base: './'`;index 引用的 JS/CSS 均存在 | +| companion 打包副本 | PASS(旧包) | source dist 与旧 frozen 副本的 `index.html` SHA-256 相同 | +| spec companion/resources 映射 | PASS | `video_companion/dist -> video_companion_dist`、`resources -> resources` 与运行时查找一致 | +| QtWebEngine 收集策略 | PASS | 显式 imports 触发官方 hooks,构建脚本检查 helper 与 `.pak` | +| 冻结启动烟测机制 | PASS(范围有限) | 旧 EXE 在隔离 demo/offscreen 环境中 `--smoke-test` 退出 0;同时暴露媒体缺口未被检测 | +| Windows 输出名 | PASS(旧包) | `DoctorWorkstation-Windows-x64-0.1.0.zip` | +| Windows SHA-256 | PASS(旧包) | `SHA256SUMS.txt` 与实际 ZIP hash 一致 | +| 版本号一致性 | PASS | pyproject `0.1.0`;Windows resource `0.1.0.0` | + +## 入口、spec 和输出合同摘要 + +### Windows + +- 根入口:中文别名 → 英文 `.bat` → `scripts/package_windows.ps1`/`run_windows.ps1`。 +- 运行:优先 `dist/DoctorWorkstation/DoctorWorkstation.exe`,否则使用 `.venv` 或 `uv sync --frozen` 后运行源码。 +- 发布打包:Node 20+ → `uv sync --frozen --extra build` → `npm ci` → companion build → PyInstaller `--clean` → 静态成品检查 → 隔离冻结烟测 → ZIP → SHA-256。 +- 产物: + - onedir:`dist/DoctorWorkstation/` + - 启动器:`dist/Start_DoctorWorkstation.bat` + - ZIP:`dist/DoctorWorkstation-Windows-x64-.zip` + - 校验:`dist/SHA256SUMS.txt` + +### macOS + +- 根入口:四个 `.command` 解析自身目录后用 `/bin/bash` 调用 `scripts/run_macos.sh` 或 `scripts/package_macos.sh`。 +- 运行:优先 `/usr/bin/open dist/DoctorWorkstation.app`,否则确保 uv、同步锁定依赖并运行源码。 +- 发布打包:原生 Darwin 检查 → uv/Node 20+(必要时下载)→ `npm ci` → companion build → PyInstaller `.app` → QtWebEngine/companion 检查 → codesign verify → 隔离冻结烟测 → `ditto` ZIP → SHA-256。 +- 产物: + - bundle:`dist/DoctorWorkstation.app` + - ZIP:`dist/DoctorWorkstation-macOS-{arm64|x64}-.zip` + - 校验:同路径 `.sha256` + +## 主代理最终构建与验证命令 + +以下命令应在 P0 修复、发布输入已审阅并固定到 commit/tag 后执行。不要把当前旧 `dist` 当成成功证据。 + +### 1. 通用发布前门禁 + +```powershell +git status --short +git diff --check +git ls-files --stage -- '*.command' 'scripts/*.sh' +``` + +要求:无意外修改/未跟踪发布输入;macOS 操作文件均为 `100755`。研究报告或明确允许的生成物可以存在,但不得混入发布源清单。 + +### 2. Windows:在 Windows x64 原生主机执行 + +轻量预检: + +```powershell +.\Build_DoctorWorkstation.bat -ValidateOnly +.\.venv\Scripts\python.exe -m pytest -p no:cacheprovider ` + tests\test_one_click_entrypoints.py tests\test_entrypoint.py +``` + +正式构建(该入口内部已经执行 locked dependency preparation、companion build、PyInstaller、冻结烟测、ZIP 和哈希): + +```powershell +.\Build_DoctorWorkstation.bat +``` + +构建后强制文件门禁: + +```powershell +$artifact = Resolve-Path 'dist\DoctorWorkstation' +$qt = Join-Path $artifact '_internal\PySide6' +$required = @( + (Join-Path $artifact 'DoctorWorkstation.exe'), + (Join-Path $qt 'QtMultimedia.pyd'), + (Join-Path $qt 'QtMultimediaWidgets.pyd'), + (Join-Path $qt 'Qt6Multimedia.dll'), + (Join-Path $qt 'Qt6MultimediaWidgets.dll'), + (Join-Path $qt 'plugins\multimedia\ffmpegmediaplugin.dll'), + (Join-Path $qt 'plugins\multimedia\windowsmediaplugin.dll'), + (Join-Path $artifact '_internal\video_companion_dist\index.html') +) +$missing = $required | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) } +if ($missing) { throw "Missing frozen release files:`n$($missing -join "`n")" } +if (-not (Get-ChildItem -LiteralPath $artifact -Recurse -File ` + -Filter 'QtWebEngineProcess.exe' | Select-Object -First 1)) { + throw 'QtWebEngineProcess.exe missing' +} +if (-not (Get-ChildItem -LiteralPath $artifact -Recurse -File ` + -Filter 'qtwebengine_resources*.pak' | Select-Object -First 1)) { + throw 'QtWebEngine resources missing' +} +``` + +版本化 ZIP 与哈希复核: + +```powershell +$version = ([regex]::Match( + [IO.File]::ReadAllText((Resolve-Path 'pyproject.toml')), + '(?m)^version\s*=\s*"([^"]+)"' +)).Groups[1].Value +$zip = Resolve-Path "dist\DoctorWorkstation-Windows-x64-$version.zip" +$actual = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash +$expected = ((Get-Content -Raw -Encoding UTF8 'dist\SHA256SUMS.txt').Trim() -split '\s+')[0] +if ($actual -ne $expected) { throw 'Windows release checksum mismatch' } +Write-Host "Verified: $zip`nSHA-256: $actual" +``` + +最后在一台无开发环境的 Windows 10/11 x64 机器解压 ZIP,双击 `Start_DoctorWorkstation.bat`,完成登录/demo、内嵌 companion、获准 MP4/HLS 回放、摄像头/麦克风与一次真实测试房间验收。若为生产外发,必须先完成 Authenticode,再重新生成 ZIP 和 SHA-256。 + +### 3. macOS:只能在原生 macOS 主机执行 + +模式、语法和入口合同: + +```bash +git ls-files --stage -- '*.command' 'scripts/*.sh' +test "$(git ls-files --stage -- '*.command' 'scripts/*.sh' | \ + awk '$1 != "100755" { bad++ } END { print bad + 0 }')" = 0 +bash -n scripts/macos_helpers.sh scripts/run_macos.sh \ + scripts/package_macos.sh scripts/build_macos.sh \ + scripts/check_macos_entrypoints.sh \ + run_macos.command package_macos.command \ + 一键运行.command 一键打包.command +CI=1 DOCTOR_NONINTERACTIVE=1 bash scripts/check_macos_entrypoints.sh +``` + +内部 QA 构建可直接运行;生产构建应先提供真实 Developer ID: + +```bash +export MACOS_CODESIGN_IDENTITY='Developer ID Application: <组织名称> ()' +./一键打包.command +``` + +构建后检查: + +```bash +app='dist/DoctorWorkstation.app' +test -x "$app/Contents/MacOS/DoctorWorkstation" +test -n "$(find "$app" -type f \( -name 'QtMultimedia.so' -o -name 'QtMultimedia.*.so' \) -print -quit)" +test -n "$(find "$app" -type f \( -name 'QtMultimediaWidgets.so' -o -name 'QtMultimediaWidgets.*.so' \) -print -quit)" +test -n "$(find "$app" -type f -path '*/plugins/multimedia/*' -print -quit)" +test -n "$(find "$app" -type f -name 'QtWebEngineProcess' -print -quit)" +test -n "$(find "$app" -type f -name 'qtwebengine_resources*.pak' -print -quit)" +test -n "$(find "$app" -type f -path '*video_companion_dist/index.html' -print -quit)" +codesign --verify --deep --strict --verbose=2 "$app" +codesign -d --entitlements :- "$app/Contents/MacOS/DoctorWorkstation" +file "$app/Contents/MacOS/DoctorWorkstation" +``` + +版本化 ZIP 与哈希复核: + +```bash +version="$(awk -F '"' '/^version = "/ { print $2; exit }' pyproject.toml)" +case "$(uname -m)" in + arm64) arch='arm64' ;; + x86_64) arch='x64' ;; + *) echo 'Unsupported macOS architecture' >&2; exit 1 ;; +esac +zip="DoctorWorkstation-macOS-$arch-$version.zip" +(cd dist && shasum -a 256 -c "$zip.sha256") +``` + +生产外发还需用 `xcrun notarytool` 提交、等待成功并 `xcrun stapler staple`。staple 后要用 `ditto --keepParent` 重新生成最终 ZIP 并重算 `.sha256`,再以 `spctl --assess --type execute --verbose=4` 和一台干净 macOS 13+ 机器验收。最终必须实测摄像头、麦克风、扬声器切换、QtMultimedia 回放、QtWebEngine companion 和 TRTC 测试房间。 + +## 发布准入判定 + +满足以下条件才可把状态改为“可发布”: + +1. macOS 操作文件的 Git 模式全部为 `100755`。 +2. intended release source 已纳入确定 commit/tag,构建工作树无意外输入。 +3. Windows 从该 commit 完整重建;新 ZIP 含 QtMultimedia Python/Widgets/插件、QtWebEngine 和当前 companion,哈希通过。 +4. 原生 macOS 从同一 commit 完整重建;`.app` 含对应媒体/QtWebEngine 组件,架构、签名、权限、ZIP 哈希通过。 +5. 两个平台冻结 `--smoke-test` 通过,且独立媒体/companion/TRTC 验收通过。 +6. 若为生产外发,Windows Authenticode 与 macOS Developer ID/notarization/stapling 均在最终归档前完成。 diff --git a/app/research/release_packaging_final_audit.md b/app/research/release_packaging_final_audit.md new file mode 100644 index 000000000..fcd6b6633 --- /dev/null +++ b/app/research/release_packaging_final_audit.md @@ -0,0 +1,230 @@ +# 发布打包最终只读复审 + +日期:2026-08-11(Asia/Shanghai) +目标:判断是否可以**从当前工作树**重建 DoctorWorkstation 发布包。 +边界:未执行完整 PyInstaller,未修改应用/打包源码;仅新增本复审报告。 + +## 最终结论 + +**结论:当前工作树已具备启动原生完整重建的条件,未发现构建前 packaging 代码级 P0。** + +- Windows 当前工作树可进入 `Build_DoctorWorkstation.bat` 完整重建;根发布入口的 `-ValidateOnly` 已通过。 +- macOS 的 9 个操作入口在 Git index 中现已全部为 `100755`,入口合同会同时检查文件系统 `-x` 和 Git index mode,不再被 Windows/NTFS 的可执行位模拟掩盖。 +- spec 已显式收集 `PySide6.QtMultimedia`/`QtMultimediaWidgets`,并安装独立 PyInstaller runtime hook。 +- Windows/macOS build 均先检查冻结媒体文件,再依次执行 `--media-smoke-test` 与 `--smoke-test`;package 仅在 build 成功后归档和计算哈希。 +- README、packaging README 与 `.env.example` 已说明 build/package 区别、双媒体门禁、macOS mode 以及 `.env` 不进入发布包的部署策略。 + +本结论只表示“可以开始原生重建”,不表示两个平台产物已经生成或可以立即对外发布。旧 `dist` 仍是修复前成品,必须由新构建替换;macOS `.app` 仍只能在原生 macOS 上产出和验收。 + +## P0:无 + +按本轮定义——是否能从**当前本地工作树**发起完整重建——未发现 P0。 + +特别澄清:`src/doctor_workstation/ui/diagnosis_media.py`、`packaging/runtime_media_smoke.py`、`tests/test_packaging_media_gate.py` 等文件目前未跟踪,但它们真实存在于当前工作树,spec/测试/发布入口都能读取,因此**不是本地工作树构建阻断**。它们的未提交状态属于跨机和后续复现风险,列为 P1,而不是本轮 P0。 + +## P1:完整发布前仍须完成 + +### P1-1:必须运行新的原生完整构建,旧 `dist` 不得作为验收证据 + +当前 `dist/DoctorWorkstation` 和版本 ZIP 早于 QtMultimedia 修复,仍缺少新媒体模块/插件。根“一键运行”会优先启动这个旧 EXE,所以完整重建前不要用它判断当前代码。 + +Windows 必须重新运行: + +```powershell +.\Build_DoctorWorkstation.bat +``` + +只有控制台依次报告以下门禁通过,且随后生成新 ZIP/哈希,才算 Windows 重建成功: + +1. QtWebEngine helper/resources 与 TRTC companion 文件检查; +2. `Frozen Qt multimedia file gate passed`; +3. `Frozen Qt multimedia smoke gate passed (--media-smoke-test, ...)`; +4. `Frozen application entry smoke gate passed (--smoke-test, ...)`; +5. `Windows package complete`。 + +本复审按约束未运行完整 PyInstaller,因此不能提前宣称这些冻结态门禁已经在新产物上通过。 + +### P1-2:macOS 必须在原生目标架构主机完成构建 + +Windows 只能验证 shell 语法和合同,不能交叉产生 `.app`、macOS framework、媒体插件或有效签名。必须在 arm64 或 x86_64 macOS 构建机执行: + +```bash +CI=1 DOCTOR_NONINTERACTIVE=1 bash scripts/check_macos_entrypoints.sh +./一键打包.command +``` + +成功证据必须包括媒体文件 gate、两个冻结 smoke gate、`codesign --verify`、实际架构、版本 ZIP 与 `.sha256`。这是一项平台执行门禁,不是当前脚本缺陷。 + +### P1-3:当前工作树尚未形成可跨机复现的发布 commit/tag + +当前本机直接构建会包含未跟踪媒体源码和 runtime hook,因此可以构建;但新的 clone、CI runner 或另一台 macOS 机器不会从当前 HEAD 获得这些文件。当前还有多处修改、staged mode 变化和大量测试产物。 + +因此: + +- 内部本机技术构建可以现在开始; +- 跨机 macOS 构建或正式发布前,发布负责人仍应审阅 intended source,提交必要文件及 9 个 mode 变化,并从确定的 commit/tag 构建; +- 不应使用 `git add -A` 把 `.pytest-tmp-*`、`artifacts/` 等临时产物一并纳入。 + +这是正式发布的可复现性 P1,但按根任务要求不作为“当前工作树本地重建”P0。 + +### P1-4:生产签名、公证链仍是发布策略门禁 + +- Windows package 生成 ZIP 与 SHA-256,但没有集成 Authenticode。 +- macOS 接受可选 `MACOS_CODESIGN_IDENTITY` 并运行 `codesign --verify`,但不自动 notarize/staple;ad-hoc 签名也可能通过完整性验证。 + +内部 QA 包可以明确标记为内部构建。生产/院外分发则必须在归档前完成 Windows 签名;macOS 必须完成 Developer ID、嵌套 QtWebEngine helper 检查、notarization 和 stapling。staple 会改变 `.app`,之后要重新生成最终 ZIP 与 SHA-256。 + +### P1-5:媒体 gate 不替代真实播放和 TRTC 准入 + +新 `--media-smoke-test` 已真实创建 Qt 媒体对象并检查 decoder backend,但没有加载具体 MP4/HLS,也不请求摄像头/麦克风或加入 TRTC 房间。正式验收仍需在最终冻结包上覆盖: + +- 获准分发的 MP4,产品要求时再覆盖 HLS; +- 画面渲染与音频输出; +- QtWebEngine companion/WebChannel; +- 摄像头、麦克风、扬声器切换; +- TRTC 测试房间、弱网、设备插拔和休眠恢复。 + +## P2:建议改进 + +1. 仓库仍无 `.gitattributes`,本机 `core.autocrlf=true`;当前 shell 文件是 LF 且 `bash -n` 通过,但建议固定 `*.sh`/`*.command` 为 LF。 +2. Windows 使用 `uv sync --frozen`,macOS 使用 `uv sync --locked`;建议补 `uv lock --check` 或统一 lock 新鲜度策略。 +3. Windows `version_info.txt` 的 `0.1.0.0` 与 pyproject `0.1.0` 当前一致,但仍是人工同步,建议增加版本一致性门禁。 +4. `Build_DoctorWorkstation.bat -ValidateOnly` 仍输出 `Package ready in: ...`,容易让人误以为已经生成新包;退出码和行为本身正确。 +5. Windows/macOS 的哈希文件命名分别为 `SHA256SUMS.txt` 与 `.sha256`,可统一以简化发布自动化。 +6. spec 尚未设置原生 `.ico`/`.icns`;不影响构建或媒体能力,但正式品牌发布可补齐。 +7. `research/release_packaging_audit.md` 与 `release_packaging_gate_fixed.md` 是阶段性历史记录,其中关于旧 mode/旧产物的描述不能替代本最终复审。 +8. AGENTS.md 引用的 `.trellis/workflow.md` 和 `.trellis/spec/` 在当前工作区仍不存在;属于治理缺口,不是本地构建阻断。 + +## 独立复审结果 + +### 1. PyInstaller spec:PASS + +`packaging/doctor_workstation.spec` 现在: + +- 显式 hidden import: + - `PySide6.QtMultimedia` + - `PySide6.QtMultimediaWidgets` +- 继续显式收集 QtWebEngine 模块; +- 将 `video_companion/dist` 放入 `video_companion_dist`; +- 要求 `packaging/runtime_media_smoke.py` 必须存在; +- 以 `runtime_hooks=[str(MEDIA_SMOKE_HOOK)]` 安装媒体 gate。 + +当前 PyInstaller 6.22 的官方 `hook-PySide6.QtMultimedia.py` 会追加 Widgets 模块,Qt6 module mapping 会收集 `plugins/multimedia`,与本 spec 的显式 imports 一致。 + +### 2. `--media-smoke-test`:PASS + +`packaging/runtime_media_smoke.py` 作为 runtime hook,在普通启动时无副作用;仅当 argv 含 `--media-smoke-test` 时才在应用入口前执行: + +- 导入 `QMediaPlayer`、`QAudioOutput`、`QMediaFormat`、`QVideoWidget`; +- 创建并连接 player/audio/video 对象; +- `player.isAvailable()` 必须为真; +- Decode 模式至少暴露一种支持格式; +- 显示 16×16 offscreen video widget,并完成一次 Qt event loop; +- 任一错误固定返回 70,成功返回 0。 + +独立测试验证了三种状态:普通 argv 惰性退出 0;隔离掉 site-packages 后媒体 argv 返回 70;当前 PySide6 环境 offscreen 实例化返回 0。 + +### 3. Windows build/package:PASS(静态与轻量合同) + +`scripts/build_windows.ps1` 在 PyInstaller 后要求以下文件: + +- `QtMultimedia.pyd` +- `QtMultimediaWidgets.pyd` +- `Qt6Multimedia.dll` +- `Qt6MultimediaWidgets.dll` +- `plugins/multimedia/ffmpegmediaplugin.dll` +- `plugins/multimedia/windowsmediaplugin.dll` + +随后先运行 `--media-smoke-test`,再运行原 `--smoke-test`;两者均沿用隔离配置目录、loopback-only proxy、offscreen、30 秒超时、退出码和未捕获异常日志检查。 + +`scripts/package_windows.ps1` 把 runtime hook 加入 required files,调用 build 之后才复制 release launcher、压缩 ZIP 和生成 SHA-256。`Build_DoctorWorkstation.bat -ValidateOnly` 本轮通过。 + +### 4. macOS build/package:PASS(静态与轻量合同) + +`scripts/build_macos.sh` 在成功前要求: + +- `PySide6.QtMultimedia` 与 `QtMultimediaWidgets` Python `.so`; +- 对应 framework,或适配 dylib 布局的后备检查; +- `plugins/multimedia` 目录; +- 至少一个 `*mediaplugin*.dylib/.so` backend; +- QtWebEngine helper/resources、companion 与主可执行文件。 + +之后执行 `codesign --verify`,再顺序运行媒体 smoke 和应用 smoke。`scripts/package_macos.sh` 在准备依赖前先运行入口合同,并只在 build 全部成功后用 `ditto` 归档和计算哈希。 + +这些路径模式与当前 PySide6/PyInstaller 的 macOS framework/plugin 布局相容;最终仍需原生构建确认。 + +### 5. macOS Git executable mode:PASS + +以下 9 个文件的 index mode 本轮均为 `100755`: + +1. `package_macos.command` +2. `run_macos.command` +3. `一键打包.command` +4. `一键运行.command` +5. `scripts/build_macos.sh` +6. `scripts/check_macos_entrypoints.sh` +7. `scripts/macos_helpers.sh` +8. `scripts/package_macos.sh` +9. `scripts/run_macos.sh` + +`scripts/check_macos_entrypoints.sh` 已将自身纳入 operational files,逐个执行 `bash -n`、检查实际 `-x`,在 Git 工作树中还读取 index mode 并要求 `100755`。本轮 Git Bash 合同执行通过。 + +### 6. README 与 `.env`:PASS + +- 根 README 正确区分 `build_*`(只生成/验证 onedir 或 `.app`)和根/package 入口(生成版本 ZIP 与哈希)。 +- Windows/macOS 一键打包描述已包含 QtWebEngine/QtMultimedia 文件门禁与两个冻结 smoke。 +- README 明确 macOS 操作文件必须以 mode `100755` 跟踪。 +- `.env.example` 与 README 明确该文件不进入 ZIP/`.app`;生产配置应由受控 launcher/MDM/进程环境注入。 +- 文档明确禁止把密码、token、UserSig、TRTC SecretKey 等凭据放进 `.env` 或发布包。 + +### 7. 输出合同:PASS(待新构建兑现) + +输出命名未被修复破坏: + +- Windows onedir:`dist/DoctorWorkstation/` +- Windows ZIP:`dist/DoctorWorkstation-Windows-x64-.zip` +- Windows checksum:`dist/SHA256SUMS.txt` +- macOS bundle:`dist/DoctorWorkstation.app` +- macOS ZIP:`dist/DoctorWorkstation-macOS-{arm64|x64}-.zip` +- macOS checksum:`.sha256` + +两个 package 流程都位于 build/gate 之后,失败不会进入新的归档步骤。 + +## 本轮实际执行的轻量检查 + +| 检查 | 结果 | +|---|---:| +| Windows 三份 PowerShell parser | PASS | +| `Build_DoctorWorkstation.bat -ValidateOnly` | PASS | +| 全部 macOS shell/command `bash -n` | PASS | +| `scripts/check_macos_entrypoints.sh`(含 index mode) | PASS | +| packaging media gate + one-click + entrypoint pytest | **10 passed** | +| runtime hook/packaging test Ruff | PASS | +| relevant tracked diff whitespace check | PASS | + +pytest 首次执行曾因沙箱无权枚举系统 `pytest-of-pc` 临时目录而产生 1 个 setup error;改用工作区内独立临时目录重跑后 10 项全部通过,并已安全清理该目录。该环境错误不是代码失败。 + +## 主代理下一步 + +### 当前 Windows 工作树 + +```powershell +.\Build_DoctorWorkstation.bat -ValidateOnly +.\Build_DoctorWorkstation.bat +``` + +完成后不要只看退出码;确认新 onedir/ZIP 时间戳、媒体文件、两个 smoke gate 日志及 SHA-256,并在无开发环境机器上做真实媒体/TRTC 验收。 + +### 原生 macOS + +先确保目标机器拿到与当前工作树等价的全部 intended files(正式发布推荐先形成 commit/tag),然后: + +```bash +git ls-files --stage -- '*.command' 'scripts/*.sh' +CI=1 DOCTOR_NONINTERACTIVE=1 bash scripts/check_macos_entrypoints.sh +export MACOS_CODESIGN_IDENTITY='Developer ID Application: <组织名称> ()' +./一键打包.command +``` + +随后验证 `.app` 架构、媒体 framework/plugin、两个 smoke gate、签名、公证/staple、最终 ZIP 哈希和真实设备/TRTC 流程。 + diff --git a/app/research/release_packaging_gate_fixed.md b/app/research/release_packaging_gate_fixed.md new file mode 100644 index 000000000..011884300 --- /dev/null +++ b/app/research/release_packaging_gate_fixed.md @@ -0,0 +1,76 @@ +# 发布打包门禁修复报告 + +日期:2026-08-11(Asia/Shanghai) +范围:仅 PyInstaller spec、打包/构建/入口检查脚本、发布说明、环境示例与打包专属测试;未修改 UI、core 或 repository,未执行完整 PyInstaller。 + +## 结论 + +`release_packaging_audit.md` 中除 Git 工作树/提交状态和 macOS Git executable mode 本身之外的 QtMultimedia 发布门禁已经落到代码与合同测试中: + +- spec 显式列出 `PySide6.QtMultimedia`、`PySide6.QtMultimediaWidgets`,从而强制触发 PyInstaller 官方 PySide6 hooks,收集对应 Python 扩展、`Qt6Multimedia`/`Qt6MultimediaWidgets` DLL、dylib/framework、`plugins/multimedia` 以及平台媒体后端。 +- 新增 PyInstaller runtime hook:冻结程序收到 `--media-smoke-test` 时,在进入正常应用入口前真实导入两个媒体模块,实例化 `QMediaPlayer`、`QAudioOutput`、`QVideoWidget`,连接音视频输出,确认媒体 backend 可用且至少暴露一种解码格式,并在 `offscreen` Qt 事件循环中退出。任一导入、链接、backend 或事件循环失败均返回非零(固定失败码 70)。 +- Windows 构建在成功前要求 `QtMultimedia.pyd`、`QtMultimediaWidgets.pyd`、`Qt6Multimedia.dll`、`Qt6MultimediaWidgets.dll`、`plugins/multimedia/ffmpegmediaplugin.dll` 和 `windowsmediaplugin.dll`,随后依次运行冻结媒体门禁与原应用 `--smoke-test`。 +- macOS 构建在成功前要求两个 Python 扩展、两个对应 framework/dylib、`plugins/multimedia` 与至少一个 `*mediaplugin*` backend,随后依次运行相同两项冻结门禁。 +- 两个平台的一键 package 入口仍先调用 build;只有 build 的文件门禁和两项冻结 smoke gate 全部通过后才归档 ZIP/生成 SHA-256。 +- `.env.example` 和 README 已明确:环境示例不会进入发布包,生产配置应由受控启动器/设备管理/进程环境注入,发布包不得包含密码、token、UserSig、TRTC SecretKey 等凭据。 + +## 主要文件 + +- `packaging/doctor_workstation.spec` +- `packaging/runtime_media_smoke.py` +- `scripts/build_windows.ps1` +- `scripts/package_windows.ps1` +- `scripts/build_macos.sh` +- `scripts/package_macos.sh` +- `scripts/check_macos_entrypoints.sh` +- `packaging/README.md` +- `README.md` +- `.env.example` +- `tests/test_packaging_media_gate.py` + +## macOS executable mode:必须由根代理完成 + +本任务遵守约束,没有执行 `git add`、`git update-index` 或 `git commit`。当前 Git index 仍把下列 9 个入口记为 `100644`;根代理必须把每个文件的 index mode 精确改为 `100755`,并把 mode 变化纳入最终发布提交: + +1. `run_macos.command` → `100755` +2. `package_macos.command` → `100755` +3. `一键运行.command` → `100755` +4. `一键打包.command` → `100755` +5. `scripts/build_macos.sh` → `100755` +6. `scripts/package_macos.sh` → `100755` +7. `scripts/run_macos.sh` → `100755` +8. `scripts/macos_helpers.sh` → `100755` +9. `scripts/check_macos_entrypoints.sh` → `100755` + +根代理可从项目根目录执行以下精确操作(本代理未执行): + +```powershell +git update-index --chmod=+x -- run_macos.command package_macos.command ` + "一键运行.command" "一键打包.command" ` + scripts/build_macos.sh scripts/package_macos.sh scripts/run_macos.sh ` + scripts/macos_helpers.sh scripts/check_macos_entrypoints.sh +git ls-files --stage -- '*.command' 'scripts/*.sh' +``` + +第二条命令对上述 9 个文件必须全部显示 `100755`。`scripts/check_macos_entrypoints.sh` 现在同时检查实际 `-x` 与 Git index mode,因此 Windows/Git Bash 将文件视为可执行也不能再掩盖 `100644`。 + +## 已执行的轻量验证 + +- 源环境媒体门禁(`QT_QPA_PLATFORM=offscreen`):PASS;实际播放器、音频输出、VideoWidget 和 decoder backend 均成功创建。 +- 缺少 site-packages 的隔离进程:PASS;`--media-smoke-test` 返回 70,证明缺组件不会误报成功。 +- PowerShell parser:`build_windows.ps1`、`package_windows.ps1`、`run_windows.ps1` PASS。 +- Git Bash `bash -n`:所有 macOS build/package/run/check/helper 与四个 `.command` PASS。 +- 打包合同测试:`tests/test_packaging_media_gate.py`、`tests/test_one_click_entrypoints.py`、`tests/test_entrypoint.py`,10 passed。 +- Ruff:新增 runtime hook 与打包测试 PASS。 +- `git diff --check`:PASS。 + +## 仍需原生构建机完成的发布验收 + +按任务约束,本次没有运行完整 PyInstaller。最终发布仍必须: + +- 在 Windows x64 原生构建机运行一键打包,确认新 onedir/ZIP 的文件门禁和冻结 `--media-smoke-test` 均通过,替换旧 `dist` 证据; +- 在目标架构 macOS 原生构建机运行一键打包,确认 `.app` framework/plugin 布局、codesign、摄像头/麦克风权限、notarization/stapling; +- 使用批准分发的 MP4(产品需要时再加 HLS)完成实际解码、画面渲染和音频输出验收;媒体 smoke gate 是 backend 准入,不替代真实播放; +- 由发布负责人审阅工作树,只从确定的 commit/tag 构建。Git 输入/提交状态属于本任务明确排除项。 + +补充:仓库指令引用的 `.trellis/workflow.md` 与 `.trellis/spec/` 在当前工作区不存在,因此本次无法执行 Trellis 规范审查;这不影响上述脚本合同结果。 diff --git a/app/scripts/build_macos.sh b/app/scripts/build_macos.sh index 617fa4816..de1cba568 100644 --- a/app/scripts/build_macos.sh +++ b/app/scripts/build_macos.sh @@ -29,11 +29,44 @@ 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" +qt_multimedia_module="$(find "$artifact" -type f -name 'QtMultimedia*.so' \ + ! -name 'QtMultimediaWidgets*.so' -print -quit)" +qt_multimedia_widgets_module="$(find "$artifact" -type f -name 'QtMultimediaWidgets*.so' -print -quit)" +qt_multimedia_framework="$(find "$artifact" -type d -name 'QtMultimedia.framework' -print -quit)" +qt_multimedia_widgets_framework="$(find "$artifact" -type d -name 'QtMultimediaWidgets.framework' -print -quit)" +qt_multimedia_dylib="$(find "$artifact" -type f -name '*Qt6Multimedia*.dylib' \ + ! -name '*Widgets*' ! -name '*Quick*' -print -quit)" +qt_multimedia_widgets_dylib="$(find "$artifact" -type f -name '*Qt6MultimediaWidgets*.dylib' -print -quit)" +multimedia_plugin_dir="$(find "$artifact" -type d -path '*/plugins/multimedia' -print -quit)" +multimedia_backend="" +if [[ -n "$multimedia_plugin_dir" ]]; then + multimedia_backend="$(find "$multimedia_plugin_dir" -type f \ + \( -name '*mediaplugin*.dylib' -o -name '*mediaplugin*.so' \) -print -quit)" +fi [[ -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; } +[[ -n "$qt_multimedia_module" ]] || { + echo "PySide6.QtMultimedia is missing from the app." >&2; exit 1; +} +[[ -n "$qt_multimedia_widgets_module" ]] || { + echo "PySide6.QtMultimediaWidgets is missing from the app." >&2; exit 1; +} +[[ -n "$qt_multimedia_framework" || -n "$qt_multimedia_dylib" ]] || { + echo "Qt6Multimedia framework/dylib is missing from the app." >&2; exit 1; +} +[[ -n "$qt_multimedia_widgets_framework" || -n "$qt_multimedia_widgets_dylib" ]] || { + echo "Qt6MultimediaWidgets framework/dylib is missing from the app." >&2; exit 1; +} +[[ -n "$multimedia_plugin_dir" ]] || { + echo "PySide6 plugins/multimedia is missing from the app." >&2; exit 1; +} +[[ -n "$multimedia_backend" ]] || { + echo "A Qt multimedia platform/FFmpeg backend plugin is missing from the app." >&2; exit 1; +} +echo "Frozen Qt multimedia file gate passed." codesign --verify --deep --strict --verbose=2 "$artifact" @@ -55,58 +88,71 @@ mkdir -p \ "$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 +run_frozen_gate() { + local gate_slug="$1" + local gate_label="$2" + local gate_argument="$3" + local stdout_file="$smoke_root/$gate_slug-stdout.txt" + local stderr_file="$smoke_root/$gate_slug-stderr.txt" + local diagnostics="$smoke_root/$gate_slug-diagnostics.txt" + local smoke_pid smoke_running smoke_status -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)." + 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" "$gate_argument" >"$stdout_file" 2>"$stderr_file" & + 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 || true + wait "$smoke_pid" 2>/dev/null || true + smoke_status=124 + elif wait "$smoke_pid"; then + smoke_status=0 + else + smoke_status=$? + fi + + if [[ "$smoke_status" -ne 0 ]]; then + cat "$stderr_file" >&2 + echo "$gate_label failed with exit code $smoke_status." >&2 + exit 1 + fi + cat "$stdout_file" "$stderr_file" >"$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 "$gate_label logs contain an unhandled exception." >&2 + exit 1 + fi + echo "$gate_label passed ($gate_argument, isolated offscreen mode)." +} + +run_frozen_gate \ + "media" "Frozen Qt multimedia smoke gate" "--media-smoke-test" +run_frozen_gate \ + "entry" "Frozen application entry smoke gate" "--smoke-test" echo "Build complete: $artifact" diff --git a/app/scripts/build_windows.ps1 b/app/scripts/build_windows.ps1 index 32622e5ea..57c8ec441 100644 --- a/app/scripts/build_windows.ps1 +++ b/app/scripts/build_windows.ps1 @@ -9,8 +9,12 @@ $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) +function Invoke-FrozenGate { + param( + [Parameter(Mandatory = $true)][string]$Executable, + [Parameter(Mandatory = $true)][string]$Argument, + [Parameter(Mandatory = $true)][string]$GateName + ) $TempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) $SmokeRoot = Join-Path $TempRoot ("doctor-workstation-smoke-" + [guid]::NewGuid().ToString("N")) @@ -59,7 +63,7 @@ function Invoke-FrozenSmokeTest { # 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.Arguments = $Argument $ProcessInfo.UseShellExecute = $false $ProcessInfo.CreateNoWindow = $true $ProcessInfo.RedirectStandardOutput = $true @@ -87,15 +91,15 @@ function Invoke-FrozenSmokeTest { $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" + throw "$GateName 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" + throw "$GateName 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" + throw "$GateName logs contain an unhandled exception" } - Write-Host "Frozen entry smoke test passed (--smoke-test, isolated demo mode)." + Write-Host "$GateName passed ($Argument, isolated offscreen mode)." } finally { foreach ($Name in $Environment.Keys) { @@ -107,6 +111,42 @@ function Invoke-FrozenSmokeTest { } } +function Assert-FrozenMultimedia { + param([Parameter(Mandatory = $true)][string]$Artifact) + + foreach ($RequiredName in @( + "QtMultimedia.pyd", + "QtMultimediaWidgets.pyd", + "Qt6Multimedia.dll", + "Qt6MultimediaWidgets.dll" + )) { + $RequiredFile = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter $RequiredName -File | + Where-Object { $_.FullName -like "*\PySide6\*" } | + Select-Object -First 1 + if (-not $RequiredFile) { + throw "Frozen Qt multimedia component is missing: $RequiredName" + } + } + + $PluginDirectories = @(Get-ChildItem -LiteralPath $Artifact -Recurse -Directory | + Where-Object { $_.Name -eq "multimedia" -and $_.Parent.Name -eq "plugins" }) + if ($PluginDirectories.Count -eq 0) { + throw "Frozen PySide6 plugins/multimedia directory is missing" + } + foreach ($BackendName in @("ffmpegmediaplugin.dll", "windowsmediaplugin.dll")) { + $Backend = $PluginDirectories | + ForEach-Object { + Get-ChildItem -LiteralPath $_.FullName -Filter $BackendName -File -ErrorAction SilentlyContinue + } | + Select-Object -First 1 + if (-not $Backend) { + throw "Frozen Qt multimedia backend is missing: $BackendName" + } + } + + Write-Host "Frozen Qt multimedia file gate passed." +} + if (-not [System.IO.Path]::IsPathRooted($Python)) { $Python = Join-Path $ProjectRoot $Python } @@ -162,7 +202,15 @@ try { if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) { throw "Frozen application entry point is missing: $Executable" } - Invoke-FrozenSmokeTest -Executable $Executable + Assert-FrozenMultimedia -Artifact $Artifact + Invoke-FrozenGate ` + -Executable $Executable ` + -Argument "--media-smoke-test" ` + -GateName "Frozen Qt multimedia smoke gate" + Invoke-FrozenGate ` + -Executable $Executable ` + -Argument "--smoke-test" ` + -GateName "Frozen application entry smoke gate" Write-Host "Build complete: $Artifact" } diff --git a/app/scripts/check_macos_entrypoints.sh b/app/scripts/check_macos_entrypoints.sh index 5fa17673a..ed6c0f6dc 100644 --- a/app/scripts/check_macos_entrypoints.sh +++ b/app/scripts/check_macos_entrypoints.sh @@ -5,6 +5,7 @@ script_dir="$(cd "$(dirname "$0")" && pwd -P)" project_root="$(cd "$script_dir/.." && pwd -P)" operational_files=( + "$script_dir/check_macos_entrypoints.sh" "$script_dir/macos_helpers.sh" "$script_dir/run_macos.sh" "$script_dir/package_macos.sh" @@ -18,14 +19,28 @@ operational_files=( for file in "${operational_files[@]}"; do [[ -f "$file" ]] || { printf 'Missing macOS entry file: %s\n' "$file" >&2; exit 1; } /bin/bash -n "$file" + [[ -x "$file" ]] || { printf 'macOS entry is not executable: %s\n' "$file" >&2; exit 1; } 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 + +# A Windows checkout can report every shell file as executable even when Git +# records mode 100644. Check the index as well so a fresh macOS clone retains +# Finder/CLI launchability. Source archives without .git still use the -x gate +# above. +if command -v git >/dev/null 2>&1 && \ + git -C "$project_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 && \ + git -C "$project_root" ls-files --error-unmatch -- \ + packaging/doctor_workstation.spec >/dev/null 2>&1; then + for file in "${operational_files[@]}"; do + relative_path="${file#"$project_root"/}" + index_record="$(git -C "$project_root" ls-files --stage -- "$relative_path")" + index_mode="${index_record%% *}" + [[ "$index_mode" == "100755" ]] || { + printf 'Git index mode must be 100755 for macOS entry: %s (found %s)\n' \ + "$relative_path" "${index_mode:-untracked}" >&2 + exit 1 + } + done +fi 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)" @@ -46,11 +61,11 @@ 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 +if grep -En '(^|[[:space:]])(export[[:space:]]+)?HOME=' "${operational_files[@]:1}"; then echo 'macOS entry scripts must not repurpose HOME.' >&2 exit 1 fi -if grep -Ein 'SDKSecret(Key)?|UserSig|userSig' "${operational_files[@]}"; then +if grep -Ein 'SDKSecret(Key)?|UserSig|userSig' "${operational_files[@]:1}"; then echo 'macOS entry scripts must not contain RTC secrets or credentials.' >&2 exit 1 fi diff --git a/app/scripts/package_macos.sh b/app/scripts/package_macos.sh index 49c7ea8e2..78bde63e3 100644 --- a/app/scripts/package_macos.sh +++ b/app/scripts/package_macos.sh @@ -7,6 +7,7 @@ project_root="$(cd "$script_dir/.." && pwd -P)" source "$script_dir/macos_helpers.sh" macos_install_exit_trap 1 macos_require_darwin +/bin/bash "$script_dir/check_macos_entrypoints.sh" NODE_BIN="" NPM_BIN="" diff --git a/app/scripts/package_windows.ps1 b/app/scripts/package_windows.ps1 index 1ce334e6b..908131891 100644 --- a/app/scripts/package_windows.ps1 +++ b/app/scripts/package_windows.ps1 @@ -7,6 +7,7 @@ $ErrorActionPreference = "Stop" $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $BuildScript = Join-Path $PSScriptRoot "build_windows.ps1" $BuildEnvironment = Join-Path $ProjectRoot ".venv-build" +$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache" $BuildPython = Join-Path $BuildEnvironment "Scripts\python.exe" $FallbackPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe" $CompanionRoot = Join-Path $ProjectRoot "video_companion" @@ -17,6 +18,7 @@ $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" +$MediaSmokeHook = Join-Path $ProjectRoot "packaging\runtime_media_smoke.py" function Test-BuildPython { param([Parameter(Mandatory = $true)][string]$Candidate) @@ -48,6 +50,7 @@ try { $PackageLock, (Join-Path $ProjectRoot "uv.lock"), $ProjectMetadata, + $MediaSmokeHook, $ReleaseLauncherTemplate )) { if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) { @@ -83,11 +86,21 @@ try { "UV_PROJECT_ENVIRONMENT", "Process" ) + $PreviousUvCache = [Environment]::GetEnvironmentVariable( + "UV_CACHE_DIR", + "Process" + ) + New-Item -ItemType Directory -Path $ProjectUvCache -Force | Out-Null [Environment]::SetEnvironmentVariable( "UV_PROJECT_ENVIRONMENT", $BuildEnvironment, "Process" ) + [Environment]::SetEnvironmentVariable( + "UV_CACHE_DIR", + $ProjectUvCache, + "Process" + ) Push-Location $ProjectRoot try { & $Uv sync --frozen --extra build @@ -102,6 +115,11 @@ try { $PreviousProjectEnvironment, "Process" ) + [Environment]::SetEnvironmentVariable( + "UV_CACHE_DIR", + $PreviousUvCache, + "Process" + ) } } elseif (Test-BuildPython -Candidate $BuildPython) { diff --git a/app/scripts/render_appointment_drawer_visual.py b/app/scripts/render_appointment_drawer_visual.py new file mode 100644 index 000000000..eda06a649 --- /dev/null +++ b/app/scripts/render_appointment_drawer_visual.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QDate, QPoint, QSize, Qt +from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap +from PySide6.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) + +from doctor_workstation.ui.appointment_drawer import AppointmentDrawer + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT_DIR = ROOT / "artifacts" / "diagnosis_visual" + + +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, **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() + + +class _DeferredAsync: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def __call__( + self, + function: Any, + *args: Any, + on_success: Any = None, + on_error: Any = None, + on_finished: Any = None, + **kwargs: Any, + ) -> object: + self.calls.append( + { + "function": function, + "args": args, + "kwargs": kwargs, + "on_success": on_success, + "on_error": on_error, + "on_finished": on_finished, + } + ) + return object() + + +class _ScreenshotRepository: + def __init__( + self, + *, + today_conflict: bool = True, + doctors: bool = True, + rosters: bool = True, + slot_error: bool = False, + ) -> None: + self.today_conflict = today_conflict + self.doctors = doctors + self.rosters = rosters + self.slot_error = slot_error + + def list_diagnosis_doctors(self) -> list[dict[str, Any]]: + if not self.doctors: + return [] + return [ + {"id": 77, "name": "陈医生", "department_name": "中医科"}, + {"id": 88, "name": "周医生", "department_name": "内科"}, + {"id": 99, "name": "林医生", "department_name": "全科"}, + {"id": 106, "name": "宋医生", "department_name": "康复科"}, + ] + + def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]: + assert dictionary_type == "channels" + return [ + {"id": 3, "name": "自媒体4H", "value": "self-4h", "status": 1, "sort": 30}, + {"id": 2, "name": "线上复诊", "value": "online", "status": 1, "sort": 20}, + {"id": 1, "name": "医生推荐", "value": "doctor", "status": 1, "sort": 10}, + ] + + def list_appointments(self, **kwargs: Any) -> dict[str, Any]: + if kwargs.get("status") == 3: + return { + "lists": [ + { + "appointment_date": "2026-08-01", + "appointment_time": "10:30-11:00", + } + ], + "count": 1, + } + if self.today_conflict: + return {"lists": [{"status": 1}], "count": 1} + return {"lists": [], "count": 0} + + def list_appointment_rosters(self, **_kwargs: Any) -> dict[str, Any]: + if not self.rosters: + return {"lists": [], "count": 0} + today = QDate.currentDate() + return { + "lists": [ + {"date": today.addDays(offset).toString("yyyy-MM-dd")} for offset in range(4) + ], + "count": 4, + } + + def get_available_appointment_slots(self, **_kwargs: Any) -> dict[str, Any]: + if self.slot_error: + raise RuntimeError("号源服务暂时不可用") + return { + "slots": [ + {"time": "09:00-09:30", "available": True, "quota": 2}, + {"time": "09:30-10:00", "available": False, "quota": 0}, + {"time": "10:00-10:30", "available": True, "quota": 1}, + {"time": "10:30-11:00", "available": True, "quota": 3}, + {"time": "14:00-14:30", "available": False, "quota": 0}, + {"time": "14:30-15:00", "available": True, "quota": 2}, + {"time": "15:00-15:30", "available": True, "quota": 1}, + {"time": "15:30-16:00", "available": False, "quota": 0}, + ] + } + + +def _background(size: tuple[int, int]) -> QWidget: + host = QWidget() + host.setObjectName("AppointmentScreenshotHost") + host.resize(*size) + host.setStyleSheet( + "QWidget#AppointmentScreenshotHost { background:#F5F7FA; color:#303133; }" + "QFrame#Sidebar { background:#1F2D3D; }" + "QFrame#Header, QFrame#Card { background:#FFFFFF; border:1px solid #EBEEF5; }" + "QLabel#Nav { color:#DDE5ED; font-size:14px; }" + "QLabel#Muted { color:#909399; }" + ) + root = QHBoxLayout(host) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + sidebar = QFrame() + sidebar.setObjectName("Sidebar") + sidebar.setFixedWidth(196) + side_layout = QVBoxLayout(sidebar) + side_layout.setContentsMargins(22, 26, 22, 26) + side_layout.setSpacing(18) + brand = QLabel("真养堂 · 医生工作站") + brand.setObjectName("Nav") + side_layout.addWidget(brand) + for item in ("诊单列表", "患者管理", "预约挂号", "处方管理", "业务订单"): + label = QLabel(item) + label.setObjectName("Nav") + side_layout.addWidget(label) + side_layout.addStretch(1) + root.addWidget(sidebar) + + content = QWidget() + content_layout = QVBoxLayout(content) + content_layout.setContentsMargins(18, 18, 18, 18) + content_layout.setSpacing(14) + header = QFrame() + header.setObjectName("Header") + header.setFixedHeight(58) + header_layout = QHBoxLayout(header) + header_layout.setContentsMargins(18, 0, 18, 0) + header_layout.addWidget(QLabel("诊单列表")) + header_layout.addStretch(1) + account = QLabel("陈医生 · 中医科") + account.setObjectName("Muted") + header_layout.addWidget(account) + content_layout.addWidget(header) + card = QFrame() + card.setObjectName("Card") + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(20, 18, 20, 18) + card_layout.setSpacing(14) + card_layout.addWidget(QLabel("患者诊单 / 林晓岚 / 待预约")) + for line in ( + "林晓岚 女 · 38岁 最近问诊 2026-08-01", + "主诉:失眠、多梦,复诊评估调方", + "接诊医生:陈医生 医助:赵医助", + ): + value = QLabel(line) + value.setObjectName("Muted") + card_layout.addWidget(value) + card_layout.addStretch(1) + content_layout.addWidget(card, 1) + root.addWidget(content, 1) + return host + + +def _build_drawer( + application: QApplication, + size: tuple[int, int], + state: str, +) -> tuple[QWidget, AppointmentDrawer]: + host = _background(size) + host.show() + repository = _ScreenshotRepository( + doctors=state != "empty_doctors", + rosters=state != "empty_roster", + slot_error=state == "error", + ) + deferred = _DeferredAsync() if state == "loading" else None + drawer = AppointmentDrawer( + { + "id": 501, + "diagnosis_id": 501, + "patient_id": 999, + "patient_name": "林晓岚", + "doctor_id": 77, + }, + repository=repository, + parent=host, + async_runner=deferred or _immediate_async, + ) + drawer.show() + for _ in range(6): + application.processEvents() + + assert drawer.drawer_width == round(size[0] * 0.60) + assert drawer.footer.geometry().bottom() == drawer.panel.rect().bottom() + assert drawer.ok_button.text() == "确定" + + if state == "loading": + assert deferred is not None and len(deferred.calls) == 1 + assert drawer._active_loading == "initial" + assert drawer.loading_overlay.isVisible() + assert drawer.loading_overlay.label.text() == "正在加载医生、渠道与挂号状态…" + assert not drawer.ok_button.isEnabled() + return host, drawer + + if state == "empty_doctors": + assert not drawer.doctor_buttons + assert drawer.doctor_empty.text() == "暂无可预约医生" + assert drawer.time_empty.label.text() == "暂无可预约医生" + assert drawer.time_empty.illustration.size() == QSize(80, 60) + assert not drawer.ok_button.isEnabled() + return host, drawer + + if state == "empty_roster": + assert drawer.doctor_buttons + assert not drawer.date_buttons + assert drawer.time_empty.label.text() == "该医生暂无排班" + assert drawer.time_empty.illustration.size() == QSize(80, 60) + assert not drawer.ok_button.isEnabled() + return host, drawer + + if state == "error": + assert drawer.banner.property("kind") == "danger" + assert "号源加载失败" in drawer.banner.label.text() + assert drawer.slot_empty.label.text() == "号源加载失败" + assert not drawer.slot_buttons + assert not drawer.ok_button.isEnabled() + return host, drawer + + assert [button.text() for button in drawer.doctor_buttons.values()] == [ + "陈医生", + "周医生", + "林医生", + "宋医生", + ] + assert all(button.size() == QSize(130, 40) for button in drawer.date_buttons.values()) + future_date = next( + value for value in drawer.date_buttons if value > QDate.currentDate().toString("yyyy-MM-dd") + ) + if drawer.date_combo.currentData() != future_date: + drawer.date_buttons[future_date].click() + application.processEvents() + drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online")) + first_available = next(button for button in drawer.slot_buttons.values() if button.isEnabled()) + first_available.click() + drawer.remark.setPlainText("复诊预约,请医生提前查看近期睡眠记录。") + drawer._update_slot_scroll_height() + for _ in range(4): + application.processEvents() + unavailable = next(button for button in drawer.slot_buttons.values() if not button.isEnabled()) + assert unavailable.status_label.text() == "已约" + assert unavailable.status_label.isVisible() + + if state == "refreshing": + deferred = _DeferredAsync() + drawer._run_async = deferred + drawer.refresh_slots_button.click() + for _ in range(3): + application.processEvents() + assert len(deferred.calls) == 1 + assert drawer.loading_overlay.isVisible() + assert drawer.loading_overlay.label.text() == "正在刷新可用号源…" + assert drawer.refresh_slots_button.text() == "刷新中…" + assert not drawer.refresh_slots_button.isEnabled() + assert drawer.slot_empty.label.text() == "正在刷新可用号源…" + elif state == "keyboard_focus": + focus_target = next( + button for button in drawer.date_buttons.values() if not button.isChecked() + ) + drawer.activateWindow() + focus_target.setFocus(Qt.FocusReason.TabFocusReason) + application.processEvents() + assert focus_target.hasFocus() + assert application.focusWidget() is focus_target + else: + assert state == "default" + assert drawer.ok_button.isEnabled() + + return host, drawer + + +def _render(application: QApplication, size: tuple[int, int], *, state: str = "default") -> Path: + host, drawer = _build_drawer(application, size, state) + canvas = QPixmap(*size) + canvas.fill(QColor("#F5F7FA")) + painter = QPainter(canvas) + origin = QPoint(0, 0) + host.render(painter, origin) + drawer.render(painter, origin) + painter.end() + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + state_suffix = "" if state == "default" else f"_{state}" + path = OUTPUT_DIR / f"appointment_drawer{state_suffix}_{size[0]}x{size[1]}.png" + if not canvas.save(str(path), "PNG"): + raise RuntimeError(f"Could not save {path}") + image = QImage(str(path)) + rendered_size = (image.width(), image.height()) + if rendered_size != size: + raise RuntimeError(f"Unexpected image size for {path}: {rendered_size}") + drawer.close() + host.close() + application.processEvents() + return path + + +def _load_cjk_font(application: QApplication) -> None: + candidates = ( + Path(r"C:\Windows\Fonts\msyh.ttc"), + Path(r"C:\Windows\Fonts\simsun.ttc"), + Path("/System/Library/Fonts/PingFang.ttc"), + Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + ) + for candidate in candidates: + if not candidate.exists(): + continue + font_id = QFontDatabase.addApplicationFont(str(candidate)) + families = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else [] + if families: + application.setFont(QFont(families[0], 10)) + return + application.setFont(QFont("sans-serif", 10)) + + +def main() -> None: + application = QApplication.instance() or QApplication([]) + application.setStyle("Fusion") + _load_cjk_font(application) + for size in ((1024, 640), (1440, 900)): + path = _render(application, size) + print(path.relative_to(ROOT)) + for state in ( + "empty_doctors", + "empty_roster", + "loading", + "refreshing", + "error", + "keyboard_focus", + ): + path = _render(application, (1440, 900), state=state) + print(path.relative_to(ROOT)) + + +if __name__ == "__main__": + main() diff --git a/app/scripts/render_diagnosis_detail_visual.py b/app/scripts/render_diagnosis_detail_visual.py new file mode 100644 index 000000000..cdd2f6261 --- /dev/null +++ b/app/scripts/render_diagnosis_detail_visual.py @@ -0,0 +1,694 @@ +"""Render deterministic diagnosis readonly, edit, and view-only references.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import QApplication + +from doctor_workstation.core import PermissionSet +from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog +from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog +from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module +from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog +from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog + + +def _detail(*, locked: bool) -> dict[str, Any]: + diagnosis: dict[str, Any] = { + "id": 501, + "patient_id": 1501, + "patient_name": "林晓岚", + "phone": "13800138000", + "id_card": "110105199203071234", + "gender": 0, + "age": 34, + "marital_status": 1, + "height": 162, + "weight": 54.5, + "region": "浙江省杭州市", + "systolic_pressure": 146, + "diastolic_pressure": 92, + "fasting_blood_sugar": 8.2, + "diagnosis_type": "follow_up", + "diagnosis_date": "2026-08-10", + "status_desc": "诊疗中", + "channel_name": "健康顾问转介", + "statistical_visit_card": "第 4 次", + "current_medications": "二甲双胍", + "diabetes_discovery_year": "2019 年", + "local_hospital_diagnosis": ["2 型糖尿病", "高血压"], + "local_hospital_name": "杭州市第一人民医院", + "oral_condition": "口干", + "water_intake": "约 2200 ml", + "weight_change": "近月下降 1 kg", + "fatty_liver_degree": "轻度", + "diet_condition": ["偏甜", "夜宵"], + "body_feeling": ["乏力", "四肢沉重"], + "sleep_condition": "入睡稍慢", + "eye_condition": "偶有干涩", + "head_feeling": "午后头昏", + "sweat_condition": "易出汗", + "skin_condition": "皮肤偏干", + "urine_condition": "夜尿 2 次", + "stool_condition": "每日一次", + "kidney_condition": "腰酸", + "present_illness": "口渴、乏力反复半年,近期血糖波动。", + "past_history": "高血压病史 5 年。", + "trauma_history": 0, + "surgery_history": 0, + "allergy_history": 1, + "family_history": 1, + "pregnancy_history": 0, + "remark": "建议持续记录空腹及餐后血糖。", + "clinical_diagnosis": "气阴两虚", + "prescription_opinion": "益气养阴,兼顾活血。", + "has_prescription": 1, + "unserved_days": 4, + "patient_basic_locked": locked, + "can_edit_patient_basic": not locked, + "latest_prescription_order": { + "id": "RX-240810-09", + "fulfillment_status_text": "待配药", + }, + } + return { + "diagnosis": diagnosis, + "patient": { + "id": 1501, + "patient_name": "林晓岚", + "phone": "13800138000", + "id_card": "110105199203071234", + "gender": 0, + "age": 34, + }, + "appointment": { + "id": 2501, + "appointment_date": "2026-08-10", + "appointment_time": "09:00-09:30", + "period_text": "上午", + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "status_desc": "已到诊", + "remark": "复诊评估", + }, + } + + +class ScreenshotRepository: + """No-network data source for visual artifact generation.""" + + def __init__(self, *, locked: bool) -> None: + self.detail = _detail(locked=locked) + + def get_diagnosis_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]: + if diagnosis_id != 501: + raise LookupError(diagnosis_id) + return self.detail + + diagnosis_readonly_detail = get_diagnosis_detail + patient_detail = get_diagnosis_detail + + def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]: + if dictionary_type == "diagnosis_type": + return [ + {"name": "初诊", "value": "first_visit"}, + {"name": "复诊", "value": "follow_up"}, + {"name": "会诊", "value": "consultation"}, + ] + if dictionary_type == "appetite": + return [ + {"name": "口干", "value": "口干"}, + {"name": "口苦", "value": "口苦"}, + ] + return [] + + def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + if diagnosis_id != 501: + raise LookupError(diagnosis_id) + return [ + { + "id": 7001, + "create_time": "2026-08-10 09:20", + "doctor_name": "陈医生", + "content": "面色稍淡,舌淡红,苔薄白。", + "tongue_images": [{"url": "tongue.jpg"}], + "report_files": [ + { + "name": "近期血糖趋势.pdf", + "url": "https://media.example.invalid/report.pdf", + } + ], + } + ] + + def get_tracking_window( + self, + diagnosis_id: int, + *, + start_date: str = "", + end_date: str = "", + ) -> dict[str, Any]: + if diagnosis_id != 501: + raise LookupError(diagnosis_id) + del start_date, end_date + return { + "blood_records": [ + { + "id": 6101, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "fasting_blood_sugar": 8.2, + "systolic_pressure": 146, + "source": 1, + }, + { + "id": 6102, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "postprandial_blood_sugar": 12.4, + "diastolic_pressure": 92, + "western_medicine": "二甲双胍", + }, + { + "id": 6103, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-09", + "fasting_blood_sugar": 7.6, + "postprandial_blood_sugar": 10.8, + }, + ], + "diet_records": [ + { + "id": 6201, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "breakfast_foods": "燕麦、鸡蛋", + "lunch_foods": "杂粮饭", + } + ], + "exercise_records": [ + { + "id": 6301, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-09", + "exercise_type": "散步", + "duration": 35, + "intensity": 2, + } + ], + } + + def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + if diagnosis_id != 501: + raise LookupError(diagnosis_id) + return [{"note_date": "2026-08-10", "content": "饭后散步,继续观察。"}] + + def list_diagnosis_todos(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]: + if diagnosis_id != 501: + raise LookupError(diagnosis_id) + return { + "lists": [ + { + "remind_time_text": "2026-08-12 09:00", + "content": "回访复测餐后血糖", + "status": 0, + "status_text": "待执行", + "creator_name": "陈医生", + "can_cancel": True, + "id": 7101, + } + ], + "count": 1, + } + + def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]: + if diagnosis_id != 501: + raise LookupError(diagnosis_id) + return [ + { + "id": 901, + "diagnosis_id": 501, + "prescription_date": "2026-08-02", + "global_visit_sequence": 4, + "prescription_type_text": "中药", + "prescription_summary": "玉泉丸加减", + "doctor_name": "陈医生", + "status_text": "已审核", + } + ] + + def appointment_history(self, **_kwargs: Any) -> dict[str, Any]: + return { + "lists": [ + { + "id": 2501, + "status_desc": "已到诊", + "patient_name": "林晓岚", + "patient_phone": "13800138000", + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "appointment_date": "2026-08-10", + "appointment_time": "09:00-09:30", + "appointment_type_text": "复诊", + "channel_name": "健康顾问", + "is_confirmed": 1, + "has_prescription": 1, + "remark": "复诊评估", + "create_time": "2026-08-08 11:20", + } + ] + } + + def assign_history(self, **_kwargs: Any) -> dict[str, Any]: + return { + "lists": [ + { + "create_time": "2026-08-08 11:22", + "from_assistant_name": "王医助", + "to_assistant_name": "赵医助", + "is_inherit": 1, + "snapshot_order_creator_name": "王医助", + "snapshot_order_create_time": "2026-08-01 10:00", + "operator_name": "陈医生", + "operator_account": "doctor.chen", + "ip": "10.1.2.8", + } + ] + } + + def list_prescription_orders(self, **_kwargs: Any) -> dict[str, Any]: + return { + "lists": [ + { + "id": 801, + "order_no": "RX-240810-09", + "global_visit_sequence": 4, + "statistics_count": 1, + "amount": "¥ 286.00", + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "fulfillment_status_text": "待配药", + "create_time": "2026-08-10 09:24", + } + ], + "count": 1, + } + + def get_prescription_order(self, order_id: int) -> dict[str, Any]: + if order_id != 801: + raise LookupError(order_id) + return { + "id": order_id, + "order_no": "RX-240810-09", + "patient_name": "林晓岚", + "amount": 286, + "fulfillment_status_text": "待配药", + } + + def add_blood_record(self, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": 7201, **payload} + + def add_diet_record(self, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": 7202, **payload} + + def add_exercise_record(self, payload: dict[str, Any]) -> dict[str, Any]: + return {"id": 7203, **payload} + + def update_blood_record( + self, record: dict[str, Any], changes: dict[str, Any] | None = None + ) -> dict[str, Any]: + return {**record, **(changes or {})} + + def update_diet_record( + self, record: dict[str, Any], changes: dict[str, Any] | None = None + ) -> dict[str, Any]: + return {**record, **(changes or {})} + + def update_exercise_record( + self, record: dict[str, Any], changes: dict[str, Any] | None = None + ) -> dict[str, Any]: + return {**record, **(changes or {})} + + def add_tracking_note(self, diagnosis_id: int, content: str) -> dict[str, Any]: + return {"id": 7204, "diagnosis_id": diagnosis_id, "content": content} + + def add_diagnosis_todo( + self, diagnosis_id: int, content: str, remind_time: int + ) -> dict[str, Any]: + return { + "id": 7205, + "diagnosis_id": diagnosis_id, + "content": content, + "remind_time": remind_time, + } + + def cancel_diagnosis_todo(self, todo_id: int) -> dict[str, Any]: + return {"id": todo_id, "status": 2} + + def add_doctor_note(self, diagnosis_id: int, content: str = "", **_kwargs: Any) -> Any: + return {"id": 7206, "diagnosis_id": diagnosis_id, "content": content} + + def delete_doctor_note_image(self, note_id: int, image_type: str, image_path: str) -> Any: + return {"note_id": note_id, "image_type": image_type, "image_path": image_path} + + def upload_material(self, path: str, material_type: str) -> str: + return f"https://media.example.invalid/{material_type}/{Path(path).name}" + + def create_prescription(self, prescription: dict[str, Any]) -> Any: + return {"id": 7207, **prescription} + + def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> Any: + return {"id": diagnosis_id, "revisit_slot_start_offset": offset} + + def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]: + return [ + { + "id": 7208, + "diagnosis_id": diagnosis_id, + "recording_urls_list": [ + "https://media.example.invalid/replay.mp4", + "https://media.example.invalid/replay-backup.m3u8", + "https://media.example.invalid/replay.webm", + ], + "start_time_text": "2026-08-10 09:10:00", + "end_time_text": "2026-08-10 09:22:00", + "call_type_text": "视频", + "room_id": "demo-room-501", + "duration_text": "12分00秒", + "status_text": "已结束", + "recording_status_text": "录制完成", + } + ] + + def upload_call_recording( + self, path: str, diagnosis_id: int, *, call_record_id: int | None = None + ) -> dict[str, Any]: + return { + "diagnosis_id": diagnosis_id, + "call_record_id": call_record_id, + "file_url": Path(path).name, + } + + def list_im_chat_messages( + self, diagnosis_id: int, *, only_archived: bool = True + ) -> dict[str, Any]: + del diagnosis_id + return { + "only_archived": only_archived, + "lists": [ + { + "msg_id": "chat-1", + "msg_type": "text", + "text": "今天空腹血糖 5.8。", + "is_from_doctor": False, + "time": "2026-08-10 08:30", + }, + { + "msg_id": "chat-2", + "msg_type": "image", + "image_url": "https://media.example.invalid/blood.jpg", + "is_from_doctor": False, + "time": "2026-08-10 08:31", + }, + { + "msg_id": "chat-3", + "msg_type": "file", + "file_url": "https://media.example.invalid/report.pdf", + "file_name": "复查报告.pdf", + "is_from_doctor": True, + "from_staff_name": "陈医生", + "time": "2026-08-10 08:40", + }, + ], + } + + def sync_im_chat_messages(self, diagnosis_id: int) -> dict[str, Any]: + return {"diagnosis_id": diagnosis_id, "queued": True} + + +class EmptyScreenshotRepository(ScreenshotRepository): + def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + del diagnosis_id + return [] + + def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]: + del diagnosis_id + return [] + + def list_prescription_orders(self, **_kwargs: Any) -> dict[str, Any]: + return {"lists": [], "count": 0} + + +class FailingScreenshotRepository(ScreenshotRepository): + def get_diagnosis_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]: + del diagnosis_id + raise RuntimeError("诊单详情加载失败,请检查网络后重试") + + +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) + 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() + + +def render() -> list[Path]: + app = QApplication.instance() or QApplication([]) + font_path = Path("C:/Windows/Fonts/msyh.ttc") + if font_path.is_file(): + font_id = QFontDatabase.addApplicationFont(str(font_path)) + families = QFontDatabase.applicationFontFamilies(font_id) + if families: + app.setFont(QFont(families[0], 9)) + diagnosis_module.run_async = _run_immediately + root = Path(__file__).resolve().parents[1] + output = root / "artifacts" / "diagnosis_visual" + output.mkdir(parents=True, exist_ok=True) + rendered: list[Path] = [] + for width, height in ((1024, 640), (1440, 900)): + for mode in ("readonly", "edit", "viewonly"): + dialog = DiagnosisDialog( + ScreenshotRepository(locked=mode == "viewonly"), + permissions=PermissionSet(["*"]), + ) + dialog.resize(width, height) + if mode == "readonly": + dialog.open_for(501, editable=False) + elif mode == "edit": + dialog.open_for(501, editable=True) + else: + dialog.open_view_only(501) + for _ in range(8): + app.processEvents() + if mode == "readonly": + dialog.readonly_scroll.verticalScrollBar().setValue(0) + else: + dialog.tabs.setCurrentIndex(0) + app.processEvents() + path = output / f"diagnosis_{mode}_{width}x{height}.png" + if not dialog.grab().save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + rendered.append(path) + dialog.close() + app.processEvents() + + def save_state(dialog: DiagnosisDialog, name: str, *, close: bool = True) -> None: + for _ in range(8): + app.processEvents() + path = output / f"diagnosis_state_{name}_1024x640.png" + if not dialog.grab().save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + rendered.append(path) + if close: + dialog.close() + app.processEvents() + + def save_widget(widget: Any, name: str) -> None: + widget.show() + for _ in range(6): + app.processEvents() + path = output / f"diagnosis_state_{name}.png" + if not widget.grab().save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + rendered.append(path) + widget.close() + app.processEvents() + + pending: list[dict[str, Any]] = [] + + def queue_async(function: Any, **options: Any) -> object: + pending.append({"function": function, **options}) + return object() + + diagnosis_module.run_async = queue_async + loading = DiagnosisDialog(ScreenshotRepository(locked=False), permissions=PermissionSet(["*"])) + loading.resize(1024, 640) + loading.open_for(501, editable=True) + save_state(loading, "loading") + + diagnosis_module.run_async = _run_immediately + error = DiagnosisDialog( + FailingScreenshotRepository(locked=False), permissions=PermissionSet(["*"]) + ) + error.resize(1024, 640) + error.open_for(501, editable=True, seed=_detail(locked=False)) + save_state(error, "error") + + empty = DiagnosisDialog( + EmptyScreenshotRepository(locked=False), permissions=PermissionSet(["*"]) + ) + empty.resize(1024, 640) + empty.open_for(501, editable=True) + empty_index = next( + index + for index in range(empty.tabs.count()) + if empty.tabs.tabBar().tabData(index) == "prescription" + ) + empty.tabs.setCurrentIndex(empty_index) + save_state(empty, "empty") + + permission = DiagnosisDialog( + ScreenshotRepository(locked=True), + permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]), + ) + permission.resize(1024, 640) + permission.open_view_only(501) + save_state(permission, "permission") + + daily = DiagnosisDialog(ScreenshotRepository(locked=False), permissions=PermissionSet(["*"])) + daily.resize(1024, 640) + daily.open_for(501, editable=True) + daily_index = next( + index + for index in range(daily.tabs.count()) + if daily.tabs.tabBar().tabData(index) == "daily" + ) + daily.tabs.setCurrentIndex(daily_index) + save_state(daily, "daily", close=False) + daily_page = daily._tab_pages["daily"] + daily_page.verticalScrollBar().setValue(daily_page.verticalScrollBar().maximum()) + save_state(daily, "daily_lower") + + focus = DiagnosisDialog(ScreenshotRepository(locked=False), permissions=PermissionSet(["*"])) + focus.resize(1024, 640) + focus.open_for(501, editable=True) + focus.edit_fields["patient_name"].setFocus() + save_state(focus, "focus") + + save_states = DiagnosisDialog( + ScreenshotRepository(locked=False), permissions=PermissionSet(["*"]) + ) + save_states.resize(1024, 640) + save_states.open_for(501, editable=True) + save_states.save_button.set_state("loading") + save_state(save_states, "save_loading", close=False) + save_states.save_button.set_state("success") + save_state(save_states, "save_success", close=False) + save_states.save_button.set_state("error") + save_state(save_states, "save_failure") + + for tab_key, state_name in ( + ("notes", "notes_actions"), + ("video", "video_replay"), + ("chat", "chat_archive"), + ("orders", "order_offset"), + ): + dialog = DiagnosisDialog( + ScreenshotRepository(locked=False), permissions=PermissionSet(["*"]) + ) + dialog.resize(1024, 640) + dialog.open_for(501, editable=True) + index = next( + item + for item in range(dialog.tabs.count()) + if dialog.tabs.tabBar().tabData(item) == tab_key + ) + dialog.tabs.setCurrentIndex(index) + save_state(dialog, state_name, close=False) + if tab_key == "orders": + order_detail = dialog._build_order_detail_dialog( + dialog.repository.get_prescription_order(801), 801 + ) + save_widget(order_detail, "order_detail_640x540") + elif tab_key == "video": + video_table = dialog._table_registry["video"][1] + video_table.horizontalScrollBar().setValue(video_table.horizontalScrollBar().maximum()) + save_state(dialog, "video_upload_action", close=False) + player = RecordingPlayerDialog( + "https://media.example.invalid/replay.mp4", parent=dialog + ) + save_widget(player, "video_player_820x560") + dialog.close() + app.processEvents() + + daily_editor = DailyRecordEditorDialog( + "blood", + { + "id": 6101, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "record_time": "08:20", + "fasting_blood_sugar": 8.2, + "postprandial_blood_sugar": 12.4, + "systolic_pressure": 146, + "diastolic_pressure": 92, + "western_medicine": "二甲双胍", + "remark": "继续观察餐后波动。", + }, + ) + daily_editor.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS) + save_widget(daily_editor, "daily_blood_edit_650x620") + + prescription = PrescriptionEditorDialog( + ScreenshotRepository(locked=False), + { + "diagnosis_id": 501, + "appointment_id": 2501, + "patient_name": "林晓岚", + "gender": 0, + "age": 34, + "visit_no": "1K00002501", + "tongue": "舌淡红、苔薄白", + "pulse": "脉细", + "clinical_diagnosis": "气阴两虚", + "doctor_name": "陈医生", + }, + mode="add", + current_user={"id": 1, "name": "陈医生"}, + ) + prescription.setObjectName("DiagnosisPrescriptionEditor") + prescription.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS) + save_widget(prescription, "prescription_editor_920x780") + return rendered + + +if __name__ == "__main__": + for path in render(): + print(path) diff --git a/app/scripts/render_diagnosis_media_thumbnail_visual.py b/app/scripts/render_diagnosis_media_thumbnail_visual.py new file mode 100644 index 000000000..bc6e48e2b --- /dev/null +++ b/app/scripts/render_diagnosis_media_thumbnail_visual.py @@ -0,0 +1,158 @@ +"""Render the Notes, Chat, and Daily-lower parity states without external network.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QBuffer, QIODevice, QPointF, Qt, QTimer +from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPen +from PySide6.QtWidgets import QApplication +from render_diagnosis_detail_visual import ScreenshotRepository, _run_immediately + +from doctor_workstation.core import PermissionSet +from doctor_workstation.ui import diagnosis_drawer +from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module +from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog + + +class MediaScreenshotRepository(ScreenshotRepository): + """Use the established artifact fixture with production-shaped media URLs.""" + + def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + notes = super().get_doctor_notes(diagnosis_id) + notes[0]["tongue_images"] = [ + {"url": "https://media.example.invalid/diagnosis/501/tongue-7001.jpg"} + ] + return notes + + +def _image_bytes(kind: str) -> bytes: + if kind == "tongue": + image = QImage(240, 180, QImage.Format.Format_ARGB32) + image.fill(QColor("#F4E3D8")) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(QPen(QColor("#D1A08F"), 3)) + painter.setBrush(QColor("#D77F83")) + painter.drawEllipse(52, 18, 136, 162) + painter.setPen(QPen(QColor("#B75F65"), 2)) + painter.drawLine(120, 42, 120, 148) + painter.setPen(QPen(QColor("#F7D8D2"), 10, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawLine(91, 55, 103, 132) + painter.drawLine(149, 55, 137, 132) + painter.end() + else: + image = QImage(640, 360, QImage.Format.Format_ARGB32) + image.fill(QColor("#F8FAFC")) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(QPen(QColor("#DCE3EC"), 1)) + for x in range(48, 610, 70): + painter.drawLine(x, 36, x, 316) + for y in range(56, 310, 52): + painter.drawLine(42, y, 606, y) + points = [ + QPointF(48, 250), + QPointF(125, 226), + QPointF(205, 238), + QPointF(285, 168), + QPointF(365, 190), + QPointF(445, 112), + QPointF(525, 142), + QPointF(602, 82), + ] + painter.setPen(QPen(QColor("#0F766E"), 7, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + for start, end in zip(points, points[1:], strict=False): + painter.drawLine(start, end) + painter.setPen(QPen(QColor("#FFFFFF"), 3)) + painter.setBrush(QColor("#0F766E")) + for point in points: + painter.drawEllipse(point, 8, 8) + painter.end() + buffer = QBuffer() + if not buffer.open(QIODevice.OpenModeFlag.WriteOnly) or not image.save(buffer, "PNG"): + raise RuntimeError("failed to encode deterministic visual image") + return bytes(buffer.data()) + + +TONGUE_IMAGE = _image_bytes("tongue") +CHAT_IMAGE = _image_bytes("chat") + + +def _offline_remote_load(self: diagnosis_drawer._RemoteImageButton, source: str) -> None: + """Preserve asynchronous completion while replacing only external transport.""" + + self._source = str(source).strip() + generation = self._invalidate_request() + self.setToolTip(self._source) + self._show_loading() + payload = TONGUE_IMAGE if self.objectName() == "DiagnosisTongueThumb" else CHAT_IMAGE + QTimer.singleShot(0, lambda: self._apply_payload(payload, generation)) + + +def _prepare_app() -> QApplication: + app = QApplication.instance() or QApplication([]) + app.setFont(QFont("Microsoft YaHei", 9)) + font_path = Path("C:/Windows/Fonts/msyh.ttc") + if font_path.is_file(): + font_id = QFontDatabase.addApplicationFont(str(font_path)) + families = QFontDatabase.applicationFontFamilies(font_id) + if families: + app.setFont(QFont(families[0], 9)) + return app + + +def _open_tab(app: QApplication, key: str) -> DiagnosisDialog: + dialog = DiagnosisDialog( + MediaScreenshotRepository(locked=False), + permissions=PermissionSet(["*"]), + ) + dialog.resize(1024, 640) + dialog.open_for(501, editable=True) + index = next( + item for item in range(dialog.tabs.count()) if dialog.tabs.tabBar().tabData(item) == key + ) + dialog.tabs.setCurrentIndex(index) + for _ in range(10): + app.processEvents() + return dialog + + +def render() -> list[Path]: + app = _prepare_app() + diagnosis_module.run_async = _run_immediately + diagnosis_drawer._RemoteImageButton.load_url = _offline_remote_load + output = Path(__file__).resolve().parents[1] / "artifacts" / "diagnosis_visual" + output.mkdir(parents=True, exist_ok=True) + rendered: list[Path] = [] + + for tab_key, state_name in (("notes", "notes_actions"), ("chat", "chat_archive")): + dialog = _open_tab(app, tab_key) + path = output / f"diagnosis_state_{state_name}_1024x640.png" + if not dialog.grab().save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + rendered.append(path) + dialog.close() + app.processEvents() + + daily = _open_tab(app, "daily") + page = daily._tab_pages["daily"] + page.verticalScrollBar().setValue(page.verticalScrollBar().maximum()) + for _ in range(4): + app.processEvents() + daily_path = output / "diagnosis_state_daily_lower_1024x640.png" + if not daily.grab().save(str(daily_path), "PNG"): + raise RuntimeError(f"failed to save {daily_path}") + rendered.append(daily_path) + daily.close() + app.processEvents() + return rendered + + +if __name__ == "__main__": + for rendered_path in render(): + print(rendered_path) diff --git a/app/scripts/render_diagnosis_order_video_visual.py b/app/scripts/render_diagnosis_order_video_visual.py new file mode 100644 index 000000000..11898537a --- /dev/null +++ b/app/scripts/render_diagnosis_order_video_visual.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from PySide6.QtCore import Qt # noqa: E402 +from PySide6.QtGui import QFont, QFontDatabase # noqa: E402 +from PySide6.QtWidgets import QApplication, QFrame, QLabel, QVBoxLayout # noqa: E402 + +from doctor_workstation.ui.diagnosis_drawer import DIAGNOSIS_QSS # noqa: E402 +from doctor_workstation.ui.diagnosis_media import ( # noqa: E402 + InlineRecordingPlayer, + RecordingPlaybackCell, +) +from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog # noqa: E402 + + +class RenderRepository: + def get_prescription_order(self, order_id: int) -> dict[str, Any]: + return {"id": order_id} + + def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> dict[str, Any]: + return {"diagnosis_id": diagnosis_id, "revisit_slot_start_offset": offset} + + def upload_call_recording( + self, + path: str, + diagnosis_id: int, + *, + call_record_id: int | None = None, + ) -> dict[str, Any]: + return { + "path": path, + "diagnosis_id": diagnosis_id, + "call_record_id": call_record_id, + } + + +def order_detail() -> dict[str, Any]: + return { + "id": 801, + "order_no": "RX-20260811-0801", + "gancao_reciperl_order_no": "GC-260811-4728", + "diagnosis_id": 501, + "amount": 428.5, + "linked_pay_paid_total": 300, + "refund_amount": 20, + "agency_collect_amount": 128.5, + "fulfillment_status": 5, + "prescription_audit_status": 1, + "payment_slip_audit_status": 1, + "doctor_name": "陈医生", + "creator_name": "赵医助", + "creator_account": "assistant.zhao", + "create_time": "2026-08-11 09:26", + "recipient_name": "林晓岚", + "recipient_phone": "18600004218", + "shipping_province": "河南省", + "shipping_city": "洛阳市", + "shipping_district": "洛龙区", + "shipping_address": "开元大道 88 号", + "is_follow_up": 1, + "medication_days": 14, + "service_channel": "线上复诊", + "service_package": ["调理服务", "复诊随访"], + "fee_type": 3, + "tracking_number": "SF164208110801", + "express_company": "sf", + "remark_assistant": "工作日下午送达", + "prescription_audit_remark": "辨证与用量已复核", + "payment_slip_audit_remark": "收款凭证已核验", + "prescription": { + "id": 601, + "sn": "RX601", + "patient_name": "林晓岚", + "gender_desc": "女", + "age": 34, + "phone": "18600004218", + "prescription_date": "2026-08-11", + "doctor_name": "陈医生", + "prescription_type": "饮片", + "clinical_diagnosis": "气阴两虚", + "dose_count": 14, + "dose_unit": "剂", + "usage_instruction": "水煎服", + "amount": 428.5, + "audit_status": 1, + "dosage_amount": 180, + "dosage_unit": "g", + "need_decoction": 1, + "times_per_day": 2, + "usage_days": 14, + "dietary_taboo": ["辛辣", "生冷"], + "void_status": 0, + }, + "linked_pay_orders": [ + { + "id": 9101, + "order_no": "PAY-9101", + "order_type_desc": "药品费用", + "amount": 300, + "status_desc": "已支付", + "creator_name": "赵医助", + "create_time": "2026-08-11 09:32", + } + ], + "unlinked_pay_orders": [], + "logistics_trace": { + "state_text": "运输中", + "carrier_label": "顺丰速运", + "traces": [ + { + "time": "2026-08-11 16:10", + "status": "运输中", + "context": "快件已离开洛阳集散中心", + }, + { + "time": "2026-08-11 13:06", + "status": "已揽收", + "context": "顺丰速运已收取快件", + }, + ], + }, + "logs": [ + { + "admin_name": "赵医助", + "action": "ship", + "summary": "确认发货并填写顺丰运单", + "create_time": "2026-08-11 13:08", + } + ], + } + + +def render_order(app: QApplication, output: Path) -> None: + host = DiagnosisDialog(RenderRepository(), permissions=["*"]) + host.resize(1440, 900) + host.show() + drawer = host._build_order_detail_dialog(order_detail(), 801) + drawer.show() + app.processEvents() + image = drawer.grab() + if not image.save(str(output), "PNG"): + raise RuntimeError(f"failed to save {output}") + host.resize(1024, 640) + drawer.sync_to_owner() + app.processEvents() + compact = output.parent / "diagnosis_state_order_detail_drawer_1024x640.png" + if not drawer.grab().save(str(compact), "PNG"): + raise RuntimeError(f"failed to save {compact}") + legacy = output.parent / "diagnosis_state_order_detail_640x540.png" + if not drawer.grab().save(str(legacy), "PNG"): + raise RuntimeError(f"failed to save {legacy}") + drawer.close() + host.close() + app.processEvents() + + +def render_video(app: QApplication, output: Path) -> None: + dialog = DiagnosisDialog(RenderRepository(), permissions=["*"]) + dialog._editable = True + dialog._can_video_upload = True + dialog._diagnosis_id = 501 + dialog._tab_generations["video"] = 3 + dialog._fill_video( + [ + { + "id": 48, + "recording_urls_list": [ + "https://media.example.invalid/consultation-48.mp4", + "https://bucket.cos.ap-shanghai.myqcloud.com/consultation-48/index.m3u8", + "https://media.example.invalid/consultation-48.webm", + ], + "start_time_text": "2026-08-11 10:02:18", + "end_time_text": "2026-08-11 10:26:43", + "call_type": 2, + "room_id": "room-501-20260811", + "duration_text": "24分25秒", + "status": 2, + "recording_status_text": "录制完成", + }, + { + "id": 47, + "recording_urls_list": [], + "start_time_text": "2026-08-04 09:18:05", + "end_time_text": "2026-08-04 09:23:17", + "call_type": 1, + "room_id": "room-501-20260804", + "duration_text": "5分12秒", + "status": 3, + "recording_status_text": "暂无录制", + }, + ] + ) + + preview = QFrame() + preview.setObjectName("DiagnosisVideoRender") + preview.setStyleSheet( + DIAGNOSIS_QSS + + "QFrame#DiagnosisVideoRender{background:#F6F8FB;}" + + "QLabel#DiagnosisVideoRenderTitle{color:#1F2937;font-size:20px;font-weight:650;}" + ) + layout = QVBoxLayout(preview) + layout.setContentsMargins(22, 18, 22, 22) + layout.setSpacing(12) + title = QLabel("视频录制回放") + title.setObjectName("DiagnosisVideoRenderTitle") + title.setFixedHeight(32) + title.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + layout.addWidget(title) + page = dialog._tab_pages["video"] + page.setParent(preview) + page.show() + dialog.video_upload_button.show() + layout.addWidget(page, 1) + preview.resize(1200, 560) + preview.show() + app.processEvents() + table = dialog._table_registry["video"][1] + playback = table.cellWidget(0, 0) + if not isinstance(playback, RecordingPlaybackCell): + raise RuntimeError("video render is missing the inline playback cell") + player = playback.findChild(InlineRecordingPlayer) + if ( + player is None + or playback.height() < playback.minimumSizeHint().height() + or not 158 <= player.height() <= 180 + or table.rowHeight(0) < playback.required_table_row_height() + ): + raise RuntimeError("video render clipped the inline playback geometry") + image = preview.grab() + if not image.save(str(output), "PNG"): + raise RuntimeError(f"failed to save {output}") + preview.resize(1024, 640) + app.processEvents() + replay_state = output.parent / "diagnosis_state_video_replay_1024x640.png" + if not preview.grab().save(str(replay_state), "PNG"): + raise RuntimeError(f"failed to save {replay_state}") + table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum()) + app.processEvents() + upload_state = output.parent / "diagnosis_state_video_upload_action_1024x640.png" + if not preview.grab().save(str(upload_state), "PNG"): + raise RuntimeError(f"failed to save {upload_state}") + preview.close() + dialog.close() + app.processEvents() + + +def render_order_offset(app: QApplication, output: Path) -> None: + dialog = DiagnosisDialog(RenderRepository(), permissions=["*"]) + dialog._editable = True + dialog._can_offset = True + dialog._can_order_detail = True + dialog._diagnosis_id = 501 + dialog._saved_order_offset = 0 + dialog.order_offset.setVisible(True) + dialog.order_offset_save.setVisible(True) + dialog.order_offset.setValue(2) + dialog._fill_orders( + [ + { + "id": 801, + "order_no": "RX-20260811-0801", + "global_visit_seq": 3, + "counts_for_revisit_rate": 1, + "amount": 428.5, + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "fulfillment_status": 5, + "create_time": "2026-08-11 09:26", + }, + { + "id": 794, + "order_no": "RX-20260724-0794", + "global_visit_seq": 2, + "counts_for_revisit_rate": 1, + "amount": 386, + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "fulfillment_status": 6, + "create_time": "2026-07-24 10:18", + }, + ] + ) + dialog._orders_total = 2 + dialog._update_orders_pager() + + preview = QFrame() + preview.setObjectName("DiagnosisOrderRender") + preview.setStyleSheet( + DIAGNOSIS_QSS + + "QFrame#DiagnosisOrderRender{background:#F6F8FB;}" + + "QLabel#DiagnosisOrderRenderTitle{color:#1F2937;font-size:20px;font-weight:650;}" + ) + layout = QVBoxLayout(preview) + layout.setContentsMargins(22, 18, 22, 22) + layout.setSpacing(12) + title = QLabel("业务订单") + title.setObjectName("DiagnosisOrderRenderTitle") + title.setFixedHeight(32) + layout.addWidget(title) + page = dialog._tab_pages["orders"] + page.setParent(preview) + page.show() + layout.addWidget(page, 1) + preview.resize(1024, 640) + preview.show() + app.processEvents() + if not preview.grab().save(str(output), "PNG"): + raise RuntimeError(f"failed to save {output}") + preview.close() + dialog.close() + app.processEvents() + + +def main() -> int: + output_dir = ROOT / "artifacts" / "diagnosis_visual" + output_dir.mkdir(parents=True, exist_ok=True) + app = QApplication.instance() or QApplication([]) + font_path = Path("C:/Windows/Fonts/msyh.ttc") + if font_path.is_file(): + font_id = QFontDatabase.addApplicationFont(str(font_path)) + families = QFontDatabase.applicationFontFamilies(font_id) + if families: + app.setFont(QFont(families[0], 9)) + order_output = output_dir / "diagnosis_order_detail_drawer_1440x900.png" + video_output = output_dir / "diagnosis_video_inline_player_1200x560.png" + offset_output = output_dir / "diagnosis_state_order_offset_1024x640.png" + render_order(app, order_output) + render_video(app, video_output) + render_order_offset(app, offset_output) + print(order_output) + print(video_output) + print(offset_output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/scripts/render_diagnosis_shell_visual.py b/app/scripts/render_diagnosis_shell_visual.py new file mode 100644 index 000000000..9daa33d27 --- /dev/null +++ b/app/scripts/render_diagnosis_shell_visual.py @@ -0,0 +1,81 @@ +"""Render the diagnosis list inside the real application shell.""" + +from __future__ import annotations + +import os +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QThreadPool +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import QApplication + +from doctor_workstation.services import DemoDoctorRepository +from doctor_workstation.ui import ShellWindow, apply_theme + + +def render() -> list[Path]: + app = QApplication.instance() or QApplication([]) + apply_theme(app) + + font_path = Path("C:/Windows/Fonts/msyh.ttc") + if font_path.is_file(): + font_id = QFontDatabase.addApplicationFont(str(font_path)) + families = QFontDatabase.applicationFontFamilies(font_id) + if families: + app.setFont(QFont(families[0], 9)) + + root = Path(__file__).resolve().parents[1] + output = root / "artifacts" / "diagnosis_visual" + output.mkdir(parents=True, exist_ok=True) + paths: list[Path] = [] + + for width, height in ((1024, 640), (1440, 900)): + repository = DemoDoctorRepository() + session = repository.login( + repository.DEMO_ACCOUNT, + repository.DEMO_PASSWORD, + ) + shell = ShellWindow( + repository, + {"session": session, "demo_mode": True}, + permissions=session.permissions, + ) + shell.resize(width, height) + shell.show() + # Exercise the real visited-page contract so the evidence includes + # a fixed landing tab, a closable visited tab, and the active diagnosis tab. + another_key = next( + ( + key + for key in ("patients", "reception", "prescriptions") + if key in shell.pages and key != "consultations" + ), + None, + ) + if another_key is not None: + shell.navigate(another_key) + shell.navigate("consultations") + QThreadPool.globalInstance().waitForDone(3000) + for _ in range(10): + app.processEvents() + + page = shell.pages["consultations"] + page.poll_timer.stop() + page.page_scroll.verticalScrollBar().setValue(0) + app.processEvents() + + path = output / f"diagnosis_shell_{width}x{height}.png" + if not shell.grab().save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + paths.append(path) + shell.close() + app.processEvents() + + return paths + + +if __name__ == "__main__": + for rendered in render(): + print(rendered) diff --git a/app/scripts/render_diagnosis_visual.py b/app/scripts/render_diagnosis_visual.py new file mode 100644 index 000000000..82acf8b7b --- /dev/null +++ b/app/scripts/render_diagnosis_visual.py @@ -0,0 +1,465 @@ +"""Render deterministic offscreen diagnosis-index reference screenshots.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import Qt, QThreadPool, Signal +from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap +from PySide6.QtWidgets import QApplication, QToolButton, QWidget + +from doctor_workstation.core import PermissionSet +from doctor_workstation.ui.pages import consultations as consultations_module +from doctor_workstation.ui.pages.consultations import ConsultationsPage + + +class _ScreenshotDiagnosisDialog(QWidget): + """Invisible list-rendering seam; no detail drawer is exercised in this script.""" + + saved = Signal() + + def __init__(self, _repository: Any, parent: QWidget | None = None) -> None: + super().__init__(parent) + + def open_for(self, *_args: Any, **_kwargs: Any) -> None: + return None + + +consultations_module.DiagnosisDialog = _ScreenshotDiagnosisDialog + + +def _row(identifier: int, variant: int) -> dict[str, Any]: + common: dict[str, Any] = { + "id": identifier, + "diagnosis_id": identifier, + "patient_id": identifier + 1000, + "patient_name": ("林晓岚", "周明远", "许安然", "沈知夏")[variant % 4], + "gender": 2 if variant % 2 else 1, + "age": 28 + variant, + "assistant_id": 8, + "assistant_name": "赵医助", + "assign_read_at": None if variant == 0 else "2026-08-10 08:30", + "DiagnosisViewRecord": [{"is_confirmed": 1}], + "has_appointment": 1, + "appointment_id": identifier + 2000, + "appointment_status": 1, + "appointment_doctor_id": 18, + "appointments": [ + { + "id": identifier + 2000, + "status": 1, + "doctor_id": 18, + "doctor_name": "陈医生", + "time_text": "今天 09:00-09:30", + } + ], + "latest_appointment_channel_text": "健康顾问转介", + "has_prescription": 1, + "prescription_audit_status": 1, + "prescription_void_status": 0, + "followup_time_text": "2026-08-17 09:00", + "followup_doctor_name": "陈医生", + "followup_rx_voided": 0, + "unserved_days": (1, 4, 8, 2)[variant % 4], + "last_blood_record_at": "2026-08-09 20:10", + "video_call_hint": "未在通话中", + } + if variant == 1: + common.update( + { + "has_appointment": 0, + "appointment_id": 0, + "appointment_status": None, + "appointments": [], + "DiagnosisViewRecord": [{"is_confirmed": 0}], + "assistant_id": 0, + "assistant_name": "", + "has_prescription": 0, + "followup_time_text": "", + "last_blood_record_at": "", + "unserved_days": None, + } + ) + elif variant == 2: + common.update( + { + "appointment_status": 4, + "appointments": [ + { + "id": identifier + 2000, + "status": 4, + "doctor_id": 18, + "doctor_name": "陈医生", + "time_text": "昨天 15:00-15:30", + }, + { + "id": identifier + 2001, + "status": 3, + "doctor_id": 19, + "doctor_name": "李医生", + "time_text": "08-03 10:30-11:00", + }, + ], + "followup_rx_voided": 1, + } + ) + return common + + +class ScreenshotRepository: + """No-network repository used only by the visual renderer.""" + + def __init__(self) -> None: + self.rows = [_row(501 + index, index % 4) for index in range(15)] + + def list_consultations( + self, + *, + page_no: int = 1, + page_size: int = 15, + **_filters: Any, + ) -> dict[str, Any]: + start = max(0, page_no - 1) * page_size + return {"lists": self.rows[start : start + page_size], "count": 42} + + def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]: + values = { + "diagnosis_type": [("中医", "tcm"), ("中西医结合", "integrated")], + "syndrome_type": [("痰湿", "phlegm_damp"), ("气虚", "qi_deficiency")], + "channels": [("健康顾问", "advisor"), ("门诊", "clinic")], + } + return [{"name": name, "value": value} for name, value in values.get(dictionary_type, [])] + + def list_diagnosis_assistants(self) -> list[dict[str, Any]]: + return [{"id": 8, "name": "赵医助"}, {"id": 9, "name": "王医助"}] + + def cancel_diagnosis_appointment(self, appointment_id: int) -> None: + raise RuntimeError(f"visual fixture does not cancel appointment #{appointment_id}") + + def generate_video_qrcode( + self, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, str]: + raise RuntimeError( + f"visual fixture does not generate video QR: {doctor_id}/{patient_id}/{share_user_id}" + ) + + def generate_diagnosis_qrcode( + self, + diagnosis_id: int, + doctor_id: int, + patient_id: int, + share_user_id: int, + ) -> dict[str, str]: + raise RuntimeError( + "visual fixture does not generate diagnosis QR: " + f"{diagnosis_id}/{doctor_id}/{patient_id}/{share_user_id}" + ) + + def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: + raise RuntimeError(f"visual fixture does not load logs for diagnosis #{diagnosis_id}") + + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + raise RuntimeError( + f"visual fixture does not create order: {patient_id}/{order_type}/{amount}/{remark}" + ) + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + raise RuntimeError(f"visual fixture does not generate payment QR: {order_no}") + + # The list only checks these methods as a fail-closed capability contract; + # rendering never starts a call. + def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> None: + raise RuntimeError(f"visual fixture does not issue tickets: {patient_id}/{diagnosis_id}") + + def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> None: + raise RuntimeError( + f"visual fixture does not start calls: {diagnosis_id}/{patient_id}/{call_type}" + ) + + def bind_call_room(self, diagnosis_id: int, room_id: str) -> None: + raise RuntimeError(f"visual fixture does not bind rooms: {diagnosis_id}/{room_id}") + + def end_call(self, diagnosis_id: int) -> None: + raise RuntimeError(f"visual fixture does not end calls: {diagnosis_id}") + + +def _settle(app: QApplication, page: ConsultationsPage) -> None: + QThreadPool.globalInstance().waitForDone(3000) + for _ in range(8): + app.processEvents() + page.poll_timer.stop() + page.loading_overlay.stop() + page.page_scroll.verticalScrollBar().setValue(0) + app.processEvents() + + +def _new_page( + app: QApplication, + repository: ScreenshotRepository, + width: int, + height: int, + *, + permissions: PermissionSet | None = None, +) -> ConsultationsPage: + page = ConsultationsPage( + repository, + permissions=permissions or PermissionSet(["*"]), + current_user={"id": 1, "name": "陈医生"}, + ) + page.resize(width, height) + page.show() + _settle(app, page) + return page + + +def _save(page: ConsultationsPage, path: Path) -> Path: + if not page.grab().save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + return path + + +def _save_with_menu(page: ConsultationsPage, path: Path) -> Path: + """Composite the real QMenu popup over the real page grab for offscreen determinism.""" + + index = page.table_host.model.index(0, 11) + cell = page.table_host.fixed.indexWidget(index) + more = next(button for button in cell.findChildren(QToolButton) if button.menu()) + menu = more.menu() + menu.ensurePolished() + menu.adjustSize() + menu.resize(menu.sizeHint()) + menu.show() + QApplication.processEvents() + page_pixmap = page.grab() + menu_pixmap = menu.grab() + painter = QPainter(page_pixmap) + x = max(16, page.width() - menu_pixmap.width() - 24) + y = min(page.height() - menu_pixmap.height() - 16, 330) + painter.drawPixmap(x, max(16, y), menu_pixmap) + painter.end() + menu.hide() + if not page_pixmap.save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + return path + + +def _payment_qr_fixture() -> QPixmap: + """Create only the renderer's stand-in for a server-fetched QR image.""" + + image = QImage(256, 256, QImage.Format.Format_ARGB32) + image.fill(QColor("#FFFFFF")) + painter = QPainter(image) + painter.setPen(Qt.PenStyle.NoPen) + module = 8 + origin = 12 + + def draw_finder(left: int, top: int) -> None: + painter.setBrush(QColor("#111111")) + painter.drawRect(origin + left * module, origin + top * module, 7 * module, 7 * module) + painter.setBrush(QColor("#FFFFFF")) + painter.drawRect( + origin + (left + 1) * module, + origin + (top + 1) * module, + 5 * module, + 5 * module, + ) + painter.setBrush(QColor("#111111")) + painter.drawRect( + origin + (left + 2) * module, + origin + (top + 2) * module, + 3 * module, + 3 * module, + ) + + for y in range(29): + for x in range(29): + inside_finder = (x < 8 and y < 8) or (x > 20 and y < 8) or (x < 8 and y > 20) + if not inside_finder and ((x * 11 + y * 7 + x * y) % 5 in {0, 2}): + painter.setBrush(QColor("#111111")) + painter.drawRect(origin + x * module, origin + y * module, module, module) + draw_finder(0, 0) + draw_finder(22, 0) + draw_finder(0, 22) + painter.end() + return QPixmap.fromImage(image) + + +def _save_with_payment_qr( + page: ConsultationsPage, + path: Path, +) -> Path: + record = page.table_host.model.rows[0] + dialog = consultations_module._DiagnosisOrderQrDialog( + record, + "ZYT202608100001", + page, + ) + dialog.set_result("https://api.zyt.example/payment/ZYT202608100001/qrcode.png") + dialog.preview.show_loading() + dialog.preview.setText("") + dialog.preview.setPixmap(_payment_qr_fixture()) + dialog.ensurePolished() + dialog.adjustSize() + dialog.show() + QApplication.processEvents() + + page_pixmap = page.grab() + dialog_pixmap = dialog.grab() + painter = QPainter(page_pixmap) + painter.fillRect(page_pixmap.rect(), QColor(0, 0, 0, 52)) + x = (page_pixmap.width() - dialog_pixmap.width()) // 2 + y = max(18, (page_pixmap.height() - dialog_pixmap.height()) // 2) + painter.fillRect( + x - 8, + y - 8, + dialog_pixmap.width() + 16, + dialog_pixmap.height() + 16, + QColor(0, 0, 0, 34), + ) + painter.drawPixmap(x, y, dialog_pixmap) + painter.end() + dialog.close() + if not page_pixmap.save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + return path + + +def render() -> list[Path]: + app = QApplication.instance() or QApplication([]) + # The offscreen Windows plugin does not enumerate system fonts. Register + # the same CJK face used by the production QSS when it is available. + font_path = Path("C:/Windows/Fonts/msyh.ttc") + if font_path.is_file(): + font_id = QFontDatabase.addApplicationFont(str(font_path)) + families = QFontDatabase.applicationFontFamilies(font_id) + if families: + app.setFont(QFont(families[0], 9)) + root = Path(__file__).resolve().parents[1] + output = root / "artifacts" / "diagnosis_visual" + output.mkdir(parents=True, exist_ok=True) + paths: list[Path] = [] + repository = ScreenshotRepository() + for width, height in ((1024, 640), (1440, 900)): + page = _new_page(app, repository, width, height) + path = output / f"diagnosis_{width}x{height}.png" + paths.append(_save(page, path)) + page.close() + app.processEvents() + + scenarios: tuple[tuple[str, int, int, str], ...] = ( + ("loading", 1280, 800, "loading"), + ("empty", 1280, 800, "empty"), + ("error", 1280, 800, "error"), + ("hover_warning", 1280, 800, "hover"), + ("focus", 1280, 800, "focus"), + ("permissions_cropped", 1280, 800, "permissions"), + ("horizontal_scroll", 1024, 640, "horizontal"), + ("pending_assign", 1280, 800, "pending_assign"), + ("advanced_filters", 1280, 800, "advanced"), + ) + for filename, width, height, state in scenarios: + permissions = ( + PermissionSet(["tcm.diagnosis/readonlyDetail"]) + if state == "permissions" + else PermissionSet(["*"]) + ) + page = _new_page(app, repository, width, height, permissions=permissions) + if state == "loading": + page.table_host.begin_loading() + page.loading_overlay.start() + elif state == "empty": + page.table_host.set_rows([]) + page.pager.update_state(1, 0) + elif state == "error": + page.table_host.set_rows([]) + page.pager.update_state(1, 0) + page.table_host.show_error("列表加载失败,请重试\n网络连接不可用") + elif state == "hover": + page.table_host.model.set_hover_row(1) + page.table_host.main.viewport().update() + page.table_host.fixed.viewport().update() + elif state == "focus": + page.activateWindow() + page.keyword_edit.setFocus(Qt.FocusReason.TabFocusReason) + elif state == "horizontal": + scrollbar = page.table_host.main.horizontalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + elif state == "pending_assign": + page._appointment_date = "" + page._pending_assign = "1" + page._update_quick_buttons() + elif state == "advanced": + page.more_filter_button.setChecked(True) + page._toggle_advanced_filters(True) + app.processEvents() + page.page_scroll.verticalScrollBar().setValue(0) + if state != "focus": + app.processEvents() + path = output / f"diagnosis_{filename}_{width}x{height}.png" + paths.append(_save(page, path)) + page.loading_overlay.stop() + page.close() + app.processEvents() + + page = _new_page(app, repository, 1280, 800) + full_menu = output / "diagnosis_full_menu_1280x800.png" + paths.append(_save_with_menu(page, full_menu)) + page.close() + app.processEvents() + + page = _new_page(app, repository, 1280, 800) + payment_qr = output / "diagnosis_order_qrcode_1280x800.png" + paths.append(_save_with_payment_qr(page, payment_qr)) + page.close() + app.processEvents() + + page = _new_page(app, repository, 1280, 800) + double = _row(880, 0) + double.update( + { + "appointment_id": 2880, + "appointment_status": 1, + "appointments": [ + { + "id": 2880, + "status": 1, + "doctor_id": 18, + "doctor_name": "陈医生", + "time_text": "今天 09:00-09:30", + }, + { + "id": 2881, + "status": 4, + "doctor_id": 19, + "doctor_name": "李医生", + "time_text": "昨天 15:00-15:30", + }, + ], + } + ) + page.table_host.set_rows([double, _row(881, 1), _row(882, 3)]) + page.pager.update_state(1, 3) + for _ in range(4): + page.page_scroll.verticalScrollBar().setValue(0) + app.processEvents() + double_cancel = output / "diagnosis_double_appointment_cancel_1280x800.png" + paths.append(_save(page, double_cancel)) + page.close() + app.processEvents() + return paths + + +if __name__ == "__main__": + for rendered in render(): + print(rendered) diff --git a/app/scripts/render_prescription_editor_visual.py b/app/scripts/render_prescription_editor_visual.py new file mode 100644 index 000000000..be9d1cd4c --- /dev/null +++ b/app/scripts/render_prescription_editor_visual.py @@ -0,0 +1,146 @@ +"""Render deterministic prescription-editor drawer references.""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import QApplication, QWidget + +from doctor_workstation.ui import apply_theme +from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module +from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog + + +class VisualRepository: + def list_medicines(self, **_filters: Any) -> dict[str, Any]: + return {"lists": [], "count": 0} + + +def _seed() -> dict[str, Any]: + return { + "diagnosis_id": 501, + "appointment_id": 2501, + "patient_name": "林晓岚", + "phone": "13800138000", + "gender": 0, + "age": 34, + "visit_no": "1K00002501", + "prescription_date": "2026-08-11", + "tongue": "舌淡红、苔薄白", + "pulse": "面色少华", + "pulse_condition": "脉细", + "clinical_diagnosis": "气阴两虚,脾气不足", + "prescription_type": "浓缩水丸", + "dosage_amount": 3, + "dosage_unit": "g", + "dosage_bag_count": 2, + "dose_count": 7, + "dose_unit": "剂", + "usage_days": 7, + "times_per_day": 2, + "usage_instruction": "早晚各一次,温水送服", + "usage_time": "饭后", + "usage_way": "温水送服", + "dietary_taboo": ["生冷食物", "浓茶"], + "usage_notes": "服药期间规律记录空腹及餐后血糖。", + "aux_usage": { + "dosage_amount": 2, + "dosage_bag_count": 1, + "times_per_day": 1, + "usage_days": 7, + "prescription_name": "宁心辅方", + }, + "doctor_name": "陈医生", + "herbs": [ + {"medicine_id": 11, "name": "黄芪", "dosage": 15, "formula_type": "主方"}, + {"medicine_id": 12, "name": "党参", "dosage": 12, "formula_type": "主方"}, + {"medicine_id": 13, "name": "白术", "dosage": 10, "formula_type": "主方"}, + {"medicine_id": 14, "name": "茯苓", "dosage": 12, "formula_type": "主方"}, + {"medicine_id": 15, "name": "麦冬", "dosage": 9, "formula_type": "主方"}, + {"medicine_id": 16, "name": "五味子", "dosage": 6, "formula_type": "主方"}, + {"medicine_id": 17, "name": "酸枣仁", "dosage": 12, "formula_type": "辅方"}, + {"medicine_id": 18, "name": "远志", "dosage": 6, "formula_type": "辅方"}, + ], + } + + +def _make_editor(host: QWidget) -> PrescriptionEditorDialog: + editor = PrescriptionEditorDialog( + VisualRepository(), + _seed(), + mode="add", + current_user=SimpleNamespace(id=1, name="陈医生"), + parent=host, + ) + # Diagnosis-detail callers apply their scoped sheet after construction. The + # prescription surface owns its own scoped rules, so this also verifies that + # real entry path rather than a renderer-only appearance. + editor.setObjectName("DiagnosisPrescriptionEditor") + editor.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS) + return editor + + +def _settle(app: QApplication, rounds: int = 8) -> None: + for _ in range(rounds): + app.processEvents() + + +def render() -> list[Path]: + app = QApplication.instance() or QApplication([]) + apply_theme(app) + font_path = Path("C:/Windows/Fonts/msyh.ttc") + if font_path.is_file(): + font_id = QFontDatabase.addApplicationFont(str(font_path)) + families = QFontDatabase.applicationFontFamilies(font_id) + if families: + app.setFont(QFont(families[0], 9)) + + output = Path(__file__).resolve().parents[1] / "artifacts" / "diagnosis_visual" + output.mkdir(parents=True, exist_ok=True) + rendered: list[Path] = [] + + for host_width, height in ((1440, 900), (1024, 768), (920, 780)): + host = QWidget() + host.resize(host_width, height) + host.show() + editor = _make_editor(host) + editor.show() + _settle(app) + editor.body_scroll.verticalScrollBar().setValue(0) + _settle(app, 3) + width = min(PrescriptionEditorDialog.DRAWER_WIDTH, host_width) + path = output / f"diagnosis_state_prescription_editor_{width}x{height}.png" + if not editor.grab().save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + rendered.append(path) + editor.close() + host.close() + _settle(app, 2) + + host = QWidget() + host.resize(1440, 900) + host.show() + editor = _make_editor(host) + editor.show() + _settle(app) + for index, state in ((1, "herbs"), (2, "usage"), (3, "signature")): + editor.tabs.setCurrentIndex(index) + _settle(app, 4) + path = output / f"diagnosis_state_prescription_editor_{state}_1200x900.png" + if not editor.grab().save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + rendered.append(path) + editor.close() + host.close() + return rendered + + +if __name__ == "__main__": + for rendered_path in render(): + print(rendered_path) diff --git a/app/scripts/run_windows.ps1 b/app/scripts/run_windows.ps1 index 185d07092..3c34a729f 100644 --- a/app/scripts/run_windows.ps1 +++ b/app/scripts/run_windows.ps1 @@ -7,6 +7,7 @@ $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" +$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache" $SourceRoot = Join-Path $ProjectRoot "src" function Test-ProjectPython { @@ -67,11 +68,21 @@ try { "UV_PROJECT_ENVIRONMENT", "Process" ) + $PreviousUvCache = [Environment]::GetEnvironmentVariable( + "UV_CACHE_DIR", + "Process" + ) + New-Item -ItemType Directory -Path $ProjectUvCache -Force | Out-Null [Environment]::SetEnvironmentVariable( "UV_PROJECT_ENVIRONMENT", (Join-Path $ProjectRoot ".venv"), "Process" ) + [Environment]::SetEnvironmentVariable( + "UV_CACHE_DIR", + $ProjectUvCache, + "Process" + ) Push-Location $ProjectRoot try { & $Uv sync --frozen @@ -86,6 +97,11 @@ try { $PreviousProjectEnvironment, "Process" ) + [Environment]::SetEnvironmentVariable( + "UV_CACHE_DIR", + $PreviousUvCache, + "Process" + ) } if (-not (Test-ProjectPython -Candidate $ProjectPython)) { throw "uv completed, but the project Python is still unusable: $ProjectPython" diff --git a/app/src/doctor_workstation/services/mock_repository.py b/app/src/doctor_workstation/services/mock_repository.py index 5294fc994..7a259a54e 100644 --- a/app/src/doctor_workstation/services/mock_repository.py +++ b/app/src/doctor_workstation/services/mock_repository.py @@ -28,6 +28,7 @@ from .repository import ( AuditAction, _audit_action, _body, + _daily_record_body, _identified_body, _material_kind, _prescription_payload, @@ -53,6 +54,16 @@ DEMO_PERMISSIONS: tuple[str, ...] = ( "tcm.diagnosis/add", "tcm.diagnosis/delete", "tcm.diagnosis/readonlyDetail", + "tcm.diagnosis/dailyRecord", + "tcm.diagnosis/chufang", + "tcm.diagnosis/huifang", + "tcm.diagnosis/chat", + "tcm.diagnosis/patientOrders", + "tcm.diagnosis/setRevisitSlotStartOffset", + "tcm.diagnosis/guahaoLogList", + "tcm.diagnosis/guahao", + "tcm.diagnosis/order", + "tcm.diagnosis/qrcode", "tcm.diagnosis/videoQr", "tcm.diagnosis/kaifang", "tcm.diagnosis/getCallSignature", @@ -140,6 +151,105 @@ class DemoDoctorRepository: 504: [], } self._calls: dict[int, dict[str, Any]] = {} + self._call_records: dict[int, list[dict[str, Any]]] = { + 501: [ + { + "id": 1, + "diagnosis_id": 501, + "call_type": 2, + "room_id": "demo-room-501", + "status": 2, + "status_text": "已结束", + "recording_status_text": "录制完成", + "recording_urls_list": ["https://media.example.invalid/demo/diagnosis-501.mp4"], + "start_time_text": f"{self._today.isoformat()} 09:10:00", + "end_time_text": f"{self._today.isoformat()} 09:22:00", + "duration_text": "12分00秒", + } + ], + 502: [], + 503: [], + 504: [], + } + self._im_messages: dict[int, list[dict[str, Any]]] = { + 501: [ + { + "msg_id": "demo-text-1", + "msg_type": "text", + "text": "今天空腹血糖 5.8,早餐后精神不错。", + "is_from_doctor": False, + "time": int(datetime.now().timestamp()) - 900, + }, + { + "msg_id": "demo-image-1", + "msg_type": "image", + "image_url": "https://media.example.invalid/demo/blood-log.jpg", + "is_from_doctor": False, + "time": int(datetime.now().timestamp()) - 600, + }, + { + "msg_id": "demo-file-1", + "msg_type": "file", + "file_url": "https://media.example.invalid/demo/check-report.pdf", + "file_name": "检查报告.pdf", + "is_from_doctor": True, + "from_staff_name": "陈医生", + "time": int(datetime.now().timestamp()) - 300, + }, + ], + 502: [], + 503: [], + 504: [], + } + self._blood_records: dict[int, list[dict[str, Any]]] = { + 501: [ + { + "id": 1, + "diagnosis_id": 501, + "patient_id": 301, + "record_date": self._today.isoformat(), + "fasting_blood_sugar": 5.8, + "systolic_pressure": 126, + "diastolic_pressure": 78, + "source": 0, + } + ], + 502: [], + 503: [], + 504: [], + } + self._diet_records: dict[int, list[dict[str, Any]]] = { + 501: [ + { + "id": 2, + "diagnosis_id": 501, + "patient_id": 301, + "record_date": self._today.isoformat(), + "breakfast_foods": "小米粥、鸡蛋", + "note": "清淡饮食", + } + ], + 502: [], + 503: [], + 504: [], + } + self._exercise_records: dict[int, list[dict[str, Any]]] = { + 501: [ + { + "id": 3, + "diagnosis_id": 501, + "patient_id": 301, + "record_date": self._today.isoformat(), + "exercise_type": "散步", + "duration": 30, + "intensity": 1, + } + ], + 502: [], + 503: [], + 504: [], + } + self._diagnosis_orders: list[dict[str, Any]] = [] self._assign_logs: dict[int, list[dict[str, Any]]] = { 501: [ { @@ -168,7 +278,9 @@ class DemoDoctorRepository: } self._next_note_id = 2 self._next_material_id = 1 - self._next_call_id = 1 + self._next_call_id = 2 + self._next_daily_record_id = 4 + self._next_generic_order_id = 1 self._next_order_id = max((row["id"] for row in self._patient_orders), default=0) + 1 self._next_todo_id = 1 @@ -387,7 +499,7 @@ class DemoDoctorRepository: def upload_material( self, path: str | PathLike[str], - material_type: Literal["image", "file", "tongue_images", "report_files"], + material_type: Literal["image", "video", "file", "tongue_images", "report_files"], cid: int = 0, ) -> str: """Copy a local-material identity into a safe synthetic server URI.""" @@ -1365,6 +1477,13 @@ class DemoDoctorRepository: consultation.has_appointment = False return {"id": appointment_id, "status": 2, "status_desc": "已取消"} + def cancel_diagnosis_appointment(self, appointment_id: int) -> dict[str, Any]: + """Exact demo counterpart of ``doctor.appointment/cancel``.""" + + if appointment_id <= 0: + raise ValueError("appointment_id must be positive") + return self.cancel_patient_appointment(appointment_id) + def patient_detail(self, diagnosis_id: int) -> dict[str, Any]: """Return the complete readonly diagnosis aggregate.""" @@ -1547,6 +1666,11 @@ class DemoDoctorRepository: self._assign_logs[diagnosis_id] = [] self._tracking_notes[diagnosis_id] = [] self._todos[diagnosis_id] = [] + self._blood_records[diagnosis_id] = [] + self._diet_records[diagnosis_id] = [] + self._exercise_records[diagnosis_id] = [] + self._call_records[diagnosis_id] = [] + self._im_messages[diagnosis_id] = [] return deepcopy(dict(consultation.raw)) def update_diagnosis( @@ -1593,11 +1717,19 @@ class DemoDoctorRepository: self._assign_logs.pop(diagnosis_id, None) self._tracking_notes.pop(diagnosis_id, None) self._todos.pop(diagnosis_id, None) + self._blood_records.pop(diagnosis_id, None) + self._diet_records.pop(diagnosis_id, None) + self._exercise_records.pop(diagnosis_id, None) + self._call_records.pop(diagnosis_id, None) + self._im_messages.pop(diagnosis_id, None) return {"id": diagnosis_id, "deleted": True} def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> dict[str, Any]: """Persist the demo revisit-statistics offset.""" + if offset < 0 or offset > 20: + raise ValueError("offset must be between 0 and 20") + with self._lock: consultation = self._find_consultation(diagnosis_id) consultation.raw["revisit_slot_start_offset"] = offset @@ -1677,24 +1809,31 @@ class DemoDoctorRepository: ) -> dict[str, Any]: """Return representative blood, diet and exercise records.""" - self._find_consultation(diagnosis_id) - return { - "diagnosis_id": diagnosis_id, - "start_date": start_date, - "end_date": end_date, - "blood_records": [ + with self._lock: + self._find_consultation(diagnosis_id) + + def in_range(row: Mapping[str, Any]) -> bool: + value = str(row.get("record_date") or "")[:10] + return (not start_date or value >= start_date) and ( + not end_date or value <= end_date + ) + + return deepcopy( { - "record_date": self._today.isoformat(), - "fasting_glucose": 5.8, - "systolic": 126, - "diastolic": 78, + "diagnosis_id": diagnosis_id, + "start_date": start_date, + "end_date": end_date, + "blood_records": [ + row for row in self._blood_records.get(diagnosis_id, []) if in_range(row) + ], + "diet_records": [ + row for row in self._diet_records.get(diagnosis_id, []) if in_range(row) + ], + "exercise_records": [ + row for row in self._exercise_records.get(diagnosis_id, []) if in_range(row) + ], } - ], - "diet_records": [{"record_date": self._today.isoformat(), "content": "清淡饮食"}], - "exercise_records": [ - {"record_date": self._today.isoformat(), "content": "步行 30 分钟"} - ], - } + ) def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: """Return persisted demo tracking notes newest first.""" @@ -1706,8 +1845,8 @@ class DemoDoctorRepository: def add_tracking_note(self, diagnosis_id: int, content: str) -> dict[str, Any]: """Append a durable demo tracking note.""" - if not content.strip(): - raise ValueError("tracking_content is required") + if not content.strip() or len(content.strip()) > 1000: + raise ValueError("tracking_content must contain 1 to 1000 characters") with self._lock: self._find_consultation(diagnosis_id) note = { @@ -1719,6 +1858,111 @@ class DemoDoctorRepository: self._tracking_notes[diagnosis_id].append(note) return deepcopy(note) + def add_blood_record( + self, payload: Mapping[str, Any] | None = None, **fields: Any + ) -> dict[str, Any]: + """Persist a validated demo blood glucose / pressure record.""" + + return self._add_daily_record("blood", payload, fields) + + def update_blood_record( + self, + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None = None, + **fields: Any, + ) -> dict[str, Any]: + """Update a demo blood record.""" + + return self._update_daily_record("blood", record, changes, fields) + + def add_diet_record( + self, payload: Mapping[str, Any] | None = None, **fields: Any + ) -> dict[str, Any]: + """Persist a validated demo diet record.""" + + return self._add_daily_record("diet", payload, fields) + + def update_diet_record( + self, + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None = None, + **fields: Any, + ) -> dict[str, Any]: + """Update a demo diet record.""" + + return self._update_daily_record("diet", record, changes, fields) + + def add_exercise_record( + self, payload: Mapping[str, Any] | None = None, **fields: Any + ) -> dict[str, Any]: + """Persist a validated demo exercise record.""" + + return self._add_daily_record("exercise", payload, fields) + + def update_exercise_record( + self, + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None = None, + **fields: Any, + ) -> dict[str, Any]: + """Update a demo exercise record.""" + + return self._update_daily_record("exercise", record, changes, fields) + + def _add_daily_record( + self, + kind: Literal["blood", "diet", "exercise"], + payload: Mapping[str, Any] | None, + fields: Mapping[str, Any], + ) -> dict[str, Any]: + body = _daily_record_body(kind, payload, fields) + diagnosis_id = int(body["diagnosis_id"]) + with self._lock: + self._find_consultation(diagnosis_id) + body["id"] = self._next_daily_record_id + body.setdefault("source", 0) + self._next_daily_record_id += 1 + self._daily_records(kind).setdefault(diagnosis_id, []).append(body) + return deepcopy(body) + + def _update_daily_record( + self, + kind: Literal["blood", "diet", "exercise"], + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None, + fields: Mapping[str, Any], + ) -> dict[str, Any]: + body = _identified_body(record, changes, fields) + record_id = int(body["id"]) + diagnosis_id = int(body.get("diagnosis_id") or 0) + with self._lock: + collections = self._daily_records(kind) + candidates = ( + collections.get(diagnosis_id, []) + if diagnosis_id > 0 + else [row for rows in collections.values() for row in rows] + ) + current = next( + (row for row in candidates if int(row.get("id") or 0) == record_id), None + ) + if current is None: + raise RepositoryNotFoundError(f"{kind} record {record_id} not found") + merged = dict(current) + merged.update(body) + validated = _daily_record_body(kind, merged, {}) + current.clear() + current.update(validated) + return deepcopy(current) + + def _daily_records( + self, kind: Literal["blood", "diet", "exercise"] + ) -> dict[int, list[dict[str, Any]]]: + if kind == "blood": + return self._blood_records + if kind == "diet": + return self._diet_records + return self._exercise_records + def list_diagnosis_todos( self, diagnosis_id: int, @@ -1743,15 +1987,24 @@ class DemoDoctorRepository: ) -> dict[str, Any]: """Create and retain a demo follow-up todo.""" + clean_content = content.strip() + if not clean_content or len(clean_content) > 500: + raise ValueError("todo content must contain 1 to 500 characters") + if remind_time <= int(datetime.now().timestamp()) + 30: + raise ValueError("remind_time must be at least 30 seconds in the future") with self._lock: self._find_consultation(diagnosis_id) todo = { "id": self._next_todo_id, "diagnosis_id": diagnosis_id, - "content": content.strip(), + "content": clean_content, "remind_time": remind_time, + "remind_time_text": datetime.fromtimestamp(remind_time).strftime("%Y-%m-%d %H:%M"), "status": 0, + "status_text": "待执行", "creator_id": 1001, + "creator_name": "陈医生(演示)", + "can_cancel": True, } self._next_todo_id += 1 self._todos[diagnosis_id].append(todo) @@ -1764,10 +2017,226 @@ class DemoDoctorRepository: for rows in self._todos.values(): for todo in rows: if int(todo.get("id") or 0) == todo_id: + if int(todo.get("status") or 0) != 0: + raise ValueError("only pending todos can be cancelled") todo["status"] = 2 + todo["status_text"] = "已取消" + todo["can_cancel"] = False return deepcopy(todo) raise RepositoryNotFoundError(f"diagnosis todo {todo_id} not found") + def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]: + """Return deterministic demo replay records.""" + + with self._lock: + self._find_consultation(diagnosis_id) + return deepcopy(self._call_records.get(diagnosis_id, [])) + + def create_manual_call_record(self, diagnosis_id: int) -> dict[str, Any]: + """Create an ended demo record capable of receiving a replay.""" + + with self._lock: + self._find_consultation(diagnosis_id) + record = { + "id": self._next_call_id, + "diagnosis_id": diagnosis_id, + "call_type": 2, + "room_id": "manual-upload", + "status": 2, + "status_text": "已结束", + "recording_status_text": "待上传", + "recording_urls_list": [], + "start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "), + "end_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "), + "duration_text": "0秒", + } + self._next_call_id += 1 + self._call_records.setdefault(diagnosis_id, []).insert(0, record) + return deepcopy(record) + + def attach_local_call_recording( + self, + diagnosis_id: int, + file_url: str, + *, + call_record_id: int | None = None, + ) -> dict[str, Any]: + """Attach a safe demo upload URI to the selected call record.""" + + clean_url = file_url.strip() + if not clean_url: + raise ValueError("file_url is required") + with self._lock: + self._find_consultation(diagnosis_id) + rows = self._call_records.setdefault(diagnosis_id, []) + record = next( + ( + row + for row in rows + if call_record_id is None or int(row.get("id") or 0) == int(call_record_id) + ), + None, + ) + if record is None: + raise RepositoryNotFoundError("call record not found") + urls = record.setdefault("recording_urls_list", []) + if clean_url not in urls: + urls.append(clean_url) + record["recording_status_text"] = "录制完成" + return deepcopy(record) + + def upload_call_recording( + self, + path: str | PathLike[str], + diagnosis_id: int, + *, + call_record_id: int | None = None, + ) -> dict[str, Any]: + """Upload then attach a demo video using the production sequence.""" + + file_url = self.upload_material(path, "video") + target_id = call_record_id + if target_id is None: + target_id = int(self.create_manual_call_record(diagnosis_id)["id"]) + self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id) + return { + "diagnosis_id": diagnosis_id, + "call_record_id": target_id, + "file_url": file_url, + } + + def list_im_chat_messages( + self, diagnosis_id: int, *, only_archived: bool = True + ) -> dict[str, Any]: + """Return the demo archive in the server response envelope shape.""" + + with self._lock: + consultation = self._find_consultation(diagnosis_id) + return deepcopy( + { + "lists": self._im_messages.get(diagnosis_id, []), + "patient_im_id": f"patient_{consultation.patient_id}", + "patient_name": consultation.patient_name, + "doctor_accounts_queried": [] if only_archived else ["doctor_1001"], + "only_archived": bool(only_archived), + } + ) + + def sync_im_chat_messages(self, diagnosis_id: int) -> dict[str, Any]: + """Acknowledge the same asynchronous sync contract in demo mode.""" + + self._find_consultation(diagnosis_id) + return {"queued": True, "diagnosis_id": diagnosis_id} + + def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: + """Build demo audit rows from the diagnosis appointments.""" + + with self._lock: + self._find_consultation(diagnosis_id) + return [ + { + "id": row.id, + "diagnosis_id": diagnosis_id, + "action": "挂号" if int(row.status) != 2 else "取消挂号", + "appointment_id": row.id, + "operator_name": "陈医生(演示)", + "create_time": row.created_at or self._today.isoformat(), + } + for row in self._appointments + if row.diagnosis_id == diagnosis_id + ] + + def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: + """Compatibility name used by the diagnosis list.""" + + return self.list_diagnosis_appointment_logs(diagnosis_id) + + def generate_mini_program_qrcode( + self, payload: Mapping[str, Any] | None = None, **fields: Any + ) -> dict[str, Any]: + """Return a deterministic non-production QR reference.""" + + body = _body(payload, fields) + patient_id = int(body.get("patient_id") or 0) + share_user_id = int(body.get("share_user_id") or 0) + if patient_id <= 0 or share_user_id <= 0: + raise ValueError("patient_id and share_user_id must be positive") + page = str(body.get("mini_program_path") or "") + scene_id = int( + body.get("doctor_id") if page == "pages/login/login" else body.get("diagnosis_id") or 0 + ) + if scene_id <= 0: + raise ValueError("QR scene id must be positive") + kind = "video" if page == "pages/login/login" else "diagnosis" + return { + "qrcode_url": f"https://demo.invalid/qrcode/{kind}/{scene_id}.png", + "diagnosis_id": int(body.get("diagnosis_id") or 0), + "patient_id": patient_id, + } + + def generate_video_qrcode( + self, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, Any]: + """Generate the demo video-login QR.""" + + return self.generate_mini_program_qrcode( + diagnosis_id=doctor_id, + doctor_id=doctor_id, + patient_id=patient_id, + share_user_id=share_user_id, + mini_program_path="pages/login/login", + ) + + def generate_diagnosis_qrcode( + self, diagnosis_id: int, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, Any]: + """Generate the demo diagnosis-confirmation QR.""" + + return self.generate_mini_program_qrcode( + diagnosis_id=diagnosis_id, + doctor_id=doctor_id, + patient_id=patient_id, + share_user_id=share_user_id, + ) + + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + """Create the generic diagnosis-list payment order in memory.""" + + if patient_id <= 0: + raise ValueError("patient_id must be positive") + if order_type not in range(1, 9): + raise ValueError("order_type must be between 1 and 8") + if amount <= 0: + raise ValueError("amount must be positive") + with self._lock: + order = { + "id": self._next_generic_order_id, + "order_no": f"DEMO{self._next_generic_order_id:08d}", + "patient_id": patient_id, + "order_type": order_type, + "amount": round(float(amount), 2), + "remark": remark.strip(), + "status": 1, + } + self._next_generic_order_id += 1 + self._diagnosis_orders.append(order) + return deepcopy(order) + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + """Return a deterministic demo payment QR reference.""" + + clean = order_no.strip() + if not clean: + raise ValueError("order_no is required") + return {"qrcode_url": f"https://demo.invalid/qrcode/order/{clean}.png"} + def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket: """Return non-production placeholder credentials for UI demonstration.""" @@ -1802,6 +2271,16 @@ class DemoDoctorRepository: } self._next_call_id += 1 self._calls[diagnosis_id] = record + replay = { + **record, + "status_text": "呼叫中", + "recording_status_text": "未录制", + "recording_urls_list": [], + "start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "), + "end_time_text": "", + "duration_text": "—", + } + self._call_records.setdefault(diagnosis_id, []).insert(0, replay) return deepcopy(record) def end_call(self, diagnosis_id: int) -> dict[str, Any]: @@ -1813,6 +2292,22 @@ class DemoDoctorRepository: raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found") record["status"] = "ended" record["ended_at"] = datetime.now().replace(microsecond=0).isoformat(sep=" ") + replay = next( + ( + row + for row in self._call_records.get(diagnosis_id, []) + if int(row.get("id") or 0) == int(record.get("id") or 0) + ), + None, + ) + if replay is not None: + replay.update( + { + "status": 2, + "status_text": "已结束", + "end_time_text": record["ended_at"], + } + ) return deepcopy(record) def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]: @@ -1826,6 +2321,22 @@ class DemoDoctorRepository: raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found") record["room_id"] = room_id.strip() record["status"] = "connected" + replay = next( + ( + row + for row in self._call_records.get(diagnosis_id, []) + if int(row.get("id") or 0) == int(record.get("id") or 0) + ), + None, + ) + if replay is not None: + replay.update( + { + "room_id": room_id.strip(), + "status": 1, + "status_text": "通话中", + } + ) return { "diagnosis_id": diagnosis_id, "room_id": room_id.strip(), diff --git a/app/src/doctor_workstation/services/repository.py b/app/src/doctor_workstation/services/repository.py index afe45266d..6ded7fd3c 100644 --- a/app/src/doctor_workstation/services/repository.py +++ b/app/src/doctor_workstation/services/repository.py @@ -3,6 +3,8 @@ from __future__ import annotations import mimetypes +import re +import time from collections.abc import Mapping from contextlib import suppress from datetime import date @@ -102,7 +104,7 @@ class DoctorRepository(Protocol): def upload_material( self, path: str | PathLike[str], - material_type: Literal["image", "file", "tongue_images", "report_files"], + material_type: Literal["image", "video", "file", "tongue_images", "report_files"], cid: int = 0, ) -> str: """Upload one local note material and return its server URI.""" @@ -224,6 +226,9 @@ class DoctorRepository(Protocol): def cancel_patient_appointment(self, appointment_id: int) -> Any: """Cancel an appointment from the patient workspace.""" + def cancel_diagnosis_appointment(self, appointment_id: int) -> Any: + """Cancel a diagnosis-list appointment through the doctor route.""" + def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]: """Return an editable or permission-aware readonly diagnosis detail.""" @@ -453,6 +458,39 @@ class DoctorRepository(Protocol): def add_tracking_note(self, diagnosis_id: int, content: str) -> Any: """Append a diagnosis tracking note.""" + def add_blood_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any: + """Create one doctor-entered blood glucose / pressure record.""" + + def update_blood_record( + self, + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None = None, + **fields: Any, + ) -> Any: + """Update one blood glucose / pressure record.""" + + def add_diet_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any: + """Create one daily diet record.""" + + def update_diet_record( + self, + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None = None, + **fields: Any, + ) -> Any: + """Update one daily diet record.""" + + def add_exercise_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any: + """Create one daily exercise record.""" + + def update_exercise_record( + self, + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None = None, + **fields: Any, + ) -> Any: + """Update one daily exercise record.""" + def check_diagnosis_phone(self, payload: Mapping[str, Any]) -> Any: """Check diagnosis phone uniqueness.""" @@ -479,6 +517,72 @@ class DoctorRepository(Protocol): def cancel_diagnosis_todo(self, todo_id: int) -> Any: """Cancel a diagnosis follow-up todo.""" + def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]: + """Return persisted audio/video call records and replay URLs.""" + + def create_manual_call_record(self, diagnosis_id: int) -> dict[str, Any]: + """Create the server record used to hold a manually uploaded replay.""" + + def attach_local_call_recording( + self, + diagnosis_id: int, + file_url: str, + *, + call_record_id: int | None = None, + ) -> Any: + """Attach one uploaded replay URI to a call record.""" + + def upload_call_recording( + self, + path: str | PathLike[str], + diagnosis_id: int, + *, + call_record_id: int | None = None, + ) -> dict[str, Any]: + """Upload a local video then attach it to a real call record.""" + + def list_im_chat_messages( + self, diagnosis_id: int, *, only_archived: bool = True + ) -> dict[str, Any]: + """Return diagnosis IM messages, defaulting to the fast archive-only path.""" + + def sync_im_chat_messages(self, diagnosis_id: int) -> Any: + """Queue the server-side IM archive synchronisation job.""" + + def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: + """Return per-diagnosis registration / cancellation audit rows.""" + + def generate_mini_program_qrcode( + self, payload: Mapping[str, Any] | None = None, **fields: Any + ) -> dict[str, Any]: + """Generate the diagnosis or video-login mini-program QR code.""" + + def generate_video_qrcode( + self, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, Any]: + """Generate the video-login QR variant.""" + + def generate_diagnosis_qrcode( + self, diagnosis_id: int, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, Any]: + """Generate the diagnosis-confirmation QR variant.""" + + def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: + """Compatibility name for diagnosis registration logs.""" + + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + """Create the diagnosis-list generic payment order (not a prescription order).""" + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + """Generate the payment mini-program QR code for a generic order.""" + def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket: """Return short-lived call credentials.""" @@ -796,7 +900,7 @@ class RemoteDoctorRepository: def upload_material( self, path: str | PathLike[str], - material_type: Literal["image", "file", "tongue_images", "report_files"], + material_type: Literal["image", "video", "file", "tongue_images", "report_files"], cid: int = 0, ) -> str: """Upload a local note material and return only its server reference.""" @@ -1437,6 +1541,13 @@ class RemoteDoctorRepository: return self.client.post("firstvisit.myPatient/cancelAppointment", {"id": appointment_id}) + def cancel_diagnosis_appointment(self, appointment_id: int) -> Any: + """Cancel through the exact route used by the admin diagnosis list.""" + + if appointment_id <= 0: + raise ValueError("appointment_id must be positive") + return self.client.post("doctor.appointment/cancel", {"id": appointment_id}) + def patient_detail(self, diagnosis_id: int) -> dict[str, Any]: """Compatibility name for permission-aware readonly diagnosis details.""" @@ -1516,6 +1627,11 @@ class RemoteDoctorRepository: def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> Any: """Set the diagnosis revisit-statistics starting offset.""" + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + if offset not in range(0, 21): + raise ValueError("revisit_slot_start_offset must be between 0 and 20") + return self.client.post( "tcm.diagnosis/setRevisitSlotStartOffset", {"id": diagnosis_id, "revisit_slot_start_offset": offset}, @@ -1623,13 +1739,66 @@ class RemoteDoctorRepository: def add_tracking_note(self, diagnosis_id: int, content: str) -> Any: """Append a textual daily tracking note.""" - if not content.strip(): - raise ValueError("tracking_content is required") + clean_content = content.strip() + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + if not clean_content or len(clean_content) > 1000: + raise ValueError("tracking_content must contain 1 to 1000 characters") return self.client.post( "tcm.diagnosis/addTrackingNote", - {"diagnosis_id": diagnosis_id, "tracking_content": content.strip()}, + {"diagnosis_id": diagnosis_id, "tracking_content": clean_content}, ) + def add_blood_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any: + """Create a blood record using the fields consumed by ``DailyMatrix``.""" + + return self.client.post("tcm.bloodRecord/add", _daily_record_body("blood", payload, fields)) + + def update_blood_record( + self, + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None = None, + **fields: Any, + ) -> Any: + """Update a blood record without weakening add-time validation.""" + + body = _identified_body(record, changes, fields) + return self.client.post("tcm.bloodRecord/edit", _daily_record_body("blood", body, {})) + + def add_diet_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any: + """Create a breakfast/lunch/dinner daily record.""" + + return self.client.post("tcm.dietRecord/add", _daily_record_body("diet", payload, fields)) + + def update_diet_record( + self, + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None = None, + **fields: Any, + ) -> Any: + """Update a diet record.""" + + body = _identified_body(record, changes, fields) + return self.client.post("tcm.dietRecord/edit", _daily_record_body("diet", body, {})) + + def add_exercise_record(self, payload: Mapping[str, Any] | None = None, **fields: Any) -> Any: + """Create a validated exercise record.""" + + return self.client.post( + "tcm.exerciseRecord/add", _daily_record_body("exercise", payload, fields) + ) + + def update_exercise_record( + self, + record: int | Mapping[str, Any], + changes: Mapping[str, Any] | None = None, + **fields: Any, + ) -> Any: + """Update an exercise record.""" + + body = _identified_body(record, changes, fields) + return self.client.post("tcm.exerciseRecord/edit", _daily_record_body("exercise", body, {})) + def list_diagnosis_todos( self, diagnosis_id: int, @@ -1660,11 +1829,19 @@ class RemoteDoctorRepository: def add_diagnosis_todo(self, diagnosis_id: int, content: str, remind_time: int) -> Any: """Create a follow-up todo using a Unix-seconds reminder.""" + clean_content = content.strip() + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + if not clean_content or len(clean_content) > 500: + raise ValueError("todo content must contain 1 to 500 characters") + if remind_time <= int(time.time()) + 30: + raise ValueError("remind_time must be at least 30 seconds in the future") + return self.client.post( "tcm.diagnosisTodo/add", { "diagnosis_id": diagnosis_id, - "content": content.strip(), + "content": clean_content, "remind_time": remind_time, }, ) @@ -1672,8 +1849,204 @@ class RemoteDoctorRepository: def cancel_diagnosis_todo(self, todo_id: int) -> Any: """Cancel an outstanding diagnosis todo.""" + if todo_id <= 0: + raise ValueError("todo_id must be positive") + return self.client.post("tcm.diagnosisTodo/cancel", {"id": todo_id}) + def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]: + """Load the confirmed replay list endpoint.""" + + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + return _mapping_rows( + self.client.get("tcm.diagnosis/getCallRecords", {"diagnosis_id": diagnosis_id}) + ) + + def create_manual_call_record(self, diagnosis_id: int) -> dict[str, Any]: + """Create a synthetic call record before a toolbar video upload.""" + + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + result = self.client.post( + "tcm.diagnosis/createManualCallRecord", {"diagnosis_id": diagnosis_id} + ) + return dict(_require_mapping(result, "tcm.diagnosis/createManualCallRecord")) + + def attach_local_call_recording( + self, + diagnosis_id: int, + file_url: str, + *, + call_record_id: int | None = None, + ) -> Any: + """Attach an uploaded replay URI to the selected call record.""" + + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + clean_url = file_url.strip() + if not clean_url: + raise ValueError("file_url is required") + body: dict[str, Any] = {"diagnosis_id": diagnosis_id, "file_url": clean_url} + if call_record_id is not None: + if call_record_id <= 0: + raise ValueError("call_record_id must be positive") + body["call_record_id"] = call_record_id + return self.client.post("tcm.diagnosis/attachLocalCallRecording", body) + + def upload_call_recording( + self, + path: str | PathLike[str], + diagnosis_id: int, + *, + call_record_id: int | None = None, + ) -> dict[str, Any]: + """Perform the same upload/create/attach sequence as ``CallRecordPanel``.""" + + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + # Match the admin component exactly: upload first so a failed upload does + # not leave behind an empty synthetic call record. + file_url = self.upload_material(path, "video") + target_id = call_record_id + if target_id is None: + target_id = int(self.create_manual_call_record(diagnosis_id).get("id") or 0) + if target_id <= 0: + raise ApiProtocolError("tcm.diagnosis/createManualCallRecord returned no id") + self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id) + return { + "diagnosis_id": diagnosis_id, + "call_record_id": target_id, + "file_url": file_url, + } + + def list_im_chat_messages( + self, diagnosis_id: int, *, only_archived: bool = True + ) -> dict[str, Any]: + """Load archive-only messages unless the caller explicitly requests live merging.""" + + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + payload = self.client.get( + "tcm.diagnosis/getImChatMessages", + {"diagnosis_id": diagnosis_id, "only_archived": int(only_archived)}, + ) + return dict(_require_mapping(payload, "tcm.diagnosis/getImChatMessages")) + + def sync_im_chat_messages(self, diagnosis_id: int) -> Any: + """Queue the confirmed shutdown-function archive sync endpoint.""" + + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + return self.client.post("tcm.diagnosis/triggerImChatSync", {"diagnosis_id": diagnosis_id}) + + def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: + """Return registration/cancellation logs from ``guahaoLogList``.""" + + if diagnosis_id <= 0: + raise ValueError("diagnosis_id must be positive") + return _mapping_rows(self.client.get("tcm.diagnosis/guahaoLogList", {"id": diagnosis_id})) + + def generate_mini_program_qrcode( + self, payload: Mapping[str, Any] | None = None, **fields: Any + ) -> dict[str, Any]: + """Generate either QR variant using the server's shared DTO.""" + + body = _body(payload, fields) + for key in ("patient_id", "share_user_id"): + if int(body.get(key) or 0) <= 0: + raise ValueError(f"{key} must be positive") + page = str(body.get("mini_program_path") or "").strip() + if page == "pages/login/login": + if int(body.get("doctor_id") or 0) <= 0: + raise ValueError("doctor_id must be positive for a video QR code") + # The current server validator mistakenly inspects diagnosis_id for this case; + # mirror the admin DTO until that server contract is fixed. + body.setdefault("diagnosis_id", body["doctor_id"]) + elif int(body.get("diagnosis_id") or 0) <= 0: + raise ValueError("diagnosis_id must be positive") + payload_result = self.client.post("tcm.diagnosis/generateMiniProgramQrcode", body) + result = dict(_require_mapping(payload_result, "tcm.diagnosis/generateMiniProgramQrcode")) + if not str(result.get("qrcode_url") or "").strip(): + raise ApiProtocolError( + "tcm.diagnosis/generateMiniProgramQrcode returned no qrcode_url", + data=result, + ) + return result + + def generate_video_qrcode( + self, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, Any]: + """Generate the video QR with the admin's confirmed login-page DTO.""" + + return self.generate_mini_program_qrcode( + diagnosis_id=doctor_id, + doctor_id=doctor_id, + patient_id=patient_id, + share_user_id=share_user_id, + mini_program_path="pages/login/login", + ) + + def generate_diagnosis_qrcode( + self, diagnosis_id: int, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, Any]: + """Generate the diagnosis-confirmation QR.""" + + return self.generate_mini_program_qrcode( + diagnosis_id=diagnosis_id, + doctor_id=doctor_id, + patient_id=patient_id, + share_user_id=share_user_id, + ) + + def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: + """Compatibility name used by the diagnosis-list menu.""" + + return self.list_diagnosis_appointment_logs(diagnosis_id) + + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + """Create the generic order used by the diagnosis-list action.""" + + if patient_id <= 0: + raise ValueError("patient_id must be positive") + if order_type not in range(1, 9): + raise ValueError("order_type must be between 1 and 8") + if amount <= 0: + raise ValueError("amount must be positive") + payload = self.client.post( + "order.order/create", + { + "patient_id": patient_id, + "order_type": order_type, + "amount": round(float(amount), 2), + "remark": remark.strip(), + }, + ) + return dict(_require_mapping(payload, "order.order/create")) + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + """Generate the QR shown immediately after a generic order is created.""" + + clean_order_no = order_no.strip() + if not clean_order_no: + raise ValueError("order_no is required") + payload = self.client.post( + "tcm.diagnosis/generateOrderQrcode", {"order_no": clean_order_no} + ) + result = dict(_require_mapping(payload, "tcm.diagnosis/generateOrderQrcode")) + if not str(result.get("qrcode_url") or "").strip(): + raise ApiProtocolError( + "tcm.diagnosis/generateOrderQrcode returned no qrcode_url", data=result + ) + return result + def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket: """Obtain short-lived Tencent credentials for a consultation call.""" @@ -1767,15 +2140,17 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]: def _material_kind( material_type: str, -) -> Literal["image", "file"]: - """Map reception concepts to the two audited upload endpoints.""" +) -> Literal["image", "video", "file"]: + """Map workstation concepts to the three audited upload endpoints.""" value = material_type.strip().lower() if value in {"image", "tongue_images"}: return "image" + if value == "video": + return "video" if value in {"file", "report_files"}: return "file" - raise ValueError("material_type must be image/file or tongue_images/report_files") + raise ValueError("material_type must be image/video/file or tongue_images/report_files") def _is_local_material_reference(value: str) -> bool: @@ -1910,6 +2285,111 @@ def _body( return result +_RECORD_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_RECORD_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$") + + +def _daily_record_body( + kind: Literal["blood", "diet", "exercise"], + payload: Mapping[str, Any] | None, + fields: Mapping[str, Any], +) -> dict[str, Any]: + """Validate the exact DTOs used by the admin ``DailyMatrix`` dialogs.""" + + body = _body(payload, fields) + for key in ("diagnosis_id", "patient_id"): + value = _to_int(body.get(key), 0) + if value <= 0: + raise ValueError(f"{key} must be positive") + body[key] = value + record_date = str(body.get("record_date") or "").strip() + if not _RECORD_DATE_PATTERN.fullmatch(record_date): + raise ValueError("record_date must use YYYY-MM-DD") + parsed_date = date.fromisoformat(record_date) + if parsed_date > date.today(): + raise ValueError("record_date cannot be in the future") + body["record_date"] = record_date + + if kind == "blood": + record_time = str(body.get("record_time") or "").strip() + if record_time and not _RECORD_TIME_PATTERN.fullmatch(record_time): + raise ValueError("record_time must use HH:MM") + body["record_time"] = record_time + limits = { + "fasting_blood_sugar": 50.0, + "postprandial_blood_sugar": 50.0, + "other_blood_sugar": 50.0, + "systolic_pressure": 300.0, + "diastolic_pressure": 200.0, + } + has_content = False + for key, maximum in limits.items(): + raw = body.get(key) + if raw in (None, ""): + continue + try: + number = float(raw) + except (TypeError, ValueError) as exc: + raise ValueError(f"{key} must be numeric") from exc + if number < 0 or number > maximum: + raise ValueError(f"{key} must be between 0 and {maximum:g}") + body[key] = number + has_content = has_content or number > 0 + for key in ("western_medicine", "insulin", "remark"): + clean = str(body.get(key) or "").strip() + body[key] = clean + has_content = has_content or bool(clean) + if not has_content: + raise ValueError("a blood record must contain at least one measurement or note") + return body + + if kind == "diet": + has_content = False + for meal in ("breakfast", "lunch", "dinner"): + foods_key = f"{meal}_foods" + foods = str(body.get(foods_key) or body.get(meal) or "").strip() + body[foods_key] = foods + has_content = has_content or bool(foods) + images_key = f"{meal}_images" + raw_images = body.get(images_key) or [] + if not isinstance(raw_images, (list, tuple)): + raise ValueError(f"{images_key} must be an array") + if len(raw_images) > 3: + raise ValueError(f"{images_key} cannot contain more than 3 images") + body[images_key] = _server_materials(raw_images, images_key) + has_content = has_content or bool(body[images_key]) + body["note"] = str(body.get("note") or "").strip() + has_content = has_content or bool(body["note"]) + if not has_content: + raise ValueError("a diet record must contain food, an image or a note") + return body + + exercise_type = str(body.get("exercise_type") or "").strip() + if not exercise_type: + raise ValueError("exercise_type is required") + duration = _to_int(body.get("duration"), 0) + if duration < 1 or duration > 300: + raise ValueError("duration must be between 1 and 300 minutes") + intensity = _to_int(body.get("intensity"), 0) + if intensity not in {1, 2, 3}: + raise ValueError("intensity must be 1, 2 or 3") + raw_images = body.get("images") or [] + if not isinstance(raw_images, (list, tuple)): + raise ValueError("images must be an array") + if len(raw_images) > 3: + raise ValueError("images cannot contain more than 3 items") + body.update( + { + "exercise_type": exercise_type, + "duration": duration, + "intensity": intensity, + "images": _server_materials(raw_images, "images"), + "note": str(body.get("note") or "").strip(), + } + ) + return body + + def _identified_body( value: int | Mapping[str, Any], changes: Mapping[str, Any] | None, diff --git a/app/src/doctor_workstation/ui/appointment_drawer.py b/app/src/doctor_workstation/ui/appointment_drawer.py new file mode 100644 index 000000000..b4917d3ef --- /dev/null +++ b/app/src/doctor_workstation/ui/appointment_drawer.py @@ -0,0 +1,1799 @@ +"""Element-aligned appointment drawer used by patient and diagnosis workspaces.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from contextlib import suppress +from types import MappingProxyType +from typing import Any + +from PySide6.QtCore import ( + QDate, + QEvent, + QPoint, + QRect, + QSize, + Qt, + QTime, + QTimer, +) +from PySide6.QtGui import QColor, QMouseEvent, QPainter, QPaintEvent, QPen, QResizeEvent +from PySide6.QtWidgets import ( + QButtonGroup, + QComboBox, + QDialog, + QFrame, + QGraphicsDropShadowEffect, + QGraphicsOpacityEffect, + QHBoxLayout, + QLabel, + QLayout, + QLayoutItem, + QLineEdit, + QPlainTextEdit, + QPushButton, + QRadioButton, + QScrollArea, + QSizePolicy, + QStackedWidget, + QStyle, + QToolButton, + QVBoxLayout, + QWidget, +) + +from .widgets import ( + display_text, + first_value, + friendly_error, + get_value, + invoke, + page_items, + run_async, +) + +APPOINTMENT_DRAWER_QSS = r""" +QDialog#AppointmentDrawerOverlay { + background-color: transparent; + color: #303133; + font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + font-size: 14px; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel { + background-color: #FFFFFF; + border-left: 1px solid #DCDFE6; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader { + background-color: #FFFFFF; + border: 0; + border-bottom: 1px solid #EBEEF5; +} + +QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle { + color: #303133; + font-size: 18px; + font-weight: 600; +} + +QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose { + min-width: 32px; + max-width: 32px; + min-height: 32px; + max-height: 32px; + padding: 0; + border: 0; + border-radius: 4px; + background-color: transparent; + color: #909399; + font-size: 22px; + font-weight: 400; +} + +QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover { + color: #409EFF; + background-color: #ECF5FF; +} + +QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody, +QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentSlotScroll, +QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody > QWidget > QWidget, +QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentSlotScroll > QWidget > QWidget { + border: 0; + background-color: #FFFFFF; +} + +QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent { + background-color: #FFFFFF; +} + +QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] { + color: #606266; + font-size: 14px; + font-weight: 500; +} + +QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] { + color: #909399; +} + +QDialog#AppointmentDrawerOverlay QComboBox, +QDialog#AppointmentDrawerOverlay QLineEdit, +QDialog#AppointmentDrawerOverlay QPlainTextEdit { + min-height: 30px; + padding: 0 11px; + border: 1px solid #DCDFE6; + border-radius: 4px; + background-color: #FFFFFF; + color: #303133; + selection-background-color: #A0CFFF; + selection-color: #303133; +} + +QDialog#AppointmentDrawerOverlay QPlainTextEdit { + padding: 7px 11px; +} + +QDialog#AppointmentDrawerOverlay QComboBox:hover, +QDialog#AppointmentDrawerOverlay QLineEdit:hover, +QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover { + border-color: #C0C4CC; +} + +QDialog#AppointmentDrawerOverlay QComboBox:focus, +QDialog#AppointmentDrawerOverlay QLineEdit:focus, +QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus { + border: 2px solid #79BBFF; +} + +QDialog#AppointmentDrawerOverlay QComboBox::drop-down { + width: 28px; + border: 0; +} + +QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView { + background-color: #FFFFFF; + color: #303133; + border: 1px solid #E4E7ED; + selection-background-color: #ECF5FF; + selection-color: #409EFF; + outline: 0; +} + +QDialog#AppointmentDrawerOverlay QRadioButton { + min-height: 24px; + spacing: 8px; + color: #303133; +} + +QDialog#AppointmentDrawerOverlay QRadioButton::indicator { + width: 12px; + height: 12px; + border-radius: 7px; + border: 1px solid #DCDFE6; + background-color: #FFFFFF; +} + +QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover { + border-color: #409EFF; +} + +QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked { + width: 4px; + height: 4px; + border: 5px solid #409EFF; + border-radius: 7px; + background-color: #FFFFFF; +} + +QDialog#AppointmentDrawerOverlay QRadioButton:focus { + color: #409EFF; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] { + padding: 0; + border: 1px solid #DCDFE6; + border-radius: 8px; + background-color: #FFFFFF; + color: #606266; + font-size: 14px; + font-weight: 500; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover { + color: #409EFF; + border-color: #409EFF; + background-color: #ECF5FF; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus { + border-color: #79BBFF; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked { + color: #FFFFFF; + border-color: #409EFF; + background-color: #409EFF; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel { + background-color: #F8F9FA; + border: 0; + border-radius: 8px; +} + +QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle { + color: #303133; + font-size: 15px; + font-weight: 600; +} + +QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots { + min-height: 24px; + max-height: 24px; + padding: 0 7px; + border: 0; + border-radius: 4px; + background-color: transparent; + color: #409EFF; + font-size: 13px; + font-weight: 500; +} + +QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover { + background-color: #ECF5FF; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] { + min-width: 110px; + min-height: 70px; + padding: 0 8px; + border: 2px solid #E4E7ED; + border-radius: 8px; + background-color: #FFFFFF; + color: #303133; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime { + color: #303133; + font-size: 15px; + font-weight: 600; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus { + padding: 0 8px; + border-radius: 4px; + background-color: #F4F4F5; + color: #909399; + font-size: 12px; + font-weight: 400; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus { + color: #67C23A; + background-color: #F0F9FF; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled { + color: #409EFF; + border-color: #409EFF; + background-color: #ECF5FF; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled { + border-color: #79BBFF; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked { + color: #FFFFFF; + border-color: #409EFF; + background: qlineargradient( + x1:0, y1:0, x2:1, y2:1, + stop:0 #409EFF, + stop:1 #66B1FF + ); +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotTime, +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus { + color: #FFFFFF; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus { + background-color: rgba(255, 255, 255, 46); +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled { + color: #C0C4CC; + border-color: #E4E7ED; + background-color: #F5F7FA; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotTime, +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus { + color: #C0C4CC; +} + +QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus { + background-color: #F5F7FA; +} + +QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty { + background-color: transparent; +} + +QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText { + color: #909399; + font-size: 14px; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] { + background-color: #ECF5FF; + border: 1px solid #D9ECFF; + border-radius: 4px; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] { + background-color: #FDF6EC; + border: 1px solid #FAECD8; + border-radius: 4px; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] { + background-color: #FEF0F0; + border: 1px solid #FDE2E2; + border-radius: 4px; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] { + background-color: #F0F9EB; + border: 1px solid #E1F3D8; + border-radius: 4px; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel { + color: #409EFF; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel { + color: #E6A23C; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel { + color: #F56C6C; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel { + color: #67C23A; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter { + background-color: #FFFFFF; + border: 0; + border-top: 1px solid #EBEEF5; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton { + min-height: 30px; + max-height: 30px; + padding: 0 15px; + border: 1px solid #DCDFE6; + border-radius: 4px; + background-color: #FFFFFF; + color: #606266; + font-size: 14px; + font-weight: 500; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover { + color: #409EFF; + border-color: #C6E2FF; + background-color: #ECF5FF; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus { + border-color: #79BBFF; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] { + color: #FFFFFF; + border-color: #409EFF; + background-color: #409EFF; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover { + color: #FFFFFF; + border-color: #66B1FF; + background-color: #66B1FF; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled { + color: #FFFFFF; + border-color: #A0CFFF; + background-color: #A0CFFF; +} + +QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay { + background-color: rgba(255, 255, 255, 218); + border: 0; +} + +QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText { + color: #606266; + font-size: 14px; +} + +QDialog#AppointmentDrawerOverlay QScrollBar:vertical { + width: 6px; + margin: 0; + border: 0; + background: transparent; +} + +QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical { + min-height: 30px; + border-radius: 3px; + background-color: #DCDFE6; +} + +QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover { + background-color: #C0C4CC; +} + +QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical, +QDialog#AppointmentDrawerOverlay QScrollBar::sub-line:vertical { + height: 0; +} +""" + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _as_bool(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _invoke_first(repository: Any, names: Sequence[str], **kwargs: Any) -> Any: + for name in names: + if callable(getattr(repository, name, None)): + return invoke(repository, name, **kwargs) + return invoke(repository, names[0], **kwargs) + + +class FlowLayout(QLayout): + """Compact wrapping layout based on Qt's canonical Flow Layout example.""" + + def __init__( + self, + parent: QWidget | None = None, + margin: int = 0, + horizontal_spacing: int = 10, + vertical_spacing: int = 10, + ) -> None: + super().__init__(parent) + self._items: list[QLayoutItem] = [] + self._horizontal_spacing = horizontal_spacing + self._vertical_spacing = vertical_spacing + self.setContentsMargins(margin, margin, margin, margin) + + def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - Qt API + self._items.append(item) + + def count(self) -> int: + return len(self._items) + + def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt API + return self._items[index] if 0 <= index < len(self._items) else None + + def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt API + return self._items.pop(index) if 0 <= index < len(self._items) else None + + def expandingDirections(self) -> Qt.Orientations: # noqa: N802 - Qt API + return Qt.Orientation(0) + + def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt API + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt API + return self._do_layout(QRect(0, 0, width, 0), True) + + def setGeometry(self, rect: QRect) -> None: # noqa: N802 - Qt API + super().setGeometry(rect) + self._do_layout(rect, False) + + def sizeHint(self) -> QSize: # noqa: N802 - Qt API + return self.minimumSize() + + def minimumSize(self) -> QSize: # noqa: N802 - Qt API + size = QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + margins = self.contentsMargins() + return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom()) + + def _smart_spacing(self, pixel_metric: QStyle.PixelMetric) -> int: + parent = self.parent() + if isinstance(parent, QWidget): + return parent.style().pixelMetric(pixel_metric, None, parent) + return 6 + + def _do_layout(self, rect: QRect, test_only: bool) -> int: + left, top, right, bottom = self.getContentsMargins() + effective = rect.adjusted(left, top, -right, -bottom) + x = effective.x() + y = effective.y() + line_height = 0 + for item in self._items: + widget = item.widget() + if widget is not None and widget.isHidden(): + continue + horizontal = self._horizontal_spacing + if horizontal < 0: + horizontal = self._smart_spacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing) + vertical = self._vertical_spacing + if vertical < 0: + vertical = self._smart_spacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing) + next_x = x + item.sizeHint().width() + horizontal + if next_x - horizontal > effective.right() and line_height > 0: + x = effective.x() + y += line_height + vertical + next_x = x + item.sizeHint().width() + horizontal + line_height = 0 + if not test_only: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + x = next_x + line_height = max(line_height, item.sizeHint().height()) + return y + line_height - rect.y() + bottom + + +class _Spinner(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._angle = 0 + self.setFixedSize(30, 30) + self._timer = QTimer(self) + self._timer.setInterval(45) + self._timer.timeout.connect(self._advance) + + def start(self) -> None: + self._timer.start() + self.show() + + def stop(self) -> None: + self._timer.stop() + + def _advance(self) -> None: + self._angle = (self._angle + 24) % 360 + self.update() + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API + del event + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QPen(QColor("#409EFF"), 3) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawArc(self.rect().adjusted(4, 4, -4, -4), self._angle * 16, 255 * 16) + + +class _LoadingOverlay(QFrame): + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.setObjectName("AppointmentLoadingOverlay") + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(10) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.spinner = _Spinner(self) + layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignCenter) + self.label = QLabel("正在加载…") + self.label.setObjectName("AppointmentLoadingText") + self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter) + self.hide() + + def show_message(self, text: str) -> None: + self.label.setText(text) + self.setGeometry(self.parentWidget().rect()) + self.raise_() + self.show() + self.spinner.start() + + def clear(self) -> None: + self.spinner.stop() + self.hide() + + +class _Alert(QFrame): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AppointmentAlert") + self.setProperty("kind", "info") + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 9, 12, 9) + layout.setSpacing(9) + self.icon = QLabel("i") + self.icon.setFixedSize(18, 18) + self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.icon.setStyleSheet("font-weight:700;") + self.label = QLabel() + self.label.setWordWrap(True) + layout.addWidget(self.icon) + layout.addWidget(self.label, 1) + self.hide() + + def show_message(self, text: str, kind: str = "info") -> None: + glyphs = {"info": "i", "warning": "!", "danger": "!", "success": "✓"} + self.icon.setText(glyphs.get(kind, "i")) + self.label.setText(text) + self.setProperty("kind", kind) + self.style().unpolish(self) + self.style().polish(self) + self.show() + + def clear(self) -> None: + self.label.clear() + self.hide() + + +class _InlineEmpty(QWidget): + def __init__(self, text: str = "请先选择医生", parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AppointmentInlineEmpty") + self.setMinimumHeight(138) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 18, 12, 18) + layout.setSpacing(5) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.illustration = _EmptyIllustration(self) + layout.addWidget(self.illustration, 0, Qt.AlignmentFlag.AlignCenter) + self.label = QLabel(text) + self.label.setObjectName("AppointmentEmptyText") + self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.label.setWordWrap(True) + layout.addWidget(self.label) + + def set_text(self, text: str) -> None: + self.label.setText(text) + + +class _EmptyIllustration(QWidget): + """Small neutral illustration matching Element's 80 px empty-state footprint.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setFixedSize(80, 60) + self.setAccessibleName("空状态插图") + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API + del event + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor("#F2F6FC")) + painter.drawEllipse(QRect(9, 48, 62, 8)) + + painter.setPen(QPen(QColor("#C0C4CC"), 1)) + painter.setBrush(QColor("#F5F7FA")) + painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4) + painter.setBrush(QColor("#EBEEF5")) + painter.drawRoundedRect(QRect(18, 15, 44, 12), 4, 4) + painter.setPen(QPen(QColor("#D7DBE2"), 2)) + painter.drawLine(30, 35, 50, 35) + painter.drawLine(34, 41, 46, 41) + painter.end() + + +class _HoverLiftButton(QPushButton): + """Button with a subtle two-pixel lift and blue-tinted shadow on hover.""" + + def __init__(self, text: str = "", parent: QWidget | None = None) -> None: + super().__init__(text, parent) + self._lifted = False + self._base_position = QPoint() + + def enterEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API + if self.isEnabled() and not self._lifted: + self._base_position = self.pos() + self.move(self.x(), self.y() - 2) + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(12) + shadow.setOffset(0, 4) + shadow.setColor(QColor(64, 158, 255, 45)) + self.setGraphicsEffect(shadow) + self._lifted = True + super().enterEvent(event) + + def leaveEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API + if self._lifted: + self.move(self._base_position) + self.setGraphicsEffect(None) + self._lifted = False + super().leaveEvent(event) + + +class _SlotCard(_HoverLiftButton): + """Two-line time card matching Element's independent time/status typography.""" + + def __init__(self, time_text: str, status_text: str) -> None: + super().__init__() + self.setAccessibleName(f"{time_text} {status_text}") + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.time_label = QLabel(time_text) + self.time_label.setObjectName("AppointmentSlotTime") + self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.time_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + layout.addWidget(self.time_label) + self.status_label = QLabel(status_text) + self.status_label.setObjectName("AppointmentSlotStatus") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + layout.addWidget(self.status_label) + + +class AppointmentDrawer(QDialog): + """Full-window modal overlay with an Element-style 60% right drawer.""" + + CHANNEL_DETAIL_NAMES = {"自媒体4H", "自媒体3Q", "自媒体3H", "自媒体2H", "自媒体2Q"} + + def __init__( + self, + row: Any, + repository: Any = None, + parent: QWidget | None = None, + *, + autoload: bool = True, + async_runner: Callable[..., Any] | None = None, + ) -> None: + super().__init__(parent) + self.row = row + self.repository = repository + self._run_async = async_runner or run_async + self.diagnosis_id = _as_int(first_value(row, "diagnosis_id", "id")) + self._load_generation = 0 + self._roster_generation = 0 + self._slot_generation = 0 + self._setting = False + self._initial_loaded = False + self._today_blocking = False + self._channel_names: dict[str, str] = {} + self._doctor_buttons: dict[int, QRadioButton] = {} + self._date_buttons: dict[str, _HoverLiftButton] = {} + self._slot_buttons: dict[str, _HoverLiftButton] = {} + self._slot_available: dict[str, bool] = {} + self._slot_restore_selection = "" + self._active_loading = "" + self._owner = parent.window() if parent is not None else None + + self.setObjectName("AppointmentDrawerOverlay") + self.setWindowTitle("预约问诊") + self.setModal(True) + self.setWindowModality(Qt.WindowModality.WindowModal) + self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.setStyleSheet(APPOINTMENT_DRAWER_QSS) + self.resize(self._owner.size() if self._owner is not None else QSize(1024, 640)) + if self._owner is not None: + self._owner.installEventFilter(self) + + root = QHBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addStretch(1) + + self.panel = QFrame() + self.panel.setObjectName("AppointmentDrawerPanel") + self.panel.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + panel_layout = QVBoxLayout(self.panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.setSpacing(0) + root.addWidget(self.panel) + + self.header = self._build_header() + panel_layout.addWidget(self.header) + self.body_scroll = QScrollArea() + self.body_scroll.setObjectName("AppointmentDrawerBody") + self.body_scroll.setWidgetResizable(True) + self.body_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.body_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.body_content = QWidget() + self.body_content.setObjectName("AppointmentDrawerBodyContent") + self.body_layout = QVBoxLayout(self.body_content) + self.body_layout.setContentsMargins(16, 16, 16, 20) + self.body_layout.setSpacing(14) + self.body_scroll.setWidget(self.body_content) + panel_layout.addWidget(self.body_scroll, 1) + self.footer = self._build_footer() + panel_layout.addWidget(self.footer) + + self._body_viewport = self.body_scroll.viewport() + self.loading_overlay = _LoadingOverlay(self._body_viewport) + self._body_viewport.installEventFilter(self) + + self._build_form() + self._update_drawer_width() + self._sync_channel_detail_visibility() + self._show_time_empty("请先选择医生") + self._update_submit_state() + if autoload: + self._load_initial_options() + + @property + def drawer_width(self) -> int: + return self.panel.width() + + @property + def doctor_buttons(self) -> Mapping[int, QRadioButton]: + return MappingProxyType(self._doctor_buttons) + + @property + def date_buttons(self) -> Mapping[str, QPushButton]: + return MappingProxyType(self._date_buttons) + + @property + def slot_buttons(self) -> Mapping[str, QPushButton]: + return MappingProxyType(self._slot_buttons) + + @staticmethod + def _rows(value: Any, key: str = "") -> list[Any]: + rows = page_items(value) + if rows: + return rows + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return list(value) + if isinstance(value, Mapping): + candidate = value.get(key) if key else None + if isinstance(candidate, Sequence) and not isinstance(candidate, (str, bytes)): + return list(candidate) + data = value.get("data") + if isinstance(data, Mapping): + candidate = data.get(key) + if isinstance(candidate, Sequence) and not isinstance(candidate, (str, bytes)): + return list(candidate) + return [] + + def _build_header(self) -> QWidget: + header = QFrame() + header.setObjectName("AppointmentDrawerHeader") + header.setFixedHeight(58) + layout = QHBoxLayout(header) + layout.setContentsMargins(16, 0, 12, 0) + title = QLabel("预约问诊") + title.setObjectName("AppointmentDrawerTitle") + layout.addWidget(title) + layout.addStretch(1) + close_button = QToolButton() + close_button.setObjectName("AppointmentDrawerClose") + close_button.setText("×") + close_button.setToolTip("关闭") + close_button.clicked.connect(self.reject) + layout.addWidget(close_button) + self.close_button = close_button + return header + + def _build_footer(self) -> QWidget: + footer = QFrame() + footer.setObjectName("AppointmentDrawerFooter") + layout = QHBoxLayout(footer) + layout.setContentsMargins(16, 12, 16, 12) + layout.setSpacing(12) + layout.addStretch(1) + self.cancel_button = QPushButton("取消") + self.cancel_button.clicked.connect(self.reject) + layout.addWidget(self.cancel_button) + self.ok_button = QPushButton("确定") + self.ok_button.setProperty("primary", True) + self.ok_button.setEnabled(False) + self.ok_button.clicked.connect(self.accept) + layout.addWidget(self.ok_button) + return footer + + def _build_form(self) -> None: + self.banner = _Alert() + self.body_layout.addWidget(self.banner) + self.conflict_alert = _Alert() + self.body_layout.addWidget(self.conflict_alert) + + self.form_labels: list[QLabel] = [] + self.last_visit_label = QLabel("无就诊记录") + self.last_visit_label.setProperty("appointmentMuted", True) + self.last_visit_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self._add_form_row("上次就诊:", self.last_visit_label) + + method_container = QWidget() + method_layout = QHBoxLayout(method_container) + method_layout.setContentsMargins(0, 0, 0, 0) + method_layout.setSpacing(12) + self.appointment_method_radio = QRadioButton("选择时段预约有专医生") + self.appointment_method_radio.setChecked(True) + method_layout.addWidget(self.appointment_method_radio) + method_layout.addStretch(1) + self._add_form_row("预约方式:", method_container) + + type_container = QWidget() + type_layout = QHBoxLayout(type_container) + type_layout.setContentsMargins(0, 0, 0, 0) + self.appointment_type_radio = QRadioButton("视频问诊") + self.appointment_type_radio.setChecked(True) + type_layout.addWidget(self.appointment_type_radio) + type_layout.addStretch(1) + self._add_form_row("预约类型:", type_container) + self.appointment_type = QComboBox(self) + self.appointment_type.addItem("视频问诊", "video") + self.appointment_type.hide() + + patient_container = QWidget() + patient_layout = QHBoxLayout(patient_container) + patient_layout.setContentsMargins(0, 0, 0, 0) + patient_name = display_text( + first_value(self.row, "patient_name", "name", default="当前患者") + ) + self.patient_radio = QRadioButton(patient_name) + self.patient_radio.setChecked(True) + patient_layout.addWidget(self.patient_radio) + patient_layout.addStretch(1) + self._add_form_row("选择患者:", patient_container) + + self.channel_source = QComboBox() + self.channel_source.setMaximumWidth(360) + self.channel_source.addItem("正在加载渠道…", "") + self.channel_source.currentIndexChanged.connect(self._channel_changed) + self._add_form_row("渠道来源:", self.channel_source, required=True) + + self.channel_source_detail = QLineEdit() + self.channel_source_detail.setMaximumWidth(360) + self.channel_source_detail.setMaxLength(128) + self.channel_source_detail.setPlaceholderText("请输入自媒体相关补充内容") + self.channel_source_detail.textChanged.connect(self._update_submit_state) + self.channel_detail_row = self._add_form_row( + "自媒体补充:", self.channel_source_detail, required=True + ) + self.channel_detail_row.hide() + + self.doctor_group = QButtonGroup(self) + self.doctor_group.setExclusive(True) + self.doctor_container = QWidget() + self.doctor_flow = FlowLayout( + self.doctor_container, horizontal_spacing=12, vertical_spacing=8 + ) + self.doctor_empty = QLabel("正在加载医生…") + self.doctor_empty.setProperty("appointmentMuted", True) + self.doctor_flow.addWidget(self.doctor_empty) + self._add_form_row("预约医生:", self.doctor_container, align_top=True) + self.doctor_combo = QComboBox(self) + self.doctor_combo.addItem("正在加载医生…", 0) + self.doctor_combo.hide() + self.doctor_combo.currentIndexChanged.connect(self._doctor_changed) + + self.time_container = QWidget() + self.time_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) + time_layout = QVBoxLayout(self.time_container) + time_layout.setContentsMargins(0, 0, 0, 0) + time_layout.setSpacing(0) + self.time_stack = QStackedWidget() + self.time_stack.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) + self.time_empty = _InlineEmpty("请先选择医生") + self.time_stack.addWidget(self.time_empty) + self.appointment_time_container = QWidget() + self.appointment_time_container.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum + ) + appointment_time_layout = QVBoxLayout(self.appointment_time_container) + appointment_time_layout.setContentsMargins(0, 0, 0, 0) + appointment_time_layout.setSpacing(20) + self.date_container = QWidget() + self.date_flow = FlowLayout(self.date_container, horizontal_spacing=10, vertical_spacing=10) + appointment_time_layout.addWidget(self.date_container) + self.slots_panel = QFrame() + self.slots_panel.setObjectName("AppointmentSlotsPanel") + self.slots_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) + self.slots_panel.setFixedHeight(164) + slots_panel_layout = QVBoxLayout(self.slots_panel) + slots_panel_layout.setContentsMargins(16, 16, 16, 16) + slots_panel_layout.setSpacing(14) + slots_header = QHBoxLayout() + slots_title = QLabel("可预约时段") + slots_title.setObjectName("AppointmentSlotsTitle") + slots_header.addWidget(slots_title) + slots_header.addStretch(1) + self.refresh_slots_button = QPushButton("刷新") + self.refresh_slots_button.setObjectName("AppointmentRefreshSlots") + self.refresh_slots_button.clicked.connect(self._refresh_slots) + slots_header.addWidget(self.refresh_slots_button) + slots_panel_layout.addLayout(slots_header) + self.slot_state_stack = QStackedWidget() + self.slot_state_stack.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.slot_state_stack.setFixedHeight(94) + self.slot_empty = _InlineEmpty("请选择日期") + self.slot_empty.setMinimumHeight(92) + self.slot_state_stack.addWidget(self.slot_empty) + self.slot_scroll = QScrollArea() + self.slot_scroll.setObjectName("AppointmentSlotScroll") + self.slot_scroll.setWidgetResizable(True) + self.slot_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.slot_scroll.setMinimumHeight(94) + self.slot_scroll.setMaximumHeight(450) + self.slot_scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.slot_content = QWidget() + self.slot_flow = FlowLayout(self.slot_content, horizontal_spacing=10, vertical_spacing=10) + self.slot_scroll.setWidget(self.slot_content) + self._slot_viewport = self.slot_scroll.viewport() + self._slot_viewport.installEventFilter(self) + self.slot_state_stack.addWidget(self.slot_scroll) + slots_panel_layout.addWidget(self.slot_state_stack) + appointment_time_layout.addWidget(self.slots_panel) + self.time_stack.addWidget(self.appointment_time_container) + time_layout.addWidget(self.time_stack) + self._add_form_row("预约时间:", self.time_container, align_top=True) + self.date_combo = QComboBox(self) + self.date_combo.addItem("请先选择医生", "") + self.date_combo.hide() + self.date_combo.currentIndexChanged.connect(self._date_changed) + self.slot_combo = QComboBox(self) + self.slot_combo.addItem("请先选择排班日期", "") + self.slot_combo.hide() + self.slot_combo.currentIndexChanged.connect(self._slot_selection_changed) + self.slot_group = QButtonGroup(self) + self.slot_group.setExclusive(True) + + self.remark = QPlainTextEdit() + self.remark.setPlaceholderText("请输入备注信息") + self.remark.setFixedHeight(70) + self._add_form_row("备注:", self.remark, align_top=True) + self.body_layout.addStretch(1) + + def _add_form_row( + self, + label_text: str, + field: QWidget, + *, + required: bool = False, + align_top: bool = False, + ) -> QWidget: + row = QWidget() + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(12) + label = QLabel( + f"{label_text[:-1]} *:" if required and label_text.endswith(":") else label_text + ) + label.setProperty("appointmentLabel", True) + label.setFixedWidth(100) + label.setAlignment( + Qt.AlignmentFlag.AlignRight + | (Qt.AlignmentFlag.AlignTop if align_top else Qt.AlignmentFlag.AlignVCenter) + ) + if align_top: + label.setContentsMargins(0, 7, 0, 0) + self.form_labels.append(label) + layout.addWidget(label) + layout.addWidget(field, 1) + self.body_layout.addWidget(row) + return row + + def _load_initial_options(self) -> None: + self._load_generation += 1 + generation = self._load_generation + diagnosis_id = self.diagnosis_id + today = QDate.currentDate().toString("yyyy-MM-dd") + self._set_loading("initial", "正在加载医生、渠道与挂号状态…") + + def query() -> dict[str, Any]: + doctors = _invoke_first(self.repository, ("list_diagnosis_doctors",)) + channels = invoke(self.repository, "get_dictionary", dictionary_type="channels") + appointments = _invoke_first( + self.repository, + ("list_appointments",), + patient_id=diagnosis_id, + start_date=today, + end_date=today, + page_no=1, + page_size=50, + ) + try: + last_visits = _invoke_first( + self.repository, + ("list_appointments",), + patient_id=diagnosis_id, + status=3, + page_no=1, + page_size=1, + ) + except Exception: + last_visits = [] + return { + "doctors": doctors, + "channels": channels, + "appointments": appointments, + "last_visits": last_visits, + } + + self._run_async( + query, + on_success=lambda result: self._apply_initial_options(result, generation), + on_error=lambda error: self._load_error(error, generation), + ) + + def _apply_initial_options(self, result: Any, generation: int) -> None: + if generation != self._load_generation: + return + doctors = self._rows(get_value(result, "doctors", [])) + channels = self._rows(get_value(result, "channels", []), "channels") + appointments = self._rows(get_value(result, "appointments", [])) + last_visits = self._rows(get_value(result, "last_visits", [])) + self._today_blocking = any( + _as_int(first_value(item, "status", "appointment_status"), -1) in {1, 4} + for item in appointments + ) + self._set_last_visit(last_visits) + self._setting = True + try: + self._populate_doctors(doctors) + self.channel_source.clear() + self.channel_source.addItem("请选择渠道来源", "") + self._channel_names.clear() + ordered_channels = sorted( + ( + item + for item in channels + if _as_int(first_value(item, "status", default=1), 1) != 0 + ), + key=lambda item: ( + -_as_int(first_value(item, "sort", default=0)), + -_as_int(first_value(item, "id", default=0)), + ), + ) + for channel in ordered_channels: + value = str(first_value(channel, "value", "id", default="") or "") + name = display_text(first_value(channel, "name", "label"), "") + if not value or not name: + continue + self.channel_source.addItem(name, value) + self._channel_names[value] = name + finally: + self._setting = False + self._initial_loaded = bool(doctors and self._channel_names) + self._sync_channel_detail_visibility() + self._update_conflict_alert() + self._clear_loading("initial") + if not doctors: + self.doctor_empty.setText("暂无可预约医生") + self.doctor_empty.show() + self._show_time_empty("暂无可预约医生") + self.banner.show_message("没有可预约医生,已停止提交。", "warning") + elif not self._channel_names: + self.banner.show_message("没有可用渠道字典,已停止提交。", "warning") + else: + self.banner.clear() + if not doctors: + pass + elif self.doctor_combo.currentData(): + self._doctor_changed() + else: + self._show_time_empty("请先选择医生") + self._update_submit_state() + + def _set_last_visit(self, rows: Sequence[Any]) -> None: + if rows: + item = rows[0] + date_text = str(first_value(item, "appointment_date", "date", default="") or "") + time_text = str( + first_value( + item, + "appointment_time", + "time_text", + "appointment_time_text", + default="", + ) + or "" + ) + rendered = " ".join(part for part in (date_text, time_text) if part) + self.last_visit_label.setText(rendered or "无就诊记录") + return + seeded = first_value( + self.row, + "last_visit", + "last_visit_at", + "last_appointment_at", + default="", + ) + self.last_visit_label.setText(display_text(seeded, "无就诊记录")) + + def _populate_doctors(self, doctors: Sequence[Any]) -> None: + self._clear_flow(self.doctor_flow) + self._doctor_buttons.clear() + self.doctor_group = QButtonGroup(self) + self.doctor_group.setExclusive(True) + self.doctor_combo.clear() + self.doctor_combo.addItem("请选择医生", 0) + preferred = _as_int(first_value(self.row, "appointment_doctor_id", "doctor_id", default=0)) + selected_index = 0 + for doctor in doctors: + doctor_id = _as_int(first_value(doctor, "id", "doctor_id")) + if doctor_id <= 0: + continue + name = display_text(first_value(doctor, "name", "doctor_name")) + self.doctor_combo.addItem(name, doctor_id) + button = QRadioButton(name) + button.setProperty("doctorId", doctor_id) + button.clicked.connect( + lambda _checked=False, value=doctor_id: self._select_doctor(value) + ) + self.doctor_group.addButton(button, doctor_id) + self.doctor_flow.addWidget(button) + self._doctor_buttons[doctor_id] = button + if doctor_id == preferred: + selected_index = self.doctor_combo.count() - 1 + if not self._doctor_buttons: + self.doctor_empty = QLabel("暂无可预约医生") + self.doctor_empty.setProperty("appointmentMuted", True) + self.doctor_flow.addWidget(self.doctor_empty) + else: + self.doctor_combo.setCurrentIndex(selected_index) + if selected_index > 0: + selected_id = _as_int(self.doctor_combo.currentData()) + button = self._doctor_buttons.get(selected_id) + if button is not None: + button.setChecked(True) + + def _load_error(self, error: Exception, generation: int) -> None: + if generation != self._load_generation: + return + self._initial_loaded = False + self._clear_loading("initial") + self.banner.show_message(f"预约基础数据加载失败:{friendly_error(error)}", "danger") + self._show_time_empty("基础数据加载失败") + self._update_submit_state() + + def _select_doctor(self, doctor_id: int) -> None: + index = self.doctor_combo.findData(doctor_id) + if index >= 0 and index != self.doctor_combo.currentIndex(): + self.doctor_combo.setCurrentIndex(index) + elif index >= 0: + self._doctor_changed() + + def _doctor_changed(self) -> None: + if self._setting: + return + doctor_id = _as_int(self.doctor_combo.currentData()) + for value, button in self._doctor_buttons.items(): + button.setChecked(value == doctor_id) + self._clear_dates() + self._clear_slots("请先选择排班日期") + if doctor_id <= 0: + self._show_time_empty("请先选择医生") + self._update_submit_state() + return + self._show_time_empty("正在加载医生排班…") + self._roster_generation += 1 + generation = self._roster_generation + start = QDate.currentDate().toString("yyyy-MM-dd") + end = QDate.currentDate().addDays(6).toString("yyyy-MM-dd") + query = MappingProxyType( + { + "doctor_id": doctor_id, + "start_date": start, + "end_date": end, + "status": 1, + "page_no": 1, + "page_size": 100, + } + ) + self._set_loading("roster", "正在加载医生排班…") + self._run_async( + lambda: _invoke_first( + self.repository, + ("list_appointment_rosters", "appointment_rosters", "roster_lists"), + **query, + ), + on_success=lambda result: self._apply_rosters(result, doctor_id, generation), + on_error=lambda error: self._roster_error(error, doctor_id, generation), + ) + + def _apply_rosters(self, result: Any, doctor_id: int, generation: int) -> None: + if generation != self._roster_generation or doctor_id != _as_int( + self.doctor_combo.currentData() + ): + return + today = QDate.currentDate() + end = today.addDays(6) + dates: set[str] = set() + for item in self._rows(result): + raw = str(first_value(item, "date", "appointment_date", "roster_date", default="")) + parsed = QDate.fromString(raw[:10], "yyyy-MM-dd") + if parsed.isValid() and today <= parsed <= end: + dates.add(parsed.toString("yyyy-MM-dd")) + ordered = sorted(dates) + self._setting = True + try: + self._populate_dates(ordered) + if ordered: + today_value = today.toString("yyyy-MM-dd") + preferred = next( + ( + value + for value in ordered + if not (self._today_blocking and value == today_value) + ), + ordered[0], + ) + self.date_combo.setCurrentIndex(self.date_combo.findData(preferred)) + self._sync_date_buttons(preferred) + finally: + self._setting = False + self._clear_loading("roster") + if not ordered: + self._show_time_empty("该医生暂无排班") + self.banner.show_message("该医生未来 7 天暂无可预约排班。", "warning") + self._update_conflict_alert() + self._update_submit_state() + return + self.banner.clear() + self.time_stack.setCurrentWidget(self.appointment_time_container) + self._date_changed() + + def _roster_error(self, error: Exception, doctor_id: int, generation: int) -> None: + if generation != self._roster_generation or doctor_id != _as_int( + self.doctor_combo.currentData() + ): + return + self._clear_loading("roster") + self._show_time_empty("排班加载失败") + self.banner.show_message(f"排班加载失败:{friendly_error(error)}", "danger") + self._update_submit_state() + + def _populate_dates(self, dates: Sequence[str]) -> None: + self._clear_flow(self.date_flow) + self._date_buttons.clear() + self.date_combo.clear() + self.date_combo.addItem("请选择排班日期", "") + week_days = ["日", "一", "二", "三", "四", "五", "六"] + for value in dates: + parsed = QDate.fromString(value, "yyyy-MM-dd") + if not parsed.isValid(): + continue + label = f"{parsed.toString('MM月dd日')} ({week_days[parsed.dayOfWeek() % 7]})" + self.date_combo.addItem(label, value) + button = _HoverLiftButton(label) + button.setProperty("appointmentDate", True) + button.setCheckable(True) + button.setFixedSize(130, 40) + button.clicked.connect(lambda _checked=False, date=value: self._select_date(date)) + self.date_flow.addWidget(button) + self._date_buttons[value] = button + + def _select_date(self, value: str) -> None: + index = self.date_combo.findData(value) + if index >= 0 and index != self.date_combo.currentIndex(): + self.date_combo.setCurrentIndex(index) + elif index >= 0: + self._date_changed() + + def _date_changed(self) -> None: + if self._setting: + return + doctor_id = _as_int(self.doctor_combo.currentData()) + appointment_date = str(self.date_combo.currentData() or "") + self._sync_date_buttons(appointment_date) + self._clear_slots( + "正在加载可用号源…" if doctor_id and appointment_date else "请选择排班日期" + ) + self._update_conflict_alert() + if doctor_id <= 0 or not appointment_date: + self._update_submit_state() + return + self._request_slots(doctor_id, appointment_date) + + def _request_slots( + self, + doctor_id: int, + appointment_date: str, + *, + restore_selection: str = "", + ) -> None: + self._slot_generation += 1 + generation = self._slot_generation + self._slot_restore_selection = restore_selection + query = MappingProxyType( + { + "doctor_id": doctor_id, + "appointment_date": appointment_date, + "period": "all", + } + ) + self.refresh_slots_button.setEnabled(False) + self.refresh_slots_button.setText("刷新中…") + loading_text = "正在刷新可用号源…" if restore_selection else "正在加载可用号源…" + self._set_loading("slots", loading_text) + self._run_async( + lambda: _invoke_first( + self.repository, + ("get_available_appointment_slots", "available_appointment_slots"), + **query, + ), + on_success=lambda result: self._apply_slots( + result, doctor_id, appointment_date, generation + ), + on_error=lambda error: self._slot_error(error, doctor_id, appointment_date, generation), + ) + + def _apply_slots( + self, result: Any, doctor_id: int, appointment_date: str, generation: int + ) -> None: + if ( + generation != self._slot_generation + or doctor_id != _as_int(self.doctor_combo.currentData()) + or appointment_date != str(self.date_combo.currentData() or "") + ): + return + rows = self._rows(result, "slots") + today = QDate.currentDate().toString("yyyy-MM-dd") + now = QTime.currentTime() + slots: list[tuple[str, bool, str]] = [] + for item in rows: + if isinstance(item, str): + raw_time = item + available = True + quota = 1 + else: + raw_time = str(first_value(item, "time", "appointment_time", default="")) + available = _as_bool(first_value(item, "available", default=False)) + quota = _as_int(first_value(item, "quota", default=1), 1) + if not raw_time: + continue + parsed = QTime.fromString(raw_time[:5], "HH:mm") + expired = appointment_date == today and parsed.isValid() and parsed <= now + can_book = available and quota > 0 and not expired + # Vue keeps expired slots in the grid and renders every unavailable slot as + # “已约”. Expiry still participates in the availability calculation above. + status = "可约" if can_book else "已约" + slots.append((raw_time, can_book, status)) + restore = self._slot_restore_selection + self._populate_slots(slots) + if restore and self._slot_available.get(restore, False): + index = self.slot_combo.findData(restore) + if index >= 0: + self.slot_combo.setCurrentIndex(index) + self._slot_restore_selection = "" + self._clear_loading("slots") + self.refresh_slots_button.setEnabled(True) + self.refresh_slots_button.setText("刷新") + if slots: + self.banner.clear() + self.slot_state_stack.setCurrentWidget(self.slot_scroll) + QTimer.singleShot(0, self._update_slot_scroll_height) + else: + self.slot_empty.set_text("该排班暂无可用号源") + self.slot_state_stack.setFixedHeight(94) + self.slots_panel.setFixedHeight(164) + self.slot_state_stack.setCurrentWidget(self.slot_empty) + self._update_submit_state() + + def _populate_slots(self, slots: Sequence[tuple[str, bool, str]]) -> None: + self._clear_flow(self.slot_flow) + self._slot_buttons.clear() + self._slot_available.clear() + self.slot_group = QButtonGroup(self) + self.slot_group.setExclusive(True) + self._setting = True + try: + self.slot_combo.clear() + self.slot_combo.addItem("请选择可用号源", "") + for raw_time, available, status in slots: + self.slot_combo.addItem(f"{raw_time} · {status}", raw_time) + button = _SlotCard(raw_time, status) + button.setProperty("appointmentSlot", True) + button.setProperty("availability", "available" if available else "unavailable") + button.setProperty( + "slotState", + "available" if available else "unavailable", + ) + button.setCheckable(available) + button.setEnabled(available) + button.setMinimumSize(110, 70) + button.clicked.connect( + lambda _checked=False, value=raw_time: self._select_slot(value) + ) + self.slot_group.addButton(button) + self.slot_flow.addWidget(button) + self._slot_buttons[raw_time] = button + self._slot_available[raw_time] = available + if not available: + opacity = QGraphicsOpacityEffect(button) + opacity.setOpacity(0.6) + button.setGraphicsEffect(opacity) + finally: + self._setting = False + + def _select_slot(self, value: str) -> None: + if not self._slot_available.get(value, False): + return + index = self.slot_combo.findData(value) + if index >= 0: + self.slot_combo.setCurrentIndex(index) + + def _slot_selection_changed(self) -> None: + if self._setting: + return + value = str(self.slot_combo.currentData() or "") + for slot_value, button in self._slot_buttons.items(): + button.setChecked(slot_value == value and self._slot_available.get(slot_value, False)) + self._update_submit_state() + + def _slot_error( + self, error: Exception, doctor_id: int, appointment_date: str, generation: int + ) -> None: + if ( + generation != self._slot_generation + or doctor_id != _as_int(self.doctor_combo.currentData()) + or appointment_date != str(self.date_combo.currentData() or "") + ): + return + self._slot_restore_selection = "" + self._clear_loading("slots") + self.refresh_slots_button.setEnabled(True) + self.refresh_slots_button.setText("刷新") + self.slot_empty.set_text("号源加载失败") + self.slot_state_stack.setFixedHeight(94) + self.slots_panel.setFixedHeight(164) + self.slot_state_stack.setCurrentWidget(self.slot_empty) + self.banner.show_message(f"号源加载失败:{friendly_error(error)}", "danger") + self._update_submit_state() + + def _refresh_slots(self) -> None: + doctor_id = _as_int(self.doctor_combo.currentData()) + appointment_date = str(self.date_combo.currentData() or "") + if doctor_id <= 0 or not appointment_date: + return + selected = str(self.slot_combo.currentData() or "") + self._clear_slots("正在刷新可用号源…") + self._request_slots(doctor_id, appointment_date, restore_selection=selected) + + def _channel_changed(self) -> None: + if self._setting: + return + self._sync_channel_detail_visibility() + self._update_submit_state() + + def _sync_channel_detail_visibility(self) -> None: + requires_detail = self._channel_requires_detail() + self.channel_detail_row.setVisible(requires_detail) + if not requires_detail and self.channel_source_detail.text(): + previous = self.channel_source_detail.blockSignals(True) + self.channel_source_detail.clear() + self.channel_source_detail.blockSignals(previous) + + def _channel_requires_detail(self) -> bool: + value = str(self.channel_source.currentData() or "") + return self._channel_names.get(value, "") in self.CHANNEL_DETAIL_NAMES + + def _update_conflict_alert(self) -> None: + if not self._today_blocking: + self.conflict_alert.clear() + return + today = QDate.currentDate().toString("yyyy-MM-dd") + if str(self.date_combo.currentData() or "") == today: + self.conflict_alert.show_message( + "该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。", + "warning", + ) + else: + self.conflict_alert.show_message( + "该患者今日已有挂号记录;可为患者预约其他日期的时段。", + "info", + ) + + def _update_submit_state(self) -> None: + if not hasattr(self, "ok_button"): + return + date_value = str(self.date_combo.currentData() or "") if hasattr(self, "date_combo") else "" + slot_value = str(self.slot_combo.currentData() or "") if hasattr(self, "slot_combo") else "" + today_blocked = self._today_blocking and date_value == QDate.currentDate().toString( + "yyyy-MM-dd" + ) + complete = bool( + self._initial_loaded + and _as_int(self.doctor_combo.currentData()) > 0 + and date_value + and slot_value + and self._slot_available.get(slot_value, False) + and self.channel_source.currentData() + and not today_blocked + and (not self._channel_requires_detail() or self.channel_source_detail.text().strip()) + ) + self.ok_button.setEnabled(complete) + + def payload(self) -> dict[str, Any]: + # firstvisit.myPatient/createAppointment deliberately names the diagnosis + # identifier ``patient_id``. source_patient_id is only used by video. + return { + "diagnosis_id": self.diagnosis_id, + "patient_id": self.diagnosis_id, + "doctor_id": _as_int(self.doctor_combo.currentData()), + "appointment_date": str(self.date_combo.currentData() or ""), + "appointment_time": str(self.slot_combo.currentData() or ""), + "period": "all", + "appointment_type": str(self.appointment_type.currentData() or "video"), + "remark": self.remark.toPlainText().strip(), + "channel_source": str(self.channel_source.currentData() or ""), + "channel_source_detail": self.channel_source_detail.text().strip(), + } + + def accept(self) -> None: + self._update_submit_state() + if not self.ok_button.isEnabled(): + if self._today_blocking and str( + self.date_combo.currentData() or "" + ) == QDate.currentDate().toString("yyyy-MM-dd"): + self.banner.show_message("该患者今天已有预约或过号,请选择其他日期。", "warning") + else: + self.banner.show_message("请完整选择医生、排班、可用号源和渠道。", "warning") + return + super().accept() + + def reject(self) -> None: + self._invalidate_requests() + super().reject() + + def done(self, result: int) -> None: + self._invalidate_requests() + owner = getattr(self, "_owner", None) + if owner is not None: + with suppress(RuntimeError): + owner.removeEventFilter(self) + super().done(result) + + def showEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API + self._sync_to_owner() + super().showEvent(event) + QTimer.singleShot(0, self._sync_to_owner) + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(15, 23, 42, 118)) + painter.end() + super().paintEvent(event) + + def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API + self._update_drawer_width() + if hasattr(self, "loading_overlay"): + self.loading_overlay.setGeometry(self.body_scroll.viewport().rect()) + super().resizeEvent(event) + + def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API + owner = getattr(self, "_owner", None) + if ( + owner is not None + and watched is owner + and event.type() + in { + QEvent.Type.Resize, + QEvent.Type.Move, + QEvent.Type.Show, + } + ): + QTimer.singleShot(0, self._sync_to_owner) + elif watched is getattr(self, "_body_viewport", None) and event.type() in { + QEvent.Type.Resize, + QEvent.Type.Show, + }: + try: + self.loading_overlay.setGeometry(watched.rect()) + except RuntimeError: + return False + elif watched is getattr(self, "_slot_viewport", None) and event.type() in { + QEvent.Type.Resize, + QEvent.Type.Show, + }: + QTimer.singleShot(0, self._update_slot_scroll_height) + return super().eventFilter(watched, event) + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt API + if not self.panel.geometry().contains(event.position().toPoint()): + event.accept() + return + super().mousePressEvent(event) + + def _sync_to_owner(self) -> None: + if self._owner is None: + self._update_drawer_width() + return + top_left = self._owner.mapToGlobal(QPoint(0, 0)) + self.setGeometry(QRect(top_left, self._owner.size())) + self._update_drawer_width() + + def _update_drawer_width(self) -> None: + if not hasattr(self, "panel"): + return + target = max(1, int(round(self.width() * 0.60))) + self.panel.setFixedWidth(target) + + def _set_loading(self, token: str, message: str) -> None: + self._active_loading = token + if hasattr(self, "loading_overlay"): + self.loading_overlay.show_message(message) + + def _clear_loading(self, token: str) -> None: + if self._active_loading != token: + return + self._active_loading = "" + self.loading_overlay.clear() + + def _show_time_empty(self, text: str) -> None: + self.time_empty.set_text(text) + self.time_stack.setCurrentWidget(self.time_empty) + + def _clear_dates(self) -> None: + self._setting = True + try: + self._clear_flow(self.date_flow) + self._date_buttons.clear() + self.date_combo.clear() + self.date_combo.addItem("请先选择医生", "") + finally: + self._setting = False + + def _clear_slots(self, message: str) -> None: + self._setting = True + try: + self._clear_flow(self.slot_flow) + self._slot_buttons.clear() + self._slot_available.clear() + self.slot_combo.clear() + self.slot_combo.addItem(message, "") + finally: + self._setting = False + self.slot_empty.set_text(message) + self.slot_state_stack.setFixedHeight(94) + self.slots_panel.setFixedHeight(164) + self.slot_state_stack.setCurrentWidget(self.slot_empty) + self._update_submit_state() + + def _update_slot_scroll_height(self) -> None: + if not self._slot_buttons or self.slot_state_stack.currentWidget() is not self.slot_scroll: + return + viewport_width = max(110, self.slot_scroll.viewport().width()) + content_height = max(70, self.slot_flow.heightForWidth(viewport_width)) + target = max(94, min(450, content_height + 4)) + self.slot_content.setMinimumHeight(content_height) + if self.slot_scroll.height() != target: + self.slot_scroll.setFixedHeight(target) + if self.slot_state_stack.height() != target: + self.slot_state_stack.setFixedHeight(target) + panel_height = target + 70 + if self.slots_panel.height() != panel_height: + self.slots_panel.setFixedHeight(panel_height) + + def _sync_date_buttons(self, selected: str) -> None: + for value, button in self._date_buttons.items(): + button.setChecked(value == selected) + + @staticmethod + def _clear_flow(layout: FlowLayout) -> None: + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() if item is not None else None + if widget is not None: + widget.hide() + widget.deleteLater() + + def _invalidate_requests(self) -> None: + self._load_generation += 1 + self._roster_generation += 1 + self._slot_generation += 1 + if hasattr(self, "loading_overlay"): + self.loading_overlay.clear() + + +__all__ = ["APPOINTMENT_DRAWER_QSS", "AppointmentDrawer", "FlowLayout"] diff --git a/app/src/doctor_workstation/ui/diagnosis_drawer.py b/app/src/doctor_workstation/ui/diagnosis_drawer.py new file mode 100644 index 000000000..dd255093a --- /dev/null +++ b/app/src/doctor_workstation/ui/diagnosis_drawer.py @@ -0,0 +1,2929 @@ +"""Scoped visual primitives for diagnosis readonly pages and edit drawers. + +The shared workstation theme intentionally stays untouched. Every selector in +this module is rooted at one of the diagnosis object names so the cooler +Element-like palette cannot leak into the rest of the application. +""" + +from __future__ import annotations + +import weakref +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from PySide6.QtCore import QDate, QPointF, QRect, QRectF, QSize, Qt, QThread, QTimer, QUrl, Signal +from PySide6.QtGui import ( + QColor, + QHideEvent, + QIcon, + QPainter, + QPen, + QPixmap, + QResizeEvent, + QShowEvent, +) +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest +from PySide6.QtWidgets import ( + QAbstractButton, + QAbstractItemView, + QButtonGroup, + QComboBox, + QDateEdit, + QDoubleSpinBox, + QFrame, + QGridLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPlainTextEdit, + QPushButton, + QRadioButton, + QScrollBar, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from .diagnosis_editors import FlowLayout + +DIAGNOSIS_QSS = r""" +QDialog#DiagnosisDialogRoot { + background: transparent; +} +QFrame#DiagnosisDrawerOverlay { + background-color: rgba(15, 23, 42, 112); +} +QFrame#DiagnosisDrawer { + background-color: #FFFFFF; + border-left: 1px solid #DCE3EC; +} +QFrame#DiagnosisDrawerHeader { + background-color: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #FFFFFF, stop:1 #F8FAFC + ); + border-bottom: 1px solid #E2E8F0; +} +QFrame#DiagnosisDrawerFooter { + background-color: #FFFFFF; + border-top: 1px solid #E2E8F0; + border-top-left-radius: 1px; + border-top-right-radius: 1px; +} +QLabel#DiagnosisDrawerTitle { + color: #0F172A; + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 18px; + font-weight: 600; +} +QLabel#DiagnosisDrawerMeta { + color: #64748B; + font-size: 13px; +} +QLabel#DiagnosisDrawerModeBadge, +QLabel#DiagnosisReadonlyStatus, +QLabel[diagnosisTag="true"] { + padding: 4px 9px; + border-radius: 9px; + font-size: 12px; + font-weight: 600; +} +QLabel#DiagnosisDrawerModeBadge[kind="info"] { + color: #475569; + background-color: #F1F5F9; + border: 1px solid #CBD5E1; +} +QLabel#DiagnosisDrawerModeBadge[kind="warning"] { + color: #B45309; + background-color: #FFFBEB; + border: 1px solid #FCD34D; +} +QLabel#DiagnosisDrawerModeBadge[kind="success"] { + color: #15803D; + background-color: #F0FDF4; + border: 1px solid #BBF7D0; +} +QLabel[diagnosisTag="true"][kind="success"] { + color: #15803D; background-color: #F0FDF4; border: 1px solid #BBF7D0; +} +QLabel[diagnosisTag="true"][kind="warning"] { + color: #B45309; background-color: #FFFBEB; border: 1px solid #FCD34D; +} +QLabel[diagnosisTag="true"][kind="danger"] { + color: #DC2626; background-color: #FEF2F2; border: 1px solid #FECACA; +} +QLabel[diagnosisTag="true"][kind="info"] { + color: #2563EB; background-color: #EFF6FF; border: 1px solid #BFDBFE; +} +QLabel[diagnosisTag="true"][kind="neutral"] { + color: #64748B; background-color: #F1F5F9; border: 1px solid #CBD5E1; +} +QDialog QPushButton { + min-height: 30px; + padding: 0 12px; + color: #606266; + background-color: #FFFFFF; + border: 1px solid #DCDFE6; + border-radius: 4px; + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 13px; +} +QDialog QPushButton:hover, +QDialog QPushButton:focus { + color: #409EFF; + background-color: #ECF5FF; + border-color: #C6E2FF; +} +QDialog QPushButton:pressed { + color: #337ECC; + border-color: #337ECC; +} +QDialog QPushButton:disabled { + color: #C0C4CC; + background-color: #FFFFFF; + border-color: #EBEEF5; +} +QDialog QPushButton[variant="primary"] { + color: #FFFFFF; + background-color: #409EFF; + border-color: #409EFF; + font-weight: 600; +} +QDialog QPushButton[variant="primary"]:hover, +QDialog QPushButton[variant="primary"]:focus { + color: #FFFFFF; + background-color: #337ECC; + border-color: #337ECC; +} +QDialog QPushButton[variant="primary"]:disabled { + color: #FFFFFF; + background-color: #A0CFFF; + border-color: #A0CFFF; +} +QDialog QPushButton[variant="secondary"] { + color: #2563EB; + background-color: #EFF6FF; + border-color: #BFDBFE; +} +QDialog QPushButton[variant="secondary"]:hover, +QDialog QPushButton[variant="secondary"]:focus { + color: #1D4ED8; + background-color: #DBEAFE; + border-color: #60A5FA; +} +QDialog QPushButton[variant="ghost"] { + color: #475569; + background-color: transparent; + border-color: transparent; +} +QDialog QPushButton[variant="ghost"]:hover, +QDialog QPushButton[variant="ghost"]:focus { + color: #2563EB; + background-color: #EFF6FF; + border-color: #BFDBFE; +} +QDialog QPushButton[variant="danger"], +QDialog QPushButton[variant="dangerGhost"] { + color: #DC2626; + background-color: #FEF2F2; + border-color: #FECACA; +} +QDialog QPushButton[variant="danger"]:hover, +QDialog QPushButton[variant="danger"]:focus, +QDialog QPushButton[variant="dangerGhost"]:hover, +QDialog QPushButton[variant="dangerGhost"]:focus { + color: #B91C1C; + background-color: #FEE2E2; + border-color: #FCA5A5; +} +QDialog QPushButton[variant="link"] { + min-height: 24px; + padding: 0 4px; + color: #2563EB; + background-color: transparent; + border-color: transparent; +} +QDialog QPushButton[variant="link"]:hover, +QDialog QPushButton[variant="link"]:focus { + color: #1D4ED8; + background-color: #EFF6FF; + border-color: transparent; +} +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisCloseButton { + min-width: 32px; + max-width: 32px; + min-height: 32px; + max-height: 32px; + padding: 0; + color: #64748B; + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + font-size: 18px; +} +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisCloseButton:hover, +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisCloseButton:focus { + color: #2563EB; + background-color: #EFF6FF; + border-color: #BFDBFE; +} +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton, +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisFooterCloseButton { + min-height: 40px; + max-height: 40px; + padding: 0 20px; + border-radius: 10px; + font-size: 13px; + font-weight: 600; +} +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton { + color: #FFFFFF; + background-color: #409EFF; + border: 1px solid #409EFF; +} +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton:hover, +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton:focus { + background-color: #337ECC; + border-color: #337ECC; +} +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton:disabled { + color: #FFFFFF; + background-color: #A0CFFF; + border-color: #A0CFFF; +} +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisFooterCloseButton { + color: #475569; + background-color: #FFFFFF; + border: 1px solid #CBD5E1; +} +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisFooterCloseButton:hover, +QDialog#DiagnosisDialogRoot QPushButton#DiagnosisFooterCloseButton:focus { + color: #2563EB; + border-color: #60A5FA; + background-color: #EFF6FF; +} +QTabWidget#DiagnosisDrawerTabs::pane { + border: 0; + background-color: #F8FAFC; +} +QTabWidget#DiagnosisDrawerTabs QTabBar::tab { + min-height: 42px; + padding: 0 14px; + margin: 0; + color: #64748B; + background: #FFFFFF; + border: 0; + border-bottom: 3px solid transparent; + border-radius: 0; + font-size: 13px; + font-weight: 500; +} +QTabWidget#DiagnosisDrawerTabs QTabBar::tab:hover { + color: #2563EB; + background-color: #F8FAFC; +} +QTabWidget#DiagnosisDrawerTabs QTabBar::tab:selected { + color: #2563EB; + background-color: #FFFFFF; + border-bottom-color: #409EFF; + font-weight: 600; +} +QTabWidget#DiagnosisDrawerTabs QTabBar QToolButton { + width: 0; + min-width: 0; + max-width: 0; + padding: 0; + margin: 0; + border: 0; + background: transparent; +} +QScrollBar#DiagnosisTabOverflow:horizontal { + height: 4px; + margin: 0; + border: 0; + background: #E2E8F0; +} +QScrollBar#DiagnosisTabOverflow::handle:horizontal { + min-width: 36px; + border-radius: 2px; + background: #93C5FD; +} +QScrollBar#DiagnosisTabOverflow::add-line:horizontal, +QScrollBar#DiagnosisTabOverflow::sub-line:horizontal, +QScrollBar#DiagnosisTabOverflow::add-page:horizontal, +QScrollBar#DiagnosisTabOverflow::sub-page:horizontal { + width: 0; + background: transparent; +} +QAbstractButton#DiagnosisSwitch { + min-width: 44px; + max-width: 44px; + min-height: 24px; + max-height: 24px; + border: 0; + background: transparent; +} +QScrollArea#DiagnosisDrawerBody, +QScrollArea#DiagnosisReadonlyScroll { + border: 0; + background-color: transparent; +} +QScrollArea#DiagnosisDrawerBody > QWidget > QWidget { + background-color: #F8FAFC; +} +QScrollArea#DiagnosisReadonlyScroll > QWidget > QWidget { + background-color: #F6F6F6; +} +QFrame#DiagnosisPrivacyBanner, +QFrame#DiagnosisMessageBanner { + background-color: #FFFBEB; + border: 1px solid #FDE68A; + border-radius: 8px; +} +QFrame#DiagnosisMessageBanner[kind="info"] { + background-color: #EFF6FF; + border-color: #BFDBFE; +} +QFrame#DiagnosisMessageBanner[kind="success"] { + background-color: #F0FDF4; + border-color: #BBF7D0; +} +QFrame#DiagnosisMessageBanner[kind="danger"] { + background-color: #FEF2F2; + border-color: #FECACA; +} +QFrame#DiagnosisMessageBanner[kind="warning"] { + background-color: #FFFBEB; + border-color: #FDE68A; +} +QFrame#DiagnosisFormHost { + background-color: transparent; + border: 0; +} +QLabel[diagnosisFieldLabel="true"] { + color: #606266; + font-size: 12px; + font-weight: 500; +} +QLineEdit[diagnosisField="true"], +QComboBox[diagnosisField="true"], +QPlainTextEdit[diagnosisField="true"], +QDateEdit[diagnosisField="true"], +QDoubleSpinBox[diagnosisField="true"] { + color: #303133; + background-color: #FFFFFF; + border: 2px solid #E4E7ED; + border-radius: 10px; + padding: 0 10px; + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; + selection-background-color: #BFDBFE; +} +QPlainTextEdit[diagnosisField="true"] { + padding: 7px 10px; +} +QLineEdit[diagnosisField="true"]:focus, +QComboBox[diagnosisField="true"]:focus, +QPlainTextEdit[diagnosisField="true"]:focus, +QDateEdit[diagnosisField="true"]:focus, +QDoubleSpinBox[diagnosisField="true"]:focus { + border-color: #409EFF; +} +QLineEdit[diagnosisField="true"][invalid="true"], +QComboBox[diagnosisField="true"][invalid="true"], +QPlainTextEdit[diagnosisField="true"][invalid="true"] { + border-color: #F56C6C; + background-color: #FFF7F7; +} +QLineEdit[diagnosisField="true"]:read-only, +QPlainTextEdit[diagnosisField="true"]:read-only, +QLineEdit[diagnosisField="true"]:disabled, +QComboBox[diagnosisField="true"]:disabled, +QPlainTextEdit[diagnosisField="true"]:disabled, +QDateEdit[diagnosisField="true"]:disabled, +QDoubleSpinBox[diagnosisField="true"]:disabled { + color: #909399; + background-color: #F5F7FA; + border-color: #E4E7ED; +} +QLabel#DiagnosisSectionTitle { + color: #0F172A; + background-color: #F8FAFC; + border: 1px solid #E2E8F0; + border-radius: 12px; + padding: 6px 14px; + font-size: 14px; + font-weight: 600; +} +QFrame#DiagnosisSectionLine { + min-height: 1px; + max-height: 1px; + background-color: #E2E8F0; +} +QFrame#DiagnosisReadonlyPage { + background-color: #F6F6F6; +} +QFrame#DiagnosisReadonlyHero { + background-color: #F2F6FF; + border: 1px solid #DDE7FF; + border-radius: 12px; +} +QLabel#DiagnosisReadonlyTitle { + color: #0F172A; + font-size: 16px; + font-weight: 700; +} +QLabel#DiagnosisReadonlyPatientName { + color: #0F172A; + font-size: 18px; + font-weight: 700; +} +QLabel#DiagnosisReadonlyStatus[severity="neutral"] { + color: #A8ABB2; + background-color: rgba(255, 255, 255, 190); + border: 1px solid #DDE7FF; +} +QLabel#DiagnosisReadonlyStatus[severity="success"] { + color: #16A34A; + background-color: #F0FDF4; + border: 1px solid #BBF7D0; +} +QLabel#DiagnosisReadonlyStatus[severity="warning"] { + color: #EA580C; + background-color: #FFF7ED; + border: 1px solid #FED7AA; +} +QLabel#DiagnosisReadonlyStatus[severity="danger"] { + color: #DC2626; + background-color: #FEF2F2; + border: 1px solid #FECACA; +} +QFrame#DiagnosisReadonlyPatientHero { + background-color: #F2F6FF; + border: 1px solid #DDE7FF; + border-radius: 12px; +} +QFrame#DiagnosisReadonlyCaseCard, +QFrame[diagnosisReadonlyCard="true"] { + background-color: #FFFFFF; + border: 1px solid #E6EBF2; + border-radius: 14px; +} +QLabel#DiagnosisReadonlyCardTitle { + color: #1F2937; + border-left: 3px solid #2563EB; + padding-left: 9px; + font-size: 15px; + font-weight: 700; +} +QLabel#DiagnosisReadonlyPatientTitle { + color: #1D4ED8; + font-size: 22px; + font-weight: 700; +} +QLabel[diagnosisPatientMeta="true"] { + color: #64748B; + font-size: 13px; +} +QLabel[diagnosisCaseGroup="true"] { + color: #2563EB; + font-size: 13px; + font-weight: 600; +} +QLabel[diagnosisCaseLabel="true"] { + color: #6B7280; + font-size: 12px; +} +QLabel[diagnosisCaseValue="true"] { + color: #1F2937; + font-size: 12px; + font-weight: 500; +} +QLabel[diagnosisCaseValue="true"][abnormal="true"] { + color: #DC2626; + font-weight: 700; +} +QTableWidget[diagnosisTable="true"] { + color: #333333; + background-color: #FFFFFF; + alternate-background-color: #FAFAFA; + border: 1px solid #EBEEF5; + border-radius: 6px; + gridline-color: #EBEEF5; + selection-background-color: #ECF5FF; + selection-color: #303133; + font-size: 13px; +} +QTableWidget[diagnosisTable="true"]::item { + padding: 7px 8px; + border-bottom: 1px solid #EBEEF5; +} +QTableWidget[diagnosisTable="true"]::item:hover { + background-color: #F5F7FA; +} +QTableWidget[diagnosisTable="true"] QHeaderView::section { + color: #333333; + background-color: #F8F8F8; + border: 0; + border-right: 1px solid #EBEEF5; + border-bottom: 1px solid #EBEEF5; + padding: 8px; + font-size: 13px; + font-weight: 600; +} +QLabel#DiagnosisEmptyState { + color: #999999; + background-color: rgba(255, 255, 255, 218); + font-size: 13px; +} +QFrame#DiagnosisTimelineNode { + background-color: #FFFFFF; + border-left: 2px solid #EBEEF5; +} +QLabel#DiagnosisTimelineDot { + min-width: 10px; + max-width: 10px; + min-height: 10px; + max-height: 10px; + background-color: #409EFF; + border: 3px solid #ECF5FF; + border-radius: 8px; +} +QLabel#DiagnosisTimelineDate { + color: #303133; + font-size: 13px; + font-weight: 600; +} +QLabel#DiagnosisTimelineContent { + color: #606266; + font-size: 12px; +} +QPushButton#DiagnosisAttachmentChip { + color: #409EFF; + background-color: #ECF5FF; + border: 1px solid #D9ECFF; + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; +} +QPushButton#DiagnosisTongueThumb { + color: #2563EB; + background-color: #EFF6FF; + border: 1px solid #BFDBFE; + border-radius: 6px; + padding: 0; + font-size: 12px; + font-weight: 600; +} +QPushButton#DiagnosisTongueThumb[loadState="loading"], +QPushButton#DiagnosisChatImage[loadState="loading"] { + color: transparent; + background-color: #F1F5F9; + border-color: #DCE3EC; +} +QPushButton#DiagnosisTongueThumb[loadState="ready"], +QPushButton#DiagnosisChatImage[loadState="ready"] { + color: transparent; + background-color: #FFFFFF; + border-color: #E5E7EB; +} +QPushButton#DiagnosisChatImage { + color: #2563EB; + background-color: #EFF6FF; + border: 1px solid #BFDBFE; + border-radius: 4px; + padding: 0; + font-size: 12px; + font-weight: 600; +} +QPushButton#DiagnosisAttachmentChip:hover, +QPushButton#DiagnosisTongueThumb:hover, +QPushButton#DiagnosisChatImage:hover { + color: #1D4ED8; + background-color: #DBEAFE; + border-color: #93C5FD; +} +QPushButton#DiagnosisAttachmentRemove { + min-width: 20px; + max-width: 20px; + min-height: 20px; + max-height: 20px; + padding: 0; + color: #DC2626; + background-color: #FFFFFF; + border: 1px solid #FECACA; + border-radius: 11px; + font-size: 14px; + font-weight: 700; +} +QPushButton#DiagnosisAttachmentRemove:hover, +QPushButton#DiagnosisAttachmentRemove:focus { + color: #FFFFFF; + background-color: #DC2626; + border-color: #DC2626; +} +QFrame#DiagnosisChatInfo { + background-color: #EFF6FF; + border: 1px solid #BFDBFE; + border-radius: 8px; +} +QLabel#DiagnosisChatInfoIcon { + color: #2563EB; + font-size: 15px; + font-weight: 700; +} +QLabel#DiagnosisChatInfoText { + color: #475569; + font-size: 12px; +} +QFrame#DiagnosisChatBubble[side="patient"] { + background-color: #F3F4F6; + border: 1px solid #E5E7EB; + border-radius: 8px; +} +QFrame#DiagnosisChatBubble[side="doctor"] { + background-color: #ECF5FF; + border: 1px solid #D9ECFF; + border-radius: 8px; +} +QLabel#DiagnosisChatMeta { + color: #909399; + font-size: 12px; +} +QLabel#DiagnosisChatText { + color: #303133; + font-size: 14px; +} +QFrame#DiagnosisLoadingOverlay { + background-color: rgba(255, 255, 255, 210); + border-radius: 10px; +} +QLabel#DiagnosisLoadingGlyph { + color: #409EFF; + font-size: 28px; + font-weight: 700; +} +QWidget#DiagnosisChoiceButtons QRadioButton { + color: #303133; + spacing: 7px; + min-height: 30px; + font-size: 12px; +} +QWidget#DiagnosisChoiceButtons QRadioButton:focus { + color: #2563EB; +} +QWidget#DiagnosisChoiceButtons QPushButton { + min-height: 30px; + padding: 0 11px; + color: #475569; + background-color: #FFFFFF; + border: 1px solid #D8DEE8; + border-radius: 7px; + font-size: 12px; +} +QWidget#DiagnosisChoiceButtons QPushButton:hover, +QWidget#DiagnosisChoiceButtons QPushButton:focus { + color: #2563EB; + border-color: #60A5FA; + background-color: #EFF6FF; +} +QWidget#DiagnosisChoiceButtons QPushButton:checked { + color: #1D4ED8; + border-color: #60A5FA; + background-color: #DBEAFE; + font-weight: 600; +} +QWidget#DiagnosisChoiceButtons QPushButton:disabled, +QWidget#DiagnosisChoiceButtons QRadioButton:disabled { + color: #A8ABB2; + background-color: #F5F7FA; + border-color: #E4E7ED; +} +QFrame#DiagnosisDailyPanel, +QFrame#DiagnosisTrendCard, +QFrame#DiagnosisTodoCard { + background-color: #FFFFFF; + border: 1px solid #E4E7ED; + border-radius: 10px; +} +QLabel#DiagnosisDailySectionTitle { + color: #303133; + font-size: 14px; + font-weight: 600; +} +QPushButton[diagnosisRange="true"] { + min-height: 32px; + padding: 0 12px; + color: #606266; + background-color: #FFFFFF; + border: 1px solid #DCDFE6; + border-radius: 7px; +} +QPushButton[diagnosisRange="true"]:checked { + color: #2563EB; + background-color: #ECF5FF; + border-color: #409EFF; + font-weight: 600; +} +QLabel#DiagnosisUnsupportedState { + color: #64748B; + background-color: #F8FAFC; + border: 1px dashed #CBD5E1; + border-radius: 10px; + padding: 22px; + font-size: 13px; +} +QDialog#DiagnosisDailyEditor, +QDialog#DiagnosisOrderDetailDialog, +QDialog#DiagnosisRecordingPlayer { + background-color: #FFFFFF; +} +QLabel#DiagnosisDialogHeading { + color: #0F172A; + font-size: 18px; + font-weight: 700; +} +QLabel#DiagnosisDialogGuidance, +QLabel#DiagnosisRecordingTime { + color: #64748B; + font-size: 12px; +} +QLabel#DiagnosisEditorError { + color: #DC2626; + background-color: #FEF2F2; + border: 1px solid #FECACA; + border-radius: 7px; + padding: 8px 10px; +} +QScrollArea#DiagnosisEditorScroll, +QScrollArea#DiagnosisOrderDetailScroll { + background-color: #F8FAFC; + border: 1px solid #E2E8F0; + border-radius: 10px; +} +QFrame#DiagnosisOrderOffsetBar, +QFrame#DiagnosisOrderDetailHero { + background-color: #F8FAFC; + border: 1px solid #E2E8F0; + border-radius: 9px; +} +QFrame#DiagnosisRecordingSurface, +QLabel#DiagnosisRecordingPlaceholder { + background-color: #0F172A; + border: 1px solid #1E293B; + border-radius: 8px; +} +QLabel#DiagnosisRecordingPlaceholder { + color: #CBD5E1; + font-size: 14px; + font-weight: 600; +} +QDialog#DiagnosisPrescriptionEditor { + background-color: #FFFFFF; +} +QDialog#DiagnosisPrescriptionEditor QTabWidget::pane { + background-color: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 8px; +} +QDialog#DiagnosisPrescriptionEditor QTabBar::tab { + min-height: 36px; + padding: 0 14px; + color: #64748B; + background-color: #F8FAFC; + border: 1px solid #E2E8F0; + border-bottom: 0; +} +QDialog#DiagnosisPrescriptionEditor QTabBar::tab:selected { + color: #2563EB; + background-color: #FFFFFF; + font-weight: 600; +} +QDialog#DiagnosisPrescriptionEditor QScrollArea, +QDialog#DiagnosisPrescriptionEditor QScrollArea > QWidget > QWidget { + background-color: #FFFFFF; + border: 0; +} +QDialog#DiagnosisPrescriptionEditor QLineEdit, +QDialog#DiagnosisPrescriptionEditor QTextEdit, +QDialog#DiagnosisPrescriptionEditor QPlainTextEdit, +QDialog#DiagnosisPrescriptionEditor QComboBox, +QDialog#DiagnosisPrescriptionEditor QDateEdit, +QDialog#DiagnosisPrescriptionEditor QSpinBox, +QDialog#DiagnosisPrescriptionEditor QDoubleSpinBox { + color: #303133; + background-color: #FFFFFF; + border: 1px solid #DCDFE6; + border-radius: 7px; + padding: 0 9px; + selection-background-color: #BFDBFE; +} +QDialog#DiagnosisPrescriptionEditor QTextEdit, +QDialog#DiagnosisPrescriptionEditor QPlainTextEdit { + padding: 7px 9px; +} +QDialog#DiagnosisPrescriptionEditor QLineEdit:focus, +QDialog#DiagnosisPrescriptionEditor QTextEdit:focus, +QDialog#DiagnosisPrescriptionEditor QPlainTextEdit:focus, +QDialog#DiagnosisPrescriptionEditor QComboBox:focus, +QDialog#DiagnosisPrescriptionEditor QDateEdit:focus, +QDialog#DiagnosisPrescriptionEditor QSpinBox:focus, +QDialog#DiagnosisPrescriptionEditor QDoubleSpinBox:focus { + border-color: #409EFF; +} +""" + + +def _text(value: Any, default: str = "—") -> str: + if value is None or value == "": + return default + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + rendered = "、".join(str(item) for item in value if item not in (None, "")) + return rendered or default + return str(value) + + +def _pick(source: Any, *keys: str, default: Any = "") -> Any: + if isinstance(source, Mapping): + for key in keys: + if key in source and source[key] not in (None, ""): + return source[key] + else: + for key in keys: + value = getattr(source, key, None) + if value not in (None, ""): + return value + return default + + +def repolish(widget: QWidget) -> None: + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + +class StatusTag(QLabel): + """Small semantic tag with dynamic-property styling.""" + + def __init__(self, text: str = "", kind: str = "info", parent: QWidget | None = None) -> None: + super().__init__(text, parent) + self.setProperty("diagnosisTag", True) + self.set_kind(kind) + + def set_kind(self, kind: str) -> None: + self.setProperty("kind", kind) + repolish(self) + + +class DiagnosisSwitch(QAbstractButton): + """Native two-state switch retaining the form editor text contract.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisSwitch") + self.setCheckable(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setAccessibleName("统计端就诊卡") + + def toPlainText(self) -> str: # noqa: N802 - editor compatibility + return "1" if self.isChecked() else "0" + + def setPlainText(self, text: str) -> None: # noqa: N802 + self.setChecked(str(text).strip().lower() in {"1", "true", "yes", "显示", "启用"}) + + def setReadOnly(self, readonly: bool) -> None: # noqa: N802 + self.setEnabled(not readonly) + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API + del event + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + track = QRectF(1, 3, self.width() - 2, self.height() - 6) + checked = self.isChecked() + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor("#409EFF" if checked else "#CBD5E1")) + painter.drawRoundedRect(track, track.height() / 2, track.height() / 2) + diameter = track.height() - 4 + x = track.right() - diameter - 2 if checked else track.left() + 2 + painter.setBrush(QColor("#FFFFFF")) + painter.drawEllipse(QRectF(x, track.top() + 2, diameter, diameter)) + painter.end() + + +class SaveStateButton(QPushButton): + """Self-painted save button with spinner and terminal status glyphs.""" + + def __init__(self, text: str = "保存病历", parent: QWidget | None = None) -> None: + super().__init__(text, parent) + self._state = "idle" + self._angle = 0 + self._spinner = QTimer(self) + self._spinner.setInterval(70) + self._spinner.timeout.connect(self._advance_spinner) + + @property + def state(self) -> str: + return self._state + + def set_state(self, state: str) -> None: + if state not in {"idle", "loading", "success", "error"}: + raise ValueError(f"unsupported save state: {state}") + self._state = state + if state == "loading": + self._spinner.start() + else: + self._spinner.stop() + self.update() + + def _advance_spinner(self) -> None: + self._angle = (self._angle + 30) % 360 + self.update() + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API + del event + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + enabled = self.isEnabled() + color = { + "success": "#16A34A", + "error": "#DC2626", + }.get(self._state, "#409EFF" if enabled else "#A0CFFF") + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(color)) + painter.drawRoundedRect(QRectF(self.rect()).adjusted(1, 1, -1, -1), 10, 10) + label = { + "loading": "正在保存", + "success": "保存成功", + "error": "保存失败", + }.get(self._state, self.text()) + metrics = painter.fontMetrics() + icon_width = 18 if self._state != "idle" else 0 + total_width = metrics.horizontalAdvance(label) + icon_width + x = (self.width() - total_width) // 2 + center_y = self.height() // 2 + if self._state == "loading": + painter.setPen(QPen(QColor("#FFFFFF"), 2)) + painter.drawArc(QRect(x, center_y - 7, 14, 14), self._angle * 16, 250 * 16) + elif self._state in {"success", "error"}: + painter.setPen( + QPen(QColor("#FFFFFF"), 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap) + ) + if self._state == "success": + painter.drawLine(x + 2, center_y, x + 6, center_y + 4) + painter.drawLine(x + 6, center_y + 4, x + 13, center_y - 4) + else: + painter.drawLine(x + 3, center_y - 5, x + 12, center_y + 5) + painter.drawLine(x + 12, center_y - 5, x + 3, center_y + 5) + painter.setPen(QColor("#FFFFFF")) + painter.drawText( + QRect(x + icon_width, 0, metrics.horizontalAdvance(label), self.height()), + Qt.AlignmentFlag.AlignCenter, + label, + ) + painter.end() + + +class DiagnosisTabWidget(QTabWidget): + """Scrollable diagnosis tabs without boxed previous/next arrow controls.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.tabBar().setUsesScrollButtons(True) + self.tabBar().setExpanding(False) + self.tabBar().setElideMode(Qt.TextElideMode.ElideNone) + self.tab_scrollbar = QScrollBar(Qt.Orientation.Horizontal, self) + self.tab_scrollbar.setObjectName("DiagnosisTabOverflow") + self.tab_scrollbar.setSingleStep(1) + self.tab_scrollbar.valueChanged.connect(self._scroll_to_tab) + self.currentChanged.connect(self._sync_scroll_value) + self.tab_scrollbar.hide() + + def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API + super().resizeEvent(event) + self._sync_overflow() + + def tabInserted(self, index: int) -> None: # noqa: N802 - Qt API + super().tabInserted(index) + QTimer.singleShot(0, self._sync_overflow) + + def tabRemoved(self, index: int) -> None: # noqa: N802 - Qt API + super().tabRemoved(index) + QTimer.singleShot(0, self._sync_overflow) + + def _sync_overflow(self) -> None: + bar_height = self.tabBar().height() + self.tab_scrollbar.setGeometry(0, max(0, bar_height - 4), self.width(), 4) + overflow = self.tabBar().sizeHint().width() > self.width() + self.tab_scrollbar.setRange(0, max(0, self.count() - 1)) + self.tab_scrollbar.setPageStep(max(1, self.width() // 92)) + self.tab_scrollbar.setVisible(overflow and self.count() > 1) + self.tab_scrollbar.raise_() + + def _scroll_to_tab(self, index: int) -> None: + if 0 <= index < self.count() and index != self.currentIndex(): + self.setCurrentIndex(index) + + def _sync_scroll_value(self, index: int) -> None: + self.tab_scrollbar.blockSignals(True) + self.tab_scrollbar.setValue(max(0, index)) + self.tab_scrollbar.blockSignals(False) + + +class DiagnosisLineEdit(QLineEdit): + """Line editor retaining the legacy plain-text editor contract.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setProperty("diagnosisField", True) + self.setFixedHeight(32) + + def toPlainText(self) -> str: # noqa: N802 - compatibility with QPlainTextEdit + return self.text() + + def setPlainText(self, text: str) -> None: # noqa: N802 + self.setText(text) + + +class DiagnosisTextEdit(QPlainTextEdit): + """Multiline editor using the same scoped field state as line inputs.""" + + def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setProperty("diagnosisField", True) + height = 54 if rows <= 2 else 72 if rows == 3 else 92 + self.setMinimumHeight(height) + self.setMaximumHeight(height) + + +class DiagnosisComboBox(QComboBox): + """Choice editor with the legacy text API used by diagnosis saving.""" + + def __init__( + self, + choices: Sequence[tuple[str, Any]], + parent: QWidget | None = None, + *, + allow_custom: bool = True, + ) -> None: + super().__init__(parent) + self.setProperty("diagnosisField", True) + self.setFixedHeight(32) + self._read_only = False + self._allow_custom = bool(allow_custom) + self.set_choices(choices, preserve=False) + + def set_choices( + self, + choices: Sequence[tuple[str, Any]], + *, + preserve: bool = True, + ) -> None: + """Replace options while retaining the selected backend value when possible.""" + + selected = self.toPlainText() if preserve and self.count() else "" + self.clear() + self.addItem("请选择", "") + seen: set[str] = set() + for label, value in choices: + token = str(value) + if not token or token in seen: + continue + seen.add(token) + self.addItem(str(label), value) + if selected: + self.setPlainText(selected) + + def toPlainText(self) -> str: # noqa: N802 + value = self.currentData() + return "" if value is None else str(value) + + def setPlainText(self, text: str) -> None: # noqa: N802 + index = self.findData(text) + if index < 0: + try: + index = self.findData(int(text)) + except (TypeError, ValueError): + index = -1 + if index < 0: + index = self.findText(text) + if index < 0 and text and self._allow_custom: + self.addItem(text, text) + index = self.count() - 1 + self.setCurrentIndex(max(0, index)) + + def setReadOnly(self, read_only: bool) -> None: # noqa: N802 + self._read_only = bool(read_only) + self.setEnabled(not self._read_only) + + def isReadOnly(self) -> bool: # noqa: N802 + return self._read_only + + +class DiagnosisChoiceButtons(QWidget): + """Radio or checkbox-button field with the legacy plain-text contract.""" + + def __init__( + self, + choices: Sequence[tuple[str, Any]] = (), + *, + multiple: bool = False, + radio_buttons: bool = False, + columns: int = 4, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisChoiceButtons") + self.setProperty("diagnosisField", True) + self._multiple = bool(multiple) + self._radio_buttons = bool(radio_buttons) + self._columns = max(1, int(columns)) + self._read_only = False + self._choices: list[tuple[str, Any]] = [] + self._buttons: list[QPushButton | QRadioButton] = [] + self._group = QButtonGroup(self) + self._group.setExclusive(not self._multiple) + self._layout = QGridLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setHorizontalSpacing(8) + self._layout.setVerticalSpacing(7) + self.set_choices(choices) + + def set_choices(self, choices: Sequence[tuple[str, Any]], *, preserve: bool = True) -> None: + selected = self.toPlainText() if preserve else "" + while self._layout.count(): + item = self._layout.takeAt(0) + widget = item.widget() + if widget is not None: + self._group.removeButton(widget) + widget.deleteLater() + self._choices = [] + self._buttons = [] + seen: set[str] = set() + for label, value in choices: + token = str(value) + if token in seen: + continue + seen.add(token) + button: QPushButton | QRadioButton + if self._radio_buttons: + button = QRadioButton(str(label)) + else: + button = QPushButton(str(label)) + button.setCheckable(True) + button.setProperty("diagnosisValue", value) + button.setEnabled(not self._read_only) + index = len(self._buttons) + self._layout.addWidget(button, index // self._columns, index % self._columns) + self._group.addButton(button, index) + self._buttons.append(button) + self._choices.append((str(label), value)) + for column in range(self._columns): + self._layout.setColumnStretch(column, 1 if self._multiple else 0) + if selected: + self.setPlainText(selected) + + def _selected_tokens(self) -> list[str]: + return [ + str(button.property("diagnosisValue")) for button in self._buttons if button.isChecked() + ] + + def toPlainText(self) -> str: # noqa: N802 + return "、".join(self._selected_tokens()) + + def setPlainText(self, text: str) -> None: # noqa: N802 + raw = str(text or "") + tokens = [ + part.strip() + for part in raw.replace(",", "、").replace(",", "、").split("、") + if part.strip() + ] + token_set = set(tokens) + existing = {str(value) for _label, value in self._choices} + if not token_set and not self._multiple and "" in existing: + token_set.add("") + unknown = [token for token in tokens if token not in existing] + if unknown: + self.set_choices( + [*self._choices, *((token, token) for token in unknown)], + preserve=False, + ) + was_exclusive = self._group.exclusive() + if was_exclusive: + self._group.setExclusive(False) + for button in self._buttons: + button.setChecked(str(button.property("diagnosisValue")) in token_set) + if was_exclusive: + self._group.setExclusive(True) + + def setReadOnly(self, read_only: bool) -> None: # noqa: N802 + self._read_only = bool(read_only) + for button in self._buttons: + button.setEnabled(not self._read_only) + + def isReadOnly(self) -> bool: # noqa: N802 + return self._read_only + + +class DiagnosisNumberEdit(QDoubleSpinBox): + """Bounded numeric field that can still represent an empty server value.""" + + def __init__( + self, + *, + maximum: float, + decimals: int = 0, + step: float = 1.0, + suffix: str = "", + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setProperty("diagnosisField", True) + self._sentinel = -1.0 + self._read_only = False + self.setRange(self._sentinel, maximum) + self.setDecimals(decimals) + self.setSingleStep(step) + self.setSpecialValueText("请输入") + self.setSuffix(suffix) + self.setFixedHeight(32) + self.setValue(self._sentinel) + + def toPlainText(self) -> str: # noqa: N802 + if self.value() <= self._sentinel: + return "" + if self.decimals() == 0: + return str(int(round(self.value()))) + return f"{self.value():.{self.decimals()}f}".rstrip("0").rstrip(".") + + def setPlainText(self, text: str) -> None: # noqa: N802 + try: + self.setValue(float(str(text).strip())) + except (TypeError, ValueError): + self.setValue(self._sentinel) + + def setReadOnly(self, read_only: bool) -> None: # noqa: N802 + self._read_only = bool(read_only) + self.setEnabled(not self._read_only) + + def isReadOnly(self) -> bool: # noqa: N802 + return self._read_only + + +class DiagnosisDateEdit(QDateEdit): + """Calendar-backed diagnosis date field with an explicit empty state.""" + + _EMPTY_DATE = QDate(1900, 1, 1) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setProperty("diagnosisField", True) + self._read_only = False + self.setCalendarPopup(True) + self.setDisplayFormat("yyyy-MM-dd") + self.setMinimumDate(self._EMPTY_DATE) + self.setMaximumDate(QDate.currentDate()) + self.setSpecialValueText("请选择日期") + self.setDate(self._EMPTY_DATE) + self.setFixedHeight(32) + + def toPlainText(self) -> str: # noqa: N802 + return "" if self.date() == self._EMPTY_DATE else self.date().toString("yyyy-MM-dd") + + def setPlainText(self, text: str) -> None: # noqa: N802 + value = QDate.fromString(str(text or ""), "yyyy-MM-dd") + self.setDate(value if value.isValid() else self._EMPTY_DATE) + + def setReadOnly(self, read_only: bool) -> None: # noqa: N802 + self._read_only = bool(read_only) + self.setEnabled(not self._read_only) + + def isReadOnly(self) -> bool: # noqa: N802 + return self._read_only + + +class MessageStrip(QFrame): + """Compact inline loading/error/warning strip.""" + + action_requested = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisMessageBanner") + self.setProperty("kind", "info") + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 9, 12, 9) + layout.setSpacing(8) + self.glyph = QLabel("i") + self.glyph.setFixedWidth(18) + self.glyph.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.glyph.setStyleSheet("font-weight:700; color:#409EFF;") + self.label = QLabel() + self.label.setWordWrap(True) + self.label.setStyleSheet("color:#475569; font-size:12px;") + layout.addWidget(self.glyph) + layout.addWidget(self.label, 1) + self.action_button = QPushButton() + self.action_button.setObjectName("DiagnosisMessageAction") + self.action_button.setStyleSheet( + "QPushButton{min-height:28px;padding:0 10px;color:#2563EB;background:#FFFFFF;" + "border:1px solid #93C5FD;border-radius:7px;font-weight:600;}" + "QPushButton:hover,QPushButton:focus{background:#DBEAFE;border-color:#60A5FA;}" + ) + self.action_button.clicked.connect(self.action_requested) + self.action_button.hide() + layout.addWidget(self.action_button) + self.hide() + + def show_message(self, text: str, kind: str = "info", action_text: str = "") -> None: + glyphs = {"info": "i", "success": "✓", "warning": "!", "danger": "!"} + self.label.setText(text) + self.glyph.setText(glyphs.get(kind, "i")) + self.action_button.setText(action_text) + self.action_button.setVisible(bool(action_text)) + self.setProperty("kind", kind) + repolish(self) + self.setVisible(bool(text)) + + def clear(self) -> None: + self.label.clear() + self.action_button.hide() + self.hide() + + +class _LoadingSpinner(QWidget): + """Font-independent Element-style indeterminate spinner.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisLoadingGlyph") + self.setFixedSize(34, 34) + self._angle = 0 + self._timer = QTimer(self) + self._timer.setInterval(80) + self._timer.timeout.connect(self._advance) + + def _advance(self) -> None: + self._angle = (self._angle - 30) % 360 + self.update() + + def start(self) -> None: + self._timer.start() + self.update() + + def stop(self) -> None: + self._timer.stop() + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API + super().paintEvent(event) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QPen(QColor("#409EFF"), 3) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawArc(QRectF(5, 5, 24, 24), self._angle * 16, 285 * 16) + painter.end() + + +class LoadingOverlay(QFrame): + """Content-preserving overlay used while the authoritative bundle loads.""" + + def __init__(self, parent: QWidget, text: str = "正在加载诊单…") -> None: + super().__init__(parent) + self.setObjectName("DiagnosisLoadingOverlay") + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setSpacing(8) + self.glyph = _LoadingSpinner(self) + label = QLabel(text) + label.setStyleSheet("color:#64748B; font-size:13px;") + layout.addWidget(self.glyph) + layout.addWidget(label) + self.hide() + + def sync_geometry(self) -> None: + parent = self.parentWidget() + if parent is not None: + self.setGeometry(parent.rect()) + self.raise_() + + def showEvent(self, event: QShowEvent) -> None: # noqa: N802 - Qt API + self.sync_geometry() + self.glyph.start() + super().showEvent(event) + + def hideEvent(self, event: QHideEvent) -> None: # noqa: N802 - Qt API + self.glyph.stop() + super().hideEvent(event) + + +class RecordTable(QTableWidget): + """Diagnosis-scoped table with a compact in-viewport empty state.""" + + def __init__( + self, + object_name: str, + columns: Sequence[tuple[str, int]], + parent: QWidget | None = None, + ) -> None: + super().__init__(0, len(columns), parent) + self.setObjectName(object_name) + self.setProperty("diagnosisTable", True) + self.setHorizontalHeaderLabels([label for label, _width in columns]) + self.setAlternatingRowColors(True) + self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.verticalHeader().setVisible(False) + self.horizontalHeader().setStretchLastSection(False) + self.horizontalHeader().setMinimumSectionSize(54) + for index, (_label, width) in enumerate(columns): + self.setColumnWidth(index, width) + self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive) + self.setMinimumHeight(180) + self.empty_label = QLabel("暂无数据", self.viewport()) + self.empty_label.setObjectName("DiagnosisEmptyState") + self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.empty_label.show() + + def set_empty_text(self, text: str) -> None: + self.empty_label.setText(text) + self.empty_label.setToolTip(text if len(text) > 36 else "") + self.empty_label.setVisible(self.rowCount() == 0) + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + header_height = self.horizontalHeader().height() + self.empty_label.setGeometry( + QRect( + 0, + header_height, + self.viewport().width(), + max(60, self.viewport().height() - header_height), + ) + ) + + def set_matrix( + self, + rows: Sequence[Sequence[Any]], + *, + tag_columns: Mapping[int, str] | None = None, + danger_columns: Sequence[int] = (), + right_columns: Sequence[int] = (), + ) -> None: + self.clearContents() + self.setRowCount(len(rows)) + semantic = dict(tag_columns or {}) + for row_index, values in enumerate(rows): + for column_index, value in enumerate(values): + item = QTableWidgetItem(_text(value)) + if column_index in right_columns: + item.setTextAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + else: + item.setTextAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + if column_index in danger_columns: + item.setForeground(QColor("#DC2626")) + kind = semantic.get(column_index) + if kind: + palette = { + "success": ("#15803D", "#F0FDF4"), + "warning": ("#B45309", "#FFFBEB"), + "danger": ("#DC2626", "#FEF2F2"), + "info": ("#2563EB", "#EFF6FF"), + "neutral": ("#64748B", "#F1F5F9"), + } + foreground, background = palette.get(kind, palette["neutral"]) + item.setForeground(QColor(foreground)) + item.setBackground(QColor(background)) + item.setToolTip(item.text() if len(item.text()) > 18 else "") + self.setItem(row_index, column_index, item) + self.resizeRowsToContents() + self.empty_label.setVisible(not rows) + self.viewport().update() + + +class BloodTrendChart(QWidget): + """Small native trend chart; keeps the daily-record view dependency free.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setMinimumHeight(220) + self._dates: list[str] = [] + self._fasting: list[float | None] = [] + self._postprandial: list[float | None] = [] + + def set_series( + self, + dates: Sequence[str], + fasting: Sequence[float | None], + postprandial: Sequence[float | None], + ) -> None: + self._dates = list(dates) + self._fasting = list(fasting) + self._postprandial = list(postprandial) + self.update() + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API + super().paintEvent(event) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + plot = self.rect().adjusted(48, 24, -22, -38) + values = [value for value in (*self._fasting, *self._postprandial) if value is not None] + if not values or plot.width() <= 0 or plot.height() <= 0: + painter.setPen(QColor("#909399")) + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "当前时间范围暂无血糖数据") + painter.end() + return + upper = max(12.0, max(values) * 1.15) + painter.setPen(QPen(QColor("#E5E7EB"), 1)) + for step in range(5): + y = plot.bottom() - round(plot.height() * step / 4) + painter.drawLine(plot.left(), y, plot.right(), y) + painter.setPen(QColor("#94A3B8")) + painter.drawText( + 4, y - 8, 40, 16, Qt.AlignmentFlag.AlignRight, f"{upper * step / 4:.0f}" + ) + painter.setPen(QPen(QColor("#E5E7EB"), 1)) + count = max(1, len(self._dates) - 1) + + def point(index: int, value: float) -> QPointF: + return QPointF( + plot.left() + plot.width() * index / count, + plot.bottom() - plot.height() * value / upper, + ) + + for series, color in ( + (self._fasting, QColor("#2563EB")), + (self._postprandial, QColor("#F97316")), + ): + previous: QPointF | None = None + painter.setPen(QPen(color, 2.5)) + painter.setBrush(color) + for index, value in enumerate(series): + if value is None: + previous = None + continue + current = point(index, value) + if previous is not None: + painter.drawLine(previous, current) + painter.drawEllipse(current, 3.2, 3.2) + previous = current + painter.setPen(QColor("#2563EB")) + painter.drawText(plot.left(), 4, 94, 18, Qt.AlignmentFlag.AlignLeft, "● 空腹血糖") + painter.setPen(QColor("#F97316")) + painter.drawText(plot.left() + 100, 4, 110, 18, Qt.AlignmentFlag.AlignLeft, "● 餐后血糖") + if self._dates: + painter.setPen(QColor("#64748B")) + painter.drawText( + plot.left(), + plot.bottom() + 8, + 80, + 20, + Qt.AlignmentFlag.AlignLeft, + self._dates[0][5:], + ) + painter.drawText( + plot.right() - 80, + plot.bottom() + 8, + 80, + 20, + Qt.AlignmentFlag.AlignRight, + self._dates[-1][5:], + ) + painter.end() + + +class DailyRecordPanel(QFrame): + """Date-window matrix, blood trend, and diagnosis todo structure.""" + + range_requested = Signal(str, str) + refresh_requested = Signal() + todo_status_requested = Signal(object) + daily_add_requested = Signal(str) + daily_edit_requested = Signal(str, object) + todo_add_requested = Signal() + todo_cancel_requested = Signal(int) + + METRICS: tuple[tuple[str, str], ...] = ( + ("fasting", "空腹血糖"), + ("postprandial", "餐后2h血糖"), + ("other", "其他血糖"), + ("bp", "血压"), + ("western", "西药"), + ("insulin", "胰岛素"), + ("breakfast", "早餐"), + ("lunch", "午餐"), + ("dinner", "晚餐"), + ("exercise", "运动"), + ("tracking", "跟踪备注"), + ) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisDailyPanel") + self._start = QDate.currentDate().addDays(-6) + self._end = QDate.currentDate() + self._loading = False + self._editable = False + self._record_editable = False + root = QVBoxLayout(self) + root.setContentsMargins(16, 16, 16, 16) + root.setSpacing(14) + + self.toolbar_host = QWidget() + self.toolbar_host.setObjectName("DiagnosisDailyToolbar") + toolbar = FlowLayout(self.toolbar_host, horizontal_spacing=8, vertical_spacing=8) + self.range_group = QButtonGroup(self) + self.range_group.setExclusive(True) + self.range_buttons: dict[str, QPushButton] = {} + for mode, label in (("7", "最近7天"), ("30", "最近30天"), ("custom", "自定义")): + button = QPushButton(label) + button.setCheckable(True) + button.setProperty("diagnosisRange", True) + self.range_group.addButton(button) + self.range_buttons[mode] = button + toolbar.addWidget(button) + button.clicked.connect( + lambda _checked=False, selected=mode: self._select_range(selected) + ) + self.range_buttons["7"].setChecked(True) + self.start_date = QDateEdit(self._start) + self.end_date = QDateEdit(self._end) + for editor in (self.start_date, self.end_date): + editor.setCalendarPopup(True) + editor.setDisplayFormat("yyyy-MM-dd") + editor.setMaximumDate(QDate.currentDate()) + editor.setProperty("diagnosisField", True) + editor.setFixedHeight(32) + editor.hide() + self.start_date.dateChanged.connect(self._custom_range_changed) + self.end_date.dateChanged.connect(self._custom_range_changed) + toolbar.addWidget(self.start_date) + self.range_separator = QLabel("至") + self.range_separator.hide() + toolbar.addWidget(self.range_separator) + toolbar.addWidget(self.end_date) + self.add_buttons: dict[str, QPushButton] = {} + for kind, label in ( + ("blood", "+血糖"), + ("diet", "+饮食"), + ("exercise", "+运动"), + ("tracking", "+备注"), + ): + button = QPushButton(label) + button.setProperty("variant", "primary" if kind == "blood" else "secondary") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect( + lambda _checked=False, selected=kind: self.daily_add_requested.emit(selected) + ) + button.hide() + self.add_buttons[kind] = button + toolbar.addWidget(button) + self.refresh_button = QPushButton("刷新") + self.refresh_button.setProperty("variant", "ghost") + self.refresh_button.clicked.connect(self.refresh_requested) + toolbar.addWidget(self.refresh_button) + root.addWidget(self.toolbar_host) + + self.state = MessageStrip() + root.addWidget(self.state) + self.matrix = QTableWidget() + self.matrix.setObjectName("DiagnosisDailyMatrix") + self.matrix.setProperty("diagnosisTable", True) + self.matrix.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.matrix.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.matrix.setAlternatingRowColors(True) + self.matrix.verticalHeader().setVisible(False) + self.matrix.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.matrix.setMinimumHeight(420) + self.matrix.cellClicked.connect(self._daily_cell_clicked) + root.addWidget(self.matrix) + + trend_card = QFrame() + trend_card.setObjectName("DiagnosisTrendCard") + trend_layout = QVBoxLayout(trend_card) + trend_layout.setContentsMargins(16, 14, 16, 14) + trend_title = QLabel("血糖趋势") + trend_title.setObjectName("DiagnosisDailySectionTitle") + trend_layout.addWidget(trend_title) + self.trend = BloodTrendChart() + trend_layout.addWidget(self.trend) + root.addWidget(trend_card) + + todo_card = QFrame() + todo_card.setObjectName("DiagnosisTodoCard") + todo_layout = QVBoxLayout(todo_card) + todo_layout.setContentsMargins(16, 14, 16, 14) + todo_title = QLabel("待办事项") + todo_title.setObjectName("DiagnosisDailySectionTitle") + todo_layout.addWidget(todo_title) + self.todo_toolbar = QWidget() + self.todo_toolbar.setObjectName("DiagnosisTodoToolbar") + todo_toolbar_layout = FlowLayout( + self.todo_toolbar, + horizontal_spacing=8, + vertical_spacing=8, + ) + self.todo_add_button = QPushButton("+ 新增待办") + self.todo_add_button.setProperty("variant", "primary") + self.todo_add_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.todo_add_button.clicked.connect(self.todo_add_requested) + self.todo_add_button.hide() + todo_toolbar_layout.addWidget(self.todo_add_button) + self.todo_group = QButtonGroup(self) + self.todo_group.setExclusive(True) + for status, label in ( + (None, "全部"), + (0, "待执行"), + (1, "已发送"), + (3, "失败"), + (2, "已取消"), + ): + button = QPushButton(label) + button.setCheckable(True) + button.setProperty("diagnosisRange", True) + button.setProperty("diagnosisTodoStatus", status) + if status is None: + button.setChecked(True) + self.todo_group.addButton(button) + todo_toolbar_layout.addWidget(button) + button.clicked.connect( + lambda _checked=False, selected=status: self.todo_status_requested.emit(selected) + ) + self.todo_refresh_button = QPushButton("刷新") + self.todo_refresh_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.todo_refresh_button.clicked.connect(self.refresh_requested) + todo_toolbar_layout.addWidget(self.todo_refresh_button) + todo_layout.addWidget(self.todo_toolbar) + self.todo_table = QTableWidget(0, 7) + self.todo_table.setObjectName("DiagnosisTodoTable") + self.todo_table.setProperty("diagnosisTable", True) + self.todo_table.setHorizontalHeaderLabels( + ["提醒时间", "内容", "状态", "创建人", "推送时间", "失败原因", "操作"] + ) + self.todo_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.todo_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.todo_table.verticalHeader().setVisible(False) + self.todo_table.horizontalHeader().setStretchLastSection(True) + self.todo_table.setMinimumHeight(180) + todo_layout.addWidget(self.todo_table) + self.todo_summary = QLabel("共 0 条") + self.todo_summary.setStyleSheet("color:#909399; font-size:12px;") + todo_layout.addWidget(self.todo_summary, 0, Qt.AlignmentFlag.AlignRight) + root.addWidget(todo_card) + self.clear() + + def set_editable(self, editable: bool, *, record_editable: bool | None = None) -> None: + self._editable = bool(editable) + self._record_editable = self._editable if record_editable is None else bool(record_editable) + for button in self.add_buttons.values(): + button.setVisible(self._editable) + self.todo_add_button.setVisible(self._editable) + self.toolbar_host.layout().invalidate() + self.toolbar_host.updateGeometry() + + def current_range(self) -> tuple[str, str]: + return self._start.toString("yyyy-MM-dd"), self._end.toString("yyyy-MM-dd") + + def set_range(self, start: str, end: str) -> None: + start_date = QDate.fromString(start, "yyyy-MM-dd") + end_date = QDate.fromString(end, "yyyy-MM-dd") + if not start_date.isValid() or not end_date.isValid() or start_date > end_date: + return + self._start, self._end = start_date, end_date + self.start_date.blockSignals(True) + self.end_date.blockSignals(True) + self.start_date.setDate(start_date) + self.end_date.setDate(end_date) + self.start_date.blockSignals(False) + self.end_date.blockSignals(False) + + def _select_range(self, mode: str) -> None: + custom = mode == "custom" + self.start_date.setVisible(custom) + self.end_date.setVisible(custom) + self.range_separator.setVisible(custom) + if mode == "7": + self._end = QDate.currentDate() + self._start = self._end.addDays(-6) + elif mode == "30": + self._end = QDate.currentDate() + self._start = self._end.addDays(-29) + else: + self._start = self.start_date.date() + self._end = self.end_date.date() + self.range_requested.emit(*self.current_range()) + + def _custom_range_changed(self, _date: QDate | None = None) -> None: + if not self.range_buttons["custom"].isChecked(): + return + start = self.start_date.date() + end = self.end_date.date() + if start > end: + return + self._start, self._end = start, end + self.range_requested.emit(*self.current_range()) + + def set_loading(self, loading: bool) -> None: + self._loading = bool(loading) + self.refresh_button.setEnabled(not self._loading) + self.todo_refresh_button.setEnabled(not self._loading) + if loading: + self.state.show_message("正在加载日常记录、跟踪备注与待办…", "info") + + def set_error(self, text: str) -> None: + self.set_loading(False) + self.state.show_message(text, "danger") + + @staticmethod + def _date_map(rows: Sequence[Any], *keys: str) -> dict[str, Any]: + result: dict[str, Any] = {} + for row in rows: + value = str(_pick(row, *keys, default="") or "")[:10] + if value and value not in result: + result[value] = row + return result + + @staticmethod + def _blood_date_map(rows: Sequence[Any]) -> dict[str, dict[str, Any]]: + """Merge same-day blood records like the Vue DailyMatrix computed map.""" + + fields: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("fasting_blood_sugar", ("fasting_glucose",)), + ("postprandial_blood_sugar", ("postprandial_glucose",)), + ("other_blood_sugar", ("other_glucose",)), + ("systolic_pressure", ("systolic",)), + ("diastolic_pressure", ("diastolic",)), + ("western_medicine", ()), + ("insulin", ()), + ("remark", ()), + ) + result: dict[str, dict[str, Any]] = {} + for row in rows: + date = str(_pick(row, "record_date", "date", default="") or "")[:10] + if not date: + continue + merged = result.setdefault( + date, + { + "id": _pick(row, "id", default=0), + "diagnosis_id": _pick(row, "diagnosis_id", default=0), + "patient_id": _pick(row, "patient_id", default=0), + "record_date": date, + "record_time": _pick(row, "record_time", default=""), + "source": _pick(row, "source", default=0), + "has_patient_self": False, + }, + ) + if _truth_source(row): + merged["has_patient_self"] = True + for field, aliases in fields: + if merged.get(field) not in (None, ""): + continue + value = _pick(row, field, *aliases, default=None) + if value not in (None, ""): + merged[field] = value + return result + + @staticmethod + def _number(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if number != 0 else None + + def _dates(self) -> list[str]: + dates: list[str] = [] + cursor = QDate(self._end) + while cursor >= self._start and len(dates) < 366: + dates.append(cursor.toString("yyyy-MM-dd")) + cursor = cursor.addDays(-1) + return dates + + def set_data(self, window: Any, notes: Sequence[Any]) -> None: + blood_rows = list(_pick(window, "blood_records", default=[]) or []) + diet_rows = list(_pick(window, "diet_records", default=[]) or []) + exercise_rows = list(_pick(window, "exercise_records", default=[]) or []) + blood = self._blood_date_map(blood_rows) + diet = self._date_map(diet_rows, "record_date", "date") + exercise = self._date_map(exercise_rows, "record_date", "date") + tracking = self._date_map(notes, "note_date", "record_date", "date") + dates = self._dates() + self.matrix.clear() + self.matrix.setColumnCount(1 + len(dates)) + self.matrix.setRowCount(len(self.METRICS)) + self.matrix.setHorizontalHeaderLabels(["指标", *(date[5:] for date in dates)]) + self.matrix.setColumnWidth(0, 120) + for column in range(1, len(dates) + 1): + self.matrix.setColumnWidth(column, 104) + fasting_series: list[float | None] = [] + post_series: list[float | None] = [] + has_records = False + for row_index, (metric, label) in enumerate(self.METRICS): + label_item = QTableWidgetItem(label) + label_item.setForeground(QColor("#475569")) + label_item.setBackground(QColor("#F8FAFC")) + self.matrix.setItem(row_index, 0, label_item) + for date_index, date in enumerate(dates, 1): + b = blood.get(date) + d = diet.get(date) + e = exercise.get(date) + n = tracking.get(date) + value = "—" + high = False + if metric == "fasting": + raw = _pick(b, "fasting_blood_sugar") + number = self._number(raw) + value = _text(raw) if number is not None else "—" + high = bool(number is not None and number >= 7) + elif metric == "postprandial": + raw = _pick(b, "postprandial_blood_sugar") + number = self._number(raw) + value = _text(raw) if number is not None else "—" + high = bool(number is not None and number >= 11.1) + elif metric == "other": + value = _text(_pick(b, "other_blood_sugar")) + elif metric == "bp": + high_value = self._number(_pick(b, "systolic_pressure")) + low_value = self._number(_pick(b, "diastolic_pressure")) + if high_value is not None or low_value is not None: + value = f"{_text(_pick(b, 'systolic_pressure'))}/{_text(_pick(b, 'diastolic_pressure'))}" + high = bool((high_value or 0) >= 140 or (low_value or 0) >= 90) + elif metric == "western": + value = _text(_pick(b, "western_medicine")) + elif metric == "insulin": + value = _text(_pick(b, "insulin")) + elif metric in {"breakfast", "lunch", "dinner"}: + foods = _pick(d, metric, f"{metric}_foods", default="") + value = "已记录" if foods not in (None, "", [], ()) else "—" + elif metric == "exercise": + duration = _pick(e, "duration", default="") + value = f"{duration}min" if duration not in (None, "", 0, "0") else "—" + elif metric == "tracking": + content = str(_pick(n, "content", "tracking_content", default="") or "") + value = next( + (line.strip() for line in content.splitlines() if line.strip()), "—" + ) + if metric in {"fasting", "postprandial", "other", "bp"} and bool( + _pick(b, "has_patient_self", default=False) + ): + value = f"{value} · 自录" if value != "—" else value + if value != "—": + has_records = True + if high and value != "—": + value = f"↑ {value}" + item = QTableWidgetItem(value) + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + if high: + item.setForeground(QColor("#DC2626")) + item.setBackground(QColor("#FEF2F2")) + elif value.endswith("· 自录"): + item.setForeground(QColor("#6D28D9")) + item.setBackground(QColor("#F5F3FF")) + edit_kind = "" + edit_source: Any = None + if metric in {"fasting", "postprandial", "other", "bp", "western", "insulin"}: + edit_kind, edit_source = "blood", b + elif metric in {"breakfast", "lunch", "dinner"}: + edit_kind, edit_source = "diet", d + elif metric == "exercise": + edit_kind, edit_source = "exercise", e + edit_id = int(_pick(edit_source, "id", default=0) or 0) + if self._record_editable and edit_kind and edit_id > 0: + item.setData( + Qt.ItemDataRole.UserRole, + ( + edit_kind, + dict(edit_source) if isinstance(edit_source, Mapping) else edit_source, + ), + ) + item.setToolTip( + f"点击编辑当日{dict(blood='血糖 / 血压', diet='饮食', exercise='运动')[edit_kind]}记录" + ) + else: + item.setToolTip(value if len(value) > 16 else "") + self.matrix.setItem(row_index, date_index, item) + chart_dates = list(reversed(dates)) + for date in chart_dates: + b = blood.get(date) + fasting_series.append(self._number(_pick(b, "fasting_blood_sugar"))) + post_series.append(self._number(_pick(b, "postprandial_blood_sugar"))) + self.trend.set_series(chart_dates, fasting_series, post_series) + self.set_loading(False) + if has_records: + self.state.clear() + else: + self.state.show_message("当前时间范围暂无日常记录。", "info") + self.matrix.resizeRowsToContents() + + def _daily_cell_clicked(self, row: int, column: int) -> None: + if not self._record_editable or column <= 0: + return + item = self.matrix.item(row, column) + payload = item.data(Qt.ItemDataRole.UserRole) if item is not None else None + if ( + not isinstance(payload, tuple) + or len(payload) != 2 + or payload[0] not in {"blood", "diet", "exercise"} + or int(_pick(payload[1], "id", default=0) or 0) <= 0 + ): + return + self.daily_edit_requested.emit(payload[0], payload[1]) + + def set_todos(self, rows: Sequence[Any], total: int | None = None) -> None: + self.todo_table.clearContents() + self.todo_table.setRowCount(len(rows)) + for row_index, row in enumerate(rows): + values = ( + _pick(row, "remind_time_text", "remind_time"), + _pick(row, "content"), + _pick(row, "creator_name"), + _pick(row, "notified_at_text", "notified_at"), + _pick(row, "error"), + ) + for column, value in enumerate(values): + target_column = column if column < 2 else column + 1 + item = QTableWidgetItem(_text(value)) + self.todo_table.setItem(row_index, target_column, item) + status_text = _text(_pick(row, "status_text", "status")) + status_kind = ( + "danger" + if "失败" in status_text + else "success" + if "发送" in status_text or "完成" in status_text + else "warning" + if "取消" in status_text + else "info" + ) + set_status_tag(self.todo_table, row_index, 2, status_text, status_kind) + status = _pick(row, "status", default=-1) + can_cancel = bool(_pick(row, "can_cancel", default=False)) + try: + pending = int(status) == 0 + except (TypeError, ValueError): + pending = status_text == "待执行" + if self._editable and pending and can_cancel: + todo_id = int(_pick(row, "id", default=0) or 0) + button = QPushButton("取消") + button.setProperty("variant", "dangerGhost") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setEnabled(todo_id > 0) + button.clicked.connect( + lambda _checked=False, selected=todo_id: self.todo_cancel_requested.emit( + selected + ) + ) + self.todo_table.setCellWidget(row_index, 6, button) + else: + self.todo_table.setItem(row_index, 6, QTableWidgetItem("—")) + self.todo_summary.setText(f"共 {total if total is not None else len(rows)} 条") + self.todo_table.resizeRowsToContents() + + def clear(self) -> None: + self.state.show_message("切换到日常记录后加载最近 7 天数据。", "info") + self.matrix.clear() + self.matrix.setRowCount(0) + self.matrix.setColumnCount(0) + self.trend.set_series([], [], []) + self.set_todos([], 0) + + +def _truth_source(record: Any) -> bool: + try: + return int(_pick(record, "source", default=0) or 0) == 1 + except (TypeError, ValueError): + return False + + +class _RemoteImageButton(QPushButton): + """Asynchronously render one safe HTTP(S) image without stale-owner updates.""" + + _MAX_IMAGE_BYTES = 12 * 1024 * 1024 + + def __init__( + self, + source: str, + *, + render_owner: QWidget, + owner_generation: int, + maximum_size: QSize, + fallback_text: str, + cover: bool, + object_name: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName(object_name) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setAccessibleName(fallback_text.replace("\n", "")) + self._source = str(source).strip() + self._render_owner_ref = weakref.ref(render_owner) + self._owner_generation = owner_generation + self._maximum_size = QSize(maximum_size) + self._fallback_size = QSize(maximum_size) if cover else QSize(132, 64) + self._fallback_text = fallback_text + self._cover = cover + self._manager = QNetworkAccessManager(self) + self._reply: QNetworkReply | None = None + self._generation = 0 + self._rendered_pixmap = QPixmap() + self.setToolTip(self._source) + self._show_loading() + self.load_url(self._source) + + def _owner_is_current(self) -> bool: + owner = self._render_owner_ref() + return bool( + owner is not None + and QThread.currentThread() == self.thread() + and getattr(owner, "_image_generation", None) == self._owner_generation + ) + + def _set_load_state(self, state: str) -> None: + self.setProperty("loadState", state) + repolish(self) + + def _show_loading(self) -> None: + self.setIcon(QIcon()) + self.setText(self._fallback_text) + self.setFixedSize(self._fallback_size) + self._set_load_state("loading") + + def _show_fallback(self) -> None: + self._rendered_pixmap = QPixmap() + self.setIcon(QIcon()) + self.setText(self._fallback_text) + self.setFixedSize(self._fallback_size) + self._set_load_state("failed") + + def _invalidate_request(self) -> int: + self._generation += 1 + reply, self._reply = self._reply, None + if reply is not None: + reply.abort() + reply.deleteLater() + return self._generation + + def load_url(self, source: str) -> None: + self._source = str(source).strip() + generation = self._invalidate_request() + self.setToolTip(self._source) + self._show_loading() + url = QUrl(self._source) + if ( + not self._owner_is_current() + or not url.isValid() + or url.scheme().lower() not in {"http", "https"} + or not url.host() + ): + if self._owner_is_current() and generation == self._generation: + self._show_fallback() + return + request = QNetworkRequest(url) + request.setTransferTimeout(10_000) + request.setMaximumRedirectsAllowed(4) + request.setAttribute( + QNetworkRequest.Attribute.RedirectPolicyAttribute, + QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy, + ) + reply = self._manager.get(request) + self._reply = reply + reply.setProperty("diagnosisImageGeneration", generation) + reply.finished.connect(self._reply_finished) + + def _reply_finished(self) -> None: + """Use a QObject receiver connection so destruction auto-disconnects the callback.""" + + reply = self.sender() + if reply is None: + return + try: + generation = int(reply.property("diagnosisImageGeneration")) + except (TypeError, ValueError): + reply.deleteLater() + return + self._loaded(reply, generation) + + def _loaded(self, reply: QNetworkReply, generation: int) -> None: + if reply is not self._reply or generation != self._generation: + reply.deleteLater() + return + self._reply = None + if not self._owner_is_current(): + reply.deleteLater() + return + error = reply.error() + payload = bytes(reply.readAll()) + reply.deleteLater() + if error != QNetworkReply.NetworkError.NoError: + self._show_fallback() + return + self._apply_payload(payload, generation) + + def _apply_payload(self, payload: bytes, generation: int) -> bool: + """Decode a current reply; kept separate so offline tests can exercise rendering.""" + + if generation != self._generation or not self._owner_is_current(): + return False + pixmap = QPixmap() + if ( + not payload + or len(payload) > self._MAX_IMAGE_BYTES + or not pixmap.loadFromData(payload) + or pixmap.isNull() + ): + self._show_fallback() + return False + if self._cover: + scaled = pixmap.scaled( + self._maximum_size, + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation, + ) + left = max(0, (scaled.width() - self._maximum_size.width()) // 2) + top = max(0, (scaled.height() - self._maximum_size.height()) // 2) + rendered = scaled.copy( + left, + top, + self._maximum_size.width(), + self._maximum_size.height(), + ) + elif ( + pixmap.width() > self._maximum_size.width() + or pixmap.height() > self._maximum_size.height() + ): + rendered = pixmap.scaled( + self._maximum_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + else: + rendered = pixmap + self._rendered_pixmap = rendered + self.setText("") + self.setFixedSize(rendered.size()) + self.setIconSize(rendered.size()) + self.setIcon(QIcon(rendered)) + self._set_load_state("ready") + return True + + +class NotesTimeline(QWidget): + """Compact visual equivalent of the admin note timeline.""" + + add_requested = Signal() + upload_requested = Signal(str) + delete_attachment_requested = Signal(int, str, str) + open_attachment_requested = Signal(str) + + def __init__(self, *, editable: bool = False, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisNotesTimeline") + self._editable = editable + self._can_delete = editable + self._image_generation = 0 + self.root = QVBoxLayout(self) + self.root.setContentsMargins(0, 0, 0, 0) + self.root.setSpacing(12) + self.toolbar = QWidget() + toolbar_layout = QHBoxLayout(self.toolbar) + toolbar_layout.setContentsMargins(0, 0, 0, 0) + toolbar_layout.setSpacing(8) + toolbar_layout.addStretch(1) + self.add_button = QPushButton("新增 / 追加笔记") + self.tongue_button = QPushButton("上传舌象") + self.report_button = QPushButton("上传报告") + for button in (self.add_button, self.tongue_button, self.report_button): + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setProperty("variant", "primary" if button is self.add_button else "secondary") + toolbar_layout.addWidget(button) + self.add_button.clicked.connect(self.add_requested) + self.tongue_button.clicked.connect(lambda: self.upload_requested.emit("tongue_images")) + self.report_button.clicked.connect(lambda: self.upload_requested.emit("report_files")) + self.root.addWidget(self.toolbar) + self.nodes = QWidget() + self.nodes_layout = QVBoxLayout(self.nodes) + self.nodes_layout.setContentsMargins(22, 0, 0, 0) + self.nodes_layout.setSpacing(0) + self.root.addWidget(self.nodes) + self.empty = QLabel("暂无医生备注") + self.empty.setObjectName("DiagnosisEmptyState") + self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty.setMinimumHeight(80) + self.root.addWidget(self.empty) + self.set_editable(editable) + + def set_editable(self, editable: bool, *, can_delete: bool | None = None) -> None: + self._editable = editable + self._can_delete = editable if can_delete is None else bool(can_delete) + self.toolbar.setVisible(self._editable) + + def set_state(self, text: str) -> None: + self.empty.setText(text) + self.empty.setToolTip(text if len(text) > 36 else "") + self.empty.show() + + def set_notes(self, notes: Sequence[Any]) -> None: + self._image_generation += 1 + image_generation = self._image_generation + while self.nodes_layout.count(): + item = self.nodes_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + for note in notes: + node = QFrame() + node.setObjectName("DiagnosisTimelineNode") + node_layout = QGridLayout(node) + node_layout.setContentsMargins(12, 4, 8, 16) + node_layout.setHorizontalSpacing(10) + node_layout.setVerticalSpacing(7) + dot = QLabel() + dot.setObjectName("DiagnosisTimelineDot") + node_layout.addWidget(dot, 0, 0, Qt.AlignmentFlag.AlignTop) + date = QLabel( + _text(_pick(note, "note_date", "create_time", "created_at", "time"), "未记录日期") + ) + date.setObjectName("DiagnosisTimelineDate") + node_layout.addWidget(date, 0, 1) + content = QLabel(_text(_pick(note, "content", "note", "remark"), "无文字备注")) + content.setObjectName("DiagnosisTimelineContent") + content.setWordWrap(True) + node_layout.addWidget(content, 1, 1) + attachments = QWidget() + attachments_layout = FlowLayout(attachments, horizontal_spacing=8, vertical_spacing=8) + images = _pick(note, "tongue_images", "images", default=[]) or [] + if not isinstance(images, Sequence) or isinstance(images, (str, bytes, bytearray)): + images = [images] + note_id = int(_pick(note, "id", "note_id", default=0) or 0) + for image in images: + path = str(_pick(image, "url", "path", "file_url", default=image) or "") + image_host = QWidget() + image_layout = QGridLayout(image_host) + image_layout.setContentsMargins(0, 0, 0, 0) + thumb = _RemoteImageButton( + path, + render_owner=self, + owner_generation=image_generation, + maximum_size=QSize(64, 64), + fallback_text="舌象\n查看", + cover=True, + object_name="DiagnosisTongueThumb", + parent=image_host, + ) + thumb.setEnabled(bool(path)) + thumb.clicked.connect( + lambda _checked=False, target=path: self.open_attachment_requested.emit(target) + ) + image_layout.addWidget(thumb, 0, 0) + if self._can_delete and note_id > 0 and path: + remove = QPushButton("×") + remove.setObjectName("DiagnosisAttachmentRemove") + remove.setFixedSize(20, 20) + remove.setToolTip("删除这张舌象图片") + remove.clicked.connect( + lambda _checked=False, selected=note_id, target=path: ( + self.delete_attachment_requested.emit(selected, "tongue_images", target) + ) + ) + image_layout.addWidget( + remove, + 0, + 0, + Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight, + ) + attachments_layout.addWidget(image_host) + files = _pick(note, "report_files", "files", default=[]) or [] + if not isinstance(files, Sequence) or isinstance(files, (str, bytes, bytearray)): + files = [files] + for attachment in files: + name = _pick(attachment, "name", "file_name", default=attachment) + path = str(_pick(attachment, "url", "path", "file_url", default=attachment) or "") + file_host = QWidget() + file_layout = QHBoxLayout(file_host) + file_layout.setContentsMargins(0, 0, 0, 0) + file_layout.setSpacing(4) + chip = QPushButton(f"报告 · {_text(name, '附件')}") + chip.setObjectName("DiagnosisAttachmentChip") + chip.setMaximumWidth(180) + chip.setToolTip(_text(name, "附件")) + chip.setCursor(Qt.CursorShape.PointingHandCursor) + chip.setEnabled(bool(path)) + chip.clicked.connect( + lambda _checked=False, target=path: self.open_attachment_requested.emit(target) + ) + file_layout.addWidget(chip) + if self._can_delete and note_id > 0 and path: + remove = QPushButton("×") + remove.setObjectName("DiagnosisAttachmentRemove") + remove.setFixedSize(20, 20) + remove.setToolTip("删除这个报告附件") + remove.clicked.connect( + lambda _checked=False, selected=note_id, target=path: ( + self.delete_attachment_requested.emit(selected, "report_files", target) + ) + ) + file_layout.addWidget(remove) + attachments_layout.addWidget(file_host) + if images or files: + node_layout.addWidget(attachments, 2, 1) + else: + attachments.deleteLater() + self.nodes_layout.addWidget(node) + self.empty.setVisible(not notes) + + +class ChatPanel(QWidget): + """Read-only chat bubbles sourced from the existing detail payload.""" + + sync_requested = Signal() + reload_requested = Signal() + open_attachment_requested = Signal(str) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisChatPanel") + self._image_generation = 0 + self.root = QVBoxLayout(self) + self.root.setContentsMargins(0, 0, 0, 0) + self.root.setSpacing(16) + self.info = QFrame() + self.info.setObjectName("DiagnosisChatInfo") + info_layout = QHBoxLayout(self.info) + info_layout.setContentsMargins(12, 10, 12, 10) + info_layout.setSpacing(9) + info_icon = QLabel("i") + info_icon.setObjectName("DiagnosisChatInfoIcon") + info_icon.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) + info_icon.setFixedWidth(18) + info_layout.addWidget(info_icon) + info_text = QLabel( + "说明:展示患者与所有医生 / 医助账号分别产生的单聊会话,并按时间排序。" + "数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;可在下方立即触发后台同步。" + ) + info_text.setObjectName("DiagnosisChatInfoText") + info_text.setWordWrap(True) + info_layout.addWidget(info_text, 1) + self.root.addWidget(self.info) + self.toolbar = QWidget() + toolbar_layout = QHBoxLayout(self.toolbar) + toolbar_layout.setContentsMargins(0, 0, 0, 0) + toolbar_layout.setSpacing(8) + archive = QLabel("仅展示服务端已归档消息(only_archived=1)") + archive.setStyleSheet("color:#64748B;font-size:12px;") + toolbar_layout.addWidget(archive) + toolbar_layout.addStretch(1) + self.sync_button = QPushButton("同步最新(后台异步)") + self.reload_button = QPushButton("重新加载已归档") + for button in (self.sync_button, self.reload_button): + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setProperty("variant", "link") + toolbar_layout.addWidget(button) + self.sync_button.clicked.connect(self.sync_requested) + self.reload_button.clicked.connect(self.reload_requested) + self.root.addWidget(self.toolbar) + self.empty = QLabel("暂无 IM 聊天记录") + self.empty.setObjectName("DiagnosisEmptyState") + self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty.setMinimumHeight(90) + self.root.addWidget(self.empty) + + def set_unavailable(self, text: str) -> None: + self.set_messages([]) + self.empty.setText(text) + self.empty.setToolTip(text if len(text) > 36 else "") + + def set_messages(self, messages: Sequence[Any]) -> None: + self._image_generation += 1 + image_generation = self._image_generation + self.empty.setText("暂无 IM 聊天记录") + while self.root.count() > 3: + item = self.root.takeAt(2) + widget = item.widget() + if widget is not None and widget not in {self.empty, self.toolbar, self.info}: + widget.deleteLater() + self.empty.setVisible(not messages) + for message in messages: + doctor_flag = _pick(message, "is_from_doctor", default=False) + is_doctor = ( + doctor_flag.strip().lower() not in {"", "0", "false", "no"} + if isinstance(doctor_flag, str) + else bool(doctor_flag) + ) + side = ( + "doctor" + if is_doctor + or str(_pick(message, "role", "sender_type", default="")).lower() + in { + "doctor", + "staff", + "2", + } + else "patient" + ) + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + bubble = QFrame() + bubble.setObjectName("DiagnosisChatBubble") + bubble.setProperty("side", side) + bubble.setMaximumWidth(620) + bubble_layout = QVBoxLayout(bubble) + bubble_layout.setContentsMargins(12, 10, 12, 10) + bubble_layout.setSpacing(5) + meta = QLabel( + f"{_text(_pick(message, 'sender_name', 'from_staff_name', 'name'), '患者' if side == 'patient' else '医生')} " + f"{_text(_pick(message, 'create_time', 'time'), '')}" + ) + meta.setObjectName("DiagnosisChatMeta") + message_type = str( + _pick(message, "message_type", "msg_type", "type", default="text") or "text" + ).lower() + body_text = _text(_pick(message, "content", "text", "message"), "不支持的消息") + body: QWidget + if message_type in {"image", "2"}: + target = str( + _pick(message, "url", "file_url", "image_url", default=body_text) or "" + ) + image = _RemoteImageButton( + target, + render_owner=self, + owner_generation=image_generation, + maximum_size=QSize(240, 200), + fallback_text="查看图片", + cover=False, + object_name="DiagnosisChatImage", + parent=bubble, + ) + image.setEnabled(bool(target)) + image.clicked.connect( + lambda _checked=False, selected=target: self.open_attachment_requested.emit( + selected + ) + ) + body = image + elif message_type in {"file", "3"}: + target = str( + _pick(message, "url", "file_url", "image_url", default=body_text) or "" + ) + label = _text(_pick(message, "file_name"), "打开文件") + action = QPushButton(label) + action.setProperty("variant", "link") + action.setCursor(Qt.CursorShape.PointingHandCursor) + action.setEnabled(bool(target)) + action.setToolTip(target) + action.clicked.connect( + lambda _checked=False, selected=target: self.open_attachment_requested.emit( + selected + ) + ) + body = action + else: + text = QLabel(body_text) + text.setObjectName("DiagnosisChatText") + text.setWordWrap(True) + body = text + bubble_layout.addWidget(meta) + bubble_layout.addWidget(body) + if side == "doctor": + row_layout.addStretch(1) + row_layout.addWidget(bubble) + else: + row_layout.addWidget(bubble) + row_layout.addStretch(1) + self.root.insertWidget(max(2, self.root.count() - 1), row) + + +@dataclass(frozen=True) +class CaseField: + label: str + key: str + span: int = 1 + + +class CaseGrid(QFrame): + """Grouped 4/3-column readonly case presentation.""" + + GROUPS: tuple[tuple[str, int, tuple[CaseField, ...]], ...] = ( + ( + "基本信息", + 4, + ( + CaseField("诊单ID", "id"), + CaseField("姓名", "patient_name"), + CaseField("身份证", "id_card"), + CaseField("手机", "phone"), + CaseField("性别", "gender"), + CaseField("年龄", "age"), + CaseField("婚姻", "marital_status"), + CaseField("地区", "region"), + ), + ), + ( + "生命体征", + 4, + ( + CaseField("身高", "height"), + CaseField("体重", "weight"), + CaseField("高压", "systolic_pressure"), + CaseField("低压", "diastolic_pressure"), + CaseField("诊断类型", "diagnosis_type"), + CaseField("空腹血糖", "fasting_blood_sugar"), + CaseField("在用药物", "current_medications", 2), + ), + ), + ( + "主诉", + 4, + ( + CaseField("当地诊断日期", "diagnosis_date"), + CaseField("糖尿病病史", "diabetes_discovery_year"), + CaseField("当地医院", "local_hospital_name"), + CaseField("诊断结果", "local_hospital_diagnosis"), + ), + ), + ( + "现病史", + 3, + ( + CaseField("口腔", "appetite"), + CaseField("饮水", "water_intake"), + CaseField("体重变化", "weight_change"), + CaseField("脂肪肝", "fatty_liver_degree"), + CaseField("饮食", "diet_condition", 2), + CaseField("肢体", "body_feeling"), + CaseField("睡眠", "sleep_condition"), + CaseField("眼", "eye_condition"), + CaseField("头", "head_feeling"), + CaseField("出汗", "sweat_condition"), + CaseField("皮肤", "skin_condition"), + CaseField("小便", "urine_condition"), + CaseField("大便", "stool_condition"), + CaseField("腰肾", "kidney_condition"), + CaseField("其他补充", "symptoms", 3), + ), + ), + ("既往史", 1, (CaseField("既往史", "past_history"),)), + ( + "其他病史", + 3, + ( + CaseField("外伤", "trauma_history"), + CaseField("手术", "surgery_history"), + CaseField("过敏", "allergy_history"), + CaseField("家族", "family_history"), + CaseField("妊娠", "pregnancy_history"), + ), + ), + ( + "补充与意见", + 1, + ( + CaseField("病史补充", "remark"), + CaseField("处方意见", "prescription_opinion"), + ), + ), + ) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisReadonlyCaseCard") + self.root = QVBoxLayout(self) + self.root.setContentsMargins(18, 18, 18, 18) + self.root.setSpacing(14) + title_row = QHBoxLayout() + title_row.setSpacing(10) + self.title_label = QLabel("患者病例") + self.title_label.setObjectName("DiagnosisReadonlyCardTitle") + title_row.addWidget(self.title_label) + self.subtitle = QLabel("病例 · 诊断日期 —") + self.subtitle.setStyleSheet( + 'color:#64748B;font-size:12px;font-family:"IBM Plex Mono",Consolas,monospace;' + ) + title_row.addWidget(self.subtitle) + title_row.addStretch(1) + self.root.addLayout(title_row) + self.value_labels: dict[str, list[QLabel]] = {} + for group_index, (group_name, columns, fields) in enumerate(self.GROUPS): + if group_index: + divider = QFrame() + divider.setFrameShape(QFrame.Shape.HLine) + divider.setStyleSheet("border:0; border-top:1px dashed #E5E7EB;") + self.root.addWidget(divider) + group_title = QLabel(f"● {group_name}") + group_title.setProperty("diagnosisCaseGroup", True) + self.root.addWidget(group_title) + grid = QGridLayout() + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(16) + grid.setVerticalSpacing(8) + row = 0 + column = 0 + for field in fields: + if column + field.span > columns: + row += 1 + column = 0 + cell = QWidget() + cell_layout = QHBoxLayout(cell) + cell_layout.setContentsMargins(0, 0, 0, 0) + cell_layout.setSpacing(7) + label = QLabel(f"{field.label}:") + label.setProperty("diagnosisCaseLabel", True) + value = QLabel("—") + value.setProperty("diagnosisCaseValue", True) + value.setWordWrap(True) + cell_layout.addWidget(label, 0, Qt.AlignmentFlag.AlignTop) + cell_layout.addWidget(value, 1, Qt.AlignmentFlag.AlignTop) + grid.addWidget(cell, row, column, 1, field.span) + self.value_labels.setdefault(field.key, []).append(value) + column += field.span + if column >= columns: + row += 1 + column = 0 + self.root.addLayout(grid) + + def set_data(self, diagnosis: Any, patient: Any) -> None: + diagnosis_type = str(_pick(diagnosis, "diagnosis_type", default="") or "") + type_text = _pick(diagnosis, "diagnosis_type_text", default="") + if not type_text: + type_text = { + "first_visit": "初诊", + "follow_up": "复诊", + "consultation": "会诊", + "1": "初诊", + "2": "复诊", + }.get(diagnosis_type, diagnosis_type or "病例") + diagnosis_date = _pick(diagnosis, "diagnosis_date", "create_time", default="—") + self.subtitle.setText(f"{type_text} · 诊断日期 {diagnosis_date}") + for key, labels in self.value_labels.items(): + raw = _pick( + diagnosis, + f"{key}_text", + key, + default=_pick(patient, f"{key}_text", key, default=""), + ) + rendered = _text(raw) + normalized = str(raw).strip().lower() + if key == "gender": + rendered = ( + "男" + if normalized in {"1", "m", "male", "男"} + else "女" + if normalized in {"0", "2", "f", "female", "女"} + else "—" + ) + elif key == "marital_status": + rendered = {"0": "未婚", "1": "已婚", "2": "离异"}.get(normalized, rendered) + elif key == "diagnosis_type": + rendered = { + "first_visit": "初诊", + "follow_up": "复诊", + "consultation": "会诊", + "1": "初诊", + "2": "复诊", + }.get(normalized, rendered) + elif key in { + "trauma_history", + "surgery_history", + "allergy_history", + "family_history", + "pregnancy_history", + }: + rendered = {"0": "无", "1": "有"}.get(normalized, rendered) + elif key == "age" and raw not in (None, ""): + rendered = f"{rendered}岁" + elif key == "height" and raw not in (None, ""): + rendered = f"{rendered} cm" + elif key == "weight" and raw not in (None, ""): + rendered = f"{rendered} kg" + elif key in {"systolic_pressure", "diastolic_pressure"} and raw not in ( + None, + "", + ): + rendered = f"{rendered} mmHg" + elif key == "fasting_blood_sugar" and raw not in (None, ""): + rendered = f"{rendered} mmol/L" + abnormal = False + if key == "systolic_pressure": + try: + abnormal = float(raw) >= 140 + except (TypeError, ValueError): + abnormal = False + elif key == "diastolic_pressure": + try: + abnormal = float(raw) >= 90 + except (TypeError, ValueError): + abnormal = False + elif key == "fasting_blood_sugar": + try: + abnormal = float(raw) >= 7 + except (TypeError, ValueError): + abnormal = False + if abnormal: + rendered = f"↑ {rendered}" + for label in labels: + label.setText(rendered) + label.setProperty("abnormal", abnormal) + repolish(label) + + +def section_heading(text: str, object_name: str) -> QWidget: + container = QWidget() + container.setObjectName(object_name) + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 12, 0, 8) + layout.setSpacing(0) + left = QFrame() + left.setObjectName("DiagnosisSectionLine") + right = QFrame() + right.setObjectName("DiagnosisSectionLine") + title = QLabel(text) + title.setObjectName("DiagnosisSectionTitle") + layout.addWidget(left, 1) + layout.addWidget(title) + layout.addWidget(right, 1) + return container + + +def readonly_card(title: str, object_name: str, body: QWidget) -> QFrame: + card = QFrame() + card.setObjectName(object_name) + card.setProperty("diagnosisReadonlyCard", True) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(18, 18, 18, 18) + card_layout.setSpacing(14) + title_label = QLabel(title) + title_label.setObjectName("DiagnosisReadonlyCardTitle") + card_layout.addWidget(title_label) + card_layout.addWidget(body) + return card + + +def set_tag_item(item: QTableWidgetItem, kind: str) -> None: + palette = { + "success": ("#15803D", "#F0FDF4"), + "warning": ("#B45309", "#FFFBEB"), + "danger": ("#DC2626", "#FEF2F2"), + "info": ("#2563EB", "#EFF6FF"), + "neutral": ("#64748B", "#F1F5F9"), + } + foreground, background = palette.get(kind, palette["neutral"]) + item.setForeground(QColor(foreground)) + item.setBackground(QColor(background)) + + +def set_status_tag( + table: QTableWidget, + row: int, + column: int, + text: str, + kind: str, +) -> StatusTag: + """Render semantic state as a compact pill instead of painting a whole cell.""" + + host = QWidget(table) + layout = QHBoxLayout(host) + layout.setContentsMargins(4, 3, 4, 3) + layout.setSpacing(0) + tag = StatusTag(text, kind, host) + tag.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(tag, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + layout.addStretch(1) + table.setCellWidget(row, column, host) + return tag + + +__all__ = [ + "CaseGrid", + "ChatPanel", + "DIAGNOSIS_QSS", + "DailyRecordPanel", + "DiagnosisChoiceButtons", + "DiagnosisComboBox", + "DiagnosisDateEdit", + "DiagnosisLineEdit", + "DiagnosisNumberEdit", + "DiagnosisSwitch", + "DiagnosisTabWidget", + "DiagnosisTextEdit", + "LoadingOverlay", + "MessageStrip", + "NotesTimeline", + "RecordTable", + "SaveStateButton", + "StatusTag", + "readonly_card", + "repolish", + "section_heading", + "set_tag_item", + "set_status_tag", +] diff --git a/app/src/doctor_workstation/ui/diagnosis_editors.py b/app/src/doctor_workstation/ui/diagnosis_editors.py new file mode 100644 index 000000000..fc2dc690d --- /dev/null +++ b/app/src/doctor_workstation/ui/diagnosis_editors.py @@ -0,0 +1,403 @@ +"""Focused editors and responsive layouts used by diagnosis detail views.""" + +from __future__ import annotations + +from collections.abc import Mapping +from contextlib import suppress +from typing import Any + +from PySide6.QtCore import QDate, QRect, QSize, Qt, QTime +from PySide6.QtWidgets import ( + QComboBox, + QDateEdit, + QDialog, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLayout, + QLayoutItem, + QLineEdit, + QPlainTextEdit, + QPushButton, + QScrollArea, + QSpinBox, + QTimeEdit, + QVBoxLayout, + QWidget, +) + + +class FlowLayout(QLayout): + """A small Qt flow layout that wraps visible widgets at the available width.""" + + def __init__( + self, + parent: QWidget | None = None, + *, + horizontal_spacing: int = 8, + vertical_spacing: int = 8, + ) -> None: + super().__init__(parent) + self._items: list[QLayoutItem] = [] + self._horizontal_spacing = horizontal_spacing + self._vertical_spacing = vertical_spacing + self.setContentsMargins(0, 0, 0, 0) + + def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - Qt virtual + self._items.append(item) + + def count(self) -> int: + return len(self._items) + + def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt virtual + return self._items[index] if 0 <= index < len(self._items) else None + + def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt virtual + return self._items.pop(index) if 0 <= index < len(self._items) else None + + def expandingDirections(self) -> Qt.Orientations: # noqa: N802 - Qt virtual + return Qt.Orientation(0) + + def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt virtual + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt virtual + return self._do_layout(QRect(0, 0, max(0, width), 0), test_only=True) + + def setGeometry(self, rect: QRect) -> None: # noqa: N802 - Qt virtual + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual + return self.minimumSize() + + def minimumSize(self) -> QSize: # noqa: N802 - Qt virtual + size = QSize() + for item in self._visible_items(): + size = size.expandedTo(item.minimumSize()) + margins = self.contentsMargins() + size += QSize(margins.left() + margins.right(), margins.top() + margins.bottom()) + return size + + def _visible_items(self) -> list[QLayoutItem]: + return [ + item for item in self._items if item.widget() is None or not item.widget().isHidden() + ] + + def _do_layout(self, rect: QRect, *, test_only: bool) -> int: + margins = self.contentsMargins() + effective = rect.adjusted( + margins.left(), margins.top(), -margins.right(), -margins.bottom() + ) + x = effective.x() + y = effective.y() + line_height = 0 + for item in self._visible_items(): + hint = item.sizeHint().expandedTo(item.minimumSize()) + next_x = x + hint.width() + if line_height and next_x > effective.right() + 1: + x = effective.x() + y += line_height + self._vertical_spacing + next_x = x + hint.width() + line_height = 0 + if not test_only: + item.setGeometry(QRect(x, y, hint.width(), hint.height())) + x = next_x + self._horizontal_spacing + line_height = max(line_height, hint.height()) + return y + line_height - rect.y() + margins.bottom() + + +def _mapping(value: Any) -> dict[str, Any]: + if isinstance(value, Mapping): + return dict(value) + with suppress(TypeError): + return dict(vars(value)) + return {} + + +def _value(source: Mapping[str, Any], *keys: str, default: Any = "") -> Any: + for key in keys: + value = source.get(key) + if value not in (None, ""): + return value + return default + + +class DailyRecordEditorDialog(QDialog): + """Create/edit the exact blood, diet, and exercise DTOs used by admin.""" + + _LABELS = {"blood": "血糖 / 血压", "diet": "饮食", "exercise": "运动"} + + def __init__( + self, + kind: str, + record: Any, + *, + parent: QWidget | None = None, + ) -> None: + if kind not in self._LABELS: + raise ValueError(f"unsupported daily record kind: {kind}") + super().__init__(parent) + self.kind = kind + self.source = _mapping(record) + self.record_id = _positive_int(self.source.get("id")) + self.setObjectName("DiagnosisDailyEditor") + self.setModal(True) + self.setWindowTitle(f"{'编辑' if self.record_id else '新增'}{self._LABELS[kind]}记录") + self.resize(650, 620 if kind == "blood" else 590) + + root = QVBoxLayout(self) + root.setContentsMargins(20, 18, 20, 18) + root.setSpacing(14) + heading = QLabel(self.windowTitle()) + heading.setObjectName("DiagnosisDialogHeading") + root.addWidget(heading) + guidance = QLabel("保存后将重新加载当前日期范围;带 * 的字段为必填项。") + guidance.setObjectName("DiagnosisDialogGuidance") + guidance.setWordWrap(True) + root.addWidget(guidance) + + scroll = QScrollArea() + scroll.setObjectName("DiagnosisEditorScroll") + scroll.setWidgetResizable(True) + content = QWidget() + self.form = QFormLayout(content) + self.form.setContentsMargins(8, 8, 12, 8) + self.form.setHorizontalSpacing(18) + self.form.setVerticalSpacing(11) + scroll.setWidget(content) + root.addWidget(scroll, 1) + + self.date_edit = QDateEdit() + self.date_edit.setCalendarPopup(True) + self.date_edit.setDisplayFormat("yyyy-MM-dd") + self.date_edit.setMaximumDate(QDate.currentDate()) + date = QDate.fromString(str(self.source.get("record_date") or ""), "yyyy-MM-dd") + self.date_edit.setDate(date if date.isValid() else QDate.currentDate()) + self._field(self.date_edit) + self.form.addRow("记录日期 *", self.date_edit) + + if kind == "blood": + self._build_blood() + elif kind == "diet": + self._build_diet() + else: + self._build_exercise() + + self.error_label = QLabel() + self.error_label.setObjectName("DiagnosisEditorError") + self.error_label.setWordWrap(True) + self.error_label.hide() + root.addWidget(self.error_label) + actions = QHBoxLayout() + actions.addStretch(1) + cancel = QPushButton("取消") + cancel.setProperty("variant", "ghost") + cancel.clicked.connect(self.reject) + actions.addWidget(cancel) + save = QPushButton("保存记录") + save.setObjectName("DiagnosisDailyEditorSave") + save.setProperty("variant", "primary") + save.setDefault(True) + save.clicked.connect(self.accept) + actions.addWidget(save) + root.addLayout(actions) + + @staticmethod + def _field(widget: QWidget) -> QWidget: + widget.setProperty("diagnosisField", True) + return widget + + def _double(self, *keys: str, maximum: float = 50.0) -> QDoubleSpinBox: + editor = QDoubleSpinBox() + editor.setRange(0.0, maximum) + editor.setDecimals(2) + editor.setSingleStep(0.1) + editor.setSpecialValueText("未填写") + with suppress(TypeError, ValueError): + editor.setValue(float(_value(self.source, *keys, default=0) or 0)) + self._field(editor) + return editor + + def _integer(self, *keys: str, maximum: int) -> QSpinBox: + editor = QSpinBox() + editor.setRange(0, maximum) + editor.setSpecialValueText("未填写") + editor.setValue(_positive_int(_value(self.source, *keys, default=0), allow_zero=True)) + self._field(editor) + return editor + + def _line(self, *keys: str) -> QLineEdit: + editor = QLineEdit(str(_value(self.source, *keys, default="") or "")) + editor.setMaxLength(500) + self._field(editor) + return editor + + def _plain(self, *keys: str, height: int = 72) -> QPlainTextEdit: + editor = QPlainTextEdit(str(_value(self.source, *keys, default="") or "")) + editor.setMaximumHeight(height) + self._field(editor) + return editor + + def _build_blood(self) -> None: + self.time_edit = QTimeEdit() + self.time_edit.setDisplayFormat("HH:mm") + parsed = QTime.fromString(str(self.source.get("record_time") or ""), "HH:mm") + self.time_edit.setTime(parsed if parsed.isValid() else QTime.currentTime()) + self._field(self.time_edit) + self.form.addRow("记录时间", self.time_edit) + self.fasting = self._double("fasting_blood_sugar", "fasting_glucose") + self.postprandial = self._double("postprandial_blood_sugar", "postprandial_glucose") + self.other = self._double("other_blood_sugar", "other_glucose") + self.systolic = self._integer("systolic_pressure", "systolic", maximum=300) + self.diastolic = self._integer("diastolic_pressure", "diastolic", maximum=200) + self.western = self._line("western_medicine") + self.insulin = self._line("insulin") + self.remark = self._plain("remark", height=82) + self.form.addRow("空腹血糖(mmol/L)", self.fasting) + self.form.addRow("餐后 2 小时血糖(mmol/L)", self.postprandial) + self.form.addRow("其他血糖(mmol/L)", self.other) + self.form.addRow("收缩压(mmHg)", self.systolic) + self.form.addRow("舒张压(mmHg)", self.diastolic) + self.form.addRow("西药", self.western) + self.form.addRow("胰岛素", self.insulin) + self.form.addRow("备注", self.remark) + + def _build_diet(self) -> None: + self.breakfast = self._plain("breakfast_foods", "breakfast") + self.lunch = self._plain("lunch_foods", "lunch") + self.dinner = self._plain("dinner_foods", "dinner") + self.note = self._plain("note", height=82) + self.form.addRow("早餐", self.breakfast) + self.form.addRow("午餐", self.lunch) + self.form.addRow("晚餐", self.dinner) + self.form.addRow("备注", self.note) + preserved = QLabel("已有餐食图片会原样保留;图片增删仍由服务端素材入口处理。") + preserved.setObjectName("DiagnosisDialogGuidance") + preserved.setWordWrap(True) + self.form.addRow("图片", preserved) + + def _build_exercise(self) -> None: + self.exercise_type = QComboBox() + self.exercise_type.addItem("请选择运动类型", "") + for value in ("散步", "慢跑", "游泳", "瑜伽", "骑自行车", "打太极拳", "其他"): + self.exercise_type.addItem(value, value) + current_type = str(self.source.get("exercise_type") or "") + if current_type and self.exercise_type.findData(current_type) < 0: + self.exercise_type.addItem(current_type, current_type) + if current_type: + self.exercise_type.setCurrentIndex(self.exercise_type.findData(current_type)) + self._field(self.exercise_type) + self.duration = self._integer("duration", maximum=300) + self.intensity = QComboBox() + for label, value in (("低强度", 1), ("中强度", 2), ("高强度", 3)): + self.intensity.addItem(label, value) + requested = _positive_int(self.source.get("intensity"), allow_zero=True) or 1 + self.intensity.setCurrentIndex(max(0, self.intensity.findData(requested))) + self._field(self.intensity) + self.note = self._plain("note", height=90) + self.form.addRow("运动类型 *", self.exercise_type) + self.form.addRow("运动时长(分钟)*", self.duration) + self.form.addRow("运动强度 *", self.intensity) + self.form.addRow("备注", self.note) + preserved = QLabel("已有运动图片会原样保留。") + preserved.setObjectName("DiagnosisDialogGuidance") + self.form.addRow("图片", preserved) + + def payload(self) -> dict[str, Any]: + payload = dict(self.source) + payload["record_date"] = self.date_edit.date().toString("yyyy-MM-dd") + if self.kind == "blood": + payload.update( + { + "record_time": self.time_edit.time().toString("HH:mm"), + "fasting_blood_sugar": self.fasting.value(), + "postprandial_blood_sugar": self.postprandial.value(), + "other_blood_sugar": self.other.value(), + "systolic_pressure": self.systolic.value(), + "diastolic_pressure": self.diastolic.value(), + "western_medicine": self.western.text().strip(), + "insulin": self.insulin.text().strip(), + "remark": self.remark.toPlainText().strip(), + } + ) + elif self.kind == "diet": + payload.update( + { + "breakfast_foods": self.breakfast.toPlainText().strip(), + "lunch_foods": self.lunch.toPlainText().strip(), + "dinner_foods": self.dinner.toPlainText().strip(), + "note": self.note.toPlainText().strip(), + "breakfast_images": payload.get("breakfast_images") or [], + "lunch_images": payload.get("lunch_images") or [], + "dinner_images": payload.get("dinner_images") or [], + } + ) + else: + payload.update( + { + "exercise_type": str(self.exercise_type.currentData() or "").strip(), + "duration": self.duration.value(), + "intensity": int(self.intensity.currentData() or 1), + "images": payload.get("images") or [], + "note": self.note.toPlainText().strip(), + } + ) + return payload + + def validation_error(self) -> str: + if not self.date_edit.date().isValid(): + return "请选择记录日期。" + if self.date_edit.date() > QDate.currentDate(): + return "记录日期不能晚于今天。" + payload = self.payload() + if self.kind == "blood": + measurements = ( + payload["fasting_blood_sugar"], + payload["postprandial_blood_sugar"], + payload["other_blood_sugar"], + payload["systolic_pressure"], + payload["diastolic_pressure"], + ) + if not any(float(value or 0) > 0 for value in measurements) and not any( + payload[key] for key in ("western_medicine", "insulin", "remark") + ): + return "请至少填写一项血糖、血压、用药或备注。" + elif self.kind == "diet": + if not any( + payload[key] + for key in ( + "breakfast_foods", + "lunch_foods", + "dinner_foods", + "breakfast_images", + "lunch_images", + "dinner_images", + "note", + ) + ): + return "请至少填写一餐、图片或备注。" + elif not payload["exercise_type"] or int(payload["duration"] or 0) <= 0: + return "请选择运动类型并填写大于 0 的运动时长。" + return "" + + def accept(self) -> None: + error = self.validation_error() + if error: + self.error_label.setText(error) + self.error_label.show() + return + self.error_label.hide() + super().accept() + + +def _positive_int(value: Any, *, allow_zero: bool = False) -> int: + try: + parsed = int(value or 0) + except (TypeError, ValueError): + return 0 + return parsed if parsed > 0 or (allow_zero and parsed == 0) else 0 + + +__all__ = ["DailyRecordEditorDialog", "FlowLayout"] diff --git a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py new file mode 100644 index 000000000..a763a1c86 --- /dev/null +++ b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py @@ -0,0 +1,2071 @@ +"""Page-local widgets used by the diagnosis index. + +The module deliberately does not extend the shared theme or widget library. Its +model, delegates and style sheet are scoped to ``#DiagnosisIndex`` so the dense +admin-table presentation cannot leak into another workstation page. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from math import ceil +from typing import Any + +from PySide6.QtCore import ( + QAbstractTableModel, + QEvent, + QItemSelectionModel, + QLine, + QModelIndex, + QPoint, + QRect, + QSize, + Qt, + QTimer, + Signal, +) +from PySide6.QtGui import ( + QAction, + QColor, + QFont, + QIcon, + QLinearGradient, + QMouseEvent, + QPainter, + QPaintEvent, + QPen, + QPixmap, + QPolygon, + QResizeEvent, +) +from PySide6.QtWidgets import ( + QAbstractItemView, + QApplication, + QComboBox, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QLayout, + QLayoutItem, + QMenu, + QSizePolicy, + QSpinBox, + QStyle, + QStyledItemDelegate, + QStyleOptionButton, + QStyleOptionViewItem, + QTableView, + QToolButton, + QWidget, + QWidgetItem, +) + +from .widgets import display_text, first_value, gender_text, get_value + +PRIMARY = QColor("#4A5DFF") +TEXT = QColor("#333333") +SECONDARY = QColor("#666666") +PLACEHOLDER = QColor("#A8ABB2") +_INVALID_INDEX = QModelIndex() +_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 340) + + +def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon: + """Draw the admin menu glyphs without relying on a symbol font.""" + + size = 18 + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + color = QColor("#F56C6C" if danger else "#606266") + pen = QPen(color, 1.6) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + + if kind == "user": + painter.drawEllipse(QRect(6, 2, 6, 6)) + painter.drawArc(QRect(3, 9, 12, 8), 0, 180 * 16) + elif kind == "remove": + painter.drawEllipse(QRect(2, 2, 14, 14)) + painter.drawLine(5, 9, 13, 9) + elif kind == "picture": + painter.drawRoundedRect(QRect(2, 3, 14, 12), 2, 2) + painter.drawEllipse(QRect(10, 5, 3, 3)) + painter.drawPolyline( + QPolygon([QPoint(4, 13), QPoint(8, 9), QPoint(11, 12), QPoint(13, 10), QPoint(15, 13)]) + ) + elif kind == "close": + painter.drawEllipse(QRect(2, 2, 14, 14)) + painter.drawLine(6, 6, 12, 12) + painter.drawLine(12, 6, 6, 12) + elif kind == "list": + painter.setBrush(color) + for y in (5, 9, 13): + painter.drawEllipse(QRect(2, y - 1, 2, 2)) + painter.drawLine(7, y, 16, y) + elif kind == "document": + painter.drawRoundedRect(QRect(3, 2, 12, 14), 1, 1) + painter.drawLine(11, 2, 11, 6) + painter.drawLine(11, 6, 15, 6) + painter.drawLine(6, 9, 12, 9) + painter.drawLine(6, 12, 12, 12) + elif kind == "delete": + painter.drawLine(3, 5, 15, 5) + painter.drawLine(7, 2, 11, 2) + painter.drawLine(5, 5, 6, 16) + painter.drawLine(13, 5, 12, 16) + painter.drawLine(6, 16, 12, 16) + painter.drawLine(8, 8, 8, 13) + painter.drawLine(10, 8, 10, 13) + painter.end() + return QIcon(pixmap) + + +class _DiagnosisActionMenu(QMenu): + """Element-like menu with an explicitly painted danger action.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._danger_action: QAction | None = None + + def set_danger_action(self, action: QAction) -> None: + self._danger_action = action + action.setProperty("danger", True) + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + super().paintEvent(event) + action = self._danger_action + if action is None or not action.isVisible(): + return + rect = self.actionGeometry(action) + if not rect.isValid(): + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.fillRect(rect, QColor("#FFFFFF")) + item_rect = rect + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor("#FEF0F0") if self.activeAction() is action else QColor("#FFFFFF")) + painter.drawRoundedRect(item_rect, 3, 3) + icon_rect = QRect(item_rect.left() + 8, item_rect.center().y() - 9, 18, 18) + action.icon().paint(painter, icon_rect) + painter.setPen(QColor("#F56C6C" if action.isEnabled() else "#FAB6B6")) + painter.drawText( + item_rect.adjusted(34, 0, -10, 0), + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, + action.text(), + ) + painter.end() + + +def _add_menu_action( + menu: _DiagnosisActionMenu, + text: str, + icon_kind: str, + callback: Any, + *, + danger: bool = False, +) -> QAction: + action = menu.addAction(_menu_action_icon(icon_kind, danger=danger), text, callback) + action.setProperty("iconSource", "qpaint") + if danger: + menu.set_danger_action(action) + return action + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _as_bool(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _appointments(record: Any) -> list[Any]: + rows = get_value(record, "appointments", None) + if isinstance(rows, Sequence) and not isinstance(rows, (str, bytes)) and rows: + return list(rows) + if not _as_bool(first_value(record, "has_appointment", default=False)): + return [] + latest = get_value(record, "latest_appointment", None) + if latest: + return [latest] + return [ + { + "id": first_value(record, "appointment_id"), + "status": first_value(record, "appointment_status"), + "doctor_name": first_value( + record, "appointment_doctor_name", "doctor_name", default="" + ), + "time_text": first_value( + record, + "appointment_time_text", + "appointment_time", + "period", + default="", + ), + } + ] + + +def _confirmed(record: Any) -> bool: + views = get_value(record, "DiagnosisViewRecord", None) + if views is None: + views = get_value(record, "diagnosis_view_records", None) + if isinstance(views, Sequence) and not isinstance(views, (str, bytes)) and views: + return any(_as_bool(first_value(row, "is_confirmed", default=False)) for row in views) + return _as_bool(first_value(record, "diagnosis_confirmed", "is_confirmed", default=False)) + + +def _has_appointment(record: Any) -> bool: + return _as_bool(first_value(record, "has_appointment", default=False)) + + +def _appointment_status(record: Any) -> int: + status = first_value(record, "appointment_status", default=None) + if status is None: + rows = _appointments(record) + status = first_value(rows[0], "status", "appointment_status", default=-1) if rows else -1 + return _as_int(status, -1) + + +def _appointment_active(record: Any) -> bool: + return _has_appointment(record) and _appointment_status(record) == 1 + + +def _single_cancellable_appointment(record: Any) -> Any | None: + """Return the sole cancellable appointment, never an ambiguous row-level fallback.""" + + if not _has_appointment(record) or _appointment_status(record) not in {1, 4}: + return None + rows = _appointments(record) + if len(rows) != 1: + return None + appointment = rows[0] + appointment_id = _as_int(first_value(appointment, "id", "appointment_id", default=0)) + status = _as_int(first_value(appointment, "status", "appointment_status", default=-1), -1) + return appointment if appointment_id > 0 and status in {1, 4} else None + + +def _cancellable_appointment(record: Any, appointment_id: int) -> Any | None: + """Resolve one visible, cancellable appointment by its exact primary key.""" + + target_id = _as_int(appointment_id) + if target_id <= 0: + return None + matches = [ + appointment + for appointment in _appointments(record) + if _as_int(first_value(appointment, "id", "appointment_id", default=0)) == target_id + and _as_int(first_value(appointment, "status", "appointment_status", default=-1), -1) + in {1, 4} + ] + return matches[0] if len(matches) == 1 else None + + +def _video_ids_complete(record: Any) -> bool: + appointment_id = _as_int( + first_value( + record, + "appointment_id", + default=first_value( + _appointments(record)[0] if _appointments(record) else None, + "id", + "appointment_id", + default=0, + ), + ) + ) + return all( + value > 0 + for value in ( + appointment_id, + _as_int(first_value(record, "patient_id", "source_patient_id", default=0)), + _as_int(first_value(record, "diagnosis_id", "id", default=0)), + ) + ) + + +def _prescription_action_label(record: Any, *, force_open: bool = False) -> str: + if force_open: + return "开方" + audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1) + voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0) + return "查看处方" if audit == 1 and voided != 1 else "开方" + + +class FlowLayout(QLayout): + """Small Qt-example-style wrapping layout with deterministic pixel gaps.""" + + def __init__( + self, + parent: QWidget | None = None, + *, + margin: int = 0, + horizontal_spacing: int = 6, + vertical_spacing: int = 6, + ) -> None: + super().__init__(parent) + self._items: list[QLayoutItem] = [] + self._horizontal_spacing = horizontal_spacing + self._vertical_spacing = vertical_spacing + self.setContentsMargins(margin, margin, margin, margin) + + def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - Qt virtual + self._items.append(item) + + def addWidget(self, widget: QWidget) -> None: # noqa: N802 - Qt API parity + self.addChildWidget(widget) + self.addItem(QWidgetItem(widget)) + + def count(self) -> int: + return len(self._items) + + def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt virtual + return self._items[index] if 0 <= index < len(self._items) else None + + def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt virtual + return self._items.pop(index) if 0 <= index < len(self._items) else None + + def expandingDirections(self) -> Qt.Orientation: # noqa: N802 - Qt virtual + return Qt.Orientation(0) + + def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt virtual + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt virtual + return self._do_layout(QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect: QRect) -> None: # noqa: N802 - Qt virtual + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual + return self.minimumSize() + + def minimumSize(self) -> QSize: # noqa: N802 - Qt virtual + size = QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + margins = self.contentsMargins() + return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom()) + + def _do_layout(self, rect: QRect, *, test_only: bool) -> int: + margins = self.contentsMargins() + effective = rect.adjusted( + margins.left(), margins.top(), -margins.right(), -margins.bottom() + ) + x = effective.x() + y = effective.y() + line_height = 0 + for item in self._items: + hint = item.sizeHint() + next_x = x + hint.width() + if line_height and next_x > effective.right() + 1: + x = effective.x() + y += line_height + self._vertical_spacing + next_x = x + hint.width() + line_height = 0 + if not test_only: + item.setGeometry(QRect(QPoint(x, y), hint)) + x = next_x + self._horizontal_spacing + line_height = max(line_height, hint.height()) + return y + line_height - rect.y() + margins.bottom() + + +class FlowWidget(QWidget): + """Convenience host exposing its :class:`FlowLayout` as ``flow``.""" + + def __init__( + self, + parent: QWidget | None = None, + *, + horizontal_spacing: int = 6, + vertical_spacing: int = 6, + ) -> None: + super().__init__(parent) + self.flow = FlowLayout( + self, + horizontal_spacing=horizontal_spacing, + vertical_spacing=vertical_spacing, + ) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual + width = max(1, self.width()) + return QSize(width, self.flow.heightForWidth(width)) + + def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual + return QSize(0, self.flow.heightForWidth(max(1, self.width()))) + + +class DiagnosisChip(QToolButton): + """Checkable semantic chip whose width grows with its inline count.""" + + def __init__( + self, + text: str, + *, + semantic: str = "primary", + small: bool = False, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setText(text) + self.setCheckable(True) + self.setAutoExclusive(False) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setProperty("diagnosisChip", True) + self.setProperty("semantic", semantic) + self.setProperty("small", small) + self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly) + + +class DiagnosisTableModel(QAbstractTableModel): + """Read-only diagnosis rows plus a persistent per-page checkbox selection.""" + + selection_changed = Signal(int) + ROW_ROLE = int(Qt.ItemDataRole.UserRole) + 1 + COLUMNS = ( + "", + "ID", + "患者", + "性别 / 年龄", + "挂号", + "确认", + "复诊", + "助理", + "开方", + "未服务天数", + "视频问诊", + "操作", + ) + + def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None: + super().__init__(parent) + self.rows: list[Any] = list(rows) + self._checked_ids: set[int] = set() + self._hover_row = -1 + self._sort_direction = "" + + def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802 + return 0 if parent.isValid() else len(self.rows) + + def columnCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802 + return 0 if parent.isValid() else len(self.COLUMNS) + + def headerData( # noqa: N802 + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + if orientation != Qt.Orientation.Horizontal or role != Qt.ItemDataRole.DisplayRole: + return None + if not 0 <= section < len(self.COLUMNS): + return None + label = self.COLUMNS[section] + return label + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid() or not 0 <= index.row() < len(self.rows): + return None + row = self.rows[index.row()] + column = index.column() + if role in (self.ROW_ROLE, Qt.ItemDataRole.UserRole): + return row + if role == Qt.ItemDataRole.CheckStateRole and column == 0: + return ( + Qt.CheckState.Checked + if self.record_id(row) in self._checked_ids + else Qt.CheckState.Unchecked + ) + if role == Qt.ItemDataRole.ToolTipRole: + return self._tooltip(row, column) + if role == Qt.ItemDataRole.TextAlignmentRole: + if column in {0, 5, 8, 9, 10}: + return int(Qt.AlignmentFlag.AlignCenter) + return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + if role == Qt.ItemDataRole.DisplayRole: + return self._display(row, column) + return None + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + if index.column() == 0: + flags |= Qt.ItemFlag.ItemIsUserCheckable + return flags + + def setData(self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole) -> bool: # noqa: N802 + if not index.isValid() or index.column() != 0 or role != Qt.ItemDataRole.CheckStateRole: + return False + record_id = self.record_id(self.rows[index.row()]) + if _as_int(value) == _as_int(Qt.CheckState.Checked): + self._checked_ids.add(record_id) + else: + self._checked_ids.discard(record_id) + self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole]) + self.selection_changed.emit(len(self._checked_ids)) + return True + + @staticmethod + def record_id(record: Any) -> int: + return _as_int(first_value(record, "diagnosis_id", "id", default=0)) + + def record(self, row: int) -> Any: + return self.rows[row] if 0 <= row < len(self.rows) else None + + def set_rows(self, rows: Iterable[Any]) -> None: + materialized = list(rows) + valid_ids = {self.record_id(row) for row in materialized} + self.beginResetModel() + self.rows = materialized + self._checked_ids.intersection_update(valid_ids) + self._hover_row = -1 + self.endResetModel() + self.selection_changed.emit(len(self._checked_ids)) + + def checked_records(self) -> list[Any]: + return [row for row in self.rows if self.record_id(row) in self._checked_ids] + + def clear_checks(self) -> None: + if not self._checked_ids: + return + self._checked_ids.clear() + if self.rows: + self.dataChanged.emit( + self.index(0, 0), + self.index(len(self.rows) - 1, 0), + [Qt.ItemDataRole.CheckStateRole], + ) + self.selection_changed.emit(0) + + def toggle_all(self) -> None: + all_ids = {self.record_id(row) for row in self.rows} + self._checked_ids = set() if all_ids and all_ids <= self._checked_ids else all_ids + if self.rows: + self.dataChanged.emit( + self.index(0, 0), + self.index(len(self.rows) - 1, 0), + [Qt.ItemDataRole.CheckStateRole], + ) + self.selection_changed.emit(len(self._checked_ids)) + + @property + def all_checked(self) -> bool: + return bool(self.rows) and len(self._checked_ids) == len(self.rows) + + @property + def partially_checked(self) -> bool: + return bool(self._checked_ids) and not self.all_checked + + def set_hover_row(self, row: int) -> None: + if row == self._hover_row: + return + previous = self._hover_row + self._hover_row = row + for changed in (previous, row): + if 0 <= changed < len(self.rows): + self.dataChanged.emit(self.index(changed, 0), self.index(changed, 11), []) + + @property + def hover_row(self) -> int: + return self._hover_row + + def set_sort_direction(self, direction: str) -> None: + direction = direction if direction in {"asc", "desc"} else "" + if direction == self._sort_direction: + return + self._sort_direction = direction + self.headerDataChanged.emit(Qt.Orientation.Horizontal, 9, 9) + + @staticmethod + def _display(row: Any, column: int) -> str: + if column == 0: + return "" + if column == 1: + return display_text(first_value(row, "diagnosis_id", "id")) + if column == 2: + return display_text(first_value(row, "patient_name", "name")) + if column == 3: + gender = first_value(row, "gender_desc", default="") or gender_text( + first_value(row, "gender") + ) + age = first_value(row, "age", default=None) + return f"{display_text(gender, '-')} · {display_text(age, '-')}岁" + if column == 4: + if not _has_appointment(row): + return "未挂号" + return "\n".join( + " · ".join( + part + for part in ( + display_text(first_value(apt, "doctor_name"), "-"), + display_text(first_value(apt, "time_text", "appointment_time"), "-"), + ) + if part + ) + for apt in _appointments(row) + ) + if column == 5: + return "已确认" if _confirmed(row) else "未确认" + if column == 6: + if not _as_bool(first_value(row, "has_prescription", default=False)): + return "无" + return "\n".join( + filter( + None, + ( + display_text(first_value(row, "followup_time_text"), "—"), + display_text(first_value(row, "followup_doctor_name"), "—"), + "处方已作废" + if _as_bool(first_value(row, "followup_rx_voided", default=False)) + else "", + ), + ) + ) + if column == 7: + return display_text(first_value(row, "assistant_name"), "—") + if column == 8: + return "已开方" if _as_bool(first_value(row, "has_prescription")) else "未开方" + if column == 9: + days = first_value(row, "unserved_days", default=None) + return "—" if days is None else display_text(days) + return "" + + @staticmethod + def _tooltip(row: Any, column: int) -> str: + if column == 4: + channel = first_value( + row, + "latest_appointment_channel_text", + "latest_appointment_channel_source_desc", + "appointment_channel_source_desc", + default="", + ) + return f"最近渠道:{channel}" if channel else "" + if column == 7: + return display_text(first_value(row, "assistant_name"), "") + if column == 9: + at = first_value(row, "last_blood_record_at", default="") + return f"最近一次健康打卡:{at}" if at else "" + return "" + + +class DiagnosisHeader(QHeaderView): + """Header that owns the select-all checkbox in section zero.""" + + def __init__(self, orientation: Qt.Orientation, parent: QWidget | None = None) -> None: + super().__init__(orientation, parent) + self.setSectionsClickable(True) + self.setHighlightSections(False) + self.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self.setFixedHeight(39) + + def paintSection(self, painter: QPainter, rect: QRect, logical_index: int) -> None: # noqa: N802 + super().paintSection(painter, rect, logical_index) + if logical_index == 9: + model = self.model() + direction = model._sort_direction if isinstance(model, DiagnosisTableModel) else "" + center_x = rect.right() - 9 + center_y = rect.center().y() + painter.save() + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(PRIMARY if direction == "asc" else QColor("#C0C4CC")) + painter.drawPolygon( + QPolygon( + ( + QPoint(center_x, center_y - 7), + QPoint(center_x - 4, center_y - 2), + QPoint(center_x + 4, center_y - 2), + ) + ) + ) + painter.setBrush(PRIMARY if direction == "desc" else QColor("#C0C4CC")) + painter.drawPolygon( + QPolygon( + ( + QPoint(center_x, center_y + 7), + QPoint(center_x - 4, center_y + 2), + QPoint(center_x + 4, center_y + 2), + ) + ) + ) + painter.restore() + if logical_index != 0: + return + model = self.model() + if not isinstance(model, DiagnosisTableModel): + return + option = QStyleOptionButton() + option.rect = QRect(rect.center().x() - 8, rect.center().y() - 8, 16, 16) + option.state = QStyle.StateFlag.State_Enabled + if model.all_checked: + option.state |= QStyle.StateFlag.State_On + elif model.partially_checked: + option.state |= QStyle.StateFlag.State_NoChange + else: + option.state |= QStyle.StateFlag.State_Off + self.style().drawControl(QStyle.ControlElement.CE_CheckBox, option, painter, self) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if self.logicalIndexAt(event.position().toPoint()) == 0: + model = self.model() + if isinstance(model, DiagnosisTableModel): + model.toggle_all() + self.viewport().update() + event.accept() + return + super().mousePressEvent(event) + + +class DiagnosisItemDelegate(QStyledItemDelegate): + """Paint compact semantic cells without allocating QWidget trees per cell.""" + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: + model = index.model() + if not isinstance(model, DiagnosisTableModel): + super().paint(painter, option, index) + return + record = model.record(index.row()) + painter.save() + self._paint_row_background(painter, option.rect, record, index.row(), index.column(), model) + if index.column() == 0: + self._paint_checkbox(painter, option, index) + elif index.column() in {1, 2}: + self._paint_identity(painter, option.rect, record, index.column()) + elif index.column() == 4: + self._paint_appointment(painter, option.rect, record) + elif index.column() == 5: + self._paint_status( + painter, + option.rect, + "已确认" if _confirmed(record) else "未确认", + QColor("#67C23A") if _confirmed(record) else QColor("#E6A23C"), + bold=not _confirmed(record), + ) + elif index.column() == 6: + self._paint_followup(painter, option.rect, record) + elif index.column() == 8: + prescribed = _as_bool(first_value(record, "has_prescription")) + self._paint_status( + painter, + option.rect, + "已开方" if prescribed else "未开方", + PRIMARY if prescribed else PLACEHOLDER, + ) + elif index.column() == 9: + self._paint_unserved(painter, option.rect, record) + elif index.column() not in {10, 11}: + self._draw_text( + painter, + option.rect.adjusted(8, 4, -8, -4), + display_text(index.data(), "—"), + SECONDARY if index.column() == 7 else TEXT, + 13, + ) + painter.restore() + + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802 + model = index.model() + record = model.record(index.row()) if isinstance(model, DiagnosisTableModel) else None + count = max(1, len(_appointments(record))) if record is not None else 1 + channel = first_value( + record, + "latest_appointment_channel_text", + "latest_appointment_channel_source_desc", + default="", + ) + height = max(52, 12 + count * 42 + (18 if channel else 0)) + return QSize(option.rect.width(), height) + + def editorEvent( # noqa: N802 - Qt virtual + self, + event: QEvent, + model: QAbstractTableModel, + option: QStyleOptionViewItem, + index: QModelIndex, + ) -> bool: + if ( + isinstance(model, DiagnosisTableModel) + and index.column() == 0 + and event.type() == QEvent.Type.MouseButtonRelease + ): + checked = index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked + return model.setData( + index, + Qt.CheckState.Unchecked if checked else Qt.CheckState.Checked, + Qt.ItemDataRole.CheckStateRole, + ) + return super().editorEvent(event, model, option, index) + + @staticmethod + def _paint_row_background( + painter: QPainter, + rect: QRect, + record: Any, + row: int, + column: int, + model: DiagnosisTableModel, + ) -> None: + hovered = model.hover_row == row + base = QColor("#F8F8F8") if hovered else QColor("#FAFAFA" if row % 2 else "#FFFFFF") + special = not _confirmed(record) or not _has_appointment(record) + if special: + global_left = sum(_TABLE_COLUMN_WIDTHS[:column]) + gradient_left = rect.left() - global_left + gradient = QLinearGradient( + gradient_left, + rect.top(), + gradient_left + sum(_TABLE_COLUMN_WIDTHS), + rect.top(), + ) + if not _confirmed(record): + gradient.setColorAt(0, QColor(230, 162, 60, 26 if hovered else 15)) + gradient.setColorAt(1, base) + stripe = QColor("#E6A23C") + else: + gradient.setColorAt(0, QColor(144, 147, 153, 20 if hovered else 10)) + gradient.setColorAt(1, base) + stripe = QColor("#909399") + painter.fillRect(rect, gradient) + if column == 0: + painter.fillRect(QRect(rect.left(), rect.top(), 3, rect.height()), stripe) + else: + painter.fillRect(rect, base) + painter.setPen(QPen(QColor("#EBEEF5"), 1)) + painter.drawLine(QLine(rect.bottomLeft(), rect.bottomRight())) + + @staticmethod + def _paint_checkbox( + painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex + ) -> None: + box = QStyleOptionButton() + box.rect = QRect(option.rect.center().x() - 8, option.rect.center().y() - 8, 16, 16) + box.state = QStyle.StateFlag.State_Enabled + if index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked: + box.state |= QStyle.StateFlag.State_On + else: + box.state |= QStyle.StateFlag.State_Off + widget = option.widget + style = widget.style() if widget is not None else QApplication.style() + style.drawControl(QStyle.ControlElement.CE_CheckBox, box, painter, widget) + + def _paint_identity(self, painter: QPainter, rect: QRect, record: Any, column: int) -> None: + text = display_text( + first_value(record, "diagnosis_id", "id") + if column == 1 + else first_value(record, "patient_name", "name"), + "—", + ) + content = rect.adjusted(8, 4, -6, -4) + self._draw_text(painter, content, text, QColor("#333333"), 14, bold=True) + if ( + column == 1 + and get_value(record, "assign_read_at", "sentinel") is None + and _as_int(first_value(record, "assistant_id", "assistant", default=0)) > 0 + ): + metrics = painter.fontMetrics() + left = min(content.right() - 31, content.left() + metrics.horizontalAdvance(text) + 6) + self._draw_tag( + painter, + QRect(left, content.center().y() - 10, 31, 20), + "NEW", + QColor("#F56C6C"), + QColor("#FFFFFF"), + 10, + ) + + def _paint_appointment(self, painter: QPainter, rect: QRect, record: Any) -> None: + content = rect.adjusted(8, 4, -8, -4) + if not _has_appointment(record): + self._draw_text(painter, content, "未挂号", PLACEHOLDER, 13) + return + rows = _appointments(record) + statuses = {_as_int(first_value(row, "status", "appointment_status"), -1) for row in rows} + if 1 in statuses: + border = QColor(74, 93, 255, 77) + start = QColor(74, 93, 255, 31) + end = QColor(74, 93, 255, 15) + elif 4 in statuses: + border = QColor(230, 162, 60, 89) + start = QColor(230, 162, 60, 31) + end = QColor(230, 162, 60, 13) + else: + border = QColor(144, 147, 153, 71) + start = QColor(144, 147, 153, 26) + end = QColor(144, 147, 153, 10) + gradient = QLinearGradient(content.topLeft(), content.bottomRight()) + gradient.setColorAt(0, start) + gradient.setColorAt(1, end) + painter.setPen(QPen(border, 1)) + painter.setBrush(gradient) + painter.drawRoundedRect(content, 6, 6) + y = content.top() + 4 + for position, appointment in enumerate(rows or [{}]): + status = _as_int(first_value(appointment, "status", "appointment_status"), -1) + label, fg, bg = { + 1: ("已预约", QColor("#3A4ACC"), QColor(74, 93, 255, 38)), + 3: ("已完成", QColor("#73767A"), QColor("#E9E9EB")), + 4: ("已过号", QColor("#B88230"), QColor("#FDF6EC")), + }.get(status, ("未知", SECONDARY, QColor("#F4F4F5"))) + self._draw_tag(painter, QRect(content.left() + 6, y, 46, 18), label, bg, fg, 11) + doctor = display_text(first_value(appointment, "doctor_name"), "-") + time_text = display_text(first_value(appointment, "time_text", "appointment_time"), "-") + self._draw_text( + painter, + QRect(content.left() + 58, y - 1, max(20, content.width() - 64), 18), + doctor, + TEXT, + 14, + bold=True, + ) + self._draw_text( + painter, + QRect(content.left() + 6, y + 19, max(20, content.width() - 12), 17), + time_text, + PRIMARY if status == 1 else (QColor("#E6A23C") if status == 4 else SECONDARY), + 12, + ) + y += 42 + if position < len(rows) - 1: + painter.setPen(QPen(QColor("#EBEEF5"), 1, Qt.PenStyle.DashLine)) + painter.drawLine(content.left() + 6, y - 2, content.right() - 6, y - 2) + channel = first_value( + record, + "latest_appointment_channel_text", + "latest_appointment_channel_source_desc", + "appointment_channel_source_desc", + default="", + ) + if channel: + self._draw_text( + painter, + QRect(content.left() + 6, y, max(20, content.width() - 12), 17), + f"最近渠道:{display_text(channel)}", + SECONDARY, + 12, + ) + + def _paint_followup(self, painter: QPainter, rect: QRect, record: Any) -> None: + content = rect.adjusted(8, 5, -6, -5) + if not _as_bool(first_value(record, "has_prescription")): + self._draw_text(painter, content, "无", PLACEHOLDER, 13) + return + self._draw_text( + painter, + QRect(content.left(), content.top(), content.width(), 18), + display_text(first_value(record, "followup_time_text"), "—"), + TEXT, + 13, + bold=True, + ) + self._draw_text( + painter, + QRect(content.left(), content.top() + 19, content.width(), 17), + display_text(first_value(record, "followup_doctor_name"), "—"), + SECONDARY, + 12, + ) + if _as_bool(first_value(record, "followup_rx_voided")): + self._draw_tag( + painter, + QRect(content.left(), content.top() + 38, 72, 18), + "处方已作废", + QColor("#F4F4F5"), + SECONDARY, + 11, + outlined=True, + ) + + def _paint_unserved(self, painter: QPainter, rect: QRect, record: Any) -> None: + value = first_value(record, "unserved_days", default=None) + if value is None or not first_value(record, "last_blood_record_at", default=""): + text, color, bold = "—", PLACEHOLDER, False + else: + days = _as_int(value) + color = ( + QColor("#16A34A") + if days <= 2 + else QColor("#EA580C") + if days <= 6 + else QColor("#DC2626") + ) + text, bold = str(days), True + self._draw_text( + painter, + rect.adjusted(4, 4, -4, -4), + text, + color, + 13, + bold=bold, + alignment=Qt.AlignmentFlag.AlignCenter, + ) + + def _paint_status( + self, + painter: QPainter, + rect: QRect, + text: str, + color: QColor, + *, + bold: bool = False, + ) -> None: + self._draw_text( + painter, + rect.adjusted(4, 4, -4, -4), + text, + color, + 13, + bold=bold, + alignment=Qt.AlignmentFlag.AlignCenter, + ) + + @staticmethod + def _draw_text( + painter: QPainter, + rect: QRect, + text: str, + color: QColor, + pixels: int, + *, + bold: bool = False, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + ) -> None: + font = QFont(painter.font()) + font.setPixelSize(pixels) + font.setWeight(QFont.Weight.DemiBold if bold else QFont.Weight.Normal) + painter.setFont(font) + painter.setPen(color) + painter.drawText(rect, int(alignment), text) + + @staticmethod + def _draw_tag( + painter: QPainter, + rect: QRect, + text: str, + background: QColor, + foreground: QColor, + pixels: int, + *, + outlined: bool = False, + ) -> None: + painter.setBrush(background if not outlined else Qt.BrushStyle.NoBrush) + if outlined: + painter.setPen(QPen(background, 1)) + else: + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(rect, 4, 4) + DiagnosisItemDelegate._draw_text( + painter, + rect, + text, + foreground, + pixels, + bold=True, + alignment=Qt.AlignmentFlag.AlignCenter, + ) + + +class _CompatItem: + """Tiny QTableWidgetItem-shaped adapter retained for existing callers/tests.""" + + def __init__(self, model: DiagnosisTableModel, row: int, column: int) -> None: + self._model = model + self._row = row + self._column = column + + def text(self) -> str: + record = self._model.record(self._row) + # The former table exposed the ID in column zero. Keep that API while + # the visual model correctly reserves column zero for selection. + if self._column == 0: + return display_text(first_value(record, "diagnosis_id", "id"), "") + value = self._model.data(self._model.index(self._row, self._column)) + return display_text(value, "") + + def data(self, role: int) -> Any: + if role in (Qt.ItemDataRole.UserRole, DiagnosisTableModel.ROW_ROLE): + return self._model.record(self._row) + return None + + +class DiagnosisTableView(QTableView): + """Table view with the small legacy surface still used by page logic.""" + + itemDoubleClicked = Signal(object) + itemSelectionChanged = Signal() + rows_replaced = Signal() + hovered_row = Signal(int) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setMouseTracking(True) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.setShowGrid(False) + self.setAlternatingRowColors(False) + self.setWordWrap(True) + self.verticalHeader().hide() + self.setHorizontalHeader(DiagnosisHeader(Qt.Orientation.Horizontal, self)) + self.doubleClicked.connect( + lambda index: self.itemDoubleClicked.emit( + _CompatItem(self._diagnosis_model(), index.row(), index.column()) + ) + ) + + def _diagnosis_model(self) -> DiagnosisTableModel: + model = self.model() + if not isinstance(model, DiagnosisTableModel): + raise TypeError("DiagnosisTableView requires DiagnosisTableModel") + return model + + def setSelectionModel(self, selection_model: QItemSelectionModel) -> None: # noqa: N802 + previous = self.selectionModel() + if previous is not None: + with suppress_runtime_error(): + previous.currentRowChanged.disconnect(self._current_row_changed) + super().setSelectionModel(selection_model) + selection_model.currentRowChanged.connect(self._current_row_changed) + + def _current_row_changed(self, _current: QModelIndex, _previous: QModelIndex) -> None: + self.itemSelectionChanged.emit() + + def set_rows(self, rows: Iterable[Any]) -> None: + self._diagnosis_model().set_rows(rows) + self.clearSelection() + self.setCurrentIndex(QModelIndex()) + self.rows_replaced.emit() + + def rowCount(self) -> int: # noqa: N802 - compatibility + return self._diagnosis_model().rowCount() + + def currentRow(self) -> int: # noqa: N802 - compatibility + return self.currentIndex().row() + + def current_data(self) -> Any: + return self._diagnosis_model().record(self.currentRow()) + + def item(self, row: int, column: int) -> _CompatItem | None: + if 0 <= row < self.rowCount() and 0 <= column < self._diagnosis_model().columnCount(): + return _CompatItem(self._diagnosis_model(), row, column) + return None + + def resizeRowsToContents(self) -> None: # noqa: N802 - compatibility + self.rows_replaced.emit() + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + row = self.indexAt(event.position().toPoint()).row() + self.hovered_row.emit(row) + super().mouseMoveEvent(event) + + def leaveEvent(self, event: Any) -> None: + self.hovered_row.emit(-1) + super().leaveEvent(event) + + +class suppress_runtime_error: + """Minimal context manager for Qt objects deleted during teardown.""" + + def __enter__(self) -> None: + return None + + def __exit__(self, error_type: Any, _error: Any, _traceback: Any) -> bool: + return bool(error_type and issubclass(error_type, (RuntimeError, TypeError))) + + +class _FixedColumnShadow(QWidget): + """Element-style inset shadow at the left edge of the fixed-right region.""" + + WIDTH = 12 + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisFixedShadow") + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def paintEvent(self, event: QPaintEvent) -> None: + painter = QPainter(self) + gradient = QLinearGradient(self.rect().left(), 0, self.rect().right(), 0) + gradient.setColorAt(0, QColor(31, 45, 61, 42)) + gradient.setColorAt(0.45, QColor(31, 45, 61, 18)) + gradient.setColorAt(1, QColor(31, 45, 61, 0)) + painter.fillRect(self.rect(), gradient) + super().paintEvent(event) + + +class _FixedCell(QWidget): + """Index-widget cell that keeps the paired table's row hover in sync.""" + + hovered_row = Signal(int) + + def __init__(self, row: int, parent: QWidget) -> None: + super().__init__(parent) + self._row = row + self.setObjectName("DiagnosisFixedCell") + self.setMouseTracking(True) + + def enterEvent(self, event: Any) -> None: + self.hovered_row.emit(self._row) + super().enterEvent(event) + + def leaveEvent(self, event: Any) -> None: + self.hovered_row.emit(-1) + super().leaveEvent(event) + + +class _AppointmentActionOverlay(QWidget): + """Transparent per-card controls carrying an exact appointment primary key.""" + + cancel_requested = Signal(object) + row_requested = Signal(int) + + def __init__(self, record: Any, row: int, parent: QWidget) -> None: + super().__init__(parent) + self._row = row + self._buttons: list[tuple[int, QToolButton]] = [] + self.setObjectName("DiagnosisAppointmentActions") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False) + for position, appointment in enumerate(_appointments(record)): + appointment_id = _as_int(first_value(appointment, "id", "appointment_id", default=0)) + if _cancellable_appointment(record, appointment_id) is None: + continue + button = QToolButton(self) + button.setText("取消") + button.setProperty("appointmentCancel", True) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setToolTip(f"取消挂号 #{appointment_id}") + button.setAccessibleName(f"取消挂号 {appointment_id}") + button.clicked.connect( + lambda _checked=False, exact_id=appointment_id: self.cancel_requested.emit(exact_id) + ) + self._buttons.append((position, button)) + + @property + def has_actions(self) -> bool: + return bool(self._buttons) + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt virtual + self.row_requested.emit(self._row) + super().mousePressEvent(event) + + def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt virtual + super().resizeEvent(event) + for position, button in self._buttons: + width = min(38, max(32, button.sizeHint().width())) + button.setGeometry(max(6, self.width() - width - 14), 5 + position * 42, width, 17) + + +class DiagnosisTableHost(QFrame): + """Synchronized scrolling table pair with a fixed 460-pixel right edge.""" + + action_requested = Signal(str, object) + appointment_cancel_requested = Signal(object, object) + video_requested = Signal(object) + sort_unserved_requested = Signal(str) + checked_changed = Signal(int) + + LEFT_WIDTHS = _TABLE_COLUMN_WIDTHS[:10] + FIXED_WIDTHS = _TABLE_COLUMN_WIDTHS[10:] + + def __init__( + self, + *, + action_policy: Mapping[str, bool] | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisTableHost") + self.action_policy = dict(action_policy or {}) + self.force_open_prescription = False + self.model = DiagnosisTableModel(parent=self) + self.delegate = DiagnosisItemDelegate(self) + self.selection = QItemSelectionModel(self.model, self) + self.main = DiagnosisTableView(self) + self.fixed = DiagnosisTableView(self) + self.main.setObjectName("DiagnosisMainTable") + self.fixed.setObjectName("DiagnosisFixedTable") + for view in (self.main, self.fixed): + view.setModel(self.model) + view.setSelectionModel(self.selection) + view.setItemDelegate(self.delegate) + view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + view.horizontalHeader().setStretchLastSection(False) + self.main.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.fixed.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.main.setMinimumWidth(0) + self.fixed.setFixedWidth(sum(self.FIXED_WIDTHS) + 2) + for column, width in enumerate(self.LEFT_WIDTHS): + self.main.setColumnWidth(column, width) + self.fixed.hideColumn(column) + for offset, width in enumerate(self.FIXED_WIDTHS, start=10): + self.main.hideColumn(offset) + self.fixed.setColumnWidth(offset, width) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.main, 1) + layout.addWidget(self.fixed) + self.fixed_shadow = _FixedColumnShadow(self) + self.fixed_shadow.show() + self.empty_label = QLabel("暂无数据", self) + self.empty_label.setObjectName("DiagnosisTableEmpty") + self.empty_label.setProperty("stateKind", "empty") + self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_label.setWordWrap(True) + self.empty_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.empty_label.hide() + self.main.rows_replaced.connect(self._rows_changed) + self.main.hovered_row.connect(self._set_hover_row) + self.fixed.hovered_row.connect(self._set_hover_row) + self.model.selection_changed.connect(self._checked_changed) + self.main.horizontalHeader().sectionClicked.connect(self._header_clicked) + self._update_height() + + def set_rows(self, rows: Iterable[Any]) -> None: + previous_id = DiagnosisTableModel.record_id(self.main.current_data()) + self.model.set_rows(rows) + self.empty_label.setText("暂无数据") + self.empty_label.setProperty("stateKind", "empty") + self.empty_label.style().unpolish(self.empty_label) + self.empty_label.style().polish(self.empty_label) + self._rows_changed() + if previous_id > 0: + for index, record in enumerate(self.model.rows): + if DiagnosisTableModel.record_id(record) == previous_id: + self.main.selectRow(index) + break + + def selected_records(self) -> list[Any]: + return self.model.checked_records() + + def clear_checks(self) -> None: + self.model.clear_checks() + + def begin_loading(self) -> None: + """Hide stale empty/error copy while the table mask owns the status.""" + + self.empty_label.hide() + + def show_error(self, message: str = "列表加载失败,请重试") -> None: + """Expose a persistent, screenshotable initial-load error state.""" + + self.empty_label.setText(message) + self.empty_label.setProperty("stateKind", "error") + self.empty_label.style().unpolish(self.empty_label) + self.empty_label.style().polish(self.empty_label) + self.empty_label.show() + self._position_empty() + + def set_sort_direction(self, direction: str) -> None: + self.model.set_sort_direction(direction) + + def _rows_changed(self) -> None: + self._sync_row_heights() + self._install_fixed_widgets() + self.empty_label.setVisible(self.model.rowCount() == 0) + self._update_height() + self._position_empty() + self._position_fixed_shadow() + + def _sync_row_heights(self) -> None: + for row, record in enumerate(self.model.rows): + count = max(1, len(_appointments(record))) + channel = first_value( + record, + "latest_appointment_channel_text", + "latest_appointment_channel_source_desc", + default="", + ) + height = max(52, 12 + count * 42 + (18 if channel else 0)) + self.main.setRowHeight(row, height) + self.fixed.setRowHeight(row, height) + + def _install_fixed_widgets(self) -> None: + for row, record in enumerate(self.model.rows): + if self.action_policy.get("appointment_cancel", False): + overlay = _AppointmentActionOverlay(record, row, self.main) + if overlay.has_actions: + overlay.cancel_requested.connect( + lambda appointment_id, item=record: self.appointment_cancel_requested.emit( + item, appointment_id + ) + ) + overlay.row_requested.connect(self.main.selectRow) + self.main.setIndexWidget(self.model.index(row, 4), overlay) + else: + overlay.deleteLater() + self.fixed.setIndexWidget(self.model.index(row, 10), self._video_widget(record, row)) + self.fixed.setIndexWidget(self.model.index(row, 11), self._action_widget(record, row)) + + def _video_widget(self, record: Any, row: int) -> QWidget: + host = _FixedCell(row, self.fixed) + host.hovered_row.connect(self._set_hover_row) + layout = QHBoxLayout(host) + layout.setContentsMargins(3, 2, 3, 2) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + video_capable = self.action_policy.get("video_call", False) + if video_capable and _appointment_active(record): + button = QToolButton(host) + button.setText("进入视频问诊") + button.setProperty("rowLink", "primary") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setToolTip("进入医生端视频问诊(将使用摄像头和麦克风)") + button.setEnabled(_video_ids_complete(record)) + if not button.isEnabled(): + button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊") + button.clicked.connect( + lambda _checked=False, item=record: self.video_requested.emit(item) + ) + layout.addWidget(button) + else: + status = "—" + if video_capable: + status = {3: "已完成", 4: "已过号"}.get( + _appointment_status(record), "未挂号" if not _has_appointment(record) else "—" + ) + label = QLabel(display_text(status), host) + label.setProperty("fixedMuted", True) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setWordWrap(True) + layout.addWidget(label) + return host + + def _action_widget(self, record: Any, row: int) -> QWidget: + host = _FixedCell(row, self.fixed) + host.hovered_row.connect(self._set_hover_row) + layout = QHBoxLayout(host) + layout.setContentsMargins(4, 2, 4, 2) + layout.setSpacing(2) + if self.action_policy.get("view", False): + layout.addWidget(self._action_button("查看", "view", record, "primary")) + if self.action_policy.get("edit", False): + layout.addWidget(self._action_button("诊单", "edit", record, "primary")) + if self.action_policy.get("prescription", False): + layout.addWidget( + self._action_button( + _prescription_action_label(record, force_open=self.force_open_prescription), + "prescription", + record, + "primary", + ) + ) + if self.action_policy.get("appointment", False): + layout.addWidget(self._action_button("预约", "appointment", record, "success")) + if self.action_policy.get("edit", False): + layout.addWidget(self._action_button("补全身份证", "fill_id_card", record, "warning")) + more = QToolButton(host) + more.setText("更多") + more.setArrowType(Qt.ArrowType.DownArrow) + more.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + more.setProperty("rowLink", "muted") + more.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + menu = _DiagnosisActionMenu(more) + menu.setStyleSheet( + "QMenu{background:#FFFFFF;color:#303133;border:1px solid #E4E7ED;" + "border-radius:4px;padding:6px;font-size:13px;}" + "QMenu::item{min-width:118px;min-height:30px;padding:0 12px 0 10px;" + "border-radius:3px;}" + "QMenu::item:selected{color:#409EFF;background:#ECF5FF;}" + "QMenu::item:disabled{color:#C0C4CC;}" + "QMenu::separator{height:1px;background:#EBEEF5;margin:5px 8px;}" + ) + if self.action_policy.get("assign", False): + _add_menu_action( + menu, + "指派", + "user", + lambda _checked=False, item=record: self.action_requested.emit("assign", item), + ) + if _as_int(first_value(record, "assistant_id", "assistant", default=0)) > 0: + _add_menu_action( + menu, + "取消指派", + "remove", + lambda _checked=False, item=record: self.action_requested.emit( + "cancel_assign", item + ), + ) + if self.action_policy.get("video_qr", False) and _appointment_active(record): + _add_menu_action( + menu, + "视频二维码", + "picture", + lambda _checked=False, item=record: self.action_requested.emit("video_qr", item), + ) + if self.action_policy.get("confirm_qr", False) and _appointment_active(record): + _add_menu_action( + menu, + "二维码", + "picture", + lambda _checked=False, item=record: self.action_requested.emit("confirm_qr", item), + ) + if self.action_policy.get("appointment_cancel", False) and _single_cancellable_appointment( + record + ): + _add_menu_action( + menu, + "取消挂号", + "close", + lambda _checked=False, item=record: self.action_requested.emit( + "cancel_appointment", item + ), + ) + if self.action_policy.get("appointment_logs", False): + _add_menu_action( + menu, + "挂号日志", + "list", + lambda _checked=False, item=record: self.action_requested.emit( + "appointment_logs", item + ), + ) + if self.action_policy.get("create_order", False): + _add_menu_action( + menu, + "创建订单", + "document", + lambda _checked=False, item=record: self.action_requested.emit( + "create_order", item + ), + ) + if self.action_policy.get("delete", False): + if not menu.isEmpty(): + menu.addSeparator() + _add_menu_action( + menu, + "删除", + "delete", + lambda _checked=False, item=record: self.action_requested.emit("delete", item), + danger=True, + ) + more.setMenu(menu) + more.setVisible(not menu.isEmpty()) + if not menu.isEmpty(): + layout.addWidget(more) + layout.addStretch(1) + return host + + def _action_button(self, text: str, action: str, record: Any, semantic: str) -> QToolButton: + button = QToolButton(self.fixed) + button.setText(text) + button.setProperty("rowLink", semantic) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect( + lambda _checked=False, command=action, item=record: self.action_requested.emit( + command, item + ) + ) + return button + + def _checked_changed(self, count: int) -> None: + self.main.horizontalHeader().viewport().update() + self.checked_changed.emit(count) + + def _set_hover_row(self, row: int) -> None: + self.model.set_hover_row(row) + self.main.viewport().update() + self.fixed.viewport().update() + + def _header_clicked(self, section: int) -> None: + if section != 9: + return + current = self.model._sort_direction + direction = "asc" if current == "desc" else "desc" + self.set_sort_direction(direction) + self.sort_unserved_requested.emit(direction) + + def _update_height(self) -> None: + rows_height = sum(self.main.rowHeight(row) for row in range(self.model.rowCount())) + body_height = rows_height if rows_height else 60 + horizontal = self.main.horizontalScrollBar().sizeHint().height() + self.setFixedHeight(39 + body_height + horizontal + 2) + + def _position_empty(self) -> None: + self.empty_label.setGeometry(0, 39, self.width(), 60) + self.empty_label.raise_() + + def _position_fixed_shadow(self) -> None: + width = _FixedColumnShadow.WIDTH + self.fixed_shadow.setGeometry( + max(0, self.width() - self.fixed.width() - width), + 0, + width, + self.height(), + ) + self.fixed_shadow.raise_() + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self._position_empty() + self._position_fixed_shadow() + + +class DiagnosisPager(QWidget): + """Full admin-style pager with size choice, five pages and a jumper.""" + + page_changed = Signal(int) + page_size_changed = Signal(int) + + def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisPager") + self.page = 1 + self.page_size = page_size + self.total = 0 + self._setting = False + self._page_buttons: list[QToolButton] = [] + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 10, 16, 16) + layout.setSpacing(8) + layout.addStretch(1) + self.summary = QLabel("共 0 条") + self.summary.setProperty("pagerMuted", True) + layout.addWidget(self.summary) + self.size_combo = QComboBox() + self.size_combo.setObjectName("DiagnosisPageSize") + for value in (15, 20, 30, 40): + self.size_combo.addItem(f"{value} 条/页", value) + self.size_combo.setCurrentIndex(max(0, self.size_combo.findData(page_size))) + self.size_combo.currentIndexChanged.connect(self._size_selected) + layout.addWidget(self.size_combo) + self.previous = QToolButton() + self.previous.setText("‹") + self.previous.setProperty("pagerButton", True) + self.previous.clicked.connect(lambda: self._request(self.page - 1)) + layout.addWidget(self.previous) + self.pages_host = QWidget() + pages_layout = QHBoxLayout(self.pages_host) + pages_layout.setContentsMargins(0, 0, 0, 0) + pages_layout.setSpacing(2) + for _ in range(5): + button = QToolButton() + button.setProperty("pagerButton", True) + button.clicked.connect( + lambda _checked=False, item=button: self._request(item.property("page")) + ) + pages_layout.addWidget(button) + self._page_buttons.append(button) + layout.addWidget(self.pages_host) + self.next = QToolButton() + self.next.setText("›") + self.next.setProperty("pagerButton", True) + self.next.clicked.connect(lambda: self._request(self.page + 1)) + layout.addWidget(self.next) + self.jump_label = QLabel("前往") + self.jump_label.setProperty("pagerMuted", True) + layout.addWidget(self.jump_label) + self.jumper = QSpinBox() + self.jumper.setObjectName("DiagnosisPageJumper") + self.jumper.setRange(1, 1) + self.jumper.setFixedWidth(58) + self.jumper.editingFinished.connect(lambda: self._request(self.jumper.value())) + layout.addWidget(self.jumper) + self.jump_suffix = QLabel("页") + self.jump_suffix.setProperty("pagerMuted", True) + layout.addWidget(self.jump_suffix) + self.update_state(1, 0) + + @property + def page_count(self) -> int: + return max(1, ceil(self.total / self.page_size)) + + def update_state(self, page: int, total: int) -> None: + self._setting = True + self.total = max(0, _as_int(total)) + self.page = min(max(1, _as_int(page, 1)), self.page_count) + self.summary.setText(f"共 {self.total} 条") + self.previous.setEnabled(self.page > 1) + self.next.setEnabled(self.page < self.page_count) + self.jumper.setRange(1, self.page_count) + self.jumper.setValue(self.page) + start = max(1, min(self.page - 2, self.page_count - 4)) + visible_pages = range(start, min(self.page_count, start + 4) + 1) + for button, number in zip(self._page_buttons, visible_pages, strict=False): + button.setText(str(number)) + button.setProperty("page", number) + button.setProperty("active", number == self.page) + button.style().unpolish(button) + button.style().polish(button) + button.show() + count = len(visible_pages) + for button in self._page_buttons[count:]: + button.hide() + self._setting = False + + def _request(self, page: Any) -> None: + requested = _as_int(page, self.page) + if 1 <= requested <= self.page_count and requested != self.page: + self.page_changed.emit(requested) + + def _size_selected(self, _index: int) -> None: + if self._setting: + return + size = _as_int(self.size_combo.currentData(), self.page_size) + if size != self.page_size: + self.page_size = size + self.page_size_changed.emit(size) + + +class DiagnosisLoadingOverlay(QWidget): + """Table-local translucent mask with a 42-pixel primary spinner.""" + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisLoadingOverlay") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground) + self._angle = 0 + self._timer = QTimer(self) + self._timer.setInterval(80) + self._timer.timeout.connect(self._advance) + parent.installEventFilter(self) + self.hide() + + def start(self) -> None: + self.setGeometry(self.parentWidget().rect()) + self.raise_() + self.show() + self._timer.start() + + def stop(self) -> None: + self._timer.stop() + self.hide() + + def _advance(self) -> None: + self._angle = (self._angle + 18) % 360 + self.update() + + def paintEvent(self, event: QPaintEvent) -> None: + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.fillRect(self.rect(), QColor(255, 255, 255, 128)) + visible = self.visibleRegion().boundingRect() + center = ( + visible.center() + if visible.isValid() and not visible.isEmpty() + else self.rect().center() + ) + spinner = QRect(center.x() - 21, center.y() - 21, 42, 42) + painter.setPen(QPen(PRIMARY, 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawArc(spinner, (90 - self._angle) * 16, -255 * 16) + super().paintEvent(event) + + def resizeEvent(self, event: QResizeEvent) -> None: + if self.parentWidget() is not None: + self.setGeometry(self.parentWidget().rect()) + super().resizeEvent(event) + + def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt virtual + if watched is self.parentWidget() and event.type() == QEvent.Type.Resize: + self.setGeometry(self.parentWidget().rect()) + return super().eventFilter(watched, event) + + +DIAGNOSIS_INDEX_QSS = """ +#DiagnosisIndex { + background: #F6F6F6; + color: #333333; + font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + font-size: 14px; +} +#DiagnosisIndex QFrame#DiagnosisFilterCard, +#DiagnosisIndex QFrame#DiagnosisListCard { + background: #FFFFFF; + border: 0; + border-radius: 4px; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"] { + min-height: 18px; + padding: 6px 12px; + border: 1px solid transparent; + border-radius: 6px; + background: #F8F8F8; + color: #666666; + font-size: 14px; + font-weight: 500; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover { + background: #F0F2F5; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"]:focus { + border-color: #4A5DFF; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="info"] { + color: #909399; + background: #F4F4F5; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="info"]:hover { + background: #E9E9EB; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="success"] { + color: #67C23A; + background: #F0F9EB; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="success"]:hover { + background: #E1F3D8; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"] { + color: #E6A23C; + background: #FDF6EC; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"]:hover { + background: #FAECD8; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="primary"] { + color: #FFFFFF; + background: #4A5DFF; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="info"] { + color: #FFFFFF; + background: #909399; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="success"] { + color: #FFFFFF; + background: #67C23A; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="warning"] { + color: #FFFFFF; + background: #E6A23C; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"][small="true"] { + padding: 4px 10px; + border-radius: 4px; + font-size: 13px; + color: #999999; + background: transparent; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"][small="true"]:hover { + color: #4A5DFF; + background: #EDEFFF; +} +#DiagnosisIndex QToolButton[diagnosisChip="true"][small="true"]:checked { + color: #4A5DFF; + background: #EDEFFF; +} +#DiagnosisIndex QLabel[filterGroup="true"] { + color: #999999; + font-size: 13px; +} +#DiagnosisIndex QFrame#DiagnosisQuickFilters, +#DiagnosisIndex QFrame#DiagnosisAdvancedFilters { + border: 0; + border-top: 1px solid #EBEEF5; + background: transparent; +} +#DiagnosisIndex QFrame#DiagnosisFilterDivider { + min-width: 1px; + max-width: 1px; + min-height: 14px; + max-height: 14px; + margin: 0 4px; + background: #EBEEF5; + border: 0; +} +#DiagnosisIndex QFrame#DiagnosisDateRange { + min-height: 30px; + max-height: 30px; + border: 1px solid #DCDFE6; + border-radius: 4px; + background: #FFFFFF; +} +#DiagnosisIndex QFrame#DiagnosisDateRange QDateEdit[diagnosisRangePart="true"] { + min-height: 28px; + max-height: 28px; + border: 0; + padding: 0 5px; +} +#DiagnosisIndex QLabel[diagnosisRangeSeparator="true"] { + color: #999999; +} +#DiagnosisIndex QFrame#DiagnosisAdvancedFilters { + border-top: 1px dashed #DCDFE6; +} +#DiagnosisIndex QToolButton#DiagnosisMoreFilter { + border: 0; + background: transparent; + color: #4A5DFF; + padding: 4px 6px; + font-size: 13px; +} +#DiagnosisIndex QLineEdit, +#DiagnosisIndex QComboBox, +#DiagnosisIndex QDateEdit, +#DiagnosisIndex QSpinBox { + min-height: 30px; + border: 1px solid #DCDFE6; + border-radius: 4px; + padding: 0 8px; + background: #FFFFFF; + color: #333333; + selection-background-color: #4A5DFF; +} +#DiagnosisIndex QLineEdit:focus, +#DiagnosisIndex QComboBox:focus, +#DiagnosisIndex QDateEdit:focus, +#DiagnosisIndex QSpinBox:focus { + border-color: #4A5DFF; +} +#DiagnosisIndex QComboBox[smallControl="true"], +#DiagnosisIndex QPushButton[smallControl="true"] { + min-height: 22px; + max-height: 22px; +} +#DiagnosisIndex QPushButton { + min-height: 30px; + padding: 0 12px; + border-radius: 4px; + border: 1px solid #DCDFE6; + background: #FFFFFF; + color: #666666; +} +#DiagnosisIndex QPushButton[variant="primary"] { + border-color: #4A5DFF; + background: #4A5DFF; + color: #FFFFFF; +} +#DiagnosisIndex QPushButton[variant="success"] { + border-color: #67C23A; + background: #67C23A; + color: #FFFFFF; +} +#DiagnosisIndex QPushButton[variant="warning"] { + border-color: #E6A23C; + color: #E6A23C; +} +#DiagnosisIndex QPushButton[variant="danger"] { + border-color: #F56C6C; + color: #F56C6C; +} +#DiagnosisIndex QFrame#DiagnosisListToolbar { + border: 0; + border-bottom: 1px solid #EBEEF5; + background: #FFFFFF; +} +#DiagnosisIndex QTableView { + border: 0; + background: #FFFFFF; + selection-background-color: transparent; + outline: 0; +} +#DiagnosisIndex QTableView:focus { + border: 1px solid #4A5DFF; +} +#DiagnosisIndex QTableView#DiagnosisFixedTable { + border-left: 1px solid #DCDFE6; +} +#DiagnosisIndex QHeaderView::section { + background: #F8F8F8; + color: #666666; + border: 0; + border-bottom: 1px solid #EBEEF5; + padding: 0 8px; + font-size: 13px; + font-weight: 600; +} +#DiagnosisIndex QWidget#DiagnosisFixedCell { + background: transparent; +} +#DiagnosisIndex QToolButton[rowLink] { + border: 0; + background: transparent; + padding: 2px 3px; + font-size: 12px; +} +#DiagnosisIndex QToolButton[rowLink]:hover, +#DiagnosisIndex QToolButton[rowLink]:focus { + background: #EDEFFF; + border-radius: 3px; +} +#DiagnosisIndex QToolButton[rowLink]:disabled { + color: #C0C4CC; + background: transparent; +} +#DiagnosisIndex QToolButton[rowLink="primary"] { color: #4A5DFF; } +#DiagnosisIndex QToolButton[rowLink="success"] { color: #67C23A; } +#DiagnosisIndex QToolButton[rowLink="warning"] { color: #E6A23C; } +#DiagnosisIndex QToolButton[rowLink="muted"] { color: #909399; } +#DiagnosisIndex QToolButton[appointmentCancel="true"] { + border: 0; + border-radius: 3px; + padding: 0 3px; + background: #FEF0F0; + color: #F56C6C; + font-size: 11px; +} +#DiagnosisIndex QToolButton[appointmentCancel="true"]:hover, +#DiagnosisIndex QToolButton[appointmentCancel="true"]:focus { + background: #FDE2E2; + color: #D73737; +} +#DiagnosisIndex QLabel[fixedMuted="true"], +#DiagnosisIndex QLabel[pagerMuted="true"] { + color: #909399; + font-size: 13px; +} +#DiagnosisIndex QLabel#DiagnosisTableEmpty { + color: #909399; + background: rgba(255, 255, 255, 0.94); + font-size: 14px; +} +#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] { + color: #F56C6C; + background: rgba(255, 255, 255, 0.96); +} +#DiagnosisIndex QToolButton[pagerButton="true"] { + min-width: 32px; + min-height: 32px; + max-height: 32px; + border: 0; + border-radius: 2px; + background: transparent; + color: #333333; + font-size: 14px; +} +#DiagnosisIndex QToolButton[pagerButton="true"]:hover, +#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] { + color: #4A5DFF; + font-weight: 600; +} +#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #C0C4CC; } +#DiagnosisIndex QComboBox#DiagnosisPageSize, +#DiagnosisIndex QSpinBox#DiagnosisPageJumper { + min-height: 30px; + max-height: 30px; +} +#DiagnosisIndex QScrollBar:horizontal { + height: 12px; + background: #F5F7FA; +} +#DiagnosisIndex QScrollBar::handle:horizontal { + min-width: 32px; + border-radius: 5px; + background: #C0C4CC; +} +""" + + +__all__ = [ + "DIAGNOSIS_INDEX_QSS", + "DiagnosisChip", + "DiagnosisLoadingOverlay", + "DiagnosisPager", + "DiagnosisTableHost", + "DiagnosisTableModel", + "FlowLayout", + "FlowWidget", +] diff --git a/app/src/doctor_workstation/ui/diagnosis_media.py b/app/src/doctor_workstation/ui/diagnosis_media.py new file mode 100644 index 000000000..e7e4d48fc --- /dev/null +++ b/app/src/doctor_workstation/ui/diagnosis_media.py @@ -0,0 +1,628 @@ +"""Safe in-application replay players for diagnosis call recordings. + +The web diagnosis page keeps the preferred recording in the table and lists +the remaining sources underneath it. This module mirrors that contract with +QtMultimedia while retaining the older standalone dialog as a codec fallback. +""" + +from __future__ import annotations + +import re +import weakref +from collections.abc import Sequence +from typing import Any + +from PySide6.QtCore import Qt, QUrl, Signal +from PySide6.QtGui import QCloseEvent, QDesktopServices +from PySide6.QtWidgets import ( + QDialog, + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QSlider, + QStackedLayout, + QVBoxLayout, + QWidget, +) + +try: + from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer + from PySide6.QtMultimediaWidgets import QVideoWidget + + MULTIMEDIA_AVAILABLE = True +except ImportError: # pragma: no cover - exercised only by minimal PySide builds + QAudioOutput = QMediaPlayer = QVideoWidget = None # type: ignore[assignment,misc] + MULTIMEDIA_AVAILABLE = False + + +def safe_http_url(target: str) -> QUrl | None: + url = QUrl(str(target).strip()) + if not url.isValid() or url.scheme().lower() not in {"http", "https"}: + return None + if not url.host(): + return None + return url + + +def normalize_recording_urls(urls: Sequence[Any] | Any) -> list[str]: + """Trim and de-duplicate backend recording URLs without inventing values.""" + + if isinstance(urls, (str, bytes, bytearray)) or not isinstance(urls, Sequence): + candidates: Sequence[Any] = [urls] + else: + candidates = urls + normalized: list[str] = [] + for candidate in candidates: + value = str(candidate or "").strip() + if value and value not in normalized: + normalized.append(value) + return normalized + + +_INLINE_RECORDING_PATTERN = re.compile( + r"\.(?:mp4|webm|ogg|mov|mkv|m4v|m3u8)(?:\?|#|$)", re.IGNORECASE +) + + +def should_inline_recording(target: str) -> bool: + """Return whether a safe remote source is suitable for native inline replay.""" + + return safe_http_url(target) is not None and bool( + _INLINE_RECORDING_PATTERN.search(str(target).strip()) + ) + + +def preferred_recording_url(urls: Sequence[Any] | Any) -> str | None: + """Choose the same preferred source order as the diagnosis web client.""" + + normalized = [url for url in normalize_recording_urls(urls) if safe_http_url(url)] + if not normalized: + return None + priorities = ( + lambda url: bool(re.search(r"\.mp4(?:\?|#|$)", url, re.IGNORECASE)), + lambda url: bool( + re.search(r"\.m3u8(?:\?|#|$)", url, re.IGNORECASE) + and re.search(r"vod-qcloud\.com", url, re.IGNORECASE) + ), + lambda url: bool( + re.search(r"\.m3u8(?:\?|#|$)", url, re.IGNORECASE) + and re.search(r"\.cos\.[^/]+\.myqcloud\.com", url, re.IGNORECASE) + ), + lambda url: bool(re.search(r"\.m3u8(?:\?|#|$)", url, re.IGNORECASE)), + ) + for matches in priorities: + preferred = next((url for url in normalized if matches(url)), None) + if preferred: + return preferred + return normalized[0] + + +def alternate_recording_label(target: str, index: int) -> str: + """Return the source label used by the web playback block.""" + + value = str(target or "").strip() + ordinal = index + 1 + if re.search(r"\.mp4(?:\?|#|$)", value, re.IGNORECASE): + return f"MP4 {ordinal}" + if re.search(r"vod-qcloud\.com", value, re.IGNORECASE) and re.search( + r"\.m3u8", value, re.IGNORECASE + ): + return f"点播 {ordinal}" + if re.search(r"\.cos\.[^/]+\.myqcloud\.com", value, re.IGNORECASE) and re.search( + r"\.m3u8", value, re.IGNORECASE + ): + return f"COS HLS {ordinal}" + if re.search(r"\.m3u8", value, re.IGNORECASE): + return f"HLS {ordinal}" + return f"链接 {ordinal}" + + +def open_safe_http_url(target: str) -> bool: + """Open only an absolute HTTP(S) target with the operating system.""" + + url = safe_http_url(target) + return bool(url is not None and QDesktopServices.openUrl(url)) + + +_INLINE_PLAYER_QSS = """ +QWidget#DiagnosisInlineRecordingPlayer { + background: #111827; + border: 1px solid #1F2937; + border-radius: 6px; +} +QFrame#DiagnosisInlineRecordingSurface { + background: #05070A; + border: 0; + border-radius: 5px 5px 0 0; +} +QLabel#DiagnosisInlineRecordingPlaceholder { + color: #D1D5DB; + font-size: 12px; + line-height: 1.4; +} +QLabel#DiagnosisInlineRecordingTime { + color: #CBD5E1; + font-size: 11px; + font-variant-numeric: tabular-nums; +} +QPushButton[recordingControl="true"] { + min-height: 24px; + max-height: 24px; + padding: 0 8px; + color: #E5E7EB; + background: #1F2937; + border: 1px solid #374151; + border-radius: 4px; + font-size: 11px; +} +QPushButton[recordingControl="true"]:hover, +QPushButton[recordingControl="true"]:focus { + color: #FFFFFF; + background: #2563EB; + border-color: #2563EB; +} +QPushButton[recordingControl="true"]:disabled { color: #6B7280; } +QSlider::groove:horizontal { height: 3px; background: #4B5563; border-radius: 1px; } +QSlider::sub-page:horizontal { background: #3B82F6; border-radius: 1px; } +QSlider::handle:horizontal { + width: 10px; + margin: -4px 0; + background: #F8FAFC; + border: 1px solid #94A3B8; + border-radius: 5px; +} +""" + + +class InlineRecordingPlayer(QWidget): + """A lazy, owner-safe QtMultimedia player capped at 180 px.""" + + fallback_requested = Signal(str) + + def __init__( + self, + target: str, + *, + render_owner: object | None = None, + owner_generation: int | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.target = str(target).strip() + self.url = safe_http_url(self.target) + self._owner_ref = weakref.ref(render_owner) if render_owner is not None else None + self._owner_generation = owner_generation + self._source_attached = False + self.player = None + self.audio_output = None + self.setObjectName("DiagnosisInlineRecordingPlayer") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setProperty("maximumPlaybackHeight", 180) + self.setMinimumHeight(158) + self.setMaximumHeight(180) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setStyleSheet(_INLINE_PLAYER_QSS) + + root = QVBoxLayout(self) + root.setContentsMargins(1, 1, 1, 5) + root.setSpacing(4) + surface = QFrame() + surface.setObjectName("DiagnosisInlineRecordingSurface") + surface.setMinimumHeight(122) + surface.setMaximumHeight(142) + self._surface_stack = QStackedLayout(surface) + self._surface_stack.setContentsMargins(0, 0, 0, 0) + self._surface_stack.setStackingMode(QStackedLayout.StackingMode.StackOne) + + if MULTIMEDIA_AVAILABLE: + self.video = QVideoWidget() + self.video.setObjectName("DiagnosisInlineRecordingVideo") + self._surface_stack.addWidget(self.video) + self.audio_output = QAudioOutput(self) + self.player = QMediaPlayer(self) + self.player.setAudioOutput(self.audio_output) + self.player.setVideoOutput(self.video) + self.player.positionChanged.connect(self._position_changed) + self.player.durationChanged.connect(self._duration_changed) + self.player.playbackStateChanged.connect(self._state_changed) + self.player.errorOccurred.connect(self._player_error) + else: + self.video = None + + self.placeholder = QLabel( + "预览待加载\n点击播放开始加载" + if MULTIMEDIA_AVAILABLE + else "当前环境未包含 QtMultimedia\n可安全外部打开" + ) + self.placeholder.setObjectName("DiagnosisInlineRecordingPlaceholder") + self.placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.placeholder.setWordWrap(True) + self._surface_stack.addWidget(self.placeholder) + self._surface_stack.setCurrentWidget(self.placeholder) + root.addWidget(surface, 1) + + controls = QHBoxLayout() + controls.setContentsMargins(5, 0, 5, 0) + controls.setSpacing(5) + self.play_button = QPushButton("播放") + self.play_button.setObjectName("DiagnosisInlineRecordingPlay") + self.play_button.setProperty("recordingControl", True) + self.play_button.clicked.connect(self._toggle_playback) + controls.addWidget(self.play_button) + self.position = QSlider(Qt.Orientation.Horizontal) + self.position.setObjectName("DiagnosisInlineRecordingPosition") + self.position.setRange(0, 0) + self.position.sliderMoved.connect(self._seek) + controls.addWidget(self.position, 1) + self.time_label = QLabel("00:00 / 00:00") + self.time_label.setObjectName("DiagnosisInlineRecordingTime") + controls.addWidget(self.time_label) + external = QPushButton("外部打开") + external.setObjectName("DiagnosisInlineRecordingExternal") + external.setProperty("recordingControl", True) + external.setToolTip("使用系统默认程序安全打开此 HTTP(S) 回放地址") + external.clicked.connect(self._open_external) + controls.addWidget(external) + fallback = QPushButton("独立窗口") + fallback.setObjectName("DiagnosisInlineRecordingFallback") + fallback.setProperty("recordingControl", True) + fallback.setToolTip("系统解码器兼容性不佳时在独立播放器中重试") + fallback.clicked.connect(lambda: self.fallback_requested.emit(self.target)) + controls.addWidget(fallback) + root.addLayout(controls) + + valid = self.url is not None + self.play_button.setEnabled(valid and self.player is not None) + external.setEnabled(valid) + fallback.setEnabled(valid) + if not valid: + self.placeholder.setText("回放地址无效\n仅允许包含主机名的 HTTP(S) 地址") + + def _owner_is_current(self) -> bool: + if self._owner_ref is None or self._owner_generation is None: + return True + owner = self._owner_ref() + if owner is None: + return False + generations = getattr(owner, "_tab_generations", None) + if isinstance(generations, dict) and "video" in generations: + return generations["video"] == self._owner_generation + current = getattr(owner, "_media_generation", self._owner_generation) + return current == self._owner_generation + + def _attach_source(self) -> bool: + if self.player is None or self.url is None: + return False + if not self._owner_is_current(): + self.play_button.setEnabled(False) + self.placeholder.setText("该回放列表已刷新\n请在最新记录中播放") + self._surface_stack.setCurrentWidget(self.placeholder) + return False + if not self._source_attached: + self.player.setSource(self.url) + self._source_attached = True + self.placeholder.setText("正在加载回放…") + return True + + def _toggle_playback(self) -> None: + if not self._attach_source() or self.player is None: + return + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + else: + self.player.play() + + def _state_changed(self, state: object) -> None: + if self.player is None: + return + playing = state == QMediaPlayer.PlaybackState.PlayingState + self.play_button.setText("暂停" if playing else "播放") + if playing and self.video is not None: + self._surface_stack.setCurrentWidget(self.video) + + def _player_error(self, _error: object, message: str = "") -> None: + detail = message.strip() if message else "系统媒体后端无法播放该格式" + self.placeholder.setText(f"{detail}\n可安全外部打开或使用独立窗口") + self._surface_stack.setCurrentWidget(self.placeholder) + + def _duration_changed(self, duration: int) -> None: + self.position.setRange(0, max(0, duration)) + current = self.player.position() if self.player is not None else 0 + self.time_label.setText(f"{_clock(current)} / {_clock(duration)}") + + def _position_changed(self, position: int) -> None: + if not self.position.isSliderDown(): + self.position.setValue(position) + duration = self.player.duration() if self.player is not None else 0 + self.time_label.setText(f"{_clock(position)} / {_clock(duration)}") + + def _seek(self, position: int) -> None: + if self.player is not None and self._owner_is_current(): + self.player.setPosition(position) + + def _open_external(self) -> None: + if not open_safe_http_url(self.target): + self.placeholder.setText("系统未能打开该安全外部链接") + self._surface_stack.setCurrentWidget(self.placeholder) + + def stop(self) -> None: + if self.player is not None: + self.player.stop() + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt virtual + self.stop() + super().closeEvent(event) + + +class RecordingPlaybackCell(QWidget): + """One call-record playback cell: preferred inline, alternates underneath.""" + + fallback_requested = Signal(str) + + def __init__( + self, + urls: Sequence[Any] | Any, + *, + record_id: int, + render_owner: object | None = None, + owner_generation: int | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisRecordingPlaybackCell") + self.setProperty("callRecordId", record_id) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.urls = normalize_recording_urls(urls) + self.preferred = preferred_recording_url(self.urls) + self.inline_player: InlineRecordingPlayer | None = None + + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(6) + if self.preferred and should_inline_recording(self.preferred): + self.inline_player = InlineRecordingPlayer( + self.preferred, + render_owner=render_owner, + owner_generation=owner_generation, + parent=self, + ) + self.inline_player.fallback_requested.connect(self.fallback_requested.emit) + layout.addWidget(self.inline_player) + elif self.preferred: + primary = QPushButton("安全打开回放") + primary.setObjectName("DiagnosisRecordingPrimaryLink") + primary.setProperty("variant", "link") + primary.setToolTip(self.preferred) + primary.clicked.connect( + lambda _checked=False, target=self.preferred: self._open_external(target) + ) + layout.addWidget(primary, 0, Qt.AlignmentFlag.AlignLeft) + elif self.urls: + invalid = QLabel("回放地址无效(仅支持 HTTP(S))") + invalid.setObjectName("DiagnosisUnsupportedState") + invalid.setWordWrap(True) + layout.addWidget(invalid) + else: + empty = QLabel("暂无录制回放") + empty.setObjectName("DiagnosisEmptyState") + layout.addWidget(empty) + + alternates = [url for url in self.urls if url != self.preferred] + if alternates: + separator = QFrame() + separator.setObjectName("DiagnosisRecordingAlternateSeparator") + separator.setFrameShape(QFrame.Shape.HLine) + separator.setStyleSheet("color:#E5E7EB;") + layout.addWidget(separator) + label = QLabel("备用地址") + label.setObjectName("DiagnosisRecordingAlternateLabel") + label.setStyleSheet("color:#909399; font-size:12px;") + layout.addWidget(label) + links = QHBoxLayout() + links.setContentsMargins(0, 0, 0, 0) + links.setSpacing(10) + for index, target in enumerate(alternates): + button = QPushButton(alternate_recording_label(target, index)) + button.setObjectName("DiagnosisRecordingAlternateLink") + button.setProperty("variant", "link") + button.setProperty("recordingUrl", target) + button.setToolTip(target) + button.setEnabled(safe_http_url(target) is not None) + button.clicked.connect( + lambda _checked=False, selected=target: self._open_external(selected) + ) + links.addWidget(button) + links.addStretch(1) + layout.addLayout(links) + self.link_status = QLabel("") + self.link_status.setObjectName("DiagnosisRecordingLinkStatus") + self.link_status.setStyleSheet("color:#DC2626; font-size:11px;") + self.link_status.setWordWrap(True) + self.link_status.hide() + layout.addWidget(self.link_status) + + def required_table_row_height(self) -> int: + """Return the real height a table row needs to avoid clipping this cell.""" + + cell_layout = self.layout() + if cell_layout is not None: + cell_layout.activate() + # The diagnosis table style gives every item 7 px of vertical padding, + # and QTableWidget also reserves a grid line. Cell-widget geometry is + # inset by those pixels, so the section must include that allowance in + # addition to the player's own layout hint. + content_height = max(self.sizeHint().height(), self.minimumSizeHint().height()) + return content_height + 16 + + def _open_external(self, target: str) -> None: + if open_safe_http_url(target): + self.link_status.hide() + return + self.link_status.setText("系统未能打开该安全外部链接。") + self.link_status.show() + + def stop(self) -> None: + if self.inline_player is not None: + self.inline_player.stop() + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt virtual + self.stop() + super().closeEvent(event) + + +class RecordingPlayerDialog(QDialog): + """Native, cross-platform replay window with a safe browser fallback.""" + + def __init__(self, target: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.target = str(target).strip() + self.url = safe_http_url(self.target) + self.setObjectName("DiagnosisRecordingPlayer") + self.setWindowTitle("视频回放") + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.resize(820, 560) + + root = QVBoxLayout(self) + root.setContentsMargins(18, 16, 18, 16) + root.setSpacing(12) + heading = QLabel("视频回放") + heading.setObjectName("DiagnosisDialogHeading") + root.addWidget(heading) + self.status = QLabel("点击播放,在工作站内查看服务端回放。") + self.status.setObjectName("DiagnosisDialogGuidance") + self.status.setWordWrap(True) + root.addWidget(self.status) + + self.player = None + self.audio_output = None + if MULTIMEDIA_AVAILABLE: + surface = QFrame() + surface.setObjectName("DiagnosisRecordingSurface") + surface.setMinimumHeight(360) + surface_stack = QStackedLayout(surface) + surface_stack.setContentsMargins(1, 1, 1, 1) + surface_stack.setStackingMode(QStackedLayout.StackingMode.StackOne) + self.video = QVideoWidget() + surface_stack.addWidget(self.video) + self.placeholder = QLabel("应用内播放器已就绪\n点击下方“播放”开始查看") + self.placeholder.setObjectName("DiagnosisRecordingPlaceholder") + self.placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + surface_stack.addWidget(self.placeholder) + surface_stack.setCurrentWidget(self.placeholder) + self._surface_stack = surface_stack + root.addWidget(surface, 1) + self.audio_output = QAudioOutput(self) + self.player = QMediaPlayer(self) + self.player.setAudioOutput(self.audio_output) + self.player.setVideoOutput(self.video) + self.player.positionChanged.connect(self._position_changed) + self.player.durationChanged.connect(self._duration_changed) + self.player.playbackStateChanged.connect(self._state_changed) + self.player.errorOccurred.connect(self._player_error) + if self.url is not None: + self.player.setSource(self.url) + else: + unavailable = QLabel("当前 PySide6 构建未包含 QtMultimedia,仍可安全外部打开。") + unavailable.setObjectName("DiagnosisUnsupportedState") + unavailable.setAlignment(Qt.AlignmentFlag.AlignCenter) + unavailable.setMinimumHeight(320) + root.addWidget(unavailable, 1) + + controls = QHBoxLayout() + self.play_button = QPushButton("播放") + self.play_button.setProperty("variant", "primary") + self.play_button.clicked.connect(self._toggle_playback) + controls.addWidget(self.play_button) + self.position = QSlider(Qt.Orientation.Horizontal) + self.position.setRange(0, 0) + self.position.sliderMoved.connect(self._seek) + controls.addWidget(self.position, 1) + self.time_label = QLabel("00:00 / 00:00") + self.time_label.setObjectName("DiagnosisRecordingTime") + controls.addWidget(self.time_label) + external = QPushButton("安全外部打开") + external.setProperty("variant", "ghost") + external.clicked.connect(self._open_external) + controls.addWidget(external) + close = QPushButton("关闭") + close.setProperty("variant", "secondary") + close.clicked.connect(self.close) + controls.addWidget(close) + root.addLayout(controls) + + valid = self.url is not None + self.play_button.setEnabled(valid and self.player is not None) + external.setEnabled(valid) + if not valid: + self.status.setText("回放地址无效:仅允许包含主机名的 HTTP(S) 地址。") + self.status.setProperty("kind", "danger") + + def _toggle_playback(self) -> None: + if self.player is None or self.url is None: + return + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + else: + self.player.play() + + def _state_changed(self, state: object) -> None: + if self.player is None: + return + playing = state == QMediaPlayer.PlaybackState.PlayingState + self.play_button.setText("暂停" if playing else "播放") + if playing: + self._surface_stack.setCurrentWidget(self.video) + self.status.setText("正在应用内播放;如格式不受系统解码器支持,可安全外部打开。") + + def _player_error(self, _error: object, message: str = "") -> None: + self.status.setText( + (message.strip() if message else "系统媒体后端无法播放该格式。") + + " 可使用右侧按钮安全外部打开。" + ) + self.status.setProperty("kind", "warning") + + def _duration_changed(self, duration: int) -> None: + self.position.setRange(0, max(0, duration)) + current = self.player.position() if self.player is not None else 0 + self.time_label.setText(f"{_clock(current)} / {_clock(duration)}") + + def _position_changed(self, position: int) -> None: + if not self.position.isSliderDown(): + self.position.setValue(position) + duration = self.player.duration() if self.player is not None else 0 + self.time_label.setText(f"{_clock(position)} / {_clock(duration)}") + + def _seek(self, position: int) -> None: + if self.player is not None: + self.player.setPosition(position) + + def _open_external(self) -> None: + if not open_safe_http_url(self.target): + self.status.setText("系统未能打开该安全外部链接。") + self.status.setProperty("kind", "danger") + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt virtual + if self.player is not None: + self.player.stop() + super().closeEvent(event) + + +def _clock(milliseconds: int) -> str: + seconds = max(0, int(milliseconds) // 1000) + return f"{seconds // 60:02d}:{seconds % 60:02d}" + + +__all__ = [ + "MULTIMEDIA_AVAILABLE", + "InlineRecordingPlayer", + "RecordingPlaybackCell", + "RecordingPlayerDialog", + "alternate_recording_label", + "normalize_recording_urls", + "open_safe_http_url", + "preferred_recording_url", + "safe_http_url", + "should_inline_recording", +] diff --git a/app/src/doctor_workstation/ui/dialogs/diagnosis.py b/app/src/doctor_workstation/ui/dialogs/diagnosis.py index b5f7747b9..8b0419752 100644 --- a/app/src/doctor_workstation/ui/dialogs/diagnosis.py +++ b/app/src/doctor_workstation/ui/dialogs/diagnosis.py @@ -4,43 +4,83 @@ from __future__ import annotations import re from collections.abc import Mapping, Sequence +from contextlib import suppress from typing import Any -from PySide6.QtCore import Qt, Signal +from PySide6.QtCore import QDate, QDateTime, QEvent, QPoint, QRect, Qt, QTimer, QUrl, Signal +from PySide6.QtGui import QColor, QDesktopServices from PySide6.QtWidgets import ( + QAbstractItemView, + QBoxLayout, QDialog, - QDialogButtonBox, - QFormLayout, + QFileDialog, QFrame, + QGraphicsDropShadowEffect, QGridLayout, QHBoxLayout, + QInputDialog, QLabel, - QPlainTextEdit, + QMessageBox, + QPushButton, QScrollArea, + QSizePolicy, + QSpinBox, + QStackedLayout, QTableWidget, QTableWidgetItem, - QTabWidget, QVBoxLayout, QWidget, ) +from ..diagnosis_drawer import ( + DIAGNOSIS_QSS, + CaseGrid, + ChatPanel, + DailyRecordPanel, + DiagnosisChoiceButtons, + DiagnosisComboBox, + DiagnosisDateEdit, + DiagnosisLineEdit, + DiagnosisNumberEdit, + DiagnosisSwitch, + DiagnosisTabWidget, + DiagnosisTextEdit, + LoadingOverlay, + MessageStrip, + NotesTimeline, + RecordTable, + SaveStateButton, + readonly_card, + repolish, + section_heading, + set_status_tag, + set_tag_item, +) +from ..diagnosis_editors import DailyRecordEditorDialog +from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog 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" +_ORDER_DETAIL_PERMISSION = "tcm.prescriptionOrder/detail" +_ORDER_LOGS_PERMISSION = "tcm.prescriptionOrder/logs" +_DAILY_PERMISSION = "tcm.diagnosis/dailyRecord" +_PRESCRIPTION_PERMISSION = "tcm.diagnosis/chufang" +_OFFSET_PERMISSION = "tcm.diagnosis/setRevisitSlotStartOffset" +_VIDEO_PERMISSION = "tcm.diagnosis/huifang" +_CHAT_PERMISSION = "tcm.diagnosis/chat" +_NOTE_ADD_PERMISSION = "doctor.appointment/addDoctorNote" +_NOTE_DELETE_PERMISSION = "doctor.appointment/deleteDoctorNoteImage" _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]$" @@ -99,6 +139,9 @@ _DIAGNOSIS_FIELDS = ( ("医嘱", "doctor_advice", "医生嘱托"), ("当前用药", "current_medications", "患者当前用药"), ("补充备注", "remark", "其他需要记录的信息"), + ("状态", "status", "启用或禁用"), + ("渠道来源", "create_source", "诊单创建来源"), + ("统计端就诊卡", "show_card", "是否在统计端显示"), ) _LIST_FIELDS = { @@ -114,6 +157,7 @@ _LIST_FIELDS = { "urine_condition", "stool_condition", "kidney_condition", + "past_history", } _INTEGER_FIELDS = { "gender", @@ -126,10 +170,214 @@ _INTEGER_FIELDS = { "allergy_history", "family_history", "pregnancy_history", + "status", + "show_card", } _FLOAT_FIELDS = {"height", "weight", "fasting_blood_sugar"} _PATIENT_BASIC_FIELDS = {"patient_name", "phone", "id_card", "gender", "age"} +_ORDER_OFFSET_HELP = ( + "在实单诊次序号上叠加偏移量。设为 0(默认):第 1 笔实单计为一诊;" + "设为 1:第 1 笔实单计为二诊;设为 2:第 1 笔实单计为三诊——" + "若有 5 笔实单且偏移 2,则统计上相当于计至七诊(5+2)。" +) + +_ORDER_DETAIL_QSS = """ +QDialog#DiagnosisOrderDetailOverlay { background: transparent; } +QFrame#DiagnosisOrderDetailScrim { background: rgba(15, 23, 42, 0.32); border: 0; } +QFrame#DiagnosisOrderDetailDrawer { + background: #F6F8FB; + border: 0; + border-left: 1px solid #DDE3EA; +} +QFrame#DiagnosisOrderDetailHeader { + background: #FFFFFF; + border: 0; + border-bottom: 1px solid #E5EAF0; +} +QLabel#DiagnosisOrderDetailTitle { color: #172033; font-size: 19px; font-weight: 650; } +QLabel#DiagnosisOrderDetailMeta { color: #697386; font-size: 12px; } +QLabel#DiagnosisOrderReadonlyBadge { + color: #526171; + background: #EEF2F6; + border: 1px solid #D8E0E8; + border-radius: 4px; + padding: 3px 7px; + font-size: 11px; + font-weight: 600; +} +QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F6F8FB; } +QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F6F8FB; } +QFrame[orderAmountCard="true"] { + background: #FFFFFF; + border: 1px solid #E4E9EF; + border-radius: 7px; +} +QLabel[orderAmountTitle="true"] { color: #7A8594; font-size: 11px; font-weight: 550; } +QLabel[orderAmountValue="true"] { + color: #172033; + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +QLabel[orderAmountTone="danger"] { color: #D84A4A; } +QLabel[orderAmountTone="success"] { color: #28845A; } +QLabel[orderAmountTone="warning"] { color: #B46B16; } +QFrame[orderDetailSection="true"] { + background: #FFFFFF; + border: 1px solid #E2E7ED; + border-radius: 7px; +} +QLabel[orderSectionTitle="true"] { color: #253047; font-size: 15px; font-weight: 650; } +QLabel[orderSectionHint="true"] { color: #8A94A3; font-size: 11px; } +QFrame[orderField="true"] { + background: #FAFBFC; + border: 1px solid #EDF0F4; + border-radius: 5px; +} +QLabel[orderFieldLabel="true"] { color: #7A8594; font-size: 11px; } +QLabel[orderFieldValue="true"] { color: #263247; font-size: 13px; } +QLabel[orderEmptyState="true"] { + color: #9099A8; + background: #FAFBFC; + border: 1px dashed #DDE3EA; + border-radius: 5px; + padding: 18px 12px; + font-size: 12px; +} +QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #D8E0E8; } +QLabel#DiagnosisOrderTimelineTime { color: #7A8594; font-size: 11px; } +QLabel#DiagnosisOrderTimelineTitle { color: #253047; font-size: 12px; font-weight: 600; } +QLabel#DiagnosisOrderTimelineBody { color: #5E6979; font-size: 12px; } +QFrame#DiagnosisOrderDetailFooter { + background: #FFFFFF; + border: 0; + border-top: 1px solid #E5EAF0; +} +""" + +_FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ...]], ...] = ( + ( + "基本信息", + ( + (("诊单ID", "__diagnosis_id", 24, "line"),), + ( + ("姓名", "patient_name", 12, "line"), + ("身份证号", "id_card", 12, "line"), + ), + ( + ("手机号", "phone", 12, "line"), + ("性别", "gender", 12, "gender"), + ), + (("年龄", "age", 12, "age_number"),), + ), + ), + ( + "生命体征", + ( + ( + ("婚姻", "marital_status", 8, "marital"), + ("身高", "height", 8, "height_number"), + ("体重", "weight", 8, "weight_number"), + ), + ( + ("地区", "region", 8, "line"), + ("高压", "systolic_pressure", 8, "systolic_number"), + ("低压", "diastolic_pressure", 8, "diastolic_number"), + ), + (("空腹血糖", "fasting_blood_sugar", 8, "line"),), + (("诊断类型", "diagnosis_type", 12, "diagnosis_type"),), + ( + ("状态", "status", 12, "status"), + ("渠道", "create_source", 12, "create_source"), + ), + (("统计端就诊卡", "show_card", 12, "show_card"),), + (("在用药物", "current_medications", 12, "textarea3"),), + ), + ), + ( + "主诉", + ( + ( + ("当地诊断日期", "diagnosis_date", 12, "date"), + ("糖尿病病史", "diabetes_discovery_year", 12, "line"), + ), + (("当地医院诊断结果", "local_hospital_diagnosis", 24, "local_diagnosis"),), + (("当地医院名称", "local_hospital_name", 24, "line"),), + ), + ), + ( + "现病史", + ( + (("口腔感觉", "appetite", 24, "appetite_choices"),), + (("每日饮水量", "water_intake", 24, "water_intake_choices"),), + (("近月体重变化", "weight_change", 24, "weight_change_choices"),), + (("脂肪肝", "fatty_liver_degree", 24, "fatty_liver_degree_choices"),), + (("饮食", "diet_condition", 24, "diet_condition_choices"),), + (("肢体", "body_feeling", 24, "body_feeling_choices"),), + (("睡眠", "sleep_condition", 24, "sleep_condition_choices"),), + (("眼睛", "eye_condition", 24, "eye_condition_choices"),), + (("头部", "head_feeling", 24, "head_feeling_choices"),), + (("出汗", "sweat_condition", 24, "sweat_condition_choices"),), + (("皮肤", "skin_condition", 24, "skin_condition_choices"),), + (("小便", "urine_condition", 24, "urine_condition_choices"),), + (("大便", "stool_condition", 24, "stool_condition_choices"),), + (("腰肾", "kidney_condition", 24, "kidney_condition_choices"),), + (("其他补充", "symptoms", 24, "textarea3"),), + ), + ), + ("既往史", ((("既往史", "past_history", 24, "past_history_choices"),),)), + ( + "其他病史", + ( + ( + ("外伤", "trauma_history", 8, "boolean"), + ("手术", "surgery_history", 8, "boolean"), + ("过敏", "allergy_history", 8, "boolean"), + ), + ( + ("家族", "family_history", 12, "boolean"), + ("妊娠", "pregnancy_history", 12, "boolean"), + ), + ), + ), + ("诊断信息", ((("病史补充", "remark", 24, "textarea2"),),)), +) + +_CHOICE_DICTIONARIES: dict[str, tuple[str, bool]] = { + "appetite": ("appetite", True), + "water_intake": ("water_intake", False), + "diet_condition": ("diet_condition", True), + "weight_change": ("weight_change", False), + "body_feeling": ("body_feeling", True), + "sleep_condition": ("sleep_condition", True), + "eye_condition": ("eye_condition", True), + "head_feeling": ("head_feeling", True), + "sweat_condition": ("sweat_condition", True), + "skin_condition": ("skin_condition", True), + "urine_condition": ("urine_condition", True), + "stool_condition": ("stool_condition", True), + "kidney_condition": ("kidney_condition", True), + "fatty_liver_degree": ("fatty_liver_degree", False), + "past_history": ("past_history", True), +} + +_TAB_DEFINITIONS: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + ("basic", "病历", ()), + ("notes", "医生备注", ()), + ("daily", "日常记录", ("tcm.diagnosis/dailyRecord",)), + ("prescription", "处方", ("tcm.diagnosis/chufang",)), + ("orders", "业务订单", (_PATIENT_ORDERS_PERMISSION,)), + ("video", "视频录制回放", ("tcm.diagnosis/huifang",)), + ("chat", "聊天", ("tcm.diagnosis/chat",)), + ( + "assign", + "指派医助记录", + ("tcm.diagnosis/assign", "tcm.diagnosis/detail"), + ), + ("appointment", "挂号记录", ("doctor.appointment/lists",)), +) + def _mask_phone(value: Any) -> str: text = str(value or "").strip() @@ -157,6 +405,17 @@ def _editor_text(value: Any) -> str: return "" if value is None else str(value) +def _diagnosis_gender_text(value: Any, default: str = "—") -> str: + """Render the diagnosis API's documented 1/0 male/female contract.""" + + normalized = str(value).strip().lower() if value not in (None, "") else "" + if normalized in {"1", "m", "male", "男"}: + return "男" + if normalized in {"0", "2", "f", "female", "女"}: + return "女" + return display_text(value, default) + + def _duplicate_found(value: Any) -> bool: for key in ("exists", "duplicate", "data.exists", "data.duplicate"): candidate = get_value(value, key, None) @@ -198,6 +457,39 @@ def _mapping(value: Any) -> Mapping[str, Any]: return value if isinstance(value, Mapping) else {} +def _dictionary_choices(value: Any, dictionary_type: str) -> list[tuple[str, Any]]: + candidates: Any = value + if isinstance(value, Mapping): + candidates = value.get(dictionary_type, value.get("lists", value.get("data", []))) + if isinstance(candidates, Mapping): + candidates = candidates.get(dictionary_type, candidates.get("lists", [])) + rows = ( + list(candidates) + if isinstance(candidates, Sequence) and not isinstance(candidates, (str, bytes, bytearray)) + else [] + ) + choices: list[tuple[str, Any]] = [] + for row in rows: + label = first_value(row, "name", "label", "text", default="") + value = first_value(row, "value", "id", default="") + if label not in (None, "") and value not in (None, ""): + choices.append((str(label), value)) + return choices + + +def _diagnosis_type_value(value: Any) -> str: + normalized = str(value or "").strip() + canonical = { + "1": "first_visit", + "2": "follow_up", + "3": "consultation", + "初诊": "first_visit", + "复诊": "follow_up", + "会诊": "consultation", + }.get(normalized, normalized) + return canonical if canonical in {"first_visit", "follow_up", "consultation"} else "" + + def _int(value: Any, default: int = 0) -> int: try: return int(value) @@ -205,8 +497,247 @@ def _int(value: Any, default: int = 0) -> int: return default +_MISSING = object() + + +def _raw_value(source: Any, *keys: str, default: Any = _MISSING) -> Any: + """Return the first present field while preserving explicit zero/false values.""" + + for key in keys: + value = get_value(source, key, _MISSING) + if value is not _MISSING and value is not None and value != "": + return value + return default + + +def _detail_rows(source: Any, *keys: str) -> tuple[list[Any], bool]: + """Read a returned list field and report whether that field actually existed.""" + + for key in keys: + value = get_value(source, key, _MISSING) + if value is _MISSING: + continue + if value is None: + return [], True + if isinstance(value, Mapping): + for nested_key in ("lists", "items", "rows", "results", "data", "traces"): + nested = value.get(nested_key, _MISSING) + if isinstance(nested, Sequence) and not isinstance(nested, (str, bytes, bytearray)): + return list(nested), True + return [], True + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return list(value), True + return [], True + return [], False + + +def _number(value: Any) -> float | None: + if value is _MISSING or value is None or value == "": + return None + if isinstance(value, bool): + return float(value) + try: + return float(str(value).replace("¥", "").replace("¥", "").replace(",", "").strip()) + except (TypeError, ValueError): + return None + + +def _money_text(value: Any) -> str: + amount = _number(value) + return "—" if amount is None else f"¥{amount:,.2f}" + + +def _fulfillment_text(value: Any) -> str: + if value is _MISSING or value in (None, ""): + return "—" + labels = { + 1: "待双审通过", + 2: "待发货", + 3: "已完成", + 4: "已取消", + 5: "已发货", + 6: "已签收", + 7: "进行中", + 8: "暂不制药", + 9: "拒收", + 10: "退款", + 11: "保留药方", + 12: "制药缓发", + } + numeric = _int(value, -1) + return labels.get(numeric, display_text(value)) + + +def _pay_status_text(value: Any) -> str: + if value is _MISSING or value in (None, ""): + return "—" + labels = {1: "待支付", 2: "已支付", 3: "已取消", 4: "已退款", 5: "待审核"} + return labels.get(_int(value, -1), display_text(value)) + + +def _fee_type_text(value: Any) -> str: + if value is _MISSING or value in (None, ""): + return "—" + labels = { + 1: "挂号费", + 2: "问诊费", + 3: "药品费用", + 4: "首付费用", + 5: "尾款费用", + 6: "其他费用", + 7: "全部费用", + 8: "驼奶费用", + } + return labels.get(_int(value, -1), display_text(value)) + + +def _express_company_text(value: Any) -> str: + if value is _MISSING or value in (None, ""): + return "—" + normalized = str(value).strip().lower() + return { + "sf": "顺丰速运", + "jd": "京东快递", + "jt": "极兔速递", + "jtexpress": "极兔速递", + "auto": "自动识别", + }.get(normalized, display_text(value)) + + +def _audit_text(value: Any) -> str: + if value is _MISSING or value in (None, ""): + return "—" + return {0: "待审核", 1: "审核通过", 2: "已驳回"}.get(_int(value, -1), display_text(value)) + + +def _log_action_text(value: Any) -> str: + if value is _MISSING or value in (None, ""): + return "—" + normalized = str(value).strip() + labels = { + "create": "创建", + "edit": "编辑", + "audit_rx_approve": "处方审核", + "audit_rx_reject": "处方审核", + "audit_pay_approve": "支付审核", + "audit_pay_reject": "支付审核", + "fill_tracking": "填快递单", + "ship": "确认发货", + "withdraw": "撤销", + "link_pay_order": "关联支付单", + "completion_request": "完单申请", + "auto_complete": "自动完成", + "revoke_rx_audit": "撤回处方审核", + "revoke_pay_audit": "撤回支付审核", + "gancao_submit": "甘草下单", + "ej_pharmacy_submit": "洛阳药房下单", + "ej_pharmacy_callback": "洛阳药房状态", + "patch_rx_patient": "处方患者信息", + "patch_rx_usage": "服用参数", + "update_amount": "修改订单金额", + "complete": "完成订单", + "refund": "退款", + "manual_log": "手工备注", + "assign_assistant": "改派医助", + "add_pay_order": "补齐支付单", + "set_ship_mode": "发货类型", + } + return labels.get(normalized, normalized) + + +def _display_collection(value: Any) -> str: + if value is _MISSING or value in (None, ""): + return "—" + if isinstance(value, Mapping): + for key in ("name", "label", "text", "path"): + item = value.get(key) + if item not in (None, ""): + return str(item) + return "—" + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + parts = [_display_collection(item) for item in value] + return " / ".join(part for part in parts if part != "—") or "—" + return display_text(value) + + +def _visit_slot_label(offset: int) -> str: + slot = max(0, int(offset)) + 1 + chinese = ("", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十") + return f"{chinese[slot]}诊" if slot <= 10 else f"第{slot}诊" + + +class OrderDetailDrawer(QDialog): + """Modal overlay whose readonly content panel occupies 80% of its owner.""" + + drawer_ratio = 0.8 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisOrderDetailOverlay") + self.setWindowTitle("业务订单详情") + self.setModal(True) + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setStyleSheet(DIAGNOSIS_QSS + _ORDER_DETAIL_QSS) + root = QHBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + self.scrim = QFrame() + self.scrim.setObjectName("DiagnosisOrderDetailScrim") + root.addWidget(self.scrim, 1) + self.drawer_panel = QFrame() + self.drawer_panel.setObjectName("DiagnosisOrderDetailDrawer") + self.drawer_panel.setProperty("readonly", True) + self.drawer_panel.setProperty("drawerRatio", self.drawer_ratio) + self.content_layout = QVBoxLayout(self.drawer_panel) + self.content_layout.setContentsMargins(0, 0, 0, 0) + self.content_layout.setSpacing(0) + root.addWidget(self.drawer_panel, 0) + self._owner = parent + if self._owner is not None: + self._owner.installEventFilter(self) + QTimer.singleShot(0, self.sync_to_owner) + + def sync_to_owner(self) -> None: + owner = self._owner + if owner is None: + self.resize(1152, 720) + width = round(self.width() * self.drawer_ratio) + else: + origin = owner.mapToGlobal(QPoint(0, 0)) + self.setGeometry(origin.x(), origin.y(), owner.width(), owner.height()) + width = round(owner.width() * self.drawer_ratio) + self.drawer_panel.setFixedWidth(max(1, width)) + + def eventFilter(self, watched: object, event: QEvent) -> bool: # noqa: N802 - Qt API + if watched is self._owner and event.type() in { + QEvent.Type.Move, + QEvent.Type.Resize, + QEvent.Type.Show, + QEvent.Type.WindowStateChange, + }: + QTimer.singleShot(0, self.sync_to_owner) + return super().eventFilter(watched, event) + + def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + self.sync_to_owner() + super().showEvent(event) + + def closeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + if self._owner is not None: + with suppress(RuntimeError): + self._owner.removeEventFilter(self) + super().closeEvent(event) + + class DiagnosisDialog(QDialog): - """Load a diagnosis and its server-backed histories without blocking Qt.""" + """Diagnosis readonly page plus edit/view-only right drawer. + + The public ``open_for`` contract and all repository calls remain compatible + with the earlier dialog. ``editable=False`` opens the standalone readonly + card flow; callers that explicitly need the admin ``viewOnly`` drawer can + pass ``view_only=True`` or call :meth:`open_view_only`. + """ saved = Signal() @@ -230,281 +761,1131 @@ class DiagnosisDialog(QDialog): self._can_patient_orders = has_permission( self.permissions, _PATIENT_ORDERS_PERMISSION, default=False ) + self._can_order_detail = has_permission( + self.permissions, _ORDER_DETAIL_PERMISSION, default=False + ) + self._can_order_logs = has_permission( + self.permissions, _ORDER_LOGS_PERMISSION, default=False + ) + self._can_daily_mutate = has_permission( + self.permissions, _DAILY_PERMISSION, default=False + ) and all( + callable(getattr(self.repository, name, None)) + for name in ( + "add_blood_record", + "add_diet_record", + "add_exercise_record", + "add_tracking_note", + "add_diagnosis_todo", + "cancel_diagnosis_todo", + ) + ) + self._can_daily_edit = has_permission( + self.permissions, _DAILY_PERMISSION, default=False + ) and all( + callable(getattr(self.repository, name, None)) + for name in ( + "update_blood_record", + "update_diet_record", + "update_exercise_record", + ) + ) + self._can_prescribe = has_permission( + self.permissions, _PRESCRIPTION_PERMISSION, default=False + ) and callable(getattr(self.repository, "create_prescription", None)) + self._can_offset = has_permission( + self.permissions, _OFFSET_PERMISSION, default=False + ) and callable(getattr(self.repository, "set_revisit_slot_start_offset", None)) + self._can_video_upload = has_permission( + self.permissions, _VIDEO_PERMISSION, default=False + ) and callable(getattr(self.repository, "upload_call_recording", None)) + self._can_chat_sync = has_permission( + self.permissions, _CHAT_PERMISSION, default=False + ) and callable(getattr(self.repository, "sync_im_chat_messages", None)) + self._can_note_add = has_permission( + self.permissions, _NOTE_ADD_PERMISSION, default=False + ) and callable(getattr(self.repository, "add_doctor_note", None)) + self._can_note_delete = has_permission( + self.permissions, _NOTE_DELETE_PERMISSION, default=False + ) and callable(getattr(self.repository, "delete_doctor_note_image", None)) self._diagnosis_id = 0 self._patient_id = 0 self._editable = False + self._authoritative_detail_loaded = False + self._saving = False + self._load_mode = "readonly" self._generation = 0 self._save_generation = 0 self._orders_generation = 0 + self._order_detail_generation = 0 + self._daily_mutation_generation = 0 + self._notes_mutation_generation = 0 + self._media_generation = 0 + self._offset_generation = 0 + self._tab_generations: dict[str, int] = {key: 0 for key, _label, _codes in _TAB_DEFINITIONS} + self._loaded_tabs: set[str] = set() + self._loading_tabs: set[str] = set() + self._daily_todo_status: int | None = None + self._recording_players: list[RecordingPlayerDialog] = [] + self._inline_recording_cells: list[RecordingPlaybackCell] = [] + self._last_order_detail_dialog: QDialog | None = None self._orders_page = 1 self._orders_page_size = 10 self._orders_total = 0 + self._saved_order_offset = 0 self._detail: Any = None self._field_originals: dict[str, Any] = {} + self._standalone_readonly = True + self._view_only = False + self._basic_locked = False + self._tab_pages: dict[str, QWidget] = {} + self._readonly_sections: dict[str, QWidget] = {} + self._form_rows: list[tuple[QBoxLayout, list[QWidget], QWidget | None]] = [] + self._form_narrow: bool | None = None + self._choice_fields: dict[str, DiagnosisChoiceButtons] = {} + self._table_registry: dict[str, list[RecordTable]] = { + "daily": [], + "prescription": [], + "orders": [], + "video": [], + "assign": [], + "appointment": [], + } + self._notes_timelines: list[NotesTimeline] = [] + self._chat_panels: list[ChatPanel] = [] + self._daily_panels: list[DailyRecordPanel] = [] + self._owner = None + self._owner_filter_installed = False - self.setModal(True) + self.setObjectName("DiagnosisDialogRoot") + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setWindowTitle("患者信息详情") - self.setMinimumSize(680, 520) - self.resize(880, 680) + self.setMinimumSize(760, 520) + self.resize(1024, 640) + self.setStyleSheet(DIAGNOSIS_QSS) - 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.view_stack = QStackedLayout(self) + self.view_stack.setContentsMargins(0, 0, 0, 0) + self.view_stack.setStackingMode(QStackedLayout.StackingMode.StackOne) + self.readonly_page = self._build_readonly_page() + self.drawer_overlay = self._build_drawer_overlay() + self.view_stack.addWidget(self.readonly_page) + self.view_stack.addWidget(self.drawer_overlay) - 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.compatibility_host = QWidget(self) + self.compatibility_host.hide() + self.notes_table = QTableWidget(0, 3, self.compatibility_host) + self.notes_table.setHorizontalHeaderLabels(["时间", "医生", "内容"]) + self._build_compatibility_editors() + self.refresh_permissions() - self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - self.save_button = self.buttons.addButton( - "保存病历", QDialogButtonBox.ButtonRole.AcceptRole + def _build_readonly_page(self) -> QFrame: + page = QFrame() + page.setObjectName("DiagnosisReadonlyPage") + page.setProperty("mode", "readonly") + root = QVBoxLayout(page) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + self.readonly_scroll = QScrollArea() + self.readonly_scroll.setObjectName("DiagnosisReadonlyScroll") + self.readonly_scroll.setWidgetResizable(True) + self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + content = QWidget() + self.readonly_content_layout = QVBoxLayout(content) + self.readonly_content_layout.setContentsMargins(16, 16, 16, 16) + self.readonly_content_layout.setSpacing(16) + self.readonly_hero = self._build_readonly_hero() + self.readonly_content_layout.addWidget(self.readonly_hero) + self.readonly_error = QFrame() + self.readonly_error.setObjectName("DiagnosisReadonlyErrorCard") + self.readonly_error.setProperty("diagnosisReadonlyCard", True) + error_layout = QVBoxLayout(self.readonly_error) + error_layout.setContentsMargins(18, 40, 18, 40) + self.readonly_error_label = QLabel("诊单不存在或无权访问") + self.readonly_error_label.setObjectName("DiagnosisEmptyState") + self.readonly_error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.readonly_error_label.setWordWrap(True) + error_layout.addWidget(self.readonly_error_label) + self.readonly_retry_button = QPushButton("重试加载") + self.readonly_retry_button.setProperty("variant", "primary") + self.readonly_retry_button.setFixedHeight(36) + self.readonly_retry_button.clicked.connect(self._retry_detail) + error_layout.addWidget(self.readonly_retry_button, 0, Qt.AlignmentFlag.AlignCenter) + self.readonly_error.hide() + self.readonly_content_layout.addWidget(self.readonly_error) + self.readonly_patient_card = self._build_readonly_patient_card() + self.readonly_content_layout.addWidget(self.readonly_patient_card) + self.case_grid = CaseGrid() + self.readonly_content_layout.addWidget(self.case_grid) + + daily = self._new_daily_panel("DiagnosisReadonlyDailyPanel") + self._add_readonly_section("daily", "日常记录", daily) + notes = NotesTimeline(editable=False) + notes.open_attachment_requested.connect(self._open_safe_resource) + self._notes_timelines.append(notes) + self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes) + orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable") + self._add_readonly_section("orders", "业务订单", orders) + video = self._new_table("video", "DiagnosisReadonlyVideoTable") + self._add_readonly_section("video", "视频录制回放记录", video) + chat = ChatPanel() + chat.sync_requested.connect(self._sync_chat_archive) + chat.reload_requested.connect(lambda: self._ensure_tab_loaded("chat", force=True)) + chat.open_attachment_requested.connect(self._open_safe_resource) + self._chat_panels.append(chat) + self._add_readonly_section("chat", "聊天记录", chat) + assignments = self._new_table("assign", "DiagnosisReadonlyAssignmentsTable") + self._add_readonly_section("assign", "指派医助记录", assignments) + appointments = self._new_table("appointment", "DiagnosisReadonlyAppointmentsTable") + self._add_readonly_section("appointment", "挂号记录", appointments) + self.readonly_content_layout.addStretch(1) + self.readonly_scroll.setWidget(content) + root.addWidget(self.readonly_scroll) + self.readonly_loading = LoadingOverlay(page) + return page + + def _build_readonly_hero(self) -> QFrame: + hero = QFrame() + hero.setObjectName("DiagnosisReadonlyHero") + layout = QGridLayout(hero) + layout.setContentsMargins(16, 12, 16, 12) + layout.setHorizontalSpacing(12) + layout.setVerticalSpacing(8) + self.readonly_hero_layout = layout + self.readonly_hero_left = QWidget() + left_layout = QHBoxLayout(self.readonly_hero_left) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.setSpacing(12) + back = QPushButton("← 返回") + back.setObjectName("DiagnosisReadonlyBack") + back.setCursor(Qt.CursorShape.PointingHandCursor) + back.setStyleSheet( + "QPushButton{height:32px;padding:0 8px;border:0;background:transparent;" + "color:#409EFF;font-size:13px;font-weight:500;}" + "QPushButton:hover,QPushButton:focus{background:#ECF5FF;border-radius:6px;}" ) - self.save_button.setProperty("variant", "primary") - self.save_button.clicked.connect(self._save) - self.buttons.rejected.connect(self.reject) - root.addWidget(self.buttons) + back.clicked.connect(self.reject) + left_layout.addWidget(back) + title = QLabel("患者信息详情") + title.setObjectName("DiagnosisReadonlyTitle") + left_layout.addWidget(title) + left_layout.addStretch(1) + self.readonly_hero_right = QWidget() + right_layout = QHBoxLayout(self.readonly_hero_right) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(12) + self.readonly_hero_name = QLabel("患者") + self.readonly_hero_name.setObjectName("DiagnosisReadonlyPatientName") + right_layout.addWidget(self.readonly_hero_name) + self.readonly_hero_meta = QLabel("—") + self.readonly_hero_meta.setStyleSheet("color:#64748B; font-size:13px;") + right_layout.addWidget(self.readonly_hero_meta) + self.readonly_status = QLabel("从未打卡") + self.readonly_status.setObjectName("DiagnosisReadonlyStatus") + self.readonly_status.setProperty("severity", "neutral") + right_layout.addWidget(self.readonly_status) + layout.addWidget(self.readonly_hero_left, 0, 0) + layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight) + layout.setColumnStretch(0, 1) + return hero - def _build_overview_tab(self) -> QWidget: + def _build_readonly_patient_card(self) -> QFrame: + card = QFrame() + card.setObjectName("DiagnosisReadonlyPatientCard") + card.setProperty("diagnosisReadonlyCard", True) + layout = QVBoxLayout(card) + layout.setContentsMargins(18, 18, 18, 18) + layout.setSpacing(14) + heading = QLabel("患者信息") + heading.setObjectName("DiagnosisReadonlyCardTitle") + layout.addWidget(heading) + patient_hero = QFrame() + patient_hero.setObjectName("DiagnosisReadonlyPatientHero") + hero_layout = QVBoxLayout(patient_hero) + hero_layout.setContentsMargins(18, 16, 18, 16) + hero_layout.setSpacing(3) + self.readonly_patient_title = QLabel("患者") + self.readonly_patient_title.setObjectName("DiagnosisReadonlyPatientTitle") + hero_layout.addWidget(self.readonly_patient_title) + self.summary_fields: dict[str, QLabel] = {} + for parts in ( + (("", "phone"), (" · ", "gender_age")), + (("", "body"), (" · ", "region")), + (("预约:", "appointment"),), + (("", "staff"),), + (("状态:", "appointment_status"), (" · ", "prescription_status")), + (("备注:", "remark"),), + ): + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(0) + for prefix, key in parts: + if prefix: + prefix_label = QLabel(prefix) + prefix_label.setProperty("diagnosisPatientMeta", True) + prefix_label.setSizePolicy( + QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred + ) + prefix_label.setMinimumWidth(prefix_label.sizeHint().width()) + row_layout.addWidget(prefix_label) + value = QLabel("—") + value.setProperty("diagnosisPatientMeta", True) + value.setWordWrap(False) + value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + value.setSizePolicy( + QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred + ) + row_layout.addWidget(value) + self.summary_fields[key] = value + row_layout.addStretch(1) + hero_layout.addWidget(row) + layout.addWidget(patient_hero) + self.summary_fields["patient"] = self.readonly_patient_title + for key in ("id_card", "pressure", "blood_sugar"): + self.summary_fields[key] = QLabel("—", card) + self.summary_fields[key].hide() + return card + + def _add_readonly_section(self, key: str, title: str, body: QWidget) -> None: + card = readonly_card(title, f"DiagnosisReadonlySection_{key}", body) + self._readonly_sections[key] = card + self.readonly_content_layout.addWidget(card) + + def _build_drawer_overlay(self) -> QFrame: + overlay = QFrame() + overlay.setObjectName("DiagnosisDrawerOverlay") + layout = QHBoxLayout(overlay) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addStretch(1) + self.drawer_panel = QFrame() + self.drawer_panel.setObjectName("DiagnosisDrawer") + self.drawer_panel.setProperty("mode", "edit") + self.drawer_panel.setFixedWidth(614) + panel_layout = QVBoxLayout(self.drawer_panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.setSpacing(0) + panel_layout.addWidget(self._build_drawer_header()) + self.drawer_banner = MessageStrip() + self.drawer_banner.setContentsMargins(16, 8, 16, 0) + self.drawer_banner.action_requested.connect(self._retry_current) + panel_layout.addWidget(self.drawer_banner) + self.tabs = DiagnosisTabWidget() + self.tabs.setObjectName("DiagnosisDrawerTabs") + self._build_drawer_tabs() + self.tabs.currentChanged.connect(self._tab_changed) + panel_layout.addWidget(self.tabs, 1) + panel_layout.addWidget(self._build_drawer_footer()) + self.drawer_loading = LoadingOverlay(self.drawer_panel) + layout.addWidget(self.drawer_panel) + return overlay + + def _build_drawer_header(self) -> QFrame: + header = QFrame() + header.setObjectName("DiagnosisDrawerHeader") + layout = QHBoxLayout(header) + layout.setContentsMargins(16, 13, 16, 13) + layout.setSpacing(10) + identity = QVBoxLayout() + identity.setSpacing(4) + title_row = QHBoxLayout() + title_row.setSpacing(10) + self.title_label = QLabel("编辑诊单") + self.title_label.setObjectName("DiagnosisDrawerTitle") + title_row.addWidget(self.title_label) + self.mode_label = QLabel("编辑") + self.mode_label.setObjectName("DiagnosisDrawerModeBadge") + self.mode_label.setProperty("kind", "warning") + title_row.addWidget(self.mode_label) + title_row.addStretch(1) + identity.addLayout(title_row) + self.meta_label = QLabel("正在准备诊单…") + self.meta_label.setObjectName("DiagnosisDrawerMeta") + identity.addWidget(self.meta_label) + layout.addLayout(identity, 1) + close = QPushButton("×") + close.setObjectName("DiagnosisCloseButton") + close.setToolTip("关闭") + close.clicked.connect(self.reject) + layout.addWidget(close, 0, Qt.AlignmentFlag.AlignTop) + return header + + def _build_drawer_footer(self) -> QFrame: + footer = QFrame() + footer.setObjectName("DiagnosisDrawerFooter") + shadow = QGraphicsDropShadowEffect(footer) + shadow.setBlurRadius(18) + shadow.setOffset(0, -4) + shadow.setColor(QColor(15, 23, 42, 42)) + footer.setGraphicsEffect(shadow) + layout = QHBoxLayout(footer) + layout.setContentsMargins(20, 14, 20, 18) + layout.setSpacing(12) + layout.addStretch(1) + self.close_button = QPushButton("关闭") + self.close_button.setObjectName("DiagnosisFooterCloseButton") + self.close_button.clicked.connect(self.reject) + layout.addWidget(self.close_button) + self.save_button = SaveStateButton("保存病历") + self.save_button.setObjectName("DiagnosisSaveButton") + self.save_button.clicked.connect(self._save) + layout.addWidget(self.save_button) + return footer + + def _build_drawer_tabs(self) -> None: + self._tab_pages["basic"] = self._build_basic_tab() + self._tab_pages["notes"] = self._build_notes_tab() + self._tab_pages["daily"] = self._wrap_tab( + "DiagnosisTabDaily", self._new_daily_panel("DiagnosisDailyPanel") + ) + self._tab_pages["prescription"] = self._build_prescription_tab() + self._tab_pages["orders"] = self._build_orders_tab() + self._tab_pages["video"] = self._build_video_tab() + chat = ChatPanel() + chat.sync_requested.connect(self._sync_chat_archive) + chat.reload_requested.connect(lambda: self._ensure_tab_loaded("chat", force=True)) + chat.open_attachment_requested.connect(self._open_safe_resource) + self._chat_panels.append(chat) + self._tab_pages["chat"] = self._wrap_tab("DiagnosisTabChat", chat) + self.assign_table = self._new_table("assign", "DiagnosisTableAssignments") + self._tab_pages["assign"] = self._wrap_tab("DiagnosisTabAssign", self.assign_table) + self.appointment_table = self._new_table("appointment", "DiagnosisTableAppointments") + self._tab_pages["appointment"] = self._wrap_tab( + "DiagnosisTabAppointment", self.appointment_table + ) + + def _build_basic_tab(self) -> QScrollArea: scroll = QScrollArea() + scroll.setObjectName("DiagnosisDrawerBody") + scroll.setProperty("diagnosisTabKey", "basic") scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) content = QWidget() + content.setObjectName("DiagnosisTabBasic") layout = QVBoxLayout(content) - layout.setContentsMargins(4, 12, 4, 16) + layout.setContentsMargins(16, 14, 16, 18) 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) + self.privacy_banner = QFrame() + self.privacy_banner.setObjectName("DiagnosisPrivacyBanner") + privacy_layout = QHBoxLayout(self.privacy_banner) + privacy_layout.setContentsMargins(12, 9, 12, 9) + self.privacy_label = QLabel("存在未完成的业务订单,患者基本信息不可修改") + self.privacy_label.setWordWrap(True) + self.privacy_label.setStyleSheet("color:#92400E; font-size:12px;") + privacy_layout.addWidget(self.privacy_label) + self.privacy_banner.hide() + layout.addWidget(self.privacy_banner) + self.form_host = QFrame() + self.form_host.setObjectName("DiagnosisFormHost") + self.form_host.setProperty("locked", False) + form_layout = QVBoxLayout(self.form_host) + form_layout.setContentsMargins(0, 4, 0, 16) + form_layout.setSpacing(0) + self.edit_fields: dict[str, Any] = {} + self.display_fields: dict[str, Any] = {} + for section_key, (title, rows) in enumerate(_FORM_SECTIONS): + form_layout.addWidget(section_heading(title, f"DiagnosisSection_{section_key}")) + for row_index, row_fields in enumerate(rows): + row_host = QWidget() + row_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, row_host) + row_layout.setContentsMargins(0, 0, 0, 8 if row_index == len(rows) - 1 else 18) + row_layout.setSpacing(12) + fields: list[QWidget] = [] + occupied = 0 + for label, key, span, field_kind in row_fields: + editor = self._ensure_editor(key, field_kind) + container = self._field_container(label, editor, span) + row_layout.addWidget(container, span) + fields.append(container) + occupied += span + remainder: QWidget | None = None + if occupied < 24: + remainder = QWidget() + remainder.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + row_layout.addWidget(remainder, 24 - occupied) + self._form_rows.append((row_layout, fields, remainder)) + form_layout.addWidget(row_host) + layout.addWidget(self.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 _field_container(self, caption: str, editor: QWidget, span: int) -> QWidget: + container = QWidget() + container.setMinimumWidth(0) + container.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + label = QLabel(caption) + label.setProperty("diagnosisFieldLabel", True) + label.setFixedWidth(62 if span <= 8 else 88 if span <= 12 else 120) + label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop) + label.setContentsMargins(0, 8, 0, 0) + layout.addWidget(label) + editor.setMinimumWidth(0) + editor.setSizePolicy(QSizePolicy.Policy.Ignored, editor.sizePolicy().verticalPolicy()) + layout.addWidget(editor, 1) + return container - 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( - ["状态", "患者", "医生", "医助", "预约日期", "时段", "备注"] + def _ensure_editor(self, key: str, field_kind: str) -> QWidget: + if key in self.edit_fields: + return self.edit_fields[key] + if key in self.display_fields: + return self.display_fields[key] + if field_kind == "gender": + editor: Any = DiagnosisChoiceButtons( + (("男", 1), ("女", 0)), radio_buttons=True, columns=2 ) - widths = (88, 110, 100, 100, 105, 80) - self.appointment_table = table + elif field_kind == "marital": + editor = DiagnosisComboBox((("未婚", 0), ("已婚", 1), ("离异", 2))) + elif field_kind == "diagnosis_type": + editor = DiagnosisComboBox( + (("初诊", "first_visit"), ("复诊", "follow_up"), ("会诊", "consultation")), + allow_custom=False, + ) + elif field_kind == "status": + editor = DiagnosisChoiceButtons( + (("启用", 1), ("禁用", 0)), radio_buttons=True, columns=2 + ) + elif field_kind == "show_card": + editor = DiagnosisSwitch() + elif field_kind == "create_source": + editor = DiagnosisComboBox( + ( + ("未设置", ""), + ("后台建档", "admin"), + ("小程序完整建档", "mnp"), + ("小程序手机号快捷建档", "mnp_daily"), + ) + ) + elif field_kind == "age_number": + editor = DiagnosisNumberEdit(maximum=150) + elif field_kind == "height_number": + editor = DiagnosisNumberEdit(maximum=300, decimals=1, step=0.5, suffix=" cm") + elif field_kind == "weight_number": + editor = DiagnosisNumberEdit(maximum=500, decimals=1, step=0.1, suffix=" kg") + elif field_kind == "systolic_number": + editor = DiagnosisNumberEdit(maximum=250, suffix=" mmHg") + elif field_kind == "diastolic_number": + editor = DiagnosisNumberEdit(maximum=150, suffix=" mmHg") + elif field_kind == "date": + editor = DiagnosisDateEdit() + elif field_kind == "local_diagnosis": + editor = DiagnosisChoiceButtons( + (("糖尿病", "糖尿病"), ("消渴病", "消渴病"), ("糖尿病前期", "糖尿病前期")), + multiple=True, + columns=3, + ) + self._choice_fields[key] = editor + elif field_kind == "boolean": + editor = DiagnosisChoiceButtons((("有", 1), ("无", 0)), columns=2) + elif field_kind.endswith("_choices"): + multiple = _CHOICE_DICTIONARIES.get(key, (key, True))[1] + editor = DiagnosisChoiceButtons((), multiple=multiple, columns=4) + self._choice_fields[key] = editor + elif field_kind.startswith("textarea"): + rows = int(field_kind[-1]) + editor = DiagnosisTextEdit(rows) else: - table = QTableWidget(0, 6) - table.setHorizontalHeaderLabels( - ["操作时间", "原医助", "新医助", "继承", "操作人", "账号"] + editor = DiagnosisLineEdit() + editor.setObjectName(f"DiagnosisField_{key.removeprefix('__')}") + if key.startswith("__"): + editor.setReadOnly(True) + self.display_fields[key] = editor + else: + placeholder = next( + (item[2] for item in _DIAGNOSIS_FIELDS if item[1] == key), + "", ) - 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 + if hasattr(editor, "setPlaceholderText"): + editor.setPlaceholderText(placeholder) + self.edit_fields[key] = editor + return editor + + def _build_compatibility_editors(self) -> None: + for _caption, key, placeholder in _DIAGNOSIS_FIELDS: + if key in self.edit_fields: + continue + editor = DiagnosisTextEdit(2, self.compatibility_host) + editor.setObjectName(f"DiagnosisField_{key}") + editor.setPlaceholderText(placeholder) + editor.hide() + self.edit_fields[key] = editor + + def _build_notes_tab(self) -> QWidget: + timeline = NotesTimeline(editable=True) + timeline.add_requested.connect(self._add_doctor_note) + timeline.upload_requested.connect(self._upload_doctor_note_material) + timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment) + timeline.open_attachment_requested.connect(self._open_safe_resource) + self.drawer_notes_timeline = timeline + self._notes_timelines.append(timeline) + return self._wrap_tab("DiagnosisTabNotes", timeline) + + def _build_prescription_tab(self) -> QWidget: + page = QWidget() + page.setObjectName("DiagnosisTabPrescription") + layout = QVBoxLayout(page) + layout.setContentsMargins(16, 14, 16, 18) + layout.setSpacing(12) + toolbar = QHBoxLayout() + title = QLabel("处方记录") + title.setObjectName("DiagnosisDailySectionTitle") + toolbar.addWidget(title) + hint = QLabel("开方后进入真实审核与订单流程") + hint.setObjectName("DiagnosisDialogGuidance") + toolbar.addWidget(hint) + toolbar.addStretch(1) + self.prescribe_button = QPushButton("开方") + self.prescribe_button.setProperty("variant", "primary") + self.prescribe_button.clicked.connect(self._open_new_prescription) + toolbar.addWidget(self.prescribe_button) + layout.addLayout(toolbar) + layout.addWidget(self._new_table("prescription", "DiagnosisPrescriptionTable"), 1) + return page + + def _build_video_tab(self) -> QWidget: + page = QWidget() + page.setObjectName("DiagnosisTabVideo") + layout = QVBoxLayout(page) + layout.setContentsMargins(16, 14, 16, 18) + layout.setSpacing(12) + toolbar = QHBoxLayout() + hint = QLabel("每条通话记录可包含多个回放地址,并可追加指定记录的视频。") + hint.setObjectName("DiagnosisDialogGuidance") + hint.setWordWrap(True) + toolbar.addWidget(hint, 1) + toolbar.addStretch(1) + self.video_upload_button = QPushButton("上传本地回放") + self.video_upload_button.setProperty("variant", "primary") + self.video_upload_button.clicked.connect( + lambda _checked=False: self._upload_call_recording(None) + ) + toolbar.addWidget(self.video_upload_button) + layout.addLayout(toolbar) + layout.addWidget(self._new_table("video", "DiagnosisVideoTable"), 1) + return page 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( - ["订单号", "处方", "患者/收货人", "手机", "金额", "发货", "状态", "创建时间"] + page = QWidget() + page.setObjectName("DiagnosisTabOrders") + layout = QVBoxLayout(page) + layout.setContentsMargins(16, 14, 16, 18) + layout.setSpacing(12) + toolbar = QFrame() + toolbar.setObjectName("DiagnosisOrderOffsetBar") + toolbar_layout = QHBoxLayout(toolbar) + toolbar_layout.setContentsMargins(14, 10, 14, 10) + label = QLabel("复诊统计起始偏移") + label.setObjectName("DiagnosisOrderOffsetLabel") + label.setStyleSheet("color:#606266; font-size:12px; font-weight:500;") + label.setToolTip(_ORDER_OFFSET_HELP) + toolbar_layout.addWidget(label) + help_button = QPushButton("?") + help_button.setObjectName("DiagnosisOrderOffsetHelp") + help_button.setAccessibleName("查看复诊统计起始偏移说明") + help_button.setToolTip(_ORDER_OFFSET_HELP) + help_button.setFixedSize(22, 22) + help_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + help_button.setStyleSheet( + "QPushButton{color:#909399;border:1px solid #D9DEE5;border-radius:11px;" + "background:#FFFFFF;padding:0;font-size:12px;font-weight:600;}" + "QPushButton:hover,QPushButton:focus{color:#409EFF;border-color:#409EFF;" + "background:#ECF5FF;}" ) - 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) + toolbar_layout.addWidget(help_button) + self.order_offset_help = help_button + self.order_offset = QSpinBox() + self.order_offset.setRange(0, 20) + self.order_offset.setSingleStep(1) + self.order_offset.setFixedWidth(120) + self.order_offset.setEnabled(False) + self.order_offset.setToolTip(_ORDER_OFFSET_HELP) + self.order_offset.valueChanged.connect(self._order_offset_changed) + toolbar_layout.addWidget(self.order_offset) + self.order_offset_preview = QLabel("第 1 笔实单计为一诊") + self.order_offset_preview.setObjectName("DiagnosisOrderOffsetPreview") + self.order_offset_preview.setToolTip(_ORDER_OFFSET_HELP) + self.order_offset_preview.setStyleSheet("color:#909399; font-size:12px;") + toolbar_layout.addWidget(self.order_offset_preview) + offset_save = QPushButton("保存") + offset_save.setProperty("variant", "primary") + self.order_offset_save = offset_save + offset_save.clicked.connect(self._save_order_offset) + toolbar_layout.addWidget(offset_save) + toolbar_layout.addStretch(1) + layout.addWidget(toolbar) + self.orders_table = self._new_table("orders", "DiagnosisTableOrders") layout.addWidget(self.orders_table, 1) footer = QHBoxLayout() self.orders_summary = QLabel("共 0 条") - self.orders_summary.setProperty("role", "muted") + self.orders_summary.setStyleSheet("color:#999999; font-size:12px;") footer.addWidget(self.orders_summary) footer.addStretch(1) - self.orders_previous = QLabel('上一页') - self.orders_previous.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - self.orders_previous.linkActivated.connect(lambda _link: self._change_orders_page(-1)) + self.orders_previous = QPushButton("上一页") + self.orders_previous.clicked.connect(lambda: 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('下一页') - self.orders_next.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - self.orders_next.linkActivated.connect(lambda _link: self._change_orders_page(1)) + self.orders_next = QPushButton("下一页") + self.orders_next.clicked.connect(lambda: self._change_orders_page(1)) footer.addWidget(self.orders_next) layout.addLayout(footer) - return widget + return page - def open_for(self, diagnosis_id: int, *, editable: bool = False, seed: Any = None) -> None: + def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea: + scroll = QScrollArea() + scroll.setObjectName(object_name) + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + content = QWidget() + layout = QVBoxLayout(content) + layout.setContentsMargins(16, 14, 16, 18) + layout.setSpacing(12) + layout.addWidget(body) + layout.addStretch(1) + scroll.setWidget(content) + return scroll + + def _new_daily_panel(self, object_name: str) -> DailyRecordPanel: + panel = DailyRecordPanel() + panel.setObjectName(object_name) + panel.range_requested.connect( + lambda start, end: self._load_daily_range(start, end, force=True) + ) + panel.refresh_requested.connect(lambda: self._ensure_tab_loaded("daily", force=True)) + panel.todo_status_requested.connect(self._daily_todo_filter_changed) + panel.daily_add_requested.connect(self._add_daily_record) + panel.daily_edit_requested.connect(self._edit_daily_record) + panel.todo_add_requested.connect(self._add_diagnosis_todo) + panel.todo_cancel_requested.connect(self._cancel_diagnosis_todo) + self._daily_panels.append(panel) + return panel + + def _new_table(self, kind: str, object_name: str) -> RecordTable: + definitions: dict[str, tuple[tuple[str, int], ...]] = { + "daily": (("日期", 120), ("指标", 150), ("记录", 180), ("记录人", 110)), + "prescription": ( + ("就诊日期", 120), + ("就诊编号", 120), + ("诊断", 180), + ("处方摘要", 220), + ("医生", 90), + ("状态", 140), + ("操作", 120), + ), + "orders": ( + ("订单编号", 200), + ("全局诊次", 96), + ("计入统计", 96), + ("金额", 120), + ("医生", 110), + ("医助", 110), + ("履约状态", 110), + ("创建时间", 170), + ("操作", 100), + ), + "video": ( + ("录制回放", 440), + ("开始时间", 170), + ("结束时间", 170), + ("通话类型", 100), + ("房间号", 180), + ("时长", 110), + ("状态", 90), + ("录制", 100), + ("操作", 130), + ), + "assign": ( + ("操作时间", 175), + ("原医助", 120), + ("新医助", 120), + ("继承", 72), + ("快照·业务单创建人", 150), + ("快照·创建时间", 190), + ("操作人", 110), + ("操作账号", 120), + ("IP", 130), + ), + "appointment": ( + ("ID", 72), + ("状态", 100), + ("患者(挂号人)", 150), + ("挂号医生", 110), + ("挂号助理", 110), + ("预约日期", 130), + ("时段", 80), + ("类型", 100), + ("渠道", 110), + ("确认诊单", 92), + ("开方", 80), + ("备注", 120), + ("创建时间", 165), + ), + } + table = RecordTable(object_name, definitions[kind]) + table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self._table_registry[kind].append(table) + return table + + def _tab_allowed(self, codes: tuple[str, ...]) -> bool: + return not codes or has_permission(self.permissions, codes, default=True) + + def refresh_permissions(self, permissions: Any | None = None) -> None: + if permissions is not None: + self.permissions = permissions + 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._can_order_detail = has_permission( + self.permissions, _ORDER_DETAIL_PERMISSION, default=False + ) + self._can_order_logs = has_permission( + self.permissions, _ORDER_LOGS_PERMISSION, default=False + ) + self._can_daily_mutate = has_permission( + self.permissions, _DAILY_PERMISSION, default=False + ) and all( + callable(getattr(self.repository, name, None)) + for name in ( + "add_blood_record", + "add_diet_record", + "add_exercise_record", + "add_tracking_note", + "add_diagnosis_todo", + "cancel_diagnosis_todo", + ) + ) + self._can_daily_edit = has_permission( + self.permissions, _DAILY_PERMISSION, default=False + ) and all( + callable(getattr(self.repository, name, None)) + for name in ( + "update_blood_record", + "update_diet_record", + "update_exercise_record", + ) + ) + self._can_prescribe = has_permission( + self.permissions, _PRESCRIPTION_PERMISSION, default=False + ) and callable(getattr(self.repository, "create_prescription", None)) + self._can_offset = has_permission( + self.permissions, _OFFSET_PERMISSION, default=False + ) and callable(getattr(self.repository, "set_revisit_slot_start_offset", None)) + self._can_video_upload = has_permission( + self.permissions, _VIDEO_PERMISSION, default=False + ) and callable(getattr(self.repository, "upload_call_recording", None)) + self._can_chat_sync = has_permission( + self.permissions, _CHAT_PERMISSION, default=False + ) and callable(getattr(self.repository, "sync_im_chat_messages", None)) + self._can_note_add = has_permission( + self.permissions, _NOTE_ADD_PERMISSION, default=False + ) and callable(getattr(self.repository, "add_doctor_note", None)) + self._can_note_delete = has_permission( + self.permissions, _NOTE_DELETE_PERMISSION, default=False + ) and callable(getattr(self.repository, "delete_doctor_note_image", None)) + current_key = self.tabs.tabBar().tabData(self.tabs.currentIndex()) + self.tabs.clear() + for key, label, codes in _TAB_DEFINITIONS: + if not self._tab_allowed(codes): + continue + index = self.tabs.addTab(self._tab_pages[key], label) + self.tabs.tabBar().setTabData(index, key) + target = next( + ( + index + for index in range(self.tabs.count()) + if self.tabs.tabBar().tabData(index) == current_key + ), + 0, + ) + self.tabs.setCurrentIndex(target) + section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS} + for key, section in self._readonly_sections.items(): + section.setVisible(self._tab_allowed(section_codes.get(key, ()))) + for panel in self._daily_panels: + panel.set_editable( + self._editable and self._can_daily_mutate, + record_editable=self._editable and self._can_daily_edit, + ) + for timeline in self._notes_timelines: + timeline.set_editable( + self._editable and timeline is self.drawer_notes_timeline and self._can_note_add, + can_delete=( + self._editable + and timeline is self.drawer_notes_timeline + and self._can_note_delete + ), + ) + self.prescribe_button.setVisible(self._editable and self._can_prescribe) + self.video_upload_button.setVisible(self._editable and self._can_video_upload) + offset_visible = self._editable and self._can_offset + self.order_offset.setVisible(offset_visible) + self.order_offset_save.setVisible(offset_visible) + self._sync_order_offset_actions() + for panel in self._chat_panels: + panel.sync_button.setVisible(self._can_chat_sync) + + def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None: + self.drawer_banner.show_message(text, kind, action_text) + if self._standalone_readonly and kind == "danger": + self.readonly_error_label.setText(text) + self.readonly_error.show() + + def _clear_message(self) -> None: + self.drawer_banner.clear() + self.readonly_error.hide() + + def _set_loading(self, loading: bool) -> None: + overlay = self.readonly_loading if self._standalone_readonly else self.drawer_loading + overlay.setVisible(loading) + if loading: + overlay.sync_geometry() + + def _reflow_form(self, viewport_width: int) -> None: + """Mirror the admin breakpoint: desktop grid survives a 1024px viewport.""" + + narrow = viewport_width <= 768 + if narrow == self._form_narrow: + return + self._form_narrow = narrow + for row_layout, _fields, remainder in self._form_rows: + row_layout.setDirection( + QBoxLayout.Direction.TopToBottom if narrow else QBoxLayout.Direction.LeftToRight + ) + row_layout.setSpacing(18 if narrow else 12) + if remainder is not None: + remainder.setVisible(not narrow) + + def _sync_host_geometry(self) -> None: + owner = self._owner + if owner is None: + if self.width() < 760 or self.height() < 520: + self.resize(1024, 640) + return + if owner is self: + return + origin = owner.mapToGlobal(QPoint(0, 0)) + self.setGeometry(QRect(origin, owner.size())) + + def _update_drawer_geometry(self) -> None: + if not hasattr(self, "drawer_panel"): + return + drawer_width = self.width() if self.width() <= 768 else max(456, round(self.width() * 0.60)) + self.drawer_panel.setFixedWidth(drawer_width) + self._reflow_form(self.width()) + + def _reflow_readonly_hero(self) -> None: + if not hasattr(self, "readonly_hero_layout"): + return + layout = self.readonly_hero_layout + layout.removeWidget(self.readonly_hero_left) + layout.removeWidget(self.readonly_hero_right) + if self.width() < 900: + layout.addWidget(self.readonly_hero_left, 0, 0) + layout.addWidget(self.readonly_hero_right, 1, 0, Qt.AlignmentFlag.AlignLeft) + else: + layout.addWidget(self.readonly_hero_left, 0, 0) + layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight) + + def _install_owner_filter(self) -> None: + if self._owner is not None and not self._owner_filter_installed: + self._owner.installEventFilter(self) + self._owner_filter_installed = True + + def _rebind_owner(self) -> None: + """Resolve the live Shell window for every open/show cycle.""" + + parent = self.parentWidget() + candidate = parent.window() if parent is not None else None + if candidate is self: + candidate = None + if candidate is not self._owner: + if self._owner is not None and self._owner_filter_installed: + with suppress(RuntimeError): + self._owner.removeEventFilter(self) + self._owner = candidate + self._owner_filter_installed = False + self._install_owner_filter() + + def resizeEvent(self, event: Any) -> None: + self._update_drawer_geometry() + self._reflow_readonly_hero() + if hasattr(self, "readonly_loading"): + self.readonly_loading.sync_geometry() + if hasattr(self, "drawer_loading"): + self.drawer_loading.sync_geometry() + super().resizeEvent(event) + + def showEvent(self, event: Any) -> None: + self._rebind_owner() + self._sync_host_geometry() + self._update_drawer_geometry() + self._reflow_readonly_hero() + super().showEvent(event) + self._queue_owner_sync() + + def _queue_owner_sync(self) -> None: + generation = self._generation + QTimer.singleShot(0, lambda: self._sync_owner_if_current(generation)) + + def _sync_owner_if_current(self, generation: int) -> None: + if generation == self._generation and self.isVisible(): + self._sync_host_geometry() + + def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API + if watched is self._owner and event.type() in { + QEvent.Type.Resize, + QEvent.Type.Move, + QEvent.Type.Show, + }: + self._sync_host_geometry() + self._update_drawer_geometry() + self._queue_owner_sync() + elif watched is self._owner and event.type() == QEvent.Type.Close: + self._invalidate_requests() + QTimer.singleShot(0, self.reject) + return super().eventFilter(watched, event) + + def reject(self) -> None: + self._invalidate_requests() + super().reject() + + def done(self, result: int) -> None: + self._invalidate_requests() + if self._owner is not None and self._owner_filter_installed: + with suppress(RuntimeError): + self._owner.removeEventFilter(self) + self._owner_filter_installed = False + super().done(result) + + def open_view_only(self, diagnosis_id: int, *, seed: Any = None) -> None: + self.open_for(diagnosis_id, editable=False, seed=seed, view_only=True) + + def open_for( + self, + diagnosis_id: int, + *, + editable: bool = False, + seed: Any = None, + view_only: bool = False, + ) -> None: """Open immediately, then replace the seed with authoritative server data.""" + self._rebind_owner() + for player in list(self._recording_players): + with suppress(RuntimeError): + player.close() + self._recording_players.clear() + self._stop_inline_recordings() + if self._last_order_detail_dialog is not None: + with suppress(RuntimeError): + self._last_order_detail_dialog.close() + self._last_order_detail_dialog = None 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._view_only = bool(view_only and not self._editable) + self._standalone_readonly = not self._editable and not self._view_only + self._load_mode = ( + "readonly" if self._standalone_readonly else "edit" if self._editable else "view_only" + ) + self._authoritative_detail_loaded = False + self._saving = False self._generation += 1 self._save_generation += 1 self._orders_generation += 1 + self._order_detail_generation += 1 + self._daily_mutation_generation += 1 + self._notes_mutation_generation += 1 + self._media_generation += 1 + self._offset_generation += 1 + for key in self._tab_generations: + self._tab_generations[key] += 1 + self._loaded_tabs.clear() + self._loading_tabs.clear() + self._daily_todo_status = None 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.save_button.set_state("idle") + self.refresh_permissions() + self.view_stack.setCurrentWidget( + self.readonly_page if self._standalone_readonly else self.drawer_overlay + ) + self.setModal(not self._standalone_readonly) + self.setWindowTitle( + "患者信息详情" + if self._standalone_readonly + else "编辑诊单" + 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", "warning" if self._editable else "info") + repolish(self.mode_label) + self.drawer_panel.setProperty("mode", "edit" if self._editable else "viewOnly") + repolish(self.drawer_panel) + self.close_button.setText("取消" if self._editable else "关闭") + for timeline in self._notes_timelines: + timeline.set_editable( + self._editable and timeline is self.drawer_notes_timeline and self._can_note_add, + can_delete=( + self._editable + and timeline is self.drawer_notes_timeline + and self._can_note_delete + ), ) self._clear_tables() + self._clear_message() if seed is not None: self._render(seed, [], []) - self.banner.show_message("正在加载完整诊单与历史记录…", "info") - self.open() + self._sync_form_interactivity() + self._sync_save_button() + self._show_message("正在加载权威诊单详情…", "info") + self._set_loading(True) + self._sync_host_geometry() + self.show() + self.raise_() + self._start_detail_load() + + def _start_detail_load(self) -> None: + self._generation += 1 + generation = self._generation diagnosis_id_snapshot = self._diagnosis_id - editable_snapshot = self._editable + mode_snapshot = self._load_mode run_async( - lambda: self._load_bundle(diagnosis_id_snapshot, editable_snapshot), + lambda: self._load_bundle(diagnosis_id_snapshot, mode_snapshot), on_success=lambda result: self._apply_bundle(result, generation), on_error=lambda error: self._load_error(error, generation), - on_finished=lambda: None, + on_finished=lambda: self._load_finished(generation), ) - def _load_bundle(self, diagnosis_id: int, editable: bool) -> dict[str, Any]: + def _load_bundle(self, diagnosis_id: int, mode: str) -> dict[str, Any]: + readonly = mode == "readonly" detail_names = ( - ("get_diagnosis_detail", "patient_detail", "diagnosis_readonly_detail") - if editable - else ("patient_detail", "diagnosis_readonly_detail", "get_diagnosis_detail") + ("diagnosis_readonly_detail", "patient_detail", "get_diagnosis_detail") + if readonly + else ("get_diagnosis_detail",) ) detail = _invoke_first( self.repository, detail_names, diagnosis_id=diagnosis_id, id=diagnosis_id, - readonly=not editable, + readonly=readonly, ) - 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 + if detail in (None, "", [], {}): + raise LookupError("诊单不存在或无权访问") diagnosis = get_value(detail, "diagnosis", None) or detail or {} patient = get_value(detail, "patient", None) or {} patient_id = _int( @@ -516,21 +1897,28 @@ class DiagnosisDialog(QDialog): ), 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)}") + dictionaries: dict[str, list[tuple[str, Any]]] = {} + dictionary_errors: list[str] = [] + if callable(getattr(self.repository, "get_dictionary", None)): + dictionary_types = { + "diagnosis_type", + *(item[0] for item in _CHOICE_DICTIONARIES.values()), + } + for dictionary_type in sorted(dictionary_types): + try: + raw = invoke( + self.repository, + "get_dictionary", + dictionary_type=dictionary_type, + ) + dictionaries[dictionary_type] = _dictionary_choices(raw, dictionary_type) + except Exception as error: + dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}") return { "detail": detail, - "appointments": appointments, - "assignments": assignments, "patient_id": patient_id, - "orders": orders, - "orders_total": orders_total, - "history_errors": history_errors, + "dictionaries": dictionaries, + "dictionary_errors": dictionary_errors, } def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any: @@ -552,24 +1940,166 @@ class DiagnosisDialog(QDialog): 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 [] + self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {}) + self._authoritative_detail_loaded = True + self._show_authoritative_content(True) + self._render(detail, [], []) + self._sync_form_interactivity() + self._sync_save_button() + errors = get_value(result, "dictionary_errors", []) or [] if errors: - self.banner.show_message(";".join(str(item) for item in errors), "warning") + self._show_message(";".join(str(item) for item in errors), "warning") else: - self.banner.clear() + self._clear_message() + if self._standalone_readonly: + self._load_visible_readonly_sections() + else: + self._ensure_tab_loaded(self._current_tab_key()) def _load_error(self, error: Exception, generation: int) -> None: if generation == self._generation: - self.banner.show_message(friendly_error(error), "danger") + self._authoritative_detail_loaded = False + self._detail = None + self._clear_authoritative_content() + self._sync_form_interactivity() + self._sync_save_button() + message = friendly_error(error) + self._show_message(message, "danger", action_text="重试") + self.readonly_error_label.setText(message) + self.readonly_error.show() + + def _load_finished(self, generation: int) -> None: + if generation == self._generation: + self._set_loading(False) + + def _apply_dictionary_choices(self, dictionaries: Mapping[str, Any]) -> None: + diagnosis_type_editor = self.edit_fields.get("diagnosis_type") + if isinstance(diagnosis_type_editor, DiagnosisComboBox): + labels = { + normalized: str(label) + for label, value in dictionaries.get("diagnosis_type", []) + if (normalized := _diagnosis_type_value(value)) + } + diagnosis_type_editor.set_choices( + tuple( + (labels.get(value, fallback), value) + for fallback, value in ( + ("初诊", "first_visit"), + ("复诊", "follow_up"), + ("会诊", "consultation"), + ) + ), + preserve=False, + ) + for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items(): + editor = self._choice_fields.get(key) + choices = list(dictionaries.get(dictionary_type, [])) + if not _multiple: + choices.insert(0, ("无", "")) + if editor is not None: + editor.set_choices(choices, preserve=False) + + def _show_authoritative_content(self, visible: bool) -> None: + self.readonly_patient_card.setVisible(visible) + self.case_grid.setVisible(visible) + for section in self._readonly_sections.values(): + section.setVisible(visible) + if visible: + self.readonly_error.hide() + self.refresh_permissions() + + def _clear_authoritative_content(self) -> None: + self._field_originals.clear() + for field in self.edit_fields.values(): + field.setPlainText("") + field.setProperty("invalid", False) + field.setReadOnly(True) + repolish(field) + for field in self.display_fields.values(): + field.setPlainText("") + self.meta_label.setText(f"诊单 #{self._diagnosis_id} · 详情加载失败") + self.case_grid.set_data({}, {}) + for label in self.summary_fields.values(): + label.setText("—") + self._clear_tables() + self._show_authoritative_content(False) + + def _sync_form_interactivity(self) -> None: + for key, field in self.edit_fields.items(): + locked = ( + not self._authoritative_detail_loaded + or not self._editable + or (key in _PATIENT_BASIC_FIELDS and self._basic_locked) + or (key in {"phone", "id_card"} and not self._can_phone_plain) + ) + field.setReadOnly(locked) + repolish(field) + + def _current_tab_key(self) -> str: + index = self.tabs.currentIndex() + return str(self.tabs.tabBar().tabData(index) or "basic") if index >= 0 else "basic" + + def _sync_save_button(self) -> None: + visible = self._editable and self._current_tab_key() == "basic" + self.save_button.setVisible(visible) + self.save_button.setEnabled( + visible and self._authoritative_detail_loaded and not self._saving + ) + if self._saving: + self.save_button.set_state("loading") + elif self.save_button.state == "loading": + self.save_button.set_state("idle") + + def _retry_detail(self) -> None: + if self._diagnosis_id <= 0: + return + self._authoritative_detail_loaded = False + self.readonly_error.hide() + self._clear_message() + self._show_message("正在重新加载权威诊单详情…", "info") + self._set_loading(True) + self._sync_form_interactivity() + self._sync_save_button() + self._start_detail_load() + + def _retry_current(self) -> None: + if not self._authoritative_detail_loaded: + self._retry_detail() + return + self._ensure_tab_loaded(self._current_tab_key(), force=True) + + def _invalidate_requests(self) -> None: + self._generation += 1 + self._save_generation += 1 + self._orders_generation += 1 + self._order_detail_generation += 1 + self._daily_mutation_generation += 1 + self._notes_mutation_generation += 1 + self._media_generation += 1 + self._offset_generation += 1 + for key in self._tab_generations: + self._tab_generations[key] += 1 + self._loading_tabs.clear() + self._saving = False + self.save_button.set_state("idle") + for player in list(getattr(self, "_recording_players", [])): + with suppress(RuntimeError): + player.close() + self._recording_players.clear() + self._stop_inline_recordings() + order_dialog = getattr(self, "_last_order_detail_dialog", None) + if order_dialog is not None: + with suppress(RuntimeError): + order_dialog.close() + self._last_order_detail_dialog = None + if hasattr(self, "readonly_loading"): + self.readonly_loading.hide() + if hasattr(self, "drawer_loading"): + self.drawer_loading.hide() + for panel in getattr(self, "_daily_panels", []): + panel.set_loading(False) def _render(self, detail: Any, appointments: Sequence[Any], assignments: Sequence[Any]) -> None: diagnosis = get_value(detail, "diagnosis", None) or detail or {} @@ -581,7 +2111,7 @@ class DiagnosisDialog(QDialog): "name", default=first_value(patient, "patient_name", "name", default="未命名患者"), ) - gender = gender_text( + gender = _diagnosis_gender_text( first_value( diagnosis, "gender_desc", @@ -590,37 +2120,24 @@ class DiagnosisDialog(QDialog): ) ) 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.meta_label.setText(f"{patient_name} · 诊单 #{self._diagnosis_id}") + self.readonly_hero_name.setText(display_text(patient_name)) + self.readonly_hero_meta.setText(f"{gender} · {age}岁") + self.readonly_patient_title.setText(display_text(patient_name)) 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"), - ), - ) - ) - ) + raw_phone = first_value( + diagnosis, + "phone", + "patient_phone", + default=first_value( + patient, + "phone", + "patient_phone", + default=first_value(appointment, "patient_phone"), + ), ) + rendered_phone = raw_phone if self._can_phone_plain else _mask_phone(raw_phone) + self.summary_fields["phone"].setText(display_text(rendered_phone)) id_card = first_value( diagnosis, "id_card", @@ -634,6 +2151,9 @@ class DiagnosisDialog(QDialog): 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" ) + self.summary_fields["region"].setText( + display_text(first_value(diagnosis, "region", default=first_value(patient, "region"))) + ) appointment_text = " ".join( part for part in ( @@ -641,13 +2161,35 @@ class DiagnosisDialog(QDialog): display_text( first_value(appointment, "appointment_time", "appointment_time_text"), "" ), + display_text(first_value(appointment, "period", "period_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'))}" + f"医生:{display_text(first_value(appointment, 'doctor_name'))} " + f"客服:{display_text(first_value(appointment, 'assistant_name'))}" + ) + self.summary_fields["appointment_status"].setText( + display_text( + first_value( + appointment, + "status_desc", + "status_text", + default=first_value(diagnosis, "appointment_status_desc", "appointment_status"), + ) + ) + ) + has_prescription = _truthy( + first_value(diagnosis, "has_prescription", "is_prescribed", default=False) + ) + self.summary_fields["prescription_status"].setText( + "已开方" if has_prescription else "未开方" + ) + self.summary_fields["remark"].setText( + display_text( + first_value(appointment, "remark", default=first_value(diagnosis, "remark")) + ) ) self.summary_fields["pressure"].setText( f"{display_text(first_value(diagnosis, 'systolic_pressure'))} / " @@ -656,9 +2198,54 @@ class DiagnosisDialog(QDialog): self.summary_fields["blood_sugar"].setText( f"{display_text(first_value(diagnosis, 'fasting_blood_sugar'))} mmol/L" ) + offset = max( + 0, + min( + 20, + _int( + first_value( + diagnosis, + "revisit_slot_start_offset", + "revisitSlotStartOffset", + default=0, + ), + 0, + ), + ), + ) + blocked = self.order_offset.blockSignals(True) + self.order_offset.setValue(offset) + self.order_offset.blockSignals(blocked) + self._saved_order_offset = offset + self._order_offset_changed(offset) + self._render_unserved(detail, diagnosis) + self.case_grid.set_data(diagnosis, patient) + if not self._can_phone_plain: + for label in self.case_grid.value_labels.get("phone", []): + label.setText(display_text(_mask_phone(raw_phone))) + for label in self.case_grid.value_labels.get("id_card", []): + label.setText(display_text(_mask_id_card(id_card))) + self.display_fields["__diagnosis_id"].setPlainText(str(self._diagnosis_id)) + basic_locked = _truthy( first_value(diagnosis, "patient_basic_locked", default=False) ) or not _truthy(first_value(diagnosis, "can_edit_patient_basic", default=True)) + self._basic_locked = basic_locked + self.form_host.setProperty("locked", basic_locked) + repolish(self.form_host) + self.privacy_banner.setVisible((self._editable or self._view_only) and basic_locked) + latest_order = first_value(diagnosis, "latest_prescription_order", default=None) + if latest_order: + order_id = first_value(latest_order, "id", "order_no") + order_status = first_value( + latest_order, "fulfillment_status_text", "status_text", "status" + ) + self.privacy_label.setText( + f"最近业务订单 #{display_text(order_id)} 状态为「{display_text(order_status)}」," + "患者基本信息不可修改" + ) + else: + self.privacy_label.setText("存在未完成的业务订单,患者基本信息不可修改") self._field_originals.clear() for key, field in self.edit_fields.items(): raw = first_value( @@ -674,6 +2261,10 @@ class DiagnosisDialog(QDialog): ), ), ) + if key == "appetite" and raw in (None, "", [], ()): + raw = first_value(diagnosis, "oral_condition", "mouth_condition", default="") + if key == "diagnosis_type": + raw = _diagnosis_type_value(raw) self._field_originals[key] = raw if key == "phone" and not self._can_phone_plain: rendered = _mask_phone(raw) @@ -682,31 +2273,823 @@ class DiagnosisDialog(QDialog): else: rendered = _editor_text(raw) field.setPlainText(rendered) + field.setProperty("invalid", False) 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) ) + repolish(field) - 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_notes([]) + latest_appointments = list(appointments) + if not latest_appointments and appointment: + latest_appointments = [appointment] + self._fill_appointments(latest_appointments) self._fill_assignments(assignments) + def _render_unserved(self, detail: Any, diagnosis: Any) -> None: + value = get_value(detail, "unserved_days", None) + if value is None: + value = first_value(diagnosis, "unserved_days", default=None) + if value in (None, ""): + self.readonly_status.setText("从未打卡") + severity = "neutral" + else: + days = _int(value, 0) + self.readonly_status.setText(f"未服务 {days} 天") + severity = "success" if days <= 2 else "warning" if days <= 6 else "danger" + self.readonly_status.setProperty("severity", severity) + repolish(self.readonly_status) + + @staticmethod + def _status_kind(value: Any) -> str: + text = str(value or "") + if any(token in text for token in ("失败", "驳回", "作废", "取消", "异常")): + return "danger" + if any(token in text for token in ("待", "未", "过号", "爽约")): + return "warning" + if any(token in text for token in ("已", "完成", "成功", "确认")): + return "success" + return "info" + + def _semanticize(self, table: RecordTable, column: int) -> None: + for row in range(table.rowCount()): + item = table.item(row, column) + if item is not None: + set_status_tag( + table, + row, + column, + item.text(), + self._status_kind(item.text()), + ) + + def _tab_changed(self, _index: int) -> None: + self._sync_save_button() + if self._authoritative_detail_loaded and not self._standalone_readonly: + self._ensure_tab_loaded(self._current_tab_key()) + + def _load_visible_readonly_sections(self) -> None: + permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS} + for key in ("notes", "daily", "orders", "video", "chat", "assign", "appointment"): + if self._tab_allowed(permission_map.get(key, ())): + self._ensure_tab_loaded(key) + + def _set_tab_loading(self, key: str) -> None: + if key == "notes": + for timeline in self._notes_timelines: + timeline.set_notes([]) + timeline.set_state("正在加载医生备注…") + elif key == "daily": + for panel in self._daily_panels: + panel.set_loading(True) + elif key == "chat": + for panel in self._chat_panels: + panel.set_unavailable("正在加载聊天归档…") + elif key in self._table_registry: + for table in self._table_registry[key]: + table.set_matrix([]) + table.set_empty_text("正在加载…") + + def _set_tab_error(self, key: str, text: str) -> None: + message = f"加载失败:{text}" + if key == "notes": + for timeline in self._notes_timelines: + timeline.set_state(message) + elif key == "daily": + for panel in self._daily_panels: + panel.set_error(message) + elif key == "chat": + for panel in self._chat_panels: + panel.set_unavailable(message) + elif key in self._table_registry: + for table in self._table_registry[key]: + table.set_empty_text(message) + + def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None: + if key == "basic" or not self._authoritative_detail_loaded or self._diagnosis_id <= 0: + return + if key == "daily": + source = next( + (panel for panel in self._daily_panels if panel.isVisibleTo(self)), + self._daily_panels[0], + ) + start, end = source.current_range() + self._load_daily_range(start, end, force=force) + return + if not force and (key in self._loaded_tabs or key in self._loading_tabs): + return + self._tab_generations[key] += 1 + generation = self._tab_generations[key] + diagnosis_id = self._diagnosis_id + self._loading_tabs.add(key) + self._set_tab_loading(key) + run_async( + lambda: self._query_tab(key, diagnosis_id), + on_success=lambda result: self._apply_tab_result(key, result, diagnosis_id, generation), + on_error=lambda error: self._tab_load_error(key, error, diagnosis_id, generation), + ) + + def _query_tab(self, key: str, diagnosis_id: int) -> Any: + if key == "notes": + return _invoke_first(self.repository, ("get_doctor_notes",), diagnosis_id=diagnosis_id) + if key == "prescription": + return _invoke_first( + self.repository, + ("list_prescriptions_by_diagnosis",), + diagnosis_id=diagnosis_id, + ) + if key == "orders": + return self._query_orders(diagnosis_id, self._patient_id, 1) + if key == "assign": + return _invoke_first( + self.repository, + ("assign_history",), + diagnosis_id=diagnosis_id, + page_no=1, + page_size=50, + ) + if key == "appointment": + return _invoke_first( + self.repository, + ("appointment_history",), + diagnosis_id=diagnosis_id, + page_no=1, + page_size=500, + ) + if key == "video": + names = tuple( + name + for name in ("list_call_records", "get_call_records") + if callable(getattr(self.repository, name, None)) + ) + if not names: + return {"unsupported": "桌面端尚未接入视频回放列表接口。"} + return _invoke_first(self.repository, names, diagnosis_id=diagnosis_id) + if key == "chat": + names = tuple( + name + for name in ("list_im_chat_messages", "get_im_chat_messages", "list_chat_records") + if callable(getattr(self.repository, name, None)) + ) + if not names: + return {"unsupported": "桌面端尚未接入 IM 聊天归档接口。"} + return _invoke_first( + self.repository, + names, + diagnosis_id=diagnosis_id, + only_archived=True, + ) + return [] + + def _apply_tab_result( + self, + key: str, + result: Any, + diagnosis_id: int, + generation: int, + ) -> None: + if ( + generation != self._tab_generations[key] + or diagnosis_id != self._diagnosis_id + or not self._authoritative_detail_loaded + ): + return + self._loading_tabs.discard(key) + self._loaded_tabs.add(key) + unsupported = get_value(result, "unsupported", "") + if unsupported: + if key == "chat": + for panel in self._chat_panels: + panel.set_unavailable(str(unsupported)) + else: + for table in self._table_registry.get(key, []): + table.set_matrix([]) + table.set_empty_text(str(unsupported)) + elif key == "notes": + self._fill_notes(page_items(result)) + elif key == "prescription": + self._fill_prescriptions(page_items(result)) + elif key == "orders": + rows = page_items(result) + self._orders_page = 1 + self._orders_total = page_total(result, len(rows)) + self._fill_orders(rows) + self._update_orders_pager() + elif key == "assign": + self._fill_assignments(page_items(result)) + elif key == "appointment": + self._fill_appointments(page_items(result)) + elif key == "video": + self._fill_video(page_items(result)) + elif key == "chat": + rows = page_items(result) + for panel in self._chat_panels: + panel.set_messages(rows) + if not self._standalone_readonly and self._current_tab_key() == key: + self._clear_message() + + def _tab_load_error( + self, + key: str, + error: Exception, + diagnosis_id: int, + generation: int, + ) -> None: + if generation != self._tab_generations[key] or diagnosis_id != self._diagnosis_id: + return + self._loading_tabs.discard(key) + text = friendly_error(error) + self._set_tab_error(key, text) + if not self._standalone_readonly and self._current_tab_key() == key: + self._show_message(text, "danger", action_text="重试") + + def _load_daily_range(self, start: str, end: str, *, force: bool = False) -> None: + key = "daily" + if not self._authoritative_detail_loaded or self._diagnosis_id <= 0: + return + if not force and (key in self._loaded_tabs or key in self._loading_tabs): + return + self._tab_generations[key] += 1 + generation = self._tab_generations[key] + diagnosis_id = self._diagnosis_id + status = self._daily_todo_status + self._loading_tabs.add(key) + for panel in self._daily_panels: + panel.set_range(start, end) + self._set_tab_loading(key) + + def load() -> dict[str, Any]: + window = _invoke_first( + self.repository, + ("get_tracking_window",), + diagnosis_id=diagnosis_id, + start_date=start, + end_date=end, + ) + notes = _invoke_first( + self.repository, + ("list_tracking_notes",), + diagnosis_id=diagnosis_id, + ) + todos: Any = [] + if callable(getattr(self.repository, "list_diagnosis_todos", None)): + todos = invoke( + self.repository, + "list_diagnosis_todos", + diagnosis_id=diagnosis_id, + page_no=1, + page_size=15, + status=status, + ) + return {"window": window, "notes": notes, "todos": todos} + + run_async( + load, + on_success=lambda result: self._apply_daily_result(result, diagnosis_id, generation), + on_error=lambda error: self._tab_load_error(key, error, diagnosis_id, generation), + ) + + def _apply_daily_result(self, result: Any, diagnosis_id: int, generation: int) -> None: + key = "daily" + if generation != self._tab_generations[key] or diagnosis_id != self._diagnosis_id: + return + self._loading_tabs.discard(key) + self._loaded_tabs.add(key) + window = get_value(result, "window", {}) or {} + notes = page_items(get_value(result, "notes", []) or []) + todo_result = get_value(result, "todos", []) or [] + todos = page_items(todo_result) + total = page_total(todo_result, len(todos)) + for panel in self._daily_panels: + panel.set_data(window, notes) + panel.set_todos(todos, total) + if not self._standalone_readonly and self._current_tab_key() == key: + self._clear_message() + + def _daily_todo_filter_changed(self, status: Any) -> None: + self._daily_todo_status = None if status is None else _int(status) + source = next( + (panel for panel in self._daily_panels if panel.isVisibleTo(self)), + self._daily_panels[0], + ) + start, end = source.current_range() + self._load_daily_range(start, end, force=True) + + def _add_daily_record(self, kind: str) -> None: + if not self._editable or not self._can_daily_mutate: + self._show_message("当前账号无日常记录写入权限或接口不可用。", "warning") + return + if self._patient_id <= 0: + self._show_message("诊单缺少患者 ID,无法新增日常记录。", "danger") + return + record_date = QDate.currentDate().toString("yyyy-MM-dd") + base: dict[str, Any] = { + "diagnosis_id": self._diagnosis_id, + "patient_id": self._patient_id, + "record_date": record_date, + } + if kind in {"blood", "diet", "exercise"}: + editor = DailyRecordEditorDialog(kind, base, parent=self) + if isinstance(editor, QWidget): + editor.setStyleSheet(DIAGNOSIS_QSS) + if editor.exec() == QDialog.DialogCode.Accepted: + self._submit_daily_record(kind, editor.payload()) + return + if kind == "tracking": + content, accepted = QInputDialog.getMultiLineText( + self, "新增跟踪备注", "备注内容(最多 1000 字)" + ) + if accepted: + self._submit_daily_record(kind, {"content": content}) + + def _edit_daily_record(self, kind: str, record: Any) -> None: + if not self._editable or not self._can_daily_edit: + self._show_message("当前账号无日常记录编辑权限或接口不可用。", "warning") + return + if kind not in {"blood", "diet", "exercise"}: + return + source = dict(record) if isinstance(record, Mapping) else {} + record_id = _int(first_value(source, "id"), 0) + if record_id <= 0: + self._show_message("该记录缺少 ID,无法安全编辑。", "warning") + return + source_diagnosis_id = _int(first_value(source, "diagnosis_id"), 0) + source_patient_id = _int(first_value(source, "patient_id"), 0) + if source_diagnosis_id not in {0, self._diagnosis_id} or source_patient_id not in { + 0, + self._patient_id, + }: + self._show_message("记录归属与当前诊单不一致,已拒绝编辑。", "danger") + return + source.update( + { + "id": record_id, + "diagnosis_id": self._diagnosis_id, + "patient_id": self._patient_id, + } + ) + editor = DailyRecordEditorDialog(kind, source, parent=self) + if isinstance(editor, QWidget): + editor.setStyleSheet(DIAGNOSIS_QSS) + if editor.exec() == QDialog.DialogCode.Accepted: + self._submit_daily_record(kind, editor.payload()) + + def _submit_daily_record(self, kind: str, payload: Mapping[str, Any]) -> None: + add_operations = { + "blood": "add_blood_record", + "diet": "add_diet_record", + "exercise": "add_exercise_record", + "tracking": "add_tracking_note", + } + update_operations = { + "blood": "update_blood_record", + "diet": "update_diet_record", + "exercise": "update_exercise_record", + } + frozen = dict(payload) + if kind != "tracking": + payload_diagnosis_id = _int(frozen.get("diagnosis_id"), 0) + payload_patient_id = _int(frozen.get("patient_id"), 0) + if payload_diagnosis_id != self._diagnosis_id or payload_patient_id != self._patient_id: + self._show_message("日常记录归属与当前诊单不一致,已拒绝提交。", "danger") + return + record_id = _int(frozen.get("id"), 0) + updating = record_id > 0 and kind in update_operations + if updating and (not self._editable or not self._can_daily_edit): + self._show_message("当前账号无日常记录编辑权限或接口不可用。", "warning") + return + if not updating and (not self._editable or not self._can_daily_mutate): + self._show_message("当前账号无日常记录写入权限或接口不可用。", "warning") + return + method = update_operations.get(kind) if updating else add_operations.get(kind) + if method is None or not callable(getattr(self.repository, method, None)): + self._show_message("该日常记录接口不可用。", "danger") + return + diagnosis_id = self._diagnosis_id + + def operation() -> Any: + if kind == "tracking": + return invoke( + self.repository, + method, + diagnosis_id=diagnosis_id, + content=str(frozen.get("content") or ""), + ) + if updating: + return invoke(self.repository, method, record=frozen) + return invoke(self.repository, method, payload=frozen) + + self._run_daily_mutation(operation, "日常记录已更新。" if updating else "日常记录已保存。") + + def _add_diagnosis_todo(self) -> None: + if not self._editable or not self._can_daily_mutate: + return + content, accepted = QInputDialog.getMultiLineText(self, "新增待办", "待办内容(1–500 字)") + if not accepted: + return + default_time = QDateTime.currentDateTime().addSecs(3600).toString("yyyy-MM-dd HH:mm") + time_text, accepted = QInputDialog.getText( + self, "新增待办", "提醒时间(YYYY-MM-DD HH:mm)", text=default_time + ) + if not accepted: + return + remind_at = QDateTime.fromString(time_text.strip(), "yyyy-MM-dd HH:mm") + if not remind_at.isValid(): + self._show_message("提醒时间格式不正确。", "warning") + return + self._run_daily_mutation( + lambda: invoke( + self.repository, + "add_diagnosis_todo", + diagnosis_id=self._diagnosis_id, + content=content, + remind_time=remind_at.toSecsSinceEpoch(), + ), + "待办已新增。", + ) + + def _cancel_diagnosis_todo(self, todo_id: int) -> None: + if not self._editable or not self._can_daily_mutate or todo_id <= 0: + return + answer = QMessageBox.question( + self, + "取消待办", + "确定取消这条待办吗?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Yes: + return + self._run_daily_mutation( + lambda: invoke(self.repository, "cancel_diagnosis_todo", todo_id=todo_id), + "待办已取消。", + ) + + def _run_daily_mutation(self, operation: Any, success_text: str) -> None: + self._daily_mutation_generation += 1 + generation = self._daily_mutation_generation + diagnosis_id = self._diagnosis_id + self._show_message("正在提交…", "info") + run_async( + operation, + on_success=lambda _result: self._daily_mutation_success( + success_text, diagnosis_id, generation + ), + on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "daily"), + ) + + def _daily_mutation_success(self, text: str, diagnosis_id: int, generation: int) -> None: + if generation != self._daily_mutation_generation or diagnosis_id != self._diagnosis_id: + return + self._show_message(text, "success") + self._loaded_tabs.discard("daily") + source = next( + (panel for panel in self._daily_panels if panel.isVisibleTo(self)), + self._daily_panels[0], + ) + self._load_daily_range(*source.current_range(), force=True) + + def _mutation_error( + self, + error: Exception, + diagnosis_id: int, + generation: int, + kind: str, + ) -> None: + current = { + "daily": self._daily_mutation_generation, + "notes": self._notes_mutation_generation, + "media": self._media_generation, + "offset": self._offset_generation, + }[kind] + if generation == current and diagnosis_id == self._diagnosis_id: + self._show_message(friendly_error(error), "danger") + + def _add_doctor_note(self) -> None: + if not self._editable or not self._can_note_add: + return + content, accepted = QInputDialog.getMultiLineText( + self, "新增 / 追加诊疗笔记", "笔记内容(最多 500 字)" + ) + if not accepted: + return + self._run_notes_mutation( + lambda: invoke( + self.repository, + "add_doctor_note", + diagnosis_id=self._diagnosis_id, + content=content, + ), + "诊疗笔记已保存。", + ) + + def _upload_doctor_note_material(self, image_type: str) -> None: + if ( + image_type not in {"tongue_images", "report_files"} + or not self._editable + or not self._can_note_add + ): + return + if not all( + callable(getattr(self.repository, name, None)) + for name in ("upload_material", "add_doctor_note") + ): + self._show_message("附件上传接口不可用。", "warning") + return + caption = "选择舌象图片" if image_type == "tongue_images" else "选择检查报告" + file_filter = ( + "图片 (*.png *.jpg *.jpeg *.webp)" + if image_type == "tongue_images" + else "报告文件 (*.pdf *.png *.jpg *.jpeg *.doc *.docx);;所有文件 (*)" + ) + path, _selected = QFileDialog.getOpenFileName(self, caption, "", file_filter) + if not path: + return + diagnosis_id = self._diagnosis_id + + def operation() -> Any: + reference = invoke( + self.repository, + "upload_material", + path=path, + material_type=image_type, + ) + kwargs: dict[str, Any] = { + "diagnosis_id": diagnosis_id, + "content": "", + image_type: [str(reference)], + } + return invoke(self.repository, "add_doctor_note", **kwargs) + + self._run_notes_mutation(operation, "附件已上传并写入诊疗笔记。") + + def _delete_doctor_note_attachment( + self, note_id: int, image_type: str, image_path: str + ) -> None: + if ( + not self._editable + or not self._can_note_delete + or note_id <= 0 + or image_type not in {"tongue_images", "report_files"} + or not callable(getattr(self.repository, "delete_doctor_note_image", None)) + ): + return + answer = QMessageBox.warning( + self, + "删除附件", + "只删除当前附件,诊疗笔记正文会保留。确定继续吗?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Yes: + return + self._run_notes_mutation( + lambda: invoke( + self.repository, + "delete_doctor_note_image", + note_id=note_id, + image_type=image_type, + image_path=image_path, + ), + "附件已删除。", + ) + + def _run_notes_mutation(self, operation: Any, success_text: str) -> None: + self._notes_mutation_generation += 1 + generation = self._notes_mutation_generation + diagnosis_id = self._diagnosis_id + self._show_message("正在更新诊疗笔记…", "info") + run_async( + operation, + on_success=lambda _result: self._notes_mutation_success( + success_text, diagnosis_id, generation + ), + on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "notes"), + ) + + def _notes_mutation_success(self, text: str, diagnosis_id: int, generation: int) -> None: + if generation != self._notes_mutation_generation or diagnosis_id != self._diagnosis_id: + return + self._show_message(text, "success") + self._loaded_tabs.discard("notes") + self._ensure_tab_loaded("notes", force=True) + + def _open_new_prescription(self) -> None: + if not self._editable or not self._can_prescribe: + self._show_message("当前账号无开方权限或接口不可用。", "warning") + return + from .prescription import PrescriptionEditorDialog + + detail = self._detail or {} + diagnosis = get_value(detail, "diagnosis", None) or detail + appointment = get_value(detail, "appointment", None) or {} + case_record = first_value(diagnosis, "case_record", default={}) or {} + diagnosis_id = self._diagnosis_id + appointment_id = _int(first_value(appointment, "id", "appointment_id"), 0) + seed = { + "diagnosis_id": diagnosis_id, + "appointment_id": appointment_id, + "case_record": dict(case_record) if isinstance(case_record, Mapping) else {}, + "patient_name": first_value(diagnosis, "patient_name", "name"), + "gender": _int(first_value(diagnosis, "gender"), 0), + "age": _int(first_value(diagnosis, "age"), 0), + "visit_no": f"1K{appointment_id:08d}" if appointment_id > 0 else "", + "tongue": first_value(diagnosis, "tongue", "tongue_coating"), + "pulse": first_value(diagnosis, "pulse"), + "clinical_diagnosis": first_value( + diagnosis, "clinical_diagnosis", "diagnosis", default="" + ), + "doctor_name": first_value( + appointment, "doctor_name", default=first_value(diagnosis, "doctor_name") + ), + } + dialog = PrescriptionEditorDialog( + self.repository, + seed, + mode="add", + current_user=getattr(self.parentWidget(), "current_user", None), + parent=self, + ) + dialog.setObjectName("DiagnosisPrescriptionEditor") + dialog.setStyleSheet(DIAGNOSIS_QSS) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + payload = dialog.payload() + payload.update( + { + "diagnosis_id": diagnosis_id, + "appointment_id": appointment_id, + "case_record": seed["case_record"], + } + ) + self._run_media_mutation( + lambda: invoke(self.repository, "create_prescription", prescription=payload), + "处方已开具并提交审核。", + "prescription", + ) + + def _upload_call_recording(self, call_record_id: int | None = None) -> None: + if not self._editable or not self._can_video_upload: + return + if call_record_id is not None and call_record_id <= 0: + self._show_message("通话记录 ID 无效,无法追加回放。", "warning") + return + path, _selected = QFileDialog.getOpenFileName( + self, + "上传本地视频回放", + "", + "视频 (*.mp4 *.mov *.m4v *.webm);;所有文件 (*)", + ) + if not path: + return + self._run_media_mutation( + lambda: invoke( + self.repository, + "upload_call_recording", + path=path, + diagnosis_id=self._diagnosis_id, + call_record_id=call_record_id, + ), + "视频回放已追加到指定通话记录。" + if call_record_id is not None + else "视频回放已上传并创建通话记录。", + "video", + ) + + def _sync_chat_archive(self) -> None: + if not self._can_chat_sync or self._diagnosis_id <= 0: + self._show_message("当前账号无聊天归档同步权限或接口不可用。", "warning") + return + self._run_media_mutation( + lambda: invoke( + self.repository, + "sync_im_chat_messages", + diagnosis_id=self._diagnosis_id, + ), + "已提交聊天归档同步,请稍后重新加载。", + "chat", + ) + + def _run_media_mutation(self, operation: Any, success_text: str, reload_key: str) -> None: + self._media_generation += 1 + generation = self._media_generation + diagnosis_id = self._diagnosis_id + self._show_message("正在提交…", "info") + run_async( + operation, + on_success=lambda _result: self._media_mutation_success( + success_text, reload_key, diagnosis_id, generation + ), + on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "media"), + ) + + def _media_mutation_success( + self, + text: str, + reload_key: str, + diagnosis_id: int, + generation: int, + ) -> None: + if generation != self._media_generation or diagnosis_id != self._diagnosis_id: + return + self._show_message(text, "success") + self._loaded_tabs.discard(reload_key) + if reload_key == "chat": + QTimer.singleShot( + 800, + lambda: ( + self._ensure_tab_loaded("chat", force=True) + if generation == self._media_generation + else None + ), + ) + else: + self._ensure_tab_loaded(reload_key, force=True) + + def _order_offset_changed(self, value: int) -> None: + self.order_offset_preview.setText(f"第 1 笔实单计为{_visit_slot_label(value)}") + self._sync_order_offset_actions() + + def _sync_order_offset_actions(self) -> None: + if not hasattr(self, "order_offset_save"): + return + can_edit = self._editable and self._can_offset + dirty = self.order_offset.value() != self._saved_order_offset + saving = self.order_offset_save.text() == "保存中…" + self.order_offset.setEnabled(can_edit and not saving) + self.order_offset_save.setEnabled(can_edit and dirty and not saving) + + def _save_order_offset(self) -> None: + if not self._editable or not self._can_offset or self._diagnosis_id <= 0: + return + self._offset_generation += 1 + generation = self._offset_generation + diagnosis_id = self._diagnosis_id + offset = self.order_offset.value() + self.order_offset.setEnabled(False) + self.order_offset_save.setEnabled(False) + self.order_offset_save.setText("保存中…") + self._show_message("正在保存复诊统计起始偏移…", "info") + run_async( + lambda: invoke( + self.repository, + "set_revisit_slot_start_offset", + diagnosis_id=diagnosis_id, + offset=offset, + ), + on_success=lambda _result: self._offset_success(diagnosis_id, offset, generation), + on_error=lambda error: self._offset_error(error, diagnosis_id, generation), + ) + + def _offset_success(self, diagnosis_id: int, offset: int, generation: int) -> None: + if generation != self._offset_generation or diagnosis_id != self._diagnosis_id: + return + self._saved_order_offset = offset + self.order_offset_save.setText("保存") + self._sync_order_offset_actions() + self._show_message("保存成功", "success") + self._ensure_tab_loaded("orders", force=True) + + def _offset_error(self, error: Exception, diagnosis_id: int, generation: int) -> None: + if generation != self._offset_generation or diagnosis_id != self._diagnosis_id: + return + self.order_offset_save.setText("保存") + self._sync_order_offset_actions() + self._mutation_error(error, diagnosis_id, generation, "offset") + + def _open_safe_resource(self, target: str) -> None: + url = QUrl(str(target).strip()) + if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host(): + QMessageBox.warning(self, "无法打开", "仅允许打开 HTTP(S) 服务端附件或回放。") + return + if not QDesktopServices.openUrl(url): + QMessageBox.warning(self, "无法打开", "系统未能打开该安全外部链接。") + 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") + empty_text = { + "daily": "切换到日常记录后加载数据。", + "prescription": "切换到处方后加载病历记录。", + "orders": "切换到业务订单后加载数据。", + "video": "切换到视频录制回放后检查桌面端接口。", + "assign": "切换到指派记录后加载数据。", + "appointment": "切换到挂号记录后加载数据。", + } + for kind, tables in self._table_registry.items(): + for table in tables: + table.set_matrix([]) + table.set_empty_text(empty_text.get(kind, "暂无数据")) + for timeline in self._notes_timelines: + timeline.set_state("暂无医生备注") + timeline.set_notes([]) + for panel in self._chat_panels: + panel.set_unavailable("切换到聊天记录后加载归档数据。") + for panel in self._daily_panels: + panel.clear() + self.orders_summary.setText("共 0 条") + self.orders_page_label.setText("1 / 1") @staticmethod def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None: @@ -727,57 +3110,988 @@ class DiagnosisDialog(QDialog): first_value(row, "content", "note", "remark"), ), ) + for timeline in self._notes_timelines: + timeline.empty.setText("暂无医生备注") + timeline.set_notes(rows) 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"), + matrix = [ + ( + first_value(row, "id", "appointment_id"), + first_value(row, "status_desc", "status_text", "status"), + "\n".join( + part + for part in ( + display_text(first_value(row, "patient_name"), ""), + display_text( + first_value(row, "patient_phone", "phone") + if self._can_phone_plain + else _mask_phone(first_value(row, "patient_phone", "phone")), + "", + ), + ) + if part ), + first_value(row, "doctor_name"), + first_value(row, "assistant_name"), + first_value(row, "appointment_date"), + first_value(row, "appointment_time", "period"), + first_value(row, "appointment_type_text", "appointment_type"), + " / ".join( + part + for part in ( + display_text(first_value(row, "channel_name", "channel_source"), ""), + display_text(first_value(row, "channel_source_detail"), ""), + ) + if part + ), + "已确认" + if _truthy(first_value(row, "is_confirmed", "confirmed", default=False)) + else "未确认", + "已开方" + if _truthy(first_value(row, "has_prescription", "is_prescribed", default=False)) + else "未开方", + first_value(row, "remark"), + first_value(row, "create_time", "created_at"), ) + for row in rows + ] + for table in self._table_registry["appointment"]: + table.set_empty_text("暂无挂号记录。") + table.set_matrix(matrix) + self._semanticize(table, 1) + self._semanticize(table, 9) + self._semanticize(table, 10) 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"), - ), + matrix = [ + ( + 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 _truthy(first_value(row, "is_inherit", default=False)) else "否", + first_value(row, "snapshot_order_creator_name", "order_creator_name"), + first_value(row, "snapshot_order_create_time", "order_create_time"), + first_value(row, "operator_name"), + first_value(row, "operator_account"), + first_value(row, "ip"), ) + for row in rows + ] + for table in self._table_registry["assign"]: + table.set_empty_text("暂无指派记录。") + table.set_matrix(matrix) + for row_index in range(table.rowCount()): + item = table.item(row_index, 3) + if item is not None: + set_tag_item(item, "success" if item.text() == "是" else "neutral") + + @staticmethod + def _action_button(text: str, tooltip: str = "") -> QPushButton: + button = QPushButton(text) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setToolTip(tooltip) + button.setProperty("variant", "link") + return button + + def _fill_prescriptions(self, rows: Sequence[Any]) -> None: + matrix: list[tuple[Any, ...]] = [] + for row in rows: + herbs = first_value(row, "herbs", default=[]) or [] + summary = first_value(row, "prescription_summary", "summary", default="") + if ( + not summary + and isinstance(herbs, Sequence) + and not isinstance(herbs, (str, bytes, bytearray)) + ): + summary = "、".join( + f"{display_text(first_value(herb, 'name'), '')}" + f"{display_text(first_value(herb, 'dosage'), '')}克" + for herb in list(herbs)[:3] + ) + if len(herbs) > 3: + summary += "…" + voided = _truthy(first_value(row, "void_status", default=False)) + matrix.append( + ( + first_value(row, "prescription_date", "visit_date", "create_time"), + first_value(row, "visit_no", "sn", "id"), + first_value(row, "clinical_diagnosis", "diagnosis"), + summary, + first_value(row, "doctor_name"), + "已作废" + if voided + else first_value(row, "status_text", "audit_status_text", default="正常"), + "", + ) + ) + for table in self._table_registry["prescription"]: + table.set_empty_text("暂无病历记录,开方后会自动显示。") + table.set_matrix(matrix) + self._semanticize(table, 5) + for row_index, row in enumerate(rows): + button = self._action_button("查看", "打开真实处方详情") + button.clicked.connect( + lambda _checked=False, source=row: self._view_prescription(source) + ) + table.setCellWidget(row_index, 6, button) + + def _view_prescription(self, prescription: Any) -> None: + from .prescription import PrescriptionDetailDialog + + dialog = PrescriptionDetailDialog( + prescription, + can_open_diagnosis=False, + can_open_orders=False, + parent=self, + ) + dialog.exec() + + def _stop_inline_recordings(self) -> None: + for cell in list(getattr(self, "_inline_recording_cells", [])): + with suppress(RuntimeError): + cell.stop() + self._inline_recording_cells.clear() + + def _fill_video(self, rows: Sequence[Any]) -> None: + self._stop_inline_recordings() + matrix: list[tuple[Any, ...]] = [] + row_urls: list[list[str]] = [] + record_ids: list[int] = [] + for row in rows: + raw_urls = first_value(row, "recording_urls_list", "recording_urls", default=[]) or [] + if isinstance(raw_urls, str) or not isinstance(raw_urls, Sequence): + raw_urls = [raw_urls] + direct = str(first_value(row, "recording_url", "url", default="") or "").strip() + normalized: list[str] = [] + for candidate in ([direct] if direct else []) + list(raw_urls): + url = str(candidate or "").strip() + if url and url not in normalized: + normalized.append(url) + row_urls.append(normalized) + record_ids.append(_int(first_value(row, "id", "call_record_id"), 0)) + call_type = _raw_value(row, "call_type_text") + if call_type is _MISSING: + raw_call_type = _raw_value(row, "call_type") + if raw_call_type is _MISSING: + call_type = "—" + else: + call_type = "语音" if _int(raw_call_type, 2) == 1 else "视频" + status = _raw_value(row, "status_text") + if status is _MISSING: + raw_status = _raw_value(row, "status") + status = { + 1: "进行中", + 2: "已结束", + 3: "未接听", + 4: "已取消", + }.get(_int(raw_status, -1), "—") + matrix.append( + ( + "" if normalized else "暂无录制回放", + first_value(row, "start_time_text", "start_time"), + first_value(row, "end_time_text", "end_time"), + call_type, + first_value(row, "room_id", "room_no"), + first_value(row, "duration_text", "duration"), + status, + first_value( + row, "recording_status_text", "record_status_text", "record_status" + ), + "", + ) + ) + for table in self._table_registry["video"]: + table.set_empty_text("暂无通话记录。") + table.set_matrix(matrix) + self._semanticize(table, 6) + playback_rows: list[tuple[int, RecordingPlaybackCell]] = [] + for row_index, urls in enumerate(row_urls): + if urls: + playback = RecordingPlaybackCell( + urls, + record_id=record_ids[row_index], + render_owner=self, + owner_generation=self._tab_generations["video"], + ) + playback.fallback_requested.connect(self._open_recording_player) + self._inline_recording_cells.append(playback) + playback_rows.append((row_index, playback)) + table.setCellWidget(row_index, 0, playback) + item = table.item(row_index, 0) + if item is not None: + item.setText("") + call_record_id = record_ids[row_index] + if self._editable and self._can_video_upload and call_record_id > 0: + upload = self._action_button( + "追加回放", f"上传并绑定通话记录 #{call_record_id}" + ) + upload.setProperty("variant", "secondary") + upload.setObjectName("DiagnosisVideoRowUpload") + upload.setProperty("callRecordId", call_record_id) + upload.setMinimumHeight(28) + upload.setMaximumHeight(30) + upload.setSizePolicy( + QSizePolicy.Policy.Preferred, + QSizePolicy.Policy.Fixed, + ) + upload.clicked.connect( + lambda _checked=False, selected=call_record_id: self._upload_call_recording( + selected + ) + ) + upload_host = QWidget() + upload_host.setObjectName("DiagnosisVideoRowUploadCell") + upload_layout = QVBoxLayout(upload_host) + upload_layout.setContentsMargins(0, 0, 0, 0) + upload_layout.addWidget(upload, 0, Qt.AlignmentFlag.AlignCenter) + table.setCellWidget(row_index, 8, upload_host) + upload_item = table.item(row_index, 8) + if upload_item is not None: + upload_item.setText("") + else: + table.setItem(row_index, 8, QTableWidgetItem("—")) + table.resizeRowsToContents() + # A fixed-height action button in another column makes Qt's content + # resize pass collapse the whole section to roughly 30 px. Restore + # the playback cell's measured layout height after that final pass; + # retaining the item hint also documents the row's semantic size for + # delegates and future table-layout changes. + for row_index, playback in playback_rows: + required_height = playback.required_table_row_height() + item = table.item(row_index, 0) + if item is not None: + hint = playback.sizeHint() + hint.setHeight(required_height) + item.setSizeHint(hint) + table.setRowHeight(row_index, required_height) + + def _open_recording_player(self, target: str) -> None: + player = RecordingPlayerDialog(target, parent=self) + self._recording_players.append(player) + + def release(*_args: Any) -> None: + with suppress(ValueError): + self._recording_players.remove(player) + + player.destroyed.connect(release) + player.show() 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, + matrix: list[tuple[Any, ...]] = [] + for row in rows: + visit_sequence = _raw_value( + row, + "global_visit_seq", + "global_visit_sequence", + "revisit_slot", + "visit_sequence", + ) + counts = _raw_value( + row, + "counts_for_revisit_rate", + "statistics_count", + "quantity", + "count", + ) + fulfillment = _raw_value(row, "fulfillment_status_text", "status_text") + if fulfillment is _MISSING: + fulfillment = _fulfillment_text(_raw_value(row, "fulfillment_status", "status")) + visit_text = display_text(visit_sequence) + if visit_sequence is not _MISSING and not visit_text.endswith("诊"): + visit_text = f"{visit_text}诊" + matrix.append( ( 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"), + "—" if visit_sequence is _MISSING else visit_text, + "—" if counts is _MISSING else ("是" if _truthy(counts) else "否"), + _money_text(_raw_value(row, "amount")), + first_value(row, "doctor_name"), + first_value(row, "assistant_name"), + fulfillment, first_value(row, "create_time", "created_at"), - ), + "", + ) ) + for table in self._table_registry["orders"]: + table.set_empty_text("暂无业务订单。") + table.set_matrix(matrix, danger_columns=(3,), right_columns=(3,)) + self._semanticize(table, 6) + for row_index, values in enumerate(matrix): + counts_text = str(values[2]) + if counts_text in {"是", "否"}: + set_status_tag( + table, + row_index, + 2, + counts_text, + "success" if counts_text == "是" else "neutral", + ) + for row_index, row in enumerate(rows): + order_id = _int(first_value(row, "id", "order_id"), 0) + if ( + self._can_order_detail + and order_id > 0 + and callable(getattr(self.repository, "get_prescription_order", None)) + ): + button = self._action_button("查看详情", "加载真实业务订单详情") + button.clicked.connect( + lambda _checked=False, selected=order_id: self._view_order(selected) + ) + table.setCellWidget(row_index, 8, button) + elif not self._can_order_detail: + table.setItem(row_index, 8, QTableWidgetItem("无详情权限")) + else: + table.setItem(row_index, 8, QTableWidgetItem("详情不可用")) + + def _view_order(self, order_id: int) -> None: + if not self._can_order_detail: + self._show_message("当前账号无业务订单详情权限。", "warning") + return + if order_id <= 0 or not callable(getattr(self.repository, "get_prescription_order", None)): + self._show_message("当前订单没有可用的详情接口。", "warning") + return + self._order_detail_generation += 1 + generation = self._order_detail_generation + diagnosis_id = self._diagnosis_id + self._show_message("正在加载订单详情…", "info") + run_async( + lambda: invoke( + self.repository, + "get_prescription_order", + order_id=order_id, + id=order_id, + ), + on_success=lambda result: self._order_detail_success( + result, diagnosis_id, order_id, generation + ), + on_error=lambda error: self._order_detail_error(error, diagnosis_id, generation), + ) + + def _order_detail_success( + self, + detail: Any, + diagnosis_id: int, + order_id: int, + generation: int, + ) -> None: + if generation != self._order_detail_generation or diagnosis_id != self._diagnosis_id: + return + self._clear_message() + payload = detail + if isinstance(detail, Mapping): + for key in ("data", "detail"): + nested = detail.get(key) + if isinstance(nested, Mapping) and any( + field in nested for field in ("id", "order_no", "prescription_id") + ): + payload = nested + break + dialog = self._build_order_detail_dialog(payload, order_id) + self._last_order_detail_dialog = dialog + + def clear_dialog(_result: int) -> None: + if self._last_order_detail_dialog is dialog: + self._last_order_detail_dialog = None + + dialog.finished.connect(clear_dialog) + dialog.open() + + def _build_order_detail_dialog(self, detail: Any, order_id: int) -> QDialog: + dialog = OrderDetailDrawer(self) + header = QFrame() + header.setObjectName("DiagnosisOrderDetailHeader") + header_layout = QHBoxLayout(header) + header_layout.setContentsMargins(20, 15, 18, 15) + header_layout.setSpacing(12) + title_stack = QVBoxLayout() + title_stack.setSpacing(4) + heading = QLabel("业务订单详情") + heading.setObjectName("DiagnosisOrderDetailTitle") + title_stack.addWidget(heading) + order_number_value = _raw_value(detail, "order_no", "sn") + order_number = QLabel( + f"订单编号:{display_text(order_number_value)}" + if order_number_value is not _MISSING + else f"订单ID:#{order_id}" + ) + order_number.setObjectName("DiagnosisOrderDetailMeta") + order_number.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + title_stack.addWidget(order_number) + references: list[str] = [] + gancao_no = _raw_value(detail, "gancao_reciperl_order_no") + if gancao_no is not _MISSING: + references.append(f"甘草 {display_text(gancao_no)}") + pharmacy_no = _raw_value(detail, "ej_pharmacy_order_no") + if pharmacy_no is not _MISSING: + references.append(f"洛阳 {display_text(pharmacy_no)}") + if references: + reference = QLabel(" · ".join(references)) + reference.setObjectName("DiagnosisOrderDetailMeta") + reference.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + title_stack.addWidget(reference) + header_layout.addLayout(title_stack, 1) + readonly_badge = QLabel("只读") + readonly_badge.setObjectName("DiagnosisOrderReadonlyBadge") + header_layout.addWidget(readonly_badge, 0, Qt.AlignmentFlag.AlignVCenter) + status_value = _raw_value(detail, "fulfillment_status_text", "status_text") + status_text = ( + display_text(status_value) + if status_value is not _MISSING + else _fulfillment_text(_raw_value(detail, "fulfillment_status", "status")) + ) + if status_text == "—": + status_text = "状态未返回" + status = QLabel(status_text) + status.setObjectName("DiagnosisOrderStatus") + status.setProperty("diagnosisTag", True) + status.setProperty("kind", self._status_kind(status_text)) + status.setAlignment(Qt.AlignmentFlag.AlignCenter) + header_layout.addWidget(status, 0, Qt.AlignmentFlag.AlignVCenter) + close_header = QPushButton("关闭") + close_header.setObjectName("DiagnosisOrderDetailClose") + close_header.setProperty("variant", "secondary") + close_header.clicked.connect(dialog.reject) + header_layout.addWidget(close_header, 0, Qt.AlignmentFlag.AlignVCenter) + dialog.content_layout.addWidget(header) + + scroll = QScrollArea() + scroll.setObjectName("DiagnosisOrderDetailScroll") + scroll.setWidgetResizable(True) + content = QWidget() + body = QVBoxLayout(content) + body.setContentsMargins(18, 16, 18, 22) + body.setSpacing(14) + self._add_order_amount_overview(body, detail) + self._add_order_prescription_section(body, detail) + self._add_order_payment_section(body, detail) + self._add_order_fulfillment_section(body, detail) + self._add_order_logistics_section(body, detail) + self._add_order_logs_section(body, detail) + body.addStretch(1) + scroll.setWidget(content) + dialog.content_layout.addWidget(scroll, 1) + + footer = QFrame() + footer.setObjectName("DiagnosisOrderDetailFooter") + actions = QHBoxLayout(footer) + actions.setContentsMargins(18, 10, 18, 10) + source_note = QLabel("只读 · 数据来自业务订单详情接口") + source_note.setObjectName("DiagnosisOrderDetailMeta") + actions.addWidget(source_note) + actions.addStretch(1) + close = QPushButton("关闭") + close.setProperty("variant", "secondary") + close.clicked.connect(dialog.reject) + actions.addWidget(close) + dialog.content_layout.addWidget(footer) + dialog.sync_to_owner() + return dialog + + def _new_order_section( + self, + title: str, + hint: str = "", + ) -> tuple[QFrame, QVBoxLayout]: + section = QFrame() + section.setProperty("orderDetailSection", True) + section.setObjectName(f"DiagnosisOrderSection{title}") + layout = QVBoxLayout(section) + layout.setContentsMargins(15, 14, 15, 16) + layout.setSpacing(11) + heading = QHBoxLayout() + heading.setSpacing(10) + title_label = QLabel(title) + title_label.setProperty("orderSectionTitle", True) + heading.addWidget(title_label) + if hint: + hint_label = QLabel(hint) + hint_label.setProperty("orderSectionHint", True) + hint_label.setWordWrap(True) + heading.addWidget(hint_label, 1) + else: + heading.addStretch(1) + layout.addLayout(heading) + return section, layout + + def _add_order_empty(self, layout: QVBoxLayout, text: str) -> QLabel: + empty = QLabel(text) + empty.setProperty("orderEmptyState", True) + empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + empty.setWordWrap(True) + empty.setMinimumHeight(62) + layout.addWidget(empty) + return empty + + def _add_order_field_grid( + self, + layout: QVBoxLayout, + fields: Sequence[tuple[str, Any]], + *, + columns: int = 3, + ) -> None: + host = QWidget() + grid = QGridLayout(host) + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(8) + for column in range(columns): + grid.setColumnStretch(column, 1) + for index, (caption, raw_value) in enumerate(fields): + card = QFrame() + card.setProperty("orderField", True) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(10, 8, 10, 9) + card_layout.setSpacing(4) + label = QLabel(caption) + label.setProperty("orderFieldLabel", True) + card_layout.addWidget(label) + value = QLabel(display_text(raw_value)) + value.setProperty("orderFieldValue", True) + value.setWordWrap(True) + value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + card_layout.addWidget(value, 1) + grid.addWidget(card, index // columns, index % columns) + layout.addWidget(host) + + def _add_order_amount_overview(self, parent: QVBoxLayout, detail: Any) -> None: + section, layout = self._new_order_section( + "金额概览", + "需代收优先使用后端快照;缺少快照时仅在总额与已付总额都返回后计算。", + ) + linked_rows, linked_present = _detail_rows(detail, "linked_pay_orders", "pay_orders") + total = _number(_raw_value(detail, "amount", "effective_amount")) + paid = _number(_raw_value(detail, "linked_pay_paid_total", "paid_total")) + if paid is None and linked_present: + amounts = [ + _number(_raw_value(row, "amount", "pay_amount", "paid_amount")) + for row in linked_rows + ] + if not linked_rows: + paid = 0.0 + elif all(amount is not None for amount in amounts): + paid = sum(amount for amount in amounts if amount is not None) + refund = _number(_raw_value(detail, "refund_amount")) + agency = _number(_raw_value(detail, "agency_collect_amount")) + if agency is None and total is not None and paid is not None: + agency = round((total - paid) * 100) / 100 + cards = ( + ("总金额", _money_text(total), "danger"), + ("已付总额", _money_text(paid), "success"), + ("退款金额", _money_text(refund), "danger" if refund and refund > 0 else "neutral"), + ("需代收", _money_text(agency), "warning" if agency and agency > 0 else "neutral"), + ("已付笔数", f"{len(linked_rows)} 笔" if linked_present else "—", "info"), + ) + host = QWidget() + grid = QGridLayout(host) + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(8) + for index, (caption, value, tone) in enumerate(cards): + card = QFrame() + card.setProperty("orderAmountCard", True) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(11, 10, 11, 11) + card_layout.setSpacing(4) + title = QLabel(caption) + title.setProperty("orderAmountTitle", True) + card_layout.addWidget(title) + amount = QLabel(value) + amount.setProperty("orderAmountValue", True) + amount.setProperty("orderAmountTone", tone) + amount.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + card_layout.addWidget(amount) + grid.addWidget(card, index // 5, index % 5) + grid.setColumnStretch(index % 5, 1) + layout.addWidget(host) + parent.addWidget(section) + + def _add_order_prescription_section(self, parent: QVBoxLayout, detail: Any) -> None: + prescription = _raw_value(detail, "prescription") + doctor = _raw_value(detail, "doctor_name") + hint = "" if doctor is _MISSING else f"开方人:{display_text(doctor)}" + section, layout = self._new_order_section("处方详情", hint) + error = _raw_value(detail, "prescription_detail_error") + if error is not _MISSING: + warning = QLabel(display_text(error)) + warning.setProperty("orderEmptyState", True) + warning.setProperty("kind", "warning") + warning.setWordWrap(True) + layout.addWidget(warning) + if prescription is _MISSING or prescription is None or prescription == {}: + self._add_order_empty(layout, "无处方数据(详情接口未返回 prescription)") + parent.addWidget(section) + return + + gender = _raw_value(prescription, "gender_desc") + if gender is _MISSING: + raw_gender = _raw_value(prescription, "gender") + gender = _diagnosis_gender_text(raw_gender) if raw_gender is not _MISSING else "—" + age = _raw_value(prescription, "age") + gender_age_parts = [display_text(gender)] + if age is not _MISSING: + gender_age_parts.append(f"{display_text(age)}岁") + + dose_count = _raw_value(detail, "dose_count") + if dose_count is _MISSING: + dose_count = _raw_value(prescription, "dose_count") + dose_unit = _raw_value(detail, "dose_unit") + if dose_unit is _MISSING: + dose_unit = _raw_value(prescription, "dose_unit") + usage_method = _raw_value(prescription, "usage_instruction", "usage_method") + dose_parts: list[str] = [] + if dose_count is not _MISSING: + dose_parts.append( + f"{display_text(dose_count)}{display_text(dose_unit, '') if dose_unit is not _MISSING else ''}" + ) + if usage_method is not _MISSING: + dose_parts.append(display_text(usage_method)) + + dosage_amount = _raw_value(prescription, "dosage_amount") + dosage_parts: list[str] = [] + if dosage_amount is not _MISSING: + dosage_unit = _raw_value(prescription, "dosage_unit") + dosage_parts.append( + f"{display_text(dosage_amount)}{display_text(dosage_unit, '') if dosage_unit is not _MISSING else ''}" + ) + bag_count = _raw_value(prescription, "dosage_bag_count") + if bag_count is not _MISSING: + dosage_parts.append(f"{display_text(bag_count)}袋") + decoction = _raw_value(prescription, "need_decoction") + if decoction is not _MISSING: + dosage_parts.append("代煎" if _truthy(decoction) else "不代煎") + + usage_parts: list[str] = [] + times = _raw_value(prescription, "times_per_day") + if times is not _MISSING: + usage_parts.append(f"每天 {display_text(times)} 次") + usage_days = _raw_value(prescription, "usage_days") + if usage_days is not _MISSING: + usage_parts.append(f"处方开立 {display_text(usage_days)} 天") + medication_days = _raw_value(detail, "medication_days") + if medication_days is not _MISSING: + usage_parts.append(f"订单设置 {display_text(medication_days)} 天") + + dietary = _display_collection(_raw_value(prescription, "dietary_taboo")) + void_status = _raw_value(prescription, "void_status") + prescription_status = ( + "—" if void_status is _MISSING else ("已作废" if _truthy(void_status) else "正常") + ) + fields = ( + ("处方编号", _raw_value(prescription, "sn", "id", default="—")), + ("患者", _raw_value(prescription, "patient_name", default="—")), + ("性别 / 年龄", " · ".join(gender_age_parts)), + ("手机", _raw_value(prescription, "phone", default="—")), + ("处方日期", _raw_value(prescription, "prescription_date", default="—")), + ("医师", _raw_value(prescription, "doctor_name", default="—")), + ("类型", _raw_value(prescription, "prescription_type", default="—")), + ("临床诊断", _raw_value(prescription, "clinical_diagnosis", default="—")), + ("剂数 / 用法", " · ".join(dose_parts) or "—"), + ("处方金额", _money_text(_raw_value(prescription, "amount"))), + ("消费者审核", _audit_text(_raw_value(prescription, "audit_status"))), + ("用量", " · ".join(dosage_parts) or "—"), + ("服用方式", " · ".join(usage_parts) or "—"), + ("忌口", dietary), + ("状态", prescription_status), + ) + self._add_order_field_grid(layout, fields, columns=3) + parent.addWidget(section) + + def _build_order_payment_table( + self, + rows: Sequence[Any], + *, + present: bool, + object_name: str, + absent_text: str, + empty_text: str, + ) -> RecordTable: + table = RecordTable( + object_name, + ( + ("ID", 72), + ("单号", 150), + ("类型", 92), + ("金额", 96), + ("状态", 94), + ("创建人", 88), + ("创建时间", 150), + ), + ) + matrix: list[tuple[Any, ...]] = [] + for row in rows: + status = _raw_value(row, "status_desc", "status_text") + if status is _MISSING: + status = _pay_status_text(_raw_value(row, "status")) + order_type = _raw_value(row, "order_type_desc", "fee_type_text") + if order_type is _MISSING: + order_type = _fee_type_text(_raw_value(row, "order_type", "fee_type")) + matrix.append( + ( + _raw_value(row, "id", default="—"), + _raw_value(row, "order_no", "sn", default="—"), + order_type, + _money_text(_raw_value(row, "amount", "pay_amount", "paid_amount")), + status, + _raw_value(row, "creator_name", "admin_name", default="—"), + _raw_value(row, "create_time", "created_at", default="—"), + ) + ) + table.set_matrix(matrix, danger_columns=(3,), right_columns=(3,)) + table.set_empty_text(empty_text if present else absent_text) + table.setMinimumHeight(145) + table.setMaximumHeight(min(280, max(145, 78 + len(matrix) * 38))) + for row_index, values in enumerate(matrix): + set_status_tag( + table, + row_index, + 4, + str(values[4]), + self._status_kind(values[4]), + ) + return table + + def _add_order_payment_section(self, parent: QVBoxLayout, detail: Any) -> None: + section, layout = self._new_order_section( + "收款记录", + "只读模式不展示收款方式;仅呈现详情接口返回的关联关系。", + ) + linked, linked_present = _detail_rows(detail, "linked_pay_orders", "pay_orders") + linked_title = QLabel("关联收款记录") + linked_title.setProperty("orderFieldValue", True) + layout.addWidget(linked_title) + layout.addWidget( + self._build_order_payment_table( + linked, + present=linked_present, + object_name="DiagnosisOrderLinkedPayments", + absent_text="详情接口未返回关联收款记录字段", + empty_text="未关联收款单", + ) + ) + + unlinked, unlinked_present = _detail_rows( + detail, + "unlinked_pay_orders", + "available_pay_orders", + ) + unlinked_title = QLabel("未关联的收款记录") + unlinked_title.setProperty("orderFieldValue", True) + layout.addWidget(unlinked_title) + layout.addWidget( + self._build_order_payment_table( + unlinked, + present=unlinked_present, + object_name="DiagnosisOrderUnlinkedPayments", + absent_text="详情接口未返回未关联收款记录字段", + empty_text="无未关联的收款单", + ) + ) + parent.addWidget(section) + + def _add_order_fulfillment_section(self, parent: QVBoxLayout, detail: Any) -> None: + section, layout = self._new_order_section("履约与收货信息") + address_parts = [ + _raw_value(detail, key) + for key in ( + "shipping_province", + "shipping_city", + "shipping_district", + "shipping_address", + ) + ] + full_address = " ".join( + display_text(part) for part in address_parts if part is not _MISSING + ) + if not full_address: + full_address = display_text( + _raw_value(detail, "recipient_address", "address", default="—") + ) + creator_parts = [ + _raw_value(detail, "creator_name"), + _raw_value(detail, "creator_account"), + _raw_value(detail, "creator_mobile"), + ] + creator = ( + " · ".join(display_text(part) for part in creator_parts if part is not _MISSING) or "—" + ) + follow_up = _raw_value(detail, "is_follow_up") + fulfillment_status = _raw_value(detail, "fulfillment_status_text", "status_text") + if fulfillment_status is _MISSING: + fulfillment_status = _fulfillment_text( + _raw_value(detail, "fulfillment_status", "status") + ) + medication_days = _raw_value(detail, "medication_days") + fields: list[tuple[str, Any]] = [ + ("诊单ID", _raw_value(detail, "diagnosis_id", default="—")), + ("创建时间", _raw_value(detail, "create_time_text", "create_time", default="—")), + ("业务订单创建人", creator), + ( + "创建人所属部门", + _display_collection( + _raw_value( + detail, + "creator_department_paths", + "creator_department_path", + "creator_dept_path", + "creator_department_name", + ) + ), + ), + ("收货人", _raw_value(detail, "recipient_name", "patient_name", default="—")), + ("收货手机", _raw_value(detail, "recipient_phone", "phone", default="—")), + ("收货地址", full_address), + ( + "复诊", + "—" if follow_up is _MISSING else ("是" if _truthy(follow_up) else "否"), + ), + ( + "用药疗程", + "—" if medication_days is _MISSING else f"{display_text(medication_days)} 天", + ), + ("上次医护", _raw_value(detail, "prev_staff", default="—")), + ("服务渠道", _raw_value(detail, "service_channel", default="—")), + ("服务套餐", _display_collection(_raw_value(detail, "service_package"))), + ("费用类别", _fee_type_text(_raw_value(detail, "fee_type"))), + ("履约状态", fulfillment_status), + ("处方审核", _audit_text(_raw_value(detail, "prescription_audit_status"))), + ("支付审核", _audit_text(_raw_value(detail, "payment_slip_audit_status"))), + ("快递单号", _raw_value(detail, "tracking_number", "express_no", default="—")), + ("快递公司", _express_company_text(_raw_value(detail, "express_company"))), + ("医助备注", _raw_value(detail, "remark_assistant", "remark", default="—")), + ( + "处方审核意见", + _raw_value(detail, "prescription_audit_remark", default="—"), + ), + ( + "支付审核意见", + _raw_value(detail, "payment_slip_audit_remark", default="—"), + ), + ] + if has_permission( + self.permissions, + "tcm.prescriptionOrder/editRemarkExtra", + default=False, + ): + fields.append(("药房备注", _raw_value(detail, "remark_extra", default="—"))) + self._add_order_field_grid(layout, fields, columns=3) + parent.addWidget(section) + + def _add_order_timeline_item( + self, + layout: QVBoxLayout, + *, + timestamp: Any, + title: Any, + body: Any, + ) -> None: + item = QFrame() + item.setObjectName("DiagnosisOrderTimelineItem") + item_layout = QGridLayout(item) + item_layout.setContentsMargins(12, 5, 8, 8) + item_layout.setHorizontalSpacing(12) + item_layout.setVerticalSpacing(3) + time_label = QLabel(display_text(timestamp)) + time_label.setObjectName("DiagnosisOrderTimelineTime") + time_label.setMinimumWidth(132) + time_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + item_layout.addWidget(time_label, 0, 0, 2, 1, Qt.AlignmentFlag.AlignTop) + title_label = QLabel(display_text(title)) + title_label.setObjectName("DiagnosisOrderTimelineTitle") + title_label.setWordWrap(True) + item_layout.addWidget(title_label, 0, 1) + body_label = QLabel(display_text(body)) + body_label.setObjectName("DiagnosisOrderTimelineBody") + body_label.setWordWrap(True) + body_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + item_layout.addWidget(body_label, 1, 1) + item_layout.setColumnStretch(1, 1) + layout.addWidget(item) + + def _add_order_logistics_section(self, parent: QVBoxLayout, detail: Any) -> None: + tracking = _raw_value(detail, "tracking_number", "express_no") + hint = "" if tracking is _MISSING else display_text(tracking) + section, layout = self._new_order_section("物流轨迹", hint) + if tracking is _MISSING: + self._add_order_empty(layout, "订单详情未返回快递单号,暂无物流轨迹") + parent.addWidget(section) + return + + payload = _raw_value( + detail, + "logistics_trace", + "logistics_trace_payload", + "tracking_trace", + "logistics", + ) + traces, traces_present = _detail_rows(detail, "logistics_traces", "tracking_traces") + if payload is not _MISSING and isinstance(payload, Mapping): + nested = payload.get("data") + if isinstance(nested, Mapping): + payload = nested + payload_traces, payload_present = _detail_rows( + payload, "traces", "trace", "items", "lists" + ) + if payload_present: + traces, traces_present = payload_traces, True + state = _raw_value(payload, "state_text", "status_text", "state") + carrier = _raw_value(payload, "carrier_label", "express_company") + metadata: list[tuple[str, Any]] = [] + if state is not _MISSING: + metadata.append(("物流状态", state)) + if carrier is not _MISSING: + metadata.append(("承运商", _express_company_text(carrier))) + if metadata: + self._add_order_field_grid(layout, metadata, columns=2) + if not traces_present: + self._add_order_empty(layout, "详情接口未返回物流轨迹数据") + elif not traces: + self._add_order_empty(layout, "暂无轨迹节点记录") + else: + for trace in traces: + self._add_order_timeline_item( + layout, + timestamp=_raw_value(trace, "time", "create_time", "timestamp", default="—"), + title=_raw_value(trace, "state_text", "status", "location", default="物流节点"), + body=_raw_value(trace, "context", "description", "content", default="—"), + ) + parent.addWidget(section) + + def _add_order_logs_section(self, parent: QVBoxLayout, detail: Any) -> None: + section, layout = self._new_order_section("操作日志") + if not self._can_order_logs: + empty = self._add_order_empty(layout, "当前账号无操作日志查看权限") + empty.setProperty("permissionDenied", True) + parent.addWidget(section) + return + rows, present = _detail_rows(detail, "logs", "operation_logs", "order_logs") + if not present: + self._add_order_empty(layout, "详情接口未返回操作日志数据") + elif not rows: + self._add_order_empty(layout, "暂无操作日志") + else: + for log in rows: + actor = _raw_value(log, "admin_name", "operator_name", "creator_name") + action = _raw_value(log, "action_text") + if action is _MISSING: + action = _log_action_text(_raw_value(log, "action")) + title = ( + " · ".join( + display_text(part) for part in (actor, action) if part is not _MISSING + ) + or "操作记录" + ) + self._add_order_timeline_item( + layout, + timestamp=_raw_value(log, "create_time", "created_at", default="—"), + title=title, + body=_raw_value(log, "summary", "content", "remark", default="—"), + ) + parent.addWidget(section) + + def _order_detail_error(self, error: Exception, diagnosis_id: int, generation: int) -> None: + if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id: + self._show_message(friendly_error(error), "danger") def _update_orders_pager(self) -> None: pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size) @@ -797,7 +4111,7 @@ class DiagnosisDialog(QDialog): generation = self._orders_generation diagnosis_id = self._diagnosis_id patient_id = self._patient_id - self.banner.show_message("正在加载患者订单…", "info") + self._show_message("正在加载患者订单…", "info") run_async( lambda: self._query_orders(diagnosis_id, patient_id, target_page), on_success=lambda result: self._apply_orders_page( @@ -820,14 +4134,23 @@ class DiagnosisDialog(QDialog): self._orders_total = page_total(result, len(rows)) self._fill_orders(rows) self._update_orders_pager() - self.banner.clear() + self._clear_message() 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") + self._show_message(friendly_error(error), "danger") def _save(self) -> None: - if not self._editable or self._diagnosis_id <= 0: + if ( + not self._editable + or self._diagnosis_id <= 0 + or not self._authoritative_detail_loaded + or self._current_tab_key() != "basic" + ): + if self._editable and not self._authoritative_detail_loaded: + self._show_message( + "权威详情尚未加载成功,禁止保存。请先重试。", "danger", action_text="重试" + ) return diagnosis_id = self._diagnosis_id changes = { @@ -835,25 +4158,44 @@ class DiagnosisDialog(QDialog): for key, field in self.edit_fields.items() if key not in {"phone", "id_card"} or self._can_phone_plain } + for key in ("patient_name", "phone", "id_card", "diagnosis_type"): + self.edit_fields[key].setProperty("invalid", False) + repolish(self.edit_fields[key]) patient_name = str(changes.get("patient_name") or "").strip() if not patient_name: - self.banner.show_message("请输入患者姓名。", "warning") + self._show_message("请输入患者姓名。", "warning") + self.edit_fields["patient_name"].setProperty("invalid", True) + repolish(self.edit_fields["patient_name"]) 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._show_message("手机号格式不正确。", "warning") + self.edit_fields["phone"].setProperty("invalid", True) + repolish(self.edit_fields["phone"]) 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._show_message("身份证号格式不正确。", "warning") + self.edit_fields["id_card"].setProperty("invalid", True) + repolish(self.edit_fields["id_card"]) self.edit_fields["id_card"].setFocus() return + diagnosis_type_raw = str(changes.get("diagnosis_type") or "").strip() + diagnosis_type = _diagnosis_type_value(diagnosis_type_raw) + if diagnosis_type_raw and not diagnosis_type: + self._show_message("诊断类型参数无效,请重新选择。", "warning") + self.edit_fields["diagnosis_type"].setProperty("invalid", True) + repolish(self.edit_fields["diagnosis_type"]) + self.edit_fields["diagnosis_type"].setFocus() + return + changes["diagnosis_type"] = diagnosis_type self._save_generation += 1 generation = self._save_generation - self.save_button.setEnabled(False) - self.banner.show_message("正在保存病历…", "info") + self._saving = True + self._sync_save_button() + self._show_message("正在保存病历…", "info") run_async( lambda: self._validate_and_save(diagnosis_id, changes), on_success=lambda _result: self._save_success(generation), @@ -905,16 +4247,19 @@ class DiagnosisDialog(QDialog): def _save_success(self, generation: int) -> None: if generation != self._save_generation: return - self.banner.show_message("病历已保存。", "success") + self.save_button.set_state("success") + self._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") + self.save_button.set_state("error") + self._show_message(friendly_error(error), "danger") def _save_finished(self, generation: int) -> None: if generation == self._save_generation: - self.save_button.setEnabled(True) + self._saving = False + self._sync_save_button() __all__ = ["DiagnosisDialog"] diff --git a/app/src/doctor_workstation/ui/dialogs/prescription.py b/app/src/doctor_workstation/ui/dialogs/prescription.py index 4f9fff75d..498105410 100644 --- a/app/src/doctor_workstation/ui/dialogs/prescription.py +++ b/app/src/doctor_workstation/ui/dialogs/prescription.py @@ -38,6 +38,7 @@ from PySide6.QtWidgets import ( QFileDialog, QFormLayout, QFrame, + QGridLayout, QHBoxLayout, QHeaderView, QLabel, @@ -47,6 +48,7 @@ from PySide6.QtWidgets import ( QMessageBox, QPushButton, QScrollArea, + QSizePolicy, QSpinBox, QTableWidget, QTableWidgetItem, @@ -69,6 +71,285 @@ from ..widgets import ( run_async, ) +PRESCRIPTION_DRAWER_QSS = r""" +QFrame#PrescriptionDrawerSurface { + color: #303133; + background-color: #FFFFFF; + border-left: 1px solid #D9E0E7; + font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; + font-size: 13px; +} +QFrame#PrescriptionDrawerHeader { + background-color: #FFFFFF; + border: 0; + border-bottom: 1px solid #E5E7EB; +} +QLabel#PrescriptionDrawerTitle { + color: #1F2937; + font-size: 20px; + font-weight: 700; +} +QLabel#PrescriptionDrawerSubtitle { + color: #909399; + font-size: 12px; +} +QLabel#PrescriptionDrawerMode { + color: #1677A3; + background-color: #EAF6FB; + border: 1px solid #B9DEEC; + border-radius: 5px; + padding: 4px 9px; + font-size: 12px; + font-weight: 600; +} +QPushButton#PrescriptionDrawerClose { + min-width: 34px; + max-width: 34px; + min-height: 34px; + max-height: 34px; + padding: 0; + color: #606266; + background-color: transparent; + border: 0; + border-radius: 7px; + font-size: 22px; + font-weight: 400; +} +QPushButton#PrescriptionDrawerClose:hover { + color: #303133; + background-color: #F2F4F7; +} +QScrollArea#PrescriptionDrawerBody, +QScrollArea#PrescriptionDrawerBody > QWidget > QWidget { + background-color: #FFFFFF; + border: 0; +} +QWidget#PrescriptionDrawerContent { + background-color: #FFFFFF; +} +QFrame#PrescriptionSection { + background-color: #F5F7FA; + border: 0; + border-radius: 7px; +} +QLabel#PrescriptionSectionTitle { + color: #303133; + font-size: 15px; + font-weight: 600; +} +QLabel#PrescriptionSectionHint { + color: #909399; + font-size: 12px; +} +QWidget#PrescriptionField { + background-color: transparent; +} +QLabel#PrescriptionFieldLabel { + color: #606266; + font-size: 12px; + font-weight: 500; +} +QLabel#PrescriptionUsageMain { + color: #409EFF; + font-size: 13px; + font-weight: 600; +} +QLabel#PrescriptionUsageAux { + color: #D58B1A; + font-size: 13px; + font-weight: 600; +} +QFrame#PrescriptionRpToolbar { + background-color: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 10px; +} +QFrame#PrescriptionRpMarker { + background-color: #409EFF; + border: 0; + border-radius: 2px; +} +QLabel#PrescriptionRpHeading { + color: #303133; + font-size: 16px; + font-weight: 600; +} +QLabel#PrescriptionRpMeta { + color: #7A8492; + font-size: 12px; +} +QLabel#PrescriptionRpLock { + color: #B42318; + background-color: #FEF3F2; + border: 1px solid #FECDCA; + border-radius: 5px; + padding: 5px 8px; + font-size: 12px; +} +QLabel#PrescriptionFormulaTag { + border-radius: 4px; + padding: 3px 8px; + font-size: 12px; + font-weight: 600; +} +QLabel#PrescriptionFormulaTag[formula="main"] { + color: #337ECC; + background-color: #ECF5FF; + border: 1px solid #A0CFFF; +} +QLabel#PrescriptionFormulaTag[formula="aux"] { + color: #B88230; + background-color: #FDF6EC; + border: 1px solid #F3D19E; +} +QLabel#PrescriptionFormulaEmpty { + color: #A8ABB2; + padding: 10px 2px; +} +QFrame#PrescriptionHerbCard { + background-color: #FFFFFF; + border: 1px solid #E4E7ED; + border-radius: 7px; +} +QFrame#PrescriptionHerbCard[duplicate="true"] { + background-color: #FDF6EC; + border: 1px solid #E6A23C; +} +QLabel#PrescriptionHerbCardTitle { + color: #909399; + font-size: 12px; +} +QPushButton#PrescriptionHerbDelete { + min-height: 30px; + padding: 0 6px; + color: #C45656; + background-color: transparent; + border: 0; + border-radius: 5px; +} +QPushButton#PrescriptionHerbDelete:hover { + color: #F56C6C; + background-color: #FEF0F0; +} +QFrame#PrescriptionDrawerFooter { + background-color: #FFFFFF; + border: 0; + border-top: 1px solid #E5E7EB; +} +QFrame#PrescriptionDrawerSurface QLineEdit, +QFrame#PrescriptionDrawerSurface QTextEdit, +QFrame#PrescriptionDrawerSurface QComboBox, +QFrame#PrescriptionDrawerSurface QDateEdit, +QFrame#PrescriptionDrawerSurface QSpinBox, +QFrame#PrescriptionDrawerSurface QDoubleSpinBox { + min-height: 34px; + color: #303133; + background-color: #FFFFFF; + border: 1px solid #D7DCE3; + border-radius: 6px; + padding: 0 9px; + selection-background-color: #D9ECFF; +} +QFrame#PrescriptionDrawerSurface QTextEdit { + padding: 7px 9px; +} +QFrame#PrescriptionDrawerSurface QLineEdit:focus, +QFrame#PrescriptionDrawerSurface QTextEdit:focus, +QFrame#PrescriptionDrawerSurface QComboBox:focus, +QFrame#PrescriptionDrawerSurface QDateEdit:focus, +QFrame#PrescriptionDrawerSurface QSpinBox:focus, +QFrame#PrescriptionDrawerSurface QDoubleSpinBox:focus { + border: 1px solid #409EFF; +} +QFrame#PrescriptionDrawerSurface QLineEdit:read-only { + color: #606266; + background-color: #F0F2F5; +} +QFrame#PrescriptionDrawerSurface QComboBox::drop-down, +QFrame#PrescriptionDrawerSurface QDateEdit::drop-down { + border: 0; + width: 24px; +} +QFrame#PrescriptionDrawerSurface QPushButton { + min-height: 34px; + padding: 0 13px; + color: #337ECC; + background-color: #FFFFFF; + border: 1px solid #A0CFFF; + border-radius: 6px; + font-weight: 600; +} +QFrame#PrescriptionDrawerSurface QPushButton:hover { + color: #FFFFFF; + background-color: #409EFF; + border-color: #409EFF; +} +QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose { + min-width: 34px; + max-width: 34px; + min-height: 34px; + max-height: 34px; + padding: 0; + color: #606266; + background-color: transparent; + border: 0; + border-radius: 7px; + font-size: 22px; + font-weight: 400; +} +QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose:hover { + color: #303133; + background-color: #F2F4F7; + border: 0; +} +QFrame#PrescriptionDrawerSurface QPushButton:pressed { + background-color: #337ECC; + border-color: #337ECC; +} +QFrame#PrescriptionDrawerSurface QPushButton:disabled { + color: #A8ABB2; + background-color: #F5F7FA; + border-color: #E4E7ED; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"] { + color: #FFFFFF; + background-color: #409EFF; + border-color: #409EFF; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"]:hover { + background-color: #337ECC; + border-color: #337ECC; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="ghost"] { + color: #606266; + background-color: transparent; + border-color: transparent; +} +QFrame#PrescriptionDrawerSurface QPushButton[variant="ghost"]:hover { + color: #303133; + background-color: #F2F4F7; +} +QFrame#PrescriptionDrawerSurface QScrollBar:vertical { + width: 10px; + margin: 2px; + background-color: transparent; +} +QFrame#PrescriptionDrawerSurface QScrollBar::handle:vertical { + min-height: 32px; + background-color: #C5CBD3; + border-radius: 4px; +} +QFrame#PrescriptionDrawerSurface QScrollBar::add-line:vertical, +QFrame#PrescriptionDrawerSurface QScrollBar::sub-line:vertical { + height: 0; +} +QWidget#PrescriptionSignaturePad { + background-color: #FFFFFF; + border: 1px solid #D7DCE3; + border-radius: 5px; +} +""" + def _int(value: Any, default: int = 0) -> int: try: @@ -279,14 +560,14 @@ class HerbRowWidget(QFrame): formula_type: str = "主方", locked: bool = False, show_formula: bool = False, + card_mode: bool = False, parent: QWidget | None = None, ) -> None: super().__init__(parent) - self.setObjectName("SubtleCard") + self._card_mode = card_mode + self.setObjectName("PrescriptionHerbCard" if card_mode else "SubtleCard") + self.setProperty("duplicate", False) self._locked = locked - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 7, 10, 7) - layout.setSpacing(8) self.formula_combo = QComboBox() self.formula_combo.addItem("主方", "主方") self.formula_combo.addItem("辅方", "辅方") @@ -294,16 +575,14 @@ class HerbRowWidget(QFrame): self.formula_combo, _formula(first_value(herb, "formula_type", default=formula_type)), ) - self.formula_combo.setVisible(show_formula) + self.formula_combo.setVisible(show_formula and not card_mode) self.formula_combo.setEnabled(not locked) - layout.addWidget(self.formula_combo) self.medicine = RemoteMedicineComboBox( repository, medicine_id=first_value(herb, "medicine_id", "id", default=None), name=str(first_value(herb, "name", "medicine_name", default="")), ) self.medicine.setEnabled(not locked) - layout.addWidget(self.medicine, 1) self.dosage = QDoubleSpinBox() self.dosage.setRange(0, 99999) self.dosage.setDecimals(1) @@ -312,12 +591,54 @@ class HerbRowWidget(QFrame): self.dosage.setValue(_float(first_value(herb, "dosage", "amount", default=0))) self.dosage.setEnabled(not locked) self.dosage.setMaximumWidth(130) - layout.addWidget(self.dosage) self.remove_button = QPushButton("删除") self.remove_button.setProperty("variant", "ghost") self.remove_button.setVisible(not locked) self.remove_button.clicked.connect(lambda: self.remove_requested.emit(self)) - layout.addWidget(self.remove_button) + if card_mode: + self._build_card_layout() + else: + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 7, 10, 7) + layout.setSpacing(8) + layout.addWidget(self.formula_combo) + layout.addWidget(self.medicine, 1) + layout.addWidget(self.dosage) + layout.addWidget(self.remove_button) + + def _build_card_layout(self) -> None: + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 9, 10, 9) + layout.setSpacing(7) + self.card_title = QLabel() + self.card_title.setObjectName("PrescriptionHerbCardTitle") + layout.addWidget(self.card_title) + self.medicine.setMinimumWidth(0) + self.medicine.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + layout.addWidget(self.medicine) + dose_row = QHBoxLayout() + dose_row.setContentsMargins(0, 0, 0, 0) + dose_row.setSpacing(5) + self.dosage.setMaximumWidth(16777215) + self.dosage.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + dose_row.addWidget(self.dosage, 1) + self.remove_button.setObjectName("PrescriptionHerbDelete") + dose_row.addWidget(self.remove_button) + layout.addLayout(dose_row) + + def set_card_position(self, position: int) -> None: + if not self._card_mode: + return + formula = _formula(self.formula_combo.currentData()) + self.card_title.setText(f"{formula} {position}") + + def set_duplicate(self, duplicate: bool) -> None: + if bool(self.property("duplicate")) == duplicate: + return + self.setProperty("duplicate", duplicate) + self.style().unpolish(self) + self.style().polish(self) @property def locked(self) -> bool: @@ -339,22 +660,32 @@ class HerbRowWidget(QFrame): class HerbEditor(QWidget): """Scrollable main/auxiliary herb row editor.""" + rows_changed = Signal() + def __init__( self, repository: Any, *, show_formula: bool = False, + grid_mode: bool = False, + show_actions: bool = True, parent: QWidget | None = None, ) -> None: super().__init__(parent) self.repository = repository self.show_formula = show_formula + self.grid_mode = grid_mode + self._show_actions = show_actions self.rows: list[HerbRowWidget] = [] self._locked = False + self._suspend_changes = False + self._grid_columns = 4 root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(8) - actions = QHBoxLayout() + self.actions_host = QWidget() + actions = QHBoxLayout(self.actions_host) + actions.setContentsMargins(0, 0, 0, 0) self.main_button = QPushButton("+ 添加主方药材") self.main_button.setProperty("variant", "secondary") self.main_button.clicked.connect(lambda: self.add_row(formula_type="主方")) @@ -365,19 +696,57 @@ class HerbEditor(QWidget): self.aux_button.setVisible(show_formula) actions.addWidget(self.aux_button) actions.addStretch(1) - root.addLayout(actions) + self.actions_host.setVisible(show_actions) + root.addWidget(self.actions_host) self.lock_banner = MessageBanner() root.addWidget(self.lock_banner) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setMinimumHeight(220) - host = QWidget() - self.rows_layout = QVBoxLayout(host) - self.rows_layout.setContentsMargins(0, 0, 4, 0) - self.rows_layout.setSpacing(7) - self.rows_layout.addStretch(1) - scroll.setWidget(host) - root.addWidget(scroll, 1) + if grid_mode: + self._build_grid_editor(root) + else: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setMinimumHeight(220) + host = QWidget() + self.rows_layout = QVBoxLayout(host) + self.rows_layout.setContentsMargins(0, 0, 4, 0) + self.rows_layout.setSpacing(7) + self.rows_layout.addStretch(1) + scroll.setWidget(host) + root.addWidget(scroll, 1) + + def _build_grid_editor(self, root: QVBoxLayout) -> None: + main_head = QHBoxLayout() + main_tag = QLabel("主方") + main_tag.setObjectName("PrescriptionFormulaTag") + main_tag.setProperty("formula", "main") + main_head.addWidget(main_tag, 0, Qt.AlignmentFlag.AlignLeft) + main_head.addStretch(1) + root.addLayout(main_head) + self.main_grid = QGridLayout() + self.main_grid.setContentsMargins(0, 0, 0, 0) + self.main_grid.setHorizontalSpacing(12) + self.main_grid.setVerticalSpacing(12) + root.addLayout(self.main_grid) + self.main_empty = QLabel("暂无主方药材") + self.main_empty.setObjectName("PrescriptionFormulaEmpty") + root.addWidget(self.main_empty) + + aux_head = QHBoxLayout() + aux_head.setContentsMargins(0, 8, 0, 0) + aux_tag = QLabel("辅方") + aux_tag.setObjectName("PrescriptionFormulaTag") + aux_tag.setProperty("formula", "aux") + aux_head.addWidget(aux_tag, 0, Qt.AlignmentFlag.AlignLeft) + aux_head.addStretch(1) + root.addLayout(aux_head) + self.aux_grid = QGridLayout() + self.aux_grid.setContentsMargins(0, 0, 0, 0) + self.aux_grid.setHorizontalSpacing(12) + self.aux_grid.setVerticalSpacing(12) + root.addLayout(self.aux_grid) + self.aux_empty = QLabel("暂无辅方药材") + self.aux_empty.setObjectName("PrescriptionFormulaEmpty") + root.addWidget(self.aux_empty) @property def locked(self) -> bool: @@ -385,24 +754,40 @@ class HerbEditor(QWidget): def clear(self) -> None: for row in self.rows: + if self.grid_mode: + self.main_grid.removeWidget(row) + self.aux_grid.removeWidget(row) row.deleteLater() self.rows.clear() + if self.grid_mode: + self._reflow_rows() + if not self._suspend_changes: + self.rows_changed.emit() def set_rows(self, herbs: Iterable[Any], *, locked: bool = False) -> None: + self._suspend_changes = True self.clear() self._locked = locked self.main_button.setEnabled(not locked) self.aux_button.setEnabled(not locked) if locked: - self.lock_banner.show_message( - "该处方含“禁用修改”模板,药材已锁定;可重新导入模板覆盖。", - "warning", - ) + if self.grid_mode: + self.lock_banner.clear() + else: + self.lock_banner.show_message( + "该处方含“禁用修改”模板,药材已锁定;可重新导入模板覆盖。", + "warning", + ) else: self.lock_banner.clear() for herb in herbs: row_locked = locked or _bool(first_value(herb, "locked", default=False)) self.add_row(herb, locked=row_locked) + self._suspend_changes = False + if self.grid_mode: + self._reflow_rows() + self._refresh_duplicate_state() + self.rows_changed.emit() def add_row( self, @@ -417,10 +802,19 @@ class HerbEditor(QWidget): formula_type=formula_type, locked=self._locked if locked is None else locked, show_formula=self.show_formula, + card_mode=self.grid_mode, ) row.remove_requested.connect(self.remove_row) + row.medicine.editTextChanged.connect(self._row_value_changed) + row.formula_combo.currentIndexChanged.connect(self._row_formula_changed) self.rows.append(row) - self.rows_layout.insertWidget(max(0, self.rows_layout.count() - 1), row) + if self.grid_mode: + self._reflow_rows() + else: + self.rows_layout.insertWidget(max(0, self.rows_layout.count() - 1), row) + self._refresh_duplicate_state() + if not self._suspend_changes: + self.rows_changed.emit() return row def remove_row(self, row: HerbRowWidget) -> None: @@ -428,10 +822,67 @@ class HerbEditor(QWidget): return if row in self.rows: self.rows.remove(row) + if self.grid_mode: + self.main_grid.removeWidget(row) + self.aux_grid.removeWidget(row) row.deleteLater() + if self.grid_mode: + self._reflow_rows() + self._refresh_duplicate_state() + self.rows_changed.emit() def values(self) -> list[dict[str, Any]]: - return [row.value() for row in self.rows if row.value()["name"]] + values = [row.value() for row in self.rows] + return [value for value in values if value["name"]] + + def _row_value_changed(self, _value: Any = None) -> None: + self._refresh_duplicate_state() + if not self._suspend_changes: + self.rows_changed.emit() + + def _row_formula_changed(self, _index: int) -> None: + if self.grid_mode: + self._reflow_rows() + self._row_value_changed() + + def _refresh_duplicate_state(self) -> None: + names: dict[str, int] = {} + for row in self.rows: + key = "".join(row.medicine.medicine_name.split()).casefold() + if key: + names[key] = names.get(key, 0) + 1 + for row in self.rows: + key = "".join(row.medicine.medicine_name.split()).casefold() + row.set_duplicate(bool(key and names.get(key, 0) > 1)) + + def _reflow_rows(self) -> None: + if not self.grid_mode: + return + for layout in (self.main_grid, self.aux_grid): + while layout.count(): + layout.takeAt(0) + main_rows = [ + row for row in self.rows if _formula(row.formula_combo.currentData()) == "主方" + ] + aux_rows = [row for row in self.rows if _formula(row.formula_combo.currentData()) == "辅方"] + for layout, rows in ((self.main_grid, main_rows), (self.aux_grid, aux_rows)): + for index, row in enumerate(rows): + row.set_card_position(index + 1) + layout.addWidget(row, index // self._grid_columns, index % self._grid_columns) + for column in range(4): + layout.setColumnStretch(column, 1 if column < self._grid_columns else 0) + self.main_empty.setVisible(not main_rows) + self.aux_empty.setVisible(not aux_rows) + + def resizeEvent(self, event: Any) -> None: + super().resizeEvent(event) + if not self.grid_mode: + return + width = max(1, event.size().width()) + columns = 4 if width >= 880 else 3 if width >= 650 else 2 if width >= 420 else 1 + if columns != self._grid_columns: + self._grid_columns = columns + self._reflow_rows() class SignaturePad(QWidget): @@ -441,7 +892,8 @@ class SignaturePad(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self.setMinimumSize(500, 150) + self.setObjectName("PrescriptionSignaturePad") + self.setMinimumSize(320, 150) self.setCursor(Qt.CursorShape.CrossCursor) self._image = QImage(1000, 300, QImage.Format.Format_ARGB32_Premultiplied) self._image.fill(QColor("white")) @@ -973,6 +1425,27 @@ class PasteHerbsDialog(QDialog): super().accept() +class _PrescriptionSectionNavigator: + """Compatibility shim for callers that used the former visible tab widget.""" + + def __init__(self, owner: PrescriptionEditorDialog) -> None: + self._owner = owner + self._current = 0 + + def count(self) -> int: + return 4 + + def currentIndex(self) -> int: + return self._current + + def setCurrentIndex(self, index: int) -> None: + self._current = max(0, min(3, int(index))) + self._owner.scroll_to_section(self._current) + + def tabText(self, index: int) -> str: + return ("患者与诊断", "药材配方", "剂型与用法", "医师签名")[index] + + class PrescriptionEditorDialog(QDialog): """Full issued-prescription add/edit form matching the admin DTO.""" @@ -991,6 +1464,7 @@ class PrescriptionEditorDialog(QDialog): "烟草", "萝卜", ) + DRAWER_WIDTH = 1200 def __init__( self, @@ -1015,191 +1489,455 @@ class PrescriptionEditorDialog(QDialog): ) ) self.setWindowTitle("新增处方" if mode == "add" else "编辑处方") - self.resize(920, 780) + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setModal(True) + self.setMinimumSize(420, 600) + self.resize(self.DRAWER_WIDTH, 900) root = QVBoxLayout(self) - root.setContentsMargins(20, 18, 20, 18) - self.tabs = QTabWidget() - self.tabs.addTab(self._build_basic_tab(), "患者与诊断") - self.tabs.addTab(self._build_herbs_tab(), "药材配方") - self.tabs.addTab(self._build_usage_tab(), "剂型与用法") - self.tabs.addTab(self._build_doctor_tab(), "医师签名") - root.addWidget(self.tabs, 1) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + self.drawer_surface = QFrame() + self.drawer_surface.setObjectName("PrescriptionDrawerSurface") + self.drawer_surface.setStyleSheet(PRESCRIPTION_DRAWER_QSS) + surface_layout = QVBoxLayout(self.drawer_surface) + surface_layout.setContentsMargins(0, 0, 0, 0) + surface_layout.setSpacing(0) + root.addWidget(self.drawer_surface) + + self.header = self._build_header() + surface_layout.addWidget(self.header) + self.body_scroll = QScrollArea() + self.body_scroll.setObjectName("PrescriptionDrawerBody") + self.body_scroll.setWidgetResizable(True) + self.body_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.body_content = QWidget() + self.body_content.setObjectName("PrescriptionDrawerContent") + self.body_layout = QVBoxLayout(self.body_content) + self.body_layout.setContentsMargins(24, 20, 24, 28) + self.body_layout.setSpacing(18) + + self.patient_section = self._build_patient_section() + self.diagnosis_section = self._build_diagnosis_section() + self.herbs_section = self._build_herbs_section() + self.usage_section = self._build_usage_section() + self.doctor_section = self._build_doctor_section() + self.section_widgets = ( + self.patient_section, + self.diagnosis_section, + self.herbs_section, + self.usage_section, + self.doctor_section, + ) + for section in self.section_widgets: + self.body_layout.addWidget(section) + self.body_layout.addStretch(1) + self.body_scroll.setWidget(self.body_content) + surface_layout.addWidget(self.body_scroll, 1) + + self.footer = QFrame() + self.footer.setObjectName("PrescriptionDrawerFooter") + footer_layout = QVBoxLayout(self.footer) + footer_layout.setContentsMargins(24, 10, 24, 12) + footer_layout.setSpacing(8) self.validation = MessageBanner() - root.addWidget(self.validation) - buttons = QDialogButtonBox( + footer_layout.addWidget(self.validation) + self.buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Save ) - buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") - buttons.button(QDialogButtonBox.StandardButton.Save).setText("保存处方") - buttons.button(QDialogButtonBox.StandardButton.Save).setProperty("variant", "primary") - buttons.rejected.connect(self.reject) - buttons.accepted.connect(self.accept) - root.addWidget(buttons) + self.cancel_button = self.buttons.button(QDialogButtonBox.StandardButton.Cancel) + self.save_button = self.buttons.button(QDialogButtonBox.StandardButton.Save) + self.cancel_button.setText("取消") + self.save_button.setText("保存处方") + self.save_button.setProperty("variant", "primary") + self.buttons.rejected.connect(self.reject) + self.buttons.accepted.connect(self.accept) + footer_layout.addWidget(self.buttons) + surface_layout.addWidget(self.footer) + + self.tabs = _PrescriptionSectionNavigator(self) self._load_values() - def _scroll_form(self) -> tuple[QWidget, QFormLayout]: - host = QWidget() - outer = QVBoxLayout(host) - outer.setContentsMargins(0, 0, 0, 0) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - content = QWidget() - form = QFormLayout(content) - form.setContentsMargins(16, 16, 16, 16) - form.setHorizontalSpacing(18) - form.setVerticalSpacing(11) - scroll.setWidget(content) - outer.addWidget(scroll) - return host, form + def _build_header(self) -> QFrame: + header = QFrame() + header.setObjectName("PrescriptionDrawerHeader") + header.setFixedHeight(76) + layout = QHBoxLayout(header) + layout.setContentsMargins(24, 12, 18, 12) + layout.setSpacing(12) + copy = QVBoxLayout() + copy.setSpacing(2) + title = QLabel("中医处方单") + title.setObjectName("PrescriptionDrawerTitle") + copy.addWidget(title) + subtitle = QLabel("患者信息、诊断、药材与用法将在同一处方工作流中提交") + subtitle.setObjectName("PrescriptionDrawerSubtitle") + copy.addWidget(subtitle) + layout.addLayout(copy, 1) + mode_badge = QLabel("新增" if self.mode == "add" else "编辑") + mode_badge.setObjectName("PrescriptionDrawerMode") + layout.addWidget(mode_badge, 0, Qt.AlignmentFlag.AlignVCenter) + close_button = QPushButton("×") + close_button.setObjectName("PrescriptionDrawerClose") + close_button.setToolTip("关闭") + close_button.clicked.connect(self.reject) + layout.addWidget(close_button, 0, Qt.AlignmentFlag.AlignVCenter) + self.close_button = close_button + return header - def _build_basic_tab(self) -> QWidget: - tab, form = self._scroll_form() + def _section(self, title: str, hint: str = "") -> tuple[QFrame, QVBoxLayout]: + section = QFrame() + section.setObjectName("PrescriptionSection") + layout = QVBoxLayout(section) + layout.setContentsMargins(16, 15, 16, 17) + layout.setSpacing(12) + if title: + heading_row = QHBoxLayout() + heading_row.setContentsMargins(0, 0, 0, 0) + heading_row.setSpacing(12) + heading = QLabel(title) + heading.setObjectName("PrescriptionSectionTitle") + heading_row.addWidget(heading) + if hint: + heading_hint = QLabel(hint) + heading_hint.setObjectName("PrescriptionSectionHint") + heading_row.addWidget(heading_hint) + heading_row.addStretch(1) + layout.addLayout(heading_row) + return section, layout + + def _field(self, label: str, widget: QWidget, *, required: bool = False) -> QWidget: + field = QWidget() + field.setObjectName("PrescriptionField") + layout = QVBoxLayout(field) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + caption = QLabel(label + (" *" if required else "")) + caption.setObjectName("PrescriptionFieldLabel") + layout.addWidget(caption) + widget.setSizePolicy(QSizePolicy.Policy.Expanding, widget.sizePolicy().verticalPolicy()) + layout.addWidget(widget) + return field + + def _place_field( + self, + grid: QGridLayout, + row: int, + column: int, + label: str, + widget: QWidget, + *, + column_span: int = 1, + required: bool = False, + ) -> QWidget: + field = self._field(label, widget, required=required) + grid.addWidget(field, row, column, 1, column_span) + return field + + @staticmethod + def _prepare_grid(grid: QGridLayout, columns: int = 3) -> None: + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(16) + grid.setVerticalSpacing(11) + for column in range(columns): + grid.setColumnStretch(column, 1) + + def _build_patient_section(self) -> QFrame: + section, layout = self._section("患者信息", "处方身份信息") + grid = QGridLayout() + self._prepare_grid(grid) self.patient_name = QLineEdit() self.patient_name.setMaxLength(50) - form.addRow("患者姓名 *", self.patient_name) - self.visit_no = QLineEdit() - form.addRow("门诊号", self.visit_no) self.gender = QComboBox() self.gender.addItem("男", 1) self.gender.addItem("女", 0) - form.addRow("性别 *", self.gender) self.age = QSpinBox() self.age.setRange(0, 150) - form.addRow("年龄", self.age) + self.phone = QLineEdit() + self.phone.setReadOnly(True) + self.phone.setPlaceholderText("未提供") + self.visit_no = QLineEdit() self.date_edit = QDateEdit(QDate.currentDate()) self.date_edit.setDisplayFormat("yyyy-MM-dd") self.date_edit.setCalendarPopup(True) - form.addRow("处方日期 *", self.date_edit) - self.tongue = QLineEdit() - form.addRow("面象", self.tongue) - self.tongue_image = QLineEdit() - form.addRow("舌象", self.tongue_image) - self.pulse = QLineEdit() - form.addRow("脉象", self.pulse) - self.pulse_condition = QLineEdit() - form.addRow("脉象详情", self.pulse_condition) - self.clinical_diagnosis = QTextEdit() - self.clinical_diagnosis.setMaximumHeight(100) - form.addRow("临床诊断 *", self.clinical_diagnosis) - return tab + self._place_field(grid, 0, 0, "姓名", self.patient_name, required=True) + self._place_field(grid, 0, 1, "性别", self.gender, required=True) + self._place_field(grid, 0, 2, "年龄", self.age) + self._place_field(grid, 1, 0, "电话", self.phone) + self._place_field(grid, 1, 1, "门诊号", self.visit_no) + self._place_field(grid, 1, 2, "处方日期", self.date_edit, required=True) + layout.addLayout(grid) + return section - def _build_herbs_tab(self) -> QWidget: - tab = QWidget() - layout = QVBoxLayout(tab) - actions = QHBoxLayout() + def _build_diagnosis_section(self) -> QFrame: + section, layout = self._section("诊断信息", "辨证信息随处方一并留档") + grid = QGridLayout() + self._prepare_grid(grid, 2) + self.pulse = QLineEdit() + self.pulse.setPlaceholderText("请输入面象") + self.tongue = QLineEdit() + self.tongue.setPlaceholderText("请输入舌象") + self.tongue_image = QLineEdit() + self.tongue_image.setPlaceholderText("补充舌象详情") + self.pulse_condition = QLineEdit() + self.pulse_condition.setPlaceholderText("补充脉象详情") + self.clinical_diagnosis = QTextEdit() + self.clinical_diagnosis.setPlaceholderText("请输入临床诊断") + self.clinical_diagnosis.setMaximumHeight(86) + self._place_field(grid, 0, 0, "面象", self.pulse) + self._place_field(grid, 0, 1, "舌象", self.tongue) + self._place_field(grid, 1, 0, "舌象详情", self.tongue_image) + self._place_field(grid, 1, 1, "脉象详情", self.pulse_condition) + self._place_field( + grid, + 2, + 0, + "临床诊断", + self.clinical_diagnosis, + column_span=2, + required=True, + ) + layout.addLayout(grid) + return section + + def _build_herbs_section(self) -> QFrame: + section, layout = self._section("") + toolbar = QFrame() + toolbar.setObjectName("PrescriptionRpToolbar") + toolbar_layout = QHBoxLayout(toolbar) + toolbar_layout.setContentsMargins(14, 12, 14, 12) + toolbar_layout.setSpacing(12) + marker = QFrame() + marker.setObjectName("PrescriptionRpMarker") + marker.setFixedWidth(4) + marker.setMinimumHeight(48) + toolbar_layout.addWidget(marker) + lead_widget = QWidget() + lead_widget.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + lead = QVBoxLayout(lead_widget) + lead.setContentsMargins(0, 0, 0, 0) + lead.setSpacing(4) + heading = QLabel("中药处方 (RP)") + heading.setObjectName("PrescriptionRpHeading") + lead.addWidget(heading) + self.herb_summary = QLabel() + self.herb_summary.setObjectName("PrescriptionRpMeta") + self.herb_summary.setWordWrap(True) + self.herb_summary.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + lead.addWidget(self.herb_summary) + self.rp_lock_label = QLabel("含“禁用修改”处方库模板,药材已锁定;可重新从处方库导入覆盖。") + self.rp_lock_label.setObjectName("PrescriptionRpLock") + self.rp_lock_label.setWordWrap(True) + self.rp_lock_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + lead.addWidget(self.rp_lock_label) + toolbar_layout.addWidget(lead_widget, 1) + actions_widget = QWidget() + actions = QHBoxLayout(actions_widget) + actions.setContentsMargins(0, 0, 0, 0) + actions.setSpacing(7) + self.add_main_button = QPushButton("添加主方") + self.add_main_button.setProperty("variant", "primary") + self.add_main_button.clicked.connect(lambda: self.herbs.add_row(formula_type="主方")) + actions.addWidget(self.add_main_button) + self.add_aux_button = QPushButton("添加辅方") + self.add_aux_button.clicked.connect(lambda: self.herbs.add_row(formula_type="辅方")) + actions.addWidget(self.add_aux_button) self.import_library_button = QPushButton("从处方库导入") - self.import_library_button.setProperty("variant", "secondary") self.import_library_button.clicked.connect(self._import_library) actions.addWidget(self.import_library_button) - self.paste_button = QPushButton("导入药方文本") - self.paste_button.setProperty("variant", "secondary") + self.paste_button = QPushButton("导入药方") self.paste_button.clicked.connect(self._paste_herbs) actions.addWidget(self.paste_button) - actions.addStretch(1) - layout.addLayout(actions) - self.herbs = HerbEditor(self.repository, show_formula=True) - layout.addWidget(self.herbs, 1) - return tab + toolbar_layout.addWidget(actions_widget) + layout.addWidget(toolbar) + self.herbs = HerbEditor( + self.repository, + show_formula=True, + grid_mode=True, + show_actions=False, + ) + self.herbs.rows_changed.connect(self._update_herb_summary) + layout.addWidget(self.herbs) + return section - def _build_usage_tab(self) -> QWidget: - tab, form = self._scroll_form() + def _build_usage_section(self) -> QFrame: + section, layout = self._section("剂型与用法", "按主方、辅方分别配置") + grid = QGridLayout() + self._prepare_grid(grid) self.prescription_type = QComboBox() for value in self.PRESCRIPTION_TYPES: self.prescription_type.addItem(value, value) self.prescription_type.currentIndexChanged.connect(self._type_changed) - form.addRow("处方类型 *", self.prescription_type) - self.dosage_amount = QDoubleSpinBox() - self.dosage_amount.setRange(0, 100000) - self.dosage_amount.setDecimals(2) - form.addRow("单次用量", self.dosage_amount) - self.dosage_unit = QComboBox() - self.dosage_unit.setEditable(True) - self.dosage_unit.addItems(["g", "ml"]) - form.addRow("用量单位", self.dosage_unit) - self.dosage_bag_count = QSpinBox() - self.dosage_bag_count.setRange(1, 99) - form.addRow("每次袋数", self.dosage_bag_count) - self.need_decoction = QCheckBox("需要代煎") - form.addRow("代煎", self.need_decoction) - self.bags_per_dose = QSpinBox() - self.bags_per_dose.setRange(1, 99) - form.addRow("每贴出包数", self.bags_per_dose) self.dose_count = QSpinBox() self.dose_count.setRange(1, 999) - form.addRow("剂数 *", self.dose_count) self.dose_unit = QComboBox() self.dose_unit.setEditable(True) self.dose_unit.addItems(["剂", "丸", "袋", "盒", "瓶", "膏", "贴"]) - form.addRow("剂量单位", self.dose_unit) + self._place_field(grid, 0, 0, "处方类型", self.prescription_type, required=True) + self._place_field(grid, 0, 1, "剂数", self.dose_count) + self._place_field(grid, 0, 2, "剂量单位", self.dose_unit) + main_heading = QLabel("主方用法") + main_heading.setObjectName("PrescriptionUsageMain") + grid.addWidget(main_heading, 1, 0, 1, 3) + self.dosage_amount = QDoubleSpinBox() + self.dosage_amount.setRange(0, 100000) + self.dosage_amount.setDecimals(2) + self.dosage_unit = QComboBox() + self.dosage_unit.setEditable(True) + self.dosage_unit.addItems(["g", "ml"]) + self.dosage_bag_count = QSpinBox() + self.dosage_bag_count.setRange(1, 99) + self.need_decoction = QCheckBox("需要代煎") + self.bags_per_dose = QSpinBox() + self.bags_per_dose.setRange(1, 99) self.times_per_day = QSpinBox() self.times_per_day.setRange(1, 6) - form.addRow("主方每天次数", self.times_per_day) self.usage_days = QSpinBox() self.usage_days.setRange(1, 365) - form.addRow("主方服用天数", self.usage_days) + self._place_field(grid, 2, 0, "单次用量", self.dosage_amount) + self._place_field(grid, 2, 1, "用量单位", self.dosage_unit) + self._main_bag_field = self._place_field(grid, 2, 2, "每次袋数", self.dosage_bag_count) + self._main_decoction_field = self._place_field(grid, 3, 0, "代煎", self.need_decoction) + self._main_bags_field = self._place_field(grid, 3, 1, "每贴出包数", self.bags_per_dose) + self._place_field(grid, 3, 2, "服用天数", self.usage_days) + self._place_field(grid, 4, 0, "每天几次", self.times_per_day) self.usage_instruction = QLineEdit() self.usage_instruction.setMaxLength(200) - form.addRow("用法", self.usage_instruction) + self._place_field(grid, 4, 1, "用法", self.usage_instruction, column_span=2) self.usage_time = QComboBox() self.usage_time.setEditable(True) self.usage_time.addItems(["饭前", "饭后", "饭中", "空腹", "睡前", "晨起", "随时"]) - form.addRow("服用时间", self.usage_time) self.usage_way = QComboBox() self.usage_way.setEditable(True) self.usage_way.addItems( ["温水送服", "开水冲服", "黄酒送服", "淡盐水送服", "米汤送服", "嚼服", "含化"] ) - form.addRow("服用方式", self.usage_way) self.dietary_taboo = QLineEdit() self.dietary_taboo.setPlaceholderText("多项用逗号分隔") - form.addRow("忌口", self.dietary_taboo) + self._place_field(grid, 5, 0, "服用时间", self.usage_time) + self._place_field(grid, 5, 1, "服用方式", self.usage_way) + self._place_field(grid, 5, 2, "忌口", self.dietary_taboo) self.usage_notes = QTextEdit() - self.usage_notes.setMaximumHeight(80) - form.addRow("其他说明", self.usage_notes) - aux_heading = QLabel("辅方用法") - aux_heading.setProperty("role", "sectionTitle") - form.addRow(aux_heading) + self.usage_notes.setMaximumHeight(72) + self._place_field(grid, 6, 0, "其他说明", self.usage_notes, column_span=3) + self.aux_usage_heading = QLabel("辅方用法") + self.aux_usage_heading.setObjectName("PrescriptionUsageAux") + grid.addWidget(self.aux_usage_heading, 7, 0, 1, 3) self.aux_dosage_amount = QDoubleSpinBox() self.aux_dosage_amount.setRange(0, 100000) self.aux_dosage_amount.setDecimals(2) - form.addRow("辅方单次用量", self.aux_dosage_amount) self.aux_dosage_bag_count = QSpinBox() self.aux_dosage_bag_count.setRange(1, 99) - form.addRow("辅方每次袋数", self.aux_dosage_bag_count) self.aux_need_decoction = QCheckBox("辅方需要代煎") - form.addRow("辅方代煎", self.aux_need_decoction) self.aux_bags_per_dose = QSpinBox() self.aux_bags_per_dose.setRange(1, 99) - form.addRow("辅方每贴出包数", self.aux_bags_per_dose) self.aux_times_per_day = QSpinBox() self.aux_times_per_day.setRange(1, 6) - form.addRow("辅方每天次数", self.aux_times_per_day) self.aux_usage_days = QSpinBox() self.aux_usage_days.setRange(1, 365) - form.addRow("辅方服用天数", self.aux_usage_days) self.aux_prescription_name = QLineEdit() - form.addRow("辅方模板名", self.aux_prescription_name) - return tab + self._aux_dosage_field = self._place_field( + grid, 8, 0, "辅方单次用量", self.aux_dosage_amount + ) + self._aux_bag_field = self._place_field( + grid, 8, 1, "辅方每次袋数", self.aux_dosage_bag_count + ) + self._aux_name_field = self._place_field( + grid, 8, 2, "辅方模板名", self.aux_prescription_name + ) + self._aux_decoction_field = self._place_field( + grid, 9, 0, "辅方代煎", self.aux_need_decoction + ) + self._aux_bags_field = self._place_field( + grid, 9, 1, "辅方每贴出包数", self.aux_bags_per_dose + ) + self._aux_times_field = self._place_field( + grid, 9, 2, "辅方每天次数", self.aux_times_per_day + ) + self._aux_days_field = self._place_field(grid, 10, 0, "辅方服用天数", self.aux_usage_days) + self._aux_usage_fields = ( + self._aux_dosage_field, + self._aux_bag_field, + self._aux_name_field, + self._aux_decoction_field, + self._aux_bags_field, + self._aux_times_field, + self._aux_days_field, + ) + layout.addLayout(grid) + return section - def _build_doctor_tab(self) -> QWidget: - tab = QWidget() - layout = QVBoxLayout(tab) - form = QFormLayout() + def _build_doctor_section(self) -> QFrame: + section, layout = self._section("医生签名", "保存前需完成手写签名") self.doctor_name = QLineEdit() - form.addRow("医生姓名 *", self.doctor_name) - layout.addLayout(form) - layout.addWidget(QLabel("医生手写签名 *")) - self.signature = SignaturePad() - layout.addWidget(self.signature) + doctor_field = self._field("医生姓名", self.doctor_name, required=True) + doctor_field.setMaximumWidth(360) + layout.addWidget(doctor_field) + signature_head = QHBoxLayout() + signature_label = QLabel("医生手写签名 *") + signature_label.setObjectName("PrescriptionFieldLabel") + signature_head.addWidget(signature_label) + signature_head.addStretch(1) clear = QPushButton("清空签名") + clear.setProperty("variant", "ghost") + signature_head.addWidget(clear) + layout.addLayout(signature_head) + self.signature = SignaturePad() + self.signature.setMaximumWidth(620) + self.signature.setMaximumHeight(180) + layout.addWidget(self.signature) clear.clicked.connect(self.signature.clear) - layout.addWidget(clear, 0, Qt.AlignmentFlag.AlignRight) - layout.addStretch(1) - return tab + return section + + def scroll_to_section(self, index: int) -> None: + targets = ( + self.patient_section, + self.herbs_section, + self.usage_section, + self.doctor_section, + ) + target = targets[max(0, min(len(targets) - 1, index))] + self.body_scroll.ensureWidgetVisible(target, 0, 24) + + def _fit_drawer_geometry(self) -> None: + parent = self.parentWidget() + if parent is None: + return + anchor = parent.window() + origin = anchor.mapToGlobal(QPoint(0, 0)) + width = min(self.DRAWER_WIDTH, max(self.minimumWidth(), anchor.width())) + height = max(self.minimumHeight(), anchor.height()) + self.setGeometry(origin.x() + anchor.width() - width, origin.y(), width, height) + + def showEvent(self, event: Any) -> None: + super().showEvent(event) + self._fit_drawer_geometry() + + def _update_herb_summary(self) -> None: + if not hasattr(self, "herbs"): + return + main_count = sum( + _formula(row.formula_combo.currentData()) == "主方" for row in self.herbs.rows + ) + aux_count = len(self.herbs.rows) - main_count + self.herb_summary.setText( + f"共 {len(self.herbs.rows)} 味(主方 {main_count} / 辅方 {aux_count})" + " · 可手动添加、从处方库或粘贴药方批量录入" + ) + locked = self.herbs.locked + self.rp_lock_label.setVisible(locked) + self.add_main_button.setEnabled(not locked) + self.add_aux_button.setEnabled(not locked) + if hasattr(self, "_aux_usage_fields"): + self.aux_usage_heading.setVisible(aux_count > 0) + for field in self._aux_usage_fields: + field.setVisible(aux_count > 0) + self._apply_type_visibility() def _load_values(self) -> None: source = self._source self.patient_name.setText(str(source.get("patient_name") or "")) + self.phone.setText(str(source.get("phone") or "")) self.visit_no.setText(str(source.get("visit_no") or "")) _set_combo_data(self.gender, source.get("gender", 1)) self.age.setValue(_int(source.get("age"), 0)) @@ -1265,8 +2003,9 @@ class PrescriptionEditorDialog(QDialog): if self.mode == "edit": self.patient_name.setReadOnly(True) self.visit_no.setReadOnly(True) + self._update_herb_summary() - def _type_changed(self) -> None: + def _type_changed(self, _index: Any = None) -> None: value = self.prescription_type.currentData() if value == "饮片": self.dosage_unit.setCurrentText("ml") @@ -1278,6 +2017,23 @@ class PrescriptionEditorDialog(QDialog): self.dosage_amount.setValue(1) else: self.dosage_unit.setCurrentText("g") + self._apply_type_visibility() + + def _apply_type_visibility(self) -> None: + if not hasattr(self, "_main_bag_field"): + return + value = self.prescription_type.currentData() + concentrated = value == "浓缩水丸" + decoction = value == "饮片" + self._main_bag_field.setVisible(concentrated) + self._main_decoction_field.setVisible(decoction) + self._main_bags_field.setVisible(decoction) + has_aux = any( + _formula(row.formula_combo.currentData()) == "辅方" for row in self.herbs.rows + ) + self._aux_bag_field.setVisible(has_aux and concentrated) + self._aux_decoction_field.setVisible(has_aux and decoction) + self._aux_bags_field.setVisible(has_aux and decoction) def _import_library(self) -> None: if not self._prescribing_creator_id: diff --git a/app/src/doctor_workstation/ui/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py index 8d7b6ae02..30f762111 100644 --- a/app/src/doctor_workstation/ui/pages/consultations.py +++ b/app/src/doctor_workstation/ui/pages/consultations.py @@ -6,10 +6,13 @@ import re from collections.abc import Mapping, Sequence from contextlib import suppress from copy import deepcopy +from html import escape from types import MappingProxyType from typing import Any -from PySide6.QtCore import QDate, QTimer, Signal +from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal +from PySide6.QtGui import QDesktopServices, QPixmap +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from PySide6.QtWidgets import ( QComboBox, QDateEdit, @@ -18,27 +21,33 @@ from PySide6.QtWidgets import ( QDoubleSpinBox, QFormLayout, QFrame, - QGridLayout, QHBoxLayout, + QInputDialog, QLabel, QLineEdit, QMessageBox, QPushButton, + QScrollArea, QSpinBox, - QStackedWidget, + QTableWidget, + QTableWidgetItem, + QToolButton, QVBoxLayout, QWidget, ) +from ..diagnosis_index_widgets import ( + DIAGNOSIS_INDEX_QSS, + DiagnosisChip, + DiagnosisLoadingOverlay, + DiagnosisPager, + DiagnosisTableHost, + FlowWidget, +) from ..dialogs import DiagnosisDialog from ..dialogs.prescription import PrescriptionDetailDialog, PrescriptionEditorDialog from ..widgets import ( - EmptyState, MessageBanner, - PageHeader, - Pager, - SortableTable, - TableColumn, display_text, first_value, friendly_error, @@ -59,6 +68,10 @@ APPOINTMENT_STATUS = { # Kept as a compatibility name for callers that imported the old module constant. CONSULTATION_STATUS = APPOINTMENT_STATUS _OPTIONAL_DATE_MINIMUM = QDate(2000, 1, 1) +_ID_CARD_18_RE = 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]$" +) +_ID_CARD_15_RE = re.compile(r"^[1-9]\d{5}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}$") def _as_int(value: Any, default: int = 0) -> int: @@ -110,6 +123,22 @@ def _canonical_allowed(permissions: Any, code: str, *, default: bool = True) -> return any(grant.endswith("/*") and code.startswith(grant[:-1]) for grant in available) +def is_valid_id_card(value: Any) -> bool: + """Match the admin's 15/18-digit preflight validation contract.""" + + candidate = str(value or "").strip() + return bool(_ID_CARD_18_RE.fullmatch(candidate) or _ID_CARD_15_RE.fullmatch(candidate)) + + +def _supports_native_video(repository: Any) -> bool: + """Fail closed unless the repository exposes the full doctor-call lifecycle.""" + + return all( + callable(getattr(repository, method_name, None)) + for method_name in ("get_call_ticket", "start_call", "bind_call_room", "end_call") + ) + + def appointment_rows(record: Any) -> list[Any]: """Return canonical ``appointments[]`` with the list-row fallback.""" @@ -163,6 +192,45 @@ def _appointment_id(record: Any) -> int: return _as_int(value) +def _single_cancellable_appointment(record: Any) -> Any | None: + if not _as_bool(first_value(record, "has_appointment", default=False)): + return None + if _as_int(_appointment_status(record), -1) not in {1, 4}: + return None + rows = appointment_rows(record) + if len(rows) != 1: + return None + appointment = rows[0] + appointment_id = _as_int(first_value(appointment, "id", "appointment_id", default=0)) + status = _as_int(first_value(appointment, "status", "appointment_status", default=-1), -1) + return appointment if appointment_id > 0 and status in {1, 4} else None + + +def _cancellable_appointment_by_id(record: Any, appointment_id: int) -> Any | None: + """Resolve one cancellable appointment from the visible nested records only.""" + + target_id = _as_int(appointment_id) + if target_id <= 0: + return None + matches = [ + appointment + for appointment in appointment_rows(record) + if _as_int(first_value(appointment, "id", "appointment_id", default=0)) == target_id + and _as_int(first_value(appointment, "status", "appointment_status", default=-1), -1) + in {1, 4} + ] + return matches[0] if len(matches) == 1 else None + + +def _repository_method_name(repository: Any, *candidates: str) -> str | None: + """Return the first concrete repository capability without manufacturing a fallback.""" + + return next( + (name for name in candidates if callable(getattr(repository, name, None))), + None, + ) + + def is_video_available(record: Any) -> bool: """Only an existing, currently booked appointment may enter video.""" @@ -426,6 +494,352 @@ class _DiagnosisCreateDialog(QDialog): super().accept() +class _ToastBanner(MessageBanner): + """Compatibility message target that renders as a non-layout-shifting toast.""" + + def __init__(self, owner: QWidget, parent: QWidget | None = None) -> None: + self._owner = owner + super().__init__(parent=parent) + self.setFixedSize(0, 0) + self.hide() + + def show_message(self, text: str, kind: str = "info") -> None: + super().show_message(text, kind) + self.hide() + if text: + show_toast(self._owner, text, kind) + + +class _DiagnosisOrderDialog(QDialog): + """Small admin-parity form for the diagnosis list's generic business order.""" + + ORDER_TYPES = ( + ("挂号费", 1), + ("问诊费", 2), + ("药品费用", 3), + ("首付费用", 4), + ("尾款费用", 5), + ("其他费用", 6), + ("全部费用", 7), + ("驼奶费用", 8), + ) + + def __init__(self, record: Any, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._patient_id = _as_int(first_value(record, "patient_id", "source_patient_id")) + self.setWindowTitle("创建订单") + self.setMinimumWidth(480) + root = QVBoxLayout(self) + form = QFormLayout() + patient = QLabel( + f"{display_text(first_value(record, 'patient_name'), '患者')} (#{self._patient_id})" + ) + patient.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + form.addRow("患者", patient) + self.order_type = QComboBox() + self.order_type.addItem("请选择", None) + for label, value in self.ORDER_TYPES: + self.order_type.addItem(label, value) + form.addRow("订单类型", self.order_type) + self.amount = QDoubleSpinBox() + self.amount.setDecimals(2) + self.amount.setRange(0.01, 9_999_999.99) + self.amount.setSingleStep(0.01) + self.amount.setPrefix("¥ ") + form.addRow("订单金额", self.amount) + self.remark = QLineEdit() + self.remark.setPlaceholderText("请输入备注(可选)") + form.addRow("备注", self.remark) + root.addLayout(form) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok + ) + buttons.button(QDialogButtonBox.StandardButton.Ok).setText("创建订单") + buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") + buttons.accepted.connect(self._accept_checked) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + def _accept_checked(self) -> None: + if self._patient_id <= 0: + QMessageBox.warning(self, "创建订单", "患者标识不完整,无法创建订单。") + return + if self.order_type.currentData() is None: + QMessageBox.warning(self, "创建订单", "请选择订单类型。") + return + self.accept() + + def payload(self) -> dict[str, Any]: + return { + "patient_id": self._patient_id, + "order_type": _as_int(self.order_type.currentData()), + "amount": float(self.amount.value()), + "remark": self.remark.text().strip(), + } + + +class _AppointmentLogDialog(QDialog): + """Read-only appointment audit log backed by the real diagnosis log endpoint.""" + + def __init__(self, record: Any, rows: Sequence[Any], parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("挂号操作日志") + self.resize(680, 420) + root = QVBoxLayout(self) + context = QLabel( + f"{display_text(first_value(record, 'patient_name'), '患者')} | " + f"诊单 #{_as_int(first_value(record, 'diagnosis_id', 'id'))}" + ) + context.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + root.addWidget(context) + table = QTableWidget(0, 4, self) + table.setHorizontalHeaderLabels(["时间", "动作", "操作人", "摘要"]) + table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + table.verticalHeader().hide() + table.horizontalHeader().setStretchLastSection(True) + for row in rows: + index = table.rowCount() + table.insertRow(index) + operator = first_value(row, "admin_name", "operator_name", default="") + if not operator: + operator_id = _as_int(first_value(row, "admin_id", "operator_id", default=0)) + operator = f"管理员 #{operator_id}" if operator_id > 0 else "—" + values = ( + first_value(row, "create_time_text", "created_at", "create_time", default="—"), + first_value(row, "action_desc", "action", default="—"), + operator, + first_value(row, "summary", "remark", "content", default="—"), + ) + for column, value in enumerate(values): + table.setItem(index, column, QTableWidgetItem(display_text(value))) + if not rows: + table.setRowCount(1) + empty = QTableWidgetItem("暂无挂号/取消挂号操作记录") + empty.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + table.setItem(0, 0, empty) + table.setSpan(0, 0, 1, 4) + root.addWidget(table) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + buttons.button(QDialogButtonBox.StandardButton.Close).setText("关闭") + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + +class _QrResultDialog(QDialog): + """Expose the server-issued QR asset without pretending to generate it locally.""" + + def __init__( + self, + title: str, + record: Any, + qrcode_url: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._url = qrcode_url + self.setWindowTitle(title) + self.setMinimumWidth(430) + root = QVBoxLayout(self) + root.addWidget( + QLabel( + f"患者:{display_text(first_value(record, 'patient_name'), '患者')}\n" + f"二维码由服务器生成,请打开后使用企业微信转发给患者。" + ) + ) + link = QLabel(f'打开服务器二维码') + link.setOpenExternalLinks(True) + link.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + root.addWidget(link) + url_text = QLineEdit(qrcode_url) + url_text.setReadOnly(True) + url_text.setToolTip(qrcode_url) + root.addWidget(url_text) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + open_button = buttons.addButton("打开二维码", QDialogButtonBox.ButtonRole.ActionRole) + open_button.clicked.connect(lambda: QDesktopServices.openUrl(QUrl.fromUserInput(self._url))) + buttons.button(QDialogButtonBox.StandardButton.Close).setText("关闭") + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + +class _QrImagePreview(QLabel): + """Load the exact server-issued QR asset while keeping its URL visible.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("DiagnosisOrderQrPreview") + self.setFixedSize(258, 258) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setWordWrap(True) + self.setStyleSheet( + "QLabel#DiagnosisOrderQrPreview{background:#FFFFFF;border:1px solid #DCDFE6;" + "border-radius:4px;color:#909399;padding:8px;}" + ) + self._manager = QNetworkAccessManager(self) + self._reply: QNetworkReply | None = None + self._generation = 0 + self.show_loading() + + def show_loading(self) -> None: + self._generation += 1 + if self._reply is not None: + self._reply.abort() + self._reply.deleteLater() + self._reply = None + self.clear() + self.setText("正在加载服务器二维码...") + + def show_failure(self, message: str = "二维码图片加载失败") -> None: + self._generation += 1 + if self._reply is not None: + self._reply.abort() + self._reply.deleteLater() + self._reply = None + self.clear() + self.setText(f"{message}\n可使用下方地址打开") + + def load_url(self, source: str) -> None: + self.show_loading() + generation = self._generation + url = QUrl.fromUserInput(source) + if not url.isValid() or url.scheme().lower() not in {"http", "https"}: + self.show_failure("二维码地址无效") + return + request = QNetworkRequest(url) + request.setTransferTimeout(10_000) + reply = self._manager.get(request) + self._reply = reply + reply.finished.connect(lambda: self._loaded(reply, generation)) + + def _loaded(self, reply: QNetworkReply, generation: int) -> None: + if reply is not self._reply or generation != self._generation: + reply.deleteLater() + return + self._reply = None + data = bytes(reply.readAll()) + error = reply.error() + reply.deleteLater() + if error != QNetworkReply.NetworkError.NoError: + self.show_failure() + return + pixmap = QPixmap() + if not data or not pixmap.loadFromData(data): + self.show_failure() + return + self.setText("") + self.setPixmap( + pixmap.scaled( + 240, + 240, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + + +class _DiagnosisOrderQrDialog(QDialog): + """Payment QR state that retains the immutable order number for retries.""" + + retry_requested = Signal(str) + + def __init__( + self, + record: Any, + order_no: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.order_no = order_no + self.qrcode_url = "" + self.setObjectName("DiagnosisOrderQrDialog") + self.setWindowTitle("付款二维码") + self.setWindowModality(Qt.WindowModality.WindowModal) + self.setMinimumWidth(470) + root = QVBoxLayout(self) + root.setContentsMargins(24, 20, 24, 18) + root.setSpacing(12) + + heading = QLabel("订单已创建", self) + heading.setStyleSheet("font-size:18px;font-weight:600;color:#303133;") + heading.setAlignment(Qt.AlignmentFlag.AlignCenter) + root.addWidget(heading) + context = QLabel( + f"患者:{display_text(first_value(record, 'patient_name'), '患者')}\n" + f"订单号:{order_no}", + self, + ) + context.setObjectName("DiagnosisOrderQrContext") + context.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + context.setAlignment(Qt.AlignmentFlag.AlignCenter) + root.addWidget(context) + self.preview = _QrImagePreview(self) + preview_row = QHBoxLayout() + preview_row.addStretch(1) + preview_row.addWidget(self.preview) + preview_row.addStretch(1) + root.addLayout(preview_row) + self.status_label = QLabel("正在生成付款二维码...", self) + self.status_label.setObjectName("DiagnosisOrderQrStatus") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.status_label.setWordWrap(True) + root.addWidget(self.status_label) + self.url_edit = QLineEdit(self) + self.url_edit.setObjectName("DiagnosisOrderQrUrl") + self.url_edit.setReadOnly(True) + self.url_edit.setPlaceholderText("服务器二维码地址将在生成后显示") + root.addWidget(self.url_edit) + hint = QLabel("请使用企业微信扫描二维码,然后转发给患者", self) + hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + hint.setStyleSheet("color:#606266;") + root.addWidget(hint) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, self) + self.open_button = buttons.addButton( + "打开二维码地址", QDialogButtonBox.ButtonRole.ActionRole + ) + self.retry_button = buttons.addButton("重新生成", QDialogButtonBox.ButtonRole.ActionRole) + self.open_button.clicked.connect(self._open_url) + self.retry_button.clicked.connect(lambda: self.retry_requested.emit(self.order_no)) + buttons.button(QDialogButtonBox.StandardButton.Close).setText("关闭") + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + self.set_loading() + + def set_loading(self) -> None: + self.qrcode_url = "" + self.url_edit.clear() + self.status_label.setText("正在生成付款二维码...") + self.status_label.setStyleSheet("color:#909399;") + self.preview.show_loading() + self.open_button.setEnabled(False) + self.retry_button.setEnabled(False) + + def set_failure(self, message: str, *, retryable: bool = True) -> None: + self.qrcode_url = "" + self.url_edit.clear() + self.status_label.setText(message) + self.status_label.setStyleSheet("color:#F56C6C;") + self.preview.show_failure("付款二维码生成失败") + self.open_button.setEnabled(False) + self.retry_button.setEnabled(retryable) + + def set_result(self, qrcode_url: str) -> None: + self.qrcode_url = qrcode_url + self.url_edit.setText(qrcode_url) + self.url_edit.setCursorPosition(0) + self.url_edit.setToolTip(qrcode_url) + self.status_label.setText("付款二维码已生成") + self.status_label.setStyleSheet("color:#67C23A;") + self.preview.load_url(qrcode_url) + self.open_button.setEnabled(True) + self.retry_button.setEnabled(True) + + def _open_url(self) -> None: + if self.qrcode_url: + QDesktopServices.openUrl(QUrl.fromUserInput(self.qrcode_url)) + + class ConsultationsPage(QWidget): """Diagnosis workspace with canonical filters and guarded row actions.""" @@ -439,6 +853,8 @@ class ConsultationsPage(QWidget): parent: QWidget | None = None, ) -> None: super().__init__(parent) + self.setObjectName("DiagnosisIndex") + self.setStyleSheet(DIAGNOSIS_INDEX_QSS) self.repository = repository self.permissions = permissions self.current_user = current_user @@ -449,10 +865,15 @@ class ConsultationsPage(QWidget): self._options_generation = 0 self._prescription_generation = 0 self._mutation_generation = 0 + self._menu_generation = 0 + self._order_generation = 0 + self._order_flow_generation: int | None = None + self._order_qr_request_pending = False self._loading = False self._mutation_pending = False self._prescription_busy = False self._options_loaded = False + self._visible_loading_generation = 0 self._appointment_date = QDate.currentDate().toString("yyyy-MM-dd") self._pending_booking = "" self._completed_appointment = "" @@ -460,103 +881,140 @@ class ConsultationsPage(QWidget): self._date_counts: dict[str, int] = {} self._assistant_names: dict[int, str] = {} self._last_prescription: Any = None + self._appointment_logs_dialog: QDialog | None = None + self._qr_result_dialog: QDialog | None = None + self._order_qr_dialog: _DiagnosisOrderQrDialog | None = None + self._native_video_capable = _supports_native_video(repository) + self._repository_methods = { + "cancel_appointment": _repository_method_name( + repository, "cancel_diagnosis_appointment" + ), + "video_qr": _repository_method_name(repository, "generate_video_qrcode"), + "confirm_qr": _repository_method_name(repository, "generate_diagnosis_qrcode"), + "appointment_logs": _repository_method_name(repository, "list_appointment_logs"), + "create_order": _repository_method_name(repository, "create_diagnosis_order"), + "order_qr": _repository_method_name(repository, "generate_order_qrcode"), + } root = QVBoxLayout(self) - root.setContentsMargins(24, 20, 24, 24) - root.setSpacing(14) - header = PageHeader( - "问诊列表", - "默认显示今天的诊单;双击进入只读病例,视频仅对当前已预约记录开放。", - ) - self.add_button = QPushButton("新增诊单") - self.add_button.setProperty("variant", "primary") - self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add")) - self.add_button.clicked.connect(self._add_diagnosis) - header.add_action(self.add_button) - root.addWidget(header) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + self.page_scroll = QScrollArea(self) + self.page_scroll.setObjectName("DiagnosisPageScroll") + self.page_scroll.setWidgetResizable(True) + self.page_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.page_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + root.addWidget(self.page_scroll) + content = QWidget() + content.setObjectName("DiagnosisIndexContent") + content.setAutoFillBackground(False) + self.page_scroll.setWidget(content) + page_layout = QVBoxLayout(content) + page_layout.setContentsMargins(8, 16, 8, 16) + page_layout.setSpacing(12) filters = QFrame() - filters.setObjectName("FilterBar") + filters.setObjectName("DiagnosisFilterCard") filter_layout = QVBoxLayout(filters) - filter_layout.setContentsMargins(16, 14, 16, 14) - filter_layout.setSpacing(10) - quick = QHBoxLayout() - quick.setSpacing(6) - self.date_buttons: dict[str, QPushButton] = {} + filter_layout.setContentsMargins(16, 12, 16, 12) + filter_layout.setSpacing(8) + + main_filters = QHBoxLayout() + main_filters.setContentsMargins(0, 0, 0, 0) + main_filters.setSpacing(10) + main_filters.setAlignment(Qt.AlignmentFlag.AlignTop) + self.main_chip_flow = FlowWidget(horizontal_spacing=6, vertical_spacing=6) + self.date_buttons: dict[str, DiagnosisChip] = {} self._date_button_labels: dict[str, str] = {} for label, offset in ( - ("前天", -2), - ("昨天", -1), - ("今天", 0), - ("明天", 1), - ("后天", 2), + ("昨天挂号", -1), + ("前天挂号", -2), + ("当天挂号", 0), + ("明天挂号", 1), + ("后天挂号", 2), ): value = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd") - button = QPushButton(label) - button.setCheckable(True) - button.setProperty("variant", "ghost") + button = DiagnosisChip(label) button.clicked.connect( lambda _checked=False, date_value=value: self._choose_date(date_value) ) self.date_buttons[value] = button self._date_button_labels[value] = label - quick.addWidget(button) - all_button = QPushButton("全部") - all_button.setCheckable(True) - all_button.setProperty("variant", "ghost") + self.main_chip_flow.flow.addWidget(button) + all_button = DiagnosisChip("全部") all_button.clicked.connect(lambda _checked=False: self._choose_date("")) self.date_buttons[""] = all_button self._date_button_labels[""] = "全部" - quick.addWidget(all_button) - self.pending_booking_button = QPushButton("待预约") - self.pending_booking_button.setCheckable(True) - self.pending_booking_button.setProperty("variant", "ghost") + self.main_chip_flow.flow.addWidget(all_button) + self.pending_booking_button = DiagnosisChip("待预约", semantic="info") self.pending_booking_button.clicked.connect(self._choose_pending_booking) - quick.addWidget(self.pending_booking_button) - self.completed_button = QPushButton("已完成") - self.completed_button.setCheckable(True) - self.completed_button.setProperty("variant", "ghost") + self.main_chip_flow.flow.addWidget(self.pending_booking_button) + self.completed_button = DiagnosisChip("已完成", semantic="success") self.completed_button.clicked.connect(self._choose_completed) - quick.addWidget(self.completed_button) - self.pending_assign_button = QPushButton("待分配医助") - self.pending_assign_button.setCheckable(True) - self.pending_assign_button.setProperty("variant", "ghost") - self.pending_assign_button.setVisible( - _canonical_allowed(permissions, "tcm.diagnosis/assign") - ) + self.main_chip_flow.flow.addWidget(self.completed_button) + self.pending_assign_wrap = QWidget() + pending_assign_wrap_layout = QHBoxLayout(self.pending_assign_wrap) + pending_assign_wrap_layout.setContentsMargins(8, 0, 0, 0) + pending_assign_wrap_layout.setSpacing(8) + self.pending_assign_button = DiagnosisChip("待分配医助", semantic="warning") self.pending_assign_button.clicked.connect(self._choose_pending_assign) - quick.addWidget(self.pending_assign_button) - quick.addStretch(1) - filter_layout.addLayout(quick) + pending_assign_wrap_layout.addWidget(self.pending_assign_button) + self.pending_assign_filters = QWidget() + pending_assign_layout = QHBoxLayout(self.pending_assign_filters) + pending_assign_layout.setContentsMargins(0, 0, 0, 0) + pending_assign_layout.setSpacing(8) + self.pending_assign_month = QDateEdit(QDate.currentDate()) + self.pending_assign_month.setObjectName("DiagnosisPendingAssignMonth") + self.pending_assign_month.setCalendarPopup(True) + self.pending_assign_month.setDisplayFormat("yyyy-MM") + self.pending_assign_month.setFixedWidth(128) + self.pending_assign_month.setToolTip("订单月份") + self.pending_assign_month.setAccessibleName("订单月份") + self.pending_assign_month.dateChanged.connect( + lambda _value: self._pending_assign_month_changed() + ) + pending_assign_layout.addWidget(self.pending_assign_month) + self.pending_assign_keyword = QLineEdit() + self.pending_assign_keyword.setPlaceholderText("搜身份证/诊单ID/患者号/备注…") + self.pending_assign_keyword.setClearButtonEnabled(True) + self.pending_assign_keyword.setFixedWidth(220) + self.pending_assign_keyword.returnPressed.connect(self._search) + pending_assign_layout.addWidget(self.pending_assign_keyword) + pending_assign_wrap_layout.addWidget(self.pending_assign_filters) + self.pending_assign_filters.hide() + self.pending_assign_wrap.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign")) + self.main_chip_flow.flow.addWidget(self.pending_assign_wrap) + main_filters.addWidget(self.main_chip_flow, 1) - grid = QGridLayout() - grid.setHorizontalSpacing(10) - grid.setVerticalSpacing(8) + search_host = QWidget() + search_layout = QHBoxLayout(search_host) + search_layout.setContentsMargins(0, 0, 0, 0) + search_layout.setSpacing(6) self.keyword_edit = QLineEdit() + self.keyword_edit.setObjectName("DiagnosisKeyword") self.keyword_edit.setPlaceholderText("患者姓名 / 手机号") self.keyword_edit.setClearButtonEnabled(True) + self.keyword_edit.setFixedWidth(160) self.keyword_edit.returnPressed.connect(self._search) - grid.addWidget(self.keyword_edit, 0, 0, 1, 2) + search_layout.addWidget(self.keyword_edit) + self.search_button = QPushButton("查询") + self.search_button.setProperty("variant", "primary") + self.search_button.clicked.connect(self._search) + search_layout.addWidget(self.search_button) + main_filters.addWidget(search_host, 0, Qt.AlignmentFlag.AlignTop) + filter_layout.addLayout(main_filters) + self.has_appointment_combo = self._fixed_combo( (("全部挂号", ""), ("已挂号", "1"), ("未挂号", "0")) ) - grid.addWidget(self.has_appointment_combo, 0, 2) + self.has_appointment_combo.setParent(filters) + self.has_appointment_combo.hide() self.diagnosis_confirmed_combo = self._fixed_combo( (("全部确认", ""), ("已确认", "1"), ("未确认", "0")) ) + self.diagnosis_confirmed_combo.setParent(filters) + self.diagnosis_confirmed_combo.hide() self.confirmed_combo = self.diagnosis_confirmed_combo - grid.addWidget(self.diagnosis_confirmed_combo, 0, 3) - - self.diagnosis_type_combo = self._editable_combo("全部诊断类型") - grid.addWidget(self.diagnosis_type_combo, 1, 0) - self.syndrome_combo = self._editable_combo("全部证型") - grid.addWidget(self.syndrome_combo, 1, 1) - self.assistant_combo = self._editable_combo("全部医助") - grid.addWidget(self.assistant_combo, 1, 2, 1, 2) - - self.latest_appointment_start_date = self._optional_date("最近挂号开始") - self.latest_appointment_end_date = self._optional_date("最近挂号结束") - self.channel_combo = self._editable_combo("全部最近挂号渠道") self.unserved_sort_combo = self._fixed_combo( ( ("未服务天数默认排序", ""), @@ -564,120 +1022,223 @@ class ConsultationsPage(QWidget): ("未服务天数从少到多", "asc"), ) ) - grid.addWidget(self.latest_appointment_start_date, 2, 0) - grid.addWidget(self.latest_appointment_end_date, 2, 1) - grid.addWidget(self.channel_combo, 2, 2) - grid.addWidget(self.unserved_sort_combo, 2, 3) + quick_frame = QFrame() + quick_frame.setObjectName("DiagnosisQuickFilters") + quick_layout = QVBoxLayout(quick_frame) + quick_layout.setContentsMargins(0, 8, 0, 0) + quick_layout.setSpacing(0) + self.quick_filter_flow = FlowWidget(horizontal_spacing=6, vertical_spacing=6) + self.quick_filter_flow.flow.addWidget(self._filter_label("挂号")) + self.appointment_filter_buttons: dict[str, DiagnosisChip] = {} + for label, value in (("全部", ""), ("已挂号", "1"), ("未挂号", "0")): + button = DiagnosisChip(label, small=True) + button.clicked.connect( + lambda _checked=False, selected=value: self._choose_appointment_filter(selected) + ) + self.appointment_filter_buttons[value] = button + self.quick_filter_flow.flow.addWidget(button) + divider = QFrame() + divider.setObjectName("DiagnosisFilterDivider") + self.quick_filter_flow.flow.addWidget(divider) + self.quick_filter_flow.flow.addWidget(self._filter_label("确认")) + self.confirmation_filter_buttons: dict[str, DiagnosisChip] = {} + for label, value in (("全部", ""), ("已确认", "1"), ("未确认", "0")): + button = DiagnosisChip(label, small=True) + button.clicked.connect( + lambda _checked=False, selected=value: self._choose_confirmation_filter(selected) + ) + self.confirmation_filter_buttons[value] = button + self.quick_filter_flow.flow.addWidget(button) + self.more_filter_button = QToolButton() + self.more_filter_button.setObjectName("DiagnosisMoreFilter") + self.more_filter_button.setText("更多筛选") + self.more_filter_button.setArrowType(Qt.ArrowType.DownArrow) + self.more_filter_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.more_filter_button.setCheckable(True) + self.more_filter_button.clicked.connect(self._toggle_advanced_filters) + self.quick_filter_flow.flow.addWidget(self.more_filter_button) + quick_layout.addWidget(self.quick_filter_flow) + filter_layout.addWidget(quick_frame) + + self.advanced_filters = QFrame() + self.advanced_filters.setObjectName("DiagnosisAdvancedFilters") + advanced_layout = QVBoxLayout(self.advanced_filters) + advanced_layout.setContentsMargins(0, 8, 0, 0) + advanced_layout.setSpacing(0) + self.advanced_filter_flow = FlowWidget(horizontal_spacing=10, vertical_spacing=10) + self.diagnosis_type_combo = self._editable_combo("诊断类型") + self.diagnosis_type_combo.setMinimumWidth(148) + self.syndrome_combo = self._editable_combo("证型") + self.syndrome_combo.setMinimumWidth(148) + self.assistant_combo = self._editable_combo("医助") + self.assistant_combo.setMinimumWidth(148) + for combo in ( + self.diagnosis_type_combo, + self.syndrome_combo, + self.assistant_combo, + ): + combo.setProperty("smallControl", True) + self.latest_appointment_start_date = self._optional_date("最近挂号开始") + self.latest_appointment_end_date = self._optional_date("最近挂号结束") + self.channel_combo = self._editable_combo("最近挂号渠道") + self.channel_combo.setProperty("smallControl", True) + self.channel_combo.setFixedWidth(170) self.latest_assign_start_date = self._optional_date("最近指派开始") self.latest_assign_end_date = self._optional_date("最近指派结束") - grid.addWidget(self.latest_assign_start_date, 3, 0) - grid.addWidget(self.latest_assign_end_date, 3, 1) - self.search_button = QPushButton("查询") - self.search_button.setProperty("variant", "secondary") - self.search_button.clicked.connect(self._search) - grid.addWidget(self.search_button, 3, 2) + self.advanced_filter_flow.flow.addWidget(self.diagnosis_type_combo) + self.advanced_filter_flow.flow.addWidget(self.syndrome_combo) + self.advanced_filter_flow.flow.addWidget(self.assistant_combo) + self.advanced_filter_flow.flow.addWidget( + self._date_range_field( + "最近挂号", self.latest_appointment_start_date, self.latest_appointment_end_date + ) + ) + self.advanced_filter_flow.flow.addWidget( + self._date_range_field( + "最近指派", self.latest_assign_start_date, self.latest_assign_end_date + ) + ) + self.advanced_filter_flow.flow.addWidget(self.channel_combo) + self.unserved_sort_combo.setParent(self.advanced_filters) + self.unserved_sort_combo.hide() + self.unserved_sort_combo.currentIndexChanged.connect(self._server_sort_changed) self.reset_button = QPushButton("重置") - self.reset_button.setProperty("variant", "ghost") + self.reset_button.setProperty("smallControl", True) self.reset_button.clicked.connect(self._reset) - grid.addWidget(self.reset_button, 3, 3) - for column in range(4): - grid.setColumnStretch(column, 1) - filter_layout.addLayout(grid) - root.addWidget(filters) - self._update_quick_buttons() + self.advanced_filter_flow.flow.addWidget(self.reset_button) + advanced_layout.addWidget(self.advanced_filter_flow) + self.advanced_filters.hide() + filter_layout.addWidget(self.advanced_filters) + page_layout.addWidget(filters) - self.banner = MessageBanner() - root.addWidget(self.banner) card = QFrame() - card.setObjectName("Card") + card.setObjectName("DiagnosisListCard") card_layout = QVBoxLayout(card) - card_layout.setContentsMargins(16, 14, 16, 14) - card_layout.setSpacing(10) - title_row = QHBoxLayout() - title = QLabel("诊单记录") - title.setProperty("role", "sectionTitle") - title_row.addWidget(title) - self.summary_label = QLabel("等待加载") - self.summary_label.setProperty("role", "muted") - title_row.addWidget(self.summary_label) - title_row.addStretch(1) - self.view_button = QPushButton("查看") - self.view_button.setProperty("variant", "ghost") + card_layout.setContentsMargins(0, 0, 0, 0) + card_layout.setSpacing(0) + toolbar = QFrame() + toolbar.setObjectName("DiagnosisListToolbar") + toolbar_layout = QHBoxLayout(toolbar) + toolbar_layout.setContentsMargins(16, 10, 16, 10) + toolbar_layout.setSpacing(8) + self.add_button = QPushButton("新增诊单") + self.add_button.setProperty("variant", "primary") + self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add")) + self.add_button.clicked.connect(self._add_diagnosis) + toolbar_layout.addWidget(self.add_button) + self.batch_assign_button = QPushButton("批量指派医助") + self.batch_assign_button.setProperty("variant", "success") + self.batch_assign_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign")) + self.batch_assign_button.clicked.connect(self._batch_assign) + toolbar_layout.addWidget(self.batch_assign_button) + self.batch_cancel_assign_button = QPushButton("批量取消指派") + self.batch_cancel_assign_button.setProperty("variant", "warning") + self.batch_cancel_assign_button.setVisible( + _canonical_allowed(permissions, "tcm.diagnosis/assign") + ) + self.batch_cancel_assign_button.clicked.connect(self._batch_cancel_assign) + toolbar_layout.addWidget(self.batch_cancel_assign_button) + toolbar_layout.addStretch(1) + self.selected_label = QLabel("") + self.selected_label.setProperty("pagerMuted", True) + self.selected_label.hide() + toolbar_layout.addWidget(self.selected_label) + card_layout.addWidget(toolbar) + + self.banner = _ToastBanner(self, card) + self.summary_label = QLabel("等待加载", card) + self.summary_label.hide() + + # These buttons retain the public compatibility API used by the shell + # and tests. The visible equivalents live in each fixed action cell. + self._compat_actions = QWidget(card) + self._compat_actions.hide() + self.view_button = QPushButton("查看", self._compat_actions) self.view_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail")) self.view_button.clicked.connect(self._open_readonly) - title_row.addWidget(self.view_button) - self.edit_button = QPushButton("诊单") - self.edit_button.setProperty("variant", "ghost") + self.edit_button = QPushButton("诊单", self._compat_actions) self.edit_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/edit")) self.edit_button.clicked.connect(self._open_edit) - title_row.addWidget(self.edit_button) - self.prescription_button = QPushButton("开方") - self.prescription_button.setProperty("variant", "secondary") + self.prescription_button = QPushButton("开方", self._compat_actions) self.prescription_button.setVisible( _canonical_allowed(permissions, "tcm.diagnosis/kaifang") ) self.prescription_button.clicked.connect(self._open_prescription) - title_row.addWidget(self.prescription_button) - self.void_button = QPushButton("作废处方") - self.void_button.setProperty("variant", "danger") + self.void_button = QPushButton("作废处方", self._compat_actions) self.void_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/kaifang")) self.void_button.clicked.connect(self._void_selected_prescription) - title_row.addWidget(self.void_button) - self.video_button = QPushButton("发起视频") - self.video_button.setProperty("variant", "primary") + self.video_button = QPushButton("进入视频问诊", self._compat_actions) + self.video_button.setVisible(self._native_video_capable) self.video_button.clicked.connect(self._request_video) - title_row.addWidget(self.video_button) - self.delete_button = QPushButton("删除") - self.delete_button.setProperty("variant", "danger") + self.delete_button = QPushButton("删除", self._compat_actions) self.delete_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/delete")) self.delete_button.clicked.connect(self._delete_selected) - title_row.addWidget(self.delete_button) - self.refresh_button = QPushButton("刷新") - self.refresh_button.setProperty("variant", "ghost") + self.refresh_button = QPushButton("刷新", self._compat_actions) self.refresh_button.clicked.connect(lambda: self.refresh()) - title_row.addWidget(self.refresh_button) - card_layout.addLayout(title_row) - self.content_stack = QStackedWidget() - host = QWidget() - host_layout = QVBoxLayout(host) - host_layout.setContentsMargins(0, 0, 0, 0) - self.table = SortableTable( - [ - TableColumn("id", "ID / NEW", 92, _id_cell), - TableColumn("patient_name", "患者", 90), - TableColumn("gender", "性别 / 年龄", 105, _gender_age_cell), - TableColumn( - "appointments", "挂号状态 / 时间 / 医生 / 渠道", 300, _appointment_cell + table_wrap = QWidget() + table_wrap_layout = QVBoxLayout(table_wrap) + table_wrap_layout.setContentsMargins(16, 10, 16, 0) + table_wrap_layout.setSpacing(0) + self.table_host = DiagnosisTableHost( + action_policy={ + "view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"), + "edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"), + "prescription": _canonical_allowed(permissions, "tcm.diagnosis/kaifang"), + "appointment": _canonical_allowed(permissions, "tcm.diagnosis/guahao"), + "assign": _canonical_allowed(permissions, "tcm.diagnosis/assign"), + "delete": _canonical_allowed(permissions, "tcm.diagnosis/delete"), + "video_call": self._native_video_capable, + "appointment_cancel": bool( + _canonical_allowed(permissions, "tcm.diagnosis/guahao") + and self._repository_methods["cancel_appointment"] ), - TableColumn("diagnosis_confirmed", "确认", 80, _confirmation_cell), - TableColumn("followup_time_text", "复诊", 125, _revisit_cell), - TableColumn("doctor_name", "医生", 90), - TableColumn("prescription_void_status", "作废", 74, _void_cell), - TableColumn("assistant_name", "医助", 90, self._assistant_cell), - TableColumn("has_prescription", "处方", 76, _prescription_cell), - TableColumn("unserved_days", "未服务 / 上次面诊", 180, _unserved_cell), - TableColumn("video_call_hint", "通话", 90, _watch_cell), - ] + "video_qr": bool( + _canonical_allowed(permissions, "tcm.diagnosis/videoQr") + and self._repository_methods["video_qr"] + ), + "confirm_qr": bool( + _canonical_allowed(permissions, "tcm.diagnosis/guahao") + and self._repository_methods["confirm_qr"] + ), + "appointment_logs": bool( + _canonical_allowed(permissions, "tcm.diagnosis/guahaoLogList") + and self._repository_methods["appointment_logs"] + ), + "create_order": bool( + _canonical_allowed(permissions, "tcm.diagnosis/order") + and self._repository_methods["create_order"] + and self._repository_methods["order_qr"] + ), + } ) - self.table.setWordWrap(True) + self.table = self.table_host.main self.table.itemSelectionChanged.connect(self._selection_changed) self.table.itemDoubleClicked.connect(lambda _item: self._open_readonly()) - host_layout.addWidget(self.table, 1) - self.pager = Pager(self._page_size) + self.table_host.action_requested.connect(self._row_action) + self.table_host.appointment_cancel_requested.connect(self._cancel_appointment_item) + self.table_host.video_requested.connect(self._row_video) + self.table_host.checked_changed.connect(self._checked_changed) + self.table_host.sort_unserved_requested.connect(self._sort_unserved) + table_wrap_layout.addWidget(self.table_host) + self.loading_overlay = DiagnosisLoadingOverlay(self.table_host) + card_layout.addWidget(table_wrap) + self.pager = DiagnosisPager(self._page_size) self.pager.page_changed.connect(self._change_page) - host_layout.addWidget(self.pager) - self.content_stack.addWidget(host) - self.content_stack.addWidget(EmptyState("没有问诊记录", "当前日期和筛选条件下没有诊单。")) - card_layout.addWidget(self.content_stack, 1) - root.addWidget(card, 1) + self.pager.page_size_changed.connect(self._change_page_size) + card_layout.addWidget(self.pager) + page_layout.addWidget(card) + page_layout.addStretch(1) self._diagnosis_dialog = DiagnosisDialog(repository, self) self._diagnosis_dialog.saved.connect(lambda: self.refresh(silent=True)) self.poll_timer = QTimer(self) self.poll_timer.setInterval(20_000) - self.poll_timer.timeout.connect(lambda: self.refresh(silent=True)) + self.poll_timer.timeout.connect(self._poll_refresh) + self._update_quick_buttons() + self._update_secondary_chips() + self._checked_changed(0) self._selection_changed() @staticmethod @@ -702,11 +1263,58 @@ class ConsultationsPage(QWidget): editor.setCalendarPopup(True) editor.setDisplayFormat("yyyy-MM-dd") editor.setMinimumDate(_OPTIONAL_DATE_MINIMUM) - editor.setSpecialValueText("不限") + editor.setSpecialValueText(tooltip) editor.setDate(_OPTIONAL_DATE_MINIMUM) editor.setToolTip(tooltip) return editor + @staticmethod + def _filter_label(text: str) -> QLabel: + label = QLabel(text) + label.setProperty("filterGroup", True) + return label + + def _filter_field(self, label_text: str, control: QWidget) -> QWidget: + host = QWidget() + layout = QHBoxLayout(host) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + layout.addWidget(self._filter_label(label_text)) + layout.addWidget(control) + return host + + def _date_range_field( + self, + label_text: str, + start: QDateEdit, + end: QDateEdit, + ) -> QWidget: + start.setProperty("diagnosisRangePart", True) + end.setProperty("diagnosisRangePart", True) + host = QFrame() + host.setObjectName("DiagnosisDateRange") + host.setAccessibleName(label_text) + host.setFixedWidth(260) + layout = QHBoxLayout(host) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(start, 1) + separator = QLabel("—") + separator.setProperty("diagnosisRangeSeparator", True) + separator.setAlignment(Qt.AlignmentFlag.AlignCenter) + separator.setFixedWidth(14) + layout.addWidget(separator) + layout.addWidget(end, 1) + return host + + def _pending_assign_month_value(self) -> str: + return self.pending_assign_month.date().toString("yyyy-MM") + + def _pending_assign_month_changed(self) -> None: + if self._pending_assign: + self._page = 1 + self.refresh() + @staticmethod def _date_value(editor: QDateEdit) -> str: if editor.date() == _OPTIONAL_DATE_MINIMUM: @@ -727,6 +1335,60 @@ class ConsultationsPage(QWidget): assistant_id = _as_int(first_value(row, "assistant_id", "assistant", default=0)) return self._assistant_names.get(assistant_id, "—") + def _choose_appointment_filter(self, value: str) -> None: + index = self.has_appointment_combo.findData(value) + self.has_appointment_combo.setCurrentIndex(max(0, index)) + self._pending_booking = "" + self._completed_appointment = "" + if value == "0": + self._clear_latest_appointment_filters() + self._update_quick_buttons() + self._update_secondary_chips() + self._page = 1 + self.refresh() + + def _choose_confirmation_filter(self, value: str) -> None: + index = self.diagnosis_confirmed_combo.findData(value) + self.diagnosis_confirmed_combo.setCurrentIndex(max(0, index)) + self._pending_booking = "" + self._completed_appointment = "" + self._update_quick_buttons() + self._update_secondary_chips() + self._page = 1 + self.refresh() + + def _update_secondary_chips(self) -> None: + appointment_value = str(self._combo_value(self.has_appointment_combo)) + confirmation_value = str(self._combo_value(self.diagnosis_confirmed_combo)) + for value, button in self.appointment_filter_buttons.items(): + button.setChecked(value == appointment_value) + for value, button in self.confirmation_filter_buttons.items(): + button.setChecked(value == confirmation_value) + direction = str(self._combo_value(self.unserved_sort_combo)) + if hasattr(self, "table_host"): + self.table_host.set_sort_direction(direction) + + def _toggle_advanced_filters(self, checked: bool) -> None: + self.advanced_filters.setVisible(checked) + self.more_filter_button.setText("收起" if checked else "更多筛选") + self.more_filter_button.setArrowType( + Qt.ArrowType.UpArrow if checked else Qt.ArrowType.DownArrow + ) + self.main_chip_flow.updateGeometry() + self.quick_filter_flow.updateGeometry() + + def _server_sort_changed(self, _index: int) -> None: + if not hasattr(self, "table_host"): + return + direction = str(self._combo_value(self.unserved_sort_combo)) + self.table_host.set_sort_direction(direction) + + def _sort_unserved(self, direction: str) -> None: + index = self.unserved_sort_combo.findData(direction) + self.unserved_sort_combo.setCurrentIndex(max(0, index)) + self._page = 1 + self.refresh(silent=True) + def _choose_date(self, value: str) -> None: self._appointment_date = value self._pending_booking = "" @@ -735,6 +1397,7 @@ class ConsultationsPage(QWidget): self.has_appointment_combo.setCurrentIndex(0) self._clear_latest_appointment_filters() self._update_quick_buttons() + self._update_secondary_chips() self._page = 1 self.refresh() @@ -746,6 +1409,7 @@ class ConsultationsPage(QWidget): self.has_appointment_combo.setCurrentIndex(2) self._clear_latest_appointment_filters() self._update_quick_buttons() + self._update_secondary_chips() self._page = 1 self.refresh() @@ -757,6 +1421,7 @@ class ConsultationsPage(QWidget): self.has_appointment_combo.setCurrentIndex(0) self._clear_latest_appointment_filters() self._update_quick_buttons() + self._update_secondary_chips() self._page = 1 self.refresh() @@ -769,7 +1434,10 @@ class ConsultationsPage(QWidget): self._pending_assign = "1" self.has_appointment_combo.setCurrentIndex(0) self._clear_latest_appointment_filters() + if not self.pending_assign_month.date().isValid(): + self.pending_assign_month.setDate(QDate.currentDate()) self._update_quick_buttons() + self._update_secondary_chips() self._page = 1 self.refresh() @@ -797,6 +1465,9 @@ class ConsultationsPage(QWidget): self.pending_assign_button.setText( f"待分配医助 {assign_count}" if assign_count is not None else "待分配医助" ) + self.pending_assign_filters.setVisible(bool(self._pending_assign)) + for flow in (self.main_chip_flow, self.quick_filter_flow): + flow.updateGeometry() def _clear_latest_appointment_filters(self) -> None: self.latest_appointment_start_date.setDate(_OPTIONAL_DATE_MINIMUM) @@ -826,7 +1497,12 @@ class ConsultationsPage(QWidget): self._pending_booking = "" self._completed_appointment = "" self._pending_assign = "" + blocked = self.pending_assign_month.blockSignals(True) + self.pending_assign_month.setDate(QDate.currentDate()) + self.pending_assign_month.blockSignals(blocked) + self.pending_assign_keyword.clear() self._update_quick_buttons() + self._update_secondary_chips() self._page = 1 self.refresh() @@ -852,6 +1528,14 @@ class ConsultationsPage(QWidget): def _search(self) -> None: if not self._validate_ranges(): return + if self._pending_assign and ( + self.pending_assign_keyword.text().strip() or self.keyword_edit.text().strip() + ): + self._appointment_date = "" + self._pending_booking = "" + self._completed_appointment = "" + self.diagnosis_confirmed_combo.setCurrentIndex(0) + self._clear_latest_appointment_filters() if ( self._date_value(self.latest_appointment_start_date) or self._date_value(self.latest_appointment_end_date) @@ -863,6 +1547,7 @@ class ConsultationsPage(QWidget): if self._combo_value(self.has_appointment_combo) == "0": self.has_appointment_combo.setCurrentIndex(0) self._update_quick_buttons() + self._update_secondary_chips() self._page = 1 self.refresh() @@ -870,6 +1555,13 @@ class ConsultationsPage(QWidget): self._page = page self.refresh(silent=True) + def _change_page_size(self, page_size: int) -> None: + if page_size not in {15, 20, 30, 40}: + return + self._page_size = page_size + self._page = 1 + self.refresh(silent=True) + def _shared_filters(self) -> dict[str, Any]: return { "keyword": self.keyword_edit.text().strip(), @@ -896,13 +1588,26 @@ class ConsultationsPage(QWidget): "sort_unserved_days": self._combo_value(self.unserved_sort_combo), } ) + if self._pending_assign: + pending_keyword = self.pending_assign_keyword.text().strip() + keyword = self.keyword_edit.text().strip() + if pending_keyword or keyword: + return { + "pending_assign": "1", + "pending_assign_keyword": pending_keyword or keyword, + } + filters["pending_assign_order_month"] = self._pending_assign_month_value() + filters["pending_assign_keyword"] = "" return filters def refresh(self, silent: bool = False) -> None: """Refresh without allowing an older async request to overwrite newer filters.""" + if self._order_flow_generation is not None: + return if not self._validate_ranges(): return + self._menu_generation += 1 self._generation += 1 generation = self._generation self._loading = True @@ -910,8 +1615,12 @@ class ConsultationsPage(QWidget): filters = MappingProxyType(self._filters()) page = self._page if not silent: - self.banner.show_message("正在加载问诊诊单…", "info") + self._visible_loading_generation = generation + self.table_host.begin_loading() + self.loading_overlay.start() self._refresh_counts() + elif self.loading_overlay.isVisible(): + self._visible_loading_generation = generation run_async( lambda: invoke( self.repository, @@ -935,24 +1644,29 @@ class ConsultationsPage(QWidget): self._page = last_page self.refresh(silent=True) return - self.table.set_rows(rows) - self.table.resizeRowsToContents() + self.table_host.force_open_prescription = bool(self._pending_assign) + self.table_host.set_rows(rows) self.pager.update_state(self._page, total) - self.summary_label.setText(f"共 {total} 条 · 第 {self._page} 页 · 每页 15 条") - self.content_stack.setCurrentIndex(0 if rows else 1) + self.summary_label.setText( + f"共 {total} 条 · 第 {self._page} 页 · 每页 {self._page_size} 条" + ) 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") + message = friendly_error(error) + self.banner.show_message(message, "danger") + if self.table.rowCount() == 0: + self.table_host.show_error(f"列表加载失败,请重试\n{message}") def _load_finished(self, generation: int) -> None: if generation == self._generation: self._loading = False self.refresh_button.setEnabled(True) + if self._visible_loading_generation == generation: + self.loading_overlay.stop() + self._visible_loading_generation = 0 def _refresh_counts(self) -> None: self._count_generation += 1 @@ -981,8 +1695,26 @@ class ConsultationsPage(QWidget): ) requests.append(("completed", MappingProxyType(completed))) if _canonical_allowed(self.permissions, "tcm.diagnosis/assign"): - assign = dict(shared) - assign.update({"appointment_date": "", "has_appointment": "", "pending_assign": "1"}) + pending_keyword = self.pending_assign_keyword.text().strip() + shared_keyword = self.keyword_edit.text().strip() + if self._pending_assign and (pending_keyword or shared_keyword): + assign = { + "pending_assign": "1", + "pending_assign_keyword": pending_keyword or shared_keyword, + } + else: + assign = dict(shared) + assign.update( + { + "appointment_date": "", + "has_appointment": "", + "pending_assign": "1", + "pending_assign_order_month": self._pending_assign_month_value() + if self._pending_assign + else QDate.currentDate().toString("yyyy-MM"), + "pending_assign_keyword": pending_keyword, + } + ) requests.append(("pending_assign", MappingProxyType(assign))) def load_counts() -> dict[str, int]: @@ -1029,7 +1761,7 @@ class ConsultationsPage(QWidget): for key in ( "diagnosis_type", "syndrome_type", - "appointment_channel_source", + "channels", ): try: value = invoke(self.repository, "get_dictionary", dictionary_type=key) @@ -1053,10 +1785,27 @@ class ConsultationsPage(QWidget): def _apply_filter_options(self, options: Any, generation: int) -> None: if generation != self._options_generation or not isinstance(options, Mapping): return + channel_rows = options.get("channels", options.get("appointment_channel_source", [])) + if isinstance(channel_rows, Sequence) and not isinstance(channel_rows, (str, bytes)): + channel_rows = sorted( + ( + row + for row in channel_rows + if _as_int(first_value(row, "status", default=1), 1) != 0 + ), + key=lambda row: ( + _as_int(first_value(row, "sort", default=0)), + _as_int(first_value(row, "id", default=0)), + ), + reverse=True, + ) mappings = ( (self.diagnosis_type_combo, options.get("diagnosis_type", [])), (self.syndrome_combo, options.get("syndrome_type", [])), - (self.channel_combo, options.get("appointment_channel_source", [])), + ( + self.channel_combo, + channel_rows, + ), (self.assistant_combo, options.get("assistants", [])), ) for combo, rows in mappings: @@ -1082,7 +1831,702 @@ class ConsultationsPage(QWidget): if _as_int(first_value(row, "id", "value")) > 0 } + def _checked_changed(self, count: int) -> None: + self.selected_label.setText(f"已选 {count} 条") + self.selected_label.setVisible(count > 0) + if hasattr(self, "batch_assign_button"): + self.batch_assign_button.setEnabled(count > 0 and not self._mutation_pending) + if hasattr(self, "batch_cancel_assign_button"): + rows = self.table_host.selected_records() + has_assigned = any( + _as_int(first_value(row, "assistant_id", "assistant", default=0)) > 0 + for row in rows + ) + self.batch_cancel_assign_button.setEnabled( + count > 0 and has_assigned and not self._mutation_pending + ) + + def _select_record(self, record: Any) -> bool: + diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0)) + for row, candidate in enumerate(self.table_host.model.rows): + if _as_int(first_value(candidate, "diagnosis_id", "id", default=0)) == diagnosis_id: + self.table.selectRow(row) + return True + return False + + def _row_action(self, action: str, record: Any) -> None: + if not self._select_record(record): + return + actions = { + "view": self._open_readonly, + "edit": self._open_edit, + "prescription": self._open_prescription, + "appointment": self._book_selected_appointment, + "fill_id_card": self._fill_selected_id_card, + "assign": self._assign_selected, + "cancel_assign": self._cancel_selected_assign, + "cancel_appointment": self._cancel_selected_appointment, + "video_qr": self._request_video_qr, + "confirm_qr": self._request_confirm_qr, + "appointment_logs": self._request_appointment_logs, + "create_order": self._create_diagnosis_order, + "delete": self._delete_selected, + } + handler = actions.get(action) + if handler is not None: + handler() + + def _row_video(self, record: Any) -> None: + if self._select_record(record): + self._request_video() + + def _batch_assign(self) -> None: + if not _canonical_allowed(self.permissions, "tcm.diagnosis/assign"): + return + records = self.table_host.selected_records() + if not records: + show_toast(self, "请先选择要指派的诊单。", "warning") + return + self._choose_assistant(records) + + def _assign_selected(self) -> None: + if not _canonical_allowed(self.permissions, "tcm.diagnosis/assign"): + return + record = self.table.current_data() + if record is not None: + self._choose_assistant([record]) + + def _choose_assistant(self, records: Sequence[Any]) -> None: + snapshot = [deepcopy(record) for record in records] + if self._assistant_names: + self._show_assistant_picker(snapshot, self._assistant_names) + return + + def load() -> list[Any]: + return page_items(invoke(self.repository, "list_diagnosis_assistants")) + + run_async( + load, + on_success=lambda rows: self._assistant_options_loaded_for_picker(rows, snapshot), + on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4200), + ) + + def _assistant_options_loaded_for_picker(self, rows: Any, records: Sequence[Any]) -> None: + options = { + _as_int(first_value(row, "id", "value")): display_text( + first_value(row, "name", "label"), "" + ) + for row in rows + if isinstance(rows, Sequence) + if _as_int(first_value(row, "id", "value")) > 0 + } + options = {key: value for key, value in options.items() if value} + self._assistant_names.update(options) + self._show_assistant_picker(records, options) + + def _show_assistant_picker( + self, + records: Sequence[Any], + options: Mapping[int, str], + ) -> None: + if not options: + show_toast(self, "暂无可指派医助。", "warning") + return + labels = [f"{name}(#{assistant_id})" for assistant_id, name in options.items()] + selected, accepted = QInputDialog.getItem( + self, + "指派医助", + f"为已选择的 {len(records)} 条诊单指派:", + labels, + 0, + False, + ) + if not accepted: + return + assistant_id = next( + (key for key, name in options.items() if selected == f"{name}(#{key})"), + 0, + ) + diagnosis_ids = tuple( + _as_int(first_value(record, "diagnosis_id", "id")) for record in records + ) + + def assign() -> None: + for diagnosis_id in diagnosis_ids: + invoke( + self.repository, + "assign_diagnosis", + diagnosis_id=diagnosis_id, + assistant_id=assistant_id, + is_inherit=0, + ) + + self._run_mutation(assign, "医助已指派。") + + def _batch_cancel_assign(self) -> None: + if not _canonical_allowed(self.permissions, "tcm.diagnosis/assign"): + return + records = [ + row + for row in self.table_host.selected_records() + if _as_int(first_value(row, "assistant_id", "assistant", default=0)) > 0 + ] + self._cancel_assign_records(records) + + def _cancel_selected_assign(self) -> None: + record = self.table.current_data() + records = [record] if record is not None else [] + self._cancel_assign_records(records) + + def _cancel_assign_records(self, records: Sequence[Any]) -> None: + if not _canonical_allowed(self.permissions, "tcm.diagnosis/assign"): + return + assigned = [ + row + for row in records + if _as_int(first_value(row, "assistant_id", "assistant", default=0)) > 0 + ] + if not assigned: + show_toast(self, "所选诊单没有已指派的医助。", "warning") + return + answer = QMessageBox.question( + self, + "取消指派", + f"确定取消 {len(assigned)} 条诊单的医助指派吗?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Yes: + return + diagnosis_ids = tuple( + _as_int(first_value(record, "diagnosis_id", "id")) for record in assigned + ) + + def cancel() -> None: + for diagnosis_id in diagnosis_ids: + invoke( + self.repository, + "assign_diagnosis", + diagnosis_id=diagnosis_id, + assistant_id=0, + ) + + self._run_mutation(cancel, "医助指派已取消。") + + def _book_selected_appointment(self) -> None: + if not _canonical_allowed(self.permissions, "tcm.diagnosis/guahao"): + return + record = self.table.current_data() + if record is None: + return + # Kept lazy to avoid making the two page modules import each other at startup. + from .patients import _AppointmentDialog + + dialog = _AppointmentDialog(record, repository=self.repository, parent=self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + payload = MappingProxyType(dialog.payload()) + self._run_mutation( + lambda: invoke( + self.repository, + "book_patient_appointment", + payload=payload, + **payload, + ), + "预约已保存。", + ) + + def _cancel_selected_appointment(self) -> None: + if not _canonical_allowed(self.permissions, "tcm.diagnosis/guahao"): + return + record = self.table.current_data() + appointment = _single_cancellable_appointment(record) + if appointment is None: + if len(appointment_rows(record)) > 1: + show_toast(self, "该诊单有多条挂号,请在具体挂号记录中取消。", "warning") + return + show_toast(self, "当前挂号不可取消。", "warning") + return + appointment_id = _as_int(first_value(appointment, "id", "appointment_id", default=0)) + self._cancel_appointment_item(record, appointment_id) + + def _cancel_appointment_item(self, record: Any, appointment_id: int) -> None: + """Cancel the exact appointment exposed by one card, with a second pre-mutation check.""" + + if ( + not _canonical_allowed(self.permissions, "tcm.diagnosis/guahao") + or self._mutation_pending + or self._repository_methods.get("cancel_appointment") is None + ): + return + diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0)) + if diagnosis_id <= 0 or not self._select_record(record): + show_toast(self, "诊单已刷新,请重新选择挂号。", "warning") + return + current = self.table.current_data() + appointment = _cancellable_appointment_by_id(current, appointment_id) + if appointment is None: + show_toast(self, "该挂号已变化或当前不可取消。", "warning") + return + patient_name = display_text(first_value(current, "patient_name"), "患者") + doctor_name = display_text(first_value(appointment, "doctor_name"), "医生") + time_text = display_text( + first_value(appointment, "time_text", "appointment_time"), "时间未标注" + ) + answer = QMessageBox.question( + self, + "取消挂号", + f"确定取消挂号 #{appointment_id} 吗?\n" + f"医生/时间:{doctor_name} · {time_text}\n患者:{patient_name}", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Yes: + return + current = next( + ( + item + for item in self.table_host.model.rows + if _as_int(first_value(item, "diagnosis_id", "id", default=0)) == diagnosis_id + ), + None, + ) + if _cancellable_appointment_by_id(current, appointment_id) is None: + show_toast(self, "挂号状态已刷新,本次未执行取消。", "warning") + return + method_name = self._repository_methods["cancel_appointment"] + assert method_name is not None + self._run_mutation( + lambda: invoke( + self.repository, + method_name, + appointment_id=appointment_id, + ), + "挂号已取消。", + ) + + def _fill_selected_id_card(self) -> None: + if not _canonical_allowed(self.permissions, "tcm.diagnosis/edit"): + return + record = self.table.current_data() + diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0)) + if diagnosis_id <= 0: + return + id_card, accepted = QInputDialog.getText( + self, + "补全身份证", + "身份证号:", + text=display_text(first_value(record, "id_card", default=""), ""), + ) + id_card = id_card.strip() + if not accepted: + return + if not id_card: + show_toast(self, "请输入身份证号。", "warning") + return + if not is_valid_id_card(id_card): + show_toast(self, "请输入15或18位有效身份证号。", "warning") + return + self._run_mutation( + lambda: invoke( + self.repository, + "fill_diagnosis_id_card", + diagnosis_id=diagnosis_id, + id_card=id_card, + ), + "身份证已补全。", + ) + + def _run_menu_request(self, operation: Any, on_success: Any) -> None: + """Run a menu request and reject results for a newer row selection.""" + + self._menu_generation += 1 + generation = self._menu_generation + run_async( + operation, + on_success=lambda result: ( + on_success(result) if generation == self._menu_generation else None + ), + on_error=lambda error: ( + show_toast(self, friendly_error(error), "danger", 4200) + if generation == self._menu_generation + else None + ), + ) + + @staticmethod + def _qr_url(result: Any) -> str: + if isinstance(result, str): + return result.strip() + return str( + first_value( + result, + "qrcode_url", + "data.qrcode_url", + "url", + "data.url", + default="", + ) + or "" + ).strip() + + def _request_video_qr(self) -> None: + self._request_qr( + capability="video_qr", + permission="tcm.diagnosis/videoQr", + title="视频二维码", + ) + + def _request_confirm_qr(self) -> None: + self._request_qr( + capability="confirm_qr", + permission="tcm.diagnosis/guahao", + title="诊单二维码", + ) + + def _request_qr( + self, + *, + capability: str, + permission: str, + title: str, + ) -> None: + method_name = self._repository_methods.get(capability) + record = self.table.current_data() + if not _canonical_allowed(self.permissions, permission) or method_name is None: + return + if record is None or not is_video_available(record): + show_toast(self, f"仅“已预约”状态可生成{title}。", "warning") + return + diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0)) + patient_id = _as_int(first_value(record, "patient_id", "source_patient_id", default=0)) + appointments = appointment_rows(record) + doctor_id = _as_int( + first_value( + record, + "appointment_doctor_id", + default=first_value( + appointments[0] if appointments else None, "doctor_id", default=0 + ), + ) + ) + if diagnosis_id <= 0 or patient_id <= 0 or doctor_id <= 0: + show_toast(self, "患者、诊单或预约医生标识不完整,无法生成二维码。", "warning") + return + share_user_id = _as_int(first_value(self.current_user, "id", default=0)) + if share_user_id <= 0: + show_toast(self, "当前用户标识不完整,无法生成二维码。", "warning") + return + payload: dict[str, Any] = { + "patient_id": patient_id, + "doctor_id": doctor_id, + "share_user_id": share_user_id, + } + if capability == "confirm_qr": + payload["diagnosis_id"] = diagnosis_id + snapshot = deepcopy(record) + frozen_payload = MappingProxyType(payload) + self._run_menu_request( + lambda: invoke( + self.repository, + method_name, + **frozen_payload, + ), + lambda result: self._show_qr_result(title, snapshot, result), + ) + + def _show_qr_result(self, title: str, record: Any, result: Any) -> None: + qrcode_url = self._qr_url(result) + if not qrcode_url: + show_toast(self, "服务器未返回可用的二维码地址。", "danger", 4200) + return + dialog = _QrResultDialog(title, record, qrcode_url, self) + self._qr_result_dialog = dialog + dialog.finished.connect(lambda _code: setattr(self, "_qr_result_dialog", None)) + dialog.open() + + def _request_appointment_logs(self) -> None: + method_name = self._repository_methods.get("appointment_logs") + record = self.table.current_data() + if ( + not _canonical_allowed(self.permissions, "tcm.diagnosis/guahaoLogList") + or method_name is None + or record is None + ): + return + diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0)) + if diagnosis_id <= 0: + show_toast(self, "诊单标识不完整,无法查询挂号日志。", "warning") + return + snapshot = deepcopy(record) + self._run_menu_request( + lambda: invoke( + self.repository, + method_name, + diagnosis_id=diagnosis_id, + id=diagnosis_id, + ), + lambda result: self._show_appointment_logs(snapshot, result), + ) + + def _show_appointment_logs(self, record: Any, result: Any) -> None: + dialog = _AppointmentLogDialog(record, page_items(result), self) + self._appointment_logs_dialog = dialog + dialog.finished.connect(lambda _code: setattr(self, "_appointment_logs_dialog", None)) + dialog.open() + + def _create_diagnosis_order(self) -> None: + method_name = self._repository_methods.get("create_order") + qr_method_name = self._repository_methods.get("order_qr") + record = self.table.current_data() + if ( + not _canonical_allowed(self.permissions, "tcm.diagnosis/order") + or method_name is None + or qr_method_name is None + or record is None + or self._mutation_pending + or self._order_flow_generation is not None + ): + return + diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0)) + patient_id = _as_int(first_value(record, "patient_id", "source_patient_id", default=0)) + if diagnosis_id <= 0 or patient_id <= 0: + show_toast(self, "患者或诊单标识不完整,无法创建订单。", "warning") + return + dialog = _DiagnosisOrderDialog(record, self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + current = self.table.current_data() + if ( + not _canonical_allowed(self.permissions, "tcm.diagnosis/order") + or not callable(getattr(self.repository, method_name, None)) + or not callable(getattr(self.repository, qr_method_name, None)) + or _as_int(first_value(current, "diagnosis_id", "id", default=0)) != diagnosis_id + or _as_int(first_value(current, "patient_id", "source_patient_id", default=0)) + != patient_id + ): + show_toast(self, "权限或当前诊单已变化,本次未创建订单。", "warning") + return + payload = MappingProxyType(dialog.payload()) + snapshot = deepcopy(record) + generation = self._begin_order_flow() + run_async( + lambda: invoke( + self.repository, + method_name, + payload=payload, + **payload, + ), + on_success=lambda result: self._diagnosis_order_created( + result, + snapshot, + diagnosis_id, + patient_id, + generation, + ), + on_error=lambda error: self._diagnosis_order_create_error(error, generation), + ) + + @staticmethod + def _order_no(result: Any) -> str: + value = first_value(result, "order_no", "data.order_no", default="") + return str(value or "").strip() + + def _begin_order_flow(self) -> int: + self._mutation_pending = True + self.table_host.setEnabled(False) + self._checked_changed(len(self.table_host.selected_records())) + self._selection_changed() + self._order_generation += 1 + self._order_flow_generation = self._order_generation + self._order_qr_request_pending = False + return self._order_generation + + def _order_flow_guard( + self, + generation: int, + diagnosis_id: int, + patient_id: int, + ) -> bool: + if generation != self._order_flow_generation: + return False + if not _canonical_allowed(self.permissions, "tcm.diagnosis/order"): + return False + method_name = self._repository_methods.get("order_qr") + if method_name is None or not callable(getattr(self.repository, method_name, None)): + return False + current = self.table.current_data() + return bool( + _as_int(first_value(current, "diagnosis_id", "id", default=0)) == diagnosis_id + and _as_int(first_value(current, "patient_id", "source_patient_id", default=0)) + == patient_id + ) + + def _diagnosis_order_created( + self, + result: Any, + record: Any, + diagnosis_id: int, + patient_id: int, + generation: int, + ) -> None: + if generation != self._order_flow_generation: + return + if not self._order_flow_guard(generation, diagnosis_id, patient_id): + self.banner.show_message("权限或当前诊单已变化,未展示付款二维码。", "danger") + self._finish_order_flow(generation, refresh=True) + return + order_no = self._order_no(result) + if not order_no: + self.banner.show_message( + "订单已创建,但服务器未返回订单号,无法生成付款二维码。", + "danger", + ) + self._finish_order_flow(generation, refresh=True) + return + dialog = _DiagnosisOrderQrDialog(record, order_no, self) + self._order_qr_dialog = dialog + dialog.retry_requested.connect( + lambda retry_order_no: self._request_order_qr( + retry_order_no, + diagnosis_id, + patient_id, + generation, + dialog, + ) + ) + dialog.finished.connect(lambda _code: self._order_qr_dialog_finished(dialog, generation)) + dialog.open() + self._request_order_qr( + order_no, + diagnosis_id, + patient_id, + generation, + dialog, + ) + + def _request_order_qr( + self, + order_no: str, + diagnosis_id: int, + patient_id: int, + generation: int, + dialog: _DiagnosisOrderQrDialog, + ) -> None: + if dialog is not self._order_qr_dialog or generation != self._order_flow_generation: + return + if order_no != dialog.order_no: + dialog.set_failure("订单号已变化,无法生成付款二维码。", retryable=False) + return + if not self._order_flow_guard(generation, diagnosis_id, patient_id): + dialog.set_failure( + "权限或当前诊单已变化,无法生成付款二维码。", + retryable=False, + ) + return + if self._order_qr_request_pending: + return + method_name = self._repository_methods["order_qr"] + self._order_qr_request_pending = True + dialog.set_loading() + run_async( + lambda: invoke( + self.repository, + method_name, + order_no=order_no, + ), + on_success=lambda result: self._diagnosis_order_qr_success( + result, + diagnosis_id, + patient_id, + generation, + dialog, + ), + on_error=lambda error: self._diagnosis_order_qr_error( + error, + diagnosis_id, + patient_id, + generation, + dialog, + ), + on_finished=lambda: self._diagnosis_order_qr_finished(generation), + ) + + def _diagnosis_order_qr_success( + self, + result: Any, + diagnosis_id: int, + patient_id: int, + generation: int, + dialog: _DiagnosisOrderQrDialog, + ) -> None: + if dialog is not self._order_qr_dialog or generation != self._order_flow_generation: + return + if not self._order_flow_guard(generation, diagnosis_id, patient_id): + dialog.set_failure( + "权限或当前诊单已变化,未展示付款二维码。", + retryable=False, + ) + return + qrcode_url = self._qr_url(result) + if not qrcode_url: + dialog.set_failure("服务器未返回可用的付款二维码地址,请重试。") + return + dialog.set_result(qrcode_url) + show_toast(self, "订单已创建,付款二维码已生成。", "success") + + def _diagnosis_order_qr_error( + self, + error: Exception, + diagnosis_id: int, + patient_id: int, + generation: int, + dialog: _DiagnosisOrderQrDialog, + ) -> None: + if dialog is not self._order_qr_dialog or generation != self._order_flow_generation: + return + retryable = self._order_flow_guard(generation, diagnosis_id, patient_id) + message = ( + f"付款二维码生成失败:{friendly_error(error)}" + if retryable + else "权限或当前诊单已变化,未展示付款二维码。" + ) + dialog.set_failure(message, retryable=retryable) + + def _diagnosis_order_qr_finished(self, generation: int) -> None: + if generation == self._order_flow_generation: + self._order_qr_request_pending = False + + def _diagnosis_order_create_error(self, error: Exception, generation: int) -> None: + if generation != self._order_flow_generation: + return + self.banner.show_message(friendly_error(error), "danger") + self._finish_order_flow(generation, refresh=False) + + def _order_qr_dialog_finished( + self, + dialog: _DiagnosisOrderQrDialog, + generation: int, + ) -> None: + if dialog is self._order_qr_dialog: + self._order_qr_dialog = None + self._finish_order_flow(generation, refresh=True) + + def _finish_order_flow(self, generation: int, *, refresh: bool) -> None: + if generation != self._order_flow_generation: + return + self._order_flow_generation = None + self._order_generation += 1 + self._order_qr_request_pending = False + self._mutation_pending = False + self.table_host.setEnabled(True) + self._checked_changed(len(self.table_host.selected_records())) + self._selection_changed() + if refresh: + self.refresh(silent=True) + def _selection_changed(self) -> None: + self._menu_generation += 1 self._prescription_generation += 1 # A row change invalidates the old worker and must also release its UI lock. self._prescription_busy = False @@ -1415,6 +2859,8 @@ class ConsultationsPage(QWidget): self._mutation_generation += 1 generation = self._mutation_generation self._mutation_pending = True + self.table_host.setEnabled(False) + self._checked_changed(len(self.table_host.selected_records())) self._selection_changed() run_async( operation, @@ -1426,6 +2872,7 @@ class ConsultationsPage(QWidget): def _mutation_success(self, message: str, generation: int) -> None: if generation != self._mutation_generation: return + self.table_host.clear_checks() show_toast(self, message, "success") self.refresh(silent=True) @@ -1436,22 +2883,35 @@ class ConsultationsPage(QWidget): def _mutation_finished(self, generation: int) -> None: if generation == self._mutation_generation: self._mutation_pending = False + self.table_host.setEnabled(True) + self._checked_changed(len(self.table_host.selected_records())) self._selection_changed() def _request_video(self) -> None: record = self.table.current_data() + if not self._native_video_capable: + self.banner.show_message("当前工作站未配置完整的视频问诊能力。", "warning") + return if record is None or not is_video_available(record): - self.banner.show_message("仅当前“已预约”的挂号可发起视频。", "warning") + self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning") return payload = _video_payload(record) if not all( _as_int(payload.get(key), 0) > 0 for key in ("appointment_id", "patient_id", "diagnosis_id") ): - self.banner.show_message("患者、诊单或挂号标识不完整,无法发起视频。", "warning") + self.banner.show_message("患者、诊单或挂号标识不完整,无法进入视频问诊。", "warning") return self.video_requested.emit(payload) + def _poll_refresh(self) -> None: + """Refresh rows and chip counts without showing the table mask.""" + + if not self.isVisible() or self._order_flow_generation is not None: + return + self.refresh(silent=True) + self._refresh_counts() + def showEvent(self, event: Any) -> None: super().showEvent(event) self._load_filter_options() @@ -1462,6 +2922,13 @@ class ConsultationsPage(QWidget): def hideEvent(self, event: Any) -> None: self.poll_timer.stop() + generation = self._order_flow_generation + dialog = self._order_qr_dialog + if generation is not None: + self._order_qr_dialog = None + self._finish_order_flow(generation, refresh=False) + if dialog is not None: + dialog.reject() super().hideEvent(event) @@ -1470,6 +2937,7 @@ __all__ = [ "appointment_rows", "can_void_prescription", "is_diagnosis_confirmed", + "is_valid_id_card", "is_video_available", "prescription_action_label", ] diff --git a/app/src/doctor_workstation/ui/pages/patients.py b/app/src/doctor_workstation/ui/pages/patients.py index 625d8815c..1994b1a32 100644 --- a/app/src/doctor_workstation/ui/pages/patients.py +++ b/app/src/doctor_workstation/ui/pages/patients.py @@ -36,6 +36,7 @@ from PySide6.QtWidgets import ( QWidget, ) +from ..appointment_drawer import AppointmentDrawer from ..dialogs import DiagnosisDialog from ..widgets import ( EmptyState, @@ -216,7 +217,7 @@ def _remote_order_locked(row: Any) -> bool: return claim_status in {"PENDING", "UNKNOWN", "PENDING_RECONCILE", "SUCCESS"} -class _AppointmentDialog(QDialog): +class _LegacyAppointmentDialog(QDialog): """Server-driven appointment form; arbitrary dates and slots are never accepted.""" CHANNEL_DETAIL_NAMES = {"自媒体4H", "自媒体3Q", "自媒体3H", "自媒体2H", "自媒体2Q"} @@ -644,6 +645,26 @@ class _AppointmentDialog(QDialog): super().accept() +class _AppointmentDialog(AppointmentDrawer): + """Compatibility entry point for the page-scoped appointment drawer.""" + + def __init__( + self, + row: Any, + repository: Any = None, + parent: QWidget | None = None, + *, + autoload: bool = True, + ) -> None: + super().__init__( + row, + repository, + parent, + autoload=autoload, + async_runner=run_async, + ) + + class _PaymentDialog(QDialog): def __init__(self, row: Any, parent: QWidget | None = None) -> None: super().__init__(parent) diff --git a/app/src/doctor_workstation/ui/shell.py b/app/src/doctor_workstation/ui/shell.py index 80439b3a0..bc69481a7 100644 --- a/app/src/doctor_workstation/ui/shell.py +++ b/app/src/doctor_workstation/ui/shell.py @@ -6,15 +6,20 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any -from PySide6.QtCore import Qt, Signal +from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal +from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap, QPolygonF from PySide6.QtWidgets import ( QButtonGroup, QFrame, QHBoxLayout, QLabel, QMainWindow, + QMenu, QPushButton, + QSizePolicy, QStackedWidget, + QTabBar, + QToolButton, QVBoxLayout, QWidget, ) @@ -249,6 +254,199 @@ def _resolve_navigation( return [] +def _painted_shell_icon(kind: str, size: int = 18) -> QIcon: + """Create a font-independent shell icon once, before widget painting.""" + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + try: + color = QColor("#606266") + center_x = size / 2 + center_y = size / 2 + pen = QPen(color, 1.6) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + + if kind in {"fold", "expand"}: + for offset in (-6.0, 0.0, 6.0): + painter.drawLine( + QPointF(center_x - 7.0, center_y + offset), + QPointF(center_x + 7.0, center_y + offset), + ) + painter.setBrush(color) + painter.setPen(Qt.PenStyle.NoPen) + points = ( + [ + QPointF(center_x - 2.0, center_y - 3.5), + QPointF(center_x - 6.0, center_y), + QPointF(center_x - 2.0, center_y + 3.5), + ] + if kind == "fold" + else [ + QPointF(center_x + 2.0, center_y - 3.5), + QPointF(center_x + 6.0, center_y), + QPointF(center_x + 2.0, center_y + 3.5), + ] + ) + painter.drawPolygon(QPolygonF(points)) + elif kind == "refresh": + painter.drawArc(QRectF(center_x - 7, center_y - 7, 14, 14), 42 * 16, 286 * 16) + painter.setBrush(color) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawPolygon( + QPolygonF( + [ + QPointF(center_x + 5.4, center_y - 7.2), + QPointF(center_x + 8.8, center_y - 6.4), + QPointF(center_x + 7.2, center_y - 3.2), + ] + ) + ) + elif kind == "fullscreen": + corner = 6.5 + inset = 7.0 + painter.drawLine( + QPointF(center_x - inset, center_y - 2.0), + QPointF(center_x - inset, center_y - corner), + ) + painter.drawLine( + QPointF(center_x - inset, center_y - corner), + QPointF(center_x - 2.0, center_y - corner), + ) + painter.drawLine( + QPointF(center_x + 2.0, center_y - corner), + QPointF(center_x + inset, center_y - corner), + ) + painter.drawLine( + QPointF(center_x + inset, center_y - corner), + QPointF(center_x + inset, center_y - 2.0), + ) + painter.drawLine( + QPointF(center_x - inset, center_y + 2.0), + QPointF(center_x - inset, center_y + corner), + ) + painter.drawLine( + QPointF(center_x - inset, center_y + corner), + QPointF(center_x - 2.0, center_y + corner), + ) + painter.drawLine( + QPointF(center_x + 2.0, center_y + corner), + QPointF(center_x + inset, center_y + corner), + ) + painter.drawLine( + QPointF(center_x + inset, center_y + corner), + QPointF(center_x + inset, center_y + 2.0), + ) + elif kind == "down": + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(color) + painter.drawPolygon( + QPolygonF( + [ + QPointF(center_x - 4.5, center_y - 2.0), + QPointF(center_x + 4.5, center_y - 2.0), + QPointF(center_x, center_y + 3.0), + ] + ) + ) + elif kind == "close": + painter.drawLine( + QPointF(center_x - 3.5, center_y - 3.5), + QPointF(center_x + 3.5, center_y + 3.5), + ) + painter.drawLine( + QPointF(center_x + 3.5, center_y - 3.5), + QPointF(center_x - 3.5, center_y + 3.5), + ) + finally: + painter.end() + return QIcon(pixmap) + + +class _PaintedIconButton(QToolButton): + """Font-independent shell icon used for every directional affordance.""" + + def __init__( + self, + kind: str, + *, + size: int = 40, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.kind = kind + icon_size = 12 if kind == "close" else 18 + self.setObjectName("ShellPaintedIconButton") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setAutoRaise(True) + self.setFixedSize(size, size) + self.setIcon(_painted_shell_icon(kind, icon_size)) + self.setIconSize(QSize(icon_size, icon_size)) + self.setStyleSheet( + """ + QToolButton#ShellPaintedIconButton { background: transparent; border: 0; } + QToolButton#ShellPaintedIconButton:hover { background: #F0F2F5; } + QToolButton#ShellPaintedIconButton:pressed { background: #E6E8EB; } + QToolButton#ShellPaintedIconButton::menu-indicator { image: none; width: 0; } + """ + ) + + +class _UserMenuButton(QToolButton): + """Compact admin-style user dropdown without font-glyph arrows.""" + + def __init__(self, display_name: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.display_name = display_name or "医生" + self.setObjectName("ShellUserMenu") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.setFixedHeight(50) + name_width = self.fontMetrics().horizontalAdvance(self.display_name) + self.setFixedWidth(max(104, min(174, name_width + 72))) + self.setAccessibleName(f"用户菜单:{self.display_name}") + self.setText(self.display_name) + avatar = QPixmap(34, 34) + avatar.fill(Qt.GlobalColor.transparent) + painter = QPainter(avatar) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor("#EEF0FF")) + painter.drawEllipse(QRectF(0, 0, 34, 34)) + painter.setPen(QColor("#4A5DFF")) + avatar_font = QFont(painter.font()) + avatar_font.setWeight(QFont.Weight.DemiBold) + painter.setFont(avatar_font) + painter.drawText(QRectF(0, 0, 34, 34), Qt.AlignmentFlag.AlignCenter, self.display_name[:1]) + painter.end() + self.setIcon(QIcon(avatar)) + self.setIconSize(QSize(34, 34)) + self.setStyleSheet( + """ + QToolButton#ShellUserMenu { + color: #303133; + background: transparent; + border: 0; + padding: 0 18px 0 7px; + font-size: 13px; + font-weight: 500; + } + QToolButton#ShellUserMenu:hover { background: #F0F2F5; } + QToolButton#ShellUserMenu:pressed { background: #E6E8EB; } + QToolButton#ShellUserMenu::menu-indicator { + subcontrol-origin: padding; + subcontrol-position: right center; + right: 7px; + } + """ + ) + + class ShellWindow(QMainWindow): """Main workstation window. @@ -302,6 +500,8 @@ class ShellWindow(QMainWindow): self.pages: dict[str, QWidget] = {} self.nav_buttons: dict[str, QPushButton] = {} self.page_titles: dict[int, str] = {} + self._fixed_tab_key: str | None = None + self._sidebar_collapsed = False self.setWindowTitle("臻阳堂 · 医生工作站") self.setMinimumSize(1024, 640) @@ -313,87 +513,148 @@ class ShellWindow(QMainWindow): root = QHBoxLayout(canvas) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) - root.addWidget(self._build_sidebar()) + self.sidebar = self._build_sidebar() + root.addWidget(self.sidebar) - workspace = QWidget() - workspace_layout = QVBoxLayout(workspace) + self.workspace = QWidget() + self.workspace.setObjectName("ShellWorkspace") + self.workspace.setStyleSheet("QWidget#ShellWorkspace { background-color: #F6F6F6; }") + workspace_layout = QVBoxLayout(self.workspace) workspace_layout.setContentsMargins(0, 0, 0, 0) workspace_layout.setSpacing(0) - workspace_layout.addWidget(self._build_topbar()) + self.topbar = self._build_topbar() + workspace_layout.addWidget(self.topbar) + self.tabs_host = self._build_tab_strip() + workspace_layout.addWidget(self.tabs_host) self.stack = QStackedWidget() + self.stack.setObjectName("ShellPageStack") + self.stack.setStyleSheet("QStackedWidget#ShellPageStack { background-color: #F6F6F6; }") workspace_layout.addWidget(self.stack, 1) - root.addWidget(workspace, 1) + root.addWidget(self.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;" + sidebar.setFixedWidth(183) + sidebar.setStyleSheet( + """ + QWidget#Sidebar { background-color: #1D2124; border-right: 1px solid #25292D; } + QFrame#ShellBrand { background-color: #1D2124; border: 0; } + QLabel#ShellBrandMark { + color: #FFFFFF; + background-color: #4A5DFF; + border: 0; + border-radius: 7px; + font-size: 15px; + font-weight: 700; + } + QLabel#ShellBrandName { color: #FFFFFF; font-size: 14px; font-weight: 600; } + QPushButton#ShellNavButton { + min-height: 46px; + max-height: 46px; + padding: 0 16px; + border: 0; + border-radius: 0; + background-color: transparent; + color: #E5EAF3; + text-align: left; + font-size: 14px; + font-weight: 400; + } + QPushButton#ShellNavButton:hover { background-color: #292E32; color: #FFFFFF; } + QPushButton#ShellNavButton:pressed { background-color: #34393E; } + QPushButton#ShellNavButton:checked { background-color: #4A5DFF; color: #FFFFFF; } + QPushButton#ShellNavButton[collapsed="true"] { padding: 0; text-align: center; } + """ ) - 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) + layout = QVBoxLayout(sidebar) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + brand = QFrame() + brand.setObjectName("ShellBrand") + brand.setFixedHeight(50) + brand_layout = QHBoxLayout(brand) + brand_layout.setContentsMargins(10, 0, 8, 0) + brand_layout.setSpacing(9) + self.brand_mark = QLabel("诊") + self.brand_mark.setObjectName("ShellBrandMark") + self.brand_mark.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.brand_mark.setFixedSize(30, 30) + brand_layout.addWidget(self.brand_mark) + self.brand_name = QLabel("医生工作站") + self.brand_name.setObjectName("ShellBrandName") + brand_layout.addWidget(self.brand_name, 1) + layout.addWidget(brand) - 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) + self.nav_layout.setContentsMargins(0, 0, 0, 0) + self.nav_layout.setSpacing(0) 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) + topbar.setFixedHeight(50) + topbar.setStyleSheet( + """ + QFrame#TopBar { background-color: #FFFFFF; border-bottom: 1px solid #E4E7ED; } + QLabel#ShellBreadcrumbHome { color: #909399; font-size: 13px; } + QLabel#ShellBreadcrumbSeparator { color: #C0C4CC; font-size: 13px; } + QLabel#ShellBreadcrumbCurrent { color: #303133; font-size: 13px; font-weight: 500; } + QLabel#ShellConnectionBadge { + color: #606266; + background-color: #F0F2F5; + border: 0; + border-radius: 3px; + padding: 3px 7px; + font-size: 11px; + font-weight: 500; + } + QLabel#ShellConnectionBadge[kind="success"] { color: #529B2E; background-color: #F0F9EB; } + QLabel#ShellConnectionBadge[kind="danger"] { color: #C45656; background-color: #FEF0F0; } + QMenu { + color: #303133; + background-color: #FFFFFF; + border: 1px solid #E4E7ED; + padding: 5px; + } + QMenu::item { min-width: 112px; min-height: 30px; padding: 0 12px; } + QMenu::item:selected { color: #4A5DFF; background-color: #EEF0FF; } + """ + ) layout = QHBoxLayout(topbar) - layout.setContentsMargins(24, 0, 22, 0) - layout.setSpacing(11) + layout.setContentsMargins(8, 0, 8, 0) + layout.setSpacing(0) + + self.fold_button = _PaintedIconButton("fold") + self.fold_button.setToolTip("收起或展开菜单") + self.fold_button.setAccessibleName("收起或展开菜单") + self.fold_button.clicked.connect(self.toggle_sidebar) + layout.addWidget(self.fold_button) + + self.refresh_button = _PaintedIconButton("refresh") + self.refresh_button.setToolTip("刷新当前页面") + self.refresh_button.setAccessibleName("刷新当前页面") + self.refresh_button.clicked.connect(self.refresh_current_page) + layout.addWidget(self.refresh_button) + + breadcrumb_home = QLabel("首页") + breadcrumb_home.setObjectName("ShellBreadcrumbHome") + layout.addWidget(breadcrumb_home) + breadcrumb_separator = QLabel(" / ") + breadcrumb_separator.setObjectName("ShellBreadcrumbSeparator") + layout.addWidget(breadcrumb_separator) self.context_label = QLabel("工作台") - self.context_label.setStyleSheet("color:#315147; font-size:14px; font-weight:600;") + self.context_label.setObjectName("ShellBreadcrumbCurrent") layout.addWidget(self.context_label) layout.addStretch(1) self.connection_badge = StatusBadge("服务正常", "success") + self.connection_badge.setObjectName("ShellConnectionBadge") layout.addWidget(self.connection_badge) display_name = display_text( @@ -401,27 +662,97 @@ class ShellWindow(QMainWindow): 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) + self.fullscreen_button = _PaintedIconButton("fullscreen") + self.fullscreen_button.setToolTip("全屏模式") + self.fullscreen_button.setAccessibleName("切换全屏模式") + self.fullscreen_button.clicked.connect(self._toggle_fullscreen) + layout.addWidget(self.fullscreen_button) + + self.user_menu_button = _UserMenuButton(display_name) + self.user_menu_button.setToolTip(f"{display_name} · {self._role_text()}") + user_menu = QMenu(self.user_menu_button) + account_action = user_menu.addAction(self._role_text()) + account_action.setEnabled(False) + user_menu.addSeparator() + logout_action = user_menu.addAction("退出登录") + logout_action.triggered.connect(lambda: self.logout_requested.emit()) + self.user_menu_button.setMenu(user_menu) + layout.addWidget(self.user_menu_button) return topbar + def _build_tab_strip(self) -> QWidget: + tabs_host = QFrame() + tabs_host.setObjectName("MultipleTabs") + tabs_host.setFixedHeight(40) + tabs_host.setStyleSheet( + """ + QFrame#MultipleTabs { + background-color: #FFFFFF; + border-top: 0; + border-bottom: 1px solid #E4E7ED; + } + QTabBar#ShellTabs { background-color: #FFFFFF; } + QTabBar#ShellTabs::tab { + min-height: 38px; + max-height: 38px; + min-width: 72px; + padding: 0 14px; + margin: 0; + color: #606266; + background-color: #FFFFFF; + border: 0; + border-top: 2px solid transparent; + border-radius: 0; + font-size: 13px; + font-weight: 400; + } + QTabBar#ShellTabs::tab:hover { color: #303133; background-color: #F5F7FA; } + QTabBar#ShellTabs::tab:selected { + color: #303133; + background-color: #EEF0FF; + border-top: 2px solid #4A5DFF; + } + QMenu { + color: #303133; + background-color: #FFFFFF; + border: 1px solid #E4E7ED; + padding: 5px; + } + QMenu::item { min-width: 112px; min-height: 30px; padding: 0 12px; } + QMenu::item:selected { color: #4A5DFF; background-color: #EEF0FF; } + """ + ) + layout = QHBoxLayout(tabs_host) + layout.setContentsMargins(16, 0, 0, 0) + layout.setSpacing(0) + + self.tab_bar = QTabBar() + self.tab_bar.setObjectName("ShellTabs") + self.tab_bar.setDocumentMode(True) + self.tab_bar.setDrawBase(False) + self.tab_bar.setExpanding(False) + self.tab_bar.setUsesScrollButtons(True) + self.tab_bar.setElideMode(Qt.TextElideMode.ElideRight) + self.tab_bar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.tab_bar.currentChanged.connect(self._tab_selected) + layout.addWidget(self.tab_bar, 1) + + self.tabs_menu_button = _PaintedIconButton("down") + self.tabs_menu_button.setToolTip("标签页操作") + self.tabs_menu_button.setAccessibleName("标签页操作") + self.tabs_menu_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + tabs_menu = QMenu(self.tabs_menu_button) + self.close_current_action = tabs_menu.addAction("关闭当前") + self.close_current_action.triggered.connect(self.close_current_tab) + self.close_other_action = tabs_menu.addAction("关闭其他") + self.close_other_action.triggered.connect(self.close_other_tabs) + self.close_all_action = tabs_menu.addAction("关闭全部") + self.close_all_action.triggered.connect(self.close_all_tabs) + self.tabs_menu_button.setMenu(tabs_menu) + layout.addWidget(self.tabs_menu_button) + self._update_tab_actions() + return tabs_host + def _role_text(self) -> str: department = first_value( self.current_user, "department_name", "department.name", default="" @@ -433,6 +764,143 @@ class ShellWindow(QMainWindow): role = "医生" if "1" in role_values else "医助" if "2" in role_values else "医疗人员" return f"{department} · {role}" if department else role + def toggle_sidebar(self) -> None: + """Toggle the admin-style compact menu while preserving the page state.""" + + self._sidebar_collapsed = not self._sidebar_collapsed + self.sidebar.setFixedWidth(64 if self._sidebar_collapsed else 183) + self.brand_name.setVisible(not self._sidebar_collapsed) + self.fold_button.kind = "expand" if self._sidebar_collapsed else "fold" + self.fold_button.setIcon(_painted_shell_icon(self.fold_button.kind)) + for button in self.nav_buttons.values(): + glyph = str(button.property("navGlyph") or "") + title = str(button.property("navTitle") or "") + button.setText(glyph if self._sidebar_collapsed else f"{glyph} {title}") + button.setToolTip(title if self._sidebar_collapsed else "") + button.setProperty("collapsed", self._sidebar_collapsed) + button.style().unpolish(button) + button.style().polish(button) + + def _toggle_fullscreen(self) -> None: + if self.isFullScreen(): + self.showNormal() + self.fullscreen_button.setToolTip("全屏模式") + else: + self.showFullScreen() + self.fullscreen_button.setToolTip("退出全屏") + + def _tab_index_for_key(self, key: str) -> int: + for index in range(self.tab_bar.count()): + if self.tab_bar.tabData(index) == key: + return index + return -1 + + def visited_tab_keys(self) -> tuple[str, ...]: + """Return open tabs in visit order for tests and session diagnostics.""" + + return tuple(str(self.tab_bar.tabData(index)) for index in range(self.tab_bar.count())) + + def _ensure_tab(self, key: str, title: str) -> int: + index = self._tab_index_for_key(key) + if index >= 0: + self.tab_bar.setTabText(index, title) + return index + index = self.tab_bar.addTab(title) + self.tab_bar.setTabData(index, key) + if self._fixed_tab_key is None: + self._fixed_tab_key = key + else: + close_button = _PaintedIconButton("close", size=18, parent=self.tab_bar) + close_button.setToolTip(f"关闭 {title}") + close_button.setAccessibleName(f"关闭标签页:{title}") + close_button.clicked.connect( + lambda _checked=False, page_key=key: self.close_tab(page_key) + ) + self.tab_bar.setTabButton(index, QTabBar.ButtonPosition.RightSide, close_button) + self._update_tab_actions() + return index + + def _set_current_tab(self, key: str) -> None: + index = self._tab_index_for_key(key) + if index < 0: + return + blocked = self.tab_bar.blockSignals(True) + self.tab_bar.setCurrentIndex(index) + self.tab_bar.blockSignals(blocked) + self._update_tab_actions() + + def _tab_selected(self, index: int) -> None: + if index < 0: + return + key = str(self.tab_bar.tabData(index) or "") + page = self.pages.get(key) + if page is None: + return + self._navigate(self.stack.indexOf(page), key) + + def _update_tab_actions(self) -> None: + if not hasattr(self, "close_current_action"): + return + current_index = self.tab_bar.currentIndex() + current_key = str(self.tab_bar.tabData(current_index) or "") if current_index >= 0 else "" + removable = [key for key in self.visited_tab_keys() if key != self._fixed_tab_key] + self.close_current_action.setEnabled( + bool(current_key and current_key != self._fixed_tab_key) + ) + self.close_other_action.setEnabled(any(key != current_key for key in removable)) + self.close_all_action.setEnabled(bool(removable)) + + def close_tab(self, key: str) -> bool: + """Close one non-fixed visited page and re-route if it was active.""" + + index = self._tab_index_for_key(key) + if index < 0 or key == self._fixed_tab_key: + return False + active_key = "" + if self.tab_bar.currentIndex() >= 0: + active_key = str(self.tab_bar.tabData(self.tab_bar.currentIndex()) or "") + keys = list(self.visited_tab_keys()) + fallback = keys[index - 1] if index > 0 else keys[index + 1] + + close_button = self.tab_bar.tabButton(index, QTabBar.ButtonPosition.RightSide) + blocked = self.tab_bar.blockSignals(True) + self.tab_bar.removeTab(index) + self.tab_bar.blockSignals(blocked) + if close_button is not None: + close_button.deleteLater() + + if active_key == key: + self.navigate(fallback) + else: + self._set_current_tab(active_key) + self._update_tab_actions() + return True + + def close_current_tab(self) -> bool: + index = self.tab_bar.currentIndex() + if index < 0: + return False + return self.close_tab(str(self.tab_bar.tabData(index) or "")) + + def close_other_tabs(self) -> None: + current_index = self.tab_bar.currentIndex() + current_key = str(self.tab_bar.tabData(current_index) or "") if current_index >= 0 else "" + keep = {current_key, self._fixed_tab_key} + for key in reversed(self.visited_tab_keys()): + if key not in keep: + self.close_tab(key) + if current_key: + self._set_current_tab(current_key) + self._update_tab_actions() + + def close_all_tabs(self) -> None: + for key in reversed(self.visited_tab_keys()): + if key != self._fixed_tab_key: + self.close_tab(key) + if self._fixed_tab_key: + self.navigate(self._fixed_tab_key) + self._update_tab_actions() + def _register_pages(self) -> None: self.nav_group = QButtonGroup(self) self.nav_group.setExclusive(True) @@ -450,7 +918,9 @@ class ShellWindow(QMainWindow): self.page_titles[index] = title button = QPushButton(f"{item.glyph} {title}") - button.setProperty("variant", "nav") + button.setObjectName("ShellNavButton") + button.setProperty("navGlyph", item.glyph) + button.setProperty("navTitle", title) button.setCheckable(True) button.setCursor(Qt.CursorShape.PointingHandCursor) button.clicked.connect( @@ -485,7 +955,10 @@ class ShellWindow(QMainWindow): if index < 0 or index >= self.stack.count(): return self.stack.setCurrentIndex(index) - self.context_label.setText(self.page_titles.get(index, "工作台")) + title = self.page_titles.get(index, "工作台") + self.context_label.setText(title) + self._ensure_tab(key, title) + self._set_current_tab(key) button = self.nav_buttons.get(key) if button is not None: button.setChecked(True) diff --git a/app/tests/test_appointment_drawer_visual.py b/app/tests/test_appointment_drawer_visual.py new file mode 100644 index 000000000..1a0047aa0 --- /dev/null +++ b/app/tests/test_appointment_drawer_visual.py @@ -0,0 +1,566 @@ +from __future__ import annotations + +import os +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QDate, QSize, Qt +from PySide6.QtWidgets import QApplication, QStyle, QStyleOptionButton, QWidget + +from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, AppointmentDrawer + + +@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, **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() + + +class _DeferredAsync: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def __call__( + self, + function: Any, + *args: Any, + on_success: Any = None, + on_error: Any = None, + on_finished: Any = None, + **kwargs: Any, + ) -> object: + self.calls.append( + { + "function": function, + "args": args, + "kwargs": kwargs, + "on_success": on_success, + "on_error": on_error, + "on_finished": on_finished, + } + ) + return object() + + def succeed(self, index: int = 0) -> Any: + call = self.calls.pop(index) + try: + result = call["function"](*call["args"], **call["kwargs"]) + except Exception as error: + if call["on_error"]: + call["on_error"](error) + raise + else: + if call["on_success"]: + call["on_success"](result) + return result + finally: + if call["on_finished"]: + call["on_finished"]() + + +class _VisualRepository: + def __init__( + self, + *, + today_conflict: bool = True, + doctors: bool = True, + rosters: bool = True, + slot_error: bool = False, + ) -> None: + self.today_conflict = today_conflict + self.doctors = doctors + self.rosters = rosters + self.slot_error = slot_error + self.roster_queries: list[dict[str, Any]] = [] + self.slot_queries: list[dict[str, Any]] = [] + + def list_diagnosis_doctors(self) -> list[dict[str, Any]]: + if not self.doctors: + return [] + return [ + {"id": 77, "name": "陈医生", "department_name": "中医科"}, + {"id": 88, "name": "周医生", "department_name": "内科"}, + {"id": 99, "name": "林医生", "department_name": "全科"}, + ] + + def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]: + assert dictionary_type == "channels" + return [ + {"id": 2, "name": "自媒体4H", "value": "self-4h", "status": 1, "sort": 20}, + {"id": 1, "name": "线上复诊", "value": "online", "status": 1, "sort": 10}, + ] + + def list_appointments(self, **kwargs: Any) -> dict[str, Any]: + if kwargs.get("status") == 3: + return { + "lists": [ + { + "appointment_date": "2026-08-01", + "appointment_time": "10:30-11:00", + } + ], + "count": 1, + } + if self.today_conflict: + return {"lists": [{"status": 1}], "count": 1} + return {"lists": [], "count": 0} + + def list_appointment_rosters(self, **kwargs: Any) -> dict[str, Any]: + self.roster_queries.append(kwargs) + if not self.rosters: + return {"lists": [], "count": 0} + today = QDate.currentDate() + return { + "lists": [ + {"date": today.toString("yyyy-MM-dd")}, + {"date": today.addDays(1).toString("yyyy-MM-dd")}, + {"date": today.addDays(2).toString("yyyy-MM-dd")}, + ], + "count": 3, + } + + def get_available_appointment_slots(self, **kwargs: Any) -> dict[str, Any]: + self.slot_queries.append(kwargs) + if self.slot_error: + raise RuntimeError("号源服务暂时不可用") + return { + "slots": [ + {"time": "09:30-10:00", "available": True, "quota": 2}, + {"time": "10:00-10:30", "available": False, "quota": 0}, + {"time": "14:30-15:00", "available": True, "quota": 1}, + {"time": "15:00-15:30", "available": False, "quota": 0}, + ] + } + + +def _show_drawer( + application: QApplication, + size: tuple[int, int], + *, + repository: Any = None, + autoload: bool = False, + async_runner: Any = _immediate_async, +) -> tuple[QWidget, AppointmentDrawer]: + host = QWidget() + host.resize(*size) + host.show() + drawer = AppointmentDrawer( + { + "id": 501, + "diagnosis_id": 501, + "patient_id": 999, + "patient_name": "林晓岚", + "doctor_id": 77, + }, + repository=repository, + parent=host, + autoload=autoload, + async_runner=async_runner, + ) + drawer.show() + application.processEvents() + application.processEvents() + return host, drawer + + +@pytest.mark.parametrize("size,expected_width", [((1024, 640), 614), ((1440, 900), 864)]) +def test_full_window_rtl_drawer_geometry_and_fixed_regions( + application: QApplication, + size: tuple[int, int], + expected_width: int, +) -> None: + application_style = application.styleSheet() + host, drawer = _show_drawer(application, size) + + assert drawer.size() == host.size() + assert drawer.drawer_width == expected_width + assert drawer.panel.geometry().right() == drawer.rect().right() + assert drawer.panel.height() == drawer.height() + assert drawer.header.height() == 58 + assert drawer.header.geometry().top() == 0 + assert drawer.footer.geometry().bottom() == drawer.panel.rect().bottom() + assert drawer.body_scroll.geometry().top() == drawer.header.geometry().bottom() + 1 + assert drawer.body_scroll.geometry().bottom() + 1 == drawer.footer.geometry().top() + assert drawer.windowFlags() & Qt.WindowType.FramelessWindowHint + assert drawer.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + footer_geometry = drawer.footer.geometry() + scroll_bar = drawer.body_scroll.verticalScrollBar() + scroll_bar.setValue(scroll_bar.maximum()) + application.processEvents() + assert drawer.footer.geometry() == footer_geometry + assert application.styleSheet() == application_style + + drawer.close() + host.close() + application.processEvents() + + +def test_field_order_density_and_conditional_channel_row( + application: QApplication, +) -> None: + host, drawer = _show_drawer(application, (1024, 640)) + + assert [label.text() for label in drawer.form_labels] == [ + "上次就诊:", + "预约方式:", + "预约类型:", + "选择患者:", + "渠道来源 *:", + "自媒体补充 *:", + "预约医生:", + "预约时间:", + "备注:", + ] + assert all(label.width() == 100 for label in drawer.form_labels) + assert drawer.channel_source.maximumWidth() == 360 + assert drawer.channel_source_detail.maximumWidth() == 360 + assert drawer.channel_source.height() == 32 + assert drawer.cancel_button.height() == 32 + assert drawer.ok_button.height() == 32 + assert drawer.ok_button.text() == "确定" + assert drawer.remark.height() == 70 + assert drawer.channel_detail_row.isHidden() + assert drawer.time_empty.illustration.size() == QSize(80, 60) + assert drawer.time_empty.illustration.accessibleName() == "空状态插图" + + for radio in ( + drawer.appointment_method_radio, + drawer.appointment_type_radio, + drawer.patient_radio, + ): + option = QStyleOptionButton() + radio.initStyleOption(option) + indicator = radio.style().subElementRect( + QStyle.SubElement.SE_RadioButtonIndicator, option, radio + ) + assert indicator.size() == QSize(14, 14) + + drawer._channel_names = {"self-4h": "自媒体4H", "online": "线上复诊"} + drawer.channel_source.clear() + drawer.channel_source.addItem("请选择渠道来源", "") + drawer.channel_source.addItem("自媒体4H", "self-4h") + drawer.channel_source.addItem("线上复诊", "online") + drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("self-4h")) + application.processEvents() + assert not drawer.channel_detail_row.isHidden() + assert drawer.channel_source_detail.height() == 32 + drawer.channel_source_detail.setText("视频号") + drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online")) + assert drawer.channel_detail_row.isHidden() + assert drawer.channel_source_detail.text() == "" + + drawer.close() + host.close() + application.processEvents() + + +def test_roster_slot_states_conflict_refresh_and_submit_contract( + application: QApplication, +) -> None: + repository = _VisualRepository(today_conflict=True) + host, drawer = _show_drawer( + application, + (1440, 900), + repository=repository, + autoload=True, + ) + today = QDate.currentDate().toString("yyyy-MM-dd") + tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd") + + assert drawer.last_visit_label.text() == "2026-08-01 10:30-11:00" + assert list(drawer.doctor_buttons) == [77, 88, 99] + assert [button.text() for button in drawer.doctor_buttons.values()] == [ + "陈医生", + "周医生", + "林医生", + ] + assert all("·" not in button.text() for button in drawer.doctor_buttons.values()) + assert all(button.size() == QSize(130, 40) for button in drawer.date_buttons.values()) + assert all(button.minimumSize() == QSize(130, 40) for button in drawer.date_buttons.values()) + assert all(button.maximumSize() == QSize(130, 40) for button in drawer.date_buttons.values()) + assert drawer.date_combo.currentData() == tomorrow + assert drawer.conflict_alert.property("kind") == "info" + assert "可为患者预约其他日期" in drawer.conflict_alert.label.text() + + available = drawer.slot_buttons["09:30-10:00"] + unavailable = drawer.slot_buttons["10:00-10:30"] + assert available.minimumWidth() >= 110 + assert available.minimumHeight() >= 70 + assert available.property("slotState") == "available" + assert available.isEnabled() + assert unavailable.property("slotState") == "unavailable" + assert not unavailable.isEnabled() + assert unavailable.status_label.text() == "已约" + assert unavailable.status_label.isVisible() + assert unavailable.accessibleName() == "10:00-10:30 已约" + available.click() + drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online")) + assert drawer.ok_button.isEnabled() + + expected_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 drawer.payload() == expected_payload + assert repository.roster_queries[-1] == { + "doctor_id": 77, + "start_date": today, + "end_date": QDate.currentDate().addDays(6).toString("yyyy-MM-dd"), + "status": 1, + "page_no": 1, + "page_size": 100, + } + assert repository.slot_queries[-1] == { + "doctor_id": 77, + "appointment_date": tomorrow, + "period": "all", + } + + query_count = len(repository.slot_queries) + drawer.refresh_slots_button.click() + assert len(repository.slot_queries) == query_count + 1 + assert drawer.slot_combo.currentData() == "09:30-10:00" + assert drawer.ok_button.isEnabled() + + drawer.date_buttons[today].click() + assert drawer.date_combo.currentData() == today + assert drawer.conflict_alert.property("kind") == "warning" + assert "不能重复预约今天" in drawer.conflict_alert.label.text() + assert not drawer.ok_button.isEnabled() + + generation = drawer._slot_generation + drawer._apply_slots( + {"slots": [{"time": "00:00-00:30", "available": True, "quota": 1}]}, + 77, + today, + generation, + ) + application.processEvents() + expired = drawer.slot_buttons["00:00-00:30"] + assert expired.property("slotState") == "unavailable" + assert not expired.isEnabled() + assert expired.status_label.text() == "已约" + assert expired.status_label.isVisible() + assert "已过期" not in expired.accessibleName() + + drawer.close() + host.close() + application.processEvents() + + +def test_initial_loading_state_covers_the_drawer_body( + application: QApplication, +) -> None: + deferred = _DeferredAsync() + host, drawer = _show_drawer( + application, + (1024, 640), + repository=_VisualRepository(), + autoload=True, + async_runner=deferred, + ) + + assert len(deferred.calls) == 1 + assert drawer._active_loading == "initial" + assert drawer.loading_overlay.isVisible() + assert drawer.loading_overlay.label.text() == "正在加载医生、渠道与挂号状态…" + assert drawer.loading_overlay.geometry() == drawer.body_scroll.viewport().rect() + assert drawer.loading_overlay.spinner._timer.isActive() + assert not drawer.ok_button.isEnabled() + + drawer.close() + host.close() + application.processEvents() + + +def test_empty_doctor_state_is_explicit_and_blocks_submit( + application: QApplication, +) -> None: + host, drawer = _show_drawer( + application, + (1024, 640), + repository=_VisualRepository(doctors=False), + autoload=True, + ) + + assert not drawer.doctor_buttons + assert drawer.doctor_empty.text() == "暂无可预约医生" + assert drawer.doctor_empty.isVisible() + assert drawer.time_stack.currentWidget() is drawer.time_empty + assert drawer.time_empty.label.text() == "暂无可预约医生" + assert drawer.time_empty.illustration.size() == QSize(80, 60) + assert drawer.banner.property("kind") == "warning" + assert not drawer._initial_loaded + assert not drawer.ok_button.isEnabled() + + drawer.close() + host.close() + application.processEvents() + + +def test_empty_roster_state_is_explicit_and_blocks_submit( + application: QApplication, +) -> None: + repository = _VisualRepository(rosters=False) + host, drawer = _show_drawer( + application, + (1024, 640), + repository=repository, + autoload=True, + ) + + assert list(drawer.doctor_buttons) == [77, 88, 99] + assert repository.roster_queries + assert not drawer.date_buttons + assert drawer.time_stack.currentWidget() is drawer.time_empty + assert drawer.time_empty.label.text() == "该医生暂无排班" + assert drawer.banner.property("kind") == "warning" + assert "未来 7 天暂无可预约排班" in drawer.banner.label.text() + assert not drawer.ok_button.isEnabled() + + drawer.close() + host.close() + application.processEvents() + + +def test_refreshing_state_and_generation_ignore_stale_slot_results( + application: QApplication, +) -> None: + repository = _VisualRepository(today_conflict=False) + host, drawer = _show_drawer( + application, + (1440, 900), + repository=repository, + autoload=True, + ) + tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd") + drawer.date_buttons[tomorrow].click() + selected = "09:30-10:00" + drawer.slot_buttons[selected].click() + drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online")) + assert drawer.ok_button.isEnabled() + + deferred = _DeferredAsync() + drawer._run_async = deferred + drawer.refresh_slots_button.click() + application.processEvents() + + assert len(deferred.calls) == 1 + assert drawer._active_loading == "slots" + assert drawer.loading_overlay.isVisible() + assert drawer.loading_overlay.label.text() == "正在刷新可用号源…" + assert drawer.refresh_slots_button.text() == "刷新中…" + assert not drawer.refresh_slots_button.isEnabled() + assert drawer.slot_state_stack.currentWidget() is drawer.slot_empty + assert drawer.slot_empty.label.text() == "正在刷新可用号源…" + assert not drawer.ok_button.isEnabled() + + doctor_id = int(drawer.doctor_combo.currentData()) + appointment_date = str(drawer.date_combo.currentData()) + stale_generation = drawer._slot_generation + drawer._request_slots(doctor_id, appointment_date, restore_selection=selected) + current_generation = drawer._slot_generation + assert current_generation == stale_generation + 1 + assert len(deferred.calls) == 2 + + deferred.succeed() + application.processEvents() + assert drawer._slot_generation == current_generation + assert drawer.loading_overlay.isVisible() + assert not drawer.slot_buttons + + deferred.succeed() + application.processEvents() + assert drawer.loading_overlay.isHidden() + assert drawer.refresh_slots_button.text() == "刷新" + assert drawer.refresh_slots_button.isEnabled() + assert drawer.slot_combo.currentData() == selected + assert drawer.slot_buttons[selected].isChecked() + assert drawer.ok_button.isEnabled() + + drawer.close() + host.close() + application.processEvents() + + +def test_slot_error_state_is_visible_and_recoverable( + application: QApplication, +) -> None: + host, drawer = _show_drawer( + application, + (1024, 640), + repository=_VisualRepository(today_conflict=False, slot_error=True), + autoload=True, + ) + + assert drawer.loading_overlay.isHidden() + assert drawer.banner.isVisible() + assert drawer.banner.property("kind") == "danger" + assert "号源加载失败" in drawer.banner.label.text() + assert drawer.slot_state_stack.currentWidget() is drawer.slot_empty + assert drawer.slot_empty.label.text() == "号源加载失败" + assert drawer.refresh_slots_button.text() == "刷新" + assert drawer.refresh_slots_button.isEnabled() + assert not drawer.slot_buttons + assert not drawer.ok_button.isEnabled() + + drawer.close() + host.close() + application.processEvents() + + +def test_keyboard_focus_has_a_visible_state( + application: QApplication, +) -> None: + host, drawer = _show_drawer( + application, + (1440, 900), + repository=_VisualRepository(today_conflict=False), + autoload=True, + ) + focus_target = list(drawer.date_buttons.values())[1] + drawer.activateWindow() + focus_target.setFocus(Qt.FocusReason.TabFocusReason) + application.processEvents() + + assert focus_target.focusPolicy() != Qt.FocusPolicy.NoFocus + assert focus_target.hasFocus() + assert application.focusWidget() is focus_target + assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS + assert "border-color: #79BBFF;" in APPOINTMENT_DRAWER_QSS + + drawer.close() + host.close() + application.processEvents() diff --git a/app/tests/test_consultations_parity_ui.py b/app/tests/test_consultations_parity_ui.py index e3fa3d3da..5eb9510be 100644 --- a/app/tests/test_consultations_parity_ui.py +++ b/app/tests/test_consultations_parity_ui.py @@ -7,8 +7,15 @@ 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 PySide6.QtCore import QDate, Signal +from PySide6.QtWidgets import ( + QApplication, + QDialog, + QInputDialog, + QMessageBox, + QToolButton, + QWidget, +) from doctor_workstation.core import PermissionSet from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module @@ -18,16 +25,32 @@ from doctor_workstation.ui.pages.consultations import ( _video_payload, appointment_rows, is_diagnosis_confirmed, + is_valid_id_card, is_video_available, prescription_action_label, ) +class _ListDiagnosisDialog(QWidget): + saved = Signal() + + def __init__(self, _repository: Any, parent: QWidget | None = None) -> None: + super().__init__(parent) + + def open_for(self, *_args: Any, **_kwargs: Any) -> None: + return None + + @pytest.fixture(scope="module") def application() -> QApplication: return QApplication.instance() or QApplication([]) +@pytest.fixture(autouse=True) +def isolate_list_from_detail_dialog(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(consultations_module, "DiagnosisDialog", _ListDiagnosisDialog) + + @pytest.fixture def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None: def run_immediately( @@ -390,7 +413,20 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy( def test_native_call_does_not_reuse_video_qr_permission( application: QApplication, ) -> None: - page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet([])) + class VideoRepository: + def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> None: + pass + + def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> None: + pass + + def bind_call_room(self, diagnosis_id: int, room_id: str) -> None: + pass + + def end_call(self, diagnosis_id: int) -> None: + pass + + page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([])) emitted: list[dict[str, Any]] = [] page.video_requested.connect(emitted.append) page.table.set_rows([_row()]) @@ -399,7 +435,849 @@ def test_native_call_does_not_reuse_video_qr_permission( assert not page.video_button.isHidden() assert page.video_button.isEnabled() + video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10)) + video_action = next(button for button in video_cell.findChildren(QToolButton)) + assert video_action.text() == "进入视频问诊" + assert "摄像头和麦克风" in video_action.toolTip() page._request_video() assert emitted == [_video_payload(_row())] page.close() application.processEvents() + + +@pytest.mark.parametrize( + "value", + [ + "11010519491231002X", + "110105491231002", + "44052420000101001x", + ], +) +def test_id_card_preflight_accepts_admin_15_and_18_digit_shapes(value: str) -> None: + assert is_valid_id_card(value) + + +@pytest.mark.parametrize( + "value", + [ + "", + "11010519491331002X", + "11010519491232002X", + "01010519491231002X", + "110105491331002", + "not-an-id", + ], +) +def test_id_card_preflight_rejects_invalid_shapes(value: str) -> None: + assert not is_valid_id_card(value) + + +def test_invalid_id_card_never_reaches_mutation( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["tcm.diagnosis/edit"])) + page.table.set_rows([_row()]) + page.table.selectRow(0) + mutations: list[tuple[Any, str]] = [] + messages: list[str] = [] + monkeypatch.setattr(QInputDialog, "getText", lambda *_args, **_kwargs: ("123456", True)) + monkeypatch.setattr( + page, "_run_mutation", lambda operation, message: mutations.append((operation, message)) + ) + monkeypatch.setattr( + consultations_module, + "show_toast", + lambda _owner, message, *_args, **_kwargs: messages.append(message), + ) + + page._fill_selected_id_card() + + assert mutations == [] + assert messages == ["请输入15或18位有效身份证号。"] + page.close() + application.processEvents() + + +def test_valid_id_card_reaches_the_existing_repository_dto( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[int, str]] = [] + + class Repository: + def fill_diagnosis_id_card(self, diagnosis_id: int, id_card: str) -> None: + calls.append((diagnosis_id, id_card)) + + page = ConsultationsPage( + Repository(), + permissions=PermissionSet(["tcm.diagnosis/edit"]), + ) + page.table.set_rows([_row()]) + page.table.selectRow(0) + monkeypatch.setattr( + QInputDialog, + "getText", + lambda *_args, **_kwargs: ("11010519491231002X", True), + ) + monkeypatch.setattr(page, "_run_mutation", lambda operation, _message: operation()) + + page._fill_selected_id_card() + + assert calls == [(501, "11010519491231002X")] + page.close() + application.processEvents() + + +def test_multi_appointment_cancel_is_revalidated_before_repository_mutation( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + record = _row( + appointments=[ + {"id": 101, "status": 1, "doctor_name": "陈医生"}, + {"id": 102, "status": 4, "doctor_name": "李医生"}, + ] + ) + page = ConsultationsPage( + SimpleNamespace(), + permissions=PermissionSet(["tcm.diagnosis/guahao"]), + ) + page.table.set_rows([record]) + page.table.selectRow(0) + mutations: list[Any] = [] + questions: list[Any] = [] + messages: list[str] = [] + monkeypatch.setattr(page, "_run_mutation", lambda *args: mutations.append(args)) + monkeypatch.setattr( + QMessageBox, + "question", + lambda *args, **kwargs: questions.append((args, kwargs)), + ) + monkeypatch.setattr( + consultations_module, + "show_toast", + lambda _owner, message, *_args, **_kwargs: messages.append(message), + ) + + page._cancel_selected_appointment() + + assert mutations == [] + assert questions == [] + assert messages == ["该诊单有多条挂号,请在具体挂号记录中取消。"] + page.close() + application.processEvents() + + +def test_single_appointment_cancel_uses_the_visible_nested_appointment_id( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + cancelled: list[int] = [] + + class Repository: + def cancel_diagnosis_appointment(self, appointment_id: int, **_kwargs: Any) -> None: + cancelled.append(appointment_id) + + record = _row( + appointment_id=999, + appointments=[{"id": 101, "status": 1, "doctor_name": "陈医生"}], + ) + page = ConsultationsPage( + Repository(), + permissions=PermissionSet(["tcm.diagnosis/guahao"]), + ) + page.table.set_rows([record]) + page.table.selectRow(0) + monkeypatch.setattr( + QMessageBox, + "question", + lambda *_args, **_kwargs: QMessageBox.StandardButton.Yes, + ) + monkeypatch.setattr( + page, + "_run_mutation", + lambda operation, _message: operation(), + ) + + page._cancel_selected_appointment() + + assert cancelled == [101] + page.close() + application.processEvents() + + +def test_multi_appointment_card_cancel_mutates_only_the_signalled_exact_id( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + cancelled: list[int] = [] + + class Repository: + def cancel_diagnosis_appointment(self, appointment_id: int) -> None: + cancelled.append(appointment_id) + + record = _row( + appointment_id=999, + appointments=[ + {"id": 101, "status": 1, "doctor_name": "陈医生", "time_text": "09:00"}, + {"id": 102, "status": 4, "doctor_name": "李医生", "time_text": "10:00"}, + ], + ) + page = ConsultationsPage( + Repository(), + permissions=PermissionSet(["tcm.diagnosis/guahao"]), + ) + page.table.set_rows([record]) + monkeypatch.setattr( + QMessageBox, + "question", + lambda *_args, **_kwargs: QMessageBox.StandardButton.Yes, + ) + monkeypatch.setattr(page, "_run_mutation", lambda operation, _message: operation()) + + page._cancel_appointment_item(record, 102) + + assert cancelled == [102] + page.close() + application.processEvents() + + +@pytest.mark.parametrize("appointment_id", [0, 999, 103]) +def test_exact_cancel_rejects_unknown_or_non_cancellable_appointment_before_confirmation( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, + appointment_id: int, +) -> None: + cancelled: list[int] = [] + questions: list[Any] = [] + + class Repository: + def cancel_diagnosis_appointment(self, appointment_id: int) -> None: + cancelled.append(appointment_id) + + record = _row( + appointments=[ + {"id": 101, "status": 1, "doctor_name": "陈医生"}, + {"id": 103, "status": 3, "doctor_name": "李医生"}, + ] + ) + page = ConsultationsPage( + Repository(), + permissions=PermissionSet(["tcm.diagnosis/guahao"]), + ) + page.table.set_rows([record]) + monkeypatch.setattr( + QMessageBox, + "question", + lambda *args, **kwargs: questions.append((args, kwargs)), + ) + + page._cancel_appointment_item(record, appointment_id) + + assert questions == [] + assert cancelled == [] + page.close() + application.processEvents() + + +def test_exact_cancel_revalidates_status_after_confirmation( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + cancelled: list[int] = [] + messages: list[str] = [] + + class Repository: + def cancel_diagnosis_appointment(self, appointment_id: int) -> None: + cancelled.append(appointment_id) + + record = _row(appointments=[{"id": 101, "status": 1, "doctor_name": "陈医生"}]) + page = ConsultationsPage( + Repository(), + permissions=PermissionSet(["tcm.diagnosis/guahao"]), + ) + page.table.set_rows([record]) + + def finish_while_confirming(*_args: Any, **_kwargs: Any) -> Any: + record["appointments"][0]["status"] = 3 + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", finish_while_confirming) + monkeypatch.setattr( + consultations_module, + "show_toast", + lambda _owner, message, *_args, **_kwargs: messages.append(message), + ) + + page._cancel_appointment_item(record, 101) + + assert cancelled == [] + assert messages == ["挂号状态已刷新,本次未执行取消。"] + page.close() + application.processEvents() + + +def test_exact_cancel_confirmation_cancel_never_starts_mutation( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[Any] = [] + + class Repository: + def cancel_diagnosis_appointment(self, appointment_id: int) -> None: + calls.append(appointment_id) + + record = _row(appointments=[{"id": 101, "status": 1, "doctor_name": "陈医生"}]) + page = ConsultationsPage( + Repository(), + permissions=PermissionSet(["tcm.diagnosis/guahao"]), + ) + page.table.set_rows([record]) + monkeypatch.setattr( + QMessageBox, + "question", + lambda *_args, **_kwargs: QMessageBox.StandardButton.Cancel, + ) + monkeypatch.setattr(page, "_run_mutation", lambda *args: calls.append(args)) + + page._cancel_appointment_item(record, 101) + + assert calls == [] + page.close() + application.processEvents() + + +def test_exact_cancel_direct_handler_rechecks_permission_before_confirmation( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[Any] = [] + + class Repository: + def cancel_diagnosis_appointment(self, appointment_id: int) -> None: + calls.append(appointment_id) + + record = _row(appointments=[{"id": 101, "status": 1}]) + page = ConsultationsPage(Repository(), permissions=PermissionSet([])) + page.table.set_rows([record]) + monkeypatch.setattr( + QMessageBox, + "question", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + page._cancel_appointment_item(record, 101) + + assert calls == [] + page.close() + application.processEvents() + + +def test_menu_handlers_call_only_permission_gated_real_repository_methods( + application: QApplication, + immediate_async: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, Any]] = [] + + class Repository: + def generate_video_qrcode( + self, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, str]: + calls.append( + ( + "video_qr", + { + "doctor_id": doctor_id, + "patient_id": patient_id, + "share_user_id": share_user_id, + }, + ) + ) + return {"qrcode_url": "https://example.invalid/video.png"} + + def generate_diagnosis_qrcode( + self, + diagnosis_id: int, + doctor_id: int, + patient_id: int, + share_user_id: int, + ) -> dict[str, str]: + calls.append( + ( + "confirm_qr", + { + "diagnosis_id": diagnosis_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "share_user_id": share_user_id, + }, + ) + ) + return {"qrcode_url": "https://example.invalid/confirm.png"} + + def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: + calls.append(("logs", diagnosis_id)) + return [{"id": 1, "action_desc": "挂号"}] + + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + calls.append( + ( + "order", + { + "patient_id": patient_id, + "order_type": order_type, + "amount": amount, + "remark": remark, + }, + ) + ) + return {"order_no": "O-1"} + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + calls.append(("order_qr", order_no)) + return {"qrcode_url": "https://example.invalid/payment.png"} + + record = _row( + appointment_doctor_id=77, + appointments=[ + { + "id": 101, + "status": 1, + "doctor_id": 77, + "doctor_name": "陈医生", + } + ], + ) + page = ConsultationsPage( + Repository(), + permissions=PermissionSet( + [ + "tcm.diagnosis/videoQr", + "tcm.diagnosis/guahao", + "tcm.diagnosis/guahaoLogList", + "tcm.diagnosis/order", + ] + ), + current_user={"id": 66}, + ) + page.table.set_rows([record]) + page.table.selectRow(0) + shown_qr: list[tuple[str, Any]] = [] + shown_logs: list[Any] = [] + monkeypatch.setattr( + page, + "_show_qr_result", + lambda title, _record, result: shown_qr.append((title, result)), + ) + monkeypatch.setattr( + page, + "_show_appointment_logs", + lambda _record, result: shown_logs.append(result), + ) + + class AcceptedOrderDialog: + def __init__(self, _record: Any, _parent: Any) -> None: + pass + + def exec(self) -> Any: + return QDialog.DialogCode.Accepted + + def payload(self) -> dict[str, Any]: + return {"patient_id": 301, "order_type": 2, "amount": 88.5, "remark": "复诊"} + + monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog) + monkeypatch.setattr(consultations_module._QrImagePreview, "load_url", lambda *_args: None) + monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None) + + page._request_video_qr() + page._request_confirm_qr() + page._request_appointment_logs() + page._create_diagnosis_order() + + qr_payload = { + "patient_id": 301, + "doctor_id": 77, + "share_user_id": 66, + } + assert calls == [ + ("video_qr", qr_payload), + ("confirm_qr", {**qr_payload, "diagnosis_id": 501}), + ("logs", 501), + ( + "order", + {"patient_id": 301, "order_type": 2, "amount": 88.5, "remark": "复诊"}, + ), + ("order_qr", "O-1"), + ] + assert [title for title, _result in shown_qr] == ["视频二维码", "诊单二维码"] + assert shown_logs == [[{"id": 1, "action_desc": "挂号"}]] + assert page._order_qr_dialog is not None + assert page._order_qr_dialog.order_no == "O-1" + assert page._order_qr_dialog.qrcode_url == "https://example.invalid/payment.png" + page._order_qr_dialog.reject() + page.close() + application.processEvents() + + +def test_menu_handler_direct_calls_fail_closed_without_permission_or_active_state( + application: QApplication, + immediate_async: None, +) -> None: + calls: list[str] = [] + + class Repository: + def generate_video_qrcode(self, payload: dict[str, Any]) -> dict[str, str]: + calls.append(str(payload)) + return {"qrcode_url": "https://example.invalid/video.png"} + + denied = ConsultationsPage(Repository(), permissions=PermissionSet([])) + denied.table.set_rows([_row(appointment_doctor_id=77)]) + denied.table.selectRow(0) + denied._request_video_qr() + assert calls == [] + denied.close() + + inactive = ConsultationsPage(Repository(), permissions=PermissionSet(["tcm.diagnosis/videoQr"])) + inactive.table.set_rows( + [_row(appointment_status=4, appointments=[{"id": 101, "status": 4, "doctor_id": 77}])] + ) + inactive.table.selectRow(0) + inactive._request_video_qr() + assert calls == [] + inactive.close() + application.processEvents() + + +def test_menu_request_generation_ignores_result_after_row_selection_changes( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Repository: + def generate_video_qrcode( + self, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, str]: + del doctor_id, patient_id, share_user_id + return {"qrcode_url": "https://example.invalid/video.png"} + + callbacks: list[Any] = [] + + def hold_request( + _operation: Any, + *, + on_success: Any = None, + on_error: Any = None, + **_kwargs: Any, + ) -> object: + callbacks.append((on_success, on_error)) + return object() + + monkeypatch.setattr(consultations_module, "run_async", hold_request) + page = ConsultationsPage( + Repository(), + permissions=PermissionSet(["tcm.diagnosis/videoQr"]), + current_user={"id": 66}, + ) + rows = [ + _row(appointment_doctor_id=77), + _row( + id=502, + diagnosis_id=502, + patient_id=302, + appointment_id=102, + appointment_doctor_id=78, + appointments=[{"id": 102, "status": 1, "doctor_id": 78}], + ), + ] + page.table.set_rows(rows) + page.table.selectRow(0) + shown: list[Any] = [] + monkeypatch.setattr(page, "_show_qr_result", lambda *args: shown.append(args)) + + page._request_video_qr() + assert len(callbacks) == 1 + page.table.selectRow(1) + callbacks[0][0]({"qrcode_url": "https://example.invalid/video.png"}) + + assert shown == [] + page.close() + application.processEvents() + + +def test_diagnosis_order_qr_failure_retries_without_creating_a_second_order( + application: QApplication, + immediate_async: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, Any]] = [] + + class Repository: + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + calls.append(("create", (patient_id, order_type, amount, remark))) + return {"order_no": "PAY-RETRY-1"} + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + calls.append(("qr", order_no)) + if sum(kind == "qr" for kind, _payload in calls) == 1: + raise RuntimeError("二维码服务暂不可用") + return {"qrcode_url": "https://example.invalid/payment-retry.png"} + + class AcceptedOrderDialog: + def __init__(self, _record: Any, _parent: Any) -> None: + pass + + def exec(self) -> Any: + return QDialog.DialogCode.Accepted + + def payload(self) -> dict[str, Any]: + return {"patient_id": 301, "order_type": 2, "amount": 88.5, "remark": "复诊"} + + page = ConsultationsPage( + Repository(), + permissions=PermissionSet(["tcm.diagnosis/order"]), + ) + page.table.set_rows([_row()]) + page.table.selectRow(0) + monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog) + monkeypatch.setattr(consultations_module._QrImagePreview, "load_url", lambda *_args: None) + monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None) + + page._create_diagnosis_order() + + dialog = page._order_qr_dialog + assert dialog is not None + assert dialog.order_no == "PAY-RETRY-1" + assert dialog.retry_button.isEnabled() + assert "生成失败" in dialog.status_label.text() + assert calls == [ + ("create", (301, 2, 88.5, "复诊")), + ("qr", "PAY-RETRY-1"), + ] + + dialog.retry_button.click() + + assert calls == [ + ("create", (301, 2, 88.5, "复诊")), + ("qr", "PAY-RETRY-1"), + ("qr", "PAY-RETRY-1"), + ] + assert dialog.qrcode_url == "https://example.invalid/payment-retry.png" + assert dialog.url_edit.text() == dialog.qrcode_url + dialog.reject() + assert not page._mutation_pending + page.close() + application.processEvents() + + +def test_diagnosis_order_without_order_no_never_requests_payment_qr( + application: QApplication, + immediate_async: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + qr_calls: list[str] = [] + + class Repository: + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + del patient_id, order_type, amount, remark + return {"id": 99} + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + qr_calls.append(order_no) + return {"qrcode_url": "https://example.invalid/should-not-open.png"} + + class AcceptedOrderDialog: + def __init__(self, _record: Any, _parent: Any) -> None: + pass + + def exec(self) -> Any: + return QDialog.DialogCode.Accepted + + def payload(self) -> dict[str, Any]: + return {"patient_id": 301, "order_type": 2, "amount": 10.0, "remark": ""} + + page = ConsultationsPage(Repository(), permissions=PermissionSet(["tcm.diagnosis/order"])) + page.table.set_rows([_row()]) + page.table.selectRow(0) + monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog) + monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None) + + page._create_diagnosis_order() + + assert qr_calls == [] + assert page._order_qr_dialog is None + assert not page._mutation_pending + page.close() + application.processEvents() + + +@pytest.mark.parametrize("invalidate", ["selection", "permission"]) +def test_diagnosis_order_create_result_is_rejected_when_active_context_changes( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, + invalidate: str, +) -> None: + qr_calls: list[str] = [] + + class Repository: + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + del patient_id, order_type, amount, remark + return {"order_no": "STALE-1"} + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + qr_calls.append(order_no) + return {"qrcode_url": "https://example.invalid/stale.png"} + + callbacks: list[tuple[Any, Any, Any]] = [] + + def hold_request( + operation: Any, + *, + on_success: Any = None, + on_error: Any = None, + on_finished: Any = None, + **_kwargs: Any, + ) -> object: + callbacks.append((operation, on_success, on_finished)) + return object() + + class AcceptedOrderDialog: + def __init__(self, _record: Any, _parent: Any) -> None: + pass + + def exec(self) -> Any: + return QDialog.DialogCode.Accepted + + def payload(self) -> dict[str, Any]: + return {"patient_id": 301, "order_type": 2, "amount": 10.0, "remark": ""} + + page = ConsultationsPage(Repository(), permissions=PermissionSet(["tcm.diagnosis/order"])) + rows = [ + _row(), + _row(id=502, diagnosis_id=502, patient_id=302, appointment_id=102), + ] + page.table.set_rows(rows) + page.table.selectRow(0) + monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog) + monkeypatch.setattr(consultations_module, "run_async", hold_request) + monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None) + + page._create_diagnosis_order() + assert len(callbacks) == 1 + operation, on_success, _on_finished = callbacks[0] + result = operation() + if invalidate == "selection": + page.table.selectRow(1) + else: + page.permissions = PermissionSet([]) + on_success(result) + + assert qr_calls == [] + assert page._order_qr_dialog is None + assert not page._mutation_pending + page.close() + application.processEvents() + + +def test_closing_payment_dialog_invalidates_inflight_qr_result( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Repository: + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + del patient_id, order_type, amount, remark + return {"order_no": "CLOSE-1"} + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + return {"qrcode_url": f"https://example.invalid/{order_no}.png"} + + staged: list[tuple[Any, Any, Any]] = [] + call_count = 0 + + def stage_request( + operation: Any, + *, + on_success: Any = None, + on_error: Any = None, + on_finished: Any = None, + **_kwargs: Any, + ) -> object: + nonlocal call_count + call_count += 1 + if call_count == 1: + on_success(operation()) + if on_finished: + on_finished() + else: + staged.append((operation, on_success, on_finished)) + return object() + + class AcceptedOrderDialog: + def __init__(self, _record: Any, _parent: Any) -> None: + pass + + def exec(self) -> Any: + return QDialog.DialogCode.Accepted + + def payload(self) -> dict[str, Any]: + return {"patient_id": 301, "order_type": 2, "amount": 10.0, "remark": ""} + + page = ConsultationsPage(Repository(), permissions=PermissionSet(["tcm.diagnosis/order"])) + page.table.set_rows([_row()]) + page.table.selectRow(0) + monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog) + monkeypatch.setattr(consultations_module, "run_async", stage_request) + monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None) + + page._create_diagnosis_order() + dialog = page._order_qr_dialog + assert dialog is not None + assert len(staged) == 1 + dialog.reject() + assert page._order_qr_dialog is None + assert not page._mutation_pending + + operation, on_success, on_finished = staged[0] + on_success(operation()) + if on_finished: + on_finished() + + assert page._order_qr_dialog is None + assert not page._mutation_pending + page.close() + application.processEvents() diff --git a/app/tests/test_diagnosis_detail_contract.py b/app/tests/test_diagnosis_detail_contract.py new file mode 100644 index 000000000..bd95401c0 --- /dev/null +++ b/app/tests/test_diagnosis_detail_contract.py @@ -0,0 +1,187 @@ +"""Exact endpoint and fail-closed tests for diagnosis-detail mutations.""" + +from __future__ import annotations + +import time +from datetime import date +from pathlib import Path +from typing import Any + +import pytest + +from doctor_workstation.services.mock_repository import DemoDoctorRepository +from doctor_workstation.services.repository import RemoteDoctorRepository + + +class RecordingClient: + """Minimal no-network client that preserves exact endpoint DTOs.""" + + def __init__(self) -> None: + self.get_calls: list[tuple[str, dict[str, Any]]] = [] + self.post_calls: list[tuple[str, dict[str, Any]]] = [] + + def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any: + body = dict(params or {}) + self.get_calls.append((endpoint, body)) + if endpoint == "tcm.diagnosis/getImChatMessages": + return {"lists": [], "patient_im_id": "patient_301"} + return {"lists": [], "count": 0} + + def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any: + body = dict(payload or {}) + self.post_calls.append((endpoint, body)) + if endpoint == "tcm.diagnosis/createManualCallRecord": + return {"id": 88} + if endpoint == "tcm.diagnosis/generateMiniProgramQrcode": + return {"qrcode_url": "https://example.invalid/mini.png"} + if endpoint == "tcm.diagnosis/generateOrderQrcode": + return {"qrcode_url": "https://example.invalid/order.png"} + if endpoint == "order.order/create": + return {"id": 99, "order_no": "ORDER99"} + return {"ok": True} + + +def test_remote_detail_actions_use_exact_confirmed_endpoints() -> None: + client = RecordingClient() + repository = RemoteDoctorRepository(client) # type: ignore[arg-type] + today = date.today().isoformat() + + repository.set_revisit_slot_start_offset(501, 4) + repository.add_blood_record( + diagnosis_id=501, + patient_id=301, + record_date=today, + record_time="09:30", + fasting_blood_sugar=6.2, + ) + repository.add_diet_record( + diagnosis_id=501, + patient_id=301, + record_date=today, + breakfast_foods="燕麦", + breakfast_images=[], + lunch_images=[], + dinner_images=[], + ) + repository.add_exercise_record( + diagnosis_id=501, + patient_id=301, + record_date=today, + exercise_type="步行", + duration=30, + intensity=2, + images=[], + ) + repository.add_tracking_note(501, "继续观察") + repository.add_diagnosis_todo(501, "复测餐后血糖", int(time.time()) + 120) + repository.cancel_diagnosis_todo(77) + repository.list_call_records(501) + repository.create_manual_call_record(501) + repository.attach_local_call_recording( + 501, "https://media.example.invalid/replay.mp4", call_record_id=88 + ) + assert repository.list_im_chat_messages(501)["lists"] == [] + repository.sync_im_chat_messages(501) + repository.list_appointment_logs(501) + repository.generate_video_qrcode(1001, 301, 1001) + repository.generate_diagnosis_qrcode(501, 1001, 301, 1001) + repository.create_diagnosis_order(301, 2, 88.6, remark="检查费") + repository.generate_order_qrcode("ORDER99") + repository.cancel_diagnosis_appointment(101) + + assert ("tcm.diagnosis/getCallRecords", {"diagnosis_id": 501}) in client.get_calls + assert ( + "tcm.diagnosis/getImChatMessages", + {"diagnosis_id": 501, "only_archived": 1}, + ) in client.get_calls + assert ("tcm.diagnosis/guahaoLogList", {"id": 501}) in client.get_calls + assert ( + "doctor.appointment/cancel", + {"id": 101}, + ) in client.post_calls + assert ( + "tcm.diagnosis/setRevisitSlotStartOffset", + {"id": 501, "revisit_slot_start_offset": 4}, + ) in client.post_calls + assert ( + "order.order/create", + {"patient_id": 301, "order_type": 2, "amount": 88.6, "remark": "检查费"}, + ) in client.post_calls + qr_payloads = [ + body + for endpoint, body in client.post_calls + if endpoint == "tcm.diagnosis/generateMiniProgramQrcode" + ] + assert qr_payloads[0] == { + "diagnosis_id": 1001, + "doctor_id": 1001, + "patient_id": 301, + "share_user_id": 1001, + "mini_program_path": "pages/login/login", + } + assert qr_payloads[1] == { + "diagnosis_id": 501, + "doctor_id": 1001, + "patient_id": 301, + "share_user_id": 1001, + } + + +@pytest.mark.parametrize( + ("call", "message"), + [ + (lambda repo: repo.set_revisit_slot_start_offset(501, 21), "between 0 and 20"), + (lambda repo: repo.add_tracking_note(501, ""), "1 to 1000"), + ( + lambda repo: repo.add_diagnosis_todo(501, "稍后", int(time.time()) + 5), + "30 seconds", + ), + (lambda repo: repo.cancel_diagnosis_todo(0), "positive"), + (lambda repo: repo.create_diagnosis_order(301, 9, 1), "between 1 and 8"), + (lambda repo: repo.cancel_diagnosis_appointment(0), "positive"), + ], +) +def test_remote_detail_validation_fails_before_transport(call: Any, message: str) -> None: + client = RecordingClient() + repository = RemoteDoctorRepository(client) # type: ignore[arg-type] + + with pytest.raises(ValueError, match=message): + call(repository) + assert client.get_calls == [] + assert client.post_calls == [] + + +def test_demo_detail_mutations_round_trip(tmp_path: Path) -> None: + repository = DemoDoctorRepository(today=date(2026, 8, 10)) + blood = repository.add_blood_record( + diagnosis_id=501, + patient_id=301, + record_date="2026-08-10", + fasting_blood_sugar=5.8, + ) + assert blood["id"] > 0 + assert ( + repository.get_tracking_window(501, start_date="2026-08-10", end_date="2026-08-10")[ + "blood_records" + ][-1]["fasting_blood_sugar"] + == 5.8 + ) + + todo = repository.add_diagnosis_todo(501, "今晚回访", int(time.time()) + 120) + cancelled = repository.cancel_diagnosis_todo(todo["id"]) + assert cancelled["status_text"] == "已取消" + + replay = tmp_path / "replay.mp4" + replay.write_bytes(b"demo-video") + uploaded = repository.upload_call_recording(replay, 501) + assert uploaded["file_url"].startswith("/demo/uploads/video/") + assert uploaded["file_url"] in repository.list_call_records(501)[0]["recording_urls_list"] + + archive = repository.list_im_chat_messages(501, only_archived=True) + assert archive["only_archived"] is True + assert {row["msg_type"] for row in archive["lists"]} >= {"text", "image", "file"} + assert repository.sync_im_chat_messages(501)["queued"] is True + + repository.set_revisit_slot_start_offset(501, 7) + assert repository.get_diagnosis_detail(501)["diagnosis"]["revisit_slot_start_offset"] == 7 + assert repository.cancel_diagnosis_appointment(101)["status"] == 2 diff --git a/app/tests/test_diagnosis_drawer_visual.py b/app/tests/test_diagnosis_drawer_visual.py new file mode 100644 index 000000000..4279ffbd6 --- /dev/null +++ b/app/tests/test_diagnosis_drawer_visual.py @@ -0,0 +1,1360 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QPoint, Qt +from PySide6.QtGui import QImage +from PySide6.QtWidgets import ( + QApplication, + QDialog, + QFrame, + QLabel, + QPushButton, + QRadioButton, + QScrollArea, + QToolButton, + QWidget, +) + +from doctor_workstation.core import PermissionSet +from doctor_workstation.ui.diagnosis_drawer import ( + DiagnosisChoiceButtons, + DiagnosisComboBox, + DiagnosisDateEdit, + DiagnosisLineEdit, + DiagnosisNumberEdit, + DiagnosisSwitch, + SaveStateButton, +) +from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module +from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.fixture(autouse=True) +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) + 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(diagnosis_module, "run_async", run_immediately) + + +def diagnosis_fixture(*, locked: bool = False) -> dict[str, Any]: + return { + "diagnosis": { + "id": 501, + "patient_id": 1501, + "patient_name": "林晓岚", + "phone": "13800138000", + "id_card": "110105199203071234", + "gender": 0, + "age": 34, + "marital_status": 1, + "height": 162, + "weight": 54.5, + "region": "浙江省杭州市", + "systolic_pressure": 146, + "diastolic_pressure": 92, + "fasting_blood_sugar": 8.2, + "diagnosis_type": "follow_up", + "diagnosis_date": "2026-08-10", + "status_desc": "诊疗中", + "channel_name": "健康顾问转介", + "statistical_visit_card": "第 4 次", + "current_medications": "二甲双胍", + "diabetes_discovery_year": "2019 年", + "local_hospital_diagnosis": ["2 型糖尿病", "高血压"], + "local_hospital_name": "杭州市第一人民医院", + "oral_condition": "口干", + "water_intake": "约 2200 ml", + "weight_change": "近月下降 1 kg", + "fatty_liver_degree": "轻度", + "diet_condition": ["偏甜", "夜宵"], + "body_feeling": ["乏力", "四肢沉重"], + "sleep_condition": "入睡稍慢", + "eye_condition": "偶有干涩", + "head_feeling": "午后头昏", + "sweat_condition": "易出汗", + "skin_condition": "皮肤偏干", + "urine_condition": "夜尿 2 次", + "stool_condition": "每日一次", + "kidney_condition": "腰酸", + "present_illness": "口渴、乏力反复半年,近期血糖波动。", + "past_history": "高血压病史 5 年。", + "trauma_history": 0, + "surgery_history": 0, + "allergy_history": 1, + "family_history": 1, + "pregnancy_history": 0, + "remark": "建议持续记录空腹及餐后血糖。", + "chief_complaint": "口渴乏力", + "clinical_diagnosis": "气阴两虚", + "prescription_opinion": "益气养阴,兼顾活血。", + "has_prescription": 1, + "unserved_days": 4, + "patient_basic_locked": locked, + "can_edit_patient_basic": not locked, + "latest_prescription_order": { + "id": "RX-240810-09", + "fulfillment_status_text": "待配药", + }, + }, + "patient": { + "id": 1501, + "patient_name": "林晓岚", + "phone": "13800138000", + "id_card": "110105199203071234", + "gender": 0, + "age": 34, + }, + "appointment": { + "id": 2501, + "appointment_date": "2026-08-10", + "appointment_time": "09:00-09:30", + "period_text": "上午", + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "status_desc": "已到诊", + "remark": "复诊评估", + }, + } + + +class VisualRepository: + def __init__(self, *, locked: bool = False) -> None: + self.detail = diagnosis_fixture(locked=locked) + self.detail_calls: list[str] = [] + self.dictionary_calls: list[str] = [] + self.notes_calls = 0 + self.tracking_calls: list[tuple[str, str]] = [] + self.tracking_note_calls = 0 + self.todo_calls: list[int | None] = [] + self.prescription_calls = 0 + self.order_queries: list[dict[str, Any]] = [] + self.updates: list[dict[str, Any]] = [] + + def get_diagnosis_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]: + assert diagnosis_id == 501 + self.detail_calls.append("ordinary") + return self.detail + + def diagnosis_readonly_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]: + assert diagnosis_id == 501 + self.detail_calls.append("readonly") + return self.detail + + def patient_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]: + assert diagnosis_id == 501 + self.detail_calls.append("patient") + return self.detail + + def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]: + self.dictionary_calls.append(dictionary_type) + dictionaries = { + "diagnosis_type": [ + {"name": "初诊", "value": "first_visit"}, + {"name": "复诊", "value": "follow_up"}, + {"name": "会诊", "value": "consultation"}, + ], + "appetite": [ + {"name": "口干", "value": "口干"}, + {"name": "口苦", "value": "口苦"}, + ], + } + return dictionaries.get(dictionary_type, []) + + def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + assert diagnosis_id == 501 + self.notes_calls += 1 + return [ + { + "id": 6101, + "diagnosis_id": 501, + "patient_id": 1501, + "create_time": "2026-08-10 09:20", + "doctor_name": "陈医生", + "content": "面色稍淡,舌淡红,苔薄白。", + "tongue_images": [{"url": "https://media.example.invalid/tongue.jpg"}], + "report_files": [ + { + "name": "近期血糖趋势.pdf", + "url": "https://media.example.invalid/report.pdf", + } + ], + } + ] + + def get_tracking_window( + self, + diagnosis_id: int, + *, + start_date: str = "", + end_date: str = "", + ) -> dict[str, Any]: + assert diagnosis_id == 501 + self.tracking_calls.append((start_date, end_date)) + return { + "blood_records": [ + { + "id": 6201, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "fasting_blood_sugar": 8.2, + "systolic_pressure": 146, + "source": 1, + }, + { + "id": 6202, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "postprandial_blood_sugar": 12.4, + "diastolic_pressure": 92, + "western_medicine": "二甲双胍", + }, + { + "id": 6203, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-09", + "fasting_blood_sugar": 7.6, + "postprandial_blood_sugar": 10.8, + }, + ], + "diet_records": [ + { + "id": 6301, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "breakfast_foods": "燕麦、鸡蛋", + "lunch_foods": "杂粮饭", + } + ], + "exercise_records": [ + { + "id": 6401, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-09", + "exercise_type": "散步", + "duration": 35, + "intensity": 2, + } + ], + } + + def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + assert diagnosis_id == 501 + self.tracking_note_calls += 1 + return [{"note_date": "2026-08-10", "content": "饭后散步,继续观察。"}] + + def list_diagnosis_todos( + self, + diagnosis_id: int, + *, + status: int | None = None, + **_kwargs: Any, + ) -> dict[str, Any]: + assert diagnosis_id == 501 + self.todo_calls.append(status) + return { + "lists": [ + { + "remind_time_text": "2026-08-12 09:00", + "content": "回访复测餐后血糖", + "status": 0, + "status_text": "待执行", + "creator_name": "陈医生", + } + ], + "count": 1, + } + + def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]: + assert diagnosis_id == 501 + self.prescription_calls += 1 + return [ + { + "id": 901, + "diagnosis_id": 501, + "prescription_date": "2026-08-02", + "global_visit_sequence": 4, + "prescription_type_text": "中药", + "prescription_summary": "玉泉丸加减", + "doctor_name": "陈医生", + "status_text": "已审核", + } + ] + + def appointment_history(self, **_kwargs: Any) -> dict[str, Any]: + return { + "lists": [ + { + "id": 2501, + "status_desc": "已到诊", + "patient_name": "林晓岚", + "patient_phone": "13800138000", + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "appointment_date": "2026-08-10", + "appointment_time": "09:00-09:30", + "appointment_type_text": "复诊", + "channel_name": "健康顾问", + "is_confirmed": 1, + "has_prescription": 1, + "remark": "复诊评估", + "create_time": "2026-08-08 11:20", + } + ] + } + + def assign_history(self, **_kwargs: Any) -> dict[str, Any]: + return { + "lists": [ + { + "create_time": "2026-08-08 11:22", + "from_assistant_name": "王医助", + "to_assistant_name": "赵医助", + "is_inherit": 1, + "snapshot_order_creator_name": "王医助", + "snapshot_order_create_time": "2026-08-01 10:00", + "operator_name": "陈医生", + "operator_account": "doctor.chen", + "ip": "10.1.2.8", + } + ] + } + + def list_prescription_orders(self, **kwargs: Any) -> dict[str, Any]: + self.order_queries.append(dict(kwargs)) + return { + "lists": [ + { + "id": 801, + "order_no": "RX-240810-09", + "global_visit_sequence": 4, + "statistics_count": 1, + "amount": "¥ 286.00", + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "fulfillment_status_text": "待配药", + "create_time": "2026-08-10 09:24", + } + ], + "count": 1, + } + + def get_prescription_order(self, order_id: int) -> dict[str, Any]: + assert order_id == 801 + return { + "id": order_id, + "order_no": "RX-240810-09", + "patient_name": "林晓岚", + "amount": 286, + "fulfillment_status_text": "待配药", + } + + def update_diagnosis( + self, diagnosis: int, changes: dict[str, Any] | None = None + ) -> dict[str, Any]: + assert diagnosis == 501 + payload = dict(changes or {}) + self.updates.append(payload) + return {"id": diagnosis, **payload} + + def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]: + del payload + return {"exists": False} + + def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]: + del payload + return {"duplicate": False} + + +class ActionRepository(VisualRepository): + """Repository double exposing every confirmed diagnosis-detail action.""" + + def __init__(self) -> None: + super().__init__() + self.action_calls: list[tuple[str, Any]] = [] + + def add_blood_record(self, payload: dict[str, Any]) -> dict[str, Any]: + self.action_calls.append(("blood", dict(payload))) + return {"id": 41, **payload} + + def add_diet_record(self, payload: dict[str, Any]) -> dict[str, Any]: + self.action_calls.append(("diet", dict(payload))) + return {"id": 42, **payload} + + def add_exercise_record(self, payload: dict[str, Any]) -> dict[str, Any]: + self.action_calls.append(("exercise", dict(payload))) + return {"id": 43, **payload} + + def update_blood_record( + self, record: dict[str, Any], changes: dict[str, Any] | None = None + ) -> dict[str, Any]: + payload = {**record, **(changes or {})} + self.action_calls.append(("blood_edit", payload)) + return payload + + def update_diet_record( + self, record: dict[str, Any], changes: dict[str, Any] | None = None + ) -> dict[str, Any]: + payload = {**record, **(changes or {})} + self.action_calls.append(("diet_edit", payload)) + return payload + + def update_exercise_record( + self, record: dict[str, Any], changes: dict[str, Any] | None = None + ) -> dict[str, Any]: + payload = {**record, **(changes or {})} + self.action_calls.append(("exercise_edit", payload)) + return payload + + def add_tracking_note(self, diagnosis_id: int, content: str) -> dict[str, Any]: + self.action_calls.append(("tracking", (diagnosis_id, content))) + return {"id": 44} + + def add_diagnosis_todo( + self, diagnosis_id: int, content: str, remind_time: int + ) -> dict[str, Any]: + self.action_calls.append(("todo", (diagnosis_id, content, remind_time))) + return {"id": 45} + + def cancel_diagnosis_todo(self, todo_id: int) -> dict[str, Any]: + self.action_calls.append(("cancel_todo", todo_id)) + return {"id": todo_id, "status": 2} + + def add_doctor_note(self, diagnosis_id: int, content: str = "", **kwargs: Any) -> Any: + self.action_calls.append(("note", (diagnosis_id, content, kwargs))) + return {"id": 46} + + def delete_doctor_note_image(self, note_id: int, image_type: str, image_path: str) -> Any: + self.action_calls.append(("delete_note_media", (note_id, image_type, image_path))) + return {"ok": True} + + def upload_material(self, path: str, material_type: str) -> str: + self.action_calls.append(("upload", (path, material_type))) + return "https://media.example.invalid/uploaded" + + def create_prescription(self, prescription: dict[str, Any]) -> Any: + self.action_calls.append(("prescription", dict(prescription))) + return {"id": 47} + + def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> Any: + self.action_calls.append(("offset", (diagnosis_id, offset))) + return {"ok": True} + + def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]: + self.action_calls.append(("video_list", diagnosis_id)) + return [ + { + "id": 48, + "recording_urls_list": [ + "https://media.example.invalid/replay.mp4", + "https://media.example.invalid/replay-backup.m3u8", + "https://media.example.invalid/replay.webm", + ], + "status_text": "已结束", + "recording_status_text": "录制完成", + } + ] + + def upload_call_recording( + self, path: str, diagnosis_id: int, *, call_record_id: int | None = None + ) -> dict[str, Any]: + self.action_calls.append(("video_upload", (path, diagnosis_id, call_record_id))) + return {"file_url": "https://media.example.invalid/replay.mp4"} + + def list_im_chat_messages( + self, diagnosis_id: int, *, only_archived: bool = True + ) -> dict[str, Any]: + self.action_calls.append(("chat_list", (diagnosis_id, only_archived))) + return { + "lists": [ + { + "msg_id": "m1", + "msg_type": "image", + "image_url": "https://media.example.invalid/image.jpg", + "is_from_doctor": False, + }, + { + "msg_id": "m2", + "msg_type": "file", + "file_url": "https://media.example.invalid/report.pdf", + "file_name": "报告.pdf", + "is_from_doctor": True, + }, + ] + } + + def sync_im_chat_messages(self, diagnosis_id: int) -> dict[str, Any]: + self.action_calls.append(("chat_sync", diagnosis_id)) + return {"queued": True} + + +def _open_dialog( + application: QApplication, + size: tuple[int, int], + *, + mode: str, + permissions: PermissionSet | None = None, + locked: bool = False, + repository: VisualRepository | None = None, + parent: QWidget | None = None, +) -> DiagnosisDialog: + dialog = DiagnosisDialog( + repository or VisualRepository(locked=locked), + parent, + permissions=permissions or PermissionSet(["*"]), + ) + dialog.resize(*size) + if mode == "readonly": + dialog.open_for(501, editable=False) + elif mode == "edit": + dialog.open_for(501, editable=True) + elif mode == "viewOnly": + dialog.open_view_only(501) + else: + raise AssertionError(mode) + for _ in range(4): + application.processEvents() + return dialog + + +def _select_tab(dialog: DiagnosisDialog, key: str) -> None: + index = next( + index for index in range(dialog.tabs.count()) if dialog.tabs.tabBar().tabData(index) == key + ) + dialog.tabs.setCurrentIndex(index) + QApplication.processEvents() + + +@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)]) +def test_readonly_is_an_independent_vertical_page_flow( + application: QApplication, + size: tuple[int, int], +) -> None: + dialog = _open_dialog(application, size, mode="readonly") + assert dialog.view_stack.currentWidget() is dialog.readonly_page + assert dialog.readonly_page.objectName() == "DiagnosisReadonlyPage" + assert dialog.readonly_scroll.horizontalScrollBar().maximum() == 0 + assert dialog.readonly_hero.isVisibleTo(dialog) + assert dialog.readonly_patient_card.isVisibleTo(dialog) + assert dialog.case_grid.isVisibleTo(dialog) + assert not dialog.tabs.isVisibleTo(dialog) + assert dialog.findChild(QFrame, "DiagnosisReadonlySection_notes") is not None + assert dialog.readonly_status.text() == "未服务 4 天" + assert dialog.readonly_content_layout.spacing() == 16 + dialog.close() + application.processEvents() + + +@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)]) +@pytest.mark.parametrize("mode", ["edit", "viewOnly"]) +def test_drawer_is_full_height_rtl_and_sixty_percent_wide( + application: QApplication, + size: tuple[int, int], + mode: str, +) -> None: + dialog = _open_dialog(application, size, mode=mode, locked=mode == "viewOnly") + expected_width = round(size[0] * 0.60) + assert dialog.view_stack.currentWidget() is dialog.drawer_overlay + assert dialog.drawer_overlay.rect() == dialog.rect() + assert dialog.drawer_panel.width() == expected_width + assert dialog.drawer_panel.height() == size[1] + assert dialog.drawer_panel.x() + dialog.drawer_panel.width() == size[0] + assert dialog.findChild(QFrame, "DiagnosisDrawerHeader").geometry().top() == 0 + footer = dialog.findChild(QFrame, "DiagnosisDrawerFooter") + assert footer.geometry().bottom() == dialog.drawer_panel.rect().bottom() + assert dialog.tabs.isVisibleTo(dialog) + for field in dialog.edit_fields.values(): + if field.isVisibleTo(dialog): + right_edge = field.mapTo(dialog.drawer_panel, QPoint(field.width(), 0)).x() + assert right_edge <= dialog.drawer_panel.width() - 12 + assert dialog.save_button.isVisibleTo(dialog) is (mode == "edit") + assert dialog.close_button.text() == ("取消" if mode == "edit" else "关闭") + assert dialog.mode_label.text() == ("编辑" if mode == "edit" else "只读") + dialog.close() + application.processEvents() + + +def test_tabs_permissions_locked_state_and_field_error_focus( + application: QApplication, +) -> None: + dialog = _open_dialog(application, (1024, 640), mode="edit", locked=True) + assert [dialog.tabs.tabText(index) for index in range(dialog.tabs.count())] == [ + "病历", + "医生备注", + "日常记录", + "处方", + "业务订单", + "视频录制回放", + "聊天", + "指派医助记录", + "挂号记录", + ] + assert dialog.privacy_banner.isVisibleTo(dialog) + assert dialog.edit_fields["patient_name"].isReadOnly() + assert not dialog.edit_fields["present_illness"].isReadOnly() + dialog.edit_fields["patient_name"].setReadOnly(False) + dialog.edit_fields["patient_name"].clear() + dialog._save() + application.processEvents() + assert dialog.edit_fields["patient_name"].property("invalid") is True + assert dialog.edit_fields["patient_name"].hasFocus() + assert dialog.drawer_banner.isVisibleTo(dialog) + dialog.close() + + minimal = _open_dialog( + application, + (1024, 640), + mode="viewOnly", + permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]), + ) + assert [minimal.tabs.tabText(index) for index in range(minimal.tabs.count())] == [ + "病历", + "医生备注", + ] + minimal.close() + application.processEvents() + + +def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type( + application: QApplication, +) -> None: + repository = VisualRepository() + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=repository, + ) + + assert isinstance(dialog.edit_fields["gender"], DiagnosisChoiceButtons) + assert len(dialog.edit_fields["gender"].findChildren(QRadioButton)) == 2 + assert isinstance(dialog.edit_fields["local_hospital_diagnosis"], DiagnosisChoiceButtons) + assert isinstance(dialog.edit_fields["age"], DiagnosisNumberEdit) + assert isinstance(dialog.edit_fields["height"], DiagnosisNumberEdit) + assert isinstance(dialog.edit_fields["weight"], DiagnosisNumberEdit) + assert isinstance(dialog.edit_fields["fasting_blood_sugar"], DiagnosisLineEdit) + assert isinstance(dialog.edit_fields["diagnosis_date"], DiagnosisDateEdit) + diagnosis_type = dialog.edit_fields["diagnosis_type"] + assert isinstance(diagnosis_type, DiagnosisComboBox) + assert [diagnosis_type.itemData(index) for index in range(1, diagnosis_type.count())] == [ + "first_visit", + "follow_up", + "consultation", + ] + assert diagnosis_type.toPlainText() == "follow_up" + assert "diagnosis_type" in repository.dictionary_calls + assert dialog._form_narrow is False + assert any(len(fields) >= 3 for _layout, fields, _remainder in dialog._form_rows) + + _select_tab(dialog, "notes") + assert not dialog.save_button.isVisibleTo(dialog) + _select_tab(dialog, "basic") + assert dialog.save_button.isVisibleTo(dialog) + diagnosis_type.setFocus() + application.processEvents() + assert diagnosis_type.hasFocus() + dialog.close() + + +def test_view_only_uses_ordinary_detail_and_standalone_uses_readonly_detail( + application: QApplication, +) -> None: + view_repository = VisualRepository() + view = _open_dialog( + application, + (1024, 640), + mode="viewOnly", + repository=view_repository, + ) + assert view_repository.detail_calls == ["ordinary"] + view.close() + + readonly_repository = VisualRepository() + readonly = _open_dialog( + application, + (1024, 640), + mode="readonly", + repository=readonly_repository, + ) + assert readonly_repository.detail_calls == ["readonly"] + readonly.close() + + +def test_tabs_lazy_load_real_repository_data_and_daily_matrix_structure( + application: QApplication, +) -> None: + repository = VisualRepository() + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=repository, + ) + assert repository.notes_calls == 0 + assert repository.tracking_calls == [] + assert repository.prescription_calls == 0 + assert repository.order_queries == [] + + _select_tab(dialog, "notes") + assert repository.notes_calls == 1 + _select_tab(dialog, "basic") + _select_tab(dialog, "notes") + assert repository.notes_calls == 1 + + _select_tab(dialog, "daily") + assert len(repository.tracking_calls) == 1 + assert repository.tracking_note_calls == 1 + assert repository.todo_calls == [None] + panel = dialog._daily_panels[1] + assert panel.matrix.rowCount() == 11 + assert panel.matrix.columnCount() == 8 + blood_column = next( + column + for column in range(1, panel.matrix.columnCount()) + if panel.matrix.horizontalHeaderItem(column).text() == "08-10" + ) + assert panel.matrix.item(0, blood_column).text() == "↑ 8.2 · 自录" + assert panel.matrix.item(3, blood_column).text() == "↑ 146/92 · 自录" + assert panel.todo_table.rowCount() == 1 + assert panel.todo_summary.text() == "共 1 条" + dialog._daily_todo_filter_changed(0) + assert repository.todo_calls[-1] == 0 + + _select_tab(dialog, "prescription") + assert repository.prescription_calls == 1 + assert dialog._table_registry["prescription"][0].rowCount() == 1 + _select_tab(dialog, "orders") + assert len(repository.order_queries) == 1 + assert dialog.orders_table.cellWidget(0, 8) is not None + + _select_tab(dialog, "video") + video_table = dialog._table_registry["video"][1] + assert video_table.rowCount() == 0 + assert not any( + token in button.text() + for button in video_table.findChildren(QPushButton) + for token in ("查看", "播放") + ) + _select_tab(dialog, "chat") + assert "尚未接入" in dialog._chat_panels[1].empty.text() + dialog.close() + + +def test_appointment_history_phone_is_fail_closed_without_phone_plain( + application: QApplication, +) -> None: + dialog = _open_dialog( + application, + (1024, 640), + mode="readonly", + permissions=PermissionSet(["tcm.diagnosis/readonlyDetail", "doctor.appointment/lists"]), + ) + patient_cell = dialog.appointment_table.item(0, 2) + assert patient_cell is not None + assert "138****8000" in patient_cell.text() + assert "13800138000" not in patient_cell.text() + dialog.close() + + +def test_order_detail_action_requires_exact_permission(application: QApplication) -> None: + repository = VisualRepository() + dialog = _open_dialog( + application, + (1024, 640), + mode="viewOnly", + permissions=PermissionSet(["tcm.diagnosis/patientOrders"]), + repository=repository, + ) + _select_tab(dialog, "orders") + assert dialog.orders_table.rowCount() == 1 + assert dialog.orders_table.cellWidget(0, 8) is None + assert dialog.orders_table.item(0, 8).text() == "无详情权限" + dialog.close() + + +def test_detail_failure_clears_seed_locks_save_and_supports_retry( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = VisualRepository() + callbacks: list[dict[str, Any]] = [] + + def queue_async(function: Any, **options: Any) -> object: + callbacks.append({"function": function, **options}) + return object() + + monkeypatch.setattr(diagnosis_module, "run_async", queue_async) + dialog = DiagnosisDialog(repository, permissions=PermissionSet(["*"])) + dialog.resize(1024, 640) + dialog.open_for(501, editable=True, seed=diagnosis_fixture()) + application.processEvents() + assert dialog.edit_fields["patient_name"].toPlainText() == "林晓岚" + assert not dialog.save_button.isEnabled() + + callbacks[0]["on_error"](RuntimeError("诊单详情读取失败")) + callbacks[0]["on_finished"]() + application.processEvents() + assert not dialog._authoritative_detail_loaded + assert dialog.edit_fields["patient_name"].toPlainText() == "" + assert dialog.edit_fields["patient_name"].isReadOnly() + assert not dialog.save_button.isEnabled() + assert dialog.drawer_banner.action_button.isVisibleTo(dialog) + dialog._save() + assert repository.updates == [] + + dialog.drawer_banner.action_button.click() + assert len(callbacks) == 2 + result = callbacks[1]["function"]() + callbacks[1]["on_success"](result) + callbacks[1]["on_finished"]() + application.processEvents() + assert dialog._authoritative_detail_loaded + assert dialog.edit_fields["patient_name"].toPlainText() == "林晓岚" + assert dialog.save_button.isEnabled() + dialog.close() + + +def test_close_invalidates_pending_load_and_save_callbacks( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = VisualRepository() + queued: list[dict[str, Any]] = [] + + def queue_async(function: Any, **options: Any) -> object: + queued.append({"function": function, **options}) + return object() + + monkeypatch.setattr(diagnosis_module, "run_async", queue_async) + loading = DiagnosisDialog(repository, permissions=PermissionSet(["*"])) + loading.open_for(501, editable=True) + load_generation = loading._generation + loading.reject() + result = queued[0]["function"]() + queued[0]["on_success"](result) + queued[0]["on_finished"]() + assert loading._generation > load_generation + assert not loading._authoritative_detail_loaded + + # Reuse the fixture's synchronous runner by manually applying a fresh authoritative bundle. + saving = DiagnosisDialog(repository, permissions=PermissionSet(["*"])) + saving._diagnosis_id = 501 + saving._editable = True + saving._standalone_readonly = False + saving._view_only = False + saving._load_mode = "edit" + saving._apply_bundle(saving._load_bundle(501, "edit"), saving._generation) + saving.view_stack.setCurrentWidget(saving.drawer_overlay) + saving.show() + application.processEvents() + queued.clear() + monkeypatch.setattr(diagnosis_module, "run_async", queue_async) + saved: list[bool] = [] + saving.saved.connect(lambda: saved.append(True)) + saving._save() + assert len(queued) == 1 + save_generation = saving._save_generation + saving.reject() + queued[0]["on_success"](None) + queued[0]["on_finished"]() + assert saving._save_generation > save_generation + assert saved == [] + + +def test_owner_resize_move_and_close_syncs_geometry_and_invalidates_generation( + application: QApplication, +) -> None: + owner = QWidget() + owner.setGeometry(80, 60, 1200, 760) + owner.show() + repository = VisualRepository() + dialog = _open_dialog( + application, + (1200, 760), + mode="edit", + repository=repository, + parent=owner, + ) + owner.setGeometry(110, 90, 1024, 650) + for _ in range(3): + application.processEvents() + assert dialog.size() == owner.size() + assert dialog.pos() == owner.mapToGlobal(QPoint(0, 0)) + generation = dialog._generation + owner.close() + for _ in range(3): + application.processEvents() + assert dialog._generation > generation + assert not dialog.isVisible() + + +def test_body_scroll_does_not_move_fixed_footer(application: QApplication) -> None: + dialog = _open_dialog(application, (1024, 640), mode="edit") + body = dialog.findChild(QScrollArea, "DiagnosisDrawerBody") + footer = dialog.findChild(QFrame, "DiagnosisDrawerFooter") + before = footer.geometry() + assert body.verticalScrollBar().maximum() > 0 + body.verticalScrollBar().setValue(body.verticalScrollBar().maximum()) + application.processEvents() + assert footer.geometry() == before + assert footer.geometry().bottom() == dialog.drawer_panel.rect().bottom() + dialog.close() + + +def test_required_detail_reference_artifacts_exist() -> None: + root = Path(__file__).resolve().parents[1] + expected = { + root / "artifacts" / "diagnosis_visual" / f"diagnosis_{mode}_{width}x{height}.png": ( + width, + height, + ) + for mode in ("readonly", "edit", "viewonly") + for width, height in ((1024, 640), (1440, 900)) + } + expected.update( + { + root / "artifacts" / "diagnosis_visual" / f"diagnosis_state_{state}_1024x640.png": ( + 1024, + 640, + ) + for state in ( + "loading", + "error", + "empty", + "permission", + "daily", + "daily_lower", + "focus", + "save_loading", + "save_success", + "save_failure", + "notes_actions", + "video_replay", + "chat_archive", + "order_offset", + ) + } + ) + for path, dimensions in expected.items(): + assert path.is_file(), ( + f"run scripts/render_diagnosis_detail_visual.py to create {path.name}" + ) + image = QImage(str(path)) + assert not image.isNull() + assert (image.width(), image.height()) == dimensions + assert image.pixelColor(0, 0).alpha() > 0 + assert image.sizeInBytes() > dimensions[0] * dimensions[1] + + +def test_drawer_tab_bar_is_horizontal_and_scrollable(application: QApplication) -> None: + dialog = _open_dialog(application, (1024, 640), mode="edit") + tab_bar = dialog.tabs.tabBar() + assert tab_bar.shape().name.startswith("RoundedNorth") + assert tab_bar.usesScrollButtons() + assert not tab_bar.expanding() + assert tab_bar.elideMode() == Qt.TextElideMode.ElideNone + assert dialog.tabs.tab_scrollbar.height() == 4 + assert dialog.tabs.tab_scrollbar.isVisibleTo(dialog) + assert not any( + button.isVisibleTo(dialog) and button.width() > 0 + for button in tab_bar.findChildren(QToolButton) + ) + dialog.close() + + +def test_switch_actions_archive_and_offset_use_real_repository_contracts( + application: QApplication, +) -> None: + repository = ActionRepository() + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=repository, + ) + + show_card = dialog.edit_fields["show_card"] + assert isinstance(show_card, DiagnosisSwitch) + show_card.setChecked(True) + dialog._save() + assert repository.updates[-1]["show_card"] == 1 + + _select_tab(dialog, "daily") + daily = dialog._daily_panels[1] + assert all(button.isVisibleTo(dialog) for button in daily.add_buttons.values()) + assert daily.todo_add_button.isVisibleTo(dialog) + dialog._submit_daily_record( + "blood", + { + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "fasting_blood_sugar": 5.9, + }, + ) + assert any(name == "blood" for name, _payload in repository.action_calls) + assert daily.todo_table.cellWidget(0, 2) is not None + + _select_tab(dialog, "notes") + assert dialog.drawer_notes_timeline.toolbar.isVisibleTo(dialog) + assert not any( + button.text() in {"编辑笔记", "删除笔记"} + for button in dialog.drawer_notes_timeline.findChildren(QPushButton) + ) + + _select_tab(dialog, "video") + assert dialog.video_upload_button.isVisibleTo(dialog) + assert dialog._table_registry["video"][1].cellWidget(0, 0) is not None + + _select_tab(dialog, "chat") + assert ("chat_list", (501, True)) in repository.action_calls + chat_buttons = {button.text() for button in dialog._chat_panels[1].findChildren(QPushButton)} + assert {"查看图片", "报告.pdf"} <= chat_buttons + + _select_tab(dialog, "orders") + dialog.order_offset.setValue(6) + dialog._save_order_offset() + assert ("offset", (501, 6)) in repository.action_calls + dialog.close() + + +def test_mutations_fail_closed_and_save_button_paints_all_states( + application: QApplication, +) -> None: + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=VisualRepository(), + ) + assert isinstance(dialog.save_button, SaveStateButton) + assert dialog.save_button.state == "idle" + dialog._saving = True + dialog._sync_save_button() + assert dialog.save_button.state == "loading" + generation = dialog._save_generation + dialog._save_success(generation) + assert dialog.save_button.state == "success" + dialog._save_error(RuntimeError("保存失败"), generation) + assert dialog.save_button.state == "error" + + _select_tab(dialog, "daily") + daily = dialog._daily_panels[1] + assert not any(button.isVisibleTo(dialog) for button in daily.add_buttons.values()) + assert not daily.todo_add_button.isVisibleTo(dialog) + _select_tab(dialog, "video") + assert not dialog.video_upload_button.isVisibleTo(dialog) + _select_tab(dialog, "chat") + assert not dialog._chat_panels[1].sync_button.isVisibleTo(dialog) + assert not dialog.summary_fields["staff"].wordWrap() + dialog.close() + + +def test_daily_toolbar_wraps_and_button_variants_survive_scoped_qss( + application: QApplication, +) -> None: + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=ActionRepository(), + ) + _select_tab(dialog, "daily") + panel = dialog._daily_panels[1] + toolbar_layout = panel.toolbar_host.layout() + assert toolbar_layout.hasHeightForWidth() + assert toolbar_layout.heightForWidth(420) > 40 + assert panel.add_buttons["blood"].property("variant") == "primary" + assert all( + panel.add_buttons[kind].property("variant") == "secondary" + for kind in ("diet", "exercise", "tracking") + ) + assert panel.todo_add_button.property("variant") == "primary" + assert panel.refresh_button.geometry().right() <= panel.toolbar_host.rect().right() + assert panel.refresh_button.geometry().bottom() <= panel.toolbar_host.rect().bottom() + assert "QDialog#DiagnosisDialogRoot QPushButton {" not in diagnosis_module.DIAGNOSIS_QSS + assert 'QDialog QPushButton[variant="primary"]' in diagnosis_module.DIAGNOSIS_QSS + assert 'QDialog QPushButton[variant="dangerGhost"]' in diagnosis_module.DIAGNOSIS_QSS + dialog.close() + + +def test_existing_daily_cells_edit_real_records_and_reject_wrong_owner( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = ActionRepository() + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=repository, + ) + _select_tab(dialog, "daily") + panel = dialog._daily_panels[1] + + class AcceptedEditor: + def __init__(self, kind: str, record: Any, **_kwargs: Any) -> None: + self.kind = kind + self.record = dict(record) + + def exec(self) -> QDialog.DialogCode: + return QDialog.DialogCode.Accepted + + def payload(self) -> dict[str, Any]: + payload = dict(self.record) + if self.kind == "blood": + payload["fasting_blood_sugar"] = 6.1 + return payload + + monkeypatch.setattr(diagnosis_module, "DailyRecordEditorDialog", AcceptedEditor) + blood_column = next( + column + for column in range(1, panel.matrix.columnCount()) + if panel.matrix.horizontalHeaderItem(column).text() == "08-10" + ) + exercise_column = next( + column + for column in range(1, panel.matrix.columnCount()) + if panel.matrix.horizontalHeaderItem(column).text() == "08-09" + ) + blood_role = panel.matrix.item(0, blood_column).data(Qt.ItemDataRole.UserRole) + diet_role = panel.matrix.item(6, blood_column).data(Qt.ItemDataRole.UserRole) + exercise_role = panel.matrix.item(9, exercise_column).data(Qt.ItemDataRole.UserRole) + assert blood_role[0] == "blood" and blood_role[1]["id"] == 6201 + assert diet_role[0] == "diet" and diet_role[1]["id"] == 6301 + assert exercise_role[0] == "exercise" and exercise_role[1]["id"] == 6401 + + panel._daily_cell_clicked(0, blood_column) + panel._daily_cell_clicked(6, blood_column) + panel._daily_cell_clicked(9, exercise_column) + assert {name for name, _payload in repository.action_calls} >= { + "blood_edit", + "diet_edit", + "exercise_edit", + } + edited_blood = next( + payload for name, payload in repository.action_calls if name == "blood_edit" + ) + assert edited_blood["id"] == 6201 + assert edited_blood["diagnosis_id"] == 501 + assert edited_blood["patient_id"] == 1501 + assert edited_blood["fasting_blood_sugar"] == 6.1 + + count = len(repository.action_calls) + dialog._edit_daily_record( + "blood", + { + "id": 9001, + "diagnosis_id": 999, + "patient_id": 1501, + "record_date": "2026-08-10", + "fasting_blood_sugar": 5.5, + }, + ) + assert len(repository.action_calls) == count + assert "归属与当前诊单不一致" in dialog.drawer_banner.label.text() + dialog.close() + + +def test_daily_edit_is_fail_closed_without_all_update_capabilities( + application: QApplication, +) -> None: + repository = ActionRepository() + repository.update_blood_record = None # type: ignore[method-assign] + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=repository, + ) + _select_tab(dialog, "daily") + panel = dialog._daily_panels[1] + assert panel.add_buttons["blood"].isVisibleTo(dialog) + assert panel.matrix.item(0, 1).data(Qt.ItemDataRole.UserRole) is None + dialog._edit_daily_record( + "blood", + { + "id": 6201, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "fasting_blood_sugar": 5.5, + }, + ) + assert not any(name == "blood_edit" for name, _payload in repository.action_calls) + dialog.close() + + +def test_stale_daily_edit_completion_cannot_reload_a_new_generation( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = ActionRepository() + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=repository, + ) + _select_tab(dialog, "daily") + callbacks: list[dict[str, Any]] = [] + + def queue_async(function: Any, **options: Any) -> object: + callbacks.append({"function": function, **options}) + return object() + + monkeypatch.setattr(diagnosis_module, "run_async", queue_async) + before_loads = len(repository.tracking_calls) + dialog._submit_daily_record( + "blood", + { + "id": 6201, + "diagnosis_id": 501, + "patient_id": 1501, + "record_date": "2026-08-10", + "fasting_blood_sugar": 6.2, + }, + ) + assert len(callbacks) == 1 + result = callbacks[0]["function"]() + dialog._daily_mutation_generation += 1 + callbacks[0]["on_success"](result) + assert len(repository.tracking_calls) == before_loads + dialog.close() + + +def test_note_attachment_hierarchy_uses_real_button_selectors( + application: QApplication, +) -> None: + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=ActionRepository(), + ) + _select_tab(dialog, "notes") + timeline = dialog.drawer_notes_timeline + assert timeline.findChild(QPushButton, "DiagnosisTongueThumb") is not None + assert timeline.findChild(QPushButton, "DiagnosisAttachmentChip") is not None + removes = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove") + assert len(removes) == 2 + assert all(button.text() == "×" and button.size().width() == 22 for button in removes) + assert "QLabel#DiagnosisAttachmentChip" not in diagnosis_module.DIAGNOSIS_QSS + assert "QPushButton#DiagnosisAttachmentChip" in diagnosis_module.DIAGNOSIS_QSS + dialog.close() + + +def test_video_renders_every_url_and_row_upload_binds_exact_record( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = ActionRepository() + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=repository, + ) + _select_tab(dialog, "video") + table = dialog._table_registry["video"][1] + playback_host = table.cellWidget(0, 0) + assert playback_host is not None + inline = playback_host.findChild(QWidget, "DiagnosisInlineRecordingPlayer") + assert inline is not None + assert inline.maximumHeight() == 180 + alternates = playback_host.findChildren(QPushButton, "DiagnosisRecordingAlternateLink") + assert [button.text() for button in alternates] == ["HLS 1", "链接 2"] + upload_host = table.cellWidget(0, 8) + assert upload_host is not None + upload = upload_host.findChild(QPushButton, "DiagnosisVideoRowUpload") + assert isinstance(upload, QPushButton) + assert upload.property("variant") == "secondary" + monkeypatch.setattr( + diagnosis_module.QFileDialog, + "getOpenFileName", + lambda *_args, **_kwargs: ("C:/safe/replay.mp4", "视频"), + ) + upload.click() + assert ( + "video_upload", + ("C:/safe/replay.mp4", 501, 48), + ) in repository.action_calls + dialog.close() + + +def test_embedded_player_chat_alert_prescription_and_order_visual_contracts( + application: QApplication, +) -> None: + repository = ActionRepository() + dialog = _open_dialog( + application, + (1024, 640), + mode="edit", + repository=repository, + ) + assert dialog.prescribe_button.text() == "开方" + assert dialog.prescribe_button.property("variant") == "primary" + assert dialog.order_offset_save.text() == "保存" + assert dialog.order_offset_save.property("variant") == "primary" + + _select_tab(dialog, "chat") + info = dialog._chat_panels[1].findChild(QFrame, "DiagnosisChatInfo") + assert info is not None + assert "所有医生 / 医助账号" in "".join(label.text() for label in info.findChildren(QLabel)) + assert dialog._chat_panels[1].sync_button.text() == "同步最新(后台异步)" + + dialog._open_recording_player("https://media.example.invalid/replay.mp4") + application.processEvents() + player = dialog._recording_players[-1] + assert player.url is not None + assert player.play_button.isEnabled() + player.close() + application.processEvents() + + detail = dialog._build_order_detail_dialog(repository.get_prescription_order(801), 801) + detail.show() + application.processEvents() + drawer_panel = detail.findChild(QFrame, "DiagnosisOrderDetailDrawer") + assert drawer_panel is not None + assert abs(drawer_panel.width() - round(dialog.width() * 0.8)) <= 1 + status = next( + label for label in detail.findChildren(QLabel) if label.property("diagnosisTag") is True + ) + assert status.text() == "待配药" + assert status.property("kind") == "warning" + detail.close() + dialog.close() diff --git a/app/tests/test_diagnosis_index_visual.py b/app/tests/test_diagnosis_index_visual.py new file mode 100644 index 000000000..0c09f4454 --- /dev/null +++ b/app/tests/test_diagnosis_index_visual.py @@ -0,0 +1,682 @@ +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QAbstractTableModel, QRect, Qt, Signal +from PySide6.QtGui import QColor, QImage, QPainter +from PySide6.QtWidgets import QApplication, QFrame, QToolButton, QWidget + +from doctor_workstation.core import PermissionSet +from doctor_workstation.ui.diagnosis_index_widgets import ( + DIAGNOSIS_INDEX_QSS, + DiagnosisItemDelegate, + DiagnosisTableModel, +) +from doctor_workstation.ui.pages import consultations as consultations_module +from doctor_workstation.ui.pages.consultations import ConsultationsPage + + +class _ListDiagnosisDialog(QWidget): + saved = Signal() + + def __init__(self, _repository: Any, parent: QWidget | None = None) -> None: + super().__init__(parent) + + def open_for(self, *_args: Any, **_kwargs: Any) -> None: + return None + + +class _CancellationRepository: + def cancel_diagnosis_appointment(self, appointment_id: int) -> None: + del appointment_id + + +class _FullMenuRepository(_CancellationRepository): + def generate_video_qrcode( + self, doctor_id: int, patient_id: int, share_user_id: int + ) -> dict[str, str]: + del doctor_id, patient_id, share_user_id + return {"qrcode_url": "https://example.invalid/video.png"} + + def generate_diagnosis_qrcode( + self, + diagnosis_id: int, + doctor_id: int, + patient_id: int, + share_user_id: int, + ) -> dict[str, str]: + del diagnosis_id, doctor_id, patient_id, share_user_id + return {"qrcode_url": "https://example.invalid/confirm.png"} + + def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: + del diagnosis_id + return [] + + def create_diagnosis_order( + self, + patient_id: int, + order_type: int, + amount: float, + *, + remark: str = "", + ) -> dict[str, Any]: + del patient_id, order_type, amount, remark + return {"order_no": "ORDER-715"} + + def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: + del order_no + return {"qrcode_url": "https://example.invalid/order.png"} + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.fixture(autouse=True) +def isolate_list_from_detail_dialog(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(consultations_module, "DiagnosisDialog", _ListDiagnosisDialog) + + +@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) + 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) + + +def _row(identifier: int, **changes: Any) -> dict[str, Any]: + row: dict[str, Any] = { + "id": identifier, + "diagnosis_id": identifier, + "patient_id": identifier + 1000, + "patient_name": f"患者{identifier}", + "gender": 1, + "age": 38, + "has_appointment": 1, + "appointment_id": identifier + 2000, + "appointment_status": 1, + "appointments": [ + { + "id": identifier + 2000, + "status": 1, + "doctor_name": "陈医生", + "time_text": "今天 09:00-09:30", + } + ], + "DiagnosisViewRecord": [{"is_confirmed": 1}], + "assistant_id": 8, + "assistant_name": "赵医助", + "assign_read_at": None, + "has_prescription": 1, + "followup_time_text": "2026-08-17 09:00", + "followup_doctor_name": "陈医生", + "unserved_days": 2, + "last_blood_record_at": "2026-08-09 20:10", + } + row.update(changes) + return row + + +def _page() -> ConsultationsPage: + return ConsultationsPage(_CancellationRepository(), permissions=PermissionSet(["*"])) + + +def test_visual_hierarchy_and_filter_contract( + application: QApplication, +) -> None: + page = _page() + content_layout = page.page_scroll.widget().layout() + margins = content_layout.contentsMargins() + assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (8, 16, 8, 16) + assert content_layout.spacing() == 12 + assert page.findChild(QFrame, "DiagnosisFilterCard") is not None + assert page.findChild(QFrame, "DiagnosisListCard") is not None + assert page.keyword_edit.width() == 160 + assert not page.advanced_filters.isVisible() + assert page.more_filter_button.text() == "更多筛选" + assert page.more_filter_button.arrowType() == Qt.ArrowType.DownArrow + assert [page._date_button_labels[key] for key in page.date_buttons] == [ + "昨天挂号", + "前天挂号", + "当天挂号", + "明天挂号", + "后天挂号", + "全部", + ] + assert page.date_buttons[page._appointment_date].isChecked() + assert page.pending_assign_month.width() == 128 + assert page.pending_assign_keyword.width() == 220 + assert page.pending_assign_keyword.placeholderText() == "搜身份证/诊单ID/患者号/备注…" + assert page.pending_assign_button.parentWidget() is page.pending_assign_wrap + assert page.pending_assign_filters.parentWidget() is page.pending_assign_wrap + assert page.batch_assign_button.text() == "批量指派医助" + assert "PageHeader" not in {type(widget).__name__ for widget in page.findChildren(QFrame)} + page.close() + application.processEvents() + + +def test_pending_assign_and_secondary_chip_semantics( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + page = _page() + monkeypatch.setattr(page, "refresh", lambda silent=False: None) + page._choose_pending_assign() + assert page.pending_assign_button.isChecked() + assert not page.date_buttons[page._appointment_date].isChecked() + assert not page.pending_assign_filters.isHidden() + page.pending_assign_keyword.setText("订单-88") + filters = page._filters() + assert filters == { + "pending_assign": "1", + "pending_assign_keyword": "订单-88", + } + page.pending_assign_keyword.clear() + filters = page._filters() + assert filters["pending_assign_order_month"] == page.pending_assign_month.text().strip() + assert filters["pending_assign_keyword"] == "" + page._choose_appointment_filter("0") + assert page.appointment_filter_buttons["0"].isChecked() + assert page._combo_value(page.has_appointment_combo) == "0" + page._toggle_advanced_filters(True) + assert not page.advanced_filters.isHidden() + assert page.more_filter_button.text() == "收起" + assert page.more_filter_button.arrowType() == Qt.ArrowType.UpArrow + assert page.unserved_sort_combo.isHidden() + date_ranges = page.advanced_filters.findChildren(QFrame, "DiagnosisDateRange") + assert len(date_ranges) == 2 + assert all(field.width() == 260 for field in date_ranges) + page.close() + application.processEvents() + + +def test_dedicated_model_fixed_columns_selection_and_sort( + application: QApplication, +) -> None: + page = _page() + assert isinstance(page.table.model(), QAbstractTableModel) + assert isinstance(page.table.model(), DiagnosisTableModel) + assert page.table_host.LEFT_WIDTHS == (48, 70, 60, 100, 175, 88, 120, 100, 72, 110) + assert page.table_host.FIXED_WIDTHS == (120, 340) + assert page.table_host.fixed.width() == 462 + assert page.table.isColumnHidden(10) + assert page.table_host.fixed.isColumnHidden(9) + assert not page.table_host.fixed.isColumnHidden(10) + assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff + + rows = [_row(501), _row(502, has_appointment=0, appointments=[])] + page.table_host.set_rows(rows) + model = page.table_host.model + assert model.setData(model.index(0, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole) + assert page.table_host.selected_records() == [rows[0]] + assert page.selected_label.text() == "已选 1 条" + + requested: list[str] = [] + page.table_host.sort_unserved_requested.connect(requested.append) + assert page.table_host.model.headerData(9, Qt.Orientation.Horizontal) == "未服务天数" + assert page.table_host.model._sort_direction == "" + page.table_host.main.horizontalHeader().sectionClicked.emit(9) + assert requested == ["desc"] + assert page.table_host.model._sort_direction == "desc" + + page.table_host.action_requested.disconnect(page._row_action) + actions: list[tuple[str, Any]] = [] + page.table_host.action_requested.connect( + lambda action, record: actions.append((action, record)) + ) + action_cell = page.table_host.fixed.indexWidget(model.index(0, 11)) + second_fixed_cell = page.table_host.fixed.indexWidget(model.index(1, 11)) + second_fixed_cell.hovered_row.emit(1) + assert model.hover_row == 1 + second_fixed_cell.hovered_row.emit(-1) + assert model.hover_row == -1 + direct_links = { + button.text() for button in action_cell.findChildren(QToolButton) if button.menu() is None + } + assert {"查看", "诊单", "开方", "预约", "补全身份证"} <= direct_links + more = next(button for button in action_cell.findChildren(QToolButton) if button.menu()) + menu_texts = [action.text() for action in more.menu().actions() if not action.isSeparator()] + assert "指派" in menu_texts + assert "取消挂号" in menu_texts + assert {"视频二维码", "二维码", "挂号日志", "创建订单"}.isdisjoint(menu_texts) + next(action for action in more.menu().actions() if action.text() == "指派").trigger() + assert actions == [("assign", rows[0])] + page.close() + application.processEvents() + + +def test_empty_loading_and_full_pager_keep_the_table_shell( + application: QApplication, +) -> None: + page = _page() + page.table_host.set_rows([]) + page._loading = True + page.resize(1024, 640) + page.show() + application.processEvents() + page._loading = False + assert page.table_host.empty_label.isVisible() + assert page.table.horizontalHeader().isVisible() + assert page.table_host.height() >= 39 + 60 + + page.loading_overlay.start() + application.processEvents() + assert page.loading_overlay.isVisible() + assert page.loading_overlay.geometry() == page.table_host.rect() + page.loading_overlay.stop() + + page.pager.update_state(3, 97) + assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40] + assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5 + assert page.pager.jumper.maximum() == 7 + page.close() + application.processEvents() + + +@pytest.mark.parametrize( + ("record", "stripe", "channel"), + [ + (_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#e6a23c", "warning"), + (_row(702, has_appointment=0, appointments=[]), "#909399", "info"), + ], +) +def test_semantic_hover_preserves_gradient_and_three_pixel_stripe( + application: QApplication, + record: dict[str, Any], + stripe: str, + channel: str, +) -> None: + model = DiagnosisTableModel([record]) + model.set_hover_row(0) + image = QImage(48, 52, QImage.Format.Format_ARGB32_Premultiplied) + image.fill(QColor("#FFFFFF")) + painter = QPainter(image) + DiagnosisItemDelegate._paint_row_background( + painter, + QRect(0, 0, 48, 52), + record, + 0, + 0, + model, + ) + painter.end() + + assert image.pixelColor(0, 20).name() == stripe + assert image.pixelColor(1, 20).name() == stripe + assert image.pixelColor(2, 20).name() == stripe + gradient = image.pixelColor(6, 20) + assert gradient.name() != "#f8f8f8", f"{channel} hover collapsed to a neutral row" + + +def test_fixed_shadow_focus_and_admin_token_contract(application: QApplication) -> None: + page = _page() + page.resize(1024, 640) + page.show() + application.processEvents() + shadow = page.table_host.fixed_shadow + assert shadow.isVisible() + assert shadow.width() == 12 + assert shadow.geometry().right() == page.table_host.fixed.geometry().left() - 1 + assert 'font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei"' in ( + DIAGNOSIS_INDEX_QSS + ) + assert "QTableView:focus" in DIAGNOSIS_INDEX_QSS + assert "QToolButton[rowLink]:focus" in DIAGNOSIS_INDEX_QSS + assert '#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"]' in ( + DIAGNOSIS_INDEX_QSS + ) + page.close() + application.processEvents() + + +def test_multi_appointment_never_exposes_ambiguous_row_cancel( + application: QApplication, +) -> None: + page = _page() + single = _row(710) + multiple = _row( + 711, + appointments=[ + {"id": 2711, "status": 1, "doctor_name": "陈医生", "time_text": "09:00"}, + { + "id": 4_294_967_302, + "status": 4, + "doctor_name": "李医生", + "time_text": "10:00", + }, + ], + ) + page.table_host.set_rows([single, multiple]) + + def menu_texts(row: int) -> set[str]: + cell = page.table_host.fixed.indexWidget(page.table_host.model.index(row, 11)) + more = next(button for button in cell.findChildren(QToolButton) if button.menu()) + return {action.text() for action in more.menu().actions() if not action.isSeparator()} + + assert "取消挂号" in menu_texts(0) + assert "取消挂号" not in menu_texts(1) + + appointment_cell = page.table_host.main.indexWidget(page.table_host.model.index(1, 4)) + appointment_buttons = appointment_cell.findChildren(QToolButton) + assert [button.text() for button in appointment_buttons] == ["取消", "取消"] + assert [button.accessibleName() for button in appointment_buttons] == [ + "取消挂号 2711", + "取消挂号 4294967302", + ] + page.table_host.appointment_cancel_requested.disconnect(page._cancel_appointment_item) + requested: list[tuple[Any, int]] = [] + page.table_host.appointment_cancel_requested.connect( + lambda record, appointment_id: requested.append((record, appointment_id)) + ) + appointment_buttons[1].click() + assert requested == [(multiple, 4_294_967_302)] + page.close() + application.processEvents() + + +def test_full_more_menu_requires_each_real_repository_capability( + application: QApplication, +) -> None: + page = ConsultationsPage(_FullMenuRepository(), permissions=PermissionSet(["*"])) + record = _row( + 715, + appointment_doctor_id=9, + appointments=[ + { + "id": 2715, + "status": 1, + "doctor_id": 9, + "doctor_name": "陈医生", + "time_text": "09:00", + } + ], + ) + page.table_host.set_rows([record]) + cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) + more = next(button for button in cell.findChildren(QToolButton) if button.menu()) + assert [action.text() for action in more.menu().actions() if not action.isSeparator()] == [ + "指派", + "取消指派", + "视频二维码", + "二维码", + "取消挂号", + "挂号日志", + "创建订单", + "删除", + ] + actions = more.menu().actions() + menu_actions = [action for action in actions if not action.isSeparator()] + assert all(not action.icon().isNull() for action in menu_actions) + assert all(action.property("iconSource") == "qpaint" for action in menu_actions) + assert actions[-2].isSeparator() + assert actions[-1].text() == "删除" + assert actions[-1].property("danger") is True + + more.menu().ensurePolished() + more.menu().adjustSize() + more.menu().show() + application.processEvents() + danger_rect = more.menu().actionGeometry(actions[-1]) + danger_image = more.menu().grab().toImage() + red_text_pixels = 0 + for y in range(max(0, danger_rect.top()), min(danger_image.height(), danger_rect.bottom() + 1)): + for x in range( + max(0, danger_rect.left() + 40), + min(danger_image.width(), danger_rect.right() + 1), + ): + color = danger_image.pixelColor(x, y) + if color.red() > 190 and color.green() < 150 and color.blue() < 150: + red_text_pixels += 1 + assert red_text_pixels > 8, "删除文案必须由 danger 色绘制,不能回退成原生黑色" + more.menu().hide() + assert page.table_host.action_policy == { + "view": True, + "edit": True, + "prescription": True, + "appointment": True, + "assign": True, + "delete": True, + "video_call": False, + "appointment_cancel": True, + "video_qr": True, + "confirm_qr": True, + "appointment_logs": True, + "create_order": True, + } + page.close() + application.processEvents() + + +def test_patient_workspace_cancel_endpoint_is_not_a_diagnosis_list_capability( + application: QApplication, +) -> None: + class PatientWorkspaceOnlyRepository: + def cancel_patient_appointment(self, appointment_id: int) -> None: + del appointment_id + + page = ConsultationsPage( + PatientWorkspaceOnlyRepository(), + permissions=PermissionSet(["tcm.diagnosis/guahao"]), + ) + page.table_host.set_rows([_row(716)]) + assert not page.table_host.action_policy["appointment_cancel"] + assert page.table_host.main.indexWidget(page.table_host.model.index(0, 4)) is None + action_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) + more_buttons = [button for button in action_cell.findChildren(QToolButton) if button.menu()] + assert all(button.isHidden() for button in more_buttons) + assert all( + "取消挂号" not in {action.text() for action in button.menu().actions()} + for button in more_buttons + ) + page.close() + application.processEvents() + + +@pytest.mark.parametrize("missing_method", ["create_diagnosis_order", "generate_order_qrcode"]) +def test_order_menu_requires_the_complete_create_and_payment_qr_capability( + application: QApplication, + missing_method: str, +) -> None: + repository = _FullMenuRepository() + setattr(repository, missing_method, None) + page = ConsultationsPage( + repository, + permissions=PermissionSet(["tcm.diagnosis/order"]), + ) + page.table_host.set_rows([_row(717, appointment_doctor_id=9)]) + cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) + more_buttons = [button for button in cell.findChildren(QToolButton) if button.menu()] + assert not page.table_host.action_policy["create_order"] + assert all( + "创建订单" not in {action.text() for action in button.menu().actions()} + for button in more_buttons + ) + page.close() + application.processEvents() + + +@pytest.mark.parametrize( + ("permission", "missing_text"), + [ + ("tcm.diagnosis/videoQr", "视频二维码"), + ("tcm.diagnosis/guahao", "二维码"), + ("tcm.diagnosis/guahaoLogList", "挂号日志"), + ("tcm.diagnosis/order", "创建订单"), + ], +) +def test_menu_capability_without_its_exact_permission_is_hidden( + application: QApplication, + permission: str, + missing_text: str, +) -> None: + granted = { + "tcm.diagnosis/assign", + "tcm.diagnosis/delete", + "tcm.diagnosis/videoQr", + "tcm.diagnosis/guahao", + "tcm.diagnosis/guahaoLogList", + "tcm.diagnosis/order", + } + granted.remove(permission) + page = ConsultationsPage(_FullMenuRepository(), permissions=PermissionSet(granted)) + page.table_host.set_rows([_row(718, appointment_doctor_id=9)]) + cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) + more = next(button for button in cell.findChildren(QToolButton) if button.menu()) + texts = {action.text() for action in more.menu().actions() if not action.isSeparator()} + assert missing_text not in texts + page.close() + application.processEvents() + + +def test_permission_crop_removes_unavailable_row_actions(application: QApplication) -> None: + page = ConsultationsPage( + SimpleNamespace(), + permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]), + ) + page.table_host.set_rows([_row(720)]) + cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) + visible = {button.text() for button in cell.findChildren(QToolButton) if not button.isHidden()} + assert visible == {"查看"} + assert not page.table_host.action_policy["video_qr"] + assert not page.table_host.action_policy["confirm_qr"] + assert not page.table_host.action_policy["appointment_logs"] + assert not page.table_host.action_policy["create_order"] + page.close() + application.processEvents() + + +def test_video_entry_is_hidden_without_the_native_repository_lifecycle( + application: QApplication, +) -> None: + page = _page() + page.table_host.set_rows([_row(725)]) + video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10)) + assert video_cell.findChildren(QToolButton) == [] + assert page.video_button.isHidden() + page.close() + application.processEvents() + + +def test_error_state_is_persistent_until_rows_replace_it(application: QApplication) -> None: + page = _page() + page.show() + application.processEvents() + page.table_host.set_rows([]) + page.table_host.show_error("列表加载失败,请重试") + assert page.table_host.empty_label.isVisible() + assert page.table_host.empty_label.property("stateKind") == "error" + assert "加载失败" in page.table_host.empty_label.text() + page.table_host.set_rows([_row(730)]) + assert not page.table_host.empty_label.isVisible() + assert page.table_host.empty_label.property("stateKind") == "empty" + page.close() + application.processEvents() + + +@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)]) +def test_two_desktop_sizes_scroll_vertically_without_horizontal_page_clipping( + application: QApplication, + size: tuple[int, int], +) -> None: + page = _page() + rows = [_row(600 + index, patient_name=f"患者{index:02d}") for index in range(15)] + page.table_host.set_rows(rows) + page.resize(*size) + page.show() + application.processEvents() + assert page.page_scroll.horizontalScrollBar().maximum() == 0 + assert page.page_scroll.verticalScrollBar().maximum() > 0 + assert page.table_host.fixed.geometry().right() <= page.table_host.rect().right() + assert page.search_button.geometry().right() <= page.search_button.parentWidget().rect().right() + page.close() + application.processEvents() + + +def test_required_reference_artifacts_exist() -> None: + root = Path(__file__).resolve().parents[1] + expected = { + root / "artifacts" / "diagnosis_visual" / "diagnosis_1024x640.png": (1024, 640), + root / "artifacts" / "diagnosis_visual" / "diagnosis_1440x900.png": (1440, 900), + root / "artifacts" / "diagnosis_visual" / "diagnosis_loading_1280x800.png": ( + 1280, + 800, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_empty_1280x800.png": ( + 1280, + 800, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_error_1280x800.png": ( + 1280, + 800, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_hover_warning_1280x800.png": ( + 1280, + 800, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_focus_1280x800.png": ( + 1280, + 800, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_permissions_cropped_1280x800.png": ( + 1280, + 800, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_horizontal_scroll_1024x640.png": ( + 1024, + 640, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_pending_assign_1280x800.png": ( + 1280, + 800, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_advanced_filters_1280x800.png": ( + 1280, + 800, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_full_menu_1280x800.png": ( + 1280, + 800, + ), + root / "artifacts" / "diagnosis_visual" / "diagnosis_order_qrcode_1280x800.png": ( + 1280, + 800, + ), + root + / "artifacts" + / "diagnosis_visual" + / "diagnosis_double_appointment_cancel_1280x800.png": ( + 1280, + 800, + ), + } + for path, dimensions in expected.items(): + assert path.is_file(), f"run scripts/render_diagnosis_visual.py to create {path.name}" + image = QImage(str(path)) + assert not image.isNull() + assert (image.width(), image.height()) == dimensions diff --git a/app/tests/test_diagnosis_media_thumbnail_visual.py b/app/tests/test_diagnosis_media_thumbnail_visual.py new file mode 100644 index 000000000..657d2ede3 --- /dev/null +++ b/app/tests/test_diagnosis_media_thumbnail_visual.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, QSize, Signal +from PySide6.QtGui import QColor, QImage +from PySide6.QtNetwork import QNetworkReply +from PySide6.QtWidgets import QApplication, QPushButton, QWidget + +from doctor_workstation.ui.diagnosis_drawer import ( + ChatPanel, + DailyRecordPanel, + NotesTimeline, + _RemoteImageButton, +) + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +def _png_bytes(width: int, height: int, color: str = "#0F766E") -> bytes: + image = QImage(width, height, QImage.Format.Format_ARGB32) + image.fill(QColor(color)) + buffer = QBuffer() + assert buffer.open(QIODevice.OpenModeFlag.WriteOnly) + assert image.save(buffer, "PNG") + return bytes(buffer.data()) + + +class _FakeReply(QObject): + finished = Signal() + + def __init__( + self, + payload: bytes, + error: QNetworkReply.NetworkError = QNetworkReply.NetworkError.NoError, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + self.payload = payload + self.network_error = error + self.aborted = False + + def abort(self) -> None: + self.aborted = True + + def error(self) -> QNetworkReply.NetworkError: + return self.network_error + + def readAll(self) -> QByteArray: # noqa: N802 - mirrors QNetworkReply + return QByteArray(self.payload) + + +class _FakeManager(QObject): + def __init__(self, parent: QObject) -> None: + super().__init__(parent) + self.responses: list[tuple[bytes, QNetworkReply.NetworkError]] = [] + self.requests: list[str] = [] + self.replies: list[_FakeReply] = [] + + def queue( + self, + payload: bytes, + error: QNetworkReply.NetworkError = QNetworkReply.NetworkError.NoError, + ) -> None: + self.responses.append((payload, error)) + + def get(self, request: object) -> _FakeReply: + payload, error = self.responses.pop(0) + self.requests.append(request.url().toString()) + reply = _FakeReply(payload, error, self) + self.replies.append(reply) + return reply + + +class _RenderOwner(QWidget): + def __init__(self, generation: int) -> None: + super().__init__() + self._image_generation = generation + + +def _hold_remote_load(self: _RemoteImageButton, source: str) -> None: + """Offline transport used by panel tests; payload completion stays explicit.""" + + self._source = str(source).strip() + self._invalidate_request() + self.setToolTip(self._source) + self._show_loading() + + +def test_remote_image_request_is_thread_owned_and_rejects_stale_results( + application: QApplication, +) -> None: + owner = _RenderOwner(7) + button = _RemoteImageButton( + "", + render_owner=owner, + owner_generation=7, + maximum_size=QSize(64, 64), + fallback_text="舌象\n查看", + cover=True, + object_name="DiagnosisTongueThumb", + parent=owner, + ) + button._manager.deleteLater() + manager = _FakeManager(button) + button._manager = manager + manager.queue(_png_bytes(180, 90, "#DC2626")) + manager.queue(_png_bytes(180, 90, "#16A34A")) + + button.load_url("https://media.example.invalid/old.png") + old_reply = manager.replies[-1] + assert button.text() == "舌象\n查看" + assert button.property("loadState") == "loading" + button.load_url("https://media.example.invalid/current.png") + current_reply = manager.replies[-1] + assert old_reply.aborted is True + + old_reply.finished.emit() + assert button.property("loadState") == "loading" + current_reply.finished.emit() + assert button.property("loadState") == "ready" + assert button._rendered_pixmap.size() == QSize(64, 64) + assert button.text() == "" + assert manager.parent() is button + assert manager.thread() == button.thread() == application.thread() + assert manager.requests == [ + "https://media.example.invalid/old.png", + "https://media.example.invalid/current.png", + ] + + manager.queue(_png_bytes(90, 180, "#2563EB")) + button.load_url("https://media.example.invalid/new-owner.png") + owner._image_generation += 1 + manager.replies[-1].finished.emit() + assert button.property("loadState") == "loading" + + +def test_remote_image_uses_text_only_after_request_or_decode_failure( + application: QApplication, +) -> None: + owner = _RenderOwner(3) + button = _RemoteImageButton( + "", + render_owner=owner, + owner_generation=3, + maximum_size=QSize(240, 200), + fallback_text="查看图片", + cover=False, + object_name="DiagnosisChatImage", + parent=owner, + ) + button._manager.deleteLater() + manager = _FakeManager(button) + button._manager = manager + manager.queue(b"not-an-image") + + button.load_url("https://media.example.invalid/broken.jpg") + assert button.text() == "查看图片" + assert button.property("loadState") == "loading" + manager.replies[-1].finished.emit() + assert button.property("loadState") == "failed" + assert button.text() == "查看图片" + + request_count = len(manager.requests) + button.load_url("file:///C:/private/image.png") + assert len(manager.requests) == request_count + assert button.property("loadState") == "failed" + assert application.thread() == button.thread() + + +def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load) + timeline = NotesTimeline(editable=True) + opened: list[str] = [] + deleted: list[tuple[int, str, str]] = [] + timeline.open_attachment_requested.connect(opened.append) + timeline.delete_attachment_requested.connect( + lambda note_id, kind, path: deleted.append((note_id, kind, path)) + ) + tongue_url = "https://media.example.invalid/tongue-7001.jpg" + timeline.set_notes( + [ + { + "id": 7001, + "note_date": "2026-08-10", + "content": "舌淡红,苔薄白。", + "tongue_images": [tongue_url], + "report_files": [ + { + "name": "近期血糖趋势.pdf", + "url": "https://media.example.invalid/report-7001.pdf", + } + ], + } + ] + ) + thumb = timeline.findChild(_RemoteImageButton, "DiagnosisTongueThumb") + assert thumb is not None + assert thumb.text() == "舌象\n查看" + assert thumb.property("loadState") == "loading" + assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation) + assert thumb.property("loadState") == "ready" + assert thumb._rendered_pixmap.size() == QSize(64, 64) + thumb.click() + assert opened == [tongue_url] + + remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove") + assert len(remove_buttons) == 2 + tongue_remove = next(button for button in remove_buttons if "舌象" in button.toolTip()) + tongue_remove.click() + assert deleted == [(7001, "tongue_images", tongue_url)] + + stale_generation = thumb._generation + timeline.set_notes( + [ + { + "id": 7002, + "note_date": "2026-08-11", + "content": "复诊舌象。", + "tongue_images": ["https://media.example.invalid/tongue-7002.jpg"], + } + ] + ) + assert not thumb._apply_payload(_png_bytes(96, 192), stale_generation) + current = next( + item + for item in timeline.findChildren(_RemoteImageButton, "DiagnosisTongueThumb") + if item is not thumb + ) + assert current.text() == "舌象\n查看" + assert current.property("loadState") == "loading" + assert not current._apply_payload(b"invalid", current._generation) + assert current.text() == "舌象\n查看" + timeline.close() + application.processEvents() + + +def test_chat_image_is_previewable_bounded_and_owner_generation_safe( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load) + panel = ChatPanel() + opened: list[str] = [] + panel.open_attachment_requested.connect(opened.append) + first_url = "https://media.example.invalid/glucose-chart.jpg" + panel.set_messages( + [ + { + "msg_id": "chat-image-1", + "msg_type": "image", + "image_url": first_url, + "is_from_doctor": False, + "time": "2026-08-10 08:31", + } + ] + ) + image = panel.findChild(_RemoteImageButton, "DiagnosisChatImage") + assert image is not None + assert image.text() == "查看图片" + assert image.property("loadState") == "loading" + assert image._apply_payload(_png_bytes(640, 480), image._generation) + assert image._rendered_pixmap.size() == QSize(240, 180) + assert image.width() <= 240 and image.height() <= 200 + image.click() + assert opened == [first_url] + + stale_generation = image._generation + second_url = "https://media.example.invalid/tall-photo.jpg" + panel.set_messages( + [ + { + "msg_id": "chat-image-2", + "msg_type": "image", + "image_url": second_url, + "is_from_doctor": True, + "from_staff_name": "陈医生", + "time": "2026-08-10 08:42", + } + ] + ) + assert not image._apply_payload(_png_bytes(480, 640), stale_generation) + current = next( + item + for item in panel.findChildren(_RemoteImageButton, "DiagnosisChatImage") + if item is not image + ) + assert current._apply_payload(_png_bytes(120, 480), current._generation) + assert current._rendered_pixmap.size() == QSize(50, 200) + assert current.property("loadState") == "ready" + panel.close() + application.processEvents() + + +def test_daily_todo_has_exact_local_toolbar_and_refresh_signal( + application: QApplication, +) -> None: + panel = DailyRecordPanel() + panel.set_editable(True) + refreshed: list[bool] = [] + panel.refresh_requested.connect(lambda: refreshed.append(True)) + + assert panel.todo_add_button.text() == "+ 新增待办" + assert panel.todo_add_button.parentWidget() is panel.todo_toolbar + assert panel.todo_refresh_button.text() == "刷新" + assert panel.todo_refresh_button.parentWidget() is panel.todo_toolbar + assert panel.todo_toolbar.objectName() == "DiagnosisTodoToolbar" + assert all(button.parentWidget() is panel.todo_toolbar for button in panel.todo_group.buttons()) + panel.todo_refresh_button.click() + assert refreshed == [True] + + panel.set_loading(True) + assert not panel.todo_refresh_button.isEnabled() + panel.set_loading(False) + assert panel.todo_refresh_button.isEnabled() + panel.close() + application.processEvents() diff --git a/app/tests/test_diagnosis_order_video_visual.py b/app/tests/test_diagnosis_order_video_visual.py new file mode 100644 index 000000000..7205b92fc --- /dev/null +++ b/app/tests/test_diagnosis_order_video_visual.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +import os +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton, QVBoxLayout + +from doctor_workstation.ui import diagnosis_media +from doctor_workstation.ui.diagnosis_drawer import RecordTable +from doctor_workstation.ui.diagnosis_media import ( + InlineRecordingPlayer, + RecordingPlaybackCell, + normalize_recording_urls, + preferred_recording_url, + safe_http_url, +) +from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module +from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog, OrderDetailDrawer + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +class _Repository: + def get_prescription_order(self, order_id: int) -> dict[str, Any]: + return {"id": order_id} + + def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> dict[str, Any]: + return {"diagnosis_id": diagnosis_id, "revisit_slot_start_offset": offset} + + def upload_call_recording( + self, + path: str, + diagnosis_id: int, + *, + call_record_id: int | None = None, + ) -> dict[str, Any]: + return { + "path": path, + "diagnosis_id": diagnosis_id, + "call_record_id": call_record_id, + } + + +def _rich_order() -> dict[str, Any]: + return { + "id": 801, + "order_no": "RX-20260811-0801", + "diagnosis_id": 501, + "prescription_id": 601, + "amount": 428.5, + "linked_pay_paid_total": 300, + "refund_amount": 20, + "agency_collect_amount": 128.5, + "fulfillment_status": 5, + "prescription_audit_status": 1, + "payment_slip_audit_status": 1, + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "creator_name": "赵医助", + "creator_account": "assistant.zhao", + "create_time": "2026-08-11 09:26", + "recipient_name": "林晓岚", + "recipient_phone": "18600004218", + "shipping_province": "河南省", + "shipping_city": "洛阳市", + "shipping_district": "洛龙区", + "shipping_address": "开元大道 88 号", + "is_follow_up": 1, + "medication_days": 14, + "service_channel": "线上复诊", + "service_package": ["调理服务", "复诊随访"], + "fee_type": 3, + "tracking_number": "SF164208110801", + "express_company": "sf", + "remark_assistant": "工作日下午送达", + "prescription_audit_remark": "辨证与用量已复核", + "payment_slip_audit_remark": "收款凭证已核验", + "prescription": { + "id": 601, + "sn": "RX601", + "patient_name": "林晓岚", + "gender_desc": "女", + "age": 34, + "phone": "18600004218", + "prescription_date": "2026-08-11", + "doctor_name": "陈医生", + "prescription_type": "饮片", + "clinical_diagnosis": "气阴两虚", + "dose_count": 14, + "dose_unit": "剂", + "usage_instruction": "水煎服", + "amount": 428.5, + "audit_status": 1, + "dosage_amount": 180, + "dosage_unit": "g", + "need_decoction": 1, + "times_per_day": 2, + "usage_days": 14, + "dietary_taboo": ["辛辣", "生冷"], + "void_status": 0, + }, + "linked_pay_orders": [ + { + "id": 9101, + "order_no": "PAY-9101", + "order_type_desc": "药品费用", + "amount": 300, + "status_desc": "已支付", + "creator_name": "赵医助", + "create_time": "2026-08-11 09:32", + } + ], + "unlinked_pay_orders": [], + "logistics_trace": { + "state_text": "运输中", + "carrier_label": "顺丰速运", + "traces": [ + { + "time": "2026-08-11 16:10", + "status": "运输中", + "context": "快件已离开洛阳集散中心", + }, + { + "time": "2026-08-11 13:06", + "status": "已揽收", + "context": "顺丰速运已收取快件", + }, + ], + }, + "logs": [ + { + "id": 1, + "admin_name": "赵医助", + "action": "ship", + "summary": "确认发货并填写顺丰运单", + "create_time": "2026-08-11 13:08", + } + ], + } + + +def test_order_offset_copy_tooltip_preview_and_list_columns_are_exact( + application: QApplication, +) -> None: + dialog = DiagnosisDialog(_Repository(), permissions=["*"]) + label = dialog.findChild(QLabel, "DiagnosisOrderOffsetLabel") + assert label is not None + assert label.text() == "复诊统计起始偏移" + assert label.toolTip() == diagnosis_module._ORDER_OFFSET_HELP + assert dialog.order_offset_help.toolTip() == diagnosis_module._ORDER_OFFSET_HELP + assert dialog.order_offset_save.text() == "保存" + + dialog._editable = True + dialog._can_offset = True + dialog._saved_order_offset = 0 + dialog.order_offset.setValue(2) + assert dialog.order_offset_preview.text() == "第 1 笔实单计为三诊" + assert dialog.order_offset_save.isEnabled() + + dialog._fill_orders( + [ + { + "id": 801, + "order_no": "RX-801", + "global_visit_seq": 4, + "counts_for_revisit_rate": 0, + "amount": 286, + "fulfillment_status": 2, + } + ] + ) + table = dialog._table_registry["orders"][1] + assert table.horizontalHeaderItem(0).text() == "订单编号" + assert table.item(0, 1).text() == "4诊" + assert table.item(0, 2).text() == "否" + assert table.item(0, 3).text() == "¥286.00" + assert table.item(0, 6).text() == "待发货" + dialog.close() + application.processEvents() + + +def test_order_detail_is_eighty_percent_readonly_drawer_with_real_sections( + application: QApplication, +) -> None: + host = DiagnosisDialog(_Repository(), permissions=["*"]) + host.resize(1200, 760) + host.show() + drawer = host._build_order_detail_dialog(_rich_order(), 801) + drawer.show() + application.processEvents() + + assert isinstance(drawer, OrderDetailDrawer) + assert drawer.size() == host.size() + assert abs(drawer.drawer_panel.width() - round(host.width() * 0.8)) <= 1 + assert drawer.drawer_panel.property("readonly") is True + labels = [label.text() for label in drawer.findChildren(QLabel)] + for section in ("金额概览", "处方详情", "收款记录", "履约与收货信息", "物流轨迹", "操作日志"): + assert section in labels + assert "¥428.50" in labels + assert "¥300.00" in labels + assert "确认发货并填写顺丰运单" in labels + assert "快件已离开洛阳集散中心" in labels + linked = drawer.findChild(RecordTable, "DiagnosisOrderLinkedPayments") + assert linked is not None and linked.rowCount() == 1 + + drawer.close() + host.close() + application.processEvents() + + +def test_order_detail_missing_fields_use_explicit_empty_states_without_fake_zero( + application: QApplication, +) -> None: + host = DiagnosisDialog(_Repository(), permissions=["*"]) + host.resize(1000, 680) + drawer = host._build_order_detail_dialog({"id": 809}, 809) + drawer.show() + application.processEvents() + + labels = [label.text() for label in drawer.findChildren(QLabel)] + amount_values = [ + label.text() + for label in drawer.findChildren(QLabel) + if label.property("orderAmountValue") is True + ] + assert amount_values == ["—", "—", "—", "—", "—"] + assert "¥0.00" not in labels + assert "无处方数据(详情接口未返回 prescription)" in labels + assert "详情接口未返回关联收款记录字段" in labels + assert "详情接口未返回未关联收款记录字段" in labels + assert "订单详情未返回快递单号,暂无物流轨迹" in labels + assert "详情接口未返回操作日志数据" in labels + + drawer.close() + host.close() + application.processEvents() + + +def test_order_logs_remain_permission_gated(application: QApplication) -> None: + host = DiagnosisDialog( + _Repository(), + permissions=["tcm.prescriptionOrder/detail"], + ) + drawer = host._build_order_detail_dialog(_rich_order(), 801) + denied = next( + label for label in drawer.findChildren(QLabel) if label.property("permissionDenied") is True + ) + assert denied.text() == "当前账号无操作日志查看权限" + assert "确认发货并填写顺丰运单" not in [label.text() for label in drawer.findChildren(QLabel)] + drawer.close() + host.close() + + +def test_recording_preference_inline_height_alternates_and_safe_external_open( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + urls = [ + "https://bucket.cos.ap-shanghai.myqcloud.com/replay/index.m3u8", + "https://media.example.invalid/replay.mp4", + "https://media.example.invalid/replay.webm", + "file:///C:/private/replay.mp4", + "https://media.example.invalid/replay.mp4", + ] + assert normalize_recording_urls(urls) == urls[:-1] + assert preferred_recording_url(urls) == "https://media.example.invalid/replay.mp4" + assert safe_http_url("file:///C:/private/replay.mp4") is None + + opened: list[str] = [] + monkeypatch.setattr( + diagnosis_media.QDesktopServices, + "openUrl", + lambda url: opened.append(url.toString()) or True, + ) + cell = RecordingPlaybackCell(urls, record_id=48) + cell.show() + application.processEvents() + player = cell.findChild(InlineRecordingPlayer, "DiagnosisInlineRecordingPlayer") + assert player is not None + assert player.target == "https://media.example.invalid/replay.mp4" + assert player.maximumHeight() == 180 + assert player.property("maximumPlaybackHeight") == 180 + assert player._source_attached is False + alternate_buttons = cell.findChildren(QPushButton, "DiagnosisRecordingAlternateLink") + assert [button.text() for button in alternate_buttons] == [ + "COS HLS 1", + "链接 2", + "MP4 3", + ] + assert alternate_buttons[-1].isEnabled() is False + alternate_buttons[0].click() + assert opened == [urls[0]] + + empty = RecordingPlaybackCell([], record_id=49) + empty_state = empty.findChild(QLabel, "DiagnosisEmptyState") + assert empty_state is not None and empty_state.text() == "暂无录制回放" + invalid = RecordingPlaybackCell(["file:///C:/private/replay.mp4"], record_id=50) + invalid_state = invalid.findChild(QLabel, "DiagnosisUnsupportedState") + assert invalid_state is not None and "回放地址无效" in invalid_state.text() + empty.close() + invalid.close() + cell.close() + application.processEvents() + + +def test_video_table_embeds_player_and_preserves_row_bound_upload( + application: QApplication, +) -> None: + dialog = DiagnosisDialog(_Repository(), permissions=["*"]) + dialog._editable = True + dialog._can_video_upload = True + dialog._diagnosis_id = 501 + dialog._tab_generations["video"] = 7 + uploaded: list[int | None] = [] + dialog._upload_call_recording = lambda call_record_id=None: uploaded.append(call_record_id) # type: ignore[method-assign] + dialog._fill_video( + [ + { + "id": 48, + "recording_urls_list": [ + "https://media.example.invalid/replay.mp4", + "https://media.example.invalid/replay-backup.m3u8", + ], + "call_type": 2, + "status": 2, + "recording_status_text": "录制完成", + }, + { + "id": 47, + "recording_urls_list": [], + "call_type": 1, + "status": 3, + "recording_status_text": "暂无录制", + }, + ] + ) + + preview = QFrame() + preview_layout = QVBoxLayout(preview) + page = dialog._tab_pages["video"] + page.setParent(preview) + page.show() + preview_layout.addWidget(page) + preview.resize(1200, 560) + preview.show() + application.processEvents() + + table = dialog._table_registry["video"][1] + playback = table.cellWidget(0, 0) + assert isinstance(playback, RecordingPlaybackCell) + assert playback.property("callRecordId") == 48 + player = playback.findChild(InlineRecordingPlayer) + assert player is not None + surface = player.findChild(QFrame, "DiagnosisInlineRecordingSurface") + external = player.findChild(QPushButton, "DiagnosisInlineRecordingExternal") + fallback = player.findChild(QPushButton, "DiagnosisInlineRecordingFallback") + assert surface is not None and external is not None and fallback is not None + + assert table.rowHeight(0) >= playback.required_table_row_height() + assert playback.height() >= playback.minimumSizeHint().height() + assert 158 <= player.height() <= 180 + assert surface.height() >= 122 + assert player.position.width() >= 50 + assert player.position.height() >= 12 + assert player.time_label.height() >= 15 + for control in (player.play_button, external, fallback): + assert control.isVisibleTo(player) + assert control.height() >= 24 + for control in (player.play_button, player.position, player.time_label, external, fallback): + assert player.rect().contains(control.geometry()) + + # Guard the actual rendered pixels: the previous regression produced only + # a 16 px dark strip despite the class-level maximumHeight declaration. + playback_image = playback.grab().toImage() + dark_rows = [] + for y in range(playback_image.height()): + dark_pixels = sum( + 1 + for x in range(playback_image.width()) + if max( + playback_image.pixelColor(x, y).red(), + playback_image.pixelColor(x, y).green(), + playback_image.pixelColor(x, y).blue(), + ) + <= 55 + ) + if dark_pixels >= round(playback_image.width() * 0.65): + dark_rows.append(y) + assert dark_rows and dark_rows[-1] - dark_rows[0] + 1 >= 120 + + assert table.item(1, 0).text() == "暂无录制回放" + assert dialog._recording_players == [] + upload_host = table.cellWidget(0, 8) + assert upload_host is not None + upload = upload_host.findChild(QPushButton, "DiagnosisVideoRowUpload") + assert upload is not None + assert upload.property("callRecordId") == 48 + assert table.item(0, 8).text() == "" + assert abs(upload_host.rect().center().y() - upload.geometry().center().y()) <= 1 + upload.click() + assert uploaded == [48] + preview.close() + dialog.close() + application.processEvents() + + +def test_inline_player_rejects_stale_owner_generation(application: QApplication) -> None: + class _Owner: + _tab_generations = {"video": 4} + + owner = _Owner() + player = InlineRecordingPlayer( + "https://media.example.invalid/replay.mp4", + render_owner=owner, + owner_generation=4, + ) + owner._tab_generations["video"] = 5 + if player.player is not None: + assert player._attach_source() is False + assert player.play_button.isEnabled() is False + assert "已刷新" in player.placeholder.text() + player.close() + application.processEvents() + + +def test_drawer_and_inline_player_render_non_empty_images( + application: QApplication, + tmp_path: Any, +) -> None: + host = DiagnosisDialog(_Repository(), permissions=["*"]) + host.resize(1100, 720) + host.show() + drawer = host._build_order_detail_dialog(_rich_order(), 801) + drawer.show() + application.processEvents() + order_image = drawer.grab().toImage() + order_path = tmp_path / "order.png" + assert order_image.width() == 1100 and order_image.height() == 720 + assert order_image.save(str(order_path), "PNG") + assert order_path.stat().st_size > 10_000 + + cell = RecordingPlaybackCell( + [ + "https://media.example.invalid/replay.mp4", + "https://media.example.invalid/replay.m3u8", + ], + record_id=48, + ) + cell.resize(520, 235) + cell.show() + application.processEvents() + video_image = cell.grab().toImage() + video_path = tmp_path / "video.png" + assert video_image.save(str(video_path), "PNG") + assert video_path.stat().st_size > 2_000 + + cell.close() + drawer.close() + host.close() + application.processEvents() diff --git a/app/tests/test_one_click_entrypoints.py b/app/tests/test_one_click_entrypoints.py index 8f3c84df4..22822b7d5 100644 --- a/app/tests/test_one_click_entrypoints.py +++ b/app/tests/test_one_click_entrypoints.py @@ -24,7 +24,11 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None: assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv") assert "& $Uv sync --frozen" in run_script + assert '"UV_CACHE_DIR"' in run_script + assert "$ProjectUvCache" in run_script assert "& $Uv sync --frozen --extra build" in package_script + assert '"UV_CACHE_DIR"' in package_script + assert "$ProjectUvCache" 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 diff --git a/app/tests/test_packaging_media_gate.py b/app/tests/test_packaging_media_gate.py new file mode 100644 index 000000000..6056ff952 --- /dev/null +++ b/app/tests/test_packaging_media_gate.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +MEDIA_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py" + + +def read(relative_path: str) -> str: + return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8") + + +def test_spec_explicitly_collects_qt_multimedia_and_installs_frozen_gate() -> None: + spec = read("packaging/doctor_workstation.spec") + + assert '"PySide6.QtMultimedia"' in spec + assert '"PySide6.QtMultimediaWidgets"' in spec + assert "qt_multimedia_hiddenimports" in spec + assert "runtime_hooks=[str(MEDIA_SMOKE_HOOK)]" in spec + assert "Qt6Multimedia*.dll/.dylib/framework" in spec + assert "plugins/multimedia" in spec + + +def test_media_runtime_hook_is_inert_without_gate_argument() -> None: + result = subprocess.run( + [sys.executable, "-I", "-S", str(MEDIA_HOOK)], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_media_runtime_hook_fails_when_frozen_components_cannot_import() -> None: + result = subprocess.run( + [sys.executable, "-I", "-S", str(MEDIA_HOOK), "--media-smoke-test"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert result.returncode == 70 + assert "Qt multimedia smoke gate failed" in result.stderr + + +def test_media_runtime_hook_constructs_player_and_video_widget_offscreen( + tmp_path: Path, +) -> None: + environment = os.environ.copy() + environment.update( + { + "QT_QPA_PLATFORM": "offscreen", + "QT_LOGGING_RULES": "qt.multimedia.*=false", + "XDG_CONFIG_HOME": str(tmp_path / "config"), + "XDG_CACHE_HOME": str(tmp_path / "cache"), + } + ) + result = subprocess.run( + [sys.executable, str(MEDIA_HOOK), "--media-smoke-test"], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "Frozen Qt multimedia smoke gate passed" in result.stdout + + +def test_windows_build_checks_files_and_runs_media_gate_before_release_archive() -> None: + build = read("scripts/build_windows.ps1") + package = read("scripts/package_windows.ps1") + + for required_name in ( + "QtMultimedia.pyd", + "QtMultimediaWidgets.pyd", + "Qt6Multimedia.dll", + "Qt6MultimediaWidgets.dll", + "ffmpegmediaplugin.dll", + "windowsmediaplugin.dll", + ): + assert required_name in build + assert "Assert-FrozenMultimedia -Artifact $Artifact" in build + assert build.index('Argument "--media-smoke-test"') < build.index('Write-Host "Build complete') + assert "packaging\\runtime_media_smoke.py" in package + assert package.index("& $BuildScript") < package.index("Compress-Archive") + + +def test_macos_build_checks_modules_frameworks_plugins_and_runs_gate_before_zip() -> None: + build = read("scripts/build_macos.sh") + package = read("scripts/package_macos.sh") + entry_check = read("scripts/check_macos_entrypoints.sh") + + for contract in ( + "QtMultimedia*.so", + "QtMultimediaWidgets*.so", + "QtMultimedia.framework", + "QtMultimediaWidgets.framework", + "*Qt6Multimedia*.dylib", + "*/plugins/multimedia", + "*mediaplugin*.dylib", + "--media-smoke-test", + ): + assert contract in build + assert build.index('"--media-smoke-test"') < build.index('echo "Build complete') + assert package.index('build_macos.sh"') < package.index("/usr/bin/ditto -c -k") + assert 'check_macos_entrypoints.sh"' in package + assert 'index_mode" == "100755"' in entry_check diff --git a/app/tests/test_prescription_editor_drawer_visual.py b/app/tests/test_prescription_editor_drawer_visual.py new file mode 100644 index 000000000..c67c0bd0f --- /dev/null +++ b/app/tests/test_prescription_editor_drawer_visual.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QPoint, Qt +from PySide6.QtWidgets import QApplication, QTabWidget, QWidget + +from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +def _repository() -> SimpleNamespace: + return SimpleNamespace(list_medicines=lambda **_kwargs: {"lists": [], "count": 0}) + + +def _seed() -> dict[str, object]: + return { + "id": 502, + "diagnosis_id": 301, + "appointment_id": 401, + "patient_name": "林晓岚", + "phone": "13800138000", + "gender": 0, + "age": 34, + "visit_no": "1K00000401", + "tongue": "舌淡红、苔薄白", + "pulse": "面色少华", + "pulse_condition": "脉细", + "clinical_diagnosis": "气阴两虚", + "doctor_name": "陈医生", + "herbs": [ + {"medicine_id": 11, "name": "黄芪", "dosage": 15, "formula_type": "主方"}, + {"medicine_id": 12, "name": "党参", "dosage": 12, "formula_type": "主方"}, + {"medicine_id": 13, "name": "白术", "dosage": 10, "formula_type": "主方"}, + {"medicine_id": 14, "name": "茯苓", "dosage": 12, "formula_type": "主方"}, + {"medicine_id": 15, "name": "酸枣仁", "dosage": 9, "formula_type": "辅方"}, + ], + } + + +def _show(editor: PrescriptionEditorDialog, application: QApplication) -> None: + editor.show() + for _ in range(5): + application.processEvents() + + +def test_editor_is_a_fixed_header_body_footer_right_drawer( + application: QApplication, +) -> None: + host = QWidget() + host.resize(1440, 900) + host.show() + editor = PrescriptionEditorDialog( + _repository(), + _seed(), + current_user=SimpleNamespace(id=7, name="陈医生"), + parent=host, + ) + _show(editor, application) + + origin = host.mapToGlobal(QPoint(0, 0)) + assert editor.windowFlags() & Qt.WindowType.FramelessWindowHint + assert editor.width() == 1200 + assert editor.height() == host.height() + assert editor.x() + editor.width() == origin.x() + host.width() + assert editor.y() == origin.y() + assert not editor.findChildren(QTabWidget) + assert editor.tabs.count() == 4 + assert editor.tabs.tabText(2) == "剂型与用法" + assert editor.body_scroll.verticalScrollBar().maximum() > 0 + assert editor.body_scroll.horizontalScrollBar().maximum() == 0 + + section_tops = [section.geometry().top() for section in editor.section_widgets] + assert section_tops == sorted(section_tops) + assert [section.parentWidget() for section in editor.section_widgets] == [ + editor.body_content + ] * 5 + + header_y = editor.header.mapToGlobal(QPoint(0, 0)).y() + footer_y = editor.footer.mapToGlobal(QPoint(0, 0)).y() + editor.body_scroll.verticalScrollBar().setValue( + editor.body_scroll.verticalScrollBar().maximum() + ) + application.processEvents() + assert editor.header.mapToGlobal(QPoint(0, 0)).y() == header_y + assert editor.footer.mapToGlobal(QPoint(0, 0)).y() == footer_y + + editor.close() + host.close() + application.processEvents() + + +def test_drawer_uses_full_width_on_a_narrow_host(application: QApplication) -> None: + host = QWidget() + host.resize(760, 720) + host.show() + editor = PrescriptionEditorDialog(_repository(), _seed(), parent=host) + _show(editor, application) + + origin = host.mapToGlobal(QPoint(0, 0)) + assert editor.width() == host.width() + assert editor.x() == origin.x() + assert editor.body_scroll.horizontalScrollBar().maximum() == 0 + assert editor.herbs._grid_columns == 3 + + editor.close() + host.close() + application.processEvents() + + +def test_duplicate_guard_and_admin_dto_survive_the_visual_restructure( + application: QApplication, +) -> None: + source = _seed() + source["herbs"] = [ + {"medicine_id": 11, "name": "黄芪", "dosage": 15, "formula_type": "主方"}, + {"medicine_id": 11, "name": " 黄芪 ", "dosage": 9, "formula_type": "辅方"}, + ] + editor = PrescriptionEditorDialog( + _repository(), + source, + mode="edit", + current_user=SimpleNamespace(id=7, name="陈医生"), + ) + editor.signature._has_strokes = True + payload = editor.payload() + + assert payload["id"] == 502 + assert payload["diagnosis_id"] == 301 + assert payload["herbs"][0]["formula_type"] == "主方" + assert payload["herbs"][1]["formula_type"] == "辅方" + assert all(bool(row.property("duplicate")) for row in editor.herbs.rows) + + editor.accept() + assert editor.result() == 0 + assert editor.tabs.currentIndex() == 1 + assert "药材不可重复:黄芪" in editor.validation.label.text() + + editor.close() + application.processEvents() + + +def test_locked_template_rows_keep_the_existing_mutation_contract( + application: QApplication, +) -> None: + source = _seed() + source["herbs"] = [ + { + "medicine_id": 11, + "name": "黄芪", + "dosage": 15, + "formula_type": "主方", + "locked": True, + } + ] + editor = PrescriptionEditorDialog(_repository(), source) + + assert editor.herbs.locked + assert not editor.add_main_button.isEnabled() + assert not editor.add_aux_button.isEnabled() + assert editor.import_library_button.isEnabled() + assert editor.paste_button.isEnabled() + assert not editor.herbs.rows[0].medicine.isEnabled() + assert not editor.herbs.rows[0].dosage.isEnabled() + assert editor.herbs.rows[0].remove_button.isHidden() + assert editor.payload()["herbs"][0]["locked"] is True + + editor.close() + application.processEvents() diff --git a/app/tests/test_shell_contract.py b/app/tests/test_shell_contract.py new file mode 100644 index 000000000..0168eca8a --- /dev/null +++ b/app/tests/test_shell_contract.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import os +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QWidget + +from doctor_workstation.ui import shell as shell_module +from doctor_workstation.ui.shell import NavigationItem, ShellWindow + + +class _ShellPageDouble(QWidget): + def __init__( + self, + _repository: Any, + *, + permissions: Any, + current_user: Any, + ) -> None: + super().__init__() + self.permissions = permissions + self.current_user = current_user + self.refresh_count = 0 + + def refresh(self) -> None: + self.refresh_count += 1 + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def shell_window( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> ShellWindow: + navigation = [ + NavigationItem(key, title, glyph, _ShellPageDouble, (permission,)) + for key, title, glyph, permission in ( + ("reception", "接诊台", "◎", "doctor.appointment/lists"), + ("prescription_library", "我的处方库", "方", "tcm.prescriptionLibrary/lists"), + ("prescriptions", "已开处方", "笺", "tcm.prescription/lists"), + ("patients", "我的患者", "患", "firstvisit.myPatient/lists"), + ("consultations", "问诊列表", "询", "tcm.diagnosis/lists"), + ) + ] + monkeypatch.setattr( + shell_module, + "_resolve_navigation", + lambda _menu, _permissions, *, demo_mode: [(item, item.title) for item in navigation], + ) + window = ShellWindow( + object(), + { + "user": {"name": "陈医生", "department_name": "中医门诊", "role_ids": [1]}, + "demo_mode": True, + }, + permissions={item.permissions[0] for item in navigation}, + ) + window.show() + application.processEvents() + yield window + window.close() + application.processEvents() + + +def test_shell_matches_admin_geometry_at_both_acceptance_sizes( + application: QApplication, + shell_window: ShellWindow, +) -> None: + for width, height in ((1024, 640), (1440, 900)): + shell_window.resize(width, height) + application.processEvents() + + assert shell_window.sidebar.width() == 183 + assert shell_window.topbar.height() == 50 + assert shell_window.tabs_host.height() == 40 + assert shell_window.workspace.width() == width - 183 + assert shell_window.stack.width() == width - 183 + assert shell_window.stack.height() == height - 90 + assert shell_window.stack.geometry().right() < shell_window.workspace.width() + assert shell_window.stack.geometry().bottom() < shell_window.workspace.height() + + image = shell_window.grab().toImage() + assert image.pixelColor(100, 100).name().lower() == "#1d2124" + assert image.pixelColor(200, 10).name().lower() == "#ffffff" + assert image.pixelColor(190, 70).name().lower() == "#ffffff" + + +def test_every_visible_page_navigates_and_visited_tabs_track_active_page( + shell_window: ShellWindow, +) -> None: + changed: list[str] = [] + shell_window.page_changed.connect(changed.append) + + for key in ( + "reception", + "prescription_library", + "prescriptions", + "patients", + "consultations", + ): + assert shell_window.navigate(key) + assert shell_window.stack.currentWidget() is shell_window.pages[key] + assert shell_window.nav_buttons[key].isChecked() + assert shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == key + + assert shell_window.visited_tab_keys() == ( + "reception", + "prescription_library", + "prescriptions", + "patients", + "consultations", + ) + assert changed[-5:] == [ + "reception", + "prescription_library", + "prescriptions", + "patients", + "consultations", + ] + + +def test_non_fixed_tabs_close_and_active_close_renavigates( + shell_window: ShellWindow, +) -> None: + for key in ("patients", "consultations"): + assert shell_window.navigate(key) + + assert not shell_window.close_tab("reception") + assert shell_window.close_tab("patients") + assert "patients" not in shell_window.visited_tab_keys() + assert shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == "consultations" + + assert shell_window.close_current_tab() + assert shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == "reception" + assert shell_window.stack.currentWidget() is shell_window.pages["reception"] + assert shell_window.nav_buttons["reception"].isChecked() + + shell_window.close_all_tabs() + assert shell_window.visited_tab_keys() == ("reception",) + assert shell_window.stack.currentWidget() is shell_window.pages["reception"] + + +def test_sidebar_collapse_preserves_active_navigation(shell_window: ShellWindow) -> None: + assert shell_window.navigate("consultations") + + shell_window.toggle_sidebar() + assert shell_window.sidebar.width() == 64 + assert shell_window.nav_buttons["consultations"].text() == "询" + assert shell_window.nav_buttons["consultations"].isChecked() + + shell_window.toggle_sidebar() + assert shell_window.sidebar.width() == 183 + assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表") + assert shell_window.nav_buttons["consultations"].isChecked() + + +def test_shell_directional_controls_have_no_unicode_arrow_text( + shell_window: ShellWindow, +) -> None: + assert shell_window.fold_button.text() == "" + assert shell_window.refresh_button.text() == "" + assert shell_window.fullscreen_button.text() == "" + assert shell_window.tabs_menu_button.text() == "" + assert all( + arrow not in button.text() + for button in shell_window.findChildren(QWidget) + if hasattr(button, "text") and callable(button.text) + for arrow in ("←", "→", "↑", "↓", "▲", "▼", "▴", "▾") + )