This commit is contained in:
Your Name
2026-08-11 09:12:51 +08:00
parent c3ceb0dd0f
commit cfe4c82c90
111 changed files with 26110 additions and 826 deletions
+4
View File
@@ -1,3 +1,7 @@
# 此文件是源码开发/企业部署模板,不会被复制进发布 ZIP/.app。
# 生产配置应由受控启动器、设备管理或进程环境注入;禁止把密码、token、
# UserSig、TRTC SecretKey 或其他长期凭据写进本文件或发布包。
# 后端根地址。程序会自动追加 /adminapi;也可直接填写以 /adminapi 结尾的地址。
DOCTOR_API_BASE_URL=https://api.example.com
+2
View File
@@ -6,6 +6,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
.pytest-tmp-*/
artifacts/pytest_*/
.ruff_cache/
.coverage
htmlcov/
+9 -3
View File
@@ -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) 的准入清单完成摄像头、麦克风、设备插拔、休眠恢复和弱网实测。
## 工程结构
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+9 -1
View File
@@ -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.
+18 -2
View File
@@ -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,
+85
View File
@@ -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)
@@ -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 pxsmall 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 pxHeader 内边距 `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.55label 最小宽 58 pxlabel 为 `#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 queryHero 仅通过 `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 pxlabel 为 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 | 在用药物,textarea3 行,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 | 其他补充,textarea3 行 |
| **既往史** | 24 | 既往史(check buttons |
| **其他病史** | 8 + 8 + 8 | 外伤;手术;过敏 |
| | 12 + 12 | 家族;妊娠 |
| **诊断信息** | 24 | 病史补充,textarea2 行 |
具体模板行:顶部/身份 `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 变为 100vwheader `12px 14px`body `10px 12px`
- Footer 改为纵向、按钮全宽,gap 10 px,内边距 `12px 14px + safe-area`,按钮最小 44 px。
- 标题 17 pxTab 高/行高 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. 备注(textarea2 行)。
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 pxhover 上移 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 pxhover 转 primary。
- 新增备注 Dialog 宽 480 pxtextarea 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 pxtoolbar 换行、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、020 数字框(宽 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. 数量统计,96Tag。
4. 金额,120,右对齐红字。
5. 医生,110。
6. 医助,110。
7. 履约状态,110Tag。
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 行 skeletonbody 水平内边距 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 列 descriptionslabel 宽 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 pxfooter 按钮 40/44 px |
| radio/checkbox button groups | `QButtonGroup` + 可换行 FlowLayout | 12 px 字号,组间 1216 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 可为桌面小窗增加降列和全宽适配,但不得改变字段顺序、权限门槛、状态语义或主操作位置。
@@ -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。
+155
View File
@@ -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 = 0P1 = 0P2 = 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 开始、宽 115280%);`diagnosis_state_order_detail_640x540.png``diagnosis_state_order_detail_drawer_1024x640.png` 的实际像素均为 1024x640panel 从 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 pxcontrols 全部可见。详见下节。 |
| 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 0243 px;可视 cell rect242 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` 构建 158180 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 逐张判定
### 预约 Drawer8/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 完整。 |
### 诊单列表、壳层与主 Drawer23/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 已关闭。
- 当前最终门禁:**PASS57 PASS / 0 PARTIAL,共检查 57 张 PNGP0=0P1=0P2=0)。**
+373
View File
@@ -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/500r6transition `.2s` | 常规 `#666` + `#F8F8F8`hover `#F0F2F5`active 白字 + 主色 | `index.vue:2227-2250` |
| chip 计数 | 与标题相隔 `4px`normal styleopacity `.8` | 跟随父 chip | `index.vue:23-24,2237-2241` |
| 待预约 | 普通 chip 外再有左 margin `8px` | info / info-light-9active info 实色 | `index.vue:26-32,2253-2265` |
| 已完成 | 左 margin `8px` | success / success-light-9active success 实色 | `index.vue:33-39,2268-2280` |
| 待分配医助 | 权限控制;外容器可换行、gap `8px`、左 margin `8px` | warning / warning-light-9active 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`14pxr4 | 白字/主色底 | `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-9active 权重 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. 诊断类型 selectsmall`24px` 高);
2. 证型 selectsmall);
3. 医助 selectsmall,可搜索);
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 }`,此处没有清零。因此相邻按钮的实际起点间空白通常是 **20px8+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`
#### 挂号块
- 每个预约 badge11px/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`,高度 autohover 使用各语义色的 light-3。证据:Element Plus `button.scss:223-248``element.scss:113-127`
- `.action-cell` 设 flex-wrap + `gap:4px`;但相邻 `.el-button + .el-button` 仍有 `12px` 左 margin,因此连续按钮间通常是 **16px4+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 默认 popper12px、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`(最小宽),14pxr2,项组间 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 blockmin-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`
- 蒙层绝对覆盖 tablez-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、上下 813pxactive primary-light-9 |
| 更多筛选 | 可显隐 `QWidget` + FlowLayout | 无动画;top dashed bordergap10small 控件 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 delegateID/患者、确认、复诊、开方、未服务天数分别用专用 delegate 绘文字和 tag,避免嵌套控件带来的滚动性能问题。
- 挂号列用一个复合 delegater6 的状态背景、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 缩放 + 默认亮色主题 + 固定权限/样本数据` 下补一轮运行态测量;本文件已经给出源码能够保证的全部静态约束与明确边界。
+153
View File
@@ -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。
- 约束:只读业务终验;未安装依赖、未打包、未访问线上接口。除更新本报告外,未修改业务源码或测试。
## 最终结论
第三次终验后的剩余项为:
- **P00,明确无 P0。**
- **P11 项 OPEN。** 诊单列表“创建订单”已调用真实 `order.order/create`,但没有消费返回的 `order_no` 并调用现成的 `generate_order_qrcode`,admin 的付款二维码闭环仍中断。
- **P22 项 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 / 日志 / 创建订单 | **OPENQR/日志/创建主体 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,不能作为桌面端单独修复项。
+113
View File
@@ -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 / EXACT4 项仍 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(组件)** | 当前已使用自绘二态 DiagnosisSwitch44×24 轨道、圆形滑块、0/1 文本合同:src/doctor_workstation/ui/diagnosis_drawer.py:643-675;表单 show_card 分支:src/doctor_workstation/ui/dialogs/diagnosis.py:924-925Vue 基准: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-wrapD:/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-1031handler 在 :1672-1692diagnosis_full_menu_1280x800.png 已出现指派、取消指派、视频二维码、确认二维码、取消挂号、挂号日志、创建订单、删除。仍不同型:Python 只创建纯文字 QActionsrc/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 横向滚动条可见并驱动选中 Tabsrc/doctor_workstation/ui/diagnosis_drawer.py:194-242、:749-792Vue 基准:D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1798-1828。所有 1024 详情状态图均无盒状左右箭头,蓝色细滚动条连续可见。 |
| 5 | footer 缺上投影 | **CLOSED** | **EXACT** | footer 使用 blur 18、offset 0/-4、半透明深色 QGraphicsDropShadowEffectsrc/doctor_workstation/ui/dialogs/diagnosis.py:784-803Vue 为 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** | 摘要值禁止换行并使用 MinimumExpandingsrc/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#DiagnosisAttachmentChipdiagnosis_drawer.py:486-500),运行时却创建 QPushButton:1897-1900、:1932-1937),所以截图中的“舌象/查看”和报告附件退化为普通白按钮,而非 Vue 的真实缩略图、文件 chip 与叠加 CircleCloseD:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:42-100)。 |
| 9 | 处方、订单、视频、聊天仍为简化实现 | **OPEN** | **PARTIAL / MISSING** | 处方已接完整 PrescriptionEditorDialog 和真实 create_prescriptionsrc/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 是表格内 RecordingPlaybackBlockD:/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** | 侧栏 183src/doctor_workstation/ui/shell.py:537-540topbar 50:599-603multiple-tabs 40:683-687。diagnosis_shell_1024x640.png 与 diagnosis_shell_1440x900.png 均可直接量出 183/50/40 三段,比例一致。 |
| 12 | Shell 颜色偏离且箭头缺字 | **CLOSED** | **EXACT(本项范围)** | 侧栏为 #1D2124src/doctor_workstation/ui/shell.py:541-569;方向、刷新、全屏、下拉、关闭图形均由 QPainter 自绘,不依赖字体 glyph:257-395topbar/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/focussrc/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 primarytoolbar 语义层级不足。 |
| 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。
## 最终判定
- **P00 OPEN。**
- **P14 OPEN / PARTIAL**Daily 动作行与 scoped 按钮语义;完整菜单图标/删除色/文案;Notes 附件视觉;处方/订单/视频/聊天视觉与证据覆盖。
- **MISSING 视觉证据**:当前 diagnosis_visual 没有滚动到 switch 的截图,也没有完整处方编辑器和订单详情抽屉截图;源码能力存在,但不能用测试代替这些像素证据。
- **乱码 / 缺字:0。** 43 张图中的中文、圆弧 loading、箭头、保存勾叉均完整。
- **最终:PARTIAL。** 只有消除上述 4 组可见差异并补齐处方编辑器、订单详情与 switch 的当前截图后,才可给视觉同型 PASS。
+547
View File
@@ -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 pxworkspace 的 `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`/`36`/`>=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 scrollbaractive 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`,每项 4682 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 pxhover 上移 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 / 更多菜单 | 助理溢出、未服务、视频 tooltipbottom-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栅格 | 无TrackingNoteactive底条3px;表单字段顺序与§4.5一致 |
| DET-04 | 两档 | 独立 readonly | 无Tabs/模态;返回+Hero+摘要+病例+权限卡片 | 1024可用;1440病例保持4/3列;未服务三色正确 |
| DET-05 | 两档 | 详情 loading/error/empty | overlayHero下错误Empty;各表局部空态 | 不残留错误seederror不导致footer/tab位移 |
| DET-06 | 1440×900 | 备注/订单/指派/挂号 | 时间轴图片/文件;订单工具栏/详情;完整列/Tag | 表头顺序、状态色、固定动作列与管理端一致 |
| APT-01 | 1024×640 | 初始 loading / 未选医生 | 右侧约614px Drawer、body overlay、时间区Empty、固定footer | label100pxfooter可见;渠道补充默认正确隐藏 |
| APT-02 | 1024×640 | 无排班 | 医生radio、时间区“暂无排班”Empty | 不用ComboBox placeholder冒充空态 |
| APT-03 | 1024×640 | 可约/不可约/hover/selected | 日期130×40slot最小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 均应原样保留,并在实现后另走现有业务回归测试。
@@ -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 真实新增与附件 | **CLOSEDAVAILABLE** | `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 生命周期合同 | 每次打开重绑真实 Shelloverlay 同步 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()` 重绑真实 owneroverlay 采用 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 测试单独代替生产合同证据。
## 发布门槛判断
- **业务/后端合同门槛:通过。**
- **OPENP0 0 / P1 0 / P2 0。**
- **AVAILABLE:题定范围内全部对齐。**
- **唯一保留项:旧医生/跟踪笔记正文覆盖与整条删除需先扩展后端合同;这是明确的 server limitation,不阻断“当前 AVAILABLE 功能”发布。**
@@ -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 硬裁切**。
- **P00 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 开始,宽 614x=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 进入 tabsy89 为 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 / readonly6
| 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` | PARTIALP2 | 趋势、待办 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` | PARTIALP1 | 动作、chip 和单附件删除存在;舌象仍为文字按钮而非真实缩略图。 |
| `diagnosis_state_prescription_editor_920x780.png` | PARTIALP1 | 完整可用的 4-tab 编辑器已入镜;但不是基准 1200 px 连续 Drawer。 |
| `diagnosis_state_order_offset_1024x640.png` | PARTIAL(P2) | 数值、保存、订单表与分页成立;偏移文案/tooltip/解释层级未精确。 |
| `diagnosis_state_order_detail_640x540.png` | PARTIALP1 | 详情 modal 可用;缺基准 80% Drawer 的金额/支付/履约/物流/日志结构。 |
| `diagnosis_state_video_replay_1024x640.png` | PARTIALP1) | 主回放 + 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` | PARTIALP1 | 完整 info、归档、同步/重载及左右气泡成立;图片消息退化为文字链接。 |
### 预约 Drawer8
| 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、文案与禁用确认完整。 |
### Shell2
| 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 可在同一轮顺手精确。
- 本轮最终:**PARTIAL40 PASS / 9 PARTIAL,共检查 49 张 PNG**。
+329
View File
@@ -0,0 +1,329 @@
# DoctorWorkstation 发布打包就绪审计
审计日期:2026-08-11Asia/Shanghai
审计主机:Windows 10/11 工作区 `D:\web\zyt\app`
审计边界:只检查入口、脚本、PyInstaller spec、冻结依赖、伴随页、文档和既有产物;未执行完整 PyInstaller,未改业务/打包源码。
## 结论
**当前状态:不可直接发布既有 `dist`,也不应立即从当前 Git 状态制作正式发布包。**
构建流水线的主体是完整的:Windows/macOS 均有根目录中英文入口,能从锁文件准备 Python/Node 依赖,能构建 TRTC Web companionPyInstaller 使用 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-1macOS 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-1QtMultimedia 依赖可用,但冻结收集没有独立门禁
通过项:
- `pyproject.toml` 依赖 `PySide6>=6.8.2,<7``uv.lock` 当前锁定 PySide6/PySide6 Addons/Essentials 6.11.1Addons 提供 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 提供完整性,不提供发布者身份;对外分发前应在压缩前签名并验证最终可执行文件。
macOSspec 接受可选的 `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-4macOS 成品尚无原生验证证据
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 用相邻的 `<zip>.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-<version>.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}-<version>.zip`
- 校验:同路径 `<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: <组织名称> (<TEAMID>)'
./一键打包.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 均在最终归档前完成。
@@ -0,0 +1,230 @@
# 发布打包最终只读复审
日期:2026-08-11Asia/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/staplead-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``<zip>.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 specPASS
`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/packagePASS(静态与轻量合同)
`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/packagePASS(静态与轻量合同)
`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 modePASS
以下 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-<version>.zip`
- Windows checksum`dist/SHA256SUMS.txt`
- macOS bundle`dist/DoctorWorkstation.app`
- macOS ZIP`dist/DoctorWorkstation-macOS-{arm64|x64}-<version>.zip`
- macOS checksum`<zip>.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: <组织名称> (<TEAMID>)'
./一键打包.command
```
随后验证 `.app` 架构、媒体 framework/plugin、两个 smoke gate、签名、公证/staple、最终 ZIP 哈希和真实设备/TRTC 流程。
@@ -0,0 +1,76 @@
# 发布打包门禁修复报告
日期:2026-08-11Asia/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 规范审查;这不影响上述脚本合同结果。
+99 -53
View File
@@ -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"
+56 -8
View File
@@ -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"
}
+24 -9
View File
@@ -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
+1
View File
@@ -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=""
+18
View File
@@ -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) {
@@ -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()
@@ -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)
@@ -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)
@@ -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())
@@ -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)
+465
View File
@@ -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)
@@ -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)
+16
View File
@@ -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"
@@ -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(),
@@ -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,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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"]
File diff suppressed because it is too large Load Diff
@@ -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",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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)
+550 -77
View File
@@ -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)

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